Now formatted correctly with eslint

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

5
.prettierignore Normal file
View File

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

14
.prettierrc.json Normal file
View File

@@ -0,0 +1,14 @@
{
"singleAttributePerLine": true,
"htmlWhitespaceSensitivity": "css",
"printWidth": 150,
"proseWrap": "preserve",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

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

58
.vscode/tasks.json vendored
View File

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

View File

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

View File

@@ -1,39 +1,57 @@
// ESLint Flat Config for Weaver
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const stylisticTs = require('@stylistic/eslint-plugin-ts');
const stylisticJs = require('@stylistic/eslint-plugin-js');
const newlines = require('eslint-plugin-import-newlines');
// Inline plugin: ban en dash (, U+2013) and em dash (—, U+2014) from source files
const noDashPlugin = {
rules: {
'no-unicode-dashes': {
meta: { fixable: 'code' },
create(context) {
const BANNED = [
{ char: '\u2013', name: 'en dash ()' },
{ char: '\u2014', name: 'em dash (—)' }
];
return {
Program() {
const src = context.getSourceCode().getText();
for (const { char, name } of BANNED) {
let idx = src.indexOf(char);
while (idx !== -1) {
const start = idx;
const end = idx + char.length;
context.report({
loc: context.getSourceCode().getLocFromIndex(idx),
message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`,
fix(fixer) {
return fixer.replaceTextRange([start, end], '-');
}
});
idx = src.indexOf(char, idx + 1);
}
}
}
};
}
}
}
};
module.exports = tseslint.config(
{
ignores: [
'**/generated/*',
'dist/**',
'dist-electron/**',
'.angular/**',
'**/migrations/**',
'release/**',
'src/index.html',
'server/**'
]
},
{
files: ['src/app/core/services/**/*.ts'],
rules: {
'@typescript-eslint/member-ordering': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-invalid-void-type': 'off',
'@typescript-eslint/prefer-for-of': 'off',
'id-length': 'off',
'max-statements-per-line': 'off'
}
ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**']
},
{
files: ['**/*.ts'],
plugins: {
'@stylistic/ts': stylisticTs,
'@stylistic/js': stylisticJs
'@stylistic/js': stylisticJs,
'import-newlines': newlines,
'no-dashes': noDashPlugin
},
extends: [
eslint.configs.recommended,
@@ -44,92 +62,38 @@ module.exports = tseslint.config(
],
processor: angular.processInlineTemplates,
rules: {
'no-dashes/no-unicode-dashes': 'error',
'@typescript-eslint/no-extraneous-class': 'off',
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
'@angular-eslint/directive-class-suffix': 'error',
'@typescript-eslint/explicit-module-boundry-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/explicit-member-accessibility': ['error',{ accessibility: 'no-public' }],
'@typescript-eslint/array-type': ['error',{ default: 'array' }],
'@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/dot-notation': 'off',
'@stylistic/ts/indent': [
'error',
2,
{
ignoredNodes: [
'TSTypeParameterInstantiation',
'@stylistic/ts/indent': ['error',2,{ ignoredNodes:[
'TSTypeParameterInstantation',
'FunctionExpression > .params[decorators.length > 0]',
'FunctionExpression > .params > :matches(Decorator, :not(:first-child))',
'ClassBody.body > PropertyDefinition[decorators.length > 0] > .key'
],
SwitchCase: 1
}
],
'@stylistic/ts/member-delimiter-style': [
'error',
{
multiline: { delimiter: 'semi', requireLast: true },
singleline: { delimiter: 'semi', requireLast: false }
}
],
'@typescript-eslint/member-ordering': [
'error',
{
default: [
'signature',
'call-signature',
'public-static-field',
'protected-static-field',
'private-static-field',
'#private-static-field',
'public-decorated-field',
'protected-decorated-field',
'private-decorated-field',
'public-instance-field',
'protected-instance-field',
'private-instance-field',
'#private-instance-field',
'public-abstract-field',
'protected-abstract-field',
'public-field',
'protected-field',
'private-field',
'#private-field',
'static-field',
'instance-field',
'abstract-field',
'decorated-field',
'field',
'static-initialization',
'public-constructor',
'protected-constructor',
'private-constructor',
'constructor',
'public-static-method',
'protected-static-method',
'private-static-method',
'#private-static-method',
'public-decorated-method',
'protected-decorated-method',
'private-decorated-method',
'public-instance-method',
'protected-instance-method',
'private-instance-method',
'#private-instance-method',
'public-abstract-method',
'protected-abstract-method',
'public-method',
'protected-method',
'private-method',
'#private-method',
'static-method',
'instance-method',
'abstract-method',
'decorated-method',
'method'
]
}
],
], SwitchCase:1 }],
'@stylistic/ts/member-delimiter-style': ['error',{ multiline:{ delimiter:'semi', requireLast:true }, singleline:{ delimiter:'semi', requireLast:false } }],
'@typescript-eslint/member-ordering': ['error',{ default:[
'signature','call-signature',
'public-static-field','protected-static-field','private-static-field','#private-static-field',
'public-decorated-field','protected-decorated-field','private-decorated-field',
'public-instance-field','protected-instance-field','private-instance-field','#private-instance-field',
'public-abstract-field','protected-abstract-field',
'public-field','protected-field','private-field','#private-field',
'static-field','instance-field','abstract-field','decorated-field','field','static-initialization',
'public-constructor','protected-constructor','private-constructor','constructor',
'public-static-method','protected-static-method','private-static-method','#private-static-method',
'public-decorated-method','protected-decorated-method','private-decorated-method',
'public-instance-method','protected-instance-method','private-instance-method','#private-instance-method',
'public-abstract-method','protected-abstract-method','public-method','protected-method','private-method','#private-method',
'static-method','instance-method','abstract-method','decorated-method','method'
] }],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
@@ -149,8 +113,8 @@ module.exports = tseslint.config(
'@stylistic/ts/comma-dangle': ['error','never'],
'@stylistic/ts/comma-spacing': 'error',
'@stylistic/js/comma-style': 'error',
complexity: ['warn', { max: 20 }],
curly: 'off',
'complexity': ['warn',{ max:20 }],
'curly': 'off',
'eol-last': 'error',
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'],
'max-len': ['error',{ code:150, ignoreComments:true }],
@@ -177,31 +141,54 @@ module.exports = tseslint.config(
'@stylistic/js/space-in-parens': 'error',
'@stylistic/js/space-unary-ops': 'error',
'@stylistic/js/spaced-comment': ['error','always',{ markers:['/'] }],
"import-newlines/enforce": [
"error",
2
],
// Require spaces inside single-line blocks: { stmt; }
'@stylistic/js/block-spacing': ['error','always'],
// Disallow single-line if statements but allow body on the next line (with or without braces)
// Examples allowed:
// if (condition)\n return true;
// if (condition)\n {\n return true;\n }
'nonblock-statement-body-position': ['error', 'below'],
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
'max-statements-per-line': ['error', { max: 1 }],
// Prevent single-character identifiers for variables/params; do not check object property names
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }],
// Require blank lines around block-like statements (if, function, class, switch, try, etc.)
'padding-line-between-statements': [
'error',
// Ensure blank lines around standalone if statements within the same scope
{ blankLine: 'always', prev: '*', next: 'if' },
{ blankLine: 'always', prev: 'if', next: '*' },
// Keep clear separation around any block-like statement (if, function, class, switch, try, etc.)
{ blankLine: 'always', prev: '*', next: 'block-like' },
{ blankLine: 'always', prev: 'block-like', next: '*' },
{ blankLine: 'always', prev: 'function', next: '*' },
{ blankLine: 'always', prev: 'class', next: '*' },
// Always require a blank line after functions (and multiline expressions)
{ blankLine: 'always', prev: ['function', 'multiline-expression'], next: '*' },
// Always require a blank line after class declarations (and multiline expressions)
{ blankLine: 'always', prev: ['class', 'multiline-expression'], next: '*' },
// Always require a blank line after groups of variable declarations
{ blankLine: 'always', prev: 'const', next: '*' },
{ blankLine: 'always', prev: 'let', next: '*' },
{ blankLine: 'always', prev: 'var', next: '*' },
// But never require a blank line between a series of variable declarations of the same kind
{ blankLine: 'never', prev: 'const', next: 'const' },
{ blankLine: 'never', prev: 'let', next: 'let' },
{ blankLine: 'never', prev: 'var', next: 'var' }
]
}
},
// HTML template formatting rules (external Angular templates only)
{
files: ['src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {
'no-dashes/no-unicode-dashes': 'error',
// Angular template best practices
'@angular-eslint/template/button-has-type': 'warn',
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
'@angular-eslint/template/eqeqeq': 'error',
@@ -210,9 +197,13 @@ module.exports = tseslint.config(
'@angular-eslint/template/prefer-self-closing-tags': 'warn',
'@angular-eslint/template/use-track-by-function': 'warn',
'@angular-eslint/template/no-negated-async': 'warn',
'@angular-eslint/template/no-call-expression': 'off'
}
}
'@angular-eslint/template/no-call-expression': 'off', // Allow method calls in templates
// Note: attributes-order is disabled in favor of Prettier handling formatting
// Prettier uses singleAttributePerLine to enforce property grouping
},
},
);
// IMPORTANT: Formatting is handled by Prettier; ESLint validates logic/accessibility.
// IMPORTANT: Formatting is handled by Prettier, not ESLint
// ESLint validates logic/accessibility, Prettier handles formatting
// Enable format on save in VS Code settings to use Prettier automatically

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,9 +30,9 @@ export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
/** Data channel name used for P2P chat */
export const DATA_CHANNEL_LABEL = 'chat';
/** High-water mark (bytes) pause sending when buffered amount exceeds this */
/** High-water mark (bytes) - pause sending when buffered amount exceeds this */
export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
/** Low-water mark (bytes) resume sending once buffered amount drops below this */
/** Low-water mark (bytes) - resume sending once buffered amount drops below this */
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<div class="chat-layout relative h-full">
<!-- Messages List -->
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
<div
#messagesContainer
class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4"
(scroll)="onScroll()"
>
<!-- Syncing indicator -->
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
@@ -25,7 +29,10 @@
@if (loadingMore()) {
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
} @else {
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
<button
(click)="loadMore()"
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary"
>
Load older messages
</button>
}
@@ -38,16 +45,26 @@
[class.opacity-50]="message.isDeleted"
>
<!-- Avatar -->
<app-user-avatar [name]="message.senderName" size="md" class="flex-shrink-0" />
<app-user-avatar
[name]="message.senderName"
size="md"
class="flex-shrink-0"
/>
<!-- Message Content -->
<div class="flex-1 min-w-0">
<!-- Reply indicator -->
@if (message.replyToId) {
@let repliedMsg = getRepliedMessage(message.replyToId);
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
<div
class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
(click)="scrollToMessage(message.replyToId)"
>
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
<ng-icon name="lucideReply" class="w-3 h-3" />
<ng-icon
name="lucideReply"
class="w-3 h-3"
/>
@if (repliedMsg) {
<span class="font-medium">{{ repliedMsg.senderName }}</span>
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
@@ -80,19 +97,31 @@
(click)="saveEdit(message.id)"
class="p-1 text-primary hover:bg-primary/10 rounded"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
</button>
<button
(click)="cancelEdit()"
class="p-1 text-muted-foreground hover:bg-secondary rounded"
>
<ng-icon name="lucideX" class="w-4 h-4" />
<ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button>
</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark [markdown]="message.content" [processor]="remarkProcessor">
<ng-template [remarkTemplate]="'code'" let-node>
<remark
[markdown]="message.content"
[processor]="remarkProcessor"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (node.lang === 'mermaid') {
<remark-mermaid [code]="node.value" />
} @else {
@@ -107,7 +136,10 @@
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<!-- Available image with hover overlay -->
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
<div
class="relative group/img inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
@@ -121,14 +153,20 @@
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="View full size"
>
<ng-icon name="lucideExpand" class="w-4 h-4" />
<ng-icon
name="lucideExpand"
class="w-4 h-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-4 h-4" />
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
</div>
</div>
@@ -137,24 +175,33 @@
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
<ng-icon
name="lucideImage"
class="w-5 h-5 text-primary"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<!-- Unavailable waiting for source -->
<!-- Unavailable - waiting for source -->
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
<ng-icon
name="lucideImage"
class="w-5 h-5 text-muted-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
@@ -181,10 +228,13 @@
@if (!isUploader(att)) {
@if (!att.available) {
<div class="w-24 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
@@ -193,18 +243,24 @@
<button
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>Request</button>
>
Request
</button>
} @else {
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>Cancel</button>
>
Cancel
</button>
}
} @else {
<button
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
(click)="downloadAttachment(att)"
>Download</button>
>
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
@@ -238,14 +294,19 @@
<!-- Message Actions (visible on hover) -->
@if (!message.isDeleted) {
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg">
<div
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg"
>
<!-- Emoji Picker Toggle -->
<div class="relative">
<button
(click)="toggleEmojiPicker(message.id)"
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
>
<ng-icon name="lucideSmile" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideSmile"
class="w-4 h-4 text-muted-foreground"
/>
</button>
@if (showEmojiPicker() === message.id) {
@@ -267,7 +328,10 @@
(click)="setReplyTo(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideReply"
class="w-4 h-4 text-muted-foreground"
/>
</button>
<!-- Edit (own messages only) -->
@@ -276,7 +340,10 @@
(click)="startEdit(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon name="lucideEdit" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideEdit"
class="w-4 h-4 text-muted-foreground"
/>
</button>
}
@@ -286,7 +353,10 @@
(click)="deleteMessage(message)"
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
>
<ng-icon name="lucideTrash2" class="w-4 h-4 text-destructive" />
<ng-icon
name="lucideTrash2"
class="w-4 h-4 text-destructive"
/>
</button>
}
</div>
@@ -299,24 +369,40 @@
<div class="sticky bottom-4 flex justify-center pointer-events-none">
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
<span class="text-sm text-muted-foreground">New messages</span>
<button (click)="readLatest()" class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm">Read latest</button>
<button
(click)="readLatest()"
class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm"
>
Read latest
</button>
</div>
</div>
}
</div>
<!-- Bottom bar: floats over messages -->
<div #bottomBar class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<div
#bottomBar
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<!-- Reply Preview -->
@if (replyTo()) {
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
<ng-icon
name="lucideReply"
class="w-4 h-4 text-muted-foreground"
/>
<span class="text-sm text-muted-foreground flex-1">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button (click)="clearReply()" class="p-1 hover:bg-secondary rounded">
<ng-icon name="lucideX" class="w-4 h-4 text-muted-foreground" />
<button
(click)="clearReply()"
class="p-1 hover:bg-secondary rounded"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-muted-foreground"
/>
</button>
</div>
}
@@ -326,23 +412,100 @@
<!-- Markdown Toolbar -->
@if (toolbarVisible()) {
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">&#96;</button>
<div
class="pointer-events-auto"
(mousedown)="$event.preventDefault()"
(mouseenter)="onToolbarMouseEnter()"
(mouseleave)="onToolbarMouseLeave()"
>
<div
class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm"
>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline('**')"
>
<b>B</b>
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline('*')"
>
<i>I</i>
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline('~~')"
>
<s>S</s>
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline(inlineCodeToken)"
>
&#96;
</button>
<span class="mx-1 text-muted-foreground">|</span>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHeading(1)"
>
H1
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHeading(2)"
>
H2
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHeading(3)"
>
H3
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyPrefix('> ')"
>
Quote
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyPrefix('- ')"
>
• List
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyOrderedList()"
>
1. List
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyCodeBlock()"
>
Code
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyLink()"
>
Link
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyImage()"
>
Image
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHorizontalRule()"
>
HR
</button>
</div>
</div>
}
@@ -379,11 +542,16 @@
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
[class.visible]="inputHovered() || messageContent.trim().length > 0"
>
<ng-icon name="lucideSend" class="w-4 h-4" />
<ng-icon
name="lucideSend"
class="w-4 h-4"
/>
</button>
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center">
<div
class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center"
>
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@@ -394,7 +562,12 @@
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
<button
(click)="removePendingFile(file)"
class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5"
>
Remove
</button>
</div>
}
</div>
@@ -413,7 +586,10 @@
tabindex="0"
#lightboxBackdrop
>
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
<div
class="relative max-w-[90vw] max-h-[90vh]"
(click)="$event.stopPropagation()"
>
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
@@ -427,14 +603,20 @@
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-5 h-5" />
<ng-icon
name="lucideDownload"
class="w-5 h-5"
/>
</button>
<button
(click)="closeLightbox()"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Close"
>
<ng-icon name="lucideX" class="w-5 h-5" />
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
</div>
<!-- Bottom info bar -->
@@ -450,14 +632,31 @@
<!-- Image Context Menu -->
@if (imageContextMenu()) {
<app-context-menu [x]="imageContextMenu()!.x" [y]="imageContextMenu()!.y" (closed)="closeImageContextMenu()">
<button (click)="copyImageToClipboard(imageContextMenu()!.attachment)" class="context-menu-item-icon">
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
<app-context-menu
[x]="imageContextMenu()!.x"
[y]="imageContextMenu()!.y"
(closed)="closeImageContextMenu()"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
class="context-menu-item-icon"
>
<ng-icon
name="lucideCopy"
class="w-4 h-4 text-muted-foreground"
/>
Copy Image
</button>
<button (click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()" class="context-menu-item-icon">
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4 text-muted-foreground"
/>
Save Image
</button>
</app-context-menu>
}
</div>

View File

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

View File

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

View File

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

View File

@@ -29,8 +29,12 @@
>
<!-- 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"
<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>
@@ -43,10 +47,16 @@
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
<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" />
<ng-icon
name="lucideCrown"
class="w-3 h-3 text-yellow-500"
/>
}
</div>
</div>
@@ -54,15 +64,27 @@
<!-- 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" />
<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" />
<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" />
<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" />
<ng-icon
name="lucideMonitor"
class="w-4 h-4 text-primary"
/>
}
</div>
@@ -82,10 +104,16 @@
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" />
<ng-icon
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
} @else {
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
}
</button>
@@ -95,7 +123,10 @@
(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" />
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
<span>Kick</span>
</button>
<button
@@ -103,7 +134,10 @@
(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" />
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
</button>
</div>
@@ -112,9 +146,7 @@
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">
No users online
</div>
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
}
</div>
@@ -129,11 +161,16 @@
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
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>
<label
for="ban-reason-input"
class="block text-sm font-medium text-foreground mb-1"
>Reason (optional)</label
>
<input
type="text"
[(ngModel)]="banReason"
@@ -144,7 +181,11 @@
</div>
<div>
<label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label>
<label
for="ban-duration-select"
class="block text-sm font-medium text-foreground mb-1"
>Duration</label
>
<select
[(ngModel)]="banDuration"
id="ban-duration-select"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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