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]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
// "editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "always"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": "explicit"
|
||||||
@@ -23,5 +26,5 @@
|
|||||||
"prettier.printWidth": 150,
|
"prettier.printWidth": 150,
|
||||||
"prettier.singleAttributePerLine": true,
|
"prettier.singleAttributePerLine": true,
|
||||||
"prettier.htmlWhitespaceSensitivity": "css",
|
"prettier.htmlWhitespaceSensitivity": "css",
|
||||||
"prettier.tabWidth": 4
|
"prettier.tabWidth": 2
|
||||||
}
|
}
|
||||||
|
|||||||
62
.vscode/tasks.json
vendored
62
.vscode/tasks.json
vendored
@@ -1,41 +1,39 @@
|
|||||||
{
|
{
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"type": "npm",
|
"label": "Sort Template Properties",
|
||||||
"script": "start",
|
"type": "shell",
|
||||||
"isBackground": true,
|
"command": "node",
|
||||||
"problemMatcher": {
|
"args": [
|
||||||
"owner": "typescript",
|
"tools/sort-template-properties.js",
|
||||||
"pattern": "$tsc",
|
"${file}"
|
||||||
"background": {
|
],
|
||||||
"activeOnStart": true,
|
"presentation": {
|
||||||
"beginsPattern": {
|
"reveal": "silent",
|
||||||
"regexp": "Changes detected"
|
"panel": "shared"
|
||||||
},
|
},
|
||||||
"endsPattern": {
|
"runOptions": {
|
||||||
"regexp": "bundle generation (complete|failed)"
|
"runOn": "folderOpen"
|
||||||
}
|
},
|
||||||
}
|
"problemMatcher": []
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "npm",
|
"label": "Format HTML on Save",
|
||||||
"script": "test",
|
"type": "shell",
|
||||||
"isBackground": true,
|
"command": "npx",
|
||||||
"problemMatcher": {
|
"args": [
|
||||||
"owner": "typescript",
|
"prettier",
|
||||||
"pattern": "$tsc",
|
"--write",
|
||||||
"background": {
|
"${file}"
|
||||||
"activeOnStart": true,
|
],
|
||||||
"beginsPattern": {
|
"presentation": {
|
||||||
"regexp": "Changes detected"
|
"reveal": "silent",
|
||||||
},
|
"panel": "shared"
|
||||||
"endsPattern": {
|
},
|
||||||
"regexp": "bundle generation (complete|failed)"
|
"problemMatcher": [],
|
||||||
}
|
"runOptions": {
|
||||||
}
|
"runOn": "folderOpen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
14
angular.json
14
angular.json
@@ -3,7 +3,10 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm",
|
"packageManager": "npm",
|
||||||
"analytics": false
|
"analytics": false,
|
||||||
|
"schematicCollections": [
|
||||||
|
"angular-eslint"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -100,6 +103,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
239
eslint.config.js
239
eslint.config.js
@@ -1,39 +1,57 @@
|
|||||||
|
// ESLint Flat Config for Weaver
|
||||||
const eslint = require('@eslint/js');
|
const eslint = require('@eslint/js');
|
||||||
const tseslint = require('typescript-eslint');
|
const tseslint = require('typescript-eslint');
|
||||||
const angular = require('angular-eslint');
|
const angular = require('angular-eslint');
|
||||||
const stylisticTs = require('@stylistic/eslint-plugin-ts');
|
const stylisticTs = require('@stylistic/eslint-plugin-ts');
|
||||||
const stylisticJs = require('@stylistic/eslint-plugin-js');
|
const stylisticJs = require('@stylistic/eslint-plugin-js');
|
||||||
|
const newlines = require('eslint-plugin-import-newlines');
|
||||||
|
|
||||||
|
// Inline plugin: ban en dash (–, U+2013) and em dash (—, U+2014) from source files
|
||||||
|
const noDashPlugin = {
|
||||||
|
rules: {
|
||||||
|
'no-unicode-dashes': {
|
||||||
|
meta: { fixable: 'code' },
|
||||||
|
create(context) {
|
||||||
|
const BANNED = [
|
||||||
|
{ char: '\u2013', name: 'en dash (–)' },
|
||||||
|
{ char: '\u2014', name: 'em dash (—)' }
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
Program() {
|
||||||
|
const src = context.getSourceCode().getText();
|
||||||
|
for (const { char, name } of BANNED) {
|
||||||
|
let idx = src.indexOf(char);
|
||||||
|
while (idx !== -1) {
|
||||||
|
const start = idx;
|
||||||
|
const end = idx + char.length;
|
||||||
|
context.report({
|
||||||
|
loc: context.getSourceCode().getLocFromIndex(idx),
|
||||||
|
message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`,
|
||||||
|
fix(fixer) {
|
||||||
|
return fixer.replaceTextRange([start, end], '-');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
idx = src.indexOf(char, idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = tseslint.config(
|
module.exports = tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**']
|
||||||
'**/generated/*',
|
|
||||||
'dist/**',
|
|
||||||
'dist-electron/**',
|
|
||||||
'.angular/**',
|
|
||||||
'**/migrations/**',
|
|
||||||
'release/**',
|
|
||||||
'src/index.html',
|
|
||||||
'server/**'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['src/app/core/services/**/*.ts'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/member-ordering': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
||||||
'@typescript-eslint/no-invalid-void-type': 'off',
|
|
||||||
'@typescript-eslint/prefer-for-of': 'off',
|
|
||||||
'id-length': 'off',
|
|
||||||
'max-statements-per-line': 'off'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts'],
|
files: ['**/*.ts'],
|
||||||
plugins: {
|
plugins: {
|
||||||
'@stylistic/ts': stylisticTs,
|
'@stylistic/ts': stylisticTs,
|
||||||
'@stylistic/js': stylisticJs
|
'@stylistic/js': stylisticJs,
|
||||||
|
'import-newlines': newlines,
|
||||||
|
'no-dashes': noDashPlugin
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
@@ -44,92 +62,38 @@ module.exports = tseslint.config(
|
|||||||
],
|
],
|
||||||
processor: angular.processInlineTemplates,
|
processor: angular.processInlineTemplates,
|
||||||
rules: {
|
rules: {
|
||||||
|
'no-dashes/no-unicode-dashes': 'error',
|
||||||
'@typescript-eslint/no-extraneous-class': 'off',
|
'@typescript-eslint/no-extraneous-class': 'off',
|
||||||
'@angular-eslint/component-class-suffix': ['error', { suffixes: ['Component', 'Page', 'Stub'] }],
|
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
|
||||||
'@angular-eslint/directive-class-suffix': 'error',
|
'@angular-eslint/directive-class-suffix': 'error',
|
||||||
|
'@typescript-eslint/explicit-module-boundry-types': 'off',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }],
|
'@typescript-eslint/explicit-member-accessibility': ['error',{ accessibility: 'no-public' }],
|
||||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
'@typescript-eslint/array-type': ['error',{ default: 'array' }],
|
||||||
'@typescript-eslint/consistent-type-definitions': 'error',
|
'@typescript-eslint/consistent-type-definitions': 'error',
|
||||||
'@typescript-eslint/dot-notation': 'off',
|
'@typescript-eslint/dot-notation': 'off',
|
||||||
'@stylistic/ts/indent': [
|
'@stylistic/ts/indent': ['error',2,{ ignoredNodes:[
|
||||||
'error',
|
'TSTypeParameterInstantation',
|
||||||
2,
|
'FunctionExpression > .params[decorators.length > 0]',
|
||||||
{
|
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
|
||||||
ignoredNodes: [
|
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
|
||||||
'TSTypeParameterInstantiation',
|
], SwitchCase:1 }],
|
||||||
'FunctionExpression > .params[decorators.length > 0]',
|
'@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }],
|
||||||
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
|
'@typescript-eslint/member-ordering': ['error',{ default:[
|
||||||
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
|
'signature','call-signature',
|
||||||
],
|
'public-static-field','protected-static-field','private-static-field','#private-static-field',
|
||||||
SwitchCase: 1
|
'public-decorated-field','protected-decorated-field','private-decorated-field',
|
||||||
}
|
'public-instance-field','protected-instance-field','private-instance-field','#private-instance-field',
|
||||||
],
|
'public-abstract-field','protected-abstract-field',
|
||||||
'@stylistic/ts/member-delimiter-style': [
|
'public-field','protected-field','private-field','#private-field',
|
||||||
'error',
|
'static-field','instance-field','abstract-field','decorated-field','field','static-initialization',
|
||||||
{
|
'public-constructor','protected-constructor','private-constructor','constructor',
|
||||||
multiline: { delimiter: 'semi', requireLast: true },
|
'public-static-method','protected-static-method','private-static-method','#private-static-method',
|
||||||
singleline: { delimiter: 'semi', requireLast: false }
|
'public-decorated-method','protected-decorated-method','private-decorated-method',
|
||||||
}
|
'public-instance-method','protected-instance-method','private-instance-method','#private-instance-method',
|
||||||
],
|
'public-abstract-method','protected-abstract-method','public-method','protected-method','private-method','#private-method',
|
||||||
'@typescript-eslint/member-ordering': [
|
'static-method','instance-method','abstract-method','decorated-method','method'
|
||||||
'error',
|
] }],
|
||||||
{
|
|
||||||
default: [
|
|
||||||
'signature',
|
|
||||||
'call-signature',
|
|
||||||
'public-static-field',
|
|
||||||
'protected-static-field',
|
|
||||||
'private-static-field',
|
|
||||||
'#private-static-field',
|
|
||||||
'public-decorated-field',
|
|
||||||
'protected-decorated-field',
|
|
||||||
'private-decorated-field',
|
|
||||||
'public-instance-field',
|
|
||||||
'protected-instance-field',
|
|
||||||
'private-instance-field',
|
|
||||||
'#private-instance-field',
|
|
||||||
'public-abstract-field',
|
|
||||||
'protected-abstract-field',
|
|
||||||
'public-field',
|
|
||||||
'protected-field',
|
|
||||||
'private-field',
|
|
||||||
'#private-field',
|
|
||||||
'static-field',
|
|
||||||
'instance-field',
|
|
||||||
'abstract-field',
|
|
||||||
'decorated-field',
|
|
||||||
'field',
|
|
||||||
'static-initialization',
|
|
||||||
'public-constructor',
|
|
||||||
'protected-constructor',
|
|
||||||
'private-constructor',
|
|
||||||
'constructor',
|
|
||||||
'public-static-method',
|
|
||||||
'protected-static-method',
|
|
||||||
'private-static-method',
|
|
||||||
'#private-static-method',
|
|
||||||
'public-decorated-method',
|
|
||||||
'protected-decorated-method',
|
|
||||||
'private-decorated-method',
|
|
||||||
'public-instance-method',
|
|
||||||
'protected-instance-method',
|
|
||||||
'private-instance-method',
|
|
||||||
'#private-instance-method',
|
|
||||||
'public-abstract-method',
|
|
||||||
'protected-abstract-method',
|
|
||||||
'public-method',
|
|
||||||
'protected-method',
|
|
||||||
'private-method',
|
|
||||||
'#private-method',
|
|
||||||
'static-method',
|
|
||||||
'instance-method',
|
|
||||||
'abstract-method',
|
|
||||||
'decorated-method',
|
|
||||||
'method'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-empty-function': 'off',
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
'@typescript-eslint/no-empty-interface': 'error',
|
'@typescript-eslint/no-empty-interface': 'error',
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
@@ -137,23 +101,23 @@ module.exports = tseslint.config(
|
|||||||
'@typescript-eslint/no-namespace': 'error',
|
'@typescript-eslint/no-namespace': 'error',
|
||||||
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
||||||
'@typescript-eslint/no-unused-expressions': 'error',
|
'@typescript-eslint/no-unused-expressions': 'error',
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', ignoreRestSiblings: true }],
|
'@typescript-eslint/no-unused-vars': ['error',{ argsIgnorePattern: '^_', ignoreRestSiblings: true }],
|
||||||
'@typescript-eslint/no-var-requires': 'error',
|
'@typescript-eslint/no-var-requires': 'error',
|
||||||
'@typescript-eslint/prefer-for-of': 'error',
|
'@typescript-eslint/prefer-for-of': 'error',
|
||||||
'@typescript-eslint/prefer-function-type': 'error',
|
'@typescript-eslint/prefer-function-type': 'error',
|
||||||
'@stylistic/ts/quotes': ['error', 'single', { avoidEscape: true }],
|
'@stylistic/ts/quotes': ['error','single',{ avoidEscape:true }],
|
||||||
'@stylistic/ts/semi': ['error', 'always'],
|
'@stylistic/ts/semi': ['error','always'],
|
||||||
'@stylistic/ts/type-annotation-spacing': 'error',
|
'@stylistic/ts/type-annotation-spacing': 'error',
|
||||||
'@typescript-eslint/unified-signatures': 'error',
|
'@typescript-eslint/unified-signatures': 'error',
|
||||||
'@stylistic/js/array-bracket-spacing': 'error',
|
'@stylistic/js/array-bracket-spacing': 'error',
|
||||||
'@stylistic/ts/comma-dangle': ['error', 'never'],
|
'@stylistic/ts/comma-dangle': ['error','never'],
|
||||||
'@stylistic/ts/comma-spacing': 'error',
|
'@stylistic/ts/comma-spacing': 'error',
|
||||||
'@stylistic/js/comma-style': 'error',
|
'@stylistic/js/comma-style': 'error',
|
||||||
complexity: ['warn', { max: 20 }],
|
'complexity': ['warn',{ max:20 }],
|
||||||
curly: 'off',
|
'curly': 'off',
|
||||||
'eol-last': 'error',
|
'eol-last': 'error',
|
||||||
'id-denylist': ['warn', 'e', 'cb', 'i', 'x', 'c', 'y', 'any', 'string', 'String', 'Undefined', 'undefined', 'callback'],
|
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
|
||||||
'max-len': ['error', { code: 150, ignoreComments: true }],
|
'max-len': ['error',{ code:150, ignoreComments:true }],
|
||||||
'new-parens': 'error',
|
'new-parens': 'error',
|
||||||
'newline-per-chained-call': 'error',
|
'newline-per-chained-call': 'error',
|
||||||
'no-bitwise': 'off',
|
'no-bitwise': 'off',
|
||||||
@@ -161,47 +125,70 @@ module.exports = tseslint.config(
|
|||||||
'no-empty': 'off',
|
'no-empty': 'off',
|
||||||
'no-eval': 'error',
|
'no-eval': 'error',
|
||||||
'@stylistic/js/no-multi-spaces': 'error',
|
'@stylistic/js/no-multi-spaces': 'error',
|
||||||
'@stylistic/js/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }],
|
'@stylistic/js/no-multiple-empty-lines': ['error',{ max:1, maxEOF:1 }],
|
||||||
'no-new-wrappers': 'error',
|
'no-new-wrappers': 'error',
|
||||||
'no-restricted-imports': ['error', 'rxjs/Rx'],
|
'no-restricted-imports': ['error','rxjs/Rx'],
|
||||||
'no-throw-literal': 'error',
|
'no-throw-literal': 'error',
|
||||||
'no-trailing-spaces': 'error',
|
'no-trailing-spaces': 'error',
|
||||||
'no-undef-init': 'error',
|
'no-undef-init': 'error',
|
||||||
'no-unsafe-finally': 'error',
|
'no-unsafe-finally': 'error',
|
||||||
'no-var': 'error',
|
'no-var': 'error',
|
||||||
'one-var': ['error', 'never'],
|
'one-var': ['error','never'],
|
||||||
'prefer-const': 'error',
|
'prefer-const': 'error',
|
||||||
'@stylistic/ts/space-before-blocks': 'error',
|
'@stylistic/ts/space-before-blocks': 'error',
|
||||||
'@stylistic/js/space-before-function-paren': ['error', { anonymous: 'never', asyncArrow: 'always', named: 'never' }],
|
'@stylistic/js/space-before-function-paren': ['error',{ anonymous:'never', asyncArrow:'always', named:'never' }],
|
||||||
'@stylistic/ts/space-infix-ops': 'error',
|
'@stylistic/ts/space-infix-ops': 'error',
|
||||||
'@stylistic/js/space-in-parens': 'error',
|
'@stylistic/js/space-in-parens': 'error',
|
||||||
'@stylistic/js/space-unary-ops': 'error',
|
'@stylistic/js/space-unary-ops': 'error',
|
||||||
'@stylistic/js/spaced-comment': ['error', 'always', { markers: ['/'] }],
|
'@stylistic/js/spaced-comment': ['error','always',{ markers:['/'] }],
|
||||||
'@stylistic/js/block-spacing': ['error', 'always'],
|
"import-newlines/enforce": [
|
||||||
|
"error",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
// Require spaces inside single-line blocks: { stmt; }
|
||||||
|
'@stylistic/js/block-spacing': ['error','always'],
|
||||||
|
|
||||||
|
// Disallow single-line if statements but allow body on the next line (with or without braces)
|
||||||
|
// Examples allowed:
|
||||||
|
// if (condition)\n return true;
|
||||||
|
// if (condition)\n {\n return true;\n }
|
||||||
'nonblock-statement-body-position': ['error', 'below'],
|
'nonblock-statement-body-position': ['error', 'below'],
|
||||||
|
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
|
||||||
'max-statements-per-line': ['error', { max: 1 }],
|
'max-statements-per-line': ['error', { max: 1 }],
|
||||||
|
// Prevent single-character identifiers for variables/params; do not check object property names
|
||||||
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
|
||||||
|
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
|
||||||
'padding-line-between-statements': [
|
'padding-line-between-statements': [
|
||||||
'error',
|
'error',
|
||||||
|
// Ensure blank lines around standalone if statements within the same scope
|
||||||
{ blankLine: 'always', prev: '*', next: 'if' },
|
{ blankLine: 'always', prev: '*', next: 'if' },
|
||||||
{ blankLine: 'always', prev: 'if', next: '*' },
|
{ blankLine: 'always', prev: 'if', next: '*' },
|
||||||
|
// Keep clear separation around any block-like statement (if, function, class, switch, try, etc.)
|
||||||
{ blankLine: 'always', prev: '*', next: 'block-like' },
|
{ blankLine: 'always', prev: '*', next: 'block-like' },
|
||||||
{ blankLine: 'always', prev: 'block-like', next: '*' },
|
{ blankLine: 'always', prev: 'block-like', next: '*' },
|
||||||
{ blankLine: 'always', prev: 'function', next: '*' },
|
// Always require a blank line after functions (and multiline expressions)
|
||||||
{ blankLine: 'always', prev: 'class', next: '*' },
|
{ blankLine: 'always', prev: ['function', 'multiline-expression'], next: '*' },
|
||||||
|
// Always require a blank line after class declarations (and multiline expressions)
|
||||||
|
{ blankLine: 'always', prev: ['class', 'multiline-expression'], next: '*' },
|
||||||
|
// Always require a blank line after groups of variable declarations
|
||||||
{ blankLine: 'always', prev: 'const', next: '*' },
|
{ blankLine: 'always', prev: 'const', next: '*' },
|
||||||
{ blankLine: 'always', prev: 'let', next: '*' },
|
{ blankLine: 'always', prev: 'let', next: '*' },
|
||||||
{ blankLine: 'always', prev: 'var', next: '*' },
|
{ blankLine: 'always', prev: 'var', next: '*' },
|
||||||
|
// But never require a blank line between a series of variable declarations of the same kind
|
||||||
{ blankLine: 'never', prev: 'const', next: 'const' },
|
{ blankLine: 'never', prev: 'const', next: 'const' },
|
||||||
{ blankLine: 'never', prev: 'let', next: 'let' },
|
{ blankLine: 'never', prev: 'let', next: 'let' },
|
||||||
{ blankLine: 'never', prev: 'var', next: 'var' }
|
{ blankLine: 'never', prev: 'var', next: 'var' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// HTML template formatting rules (external Angular templates only)
|
||||||
{
|
{
|
||||||
files: ['src/app/**/*.html'],
|
files: ['src/app/**/*.html'],
|
||||||
|
plugins: { 'no-dashes': noDashPlugin },
|
||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||||
rules: {
|
rules: {
|
||||||
|
'no-dashes/no-unicode-dashes': 'error',
|
||||||
|
// Angular template best practices
|
||||||
'@angular-eslint/template/button-has-type': 'warn',
|
'@angular-eslint/template/button-has-type': 'warn',
|
||||||
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
||||||
'@angular-eslint/template/eqeqeq': 'error',
|
'@angular-eslint/template/eqeqeq': 'error',
|
||||||
@@ -210,9 +197,13 @@ module.exports = tseslint.config(
|
|||||||
'@angular-eslint/template/prefer-self-closing-tags': 'warn',
|
'@angular-eslint/template/prefer-self-closing-tags': 'warn',
|
||||||
'@angular-eslint/template/use-track-by-function': 'warn',
|
'@angular-eslint/template/use-track-by-function': 'warn',
|
||||||
'@angular-eslint/template/no-negated-async': 'warn',
|
'@angular-eslint/template/no-negated-async': 'warn',
|
||||||
'@angular-eslint/template/no-call-expression': 'off'
|
'@angular-eslint/template/no-call-expression': 'off', // Allow method calls in templates
|
||||||
}
|
// Note: attributes-order is disabled in favor of Prettier handling formatting
|
||||||
}
|
// Prettier uses singleAttributePerLine to enforce property grouping
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// IMPORTANT: Formatting is handled by Prettier; ESLint validates logic/accessibility.
|
// IMPORTANT: Formatting is handled by Prettier, not ESLint
|
||||||
|
// ESLint validates logic/accessibility, Prettier handles formatting
|
||||||
|
// Enable format on save in VS Code settings to use Prettier automatically
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -29,19 +29,11 @@
|
|||||||
"build:prod:all": "npm run build:prod && cd server && npm run build",
|
"build:prod:all": "npm run build:prod && cd server && npm run build",
|
||||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||||
"dev": "npm run electron:full",
|
"dev": "npm run electron:full",
|
||||||
"lint": "eslint . --ext .ts,.html"
|
"lint": "eslint .",
|
||||||
},
|
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||||
"prettier": {
|
"format": "prettier --write \"src/app/**/*.html\"",
|
||||||
"printWidth": 100,
|
"format:check": "prettier --check \"src/app/**/*.html\"",
|
||||||
"singleQuote": true,
|
"sort:props": "node tools/sort-template-properties.js"
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.html",
|
|
||||||
"options": {
|
|
||||||
"parser": "angular"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@10.9.2",
|
"packageManager": "npm@10.9.2",
|
||||||
@@ -84,17 +76,22 @@
|
|||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "^21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-import-newlines": "^1.4.1",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"glob": "^10.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "8.50.1",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,8 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
isDevMode
|
||||||
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { provideStore } from '@ngrx/store';
|
import { provideStore } from '@ngrx/store';
|
||||||
@@ -26,7 +30,12 @@ export const appConfig: ApplicationConfig = {
|
|||||||
users: usersReducer,
|
users: usersReducer,
|
||||||
rooms: roomsReducer
|
rooms: roomsReducer
|
||||||
}),
|
}),
|
||||||
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]),
|
provideEffects([
|
||||||
|
MessagesEffects,
|
||||||
|
MessagesSyncEffects,
|
||||||
|
UsersEffects,
|
||||||
|
RoomsEffects
|
||||||
|
]),
|
||||||
provideStoreDevtools({
|
provideStoreDevtools({
|
||||||
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
||||||
logOnly: !isDevMode(),
|
logOnly: !isDevMode(),
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
|
/* eslint-disable @angular-eslint/component-class-suffix, @typescript-eslint/member-ordering */
|
||||||
import { Component, OnInit, inject, HostListener } from '@angular/core';
|
import {
|
||||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
Component,
|
||||||
|
OnInit,
|
||||||
|
inject,
|
||||||
|
HostListener
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
Router,
|
||||||
|
RouterOutlet,
|
||||||
|
NavigationEnd
|
||||||
|
} from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const DEFAULT_MAX_USERS = 50;
|
|||||||
/** Default audio bitrate in kbps for voice chat. */
|
/** Default audio bitrate in kbps for voice chat. */
|
||||||
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||||
|
|
||||||
/** Default volume level (0–100). */
|
/** Default volume level (0-100). */
|
||||||
export const DEFAULT_VOLUME = 100;
|
export const DEFAULT_VOLUME = 100;
|
||||||
|
|
||||||
/** Default search debounce time in milliseconds. */
|
/** Default search debounce time in milliseconds. */
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ export type ChatEventType =
|
|||||||
| 'room-deleted'
|
| 'room-deleted'
|
||||||
| 'room-settings-update'
|
| 'room-settings-update'
|
||||||
| 'voice-state'
|
| 'voice-state'
|
||||||
|
| 'chat-inventory-request'
|
||||||
| 'voice-state-request'
|
| 'voice-state-request'
|
||||||
| 'state-request'
|
| 'state-request'
|
||||||
| 'screen-state'
|
| 'screen-state'
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
|
||||||
import { Injectable, inject, signal, effect } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
effect
|
||||||
|
} from '@angular/core';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -168,7 +173,7 @@ export class AttachmentService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register attachment metadata received via message sync
|
* Register attachment metadata received via message sync
|
||||||
* (content is not yet available — only metadata).
|
* (content is not yet available - only metadata).
|
||||||
*
|
*
|
||||||
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
||||||
*/
|
*/
|
||||||
@@ -184,7 +189,9 @@ export class AttachmentService {
|
|||||||
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
||||||
|
|
||||||
if (!alreadyKnown) {
|
if (!alreadyKnown) {
|
||||||
const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 };
|
const attachment: Attachment = { ...meta,
|
||||||
|
available: false,
|
||||||
|
receivedBytes: 0 };
|
||||||
|
|
||||||
existing.push(attachment);
|
existing.push(attachment);
|
||||||
newAttachments.push(attachment);
|
newAttachments.push(attachment);
|
||||||
@@ -228,7 +235,7 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a `file-not-found` response — try the next available peer.
|
* Handle a `file-not-found` response - try the next available peer.
|
||||||
*/
|
*/
|
||||||
handleFileNotFound(payload: any): void {
|
handleFileNotFound(payload: any): void {
|
||||||
const { messageId, fileId } = payload;
|
const { messageId, fileId } = payload;
|
||||||
@@ -428,6 +435,7 @@ export class AttachmentService {
|
|||||||
attachment.speedBps =
|
attachment.speedBps =
|
||||||
EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
||||||
EWMA_CURRENT_WEIGHT * instantaneousBps;
|
EWMA_CURRENT_WEIGHT * instantaneousBps;
|
||||||
|
|
||||||
attachment.lastUpdateMs = now;
|
attachment.lastUpdateMs = now;
|
||||||
|
|
||||||
this.touch(); // trigger UI update for progress bars
|
this.touch(); // trigger UI update for progress bars
|
||||||
@@ -600,7 +608,7 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a `file-cancel` from the requester — record the
|
* Handle a `file-cancel` from the requester - record the
|
||||||
* cancellation so the streaming loop breaks early.
|
* cancellation so the streaming loop breaks early.
|
||||||
*/
|
*/
|
||||||
handleFileCancel(payload: any): void {
|
handleFileCancel(payload: any): void {
|
||||||
@@ -690,6 +698,7 @@ export class AttachmentService {
|
|||||||
messageId,
|
messageId,
|
||||||
fileId
|
fileId
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,7 +932,8 @@ export class AttachmentService {
|
|||||||
const grouped = new Map<string, Attachment[]>();
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
|
||||||
for (const record of allRecords) {
|
for (const record of allRecords) {
|
||||||
const attachment: Attachment = { ...record, available: false };
|
const attachment: Attachment = { ...record,
|
||||||
|
available: false };
|
||||||
const bucket = grouped.get(record.messageId) ?? [];
|
const bucket = grouped.get(record.messageId) ?? [];
|
||||||
|
|
||||||
bucket.push(attachment);
|
bucket.push(attachment);
|
||||||
@@ -949,7 +959,8 @@ export class AttachmentService {
|
|||||||
const existing = this.attachmentsByMessage.get(meta.messageId) ?? [];
|
const existing = this.attachmentsByMessage.get(meta.messageId) ?? [];
|
||||||
|
|
||||||
if (!existing.find((entry) => entry.id === meta.id)) {
|
if (!existing.find((entry) => entry.id === meta.id)) {
|
||||||
const attachment: Attachment = { ...meta, available: false };
|
const attachment: Attachment = { ...meta,
|
||||||
|
available: false };
|
||||||
|
|
||||||
existing.push(attachment);
|
existing.push(attachment);
|
||||||
this.attachmentsByMessage.set(meta.messageId, existing);
|
this.attachmentsByMessage.set(meta.messageId, existing);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
import {
|
||||||
|
Message,
|
||||||
|
User,
|
||||||
|
Room,
|
||||||
|
Reaction,
|
||||||
|
BanEntry
|
||||||
|
} from '../models';
|
||||||
|
|
||||||
/** IndexedDB database name for the MetoYou application. */
|
/** IndexedDB database name for the MetoYou application. */
|
||||||
const DATABASE_NAME = 'metoyou';
|
const DATABASE_NAME = 'metoyou';
|
||||||
/** IndexedDB schema version — bump when adding/changing object stores. */
|
/** IndexedDB schema version - bump when adding/changing object stores. */
|
||||||
const DATABASE_VERSION = 2;
|
const DATABASE_VERSION = 2;
|
||||||
/** Names of every object store used by the application. */
|
/** Names of every object store used by the application. */
|
||||||
const STORE_MESSAGES = 'messages';
|
const STORE_MESSAGES = 'messages';
|
||||||
@@ -77,7 +83,8 @@ export class BrowserDatabaseService {
|
|||||||
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
|
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.put(STORE_MESSAGES, { ...existing, ...updates });
|
await this.put(STORE_MESSAGES, { ...existing,
|
||||||
|
...updates });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +167,8 @@ export class BrowserDatabaseService {
|
|||||||
|
|
||||||
/** Store which user ID is considered "current" (logged-in). */
|
/** Store which user ID is considered "current" (logged-in). */
|
||||||
async setCurrentUserId(userId: string): Promise<void> {
|
async setCurrentUserId(userId: string): Promise<void> {
|
||||||
await this.put(STORE_META, { id: 'currentUserId', value: userId });
|
await this.put(STORE_META, { id: 'currentUserId',
|
||||||
|
value: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,7 +184,8 @@ export class BrowserDatabaseService {
|
|||||||
const existing = await this.get<User>(STORE_USERS, userId);
|
const existing = await this.get<User>(STORE_USERS, userId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.put(STORE_USERS, { ...existing, ...updates });
|
await this.put(STORE_USERS, { ...existing,
|
||||||
|
...updates });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +215,8 @@ export class BrowserDatabaseService {
|
|||||||
const existing = await this.get<Room>(STORE_ROOMS, roomId);
|
const existing = await this.get<Room>(STORE_ROOMS, roomId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.put(STORE_ROOMS, { ...existing, ...updates });
|
await this.put(STORE_ROOMS, { ...existing,
|
||||||
|
...updates });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +306,7 @@ export class BrowserDatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════
|
||||||
// Private helpers — thin wrappers around IndexedDB
|
// Private helpers - thin wrappers around IndexedDB
|
||||||
// ══════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||||
import { inject, Injectable, signal } from '@angular/core';
|
import {
|
||||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
inject,
|
||||||
|
Injectable,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
User,
|
||||||
|
Room,
|
||||||
|
Reaction,
|
||||||
|
BanEntry
|
||||||
|
} from '../models';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from './platform.service';
|
||||||
import { BrowserDatabaseService } from './browser-database.service';
|
import { BrowserDatabaseService } from './browser-database.service';
|
||||||
import { ElectronDatabaseService } from './electron-database.service';
|
import { ElectronDatabaseService } from './electron-database.service';
|
||||||
@@ -12,7 +22,7 @@ import { ElectronDatabaseService } from './electron-database.service';
|
|||||||
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||||
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
|
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
|
||||||
*
|
*
|
||||||
* All consumers inject `DatabaseService` — the underlying storage engine
|
* All consumers inject `DatabaseService` - the underlying storage engine
|
||||||
* is selected automatically.
|
* is selected automatically.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
import {
|
||||||
|
Message,
|
||||||
|
User,
|
||||||
|
Room,
|
||||||
|
Reaction,
|
||||||
|
BanEntry
|
||||||
|
} from '../models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database service for the Electron (desktop) runtime.
|
* Database service for the Electron (desktop) runtime.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const AUDIO_BASE = '/assets/audio';
|
|||||||
const AUDIO_EXT = 'wav';
|
const AUDIO_EXT = 'wav';
|
||||||
/** localStorage key for persisting notification volume. */
|
/** localStorage key for persisting notification volume. */
|
||||||
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
|
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
|
||||||
/** Default notification volume (0 – 1). */
|
/** Default notification volume (0 - 1). */
|
||||||
const DEFAULT_VOLUME = 0.2;
|
const DEFAULT_VOLUME = 0.2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ export class NotificationAudioService {
|
|||||||
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
||||||
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
||||||
|
|
||||||
/** Reactive notification volume (0 – 1), persisted to localStorage. */
|
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||||
readonly notificationVolume = signal(this.loadVolume());
|
readonly notificationVolume = signal(this.loadVolume());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -88,10 +88,10 @@ export class NotificationAudioService {
|
|||||||
* Play a sound effect at the current notification volume.
|
* Play a sound effect at the current notification volume.
|
||||||
*
|
*
|
||||||
* If playback fails (e.g. browser autoplay policy) the error is
|
* If playback fails (e.g. browser autoplay policy) the error is
|
||||||
* silently swallowed — sound effects are non-critical.
|
* silently swallowed - sound effects are non-critical.
|
||||||
*
|
*
|
||||||
* @param sound - The {@link AppSound} to play.
|
* @param sound - The {@link AppSound} to play.
|
||||||
* @param volumeOverride - Optional explicit volume (0 – 1). When omitted
|
* @param volumeOverride - Optional explicit volume (0 - 1). When omitted
|
||||||
* the persisted {@link notificationVolume} is used.
|
* the persisted {@link notificationVolume} is used.
|
||||||
*/
|
*/
|
||||||
play(sound: AppSound, volumeOverride?: number): void {
|
play(sound: AppSound, volumeOverride?: number): void {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class PlatformService {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.isElectron =
|
this.isElectron =
|
||||||
typeof window !== 'undefined' && !!(window as any).electronAPI;
|
typeof window !== 'undefined' && !!(window as any).electronAPI;
|
||||||
|
|
||||||
this.isBrowser = !this.isElectron;
|
this.isBrowser = !this.isElectron;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */
|
/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */
|
||||||
import { Injectable, signal, computed } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed
|
||||||
|
} from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, of, throwError, forkJoin } from 'rxjs';
|
import {
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
throwError,
|
||||||
|
forkJoin
|
||||||
|
} from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { ServerInfo, JoinRequest, User } from '../models';
|
import {
|
||||||
|
ServerInfo,
|
||||||
|
JoinRequest,
|
||||||
|
User
|
||||||
|
} from '../models';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,6 +150,7 @@ export class ServerDirectoryService {
|
|||||||
isActive: endpoint.id === endpointId
|
isActive: endpoint.id === endpointId
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
this.saveEndpoints();
|
this.saveEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +162,12 @@ export class ServerDirectoryService {
|
|||||||
): void {
|
): void {
|
||||||
this._servers.update((endpoints) =>
|
this._servers.update((endpoints) =>
|
||||||
endpoints.map((endpoint) =>
|
endpoints.map((endpoint) =>
|
||||||
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint
|
endpoint.id === endpointId ? { ...endpoint,
|
||||||
|
status,
|
||||||
|
latency } : endpoint
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.saveEndpoints();
|
this.saveEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +558,8 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
endpoints = endpoints.map((endpoint) => {
|
endpoints = endpoints.map((endpoint) => {
|
||||||
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
|
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
|
||||||
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
return { ...endpoint,
|
||||||
|
url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpoint;
|
return endpoint;
|
||||||
@@ -556,7 +574,8 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
/** Create and persist the built-in default endpoint. */
|
/** Create and persist the built-in default endpoint. */
|
||||||
private initialiseDefaultEndpoint(): void {
|
private initialiseDefaultEndpoint(): void {
|
||||||
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
|
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT,
|
||||||
|
id: uuidv4() };
|
||||||
|
|
||||||
this._servers.set([defaultEndpoint]);
|
this._servers.set([defaultEndpoint]);
|
||||||
this.saveEndpoints();
|
this.saveEndpoints();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, signal, computed } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
/** Default timeout (ms) for the NTP-style HTTP sync request. */
|
/** Default timeout (ms) for the NTP-style HTTP sync request. */
|
||||||
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
|
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* VoiceActivityService — monitors audio levels for local microphone
|
* VoiceActivityService - monitors audio levels for local microphone
|
||||||
* and remote peer streams, exposing per-user "speaking" state as
|
* and remote peer streams, exposing per-user "speaking" state as
|
||||||
* reactive Angular signals.
|
* reactive Angular signals.
|
||||||
*
|
*
|
||||||
@@ -9,19 +9,26 @@
|
|||||||
* // speaking() => true when the user's audio level exceeds the threshold
|
* // speaking() => true when the user's audio level exceeds the threshold
|
||||||
*
|
*
|
||||||
* const volume = voiceActivity.volume(userId);
|
* const volume = voiceActivity.volume(userId);
|
||||||
* // volume() => normalised 0–1 audio level
|
* // volume() => normalised 0-1 audio level
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Internally uses the Web Audio API ({@link AudioContext} +
|
* Internally uses the Web Audio API ({@link AudioContext} +
|
||||||
* {@link AnalyserNode}) per tracked stream, with a single
|
* {@link AnalyserNode}) per tracked stream, with a single
|
||||||
* `requestAnimationFrame` poll loop.
|
* `requestAnimationFrame` poll loop.
|
||||||
*/
|
*/
|
||||||
import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
OnDestroy,
|
||||||
|
Signal
|
||||||
|
} from '@angular/core';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */
|
||||||
|
|
||||||
/** RMS volume threshold (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;
|
const SPEAKING_THRESHOLD = 0.015;
|
||||||
/** How many consecutive silent frames before we flip speaking → false. */
|
/** How many consecutive silent frames before we flip speaking → false. */
|
||||||
const SILENT_FRAME_GRACE = 8;
|
const SILENT_FRAME_GRACE = 8;
|
||||||
@@ -38,7 +45,7 @@ interface TrackedStream {
|
|||||||
analyser: AnalyserNode;
|
analyser: AnalyserNode;
|
||||||
/** Reusable buffer for `getByteTimeDomainData`. */
|
/** Reusable buffer for `getByteTimeDomainData`. */
|
||||||
dataArray: Uint8Array<ArrayBuffer>;
|
dataArray: Uint8Array<ArrayBuffer>;
|
||||||
/** Writable signal for the normalised volume (0–1). */
|
/** Writable signal for the normalised volume (0-1). */
|
||||||
volumeSignal: ReturnType<typeof signal<number>>;
|
volumeSignal: ReturnType<typeof signal<number>>;
|
||||||
/** Writable signal for speaking state. */
|
/** Writable signal for speaking state. */
|
||||||
speakingSignal: ReturnType<typeof signal<boolean>>;
|
speakingSignal: ReturnType<typeof signal<boolean>>;
|
||||||
@@ -123,7 +130,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a read-only signal with the normalised (0–1) volume
|
* Returns a read-only signal with the normalised (0-1) volume
|
||||||
* for the given user.
|
* for the given user.
|
||||||
*/
|
*/
|
||||||
volume(userId: string): Signal<number> {
|
volume(userId: string): Signal<number> {
|
||||||
@@ -160,7 +167,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
analyser.fftSize = FFT_SIZE;
|
analyser.fftSize = FFT_SIZE;
|
||||||
|
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
// Do NOT connect analyser to ctx.destination — we don't want to
|
// Do NOT connect analyser to ctx.destination - we don't want to
|
||||||
// double-play audio; playback is handled elsewhere.
|
// double-play audio; playback is handled elsewhere.
|
||||||
|
|
||||||
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
||||||
@@ -226,7 +233,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
// Compute RMS volume from time-domain data (values 0–255, centred at 128).
|
// Compute RMS volume from time-domain data (values 0-255, centred at 128).
|
||||||
let sumSquares = 0;
|
let sumSquares = 0;
|
||||||
|
|
||||||
for (let i = 0; i < dataArray.length; i++) {
|
for (let i = 0; i < dataArray.length; i++) {
|
||||||
@@ -271,6 +278,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this.tracked.forEach((entry, id) => {
|
this.tracked.forEach((entry, id) => {
|
||||||
map.set(id, entry.speakingSignal());
|
map.set(id, entry.speakingSignal());
|
||||||
});
|
});
|
||||||
|
|
||||||
this._speakingMap.set(map);
|
this._speakingMap.set(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* VoiceLevelingService — Angular service that manages the
|
* VoiceLevelingService - Angular service that manages the
|
||||||
* per-speaker voice leveling (AGC) system.
|
* per-speaker voice leveling (AGC) system.
|
||||||
*
|
*
|
||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*
|
*
|
||||||
* 4. Provides an `enable` / `disable` / `disableAll` API that
|
* 4. Provides an `enable` / `disable` / `disableAll` API that
|
||||||
* the voice-controls component uses to insert and remove the
|
* the voice-controls component uses to insert and remove the
|
||||||
* AGC pipeline from the remote audio playback chain — mirroring
|
* AGC pipeline from the remote audio playback chain - mirroring
|
||||||
* the {@link NoiseReductionManager} toggle pattern.
|
* the {@link NoiseReductionManager} toggle pattern.
|
||||||
*
|
*
|
||||||
* 5. Fires a callback when the user toggles the enabled state so
|
* 5. Fires a callback when the user toggles the enabled state so
|
||||||
@@ -26,7 +26,12 @@
|
|||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
VoiceLevelingManager,
|
VoiceLevelingManager,
|
||||||
VoiceLevelingSettings,
|
VoiceLevelingSettings,
|
||||||
@@ -167,7 +172,7 @@ export class VoiceLevelingService implements OnDestroy {
|
|||||||
* Set the post-AGC volume for a specific speaker.
|
* Set the post-AGC volume for a specific speaker.
|
||||||
*
|
*
|
||||||
* @param peerId The speaker's peer ID.
|
* @param peerId The speaker's peer ID.
|
||||||
* @param volume Normalised volume (0–1).
|
* @param volume Normalised volume (0-1).
|
||||||
*/
|
*/
|
||||||
setSpeakerVolume(peerId: string, volume: number): void {
|
setSpeakerVolume(peerId: string, volume: number): void {
|
||||||
this.manager.setSpeakerVolume(peerId, volume);
|
this.manager.setSpeakerVolume(peerId, volume);
|
||||||
@@ -176,7 +181,7 @@ export class VoiceLevelingService implements OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Set the master volume applied after AGC to all speakers.
|
* Set the master volume applied after AGC to all speakers.
|
||||||
*
|
*
|
||||||
* @param volume Normalised volume (0–1).
|
* @param volume Normalised volume (0-1).
|
||||||
*/
|
*/
|
||||||
setMasterVolume(volume: number): void {
|
setMasterVolume(volume: number): void {
|
||||||
this.manager.setMasterVolume(volume);
|
this.manager.setMasterVolume(volume);
|
||||||
@@ -222,7 +227,7 @@ export class VoiceLevelingService implements OnDestroy {
|
|||||||
STORAGE_KEY_VOICE_LEVELING_SETTINGS,
|
STORAGE_KEY_VOICE_LEVELING_SETTINGS,
|
||||||
JSON.stringify(settings)
|
JSON.stringify(settings)
|
||||||
);
|
);
|
||||||
} catch { /* localStorage unavailable — ignore */ }
|
} catch { /* localStorage unavailable - ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load settings from localStorage and apply to the manager. */
|
/** Load settings from localStorage and apply to the manager. */
|
||||||
@@ -264,7 +269,7 @@ export class VoiceLevelingService implements OnDestroy {
|
|||||||
speed: this._speed(),
|
speed: this._speed(),
|
||||||
noiseGate: this._noiseGate()
|
noiseGate: this._noiseGate()
|
||||||
});
|
});
|
||||||
} catch { /* corrupted data — use defaults */ }
|
} catch { /* corrupted data - use defaults */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cleanup ─────────────────────────────────────────────────── */
|
/* ── Cleanup ─────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
|
||||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
@@ -31,7 +36,7 @@ export interface VoiceSessionInfo {
|
|||||||
* navigation so that floating voice controls remain visible when
|
* navigation so that floating voice controls remain visible when
|
||||||
* the user is browsing a different server or view.
|
* the user is browsing a different server or view.
|
||||||
*
|
*
|
||||||
* This service is purely a UI-state tracker — actual WebRTC
|
* This service is purely a UI-state tracker - actual WebRTC
|
||||||
* voice management lives in {@link WebRTCService} and its managers.
|
* voice management lives in {@link WebRTCService} and its managers.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -132,6 +137,7 @@ export class VoiceSessionService {
|
|||||||
} as any
|
} as any
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this._isViewingVoiceServer.set(true);
|
this._isViewingVoiceServer.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* WebRTCService — thin Angular service that composes specialised managers.
|
* WebRTCService - thin Angular service that composes specialised managers.
|
||||||
*
|
*
|
||||||
* Each concern lives in its own file under `./webrtc/`:
|
* Each concern lives in its own file under `./webrtc/`:
|
||||||
* • SignalingManager – WebSocket lifecycle & reconnection
|
* • SignalingManager - WebSocket lifecycle & reconnection
|
||||||
* • PeerConnectionManager – RTCPeerConnection, offers/answers, ICE, data channels
|
* • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
|
||||||
* • MediaManager – mic voice, mute, deafen, bitrate
|
* • MediaManager - mic voice, mute, deafen, bitrate
|
||||||
* • ScreenShareManager – screen capture & mixed audio
|
* • ScreenShareManager - screen capture & mixed audio
|
||||||
* • WebRTCLogger – debug / diagnostic logging
|
* • WebRTCLogger - debug / diagnostic logging
|
||||||
*
|
*
|
||||||
* This file wires them together and exposes a public API that is
|
* This file wires them together and exposes a public API that is
|
||||||
* identical to the old monolithic service so consumers don't change.
|
* identical to the old monolithic service so consumers don't change.
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */
|
||||||
import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core';
|
import {
|
||||||
|
Injectable,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject } from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { SignalingMessage, ChatEvent } from '../models';
|
import { SignalingMessage, ChatEvent } from '../models';
|
||||||
import { TimeSyncService } from './time-sync.service';
|
import { TimeSyncService } from './time-sync.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
// Managers
|
|
||||||
SignalingManager,
|
SignalingManager,
|
||||||
PeerConnectionManager,
|
PeerConnectionManager,
|
||||||
MediaManager,
|
MediaManager,
|
||||||
ScreenShareManager,
|
ScreenShareManager,
|
||||||
WebRTCLogger,
|
WebRTCLogger,
|
||||||
// Types
|
|
||||||
IdentifyCredentials,
|
IdentifyCredentials,
|
||||||
JoinedServerInfo,
|
JoinedServerInfo,
|
||||||
VoiceStateSnapshot,
|
VoiceStateSnapshot,
|
||||||
LatencyProfile,
|
LatencyProfile,
|
||||||
// Constants
|
|
||||||
SIGNALING_TYPE_IDENTIFY,
|
SIGNALING_TYPE_IDENTIFY,
|
||||||
SIGNALING_TYPE_JOIN_SERVER,
|
SIGNALING_TYPE_JOIN_SERVER,
|
||||||
SIGNALING_TYPE_VIEW_SERVER,
|
SIGNALING_TYPE_VIEW_SERVER,
|
||||||
@@ -255,6 +258,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
serverId: message.serverId
|
serverId: message.serverId
|
||||||
});
|
});
|
||||||
|
|
||||||
this.peerManager.createPeerConnection(user.oderId, true);
|
this.peerManager.createPeerConnection(user.oderId, true);
|
||||||
this.peerManager.createAndSendOffer(user.oderId);
|
this.peerManager.createAndSendOffer(user.oderId);
|
||||||
|
|
||||||
@@ -273,6 +277,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
displayName: message.displayName,
|
displayName: message.displayName,
|
||||||
oderId: message.oderId
|
oderId: message.oderId
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_LEFT:
|
case SIGNALING_TYPE_USER_LEFT:
|
||||||
@@ -337,7 +342,9 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const peerId of peersToClose) {
|
for (const peerId of peersToClose) {
|
||||||
this.logger.info('Closing peer from different server', { peerId, currentServer: serverId });
|
this.logger.info('Closing peer from different server', { peerId,
|
||||||
|
currentServer: serverId });
|
||||||
|
|
||||||
this.peerManager.removePeer(peerId);
|
this.peerManager.removePeer(peerId);
|
||||||
this.peerServerMap.delete(peerId);
|
this.peerServerMap.delete(peerId);
|
||||||
}
|
}
|
||||||
@@ -355,7 +362,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// PUBLIC API – matches the old monolithic service's interface
|
// PUBLIC API - matches the old monolithic service's interface
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,8 +421,12 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param displayName - The user's display name.
|
* @param displayName - The user's display name.
|
||||||
*/
|
*/
|
||||||
identify(oderId: string, displayName: string): void {
|
identify(oderId: string, displayName: string): void {
|
||||||
this.lastIdentifyCredentials = { oderId, displayName };
|
this.lastIdentifyCredentials = { oderId,
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
|
displayName };
|
||||||
|
|
||||||
|
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||||
|
oderId,
|
||||||
|
displayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -425,9 +436,12 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param userId - The local user ID.
|
* @param userId - The local user ID.
|
||||||
*/
|
*/
|
||||||
joinRoom(roomId: string, userId: string): void {
|
joinRoom(roomId: string, userId: string): void {
|
||||||
this.lastJoinedServer = { serverId: roomId, userId };
|
this.lastJoinedServer = { serverId: roomId,
|
||||||
|
userId };
|
||||||
|
|
||||||
this.memberServerIds.add(roomId);
|
this.memberServerIds.add(roomId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
serverId: roomId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,10 +452,13 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param userId - The local user ID.
|
* @param userId - The local user ID.
|
||||||
*/
|
*/
|
||||||
switchServer(serverId: string, userId: string): void {
|
switchServer(serverId: string, userId: string): void {
|
||||||
this.lastJoinedServer = { serverId, userId };
|
this.lastJoinedServer = { serverId,
|
||||||
|
userId };
|
||||||
|
|
||||||
if (this.memberServerIds.has(serverId)) {
|
if (this.memberServerIds.has(serverId)) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||||
|
serverId });
|
||||||
|
|
||||||
this.logger.info('Viewed server (already joined)', {
|
this.logger.info('Viewed server (already joined)', {
|
||||||
serverId,
|
serverId,
|
||||||
userId,
|
userId,
|
||||||
@@ -449,7 +466,9 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.memberServerIds.add(serverId);
|
this.memberServerIds.add(serverId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
serverId });
|
||||||
|
|
||||||
this.logger.info('Joined new server via switch', {
|
this.logger.info('Joined new server via switch', {
|
||||||
serverId,
|
serverId,
|
||||||
userId,
|
userId,
|
||||||
@@ -469,7 +488,9 @@ export class WebRTCService implements OnDestroy {
|
|||||||
leaveRoom(serverId?: string): void {
|
leaveRoom(serverId?: string): void {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
this.memberServerIds.delete(serverId);
|
this.memberServerIds.delete(serverId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
|
serverId });
|
||||||
|
|
||||||
this.logger.info('Left server', { serverId });
|
this.logger.info('Left server', { serverId });
|
||||||
|
|
||||||
if (this.memberServerIds.size === 0) {
|
if (this.memberServerIds.size === 0) {
|
||||||
@@ -480,8 +501,10 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.memberServerIds.forEach((sid) => {
|
this.memberServerIds.forEach((sid) => {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid });
|
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
|
serverId: sid });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.memberServerIds.clear();
|
this.memberServerIds.clear();
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
@@ -619,7 +642,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Set the output volume for remote audio playback.
|
* Set the output volume for remote audio playback.
|
||||||
*
|
*
|
||||||
* @param volume - Normalised volume (0–1).
|
* @param volume - Normalised volume (0-1).
|
||||||
*/
|
*/
|
||||||
setOutputVolume(volume: number): void {
|
setOutputVolume(volume: number): void {
|
||||||
this.mediaManager.setOutputVolume(volume);
|
this.mediaManager.setOutputVolume(volume);
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class MediaManager {
|
|||||||
getIsSelfDeafened(): boolean {
|
getIsSelfDeafened(): boolean {
|
||||||
return this.isSelfDeafened;
|
return this.isSelfDeafened;
|
||||||
}
|
}
|
||||||
/** Current remote audio output volume (normalised 0–1). */
|
/** Current remote audio output volume (normalised 0-1). */
|
||||||
getRemoteAudioVolume(): number {
|
getRemoteAudioVolume(): number {
|
||||||
return this.remoteAudioVolume;
|
return this.remoteAudioVolume;
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ export class MediaManager {
|
|||||||
*/
|
*/
|
||||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||||
this.rawMicStream = stream;
|
this.rawMicStream = stream;
|
||||||
this.logger.info('setLocalStream — noiseReductionDesired =', this._noiseReductionDesired);
|
this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired);
|
||||||
|
|
||||||
// Pipe through the denoiser when the user wants noise reduction
|
// Pipe through the denoiser when the user wants noise reduction
|
||||||
if (this._noiseReductionDesired) {
|
if (this._noiseReductionDesired) {
|
||||||
@@ -259,6 +259,7 @@ export class MediaManager {
|
|||||||
audioTracks.forEach((track) => {
|
audioTracks.forEach((track) => {
|
||||||
track.enabled = !newMutedState;
|
track.enabled = !newMutedState;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isMicMuted = newMutedState;
|
this.isMicMuted = newMutedState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,8 +300,9 @@ export class MediaManager {
|
|||||||
if (shouldEnable) {
|
if (shouldEnable) {
|
||||||
if (!this.rawMicStream) {
|
if (!this.rawMicStream) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Cannot enable noise reduction — no mic stream yet (will apply on connect)'
|
'Cannot enable noise reduction - no mic stream yet (will apply on connect)'
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { WebRTCLogger } from './webrtc-logger';
|
|||||||
|
|
||||||
/** Name used to register / instantiate the AudioWorklet processor. */
|
/** Name used to register / instantiate the AudioWorklet processor. */
|
||||||
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
|
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
|
||||||
/** RNNoise is trained on 48 kHz audio — the AudioContext must match. */
|
/** RNNoise is trained on 48 kHz audio - the AudioContext must match. */
|
||||||
const RNNOISE_SAMPLE_RATE = 48_000;
|
const RNNOISE_SAMPLE_RATE = 48_000;
|
||||||
/**
|
/**
|
||||||
* Relative path (from the served application root) to the **bundled**
|
* Relative path (from the served application root) to the **bundled**
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ export class PeerConnectionManager {
|
|||||||
* @returns The newly-created {@link PeerData} record.
|
* @returns The newly-created {@link PeerData} record.
|
||||||
*/
|
*/
|
||||||
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
||||||
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
this.logger.info('Creating peer connection', { remotePeerId,
|
||||||
|
isInitiator });
|
||||||
|
|
||||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ export class PeerConnectionManager {
|
|||||||
remotePeerId,
|
remotePeerId,
|
||||||
candidateType: (event.candidate as any)?.type
|
candidateType: (event.candidate as any)?.type
|
||||||
});
|
});
|
||||||
|
|
||||||
this.callbacks.sendRawMessage({
|
this.callbacks.sendRawMessage({
|
||||||
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
||||||
targetUserId: remotePeerId,
|
targetUserId: remotePeerId,
|
||||||
@@ -182,7 +184,8 @@ export class PeerConnectionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
connection.onsignalingstatechange = () => {
|
connection.onsignalingstatechange = () => {
|
||||||
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
|
this.logger.info('signalingstatechange', { remotePeerId,
|
||||||
|
state: connection.signalingState });
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.onnegotiationneeded = () => {
|
connection.onnegotiationneeded = () => {
|
||||||
@@ -302,6 +305,7 @@ export class PeerConnectionManager {
|
|||||||
type: offer.type,
|
type: offer.type,
|
||||||
sdpLength: offer.sdp?.length
|
sdpLength: offer.sdp?.length
|
||||||
});
|
});
|
||||||
|
|
||||||
this.callbacks.sendRawMessage({
|
this.callbacks.sendRawMessage({
|
||||||
type: SIGNALING_TYPE_OFFER,
|
type: SIGNALING_TYPE_OFFER,
|
||||||
targetUserId: remotePeerId,
|
targetUserId: remotePeerId,
|
||||||
@@ -370,11 +374,15 @@ export class PeerConnectionManager {
|
|||||||
const isPolite = localId > fromUserId;
|
const isPolite = localId > fromUserId;
|
||||||
|
|
||||||
if (!isPolite) {
|
if (!isPolite) {
|
||||||
this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId });
|
this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId,
|
||||||
return; // Our offer takes priority – remote will answer it.
|
localId });
|
||||||
|
|
||||||
|
return; // Our offer takes priority - remote will answer it.
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId });
|
this.logger.info('Rolling back local offer (polite side)', { fromUserId,
|
||||||
|
localId });
|
||||||
|
|
||||||
await peerData.connection.setLocalDescription({
|
await peerData.connection.setLocalDescription({
|
||||||
type: 'rollback'
|
type: 'rollback'
|
||||||
} as RTCSessionDescriptionInit);
|
} as RTCSessionDescriptionInit);
|
||||||
@@ -438,6 +446,7 @@ export class PeerConnectionManager {
|
|||||||
type: answer.type,
|
type: answer.type,
|
||||||
sdpLength: answer.sdp?.length
|
sdpLength: answer.sdp?.length
|
||||||
});
|
});
|
||||||
|
|
||||||
this.callbacks.sendRawMessage({
|
this.callbacks.sendRawMessage({
|
||||||
type: SIGNALING_TYPE_ANSWER,
|
type: SIGNALING_TYPE_ANSWER,
|
||||||
targetUserId: fromUserId,
|
targetUserId: fromUserId,
|
||||||
@@ -482,7 +491,7 @@ export class PeerConnectionManager {
|
|||||||
|
|
||||||
peerData.pendingIceCandidates = [];
|
peerData.pendingIceCandidates = [];
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn('Ignoring answer – wrong signaling state', {
|
this.logger.warn('Ignoring answer - wrong signaling state', {
|
||||||
state: peerData.connection.signalingState
|
state: peerData.connection.signalingState
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -559,6 +568,7 @@ export class PeerConnectionManager {
|
|||||||
type: offer.type,
|
type: offer.type,
|
||||||
sdpLength: offer.sdp?.length
|
sdpLength: offer.sdp?.length
|
||||||
});
|
});
|
||||||
|
|
||||||
this.callbacks.sendRawMessage({
|
this.callbacks.sendRawMessage({
|
||||||
type: SIGNALING_TYPE_OFFER,
|
type: SIGNALING_TYPE_OFFER,
|
||||||
targetUserId: peerId,
|
targetUserId: peerId,
|
||||||
@@ -622,16 +632,19 @@ export class PeerConnectionManager {
|
|||||||
* @param message - The parsed JSON payload.
|
* @param message - The parsed JSON payload.
|
||||||
*/
|
*/
|
||||||
private handlePeerMessage(peerId: string, message: any): void {
|
private handlePeerMessage(peerId: string, message: any): void {
|
||||||
this.logger.info('Received P2P message', { peerId, type: message?.type });
|
this.logger.info('Received P2P message', { peerId,
|
||||||
|
type: message?.type });
|
||||||
|
|
||||||
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
||||||
this.sendCurrentStatesToPeer(peerId);
|
this.sendCurrentStatesToPeer(peerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping/pong latency measurement – handled internally, not forwarded
|
// Ping/pong latency measurement - handled internally, not forwarded
|
||||||
if (message.type === P2P_TYPE_PING) {
|
if (message.type === P2P_TYPE_PING) {
|
||||||
this.sendToPeer(peerId, { type: P2P_TYPE_PONG, ts: message.ts } as any);
|
this.sendToPeer(peerId, { type: P2P_TYPE_PONG,
|
||||||
|
ts: message.ts } as any);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,14 +655,16 @@ export class PeerConnectionManager {
|
|||||||
const latencyMs = Math.round(performance.now() - sent);
|
const latencyMs = Math.round(performance.now() - sent);
|
||||||
|
|
||||||
this.peerLatencies.set(peerId, latencyMs);
|
this.peerLatencies.set(peerId, latencyMs);
|
||||||
this.peerLatencyChanged$.next({ peerId, latencyMs });
|
this.peerLatencyChanged$.next({ peerId,
|
||||||
|
latencyMs });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingPings.delete(peerId);
|
this.pendingPings.delete(peerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enriched = { ...message, fromPeerId: peerId };
|
const enriched = { ...message,
|
||||||
|
fromPeerId: peerId };
|
||||||
|
|
||||||
this.messageReceived$.next(enriched);
|
this.messageReceived$.next(enriched);
|
||||||
}
|
}
|
||||||
@@ -682,7 +697,7 @@ export class PeerConnectionManager {
|
|||||||
const peerData = this.activePeerConnections.get(peerId);
|
const peerData = this.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
this.logger.warn('Peer not connected – cannot send', { peerId });
|
this.logger.warn('Peer not connected - cannot send', { peerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +721,7 @@ export class PeerConnectionManager {
|
|||||||
const peerData = this.activePeerConnections.get(peerId);
|
const peerData = this.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
this.logger.warn('Peer not connected – cannot send buffered', { peerId });
|
this.logger.warn('Peer not connected - cannot send buffered', { peerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,7 +763,11 @@ export class PeerConnectionManager {
|
|||||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any);
|
this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
voiceState } as any);
|
||||||
|
|
||||||
this.sendToPeer(peerId, {
|
this.sendToPeer(peerId, {
|
||||||
type: P2P_TYPE_SCREEN_STATE,
|
type: P2P_TYPE_SCREEN_STATE,
|
||||||
oderId,
|
oderId,
|
||||||
@@ -759,10 +778,11 @@ export class PeerConnectionManager {
|
|||||||
|
|
||||||
private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
||||||
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
this.logger.warn('Cannot send states – channel not open', {
|
this.logger.warn('Cannot send states - channel not open', {
|
||||||
remotePeerId,
|
remotePeerId,
|
||||||
state: channel.readyState
|
state: channel.readyState
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +792,11 @@ export class PeerConnectionManager {
|
|||||||
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState }));
|
channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
voiceState }));
|
||||||
|
|
||||||
channel.send(
|
channel.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: P2P_TYPE_SCREEN_STATE,
|
type: P2P_TYPE_SCREEN_STATE,
|
||||||
@@ -781,7 +805,9 @@ export class PeerConnectionManager {
|
|||||||
isScreenSharing: this.callbacks.isScreenSharingActive()
|
isScreenSharing: this.callbacks.isScreenSharingActive()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
|
|
||||||
|
this.logger.info('Sent initial states to channel', { remotePeerId,
|
||||||
|
voiceState });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Failed to send initial states to channel', e);
|
this.logger.error('Failed to send initial states to channel', e);
|
||||||
}
|
}
|
||||||
@@ -794,7 +820,11 @@ export class PeerConnectionManager {
|
|||||||
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
const voiceState = this.callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any);
|
this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
voiceState } as any);
|
||||||
|
|
||||||
this.broadcastMessage({
|
this.broadcastMessage({
|
||||||
type: P2P_TYPE_SCREEN_STATE,
|
type: P2P_TYPE_SCREEN_STATE,
|
||||||
oderId,
|
oderId,
|
||||||
@@ -816,6 +846,7 @@ export class PeerConnectionManager {
|
|||||||
readyState: track.readyState,
|
readyState: track.readyState,
|
||||||
settings
|
settings
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
|
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
|
||||||
|
|
||||||
// Skip inactive video placeholder tracks
|
// Skip inactive video placeholder tracks
|
||||||
@@ -825,6 +856,7 @@ export class PeerConnectionManager {
|
|||||||
enabled: track.enabled,
|
enabled: track.enabled,
|
||||||
readyState: track.readyState
|
readyState: track.readyState
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +875,8 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.remotePeerStreams.set(remotePeerId, compositeStream);
|
this.remotePeerStreams.set(remotePeerId, compositeStream);
|
||||||
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
|
this.remoteStream$.next({ peerId: remotePeerId,
|
||||||
|
stream: compositeStream });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -879,6 +912,7 @@ export class PeerConnectionManager {
|
|||||||
|
|
||||||
peerData.connection.close();
|
peerData.connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activePeerConnections.clear();
|
this.activePeerConnections.clear();
|
||||||
this.peerNegotiationQueue.clear();
|
this.peerNegotiationQueue.clear();
|
||||||
this.peerLatencies.clear();
|
this.peerLatencies.clear();
|
||||||
@@ -924,7 +958,8 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info.reconnectAttempts++;
|
info.reconnectAttempts++;
|
||||||
this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts });
|
this.logger.info('P2P reconnect attempt', { peerId,
|
||||||
|
attempt: info.reconnectAttempts });
|
||||||
|
|
||||||
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
|
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
|
||||||
this.logger.info('P2P reconnect max attempts reached', { peerId });
|
this.logger.info('P2P reconnect max attempts reached', { peerId });
|
||||||
@@ -934,7 +969,7 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.callbacks.isSignalingConnected()) {
|
if (!this.callbacks.isSignalingConnected()) {
|
||||||
this.logger.info('Skipping P2P reconnect – no signaling connection', { peerId });
|
this.logger.info('Skipping P2P reconnect - no signaling connection', { peerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,6 +1031,7 @@ export class PeerConnectionManager {
|
|||||||
this.connectedPeersList = this.connectedPeersList.filter(
|
this.connectedPeersList = this.connectedPeersList.filter(
|
||||||
(connectedId) => connectedId !== peerId
|
(connectedId) => connectedId !== peerId
|
||||||
);
|
);
|
||||||
|
|
||||||
this.connectedPeersChanged$.next(this.connectedPeersList);
|
this.connectedPeersChanged$.next(this.connectedPeersList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,7 +1083,8 @@ export class PeerConnectionManager {
|
|||||||
this.pendingPings.set(peerId, ts);
|
this.pendingPings.set(peerId, ts);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING, ts }));
|
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_PING,
|
||||||
|
ts }));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,11 +80,13 @@ export class ScreenShareManager {
|
|||||||
const sources = await (window as any).electronAPI.getSources();
|
const sources = await (window as any).electronAPI.getSources();
|
||||||
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
|
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
|
||||||
const electronConstraints: any = {
|
const electronConstraints: any = {
|
||||||
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }
|
video: { mandatory: { chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: screenSource.id } }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeSystemAudio) {
|
if (includeSystemAudio) {
|
||||||
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } };
|
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: screenSource.id } };
|
||||||
} else {
|
} else {
|
||||||
electronConstraints.audio = false;
|
electronConstraints.audio = false;
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,9 @@ export class ScreenShareManager {
|
|||||||
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
|
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
|
||||||
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }
|
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }
|
||||||
},
|
},
|
||||||
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false
|
audio: includeSystemAudio ? { echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false } : false
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SignalingManager {
|
|||||||
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
/** Fires every heartbeat tick – the main service hooks this to broadcast state. */
|
/** Fires every heartbeat tick - the main service hooks this to broadcast state. */
|
||||||
readonly heartbeatTick$ = new Subject<void>();
|
readonly heartbeatTick$ = new Subject<void>();
|
||||||
|
|
||||||
/** Fires whenever a raw signaling message arrives from the server. */
|
/** Fires whenever a raw signaling message arrives from the server. */
|
||||||
@@ -73,14 +73,18 @@ export class SignalingManager {
|
|||||||
|
|
||||||
this.signalingWebSocket.onerror = (error) => {
|
this.signalingWebSocket.onerror = (error) => {
|
||||||
this.logger.error('Signaling socket error', error);
|
this.logger.error('Signaling socket error', error);
|
||||||
this.connectionStatus$.next({ connected: false, errorMessage: 'Connection to signaling server failed' });
|
this.connectionStatus$.next({ connected: false,
|
||||||
|
errorMessage: 'Connection to signaling server failed' });
|
||||||
|
|
||||||
observer.error(error);
|
observer.error(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.signalingWebSocket.onclose = () => {
|
this.signalingWebSocket.onclose = () => {
|
||||||
this.logger.info('Disconnected from signaling server');
|
this.logger.info('Disconnected from signaling server');
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' });
|
this.connectionStatus$.next({ connected: false,
|
||||||
|
errorMessage: 'Disconnected from signaling server' });
|
||||||
|
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -118,7 +122,9 @@ export class SignalingManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() };
|
const fullMessage: SignalingMessage = { ...message,
|
||||||
|
from: localPeerId,
|
||||||
|
timestamp: Date.now() };
|
||||||
|
|
||||||
this.signalingWebSocket!.send(JSON.stringify(fullMessage));
|
this.signalingWebSocket!.send(JSON.stringify(fullMessage));
|
||||||
}
|
}
|
||||||
@@ -159,25 +165,31 @@ export class SignalingManager {
|
|||||||
const credentials = this.getLastIdentify();
|
const credentials = this.getLastIdentify();
|
||||||
|
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName });
|
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||||
|
oderId: credentials.oderId,
|
||||||
|
displayName: credentials.displayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberIds = this.getMemberServerIds();
|
const memberIds = this.getMemberServerIds();
|
||||||
|
|
||||||
if (memberIds.size > 0) {
|
if (memberIds.size > 0) {
|
||||||
memberIds.forEach((serverId) => {
|
memberIds.forEach((serverId) => {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
serverId });
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastJoined = this.getLastJoinedServer();
|
const lastJoined = this.getLastJoinedServer();
|
||||||
|
|
||||||
if (lastJoined) {
|
if (lastJoined) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||||
|
serverId: lastJoined.serverId });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const lastJoined = this.getLastJoinedServer();
|
const lastJoined = this.getLastJoinedServer();
|
||||||
|
|
||||||
if (lastJoined) {
|
if (lastJoined) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
serverId: lastJoined.serverId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable id-length, max-statements-per-line */
|
/* eslint-disable id-length, max-statements-per-line */
|
||||||
/**
|
/**
|
||||||
* VoiceLevelingManager — manages per-speaker automatic gain control
|
* VoiceLevelingManager - manages per-speaker automatic gain control
|
||||||
* pipelines for remote voice streams.
|
* pipelines for remote voice streams.
|
||||||
*
|
*
|
||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
* ↓
|
* ↓
|
||||||
* MediaStreamSource (AudioContext)
|
* MediaStreamSource (AudioContext)
|
||||||
* ↓
|
* ↓
|
||||||
* AudioWorkletNode (VoiceLevelingProcessor — per-speaker AGC)
|
* AudioWorkletNode (VoiceLevelingProcessor - per-speaker AGC)
|
||||||
* ↓
|
* ↓
|
||||||
* GainNode (post fine-tuning — master volume knob)
|
* GainNode (post fine-tuning - master volume knob)
|
||||||
* ↓
|
* ↓
|
||||||
* MediaStreamDestination → leveled MediaStream
|
* MediaStreamDestination → leveled MediaStream
|
||||||
*
|
*
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
* for browsers that don't support AudioWorklet or SharedArrayBuffer.
|
* for browsers that don't support AudioWorklet or SharedArrayBuffer.
|
||||||
*
|
*
|
||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
* DESIGN — mirrors the NoiseReductionManager pattern
|
* DESIGN - mirrors the NoiseReductionManager pattern
|
||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
*
|
*
|
||||||
* • `enable(peerId, rawStream)` builds the pipeline and returns a
|
* • `enable(peerId, rawStream)` builds the pipeline and returns a
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
*
|
*
|
||||||
* The calling component keeps a reference to the original raw stream
|
* The calling component keeps a reference to the original raw stream
|
||||||
* and swaps the Audio element's `srcObject` between the raw stream
|
* and swaps the Audio element's `srcObject` between the raw stream
|
||||||
* and the leveled stream when the user toggles the feature — exactly
|
* and the leveled stream when the user toggles the feature - exactly
|
||||||
* like noise reduction does for the local mic.
|
* like noise reduction does for the local mic.
|
||||||
*
|
*
|
||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -90,7 +90,7 @@ interface SpeakerPipeline {
|
|||||||
|
|
||||||
/** AudioWorklet module path (served from public/). */
|
/** AudioWorklet module path (served from public/). */
|
||||||
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
|
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
|
||||||
/** Processor name — must match `registerProcessor` in the worklet. */
|
/** Processor name - must match `registerProcessor` in the worklet. */
|
||||||
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
|
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────── */
|
||||||
@@ -134,7 +134,9 @@ export class VoiceLevelingManager {
|
|||||||
* Only provided keys are updated; the rest stay unchanged.
|
* Only provided keys are updated; the rest stay unchanged.
|
||||||
*/
|
*/
|
||||||
updateSettings(partial: Partial<VoiceLevelingSettings>): void {
|
updateSettings(partial: Partial<VoiceLevelingSettings>): void {
|
||||||
this._settings = { ...this._settings, ...partial };
|
this._settings = { ...this._settings,
|
||||||
|
...partial };
|
||||||
|
|
||||||
this.pipelines.forEach((p) => this._pushSettingsToPipeline(p));
|
this.pipelines.forEach((p) => this._pushSettingsToPipeline(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +182,7 @@ export class VoiceLevelingManager {
|
|||||||
peerId,
|
peerId,
|
||||||
fallback: pipeline.isFallback
|
fallback: pipeline.isFallback
|
||||||
});
|
});
|
||||||
|
|
||||||
return pipeline.destination.stream;
|
return pipeline.destination.stream;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err);
|
this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err);
|
||||||
|
|||||||
@@ -46,9 +46,14 @@ export class WebRTCLogger {
|
|||||||
settings
|
settings
|
||||||
});
|
});
|
||||||
|
|
||||||
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind }));
|
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id,
|
||||||
track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind }));
|
kind: track.kind }));
|
||||||
track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id, kind: track.kind }));
|
|
||||||
|
track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id,
|
||||||
|
kind: track.kind }));
|
||||||
|
|
||||||
|
track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id,
|
||||||
|
kind: track.kind }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Log a MediaStream summary and attach diagnostics to every track. */
|
/** Log a MediaStream summary and attach diagnostics to every track. */
|
||||||
@@ -65,8 +70,10 @@ export class WebRTCLogger {
|
|||||||
id: (stream as any).id,
|
id: (stream as any).id,
|
||||||
audioTrackCount: audioTracks.length,
|
audioTrackCount: audioTracks.length,
|
||||||
videoTrackCount: videoTracks.length,
|
videoTrackCount: videoTracks.length,
|
||||||
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind }))
|
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id,
|
||||||
|
kind: streamTrack.kind }))
|
||||||
});
|
});
|
||||||
|
|
||||||
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
||||||
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
|||||||
|
|
||||||
/** Data channel name used for P2P chat */
|
/** Data channel name used for P2P chat */
|
||||||
export const DATA_CHANNEL_LABEL = 'chat';
|
export const DATA_CHANNEL_LABEL = 'chat';
|
||||||
/** High-water mark (bytes) – pause sending when buffered amount exceeds this */
|
/** High-water mark (bytes) - pause sending when buffered amount exceeds this */
|
||||||
export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
|
export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||||
/** Low-water mark (bytes) – resume sending once buffered amount drops below this */
|
/** Low-water mark (bytes) - resume sending once buffered amount drops below this */
|
||||||
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||||
|
|
||||||
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
<div class="h-full flex flex-col bg-card">
|
<div class="h-full flex flex-col bg-card">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
<div class="p-4 border-b border-border flex items-center gap-2">
|
||||||
<ng-icon name="lucideShield" class="w-5 h-5 text-primary" />
|
<ng-icon
|
||||||
|
name="lucideShield"
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
/>
|
||||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,7 +20,10 @@
|
|||||||
[class.border-primary]="activeTab() === 'settings'"
|
[class.border-primary]="activeTab() === 'settings'"
|
||||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
<ng-icon
|
||||||
|
name="lucideSettings"
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
/>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -29,7 +35,10 @@
|
|||||||
[class.border-primary]="activeTab() === 'members'"
|
[class.border-primary]="activeTab() === 'members'"
|
||||||
[class.text-muted-foreground]="activeTab() !== 'members'"
|
[class.text-muted-foreground]="activeTab() !== 'members'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
<ng-icon
|
||||||
|
name="lucideUsers"
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
/>
|
||||||
Members
|
Members
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -41,7 +50,10 @@
|
|||||||
[class.border-primary]="activeTab() === 'bans'"
|
[class.border-primary]="activeTab() === 'bans'"
|
||||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
|
<ng-icon
|
||||||
|
name="lucideBan"
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
/>
|
||||||
Bans
|
Bans
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -53,7 +65,10 @@
|
|||||||
[class.border-primary]="activeTab() === 'permissions'"
|
[class.border-primary]="activeTab() === 'permissions'"
|
||||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
|
<ng-icon
|
||||||
|
name="lucideShield"
|
||||||
|
class="w-4 h-4 inline mr-1"
|
||||||
|
/>
|
||||||
Perms
|
Perms
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +82,11 @@
|
|||||||
|
|
||||||
<!-- Room Name -->
|
<!-- Room Name -->
|
||||||
<div>
|
<div>
|
||||||
<label for="room-name-input" class="block text-sm text-muted-foreground mb-1">Room Name</label>
|
<label
|
||||||
|
for="room-name-input"
|
||||||
|
class="block text-sm text-muted-foreground mb-1"
|
||||||
|
>Room Name</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="room-name-input"
|
id="room-name-input"
|
||||||
@@ -78,7 +97,11 @@
|
|||||||
|
|
||||||
<!-- Room Description -->
|
<!-- Room Description -->
|
||||||
<div>
|
<div>
|
||||||
<label for="room-description-input" class="block text-sm text-muted-foreground mb-1">Description</label>
|
<label
|
||||||
|
for="room-description-input"
|
||||||
|
class="block text-sm text-muted-foreground mb-1"
|
||||||
|
>Description</label
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="room-description-input"
|
id="room-description-input"
|
||||||
[(ngModel)]="roomDescription"
|
[(ngModel)]="roomDescription"
|
||||||
@@ -103,16 +126,26 @@
|
|||||||
[class.text-muted-foreground]="!isPrivate()"
|
[class.text-muted-foreground]="!isPrivate()"
|
||||||
>
|
>
|
||||||
@if (isPrivate()) {
|
@if (isPrivate()) {
|
||||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideUnlock"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Max Users -->
|
<!-- Max Users -->
|
||||||
<div>
|
<div>
|
||||||
<label for="max-users-input" class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
|
<label
|
||||||
|
for="max-users-input"
|
||||||
|
class="block text-sm text-muted-foreground mb-1"
|
||||||
|
>Max Users (0 = unlimited)</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="max-users-input"
|
id="max-users-input"
|
||||||
@@ -128,7 +161,10 @@
|
|||||||
(click)="saveSettings()"
|
(click)="saveSettings()"
|
||||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideCheck"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -140,7 +176,10 @@
|
|||||||
(click)="confirmDeleteRoom()"
|
(click)="confirmDeleteRoom()"
|
||||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Delete Room
|
Delete Room
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,13 +190,14 @@
|
|||||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
||||||
|
|
||||||
@if (membersFiltered().length === 0) {
|
@if (membersFiltered().length === 0) {
|
||||||
<p class="text-sm text-muted-foreground text-center py-8">
|
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
||||||
No other members online
|
|
||||||
</p>
|
|
||||||
} @else {
|
} @else {
|
||||||
@for (user of membersFiltered(); track user.id) {
|
@for (user of membersFiltered(); track user.id) {
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||||
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
|
<app-user-avatar
|
||||||
|
[name]="user.displayName || '?'"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
||||||
@@ -188,7 +228,10 @@
|
|||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title="Kick"
|
title="Kick"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideUserX"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -196,7 +239,10 @@
|
|||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title="Ban"
|
title="Ban"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideBan"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -210,9 +256,7 @@
|
|||||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||||
|
|
||||||
@if (bannedUsers().length === 0) {
|
@if (bannedUsers().length === 0) {
|
||||||
<p class="text-sm text-muted-foreground text-center py-8">
|
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
||||||
No banned users
|
|
||||||
</p>
|
|
||||||
} @else {
|
} @else {
|
||||||
@for (ban of bannedUsers(); track ban.oderId) {
|
@for (ban of bannedUsers(); track ban.oderId) {
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||||
@@ -224,14 +268,10 @@
|
|||||||
{{ ban.displayName || 'Unknown User' }}
|
{{ ban.displayName || 'Unknown User' }}
|
||||||
</p>
|
</p>
|
||||||
@if (ban.reason) {
|
@if (ban.reason) {
|
||||||
<p class="text-xs text-muted-foreground truncate">
|
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||||
Reason: {{ ban.reason }}
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
@if (ban.expiresAt) {
|
@if (ban.expiresAt) {
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
|
||||||
</p>
|
|
||||||
} @else {
|
} @else {
|
||||||
<p class="text-xs text-destructive">Permanent</p>
|
<p class="text-xs text-destructive">Permanent</p>
|
||||||
}
|
}
|
||||||
@@ -241,7 +281,10 @@
|
|||||||
(click)="unbanUser(ban)"
|
(click)="unbanUser(ban)"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -363,7 +406,10 @@
|
|||||||
(click)="savePermissions()"
|
(click)="savePermissions()"
|
||||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideCheck"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Save Permissions
|
Save Permissions
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -35,7 +39,13 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-panel',
|
selector: 'app-admin-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent,
|
||||||
|
ConfirmDialogComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideShield,
|
lucideShield,
|
||||||
@@ -181,7 +191,8 @@ export class AdminPanelComponent {
|
|||||||
formatExpiry(timestamp: number): string {
|
formatExpiry(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
|
||||||
|
minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Members tab: get all users except self
|
// Members tab: get all users except self
|
||||||
@@ -194,7 +205,9 @@ export class AdminPanelComponent {
|
|||||||
|
|
||||||
/** Change a member's role and broadcast the update to all peers. */
|
/** Change a member's role and broadcast the update to all peers. */
|
||||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
||||||
|
role }));
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'role-change',
|
type: 'role-change',
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<div class="h-full grid place-items-center bg-background">
|
<div class="h-full grid place-items-center bg-background">
|
||||||
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<ng-icon name="lucideLogIn" class="w-5 h-5 text-primary" />
|
<ng-icon
|
||||||
|
name="lucideLogIn"
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
/>
|
||||||
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="login-username" class="block text-xs text-muted-foreground mb-1">Username</label>
|
<label
|
||||||
|
for="login-username"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Username</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
[(ngModel)]="username"
|
[(ngModel)]="username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -16,7 +23,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="login-password" class="block text-xs text-muted-foreground mb-1">Password</label>
|
<label
|
||||||
|
for="login-password"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
[(ngModel)]="password"
|
[(ngModel)]="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -25,7 +36,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="login-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
|
<label
|
||||||
|
for="login-server"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Server App</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
[(ngModel)]="serverId"
|
[(ngModel)]="serverId"
|
||||||
id="login-server"
|
id="login-server"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
viewProviders: [provideIcons({ lucideLogIn })],
|
viewProviders: [provideIcons({ lucideLogIn })],
|
||||||
templateUrl: './login.component.html'
|
templateUrl: './login.component.html'
|
||||||
})
|
})
|
||||||
@@ -43,7 +51,9 @@ export class LoginComponent {
|
|||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||||
|
|
||||||
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
|
this.auth.login({ username: this.username.trim(),
|
||||||
|
password: this.password,
|
||||||
|
serverId: sid }).subscribe({
|
||||||
next: (resp) => {
|
next: (resp) => {
|
||||||
if (sid)
|
if (sid)
|
||||||
this.serversSvc.setActiveServer(sid);
|
this.serversSvc.setActiveServer(sid);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<div class="h-full grid place-items-center bg-background">
|
<div class="h-full grid place-items-center bg-background">
|
||||||
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<ng-icon name="lucideUserPlus" class="w-5 h-5 text-primary" />
|
<ng-icon
|
||||||
|
name="lucideUserPlus"
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
/>
|
||||||
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="register-username" class="block text-xs text-muted-foreground mb-1">Username</label>
|
<label
|
||||||
|
for="register-username"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Username</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
[(ngModel)]="username"
|
[(ngModel)]="username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -16,7 +23,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="register-display-name" class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
<label
|
||||||
|
for="register-display-name"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Display Name</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
[(ngModel)]="displayName"
|
[(ngModel)]="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -25,7 +36,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="register-password" class="block text-xs text-muted-foreground mb-1">Password</label>
|
<label
|
||||||
|
for="register-password"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
[(ngModel)]="password"
|
[(ngModel)]="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -34,7 +49,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="register-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
|
<label
|
||||||
|
for="register-server"
|
||||||
|
class="block text-xs text-muted-foreground mb-1"
|
||||||
|
>Server App</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
[(ngModel)]="serverId"
|
[(ngModel)]="serverId"
|
||||||
id="register-server"
|
id="register-server"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register',
|
selector: 'app-register',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||||
templateUrl: './register.component.html'
|
templateUrl: './register.component.html'
|
||||||
})
|
})
|
||||||
@@ -44,7 +52,10 @@ export class RegisterComponent {
|
|||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||||
|
|
||||||
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
|
this.auth.register({ username: this.username.trim(),
|
||||||
|
password: this.password,
|
||||||
|
displayName: this.displayName.trim(),
|
||||||
|
serverId: sid }).subscribe({
|
||||||
next: (resp) => {
|
next: (resp) => {
|
||||||
if (sid)
|
if (sid)
|
||||||
this.serversSvc.setActiveServer(sid);
|
this.serversSvc.setActiveServer(sid);
|
||||||
|
|||||||
@@ -2,16 +2,33 @@
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
@if (user()) {
|
@if (user()) {
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<ng-icon name="lucideUser" class="w-4 h-4 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideUser"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
|
<button
|
||||||
<ng-icon name="lucideLogIn" class="w-4 h-4" />
|
type="button"
|
||||||
|
(click)="goto('login')"
|
||||||
|
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideLogIn"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
<button type="button" (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
|
<button
|
||||||
<ng-icon name="lucideUserPlus" class="w-4 h-4" />
|
type="button"
|
||||||
|
(click)="goto('register')"
|
||||||
|
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideUserPlus"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Register
|
Register
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
import {
|
||||||
|
lucideUser,
|
||||||
|
lucideLogIn,
|
||||||
|
lucideUserPlus
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon],
|
||||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
viewProviders: [
|
||||||
|
provideIcons({ lucideUser,
|
||||||
|
lucideLogIn,
|
||||||
|
lucideUserPlus })
|
||||||
|
],
|
||||||
templateUrl: './user-bar.component.html'
|
templateUrl: './user-bar.component.html'
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
|
|||||||
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 */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
AfterViewChecked,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
ChangeDetectorRef
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -21,7 +33,11 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||||
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
|
import {
|
||||||
|
selectAllMessages,
|
||||||
|
selectMessagesLoading,
|
||||||
|
selectMessagesSyncing
|
||||||
|
} from '../../../store/messages/messages.selectors';
|
||||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||||
import { Message } from '../../../core/models';
|
import { Message } from '../../../core/models';
|
||||||
@@ -36,12 +52,30 @@ import remarkParse from 'remark-parse';
|
|||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
import { ChatMarkdownService } from './services/chat-markdown.service';
|
import { ChatMarkdownService } from './services/chat-markdown.service';
|
||||||
|
|
||||||
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
const COMMON_EMOJIS = [
|
||||||
|
'👍',
|
||||||
|
'❤️',
|
||||||
|
'😂',
|
||||||
|
'😮',
|
||||||
|
'😢',
|
||||||
|
'🎉',
|
||||||
|
'🔥',
|
||||||
|
'👀'
|
||||||
|
];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-messages',
|
selector: 'app-chat-messages',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
ContextMenuComponent,
|
||||||
|
UserAvatarComponent,
|
||||||
|
TypingIndicatorComponent,
|
||||||
|
RemarkModule,
|
||||||
|
MermaidComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideSend,
|
lucideSend,
|
||||||
@@ -105,7 +139,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Paginated view — only the most recent `displayLimit` messages */
|
/** Paginated view - only the most recent `displayLimit` messages */
|
||||||
messages = computed(() => {
|
messages = computed(() => {
|
||||||
const all = this.allChannelMessages();
|
const all = this.allChannelMessages();
|
||||||
const limit = this.displayLimit();
|
const limit = this.displayLimit();
|
||||||
@@ -391,7 +425,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||||
|
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
el.scrollIntoView({ behavior: 'smooth',
|
||||||
|
block: 'center' });
|
||||||
|
|
||||||
el.classList.add('bg-primary/10');
|
el.classList.add('bg-primary/10');
|
||||||
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
|
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
|
||||||
}
|
}
|
||||||
@@ -406,7 +442,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
|
|
||||||
/** Add a reaction emoji to a message. */
|
/** Add a reaction emoji to a message. */
|
||||||
addReaction(messageId: string, emoji: string): void {
|
addReaction(messageId: string, emoji: string): void {
|
||||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||||
|
emoji }));
|
||||||
|
|
||||||
this.showEmojiPicker.set(null);
|
this.showEmojiPicker.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,9 +461,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasReacted) {
|
if (hasReacted) {
|
||||||
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
|
this.store.dispatch(MessagesActions.removeReaction({ messageId,
|
||||||
|
emoji }));
|
||||||
} else {
|
} else {
|
||||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||||
|
emoji }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +480,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
const currentUserId = this.currentUser()?.id;
|
const currentUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
message.reactions.forEach((reaction) => {
|
message.reactions.forEach((reaction) => {
|
||||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
const existing = groups.get(reaction.emoji) || { count: 0,
|
||||||
|
hasCurrentUser: false };
|
||||||
|
|
||||||
groups.set(reaction.emoji, {
|
groups.set(reaction.emoji, {
|
||||||
count: existing.count + 1,
|
count: existing.count + 1,
|
||||||
@@ -458,7 +499,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
formatTimestamp(timestamp: number): string {
|
formatTimestamp(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date(this.nowRef);
|
const now = new Date(this.nowRef);
|
||||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const time = date.toLocaleTimeString([], { hour: '2-digit',
|
||||||
|
minute: '2-digit' });
|
||||||
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||||
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||||
@@ -470,7 +512,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
} else if (dayDiff < 7) {
|
} else if (dayDiff < 7) {
|
||||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||||
} else {
|
} else {
|
||||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
return date.toLocaleDateString([], { month: 'short',
|
||||||
|
day: 'numeric' }) + ' ' + time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +556,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.initialScrollObserver = new MutationObserver(() => {
|
this.initialScrollObserver = new MutationObserver(() => {
|
||||||
requestAnimationFrame(snap);
|
requestAnimationFrame(snap);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.initialScrollObserver.observe(el, {
|
this.initialScrollObserver.observe(el, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
@@ -550,7 +594,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
const el = this.messagesContainer.nativeElement;
|
const el = this.messagesContainer.nativeElement;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
el.scrollTo({ top: el.scrollHeight,
|
||||||
|
behavior: 'smooth' });
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback if smooth not supported
|
// Fallback if smooth not supported
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
@@ -591,7 +636,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.stopInitialScrollWatch();
|
this.stopInitialScrollWatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infinite scroll upwards — load older messages when near the top
|
// Infinite scroll upwards - load older messages when near the top
|
||||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||||
this.loadMore();
|
this.loadMore();
|
||||||
}
|
}
|
||||||
@@ -640,7 +685,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
private getSelection(): { start: number; end: number } {
|
private getSelection(): { start: number; end: number } {
|
||||||
const el = this.messageInputRef?.nativeElement;
|
const el = this.messageInputRef?.nativeElement;
|
||||||
|
|
||||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
return { start: el?.selectionStart ?? this.messageContent.length,
|
||||||
|
end: el?.selectionEnd ?? this.messageContent.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
private setSelection(start: number, end: number): void {
|
private setSelection(start: number, end: number): void {
|
||||||
@@ -772,7 +818,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
|
|
||||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||||
formatBytes(bytes: number): string {
|
formatBytes(bytes: number): string {
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
const units = [
|
||||||
|
'B',
|
||||||
|
'KB',
|
||||||
|
'MB',
|
||||||
|
'GB'
|
||||||
|
];
|
||||||
|
|
||||||
let size = bytes;
|
let size = bytes;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -787,7 +838,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
if (!bps || bps <= 0)
|
if (!bps || bps <= 0)
|
||||||
return '0 B/s';
|
return '0 B/s';
|
||||||
|
|
||||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
const units = [
|
||||||
|
'B/s',
|
||||||
|
'KB/s',
|
||||||
|
'MB/s',
|
||||||
|
'GB/s'
|
||||||
|
];
|
||||||
|
|
||||||
let speed = bps;
|
let speed = bps;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -855,7 +911,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
|
this.imageContextMenu.set({ x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
attachment: att });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the image context menu. */
|
/** Close the image context menu. */
|
||||||
@@ -876,9 +934,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
// Convert to PNG for clipboard compatibility
|
// Convert to PNG for clipboard compatibility
|
||||||
const pngBlob = await this.convertToPng(blob);
|
const pngBlob = await this.convertToPng(blob);
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||||
new ClipboardItem({ 'image/png': pngBlob })
|
|
||||||
]);
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Failed to copy image to clipboard
|
// Failed to copy image to clipboard
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export class ChatMarkdownService {
|
|||||||
const newText = `${before}${token}${selected}${token}${after}`;
|
const newText = `${before}${token}${selected}${token}${after}`;
|
||||||
const cursor = before.length + token.length + selected.length + token.length;
|
const cursor = before.length + token.length + selected.length + token.length;
|
||||||
|
|
||||||
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
|
return { text: newText,
|
||||||
|
selectionStart: cursor,
|
||||||
|
selectionEnd: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
||||||
@@ -34,7 +36,9 @@ export class ChatMarkdownService {
|
|||||||
const text = `${before}${newSelected}${after}`;
|
const text = `${before}${newSelected}${after}`;
|
||||||
const cursor = before.length + newSelected.length;
|
const cursor = before.length + newSelected.length;
|
||||||
|
|
||||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
return { text,
|
||||||
|
selectionStart: cursor,
|
||||||
|
selectionEnd: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
|
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
|
||||||
@@ -49,7 +53,9 @@ export class ChatMarkdownService {
|
|||||||
const text = `${before}${block}${after}`;
|
const text = `${before}${block}${after}`;
|
||||||
const cursor = before.length + block.length;
|
const cursor = before.length + block.length;
|
||||||
|
|
||||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
return { text,
|
||||||
|
selectionStart: cursor,
|
||||||
|
selectionEnd: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
||||||
@@ -62,7 +68,9 @@ export class ChatMarkdownService {
|
|||||||
const text = `${before}${newSelected}${after}`;
|
const text = `${before}${newSelected}${after}`;
|
||||||
const cursor = before.length + newSelected.length;
|
const cursor = before.length + newSelected.length;
|
||||||
|
|
||||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
return { text,
|
||||||
|
selectionStart: cursor,
|
||||||
|
selectionEnd: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
||||||
@@ -74,7 +82,9 @@ export class ChatMarkdownService {
|
|||||||
const text = `${before}${fenced}${after}`;
|
const text = `${before}${fenced}${after}`;
|
||||||
const cursor = before.length + fenced.length;
|
const cursor = before.length + fenced.length;
|
||||||
|
|
||||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
return { text,
|
||||||
|
selectionStart: cursor,
|
||||||
|
selectionEnd: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
||||||
@@ -87,7 +97,9 @@ export class ChatMarkdownService {
|
|||||||
const cursorStart = before.length + link.length - 1;
|
const cursorStart = before.length + link.length - 1;
|
||||||
|
|
||||||
// Position inside the URL placeholder
|
// Position inside the URL placeholder
|
||||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
return { text,
|
||||||
|
selectionStart: cursorStart - 8,
|
||||||
|
selectionEnd: cursorStart - 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
||||||
@@ -99,7 +111,9 @@ export class ChatMarkdownService {
|
|||||||
const text = `${before}${img}${after}`;
|
const text = `${before}${img}${after}`;
|
||||||
const cursorStart = before.length + img.length - 1;
|
const cursorStart = before.length + img.length - 1;
|
||||||
|
|
||||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
return { text,
|
||||||
|
selectionStart: cursorStart - 8,
|
||||||
|
selectionEnd: cursorStart - 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
||||||
@@ -110,7 +124,9 @@ export class ChatMarkdownService {
|
|||||||
const text = `${before}${hr}${after}`;
|
const text = `${before}${hr}${after}`;
|
||||||
const cursor = before.length + hr.length;
|
const cursor = before.length + hr.length;
|
||||||
|
|
||||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
return { text,
|
||||||
|
selectionStart: cursor,
|
||||||
|
selectionEnd: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
appendImageMarkdown(content: string): string {
|
appendImageMarkdown(content: string): string {
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
||||||
import { Component, inject, signal, DestroyRef } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
DestroyRef
|
||||||
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { merge, interval, filter, map, tap } from 'rxjs';
|
import {
|
||||||
|
merge,
|
||||||
|
interval,
|
||||||
|
filter,
|
||||||
|
map,
|
||||||
|
tap
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
const TYPING_TTL = 3_000;
|
const TYPING_TTL = 3_000;
|
||||||
const PURGE_INTERVAL = 1_000;
|
const PURGE_INTERVAL = 1_000;
|
||||||
|
|||||||
@@ -1,161 +1,202 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b border-border">
|
<div class="p-4 border-b border-border">
|
||||||
<h3 class="font-semibold text-foreground">Members</h3>
|
<h3 class="font-semibold text-foreground">Members</h3>
|
||||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||||
@if (voiceUsers().length > 0) {
|
@if (voiceUsers().length > 0) {
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@for (v of voiceUsers(); track v.id) {
|
@for (v of voiceUsers(); track v.id) {
|
||||||
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
||||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||||
{{ v.displayName }}
|
{{ v.displayName }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User List -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
@for (user of onlineUsers(); track user.id) {
|
||||||
|
<div
|
||||||
|
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||||
|
(click)="toggleUserMenu(user.id)"
|
||||||
|
(keydown.enter)="toggleUserMenu(user.id)"
|
||||||
|
(keydown.space)="toggleUserMenu(user.id)"
|
||||||
|
(keyup.enter)="toggleUserMenu(user.id)"
|
||||||
|
(keyup.space)="toggleUserMenu(user.id)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<!-- Avatar with online indicator -->
|
||||||
|
<div class="relative">
|
||||||
|
<app-user-avatar
|
||||||
|
[name]="user.displayName"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||||
|
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||||
|
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User List -->
|
<!-- User Info -->
|
||||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
<div class="flex-1 min-w-0">
|
||||||
@for (user of onlineUsers(); track user.id) {
|
<div class="flex items-center gap-1">
|
||||||
<div
|
<span class="font-medium text-sm text-foreground truncate">
|
||||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
{{ user.displayName }}
|
||||||
(click)="toggleUserMenu(user.id)"
|
</span>
|
||||||
(keydown.enter)="toggleUserMenu(user.id)"
|
@if (user.isAdmin) {
|
||||||
(keydown.space)="toggleUserMenu(user.id)"
|
<ng-icon
|
||||||
(keyup.enter)="toggleUserMenu(user.id)"
|
name="lucideShield"
|
||||||
(keyup.space)="toggleUserMenu(user.id)"
|
class="w-3 h-3 text-primary"
|
||||||
role="button"
|
/>
|
||||||
tabindex="0"
|
}
|
||||||
>
|
@if (user.isRoomOwner) {
|
||||||
<!-- Avatar with online indicator -->
|
<ng-icon
|
||||||
<div class="relative">
|
name="lucideCrown"
|
||||||
<app-user-avatar [name]="user.displayName" size="sm" />
|
class="w-3 h-3 text-yellow-500"
|
||||||
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
/>
|
||||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
}
|
||||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
</div>
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Info -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="font-medium text-sm text-foreground truncate">
|
|
||||||
{{ user.displayName }}
|
|
||||||
</span>
|
|
||||||
@if (user.isAdmin) {
|
|
||||||
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
|
|
||||||
}
|
|
||||||
@if (user.isRoomOwner) {
|
|
||||||
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Voice/Screen Status -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
@if (user.voiceState?.isSpeaking) {
|
|
||||||
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
|
|
||||||
} @else if (user.voiceState?.isMuted) {
|
|
||||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
|
||||||
} @else if (user.voiceState?.isConnected) {
|
|
||||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (user.screenShareState?.isSharing) {
|
|
||||||
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Menu -->
|
|
||||||
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
|
||||||
<div
|
|
||||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
(keydown)="$event.stopPropagation()"
|
|
||||||
role="menu"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
@if (user.voiceState?.isConnected) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="muteUser(user)"
|
|
||||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
@if (user.voiceState?.isMutedByAdmin) {
|
|
||||||
<ng-icon name="lucideVolume2" class="w-4 h-4" />
|
|
||||||
<span>Unmute</span>
|
|
||||||
} @else {
|
|
||||||
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
|
|
||||||
<span>Mute</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="kickUser(user)"
|
|
||||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
|
||||||
<span>Kick</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="banUser(user)"
|
|
||||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
|
||||||
<span>Ban</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (onlineUsers().length === 0) {
|
|
||||||
<div class="text-center py-8 text-muted-foreground text-sm">
|
|
||||||
No users online
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ban Dialog -->
|
<!-- Voice/Screen Status -->
|
||||||
@if (showBanDialog()) {
|
<div class="flex items-center gap-1">
|
||||||
<app-confirm-dialog
|
@if (user.voiceState?.isSpeaking) {
|
||||||
title="Ban User"
|
<ng-icon
|
||||||
confirmLabel="Ban User"
|
name="lucideMic"
|
||||||
variant="danger"
|
class="w-4 h-4 text-green-500 animate-pulse"
|
||||||
[widthClass]="'w-96 max-w-[90vw]'"
|
|
||||||
(confirmed)="confirmBan()"
|
|
||||||
(cancelled)="closeBanDialog()"
|
|
||||||
>
|
|
||||||
<p class="mb-4">
|
|
||||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="ban-reason-input" class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="banReason"
|
|
||||||
placeholder="Enter ban reason..."
|
|
||||||
id="ban-reason-input"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
} @else if (user.voiceState?.isMuted) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMicOff"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
} @else if (user.voiceState?.isConnected) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
<div>
|
@if (user.screenShareState?.isSharing) {
|
||||||
<label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
<ng-icon
|
||||||
<select
|
name="lucideMonitor"
|
||||||
[(ngModel)]="banDuration"
|
class="w-4 h-4 text-primary"
|
||||||
id="ban-duration-select"
|
/>
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(keydown)="$event.stopPropagation()"
|
||||||
|
role="menu"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
@if (user.voiceState?.isConnected) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="muteUser(user)"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
@if (user.voiceState?.isMutedByAdmin) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideVolume2"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>Unmute</span>
|
||||||
|
} @else {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideVolumeX"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>Mute</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="kickUser(user)"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||||
>
|
>
|
||||||
<option value="3600000">1 hour</option>
|
<ng-icon
|
||||||
<option value="86400000">1 day</option>
|
name="lucideUserX"
|
||||||
<option value="604800000">1 week</option>
|
class="w-4 h-4"
|
||||||
<option value="2592000000">30 days</option>
|
/>
|
||||||
<option value="0">Permanent</option>
|
<span>Kick</span>
|
||||||
</select>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="banUser(user)"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideBan"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>Ban</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</app-confirm-dialog>
|
}
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (onlineUsers().length === 0) {
|
||||||
|
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ban Dialog -->
|
||||||
|
@if (showBanDialog()) {
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Ban User"
|
||||||
|
confirmLabel="Ban User"
|
||||||
|
variant="danger"
|
||||||
|
[widthClass]="'w-96 max-w-[90vw]'"
|
||||||
|
(confirmed)="confirmBan()"
|
||||||
|
(cancelled)="closeBanDialog()"
|
||||||
|
>
|
||||||
|
<p class="mb-4">
|
||||||
|
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="ban-reason-input"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Reason (optional)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="banReason"
|
||||||
|
placeholder="Enter ban reason..."
|
||||||
|
id="ban-reason-input"
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="ban-duration-select"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Duration</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
[(ngModel)]="banDuration"
|
||||||
|
id="ban-duration-select"
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="3600000">1 hour</option>
|
||||||
|
<option value="86400000">1 day</option>
|
||||||
|
<option value="604800000">1 week</option>
|
||||||
|
<option value="2592000000">30 days</option>
|
||||||
|
<option value="0">Permanent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</app-confirm-dialog>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal, computed } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
computed
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -29,7 +34,13 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-list',
|
selector: 'app-user-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent,
|
||||||
|
ConfirmDialogComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMic,
|
lucideMic,
|
||||||
|
|||||||
@@ -29,7 +29,10 @@
|
|||||||
<!-- No Room Selected -->
|
<!-- No Room Selected -->
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<div class="flex-1 flex items-center justify-center">
|
||||||
<div class="text-center text-muted-foreground">
|
<div class="text-center text-muted-foreground">
|
||||||
<ng-icon name="lucideHash" class="w-16 h-16 mx-auto mb-4 opacity-30" />
|
<ng-icon
|
||||||
|
name="lucideHash"
|
||||||
|
class="w-16 h-16 mx-auto mb-4 opacity-30"
|
||||||
|
/>
|
||||||
<h2 class="text-xl font-medium mb-2">No room selected</h2>
|
<h2 class="text-xl font-medium mb-2">No room selected</h2>
|
||||||
<p class="text-sm">Select or create a room to start chatting</p>
|
<p class="text-sm">Select or create a room to start chatting</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
[class.text-muted-foreground]="activeTab() !== 'channels'"
|
[class.text-muted-foreground]="activeTab() !== 'channels'"
|
||||||
[class.hover:text-foreground]="activeTab() !== 'channels'"
|
[class.hover:text-foreground]="activeTab() !== 'channels'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideHash" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideHash"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
<span>Channels</span>
|
<span>Channels</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -25,11 +28,12 @@
|
|||||||
[class.text-muted-foreground]="activeTab() !== 'users'"
|
[class.text-muted-foreground]="activeTab() !== 'users'"
|
||||||
[class.hover:text-foreground]="activeTab() !== 'users'"
|
[class.hover:text-foreground]="activeTab() !== 'users'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideUsers" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideUsers"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{
|
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
|
||||||
onlineUsers().length
|
|
||||||
}}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,16 +44,17 @@
|
|||||||
<!-- Text Channels -->
|
<!-- Text Channels -->
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex items-center justify-between mb-2 px-1">
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
|
||||||
Text Channels
|
|
||||||
</h4>
|
|
||||||
@if (canManageChannels()) {
|
@if (canManageChannels()) {
|
||||||
<button
|
<button
|
||||||
(click)="createChannel('text')"
|
(click)="createChannel('text')"
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
title="Create Text Channel"
|
title="Create Text Channel"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,16 +94,17 @@
|
|||||||
<!-- Voice Channels -->
|
<!-- Voice Channels -->
|
||||||
<div class="p-3 pt-0">
|
<div class="p-3 pt-0">
|
||||||
<div class="flex items-center justify-between mb-2 px-1">
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
|
||||||
Voice Channels
|
|
||||||
</h4>
|
|
||||||
@if (canManageChannels()) {
|
@if (canManageChannels()) {
|
||||||
<button
|
<button
|
||||||
(click)="createChannel('voice')"
|
(click)="createChannel('voice')"
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
title="Create Voice Channel"
|
title="Create Voice Channel"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +122,10 @@
|
|||||||
[disabled]="!voiceEnabled()"
|
[disabled]="!voiceEnabled()"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2 text-foreground/80">
|
<span class="flex items-center gap-2 text-foreground/80">
|
||||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
@if (renamingChannelId() === ch.id) {
|
@if (renamingChannelId() === ch.id) {
|
||||||
<input
|
<input
|
||||||
#renameInput
|
#renameInput
|
||||||
@@ -155,17 +164,13 @@
|
|||||||
: 'ring-2 ring-green-500/40'
|
: 'ring-2 ring-green-500/40'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-foreground/80 truncate flex-1">{{
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
u.displayName
|
|
||||||
}}</span>
|
|
||||||
<!-- Ping latency indicator -->
|
<!-- Ping latency indicator -->
|
||||||
@if (u.id !== currentUser()?.id) {
|
@if (u.id !== currentUser()?.id) {
|
||||||
<span
|
<span
|
||||||
class="w-2 h-2 rounded-full shrink-0"
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
[class]="getPingColorClass(u)"
|
[class]="getPingColorClass(u)"
|
||||||
[title]="
|
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
|
||||||
getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'
|
|
||||||
"
|
|
||||||
></span>
|
></span>
|
||||||
}
|
}
|
||||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||||
@@ -177,7 +182,10 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (u.voiceState?.isMuted) {
|
@if (u.voiceState?.isMuted) {
|
||||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideMicOff"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -196,9 +204,7 @@
|
|||||||
<!-- Current User (You) -->
|
<!-- Current User (You) -->
|
||||||
@if (currentUser()) {
|
@if (currentUser()) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||||
You
|
|
||||||
</h4>
|
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
@@ -206,27 +212,26 @@
|
|||||||
[avatarUrl]="currentUser()?.avatarUrl"
|
[avatarUrl]="currentUser()?.avatarUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span
|
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@if (currentUser()?.voiceState?.isConnected) {
|
@if (currentUser()?.voiceState?.isConnected) {
|
||||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||||
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-2.5 h-2.5"
|
||||||
|
/>
|
||||||
In voice
|
In voice
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@if (
|
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
|
||||||
currentUser()?.screenShareState?.isSharing ||
|
<span class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse">
|
||||||
(currentUser()?.id && isUserSharing(currentUser()!.id))
|
<ng-icon
|
||||||
) {
|
name="lucideMonitor"
|
||||||
<span
|
class="w-2.5 h-2.5"
|
||||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse"
|
/>
|
||||||
>
|
|
||||||
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
|
|
||||||
LIVE
|
LIVE
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -239,9 +244,7 @@
|
|||||||
<!-- Other Online Users -->
|
<!-- Other Online Users -->
|
||||||
@if (onlineUsersFiltered().length > 0) {
|
@if (onlineUsersFiltered().length > 0) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineUsersFiltered().length }}</h4>
|
||||||
Online — {{ onlineUsersFiltered().length }}
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (user of onlineUsersFiltered(); track user.id) {
|
@for (user of onlineUsersFiltered(); track user.id) {
|
||||||
<div
|
<div
|
||||||
@@ -254,34 +257,26 @@
|
|||||||
[avatarUrl]="user.avatarUrl"
|
[avatarUrl]="user.avatarUrl"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span
|
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
||||||
@if (user.role === 'host') {
|
@if (user.role === 'host') {
|
||||||
<span
|
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
|
||||||
class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium"
|
|
||||||
>Owner</span
|
|
||||||
>
|
|
||||||
} @else if (user.role === 'admin') {
|
} @else if (user.role === 'admin') {
|
||||||
<span
|
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
|
||||||
class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium"
|
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
} @else if (user.role === 'moderator') {
|
} @else if (user.role === 'moderator') {
|
||||||
<span
|
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
|
||||||
class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium"
|
|
||||||
>Mod</span
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@if (user.voiceState?.isConnected) {
|
@if (user.voiceState?.isConnected) {
|
||||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||||
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-2.5 h-2.5"
|
||||||
|
/>
|
||||||
In voice
|
In voice
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -290,7 +285,10 @@
|
|||||||
(click)="viewStream(user.id); $event.stopPropagation()"
|
(click)="viewStream(user.id); $event.stopPropagation()"
|
||||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
|
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
|
<ng-icon
|
||||||
|
name="lucideMonitor"
|
||||||
|
class="w-2.5 h-2.5"
|
||||||
|
/>
|
||||||
LIVE
|
LIVE
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -327,42 +325,81 @@
|
|||||||
(closed)="closeChannelMenu()"
|
(closed)="closeChannelMenu()"
|
||||||
[width]="'w-44'"
|
[width]="'w-44'"
|
||||||
>
|
>
|
||||||
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
|
<button
|
||||||
|
(click)="resyncMessages()"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
|
Resync Messages
|
||||||
|
</button>
|
||||||
@if (canManageChannels()) {
|
@if (canManageChannels()) {
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
|
<button
|
||||||
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
|
(click)="startRename()"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
|
Rename Channel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="deleteChannel()"
|
||||||
|
class="context-menu-item-danger"
|
||||||
|
>
|
||||||
|
Delete Channel
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</app-context-menu>
|
</app-context-menu>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- User context menu (kick / role management) -->
|
<!-- User context menu (kick / role management) -->
|
||||||
@if (showUserMenu()) {
|
@if (showUserMenu()) {
|
||||||
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
|
<app-context-menu
|
||||||
|
[x]="userMenuX()"
|
||||||
|
[y]="userMenuY()"
|
||||||
|
(closed)="closeUserMenu()"
|
||||||
|
>
|
||||||
@if (isAdmin()) {
|
@if (isAdmin()) {
|
||||||
@if (contextMenuUser()?.role === 'member') {
|
@if (contextMenuUser()?.role === 'member') {
|
||||||
<button (click)="changeUserRole('moderator')" class="context-menu-item">
|
<button
|
||||||
|
(click)="changeUserRole('moderator')"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
Promote to Moderator
|
Promote to Moderator
|
||||||
</button>
|
</button>
|
||||||
<button (click)="changeUserRole('admin')" class="context-menu-item">
|
<button
|
||||||
|
(click)="changeUserRole('admin')"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
Promote to Admin
|
Promote to Admin
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (contextMenuUser()?.role === 'moderator') {
|
@if (contextMenuUser()?.role === 'moderator') {
|
||||||
<button (click)="changeUserRole('admin')" class="context-menu-item">
|
<button
|
||||||
|
(click)="changeUserRole('admin')"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
Promote to Admin
|
Promote to Admin
|
||||||
</button>
|
</button>
|
||||||
<button (click)="changeUserRole('member')" class="context-menu-item">
|
<button
|
||||||
|
(click)="changeUserRole('member')"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
Demote to Member
|
Demote to Member
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (contextMenuUser()?.role === 'admin') {
|
@if (contextMenuUser()?.role === 'admin') {
|
||||||
<button (click)="changeUserRole('member')" class="context-menu-item">
|
<button
|
||||||
|
(click)="changeUserRole('member')"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
Demote to Member
|
Demote to Member
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
|
<button
|
||||||
|
(click)="kickUserAction()"
|
||||||
|
class="context-menu-item-danger"
|
||||||
|
>
|
||||||
|
Kick User
|
||||||
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="context-menu-empty">No actions available</div>
|
<div class="context-menu-empty">No actions available</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -32,8 +36,17 @@ import { WebRTCService } from '../../../core/services/webrtc.service';
|
|||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
||||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||||
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
import {
|
||||||
import { Channel, User } from '../../../core/models';
|
ContextMenuComponent,
|
||||||
|
UserAvatarComponent,
|
||||||
|
ConfirmDialogComponent
|
||||||
|
} from '../../../shared';
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChatEvent,
|
||||||
|
Room,
|
||||||
|
User
|
||||||
|
} from '../../../core/models';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
type TabView = 'channels' | 'users';
|
type TabView = 'channels' | 'users';
|
||||||
@@ -41,15 +54,7 @@ type TabView = 'channels' | 'users';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-rooms-side-panel',
|
selector: 'app-rooms-side-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
NgIcon,
|
|
||||||
VoiceControlsComponent,
|
|
||||||
ContextMenuComponent,
|
|
||||||
UserAvatarComponent,
|
|
||||||
ConfirmDialogComponent
|
|
||||||
],
|
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMessageSquare,
|
lucideMessageSquare,
|
||||||
@@ -110,9 +115,7 @@ export class RoomsSidePanelComponent {
|
|||||||
const currentId = current?.id;
|
const currentId = current?.id;
|
||||||
const currentOderId = current?.oderId;
|
const currentOderId = current?.oderId;
|
||||||
|
|
||||||
return this.onlineUsers().filter(
|
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
|
||||||
(user) => user.id !== currentId && user.oderId !== currentOderId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the current user has permission to manage channels. */
|
/** Check whether the current user has permission to manage channels. */
|
||||||
@@ -218,12 +221,14 @@ export class RoomsSidePanelComponent {
|
|||||||
const peers = this.webrtc.getConnectedPeers();
|
const peers = this.webrtc.getConnectedPeers();
|
||||||
|
|
||||||
if (peers.length === 0) {
|
if (peers.length === 0) {
|
||||||
// No connected peers — sync will time out
|
// No connected peers - sync will time out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
||||||
|
|
||||||
peers.forEach((pid) => {
|
peers.forEach((pid) => {
|
||||||
try {
|
try {
|
||||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
this.webrtc.sendToPeer(pid, inventoryRequest);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Failed to send inventory request to this peer
|
// Failed to send inventory request to this peer
|
||||||
}
|
}
|
||||||
@@ -327,6 +332,9 @@ export class RoomsSidePanelComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||||
@@ -334,7 +342,7 @@ export class RoomsSidePanelComponent {
|
|||||||
// clear it so the user can join.
|
// clear it so the user can join.
|
||||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||||
if (!this.webrtc.isVoiceConnected()) {
|
if (!this.webrtc.isVoiceConnected()) {
|
||||||
// Stale state – clear it so the user can proceed
|
// Stale state - clear it so the user can proceed
|
||||||
if (current.id) {
|
if (current.id) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
UsersActions.updateVoiceState({
|
UsersActions.updateVoiceState({
|
||||||
@@ -356,67 +364,76 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If switching channels within the same server, just update the room
|
// If switching channels within the same server, just update the room
|
||||||
const isSwitchingChannels =
|
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
||||||
current?.voiceState?.isConnected &&
|
|
||||||
current.voiceState.serverId === room?.id &&
|
|
||||||
current.voiceState.roomId !== roomId;
|
|
||||||
// Enable microphone and broadcast voice-state
|
// Enable microphone and broadcast voice-state
|
||||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||||
|
|
||||||
enableVoicePromise
|
enableVoicePromise
|
||||||
.then(() => {
|
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||||
if (current?.id && room) {
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: current.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: current.voiceState?.isMuted ?? false,
|
|
||||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
|
||||||
roomId: roomId,
|
|
||||||
serverId: room.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
|
||||||
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'voice-state',
|
|
||||||
oderId: current?.oderId || current?.id,
|
|
||||||
displayName: current?.displayName || 'User',
|
|
||||||
voiceState: {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: current?.voiceState?.isMuted ?? false,
|
|
||||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
|
||||||
roomId: roomId,
|
|
||||||
serverId: room?.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update voice session for floating controls
|
|
||||||
if (room) {
|
|
||||||
// Find label from channel list
|
|
||||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
|
||||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
|
||||||
|
|
||||||
this.voiceSessionService.startSession({
|
|
||||||
serverId: room.id,
|
|
||||||
serverName: room.name,
|
|
||||||
roomId: roomId,
|
|
||||||
roomName: voiceRoomName,
|
|
||||||
serverIcon: room.icon,
|
|
||||||
serverDescription: room.description,
|
|
||||||
serverRoute: `/room/${room.id}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((_error) => {
|
.catch((_error) => {
|
||||||
// Failed to join voice room
|
// Failed to join voice room
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||||
|
this.updateVoiceStateStore(roomId, room, current);
|
||||||
|
this.startVoiceHeartbeat(roomId, room);
|
||||||
|
this.broadcastVoiceConnected(roomId, room, current);
|
||||||
|
this.startVoiceSession(roomId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||||
|
if (!current?.id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.updateVoiceState({
|
||||||
|
userId: current.id,
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: current.voiceState?.isMuted ?? false,
|
||||||
|
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||||
|
roomId,
|
||||||
|
serverId: room.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||||
|
this.webrtc.startVoiceHeartbeat(roomId, room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'voice-state',
|
||||||
|
oderId: current?.oderId || current?.id,
|
||||||
|
displayName: current?.displayName || 'User',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: current?.voiceState?.isMuted ?? false,
|
||||||
|
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||||
|
roomId,
|
||||||
|
serverId: room.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startVoiceSession(roomId: string, room: Room): void {
|
||||||
|
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||||
|
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||||
|
|
||||||
|
this.voiceSessionService.startSession({
|
||||||
|
serverId: room.id,
|
||||||
|
serverName: room.name,
|
||||||
|
roomId,
|
||||||
|
roomName: voiceRoomName,
|
||||||
|
serverIcon: room.icon,
|
||||||
|
serverDescription: room.description,
|
||||||
|
serverRoute: `/room/${room.id}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Leave a voice channel and broadcast the disconnect state. */
|
/** Leave a voice channel and broadcast the disconnect state. */
|
||||||
leaveVoice(roomId: string) {
|
leaveVoice(roomId: string) {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
@@ -470,12 +487,8 @@ export class RoomsSidePanelComponent {
|
|||||||
const users = this.onlineUsers();
|
const users = this.onlineUsers();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|
||||||
return users.filter(
|
return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id)
|
||||||
(user) =>
|
.length;
|
||||||
!!user.voiceState?.isConnected &&
|
|
||||||
user.voiceState?.roomId === roomId &&
|
|
||||||
user.voiceState?.serverId === room?.id
|
|
||||||
).length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||||
@@ -500,9 +513,7 @@ export class RoomsSidePanelComponent {
|
|||||||
return this.webrtc.isScreenSharing();
|
return this.webrtc.isScreenSharing();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = this.onlineUsers().find(
|
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user?.screenShareState?.isSharing === false) {
|
if (user?.screenShareState?.isSharing === false) {
|
||||||
return false;
|
return false;
|
||||||
@@ -518,10 +529,7 @@ export class RoomsSidePanelComponent {
|
|||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|
||||||
return this.onlineUsers().filter(
|
return this.onlineUsers().filter(
|
||||||
(user) =>
|
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
||||||
!!user.voiceState?.isConnected &&
|
|
||||||
user.voiceState?.roomId === roomId &&
|
|
||||||
user.voiceState?.serverId === room?.id
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,11 +538,7 @@ export class RoomsSidePanelComponent {
|
|||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|
||||||
return !!(
|
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||||
me?.voiceState?.isConnected &&
|
|
||||||
me.voiceState?.roomId === roomId &&
|
|
||||||
me.voiceState?.serverId === room?.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether voice is enabled by the current room's permissions. */
|
/** Check whether voice is enabled by the current room's permissions. */
|
||||||
@@ -558,8 +562,8 @@ export class RoomsSidePanelComponent {
|
|||||||
/**
|
/**
|
||||||
* Return a Tailwind `bg-*` class representing the latency quality.
|
* Return a Tailwind `bg-*` class representing the latency quality.
|
||||||
* - green : < 100 ms
|
* - green : < 100 ms
|
||||||
* - yellow : 100–199 ms
|
* - yellow : 100-199 ms
|
||||||
* - orange : 200–349 ms
|
* - orange : 200-349 ms
|
||||||
* - red : >= 350 ms
|
* - red : >= 350 ms
|
||||||
* - gray : no data yet
|
* - gray : no data yet
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -40,7 +40,10 @@
|
|||||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||||
title="Settings"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideSettings" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideSettings"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +55,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Create New Server
|
Create New Server
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +71,10 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else if (searchResults().length === 0) {
|
} @else if (searchResults().length === 0) {
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
<ng-icon name="lucideSearch" class="w-12 h-12 mb-4 opacity-50" />
|
<ng-icon
|
||||||
|
name="lucideSearch"
|
||||||
|
class="w-12 h-12 mb-4 opacity-50"
|
||||||
|
/>
|
||||||
<p class="text-lg">No servers found</p>
|
<p class="text-lg">No servers found</p>
|
||||||
<p class="text-sm">Try a different search or create your own</p>
|
<p class="text-sm">Try a different search or create your own</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,9 +93,15 @@
|
|||||||
{{ server.name }}
|
{{ server.name }}
|
||||||
</h3>
|
</h3>
|
||||||
@if (server.isPrivate) {
|
@if (server.isPrivate) {
|
||||||
<ng-icon name="lucideLock" class="w-4 h-4 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucideGlobe" class="w-4 h-4 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideGlobe"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (server.description) {
|
@if (server.description) {
|
||||||
@@ -101,13 +116,14 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
||||||
<ng-icon name="lucideUsers" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideUsers"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
|
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-xs text-muted-foreground">
|
<div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
|
||||||
Hosted by {{ server.hostName }}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +161,11 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="create-server-name" class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
<label
|
||||||
|
for="create-server-name"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Server Name</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="newServerName"
|
[(ngModel)]="newServerName"
|
||||||
@@ -156,7 +176,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="create-server-description" class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
<label
|
||||||
|
for="create-server-description"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Description (optional)</label
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="newServerDescription"
|
[(ngModel)]="newServerDescription"
|
||||||
placeholder="What's your server about?"
|
placeholder="What's your server about?"
|
||||||
@@ -167,7 +191,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="create-server-topic" class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
<label
|
||||||
|
for="create-server-topic"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Topic (optional)</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="newServerTopic"
|
[(ngModel)]="newServerTopic"
|
||||||
@@ -184,12 +212,20 @@
|
|||||||
id="private"
|
id="private"
|
||||||
class="w-4 h-4 rounded border-border bg-secondary"
|
class="w-4 h-4 rounded border-border bg-secondary"
|
||||||
/>
|
/>
|
||||||
<label for="private" class="text-sm text-foreground">Private server</label>
|
<label
|
||||||
|
for="private"
|
||||||
|
class="text-sm text-foreground"
|
||||||
|
>Private server</label
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (newServerPrivate()) {
|
@if (newServerPrivate()) {
|
||||||
<div>
|
<div>
|
||||||
<label for="create-server-password" class="block text-sm font-medium text-foreground mb-1">Password</label>
|
<label
|
||||||
|
for="create-server-password"
|
||||||
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
[(ngModel)]="newServerPassword"
|
[(ngModel)]="newServerPassword"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
|
import {
|
||||||
|
debounceTime,
|
||||||
|
distinctUntilChanged,
|
||||||
|
Subject
|
||||||
|
} from 'rxjs';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideSearch,
|
lucideSearch,
|
||||||
@@ -147,16 +156,20 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
|
|
||||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
this.joinServer({
|
this.joinServer(this.toServerInfo(room));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toServerInfo(room: Room): ServerInfo {
|
||||||
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
name: room.name,
|
name: room.name,
|
||||||
description: room.description,
|
description: room.description,
|
||||||
hostName: room.hostId || 'Unknown',
|
hostName: room.hostId || 'Unknown',
|
||||||
userCount: room.userCount,
|
userCount: room.userCount,
|
||||||
maxUsers: room.maxUsers || 50,
|
maxUsers: room.maxUsers ?? 50,
|
||||||
isPrivate: !!room.password,
|
isPrivate: !!room.password,
|
||||||
createdAt: room.createdAt
|
createdAt: room.createdAt
|
||||||
} as any);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetCreateForm(): void {
|
private resetCreateForm(): void {
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
||||||
<!-- Create button -->
|
<!-- Create button -->
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
title="Create Server"
|
title="Create Server"
|
||||||
(click)="createServer()"
|
(click)="createServer()"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Saved servers icons -->
|
<!-- Saved servers icons -->
|
||||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||||
@for (room of savedRooms(); track room.id) {
|
@for (room of savedRooms(); track room.id) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||||
[title]="room.name"
|
[title]="room.name"
|
||||||
(click)="joinSavedRoom(room)"
|
(click)="joinSavedRoom(room)"
|
||||||
(contextmenu)="openContextMenu($event, room)"
|
(contextmenu)="openContextMenu($event, room)"
|
||||||
>
|
>
|
||||||
@if (room.icon) {
|
@if (room.icon) {
|
||||||
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
<img
|
||||||
|
[ngSrc]="room.icon"
|
||||||
|
[alt]="room.name"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||||
@@ -31,11 +40,28 @@
|
|||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
@if (showMenu()) {
|
@if (showMenu()) {
|
||||||
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
|
<app-context-menu
|
||||||
|
[x]="menuX()"
|
||||||
|
[y]="menuY()"
|
||||||
|
(closed)="closeMenu()"
|
||||||
|
[width]="'w-44'"
|
||||||
|
>
|
||||||
@if (isCurrentContextRoom()) {
|
@if (isCurrentContextRoom()) {
|
||||||
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="leaveServer()"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
|
Leave Server
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openForgetConfirm()"
|
||||||
|
class="context-menu-item"
|
||||||
|
>
|
||||||
|
Forget Server
|
||||||
|
</button>
|
||||||
</app-context-menu>
|
</app-context-menu>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +74,8 @@
|
|||||||
(cancelled)="cancelForget()"
|
(cancelled)="cancelForget()"
|
||||||
[widthClass]="'w-[280px]'"
|
[widthClass]="'w-[280px]'"
|
||||||
>
|
>
|
||||||
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
|
<p>
|
||||||
|
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||||
|
</p>
|
||||||
</app-confirm-dialog>
|
</app-confirm-dialog>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
import { CommonModule } from '@angular/common';
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -16,7 +20,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-servers-rail',
|
selector: 'app-servers-rail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
|
||||||
viewProviders: [provideIcons({ lucidePlus })],
|
viewProviders: [provideIcons({ lucidePlus })],
|
||||||
templateUrl: './servers-rail.component.html'
|
templateUrl: './servers-rail.component.html'
|
||||||
})
|
})
|
||||||
@@ -92,14 +96,16 @@ export class ServersRailComponent {
|
|||||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||||
} else {
|
} else {
|
||||||
// First time joining this server
|
// First time joining this server
|
||||||
this.store.dispatch(RoomsActions.joinRoom({
|
this.store.dispatch(
|
||||||
roomId: room.id,
|
RoomsActions.joinRoom({
|
||||||
serverInfo: {
|
roomId: room.id,
|
||||||
name: room.name,
|
serverInfo: {
|
||||||
description: room.description,
|
name: room.name,
|
||||||
hostName: room.hostId || 'Unknown'
|
description: room.description,
|
||||||
}
|
hostName: room.hostId || 'Unknown'
|
||||||
}));
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +114,7 @@ export class ServersRailComponent {
|
|||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
this.contextRoom.set(room);
|
this.contextRoom.set(room);
|
||||||
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
|
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
|
||||||
this.menuX.set(Math.max((evt.clientX + 8), 72));
|
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
||||||
this.menuY.set(evt.clientY);
|
this.menuY.set(evt.clientY);
|
||||||
this.showMenu.set(true);
|
this.showMenu.set(true);
|
||||||
}
|
}
|
||||||
@@ -161,5 +167,4 @@ export class ServersRailComponent {
|
|||||||
cancelForget(): void {
|
cancelForget(): void {
|
||||||
this.showConfirm.set(false);
|
this.showConfirm.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@for (ban of bannedUsers(); track ban.oderId) {
|
@for (ban of bannedUsers(); track ban.oderId) {
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||||
<div
|
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
||||||
class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm"
|
|
||||||
>
|
|
||||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -18,19 +16,21 @@
|
|||||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||||
}
|
}
|
||||||
@if (ban.expiresAt) {
|
@if (ban.expiresAt) {
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
|
||||||
</p>
|
|
||||||
} @else {
|
} @else {
|
||||||
<p class="text-xs text-destructive">Permanent</p>
|
<p class="text-xs text-destructive">Permanent</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (isAdmin()) {
|
@if (isAdmin()) {
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
(click)="unbanUser(ban)"
|
(click)="unbanUser(ban)"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,5 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||||
Select a server from the sidebar to manage
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, input } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -40,7 +44,8 @@ export class BansSettingsComponent {
|
|||||||
return (
|
return (
|
||||||
date.toLocaleDateString() +
|
date.toLocaleDateString() +
|
||||||
' ' +
|
' ' +
|
||||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
date.toLocaleTimeString([], { hour: '2-digit',
|
||||||
|
minute: '2-digit' })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,21 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@for (user of membersFiltered(); track user.id) {
|
@for (user of membersFiltered(); track user.id) {
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||||
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
|
<app-user-avatar
|
||||||
|
[name]="user.displayName || '?'"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm font-medium text-foreground truncate">
|
<p class="text-sm font-medium text-foreground truncate">
|
||||||
{{ user.displayName }}
|
{{ user.displayName }}
|
||||||
</p>
|
</p>
|
||||||
@if (user.role === 'host') {
|
@if (user.role === 'host') {
|
||||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded"
|
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||||
>Owner</span
|
|
||||||
>
|
|
||||||
} @else if (user.role === 'admin') {
|
} @else if (user.role === 'admin') {
|
||||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded"
|
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
} @else if (user.role === 'moderator') {
|
} @else if (user.role === 'moderator') {
|
||||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded"
|
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||||
>Mod</span
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,14 +39,20 @@
|
|||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title="Kick"
|
title="Kick"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideUserX"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="banMember(user)"
|
(click)="banMember(user)"
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title="Ban"
|
title="Ban"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideBan"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,5 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||||
Select a server from the sidebar to manage
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, input } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -15,7 +19,12 @@ import { UserAvatarComponent } from '../../../../shared';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-members-settings',
|
selector: 'app-members-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideUserX,
|
lucideUserX,
|
||||||
@@ -43,7 +52,9 @@ export class MembersSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
||||||
|
role }));
|
||||||
|
|
||||||
this.webrtcService.broadcastMessage({
|
this.webrtcService.broadcastMessage({
|
||||||
type: 'role-change',
|
type: 'role-change',
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideGlobe"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -11,14 +14,16 @@
|
|||||||
[disabled]="isTesting()"
|
[disabled]="isTesting()"
|
||||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideRefreshCw" class="w-3.5 h-3.5" [class.animate-spin]="isTesting()" />
|
<ng-icon
|
||||||
|
name="lucideRefreshCw"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
[class.animate-spin]="isTesting()"
|
||||||
|
/>
|
||||||
Test All
|
Test All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground mb-3">
|
<p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
|
||||||
Server directories to search for rooms. The active server is used for creating new rooms.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
<div class="space-y-2 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
@@ -41,10 +46,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
|
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
|
||||||
@if (server.isActive) {
|
@if (server.isActive) {
|
||||||
<span
|
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
|
||||||
class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full"
|
|
||||||
>Active</span
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
|
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
|
||||||
@@ -105,7 +107,10 @@
|
|||||||
[disabled]="!newServerName || !newServerUrl"
|
[disabled]="!newServerName || !newServerUrl"
|
||||||
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (addError()) {
|
@if (addError()) {
|
||||||
@@ -117,7 +122,10 @@
|
|||||||
<!-- Connection Settings -->
|
<!-- Connection Settings -->
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideServer"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
|
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -18,7 +22,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-network-settings',
|
selector: 'app-network-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
@@ -65,6 +73,7 @@ export class NetworkSettingsComponent {
|
|||||||
name: this.newServerName.trim(),
|
name: this.newServerName.trim(),
|
||||||
url: this.newServerUrl.trim().replace(/\/$/, '')
|
url: this.newServerUrl.trim().replace(/\/$/, '')
|
||||||
});
|
});
|
||||||
|
|
||||||
this.newServerName = '';
|
this.newServerName = '';
|
||||||
this.newServerUrl = '';
|
this.newServerUrl = '';
|
||||||
const servers = this.servers();
|
const servers = this.servers();
|
||||||
@@ -108,6 +117,7 @@ export class NetworkSettingsComponent {
|
|||||||
searchAllServers: this.searchAllServers
|
searchAllServers: this.searchAllServers
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
@if (server()) {
|
@if (server()) {
|
||||||
<div class="space-y-4 max-w-xl">
|
<div class="space-y-4 max-w-xl">
|
||||||
@if (!isAdmin()) {
|
@if (!isAdmin()) {
|
||||||
<p class="text-xs text-muted-foreground mb-1">
|
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
|
||||||
You are viewing this server's permissions. Only the server owner can make changes.
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
<div class="space-y-2.5">
|
<div class="space-y-2.5">
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||||
@@ -118,13 +116,14 @@
|
|||||||
[class.bg-green-600]="saveSuccess() === 'permissions'"
|
[class.bg-green-600]="saveSuccess() === 'permissions'"
|
||||||
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
|
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideCheck"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
|
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||||
Select a server from the sidebar to manage
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, input, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -12,7 +17,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-permissions-settings',
|
selector: 'app-permissions-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideCheck
|
lucideCheck
|
||||||
@@ -75,6 +84,7 @@ export class PermissionsSettingsComponent {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.showSaveSuccess('permissions');
|
this.showSaveSuccess('permissions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
|
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
|
||||||
@if (!isAdmin()) {
|
@if (!isAdmin()) {
|
||||||
<p class="text-xs text-muted-foreground mb-3">
|
<p class="text-xs text-muted-foreground mb-3">
|
||||||
You are viewing this server's settings as a non-admin. Only the server owner can make
|
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
|
||||||
changes.
|
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="room-name" class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
|
<label
|
||||||
|
for="room-name"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>Room Name</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="roomName"
|
[(ngModel)]="roomName"
|
||||||
@@ -22,7 +25,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="room-description" class="block text-xs font-medium text-muted-foreground mb-1">Description</label>
|
<label
|
||||||
|
for="room-description"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>Description</label
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
[(ngModel)]="roomDescription"
|
[(ngModel)]="roomDescription"
|
||||||
[readOnly]="!isAdmin()"
|
[readOnly]="!isAdmin()"
|
||||||
@@ -49,9 +56,15 @@
|
|||||||
[class.text-muted-foreground]="!isPrivate()"
|
[class.text-muted-foreground]="!isPrivate()"
|
||||||
>
|
>
|
||||||
@if (isPrivate()) {
|
@if (isPrivate()) {
|
||||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideUnlock"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +78,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<label for="room-max-users" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="room-max-users"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Max Users (0 = unlimited)
|
Max Users (0 = unlimited)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -90,7 +106,10 @@
|
|||||||
[class.bg-green-600]="saveSuccess() === 'server'"
|
[class.bg-green-600]="saveSuccess() === 'server'"
|
||||||
[class.hover:bg-green-600]="saveSuccess() === 'server'"
|
[class.hover:bg-green-600]="saveSuccess() === 'server'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideCheck"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
|
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -102,7 +121,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
|
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
Delete Room
|
Delete Room
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +145,5 @@
|
|||||||
</app-confirm-dialog>
|
</app-confirm-dialog>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||||
Select a server from the sidebar to manage
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, input, signal, computed } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
computed
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { lucideCheck, lucideTrash2, lucideLock, lucideUnlock } from '@ng-icons/lucide';
|
import {
|
||||||
|
lucideCheck,
|
||||||
|
lucideTrash2,
|
||||||
|
lucideLock,
|
||||||
|
lucideUnlock
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { Room } from '../../../../core/models';
|
import { Room } from '../../../../core/models';
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
@@ -14,7 +25,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-settings',
|
selector: 'app-server-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon,
|
||||||
|
ConfirmDialogComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideCheck,
|
lucideCheck,
|
||||||
@@ -78,6 +94,7 @@ export class ServerSettingsComponent {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.showSaveSuccess('server');
|
this.showSaveSuccess('server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,7 @@
|
|||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-2">
|
<div class="flex-1 overflow-y-auto py-2">
|
||||||
<!-- Global section -->
|
<!-- Global section -->
|
||||||
<p
|
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">General</p>
|
||||||
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
General
|
|
||||||
</p>
|
|
||||||
@for (page of globalPages; track page.id) {
|
@for (page of globalPages; track page.id) {
|
||||||
<button
|
<button
|
||||||
(click)="navigate(page.id)"
|
(click)="navigate(page.id)"
|
||||||
@@ -51,7 +47,10 @@
|
|||||||
[class.text-foreground]="activePage() !== page.id"
|
[class.text-foreground]="activePage() !== page.id"
|
||||||
[class.hover:bg-secondary]="activePage() !== page.id"
|
[class.hover:bg-secondary]="activePage() !== page.id"
|
||||||
>
|
>
|
||||||
<ng-icon [name]="page.icon" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
[name]="page.icon"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
{{ page.label }}
|
{{ page.label }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -59,11 +58,7 @@
|
|||||||
<!-- Server section -->
|
<!-- Server section -->
|
||||||
@if (savedRooms().length > 0) {
|
@if (savedRooms().length > 0) {
|
||||||
<div class="mt-3 pt-3 border-t border-border">
|
<div class="mt-3 pt-3 border-t border-border">
|
||||||
<p
|
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
|
||||||
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Server
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Server selector -->
|
<!-- Server selector -->
|
||||||
<div class="px-3 pb-2">
|
<div class="px-3 pb-2">
|
||||||
@@ -91,7 +86,10 @@
|
|||||||
[class.text-foreground]="activePage() !== page.id"
|
[class.text-foreground]="activePage() !== page.id"
|
||||||
[class.hover:bg-secondary]="activePage() !== page.id"
|
[class.hover:bg-secondary]="activePage() !== page.id"
|
||||||
>
|
>
|
||||||
<ng-icon [name]="page.icon" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
[name]="page.icon"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
{{ page.label }}
|
{{ page.label }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -104,9 +102,7 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"
|
|
||||||
>
|
|
||||||
<h3 class="text-lg font-semibold text-foreground">
|
<h3 class="text-lg font-semibold text-foreground">
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
@@ -134,7 +130,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ import {
|
|||||||
|
|
||||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import {
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
selectCurrentUser
|
|
||||||
} from '../../../store/users/users.selectors';
|
|
||||||
import { Room } from '../../../core/models';
|
import { Room } from '../../../core/models';
|
||||||
|
|
||||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||||
@@ -80,14 +78,25 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
// --- Side-nav items ---
|
// --- Side-nav items ---
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
{ id: 'network',
|
||||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }
|
label: 'Network',
|
||||||
|
icon: 'lucideGlobe' }, { id: 'voice',
|
||||||
|
label: 'Voice & Audio',
|
||||||
|
icon: 'lucideAudioLines' }
|
||||||
];
|
];
|
||||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
{ id: 'server',
|
||||||
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
label: 'Server',
|
||||||
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
icon: 'lucideSettings' },
|
||||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
{ id: 'members',
|
||||||
|
label: 'Members',
|
||||||
|
icon: 'lucideUsers' },
|
||||||
|
{ id: 'bans',
|
||||||
|
label: 'Bans',
|
||||||
|
icon: 'lucideBan' },
|
||||||
|
{ id: 'permissions',
|
||||||
|
label: 'Permissions',
|
||||||
|
icon: 'lucideShield' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== SERVER SELECTOR =====
|
// ===== SERVER SELECTOR =====
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
<!-- Devices -->
|
<!-- Devices -->
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<ng-icon name="lucideMic" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
|
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="input-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
|
<label
|
||||||
|
for="input-device-select"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>Microphone</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
(change)="onInputDeviceChange($event)"
|
(change)="onInputDeviceChange($event)"
|
||||||
id="input-device-select"
|
id="input-device-select"
|
||||||
@@ -24,7 +31,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="output-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label>
|
<label
|
||||||
|
for="output-device-select"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>Speaker</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
(change)="onOutputDeviceChange($event)"
|
(change)="onOutputDeviceChange($event)"
|
||||||
id="output-device-select"
|
id="output-device-select"
|
||||||
@@ -46,12 +57,18 @@
|
|||||||
<!-- Volume -->
|
<!-- Volume -->
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<ng-icon name="lucideHeadphones" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideHeadphones"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
|
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="input-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="input-volume-slider"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Input Volume: {{ inputVolume() }}%
|
Input Volume: {{ inputVolume() }}%
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -65,7 +82,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="output-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="output-volume-slider"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Output Volume: {{ outputVolume() }}%
|
Output Volume: {{ outputVolume() }}%
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -79,7 +99,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="notification-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="notification-volume-slider"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -102,9 +125,7 @@
|
|||||||
Test
|
Test
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[10px] text-muted-foreground/60 mt-1">
|
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave & notification sounds</p>
|
||||||
Controls join, leave & notification sounds
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -112,24 +133,49 @@
|
|||||||
<!-- Quality & Processing -->
|
<!-- Quality & Processing -->
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideAudioLines"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
|
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="latency-profile-select" class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
|
<label
|
||||||
|
for="latency-profile-select"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>Latency Profile</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
(change)="onLatencyProfileChange($event)"
|
(change)="onLatencyProfileChange($event)"
|
||||||
id="latency-profile-select"
|
id="latency-profile-select"
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option>
|
<option
|
||||||
<option value="balanced" [selected]="latencyProfile() === 'balanced'">Balanced</option>
|
value="low"
|
||||||
<option value="high" [selected]="latencyProfile() === 'high'">High (quality)</option>
|
[selected]="latencyProfile() === 'low'"
|
||||||
|
>
|
||||||
|
Low (fast)
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="balanced"
|
||||||
|
[selected]="latencyProfile() === 'balanced'"
|
||||||
|
>
|
||||||
|
Balanced
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="high"
|
||||||
|
[selected]="latencyProfile() === 'high'"
|
||||||
|
>
|
||||||
|
High (quality)
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="audio-bitrate-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="audio-bitrate-slider"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -187,7 +233,10 @@
|
|||||||
<!-- Voice Leveling -->
|
<!-- Voice Leveling -->
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<ng-icon name="lucideActivity" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideActivity"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4>
|
<h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -212,12 +261,15 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced controls — visible only when enabled -->
|
<!-- Advanced controls - visible only when enabled -->
|
||||||
@if (voiceLeveling.enabled()) {
|
@if (voiceLeveling.enabled()) {
|
||||||
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
|
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
|
||||||
<!-- Target Loudness -->
|
<!-- Target Loudness -->
|
||||||
<div class="pl-3">
|
<div class="pl-3">
|
||||||
<label for="target-loudness-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="target-loudness-slider"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
|
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -238,19 +290,32 @@
|
|||||||
|
|
||||||
<!-- AGC Strength -->
|
<!-- AGC Strength -->
|
||||||
<div class="pl-3">
|
<div class="pl-3">
|
||||||
<label for="agc-strength-select" class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label>
|
<label
|
||||||
|
for="agc-strength-select"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>AGC Strength</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
(change)="onStrengthChange($event)"
|
(change)="onStrengthChange($event)"
|
||||||
id="agc-strength-select"
|
id="agc-strength-select"
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="low" [selected]="voiceLeveling.strength() === 'low'">
|
<option
|
||||||
|
value="low"
|
||||||
|
[selected]="voiceLeveling.strength() === 'low'"
|
||||||
|
>
|
||||||
Low (gentle)
|
Low (gentle)
|
||||||
</option>
|
</option>
|
||||||
<option value="medium" [selected]="voiceLeveling.strength() === 'medium'">
|
<option
|
||||||
|
value="medium"
|
||||||
|
[selected]="voiceLeveling.strength() === 'medium'"
|
||||||
|
>
|
||||||
Medium
|
Medium
|
||||||
</option>
|
</option>
|
||||||
<option value="high" [selected]="voiceLeveling.strength() === 'high'">
|
<option
|
||||||
|
value="high"
|
||||||
|
[selected]="voiceLeveling.strength() === 'high'"
|
||||||
|
>
|
||||||
High (aggressive)
|
High (aggressive)
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -258,7 +323,10 @@
|
|||||||
|
|
||||||
<!-- Max Gain Boost -->
|
<!-- Max Gain Boost -->
|
||||||
<div class="pl-3">
|
<div class="pl-3">
|
||||||
<label for="max-gain-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="max-gain-slider"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
|
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -279,7 +347,10 @@
|
|||||||
|
|
||||||
<!-- Response Speed -->
|
<!-- Response Speed -->
|
||||||
<div class="pl-3">
|
<div class="pl-3">
|
||||||
<label for="response-speed-select" class="block text-xs font-medium text-muted-foreground mb-1">
|
<label
|
||||||
|
for="response-speed-select"
|
||||||
|
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||||
|
>
|
||||||
Response Speed
|
Response Speed
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -287,11 +358,22 @@
|
|||||||
id="response-speed-select"
|
id="response-speed-select"
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="slow" [selected]="voiceLeveling.speed() === 'slow'">
|
<option
|
||||||
|
value="slow"
|
||||||
|
[selected]="voiceLeveling.speed() === 'slow'"
|
||||||
|
>
|
||||||
Slow (natural)
|
Slow (natural)
|
||||||
</option>
|
</option>
|
||||||
<option value="medium" [selected]="voiceLeveling.speed() === 'medium'">Medium</option>
|
<option
|
||||||
<option value="fast" [selected]="voiceLeveling.speed() === 'fast'">
|
value="medium"
|
||||||
|
[selected]="voiceLeveling.speed() === 'medium'"
|
||||||
|
>
|
||||||
|
Medium
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="fast"
|
||||||
|
[selected]="voiceLeveling.speed() === 'fast'"
|
||||||
|
>
|
||||||
Fast (aggressive)
|
Fast (aggressive)
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideMic, lucideHeadphones, lucideAudioLines, lucideActivity } from '@ng-icons/lucide';
|
import {
|
||||||
|
lucideMic,
|
||||||
|
lucideHeadphones,
|
||||||
|
lucideAudioLines,
|
||||||
|
lucideActivity
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||||
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||||
import {
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||||
NotificationAudioService,
|
|
||||||
AppSound
|
|
||||||
} from '../../../../core/services/notification-audio.service';
|
|
||||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -21,7 +27,11 @@ interface AudioDevice {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-settings',
|
selector: 'app-voice-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMic,
|
lucideMic,
|
||||||
@@ -63,12 +73,15 @@ export class VoiceSettingsComponent {
|
|||||||
this.inputDevices.set(
|
this.inputDevices.set(
|
||||||
devices
|
devices
|
||||||
.filter((device) => device.kind === 'audioinput')
|
.filter((device) => device.kind === 'audioinput')
|
||||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
.map((device) => ({ deviceId: device.deviceId,
|
||||||
|
label: device.label }))
|
||||||
);
|
);
|
||||||
|
|
||||||
this.outputDevices.set(
|
this.outputDevices.set(
|
||||||
devices
|
devices
|
||||||
.filter((device) => device.kind === 'audiooutput')
|
.filter((device) => device.kind === 'audiooutput')
|
||||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
.map((device) => ({ deviceId: device.deviceId,
|
||||||
|
label: device.label }))
|
||||||
);
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@
|
|||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Go back"
|
title="Go back"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideArrowLeft" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideArrowLeft"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
|
<ng-icon
|
||||||
|
name="lucideSettings"
|
||||||
|
class="w-6 h-6 text-primary"
|
||||||
|
/>
|
||||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -15,7 +21,10 @@
|
|||||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideGlobe"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -23,14 +32,18 @@
|
|||||||
[disabled]="isTesting()"
|
[disabled]="isTesting()"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideRefreshCw" class="w-4 h-4" [class.animate-spin]="isTesting()" />
|
<ng-icon
|
||||||
|
name="lucideRefreshCw"
|
||||||
|
class="w-4 h-4"
|
||||||
|
[class.animate-spin]="isTesting()"
|
||||||
|
/>
|
||||||
Test All
|
Test All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
Add multiple server directories to search for rooms across different networks. The active
|
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
|
||||||
server will be used for creating and registering new rooms.
|
rooms.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
@@ -58,9 +71,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
|
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
|
||||||
@if (server.isActive) {
|
@if (server.isActive) {
|
||||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full"
|
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
|
||||||
>Active</span
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||||
@@ -123,7 +134,10 @@
|
|||||||
[disabled]="!newServerName || !newServerUrl"
|
[disabled]="!newServerName || !newServerUrl"
|
||||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (addError()) {
|
@if (addError()) {
|
||||||
@@ -135,7 +149,10 @@
|
|||||||
<!-- Connection Settings -->
|
<!-- Connection Settings -->
|
||||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideServer"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,9 +160,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-foreground">Auto-reconnect</p>
|
<p class="font-medium text-foreground">Auto-reconnect</p>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
|
||||||
Automatically reconnect when connection is lost
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -163,9 +178,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-foreground">Search all servers</p>
|
<p class="font-medium text-foreground">Search all servers</p>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
|
||||||
Search across all configured server directories
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -185,7 +198,10 @@
|
|||||||
<!-- Voice Settings -->
|
<!-- Voice Settings -->
|
||||||
<div class="bg-card border border-border rounded-lg p-6">
|
<div class="bg-card border border-border rounded-lg p-6">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideAudioLines"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
|
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,9 +211,7 @@
|
|||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-foreground">Notification volume</p>
|
<p class="font-medium text-foreground">Notification volume</p>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
|
||||||
Volume for join, leave, and notification sounds
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
|
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
|
||||||
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||||
@@ -226,9 +240,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-foreground">Noise reduction</p>
|
<p class="font-medium text-foreground">Noise reduction</p>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
|
||||||
Use RNNoise to suppress background noise from your microphone
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@@ -25,7 +30,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgIcon
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideServer,
|
lucideServer,
|
||||||
@@ -142,6 +151,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
searchAllServers: this.searchAllServers
|
searchAllServers: this.searchAllServers
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +200,10 @@ export class SettingsComponent implements OnInit {
|
|||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY_VOICE_SETTINGS,
|
STORAGE_KEY_VOICE_SETTINGS,
|
||||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
|
JSON.stringify({ ...existing,
|
||||||
|
noiseReduction: this.noiseReduction })
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,45 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
|
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
|
||||||
style="-webkit-app-region: drag;"
|
style="-webkit-app-region: drag"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
<div
|
||||||
|
class="flex items-center gap-2 min-w-0 relative"
|
||||||
|
style="-webkit-app-region: no-drag"
|
||||||
|
>
|
||||||
@if (inRoom()) {
|
@if (inRoom()) {
|
||||||
<button type="button" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
<button
|
||||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
type="button"
|
||||||
|
(click)="onBack()"
|
||||||
|
class="p-2 hover:bg-secondary rounded"
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideChevronLeft"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (inRoom()) {
|
@if (inRoom()) {
|
||||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
<ng-icon
|
||||||
|
name="lucideHash"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||||
@if (roomDescription()) {
|
@if (roomDescription()) {
|
||||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||||
{{ roomDescription() }}
|
{{ roomDescription() }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
<button type="button" (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
<button
|
||||||
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
|
type="button"
|
||||||
|
(click)="toggleMenu()"
|
||||||
|
class="ml-2 p-2 hover:bg-secondary rounded"
|
||||||
|
title="Menu"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMenu"
|
||||||
|
class="w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<!-- Anchored dropdown under the menu button -->
|
<!-- Anchored dropdown under the menu button -->
|
||||||
@if (showMenu()) {
|
@if (showMenu()) {
|
||||||
@@ -48,7 +70,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
<div
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
style="-webkit-app-region: no-drag"
|
||||||
|
>
|
||||||
@if (!isAuthed()) {
|
@if (!isAuthed()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -60,14 +85,38 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (isElectron()) {
|
@if (isElectron()) {
|
||||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
<button
|
||||||
<ng-icon name="lucideMinus" class="w-4 h-4" />
|
type="button"
|
||||||
|
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||||
|
title="Minimize"
|
||||||
|
(click)="minimize()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMinus"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
|
<button
|
||||||
<ng-icon name="lucideSquare" class="w-4 h-4" />
|
type="button"
|
||||||
|
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||||
|
title="Maximize"
|
||||||
|
(click)="maximize()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideSquare"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
|
<button
|
||||||
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
|
type="button"
|
||||||
|
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
|
||||||
|
title="Close"
|
||||||
|
(click)="close()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-4 h-4 text-destructive"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,6 +131,6 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="Close menu overlay"
|
aria-label="Close menu overlay"
|
||||||
style="-webkit-app-region: no-drag;"
|
style="-webkit-app-region: no-drag"
|
||||||
></div>
|
></div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Component, inject, computed, signal } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
computed,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
|
import {
|
||||||
|
lucideMinus,
|
||||||
|
lucideSquare,
|
||||||
|
lucideX,
|
||||||
|
lucideChevronLeft,
|
||||||
|
lucideHash,
|
||||||
|
lucideMenu
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
@@ -17,7 +29,14 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
|||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon],
|
||||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
viewProviders: [
|
||||||
|
provideIcons({ lucideMinus,
|
||||||
|
lucideSquare,
|
||||||
|
lucideX,
|
||||||
|
lucideChevronLeft,
|
||||||
|
lucideHash,
|
||||||
|
lucideMenu })
|
||||||
|
],
|
||||||
templateUrl: './title-bar.component.html'
|
templateUrl: './title-bar.component.html'
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
@@ -102,7 +121,7 @@ export class TitleBarComponent {
|
|||||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
// Disconnect from signaling server – this broadcasts "user_left" to all
|
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||||
// servers the user was a member of, so other users see them go offline.
|
// servers the user was a member of, so other users see them go offline.
|
||||||
this.webrtc.disconnect();
|
this.webrtc.disconnect();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
||||||
title="Back to {{ voiceSession()?.serverName }}"
|
title="Back to {{ voiceSession()?.serverName }}"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideArrowLeft" class="w-3.5 h-3.5" />
|
<ng-icon
|
||||||
|
name="lucideArrowLeft"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
@if (voiceSession()?.serverIcon) {
|
@if (voiceSession()?.serverIcon) {
|
||||||
<img
|
<img
|
||||||
[src]="voiceSession()?.serverIcon"
|
[src]="voiceSession()?.serverIcon"
|
||||||
@@ -40,7 +43,10 @@
|
|||||||
[class]="getCompactButtonClass(isMuted())"
|
[class]="getCompactButtonClass(isMuted())"
|
||||||
title="Toggle Mute"
|
title="Toggle Mute"
|
||||||
>
|
>
|
||||||
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -49,7 +55,10 @@
|
|||||||
[class]="getCompactButtonClass(isDeafened())"
|
[class]="getCompactButtonClass(isDeafened())"
|
||||||
title="Toggle Deafen"
|
title="Toggle Deafen"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideHeadphones"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -58,7 +67,10 @@
|
|||||||
[class]="getCompactScreenShareClass()"
|
[class]="getCompactScreenShareClass()"
|
||||||
title="Toggle Screen Share"
|
title="Toggle Screen Share"
|
||||||
>
|
>
|
||||||
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +79,10 @@
|
|||||||
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||||
title="Disconnect"
|
title="Disconnect"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePhoneOff" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucidePhoneOff"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||||
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -163,7 +170,11 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
this.store.dispatch(UsersActions.updateVoiceState({
|
this.store.dispatch(UsersActions.updateVoiceState({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
voiceState: { isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
roomId: undefined,
|
||||||
|
serverId: undefined }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
|
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2 text-white">
|
<div class="flex items-center gap-2 text-white">
|
||||||
<ng-icon name="lucideMonitor" class="w-4 h-4" />
|
<ng-icon
|
||||||
|
name="lucideMonitor"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
@if (activeScreenSharer()) {
|
@if (activeScreenSharer()) {
|
||||||
<span class="text-sm font-medium">
|
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
|
||||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
|
||||||
</span>
|
|
||||||
} @else {
|
} @else {
|
||||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||||
}
|
}
|
||||||
@@ -46,9 +47,15 @@
|
|||||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
@if (isFullscreen()) {
|
@if (isFullscreen()) {
|
||||||
<ng-icon name="lucideMinimize" class="w-4 h-4 text-white" />
|
<ng-icon
|
||||||
|
name="lucideMinimize"
|
||||||
|
class="w-4 h-4 text-white"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
|
<ng-icon
|
||||||
|
name="lucideMaximize"
|
||||||
|
class="w-4 h-4 text-white"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (isLocalShare()) {
|
@if (isLocalShare()) {
|
||||||
@@ -58,7 +65,10 @@
|
|||||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||||
title="Stop sharing"
|
title="Stop sharing"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-4 h-4 text-white"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +77,10 @@
|
|||||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||||
title="Stop watching"
|
title="Stop watching"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="w-4 h-4 text-white"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +91,10 @@
|
|||||||
@if (!hasStream()) {
|
@if (!hasStream()) {
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
|
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||||
<div class="text-center text-muted-foreground">
|
<div class="text-center text-muted-foreground">
|
||||||
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
<ng-icon
|
||||||
|
name="lucideMonitor"
|
||||||
|
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||||
|
/>
|
||||||
<p>Waiting for screen share...</p>
|
<p>Waiting for screen share...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||||
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
OnDestroy,
|
||||||
|
effect
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -235,6 +243,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hasStream.set(true);
|
this.hasStream.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export class VoicePlaybackService {
|
|||||||
audio.srcObject = null;
|
audio.srcObject = null;
|
||||||
audio.remove();
|
audio.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.remoteAudioElements.clear();
|
this.remoteAudioElements.clear();
|
||||||
this.rawRemoteStreams.clear();
|
this.rawRemoteStreams.clear();
|
||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<div class="bg-card border-border p-4">
|
<div class="bg-card border-border p-4">
|
||||||
<!-- Connection Error Banner -->
|
<!-- Connection Error Banner -->
|
||||||
@if (showConnectionError()) {
|
@if (showConnectionError()) {
|
||||||
<div
|
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
|
||||||
class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
||||||
<span class="text-xs text-destructive">{{
|
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||||
connectionErrorMessage() || 'Connection error'
|
<button
|
||||||
}}</span>
|
type="button"
|
||||||
<button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
|
(click)="retryConnection()"
|
||||||
|
class="ml-auto text-xs text-destructive hover:underline"
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
|
<app-user-avatar
|
||||||
|
[name]="currentUser()?.displayName || '?'"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm text-foreground truncate">
|
<p class="font-medium text-sm text-foreground truncate">
|
||||||
{{ currentUser()?.displayName || 'Unknown' }}
|
{{ currentUser()?.displayName || 'Unknown' }}
|
||||||
@@ -31,8 +34,15 @@
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors">
|
<button
|
||||||
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
|
type="button"
|
||||||
|
(click)="toggleSettings()"
|
||||||
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideSettings"
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,25 +50,52 @@
|
|||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
@if (isConnected()) {
|
@if (isConnected()) {
|
||||||
<!-- Mute Toggle -->
|
<!-- Mute Toggle -->
|
||||||
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="toggleMute()"
|
||||||
|
[class]="getMuteButtonClass()"
|
||||||
|
>
|
||||||
@if (isMuted()) {
|
@if (isMuted()) {
|
||||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucideMicOff"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucideMic" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Deafen Toggle -->
|
<!-- Deafen Toggle -->
|
||||||
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
<button
|
||||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
type="button"
|
||||||
|
(click)="toggleDeafen()"
|
||||||
|
[class]="getDeafenButtonClass()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideHeadphones"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Screen Share Toggle -->
|
<!-- Screen Share Toggle -->
|
||||||
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="toggleScreenShare()"
|
||||||
|
[class]="getScreenShareButtonClass()"
|
||||||
|
>
|
||||||
@if (isScreenSharing()) {
|
@if (isScreenSharing()) {
|
||||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucideMonitorOff"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucideMonitor" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucideMonitor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -68,7 +105,10 @@
|
|||||||
(click)="disconnect()"
|
(click)="disconnect()"
|
||||||
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
|
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePhoneOff" class="w-5 h-5" />
|
<ng-icon
|
||||||
|
name="lucidePhoneOff"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ interface AudioDevice {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-controls',
|
selector: 'app-voice-controls',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
NgIcon,
|
||||||
|
UserAvatarComponent
|
||||||
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMic,
|
lucideMic,
|
||||||
@@ -164,12 +168,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.inputDevices.set(
|
this.inputDevices.set(
|
||||||
devices
|
devices
|
||||||
.filter((device) => device.kind === 'audioinput')
|
.filter((device) => device.kind === 'audioinput')
|
||||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
.map((device) => ({ deviceId: device.deviceId,
|
||||||
|
label: device.label }))
|
||||||
);
|
);
|
||||||
|
|
||||||
this.outputDevices.set(
|
this.outputDevices.set(
|
||||||
devices
|
devices
|
||||||
.filter((device) => device.kind === 'audiooutput')
|
.filter((device) => device.kind === 'audiooutput')
|
||||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
.map((device) => ({ deviceId: device.deviceId,
|
||||||
|
label: device.label }))
|
||||||
);
|
);
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
}
|
}
|
||||||
@@ -502,7 +509,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
||||||
this.applyOutputDevice();
|
this.applyOutputDevice();
|
||||||
// Always sync the desired noise-reduction preference (even before
|
// Always sync the desired noise-reduction preference (even before
|
||||||
// a mic stream exists — the flag will be honoured on connect).
|
// a mic stream exists - the flag will be honoured on connect).
|
||||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Component, input, output, HostListener } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
HostListener
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable confirmation dialog modal.
|
* Reusable confirmation dialog modal.
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Component, input, output, HostListener } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
HostListener
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic positioned context-menu overlay.
|
* Generic positioned context-menu overlay.
|
||||||
@@ -13,9 +18,9 @@ import { Component, input, output, HostListener } from '@angular/core';
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Built-in item classes are available via the host styles:
|
* Built-in item classes are available via the host styles:
|
||||||
* - `.context-menu-item` — normal item
|
* - `.context-menu-item` - normal item
|
||||||
* - `.context-menu-item-danger` — destructive (red) item
|
* - `.context-menu-item-danger` - destructive (red) item
|
||||||
* - `.context-menu-divider` — horizontal separator
|
* - `.context-menu-divider` - horizontal separator
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-context-menu',
|
selector: 'app-context-menu',
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { Component, input } from '@angular/core';
|
|||||||
styles: [':host { display: contents; }']
|
styles: [':host { display: contents; }']
|
||||||
})
|
})
|
||||||
export class UserAvatarComponent {
|
export class UserAvatarComponent {
|
||||||
/** Display name — first character is used as fallback initial. */
|
/** Display name - first character is used as fallback initial. */
|
||||||
name = input.required<string>();
|
name = input.required<string>();
|
||||||
/** Optional avatar image URL. */
|
/** Optional avatar image URL. */
|
||||||
avatarUrl = input<string | undefined | null>();
|
avatarUrl = input<string | undefined | null>();
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* Root state definition and barrel exports for the NgRx store.
|
* Root state definition and barrel exports for the NgRx store.
|
||||||
*
|
*
|
||||||
* Three feature slices:
|
* Three feature slices:
|
||||||
* - **messages** – chat messages, reactions, sync state
|
* - **messages** - chat messages, reactions, sync state
|
||||||
* - **users** – online users, bans, roles, voice state
|
* - **users** - online users, bans, roles, voice state
|
||||||
* - **rooms** – servers / rooms, channels, search results
|
* - **rooms** - servers / rooms, channels, search results
|
||||||
*/
|
*/
|
||||||
import { isDevMode } from '@angular/core';
|
import { isDevMode } from '@angular/core';
|
||||||
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
* handlers, and `dispatchIncomingMessage()` is the single entry point
|
* handlers, and `dispatchIncomingMessage()` is the single entry point
|
||||||
* consumed by the `incomingMessages$` effect.
|
* consumed by the `incomingMessages$` effect.
|
||||||
*/
|
*/
|
||||||
import { Observable, of, from, EMPTY } from 'rxjs';
|
import {
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
from,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
import { mergeMap } from 'rxjs/operators';
|
import { mergeMap } from 'rxjs/operators';
|
||||||
import { Action } from '@ngrx/store';
|
import { Action } from '@ngrx/store';
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models';
|
||||||
@@ -264,6 +269,7 @@ function handleMessageEdited(
|
|||||||
content: event.content,
|
content: event.content,
|
||||||
editedAt: event.editedAt
|
editedAt: event.editedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(
|
return of(
|
||||||
MessagesActions.editMessageSuccess({
|
MessagesActions.editMessageSuccess({
|
||||||
messageId: event.messageId,
|
messageId: event.messageId,
|
||||||
|
|||||||
@@ -10,9 +10,19 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import {
|
||||||
|
Actions,
|
||||||
|
createEffect,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from, timer, Subject, EMPTY } from 'rxjs';
|
import {
|
||||||
|
of,
|
||||||
|
from,
|
||||||
|
timer,
|
||||||
|
Subject,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
import {
|
import {
|
||||||
map,
|
map,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
@@ -78,6 +88,7 @@ export class MessagesSyncEffects {
|
|||||||
count,
|
count,
|
||||||
lastUpdated
|
lastUpdated
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
this.webrtc.sendToPeer(peerId, {
|
this.webrtc.sendToPeer(peerId, {
|
||||||
type: 'chat-inventory-request',
|
type: 'chat-inventory-request',
|
||||||
roomId: room.id
|
roomId: room.id
|
||||||
@@ -119,6 +130,7 @@ export class MessagesSyncEffects {
|
|||||||
count,
|
count,
|
||||||
lastUpdated
|
lastUpdated
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
this.webrtc.sendToPeer(pid, {
|
this.webrtc.sendToPeer(pid, {
|
||||||
type: 'chat-inventory-request',
|
type: 'chat-inventory-request',
|
||||||
roomId: activeRoom.id
|
roomId: activeRoom.id
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* Action type strings follow the `[Messages] Event Name` convention and are
|
* Action type strings follow the `[Messages] Event Name` convention and are
|
||||||
* generated automatically by NgRx from the `source` and event key.
|
* generated automatically by NgRx from the `source` and event key.
|
||||||
*/
|
*/
|
||||||
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
import {
|
||||||
|
createActionGroup,
|
||||||
|
emptyProps,
|
||||||
|
props
|
||||||
|
} from '@ngrx/store';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models';
|
||||||
|
|
||||||
export const MessagesActions = createActionGroup({
|
export const MessagesActions = createActionGroup({
|
||||||
|
|||||||
@@ -10,10 +10,23 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import {
|
||||||
|
Actions,
|
||||||
|
createEffect,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from, EMPTY } from 'rxjs';
|
import {
|
||||||
import { mergeMap, catchError, withLatestFrom, switchMap } from 'rxjs/operators';
|
of,
|
||||||
|
from,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
mergeMap,
|
||||||
|
catchError,
|
||||||
|
withLatestFrom,
|
||||||
|
switchMap
|
||||||
|
} from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
@@ -24,10 +37,7 @@ import { TimeSyncService } from '../../core/services/time-sync.service';
|
|||||||
import { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models';
|
||||||
import { hydrateMessages } from './messages.helpers';
|
import { hydrateMessages } from './messages.helpers';
|
||||||
import {
|
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||||
dispatchIncomingMessage,
|
|
||||||
IncomingMessageContext
|
|
||||||
} from './messages-incoming.handlers';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MessagesEffects {
|
export class MessagesEffects {
|
||||||
@@ -65,7 +75,11 @@ export class MessagesEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom)
|
||||||
),
|
),
|
||||||
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
mergeMap(([
|
||||||
|
{ content, replyToId, channelId },
|
||||||
|
currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) {
|
||||||
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
|
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
|
||||||
}
|
}
|
||||||
@@ -84,7 +98,8 @@ export class MessagesEffects {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.db.saveMessage(message);
|
this.db.saveMessage(message);
|
||||||
this.webrtc.broadcastMessage({ type: 'chat-message', message });
|
this.webrtc.broadcastMessage({ type: 'chat-message',
|
||||||
|
message });
|
||||||
|
|
||||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||||
}),
|
}),
|
||||||
@@ -116,10 +131,17 @@ export class MessagesEffects {
|
|||||||
|
|
||||||
const editedAt = this.timeSync.now();
|
const editedAt = this.timeSync.now();
|
||||||
|
|
||||||
this.db.updateMessage(messageId, { content, editedAt });
|
this.db.updateMessage(messageId, { content,
|
||||||
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
|
editedAt });
|
||||||
|
|
||||||
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
|
this.webrtc.broadcastMessage({ type: 'message-edited',
|
||||||
|
messageId,
|
||||||
|
content,
|
||||||
|
editedAt });
|
||||||
|
|
||||||
|
return of(MessagesActions.editMessageSuccess({ messageId,
|
||||||
|
content,
|
||||||
|
editedAt }));
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.editMessageFailure({ error: error.message }))
|
of(MessagesActions.editMessageFailure({ error: error.message }))
|
||||||
@@ -150,7 +172,8 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.db.updateMessage(messageId, { isDeleted: true });
|
this.db.updateMessage(messageId, { isDeleted: true });
|
||||||
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId });
|
this.webrtc.broadcastMessage({ type: 'message-deleted',
|
||||||
|
messageId });
|
||||||
|
|
||||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||||
}),
|
}),
|
||||||
@@ -182,7 +205,9 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.db.updateMessage(messageId, { isDeleted: true });
|
this.db.updateMessage(messageId, { isDeleted: true });
|
||||||
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedBy: currentUser.id });
|
this.webrtc.broadcastMessage({ type: 'message-deleted',
|
||||||
|
messageId,
|
||||||
|
deletedBy: currentUser.id });
|
||||||
|
|
||||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||||
}),
|
}),
|
||||||
@@ -211,7 +236,9 @@ export class MessagesEffects {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.db.saveReaction(reaction);
|
this.db.saveReaction(reaction);
|
||||||
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction });
|
this.webrtc.broadcastMessage({ type: 'reaction-added',
|
||||||
|
messageId,
|
||||||
|
reaction });
|
||||||
|
|
||||||
return of(MessagesActions.addReactionSuccess({ reaction }));
|
return of(MessagesActions.addReactionSuccess({ reaction }));
|
||||||
})
|
})
|
||||||
@@ -256,7 +283,11 @@ export class MessagesEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom)
|
||||||
),
|
),
|
||||||
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
mergeMap(([
|
||||||
|
event,
|
||||||
|
currentUser,
|
||||||
|
currentRoom]: [any, any, any
|
||||||
|
]) => {
|
||||||
const ctx: IncomingMessageContext = {
|
const ctx: IncomingMessageContext = {
|
||||||
db: this.db,
|
db: this.db,
|
||||||
webrtc: this.webrtc,
|
webrtc: this.webrtc,
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export async function hydrateMessage(
|
|||||||
): Promise<Message> {
|
): Promise<Message> {
|
||||||
const reactions = await db.getReactionsForMessage(msg.id);
|
const reactions = await db.getReactionsForMessage(msg.id);
|
||||||
|
|
||||||
return reactions.length > 0 ? { ...msg, reactions } : msg;
|
return reactions.length > 0 ? { ...msg,
|
||||||
|
reactions } : msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hydrates an array of messages with their reactions. */
|
/** Hydrates an array of messages with their reactions. */
|
||||||
@@ -81,7 +82,9 @@ export async function buildInventoryItem(
|
|||||||
): Promise<InventoryItem> {
|
): Promise<InventoryItem> {
|
||||||
const reactions = await db.getReactionsForMessage(msg.id);
|
const reactions = await db.getReactionsForMessage(msg.id);
|
||||||
|
|
||||||
return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length };
|
return { id: msg.id,
|
||||||
|
ts: getMessageTimestamp(msg),
|
||||||
|
rc: reactions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
|
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
|
||||||
@@ -95,9 +98,11 @@ export async function buildLocalInventoryMap(
|
|||||||
messages.map(async (msg) => {
|
messages.map(async (msg) => {
|
||||||
const reactions = await db.getReactionsForMessage(msg.id);
|
const reactions = await db.getReactionsForMessage(msg.id);
|
||||||
|
|
||||||
map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length });
|
map.set(msg.id, { ts: getMessageTimestamp(msg),
|
||||||
|
rc: reactions.length });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,18 +166,22 @@ export async function mergeIncomingMessage(
|
|||||||
const baseMessage = isNewer ? incoming : existing;
|
const baseMessage = isNewer ? incoming : existing;
|
||||||
|
|
||||||
if (!baseMessage) {
|
if (!baseMessage) {
|
||||||
return { message: incoming, changed };
|
return { message: incoming,
|
||||||
|
changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: { ...baseMessage, reactions },
|
message: { ...baseMessage,
|
||||||
|
reactions },
|
||||||
changed
|
changed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return { message: incoming, changed: false };
|
return { message: incoming,
|
||||||
|
changed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message: existing, changed: false };
|
return { message: existing,
|
||||||
|
changed: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
import { createReducer, on } from '@ngrx/store';
|
||||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
import {
|
||||||
|
EntityState,
|
||||||
|
EntityAdapter,
|
||||||
|
createEntityAdapter
|
||||||
|
} from '@ngrx/entity';
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
|
|||||||
export const messagesReducer = createReducer(
|
export const messagesReducer = createReducer(
|
||||||
initialState,
|
initialState,
|
||||||
|
|
||||||
// Load messages — clear stale messages when switching to a different room
|
// Load messages - clear stale messages when switching to a different room
|
||||||
on(MessagesActions.loadMessages, (state, { roomId }) => {
|
on(MessagesActions.loadMessages, (state, { roomId }) => {
|
||||||
if (state.currentRoomId && state.currentRoomId !== roomId) {
|
if (state.currentRoomId && state.currentRoomId !== roomId) {
|
||||||
return messagesAdapter.removeAll({
|
return messagesAdapter.removeAll({
|
||||||
@@ -91,7 +95,8 @@ export const messagesReducer = createReducer(
|
|||||||
messagesAdapter.updateOne(
|
messagesAdapter.updateOne(
|
||||||
{
|
{
|
||||||
id: messageId,
|
id: messageId,
|
||||||
changes: { content, editedAt }
|
changes: { content,
|
||||||
|
editedAt }
|
||||||
},
|
},
|
||||||
state
|
state
|
||||||
)
|
)
|
||||||
@@ -102,7 +107,8 @@ export const messagesReducer = createReducer(
|
|||||||
messagesAdapter.updateOne(
|
messagesAdapter.updateOne(
|
||||||
{
|
{
|
||||||
id: messageId,
|
id: messageId,
|
||||||
changes: { isDeleted: true, content: '[Message deleted]' }
|
changes: { isDeleted: true,
|
||||||
|
content: '[Message deleted]' }
|
||||||
},
|
},
|
||||||
state
|
state
|
||||||
)
|
)
|
||||||
@@ -184,7 +190,8 @@ export const messagesReducer = createReducer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...message, reactions: combined };
|
return { ...message,
|
||||||
|
reactions: combined };
|
||||||
}
|
}
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Rooms store actions using `createActionGroup`.
|
* Rooms store actions using `createActionGroup`.
|
||||||
*/
|
*/
|
||||||
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
import {
|
||||||
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models';
|
createActionGroup,
|
||||||
|
emptyProps,
|
||||||
|
props
|
||||||
|
} from '@ngrx/store';
|
||||||
|
import {
|
||||||
|
Room,
|
||||||
|
RoomSettings,
|
||||||
|
ServerInfo,
|
||||||
|
RoomPermissions,
|
||||||
|
Channel
|
||||||
|
} from '../../core/models';
|
||||||
|
|
||||||
export const RoomsActions = createActionGroup({
|
export const RoomsActions = createActionGroup({
|
||||||
source: 'Rooms',
|
source: 'Rooms',
|
||||||
|
|||||||
@@ -3,9 +3,17 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import {
|
||||||
|
Actions,
|
||||||
|
createEffect,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from, EMPTY } from 'rxjs';
|
import {
|
||||||
|
of,
|
||||||
|
from,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
import {
|
import {
|
||||||
map,
|
map,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
@@ -25,7 +33,12 @@ import { selectCurrentRoom } from './rooms.selectors';
|
|||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import { Room, RoomSettings, RoomPermissions, VoiceState } from '../../core/models';
|
import {
|
||||||
|
Room,
|
||||||
|
RoomSettings,
|
||||||
|
RoomPermissions,
|
||||||
|
VoiceState
|
||||||
|
} from '../../core/models';
|
||||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
|
|
||||||
/** Build a minimal User object from signaling payload. */
|
/** Build a minimal User object from signaling payload. */
|
||||||
@@ -285,11 +298,16 @@ export class RoomsEffects {
|
|||||||
ofType(RoomsActions.deleteRoom),
|
ofType(RoomsActions.deleteRoom),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
filter(
|
filter(
|
||||||
([, currentUser, currentRoom]) => !!currentUser && currentRoom?.hostId === currentUser.id
|
([
|
||||||
|
, currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => !!currentUser && currentRoom?.hostId === currentUser.id
|
||||||
),
|
),
|
||||||
switchMap(([{ roomId }]) => {
|
switchMap(([{ roomId }]) => {
|
||||||
this.db.deleteRoom(roomId);
|
this.db.deleteRoom(roomId);
|
||||||
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
|
this.webrtc.broadcastMessage({ type: 'room-deleted',
|
||||||
|
roomId });
|
||||||
|
|
||||||
this.webrtc.disconnectAll();
|
this.webrtc.disconnectAll();
|
||||||
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
||||||
})
|
})
|
||||||
@@ -318,7 +336,11 @@ export class RoomsEffects {
|
|||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomSettings),
|
ofType(RoomsActions.updateRoomSettings),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
mergeMap(([{ settings }, currentUser, currentRoom]) => {
|
mergeMap(([
|
||||||
|
{ settings },
|
||||||
|
currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) {
|
||||||
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' }));
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' }));
|
||||||
}
|
}
|
||||||
@@ -377,15 +399,22 @@ export class RoomsEffects {
|
|||||||
ofType(RoomsActions.updateRoomPermissions),
|
ofType(RoomsActions.updateRoomPermissions),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
filter(
|
filter(
|
||||||
([{ roomId }, currentUser, currentRoom]) =>
|
([
|
||||||
|
{ roomId },
|
||||||
|
currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) =>
|
||||||
!!currentUser &&
|
!!currentUser &&
|
||||||
!!currentRoom &&
|
!!currentRoom &&
|
||||||
currentRoom.id === roomId &&
|
currentRoom.id === roomId &&
|
||||||
currentRoom.hostId === currentUser.id
|
currentRoom.hostId === currentUser.id
|
||||||
),
|
),
|
||||||
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
mergeMap(([
|
||||||
|
{ roomId, permissions }, , currentRoom
|
||||||
|
]) => {
|
||||||
const updated: Partial<Room> = {
|
const updated: Partial<Room> = {
|
||||||
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions
|
permissions: { ...(currentRoom!.permissions || {}),
|
||||||
|
...permissions } as RoomPermissions
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.updateRoom(roomId, updated);
|
this.db.updateRoom(roomId, updated);
|
||||||
@@ -394,7 +423,9 @@ export class RoomsEffects {
|
|||||||
type: 'room-permissions-update',
|
type: 'room-permissions-update',
|
||||||
permissions: updated.permissions
|
permissions: updated.permissions
|
||||||
} as any);
|
} as any);
|
||||||
return of(RoomsActions.updateRoom({ roomId, changes: updated }));
|
|
||||||
|
return of(RoomsActions.updateRoom({ roomId,
|
||||||
|
changes: updated }));
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -404,7 +435,11 @@ export class RoomsEffects {
|
|||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateServerIcon),
|
ofType(RoomsActions.updateServerIcon),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
mergeMap(([{ roomId, icon }, currentUser, currentRoom]) => {
|
mergeMap(([
|
||||||
|
{ roomId, icon },
|
||||||
|
currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => {
|
||||||
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
||||||
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
||||||
}
|
}
|
||||||
@@ -421,7 +456,8 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconUpdatedAt = Date.now();
|
const iconUpdatedAt = Date.now();
|
||||||
const changes: Partial<Room> = { icon, iconUpdatedAt };
|
const changes: Partial<Room> = { icon,
|
||||||
|
iconUpdatedAt };
|
||||||
|
|
||||||
this.db.updateRoom(roomId, changes);
|
this.db.updateRoom(roomId, changes);
|
||||||
// Broadcast to peers
|
// Broadcast to peers
|
||||||
@@ -431,7 +467,10 @@ export class RoomsEffects {
|
|||||||
icon,
|
icon,
|
||||||
iconUpdatedAt
|
iconUpdatedAt
|
||||||
} as any);
|
} as any);
|
||||||
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
|
|
||||||
|
return of(RoomsActions.updateServerIconSuccess({ roomId,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt }));
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -491,7 +530,11 @@ export class RoomsEffects {
|
|||||||
signalingMessages$ = createEffect(() =>
|
signalingMessages$ = createEffect(() =>
|
||||||
this.webrtc.onSignalingMessage.pipe(
|
this.webrtc.onSignalingMessage.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
|
mergeMap(([
|
||||||
|
message,
|
||||||
|
currentUser,
|
||||||
|
currentRoom]: [any, any, any
|
||||||
|
]) => {
|
||||||
const myId = currentUser?.oderId || currentUser?.id;
|
const myId = currentUser?.oderId || currentUser?.id;
|
||||||
const viewedServerId = currentRoom?.id;
|
const viewedServerId = currentRoom?.id;
|
||||||
|
|
||||||
@@ -533,7 +576,11 @@ export class RoomsEffects {
|
|||||||
this.webrtc.onMessageReceived.pipe(
|
this.webrtc.onMessageReceived.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)),
|
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)),
|
||||||
filter(([, room]) => !!room),
|
filter(([, room]) => !!room),
|
||||||
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
mergeMap(([
|
||||||
|
event,
|
||||||
|
currentRoom,
|
||||||
|
allUsers]: [any, any, any[]
|
||||||
|
]) => {
|
||||||
const room = currentRoom as Room;
|
const room = currentRoom as Room;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -590,7 +637,8 @@ export class RoomsEffects {
|
|||||||
return of(
|
return of(
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
user: buildSignalingUser(
|
user: buildSignalingUser(
|
||||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
{ oderId: userId,
|
||||||
|
displayName: event.displayName || 'User' },
|
||||||
{
|
{
|
||||||
voiceState: {
|
voiceState: {
|
||||||
isConnected: vs.isConnected ?? false,
|
isConnected: vs.isConnected ?? false,
|
||||||
@@ -608,7 +656,8 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
return of(UsersActions.updateVoiceState({ userId,
|
||||||
|
voiceState: vs }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// screen-state
|
// screen-state
|
||||||
@@ -621,7 +670,8 @@ export class RoomsEffects {
|
|||||||
return of(
|
return of(
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
user: buildSignalingUser(
|
user: buildSignalingUser(
|
||||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
{ oderId: userId,
|
||||||
|
displayName: event.displayName || 'User' },
|
||||||
{ screenShareState: { isSharing } }
|
{ screenShareState: { isSharing } }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -700,7 +750,8 @@ export class RoomsEffects {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.db.updateRoom(room.id, updates);
|
this.db.updateRoom(room.id, updates);
|
||||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
return of(RoomsActions.updateRoom({ roomId: room.id,
|
||||||
|
changes: updates }));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
import { createReducer, on } from '@ngrx/store';
|
||||||
import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models';
|
import {
|
||||||
|
Room,
|
||||||
|
ServerInfo,
|
||||||
|
RoomSettings,
|
||||||
|
Channel
|
||||||
|
} from '../../core/models';
|
||||||
import { RoomsActions } from './rooms.actions';
|
import { RoomsActions } from './rooms.actions';
|
||||||
|
|
||||||
/** Default channels for a new server */
|
/** Default channels for a new server */
|
||||||
export function defaultChannels(): Channel[] {
|
export function defaultChannels(): Channel[] {
|
||||||
return [
|
return [
|
||||||
{ id: 'general', name: 'general', type: 'text', position: 0 },
|
{ id: 'general',
|
||||||
{ id: 'random', name: 'random', type: 'text', position: 1 },
|
name: 'general',
|
||||||
{ id: 'vc-general', name: 'General', type: 'voice', position: 0 },
|
type: 'text',
|
||||||
{ id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 }
|
position: 0 },
|
||||||
|
{ id: 'random',
|
||||||
|
name: 'random',
|
||||||
|
type: 'text',
|
||||||
|
position: 1 },
|
||||||
|
{ id: 'vc-general',
|
||||||
|
name: 'General',
|
||||||
|
type: 'voice',
|
||||||
|
position: 0 },
|
||||||
|
{ id: 'vc-afk',
|
||||||
|
name: 'AFK',
|
||||||
|
type: 'voice',
|
||||||
|
position: 1 }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +140,8 @@ export const roomsReducer = createReducer(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.createRoomSuccess, (state, { room }) => {
|
on(RoomsActions.createRoomSuccess, (state, { room }) => {
|
||||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
const enriched = { ...room,
|
||||||
|
channels: room.channels || defaultChannels() };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -149,7 +167,8 @@ export const roomsReducer = createReducer(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
|
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
|
||||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
const enriched = { ...room,
|
||||||
|
channels: room.channels || defaultChannels() };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -181,7 +200,7 @@ export const roomsReducer = createReducer(
|
|||||||
isConnected: false
|
isConnected: false
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// View server – just switch the viewed room, stay connected
|
// View server - just switch the viewed room, stay connected
|
||||||
on(RoomsActions.viewServer, (state) => ({
|
on(RoomsActions.viewServer, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
@@ -189,7 +208,8 @@ export const roomsReducer = createReducer(
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||||
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
const enriched = { ...room,
|
||||||
|
channels: room.channels || defaultChannels() };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -264,7 +284,8 @@ export const roomsReducer = createReducer(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentRoom: { ...state.currentRoom, ...changes }
|
currentRoom: { ...state.currentRoom,
|
||||||
|
...changes }
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -275,14 +296,17 @@ export const roomsReducer = createReducer(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt }
|
currentRoom: { ...state.currentRoom,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt }
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Receive room update
|
// Receive room update
|
||||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
|
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
|
||||||
...state,
|
...state,
|
||||||
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null
|
currentRoom: state.currentRoom ? { ...state.currentRoom,
|
||||||
|
...room } : null
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Clear search results
|
// Clear search results
|
||||||
@@ -309,7 +333,8 @@ export const roomsReducer = createReducer(
|
|||||||
|
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const updatedChannels = [...existing, channel];
|
const updatedChannels = [...existing, channel];
|
||||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
const updatedRoom = { ...state.currentRoom,
|
||||||
|
channels: updatedChannels };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -324,7 +349,8 @@ export const roomsReducer = createReducer(
|
|||||||
|
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
const updatedRoom = { ...state.currentRoom,
|
||||||
|
channels: updatedChannels };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -339,8 +365,10 @@ export const roomsReducer = createReducer(
|
|||||||
return state;
|
return state;
|
||||||
|
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
|
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
|
||||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
name } : channel);
|
||||||
|
const updatedRoom = { ...state.currentRoom,
|
||||||
|
channels: updatedChannels };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Users store actions using `createActionGroup`.
|
* Users store actions using `createActionGroup`.
|
||||||
*/
|
*/
|
||||||
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
import {
|
||||||
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models';
|
createActionGroup,
|
||||||
|
emptyProps,
|
||||||
|
props
|
||||||
|
} from '@ngrx/store';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
BanEntry,
|
||||||
|
VoiceState,
|
||||||
|
ScreenShareState
|
||||||
|
} from '../../core/models';
|
||||||
|
|
||||||
export const UsersActions = createActionGroup({
|
export const UsersActions = createActionGroup({
|
||||||
source: 'Users',
|
source: 'Users',
|
||||||
|
|||||||
@@ -3,13 +3,32 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import {
|
||||||
|
Actions,
|
||||||
|
createEffect,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from, EMPTY } from 'rxjs';
|
import {
|
||||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
of,
|
||||||
|
from,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
catchError,
|
||||||
|
withLatestFrom,
|
||||||
|
tap,
|
||||||
|
switchMap
|
||||||
|
} from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors';
|
import {
|
||||||
|
selectCurrentUser,
|
||||||
|
selectCurrentUserId,
|
||||||
|
selectHostId
|
||||||
|
} from './users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
@@ -67,7 +86,11 @@ export class UsersEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom)
|
||||||
),
|
),
|
||||||
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
mergeMap(([
|
||||||
|
{ userId },
|
||||||
|
currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => {
|
||||||
if (!currentUser || !currentRoom)
|
if (!currentUser || !currentRoom)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
@@ -99,7 +122,11 @@ export class UsersEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom)
|
||||||
),
|
),
|
||||||
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
mergeMap(([
|
||||||
|
{ userId, reason, expiresAt },
|
||||||
|
currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => {
|
||||||
if (!currentUser || !currentRoom)
|
if (!currentUser || !currentRoom)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
@@ -127,7 +154,8 @@ export class UsersEffects {
|
|||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(UsersActions.banUserSuccess({ userId, ban }));
|
return of(UsersActions.banUserSuccess({ userId,
|
||||||
|
ban }));
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -171,7 +199,11 @@ export class UsersEffects {
|
|||||||
this.store.select(selectHostId),
|
this.store.select(selectHostId),
|
||||||
this.store.select(selectCurrentUserId)
|
this.store.select(selectCurrentUserId)
|
||||||
),
|
),
|
||||||
mergeMap(([{ userId }, hostId, currentUserId]) =>
|
mergeMap(([
|
||||||
|
{ userId },
|
||||||
|
hostId,
|
||||||
|
currentUserId
|
||||||
|
]) =>
|
||||||
userId === hostId && currentUserId
|
userId === hostId && currentUserId
|
||||||
? of(UsersActions.updateHost({ userId: currentUserId }))
|
? of(UsersActions.updateHost({ userId: currentUserId }))
|
||||||
: EMPTY
|
: EMPTY
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
import { createReducer, on } from '@ngrx/store';
|
||||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
import {
|
||||||
|
EntityState,
|
||||||
|
EntityAdapter,
|
||||||
|
createEntityAdapter
|
||||||
|
} from '@ngrx/entity';
|
||||||
import { User, BanEntry } from '../../core/models';
|
import { User, BanEntry } from '../../core/models';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
|
|
||||||
|
|||||||
24
tools/eslint-rules.js
Normal file
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