# Conflicts:
#	README.md
This commit is contained in:
J3vb
2026-04-05 09:23:19 +02:00
420 changed files with 38636 additions and 8199 deletions
+71 -13
View File
@@ -19,28 +19,35 @@ jobs:
run:
working-directory: Server/
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@v5
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: "1.25"
cache-dependency-path: Server/go.sum
- name: Build server
run: go build -o chatserver.exe -ldflags "-s -w" .
- name: Run tests with coverage
run: go test ./... -coverprofile=coverage.out -cover
- name: Go vulnerability check
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./...
- name: Run tests with race detection and coverage
run: go test -race -timeout 20m ./... -coverprofile=coverage.out -cover
- name: Run tests with deadlock detection
run: go test -tags deadlock -count=1 ./...
- name: Upload Go coverage
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: go-coverage
path: Server/coverage.out
retention-days: 7
- name: Lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.11.3
working-directory: Server/
@@ -52,9 +59,9 @@ jobs:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
@@ -63,15 +70,30 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Security audit (npm)
run: npm audit --audit-level=high
- name: Oxlint (fast correctness checks)
run: npx oxlint src/
- name: TypeScript check
run: npx tsc --noEmit
- name: ESLint (type-aware rules)
run: npx eslint src/
- name: Prettier format check
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
- name: Knip (unused code & deps)
run: npx knip || true
- name: Run unit tests with coverage
run: npx vitest run --coverage --reporter=default
- name: Upload client coverage
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: client-coverage
path: Client/tauri-client/coverage/
@@ -87,25 +109,61 @@ jobs:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
cache-dependency-path: Client/tauri-client/package-lock.json
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with:
workspaces: Client/tauri-client/src-tauri
- name: Install npm dependencies
run: npm ci
- name: Install tauri-typegen
run: cargo install tauri-typegen@0.5.0 --quiet
- name: Generate TypeScript IPC bindings
working-directory: Client/tauri-client/
run: cargo tauri-typegen generate
- name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround)
working-directory: Client/tauri-client/
# tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation.
# Duplicate events are avoided at source by using one emit() call site per event name.
run: |
node -e "
const fs = require('fs');
const tp = fs.readFileSync('src/generated/types.ts', 'utf8');
if (!tp.includes('export type Value')) {
fs.writeFileSync('src/generated/types.ts', tp.replace(
'export interface CredentialData',
'export type Value = unknown;\n\nexport interface CredentialData'
));
}
console.log('Generated bindings patched.');
"
- name: Clippy lint (Rust)
working-directory: Client/tauri-client/src-tauri/
run: cargo clippy -- -D warnings
- name: Security audit (Rust dependencies)
working-directory: Client/tauri-client/src-tauri/
run: |
cargo install cargo-audit@0.22.1 --quiet
cargo audit
- name: Build Tauri app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+3 -2
View File
@@ -1,7 +1,7 @@
name: Claude Code Review
on:
pull_request:
pull_request_target:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
pull-requests: write
issues: read
id-token: write
@@ -29,6 +29,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
- name: Run Claude Code Review
+35 -1
View File
@@ -85,6 +85,37 @@ jobs:
}
$lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline
$manifest = @{
version = "v$env:VERSION"
asset = "chatserver.exe"
sha256 = $serverHash
} | ConvertTo-Json -Compress
$manifest | Out-File -FilePath Server/server-update-manifest.json -Encoding utf8 -NoNewline
- name: Sign server update assets
working-directory: Client/tauri-client
shell: pwsh
env:
SERVER_UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY }}
SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
$keyPath = Join-Path $env:RUNNER_TEMP 'owncord-server-update.key'
[System.IO.File]::WriteAllText($keyPath, $env:SERVER_UPDATE_SIGNING_PRIVATE_KEY)
try {
npx tauri signer sign -k $keyPath -p "$env:SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../Server/chatserver.exe
npx tauri signer sign -k $keyPath -p "$env:SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../Server/server-update-manifest.json
}
finally {
Remove-Item $keyPath -Force -ErrorAction SilentlyContinue
}
- name: Install root dependencies (changelogen)
run: npm ci
- name: Generate changelog
shell: bash
run: npx changelogen --output CHANGELOG.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -92,6 +123,9 @@ jobs:
run: |
ASSETS=(
Server/chatserver.exe
Server/chatserver.exe.sig
Server/server-update-manifest.json
Server/server-update-manifest.json.sig
"${{ steps.artifacts.outputs.installer_path }}"
checksums.sha256
)
@@ -103,5 +137,5 @@ jobs:
ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}")
fi
gh release create ${{ github.ref_name }} \
--generate-notes \
--notes-file CHANGELOG.md \
"${ASSETS[@]}"
+13
View File
@@ -8,6 +8,9 @@ CLAUDE.md
.github/copilot-instructions.md
.github/instructions/
# Agent worktrees (Mission Control fleet isolation)
.worktrees/
# AI-specific / internal planning docs
docs/brain/
docs/CODEMAPS/
@@ -19,6 +22,8 @@ skills/
# Server runtime artifacts
Server/chatserver.exe
Server/chatserver.exe~
Server/owncord-server.exe
Server/server.exe
Server/config.yaml
Server/data/
@@ -39,6 +44,10 @@ Client/publish-release/
Client/login-mockup.html
Client/ui-mockup.html
# Tauri typegen (auto-generated IPC bindings)
Client/tauri-client/src/generated/
.typecache
# Node modules
node_modules/
@@ -48,6 +57,10 @@ node_modules/
.mcp.json
.superpowers/
# Internal dev tools
tools/
.cache/
# Internal dev files
SKILL.md
TODOS.md
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn"
},
"rules": {
"no-map-spread": "off"
},
"ignorePatterns": ["dist", "node_modules", "public"]
}
+6
View File
@@ -0,0 +1,6 @@
dist/
src-tauri/
node_modules/
public/
coverage/
*.html
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/main.ts"],
"project": ["src/**/*.ts"],
"ignore": [
"public/**",
"src-tauri/**"
],
"ignoreDependencies": [
"@tauri-apps/cli"
],
"ignoreExportsUsedInFile": true
}
+3789 -2
View File
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -17,23 +17,46 @@
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:browser": "vitest run --config vitest.config.browser.ts",
"typecheck": "tsc --noEmit",
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix"
"lint": "oxlint src/ && eslint src/",
"lint:fix": "eslint src/ --fix",
"lint:ox": "oxlint src/",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"knip": "knip",
"test:mutate": "stryker run",
"test:mutate:dry": "stryker run --dryRunOnly"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1",
"@stryker-mutator/core": "^9.6.0",
"@stryker-mutator/typescript-checker": "^9.6.0",
"@stryker-mutator/vitest-runner": "^9.6.0",
"@tauri-apps/cli": "^2",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^9.39.4",
"jsdom": "^29.0.0",
"knip": "^6.1.1",
"oxlint": "^1.58.0",
"prettier": "^3.8.1",
"typescript": "^5.7",
"typescript-eslint": "^8.58.0",
"vite": "^6",
"vitest": "^3"
},
"prettier": {
"singleQuote": false,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "crlf"
},
"dependencies": {
"@jitsi/rnnoise-wasm": "^0.2.1",
"@tauri-apps/api": "^2.10.1",
@@ -46,6 +69,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.18.0"
"livekit-client": "^2.18.0",
"zod": "^4.3.6"
}
}
+324 -2
View File
@@ -366,6 +366,16 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -516,11 +526,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf 0.11.3",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf 0.11.3",
"phf_codegen 0.11.3",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -546,6 +620,19 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -676,6 +763,25 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -843,6 +949,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1000,6 +1112,12 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1589,6 +1707,30 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.11.0",
"ignore",
"walkdir",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -1777,6 +1919,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -1989,6 +2140,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2012,6 +2179,19 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]]
name = "infer"
version = "0.19.0"
@@ -2192,6 +2372,12 @@ dependencies = [
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2238,6 +2424,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
@@ -2511,6 +2703,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "objc2"
version = "0.6.4"
@@ -2729,6 +2927,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-typegen",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
@@ -2790,6 +2989,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -2802,6 +3010,49 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -3704,9 +3955,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3880,6 +4131,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-rename-rule"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3"
[[package]]
name = "serde-untagged"
version = "0.1.9"
@@ -4111,6 +4368,16 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -4756,6 +5023,27 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-typegen"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dbffa54fe0a9bb776ffd2f2f52ef4d3324d53e60fe92436df004d2c3d78873b"
dependencies = [
"chrono",
"clap",
"indicatif",
"proc-macro2",
"quote",
"regex",
"serde",
"serde-rename-rule",
"serde_json",
"syn 2.0.117",
"tera",
"thiserror 2.0.18",
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.8.3"
@@ -4851,6 +5139,28 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tera"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
dependencies = [
"chrono",
"chrono-tz",
"globwalk",
"humansize",
"lazy_static",
"percent-encoding",
"pest",
"pest_derive",
"rand 0.8.5",
"regex",
"serde",
"serde_json",
"slug",
"unicode-segmentation",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -5252,6 +5562,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5316,6 +5632,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
+1
View File
@@ -10,6 +10,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-typegen = "0.5"
[features]
default = ["devtools"]
@@ -72,7 +72,10 @@
"identifier": "fs:allow-write-file",
"allow": [
{
"path": "**"
"path": "$APPDATA/**"
},
{
"path": "$APPLOG/**"
}
]
},
@@ -78,8 +78,12 @@ pub fn store_cert_fingerprint(
// Normalize to lowercase for consistent comparison with ws_proxy fingerprints
let fingerprint = fingerprint.to_lowercase();
if host.is_empty() {
return Err("host must not be empty".into());
if host.is_empty() || host.len() > 253 {
return Err("host must be 1-253 characters".into());
}
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
return Err("host contains invalid characters".into());
}
if fingerprint.is_empty() {
return Err("fingerprint must not be empty".into());
@@ -142,7 +146,6 @@ pub fn get_cert_fingerprint(
pub fn open_devtools(_window: tauri::WebviewWindow) {
#[cfg(feature = "devtools")]
{
use tauri::Manager;
_window.open_devtools();
}
}
@@ -2,9 +2,12 @@ use serde::Serialize;
use std::ptr;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::ERROR_NOT_FOUND;
// CRED_PERSIST_ENTERPRISE scopes credentials per-user (roams with domain
// profile). Previously CRED_PERSIST_LOCAL_MACHINE was used, which exposes
// credentials to all users on shared machines.
use windows::Win32::Security::Credentials::{
CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS,
CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC,
CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC,
};
/// Data returned from `load_credential`.
@@ -12,7 +15,9 @@ use windows::Win32::Security::Credentials::{
pub struct CredentialData {
pub username: String,
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
// Password is stored in the credential blob for re-authentication but
// is never serialized back to the frontend over IPC to limit exposure.
#[serde(skip)]
pub password: Option<String>,
}
@@ -74,7 +79,7 @@ pub fn save_credential(host: String, username: String, token: String, password:
}
let blob = payload.to_string().into_bytes();
let mut cred = CREDENTIALW {
let cred = CREDENTIALW {
Flags: CRED_FLAGS(0),
Type: CRED_TYPE_GENERIC,
TargetName: PWSTR(target.as_ptr() as *mut u16),
@@ -82,7 +87,7 @@ pub fn save_credential(host: String, username: String, token: String, password:
LastWritten: Default::default(),
CredentialBlobSize: blob.len() as u32,
CredentialBlob: blob.as_ptr() as *mut u8,
Persist: CRED_PERSIST_LOCAL_MACHINE,
Persist: CRED_PERSIST_ENTERPRISE,
AttributeCount: 0,
Attributes: ptr::null_mut(),
TargetAlias: PWSTR::null(),
@@ -90,7 +95,7 @@ pub fn save_credential(host: String, username: String, token: String, password:
};
unsafe {
CredWriteW(&mut cred, 0)
CredWriteW(&cred, 0)
.map_err(|e| format!("CredWriteW failed: {e}"))?;
}
@@ -1,35 +0,0 @@
use tauri::{Emitter, Runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
/// Registers a global push-to-talk shortcut that emits `ptt-press` and
/// `ptt-release` events to the frontend webview.
pub fn register_push_to_talk<R: Runtime>(
app: &tauri::AppHandle<R>,
shortcut_str: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let shortcut: tauri_plugin_global_shortcut::Shortcut = shortcut_str.parse()?;
// Remove any previous binding for this shortcut before registering.
if app.global_shortcut().is_registered(shortcut) {
app.global_shortcut().unregister(shortcut)?;
}
let handle = app.clone();
app.global_shortcut().on_shortcut(shortcut, move |_app, _shortcut, event| {
let event_name = match event.state {
ShortcutState::Pressed => "ptt-press",
ShortcutState::Released => "ptt-release",
};
let _ = handle.emit(event_name, ());
})?;
Ok(())
}
/// Removes all registered global shortcuts.
pub fn unregister_all<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> Result<(), Box<dyn std::error::Error>> {
app.global_shortcut().unregister_all()?;
Ok(())
}
+1 -1
View File
@@ -1,6 +1,5 @@
mod commands;
mod credentials;
mod hotkeys;
mod livekit_proxy;
mod ptt;
mod tray;
@@ -42,6 +41,7 @@ pub fn run() {
ptt::ptt_listen_for_key,
livekit_proxy::start_livekit_proxy,
livekit_proxy::stop_livekit_proxy,
#[cfg(feature = "devtools")]
commands::open_devtools,
])
.setup(|app| {
@@ -68,20 +68,20 @@ impl LiveKitProxyState {
// ---------------------------------------------------------------------------
/// Tauri store file for certificate fingerprints (shared with ws_proxy).
const CERTS_STORE: &str = "certs.json";
pub(crate) const CERTS_STORE: &str = "certs.json";
/// Verifies the server certificate against a known SHA-256 fingerprint.
/// Reuses the fingerprint stored by ws_proxy's TOFU handshake for the same
/// host, so LiveKit connections are pinned to the same certificate the user
/// already trusted during WebSocket setup.
#[derive(Debug)]
struct PinnedVerifier {
pub(crate) struct PinnedVerifier {
/// Expected SHA-256 colon-hex fingerprint (e.g. "aa:bb:cc:...").
expected_fingerprint: String,
}
impl PinnedVerifier {
fn new(expected_fingerprint: String) -> Self {
pub(crate) fn new(expected_fingerprint: String) -> Self {
Self { expected_fingerprint }
}
}
@@ -155,12 +155,12 @@ impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
/// Produce the cert store key matching ws_proxy's format.
/// ws_proxy extracts the host from "wss://host/path" which omits port 443.
/// We normalise by stripping the default ":443" suffix so the keys match.
fn cert_store_key(remote_host: &str) -> String {
pub(crate) fn cert_store_key(remote_host: &str) -> String {
remote_host.strip_suffix(":443").unwrap_or(remote_host).to_string()
}
/// Load the stored certificate fingerprint for a host from the Tauri cert store.
fn load_stored_fingerprint<R: Runtime>(
pub(crate) fn load_stored_fingerprint<R: Runtime>(
app: &tauri::AppHandle<R>,
host: &str,
) -> Result<Option<String>, String> {
@@ -189,6 +189,16 @@ pub async fn start_livekit_proxy<R: Runtime>(
state: tauri::State<'_, LiveKitProxyState>,
remote_host: String,
) -> Result<u16, String> {
// Reject remote_host values containing CRLF or null bytes to prevent
// HTTP header injection in the proxy's header rewriting logic.
if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') {
return Err("remote_host contains invalid characters".into());
}
// Basic hostname format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !remote_host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
return Err("remote_host contains unexpected characters".into());
}
let mut inner = state.inner.lock().await;
info!("[livekit_proxy] start requested for {}", remote_host);
+28 -18
View File
@@ -57,9 +57,14 @@ pub fn ptt_stop() {
}
/// Set the PTT virtual key code. Pass 0 to disable.
/// Valid range: 0 (disabled) or 1254 (Windows virtual key codes).
#[tauri::command]
pub fn ptt_set_key(vk_code: i32) {
pub fn ptt_set_key(vk_code: i32) -> Result<(), String> {
if vk_code != 0 && !(1..=254).contains(&vk_code) {
return Err(format!("invalid virtual key code: {vk_code} (must be 0 or 1-254)"));
}
PTT_VKEY.store(vk_code, Ordering::SeqCst);
Ok(())
}
/// Get the current PTT virtual key code.
@@ -71,27 +76,32 @@ pub fn ptt_get_key() -> i32 {
/// Wait for the user to press any non-modifier key and return its VK code.
/// Used by the keybind capture UI. Times out after 10 seconds and returns 0
/// to avoid blocking a thread indefinitely if the user navigates away.
/// Runs on a dedicated thread to avoid blocking the Tauri async thread pool.
#[tauri::command]
pub fn ptt_listen_for_key() -> i32 {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
pub async fn ptt_listen_for_key() -> i32 {
tokio::task::spawn_blocking(|| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline {
for vk in 1..=254i32 {
// Skip modifier keys
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
continue;
}
if is_key_down(vk) {
// Wait for release (with its own timeout)
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
std::thread::sleep(Duration::from_millis(20));
while std::time::Instant::now() < deadline {
for vk in 1..=254i32 {
// Skip modifier keys
if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) {
continue;
}
if is_key_down(vk) {
// Wait for release (with its own timeout)
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
std::thread::sleep(Duration::from_millis(20));
}
return vk;
}
return vk;
}
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(20));
}
0 // timed out — no key pressed
0 // timed out — no key pressed
})
.await
.unwrap_or(0)
}
@@ -1,7 +1,10 @@
use std::sync::Arc;
use serde::Serialize;
use tauri::AppHandle;
use tauri_plugin_updater::UpdaterExt;
use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier};
#[derive(Serialize)]
pub struct UpdateCheckResult {
pub available: bool,
@@ -9,6 +12,57 @@ pub struct UpdateCheckResult {
pub body: Option<String>,
}
/// Extract the host (with port if non-443) from an https:// URL for cert store lookup.
fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
let parsed = url::Url::parse(server_url)
.map_err(|e| format!("failed to parse server URL: {e}"))?;
let host = parsed.host_str()
.ok_or_else(|| "server URL has no host".to_string())?;
let port = parsed.port().unwrap_or(443);
let raw = if port == 443 {
host.to_string()
} else {
format!("{host}:{port}")
};
Ok(cert_store_key(&raw))
}
/// Build a rustls ClientConfig that validates the server cert against the
/// TOFU-pinned fingerprint. Falls back to system certs if no fingerprint
/// is stored (server uses a real CA cert).
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> {
let store_key = extract_host_for_cert_store(server_url)?;
let fingerprint = load_stored_fingerprint(app, &store_key)?;
match fingerprint {
Some(fp) => {
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinnedVerifier::new(fp)))
.with_no_client_auth();
Ok(Some(config))
}
None => {
// No TOFU fingerprint stored — use system TLS (works for CA-signed certs).
Ok(None)
}
}
}
/// Validate that a server URL is safe for the updater to connect to.
fn validate_server_url(server_url: &str) -> Result<(), String> {
let trimmed = server_url.trim_end_matches('/');
if !trimmed.starts_with("https://") {
return Err("server_url must use https:// scheme".into());
}
// Reject URLs with userinfo (e.g. "https://evil@host")
if let Ok(parsed) = url::Url::parse(trimmed) {
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("server_url must not contain userinfo".into());
}
}
Ok(())
}
/// Check for a client update using the given server URL to build the endpoint
/// dynamically. This is required because OwnCord is self-hosted and the
/// server address varies per user.
@@ -17,6 +71,8 @@ pub async fn check_client_update(
app: AppHandle,
server_url: String,
) -> Result<UpdateCheckResult, String> {
validate_server_url(&server_url)?;
let current_version = app
.config()
.version
@@ -33,14 +89,20 @@ pub async fn check_client_update(
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// OwnCord is self-hosted and commonly uses self-signed TLS certs.
// The updater connects to the user's own server, so accept invalid certs
// (the update artifact itself is verified via Ed25519 signature).
let updater = app
// Use TOFU-pinned certificate for self-signed servers, or system certs
// for CA-signed servers. Never blindly accept invalid certs (BUG-134).
let tls_config = build_tls_config(&app, &server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder = builder.configure_client(move |client| {
client.use_preconfigured_tls((*config).clone())
});
}
let updater = builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
@@ -71,6 +133,8 @@ pub async fn download_and_install_update(
app: AppHandle,
server_url: String,
) -> Result<(), String> {
validate_server_url(&server_url)?;
let current_version = app
.config()
.version
@@ -87,11 +151,19 @@ pub async fn download_and_install_update(
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
let updater = app
// Use TOFU-pinned certificate for self-signed servers (BUG-134).
let tls_config = build_tls_config(&app, &server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder = builder.configure_client(move |client| {
client.use_preconfigured_tls((*config).clone())
});
}
let updater = builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
+24 -20
View File
@@ -179,6 +179,16 @@ fn tofu_check<R: Runtime>(
}
}
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
fn emit_ws_state<R: Runtime>(app: &AppHandle<R>, state: &str) {
let _ = app.emit("ws-state", state);
}
/// Single call site for cert-tofu events — keeps tauri-typegen from generating duplicates.
fn emit_cert_tofu<R: Runtime>(app: &AppHandle<R>, payload: serde_json::Value) {
let _ = app.emit("cert-tofu", payload);
}
/// Connect to a WSS server. Spawns a background task that:
/// - Emits `ws-message` events for incoming server messages
/// - Emits `ws-state` events for connection state changes
@@ -207,7 +217,7 @@ pub async fn ws_connect<R: Runtime>(
return Err("Only wss:// connections are permitted".into());
}
let _ = app.emit("ws-state", "connecting");
emit_ws_state(&app, "connecting");
// Create TOFU verifier that captures the cert fingerprint during handshake.
let (verifier, captured_fp) = TofuVerifier::new();
@@ -255,27 +265,21 @@ pub async fn ws_connect<R: Runtime>(
match tofu_check(&app, &host, &fingerprint) {
Ok(status) => {
info!("[ws_proxy] TOFU check passed for {}: {}", host, status);
let _ = app.emit(
"cert-tofu",
serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": status,
}),
);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": status,
}));
}
Err(mismatch_msg) => {
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host);
debug!("[ws_proxy] TOFU detail: {}", mismatch_msg);
let _ = app.emit(
"cert-tofu",
serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "mismatch",
"message": mismatch_msg,
}),
);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "mismatch",
"message": mismatch_msg,
}));
// Reject the connection — do not proceed.
return Err(mismatch_msg);
}
@@ -283,7 +287,7 @@ pub async fn ws_connect<R: Runtime>(
// ── End TOFU check ───────────────────────────────────────────────────
info!("[ws_proxy] connected to {}", host);
let _ = app.emit("ws-state", "open");
emit_ws_state(&app, "open");
let (mut sink, mut stream) = ws_stream.split();
@@ -340,7 +344,7 @@ pub async fn ws_connect<R: Runtime>(
}
}
info!("[ws_proxy] connection closed");
let _ = app_state.emit("ws-state", "closed");
emit_ws_state(&app_state, "closed");
});
Ok(())
@@ -19,7 +19,7 @@
"decorations": true,
"resizable": true,
"center": true,
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required"
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
}
],
"withGlobalTauri": true,
@@ -46,6 +46,12 @@
}
},
"plugins": {
"tauri-typegen": {
"project_path": ".",
"output_path": "../src/generated",
"validation_library": "none",
"verbose": false
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
"endpoints": [],
@@ -60,53 +60,73 @@ function withConfirmation(
let confirming = false;
const originalLabel = item.textContent ?? "";
item.addEventListener("click", (e) => {
e.stopPropagation();
if (confirming) {
confirming = false;
setText(item, originalLabel);
onConfirm();
} else {
confirming = true;
setText(item, confirmLabel);
}
}, { signal });
item.addEventListener(
"click",
(e) => {
e.stopPropagation();
if (confirming) {
confirming = false;
setText(item, originalLabel);
onConfirm();
} else {
confirming = true;
setText(item, confirmLabel);
}
},
{ signal },
);
}
// ---------------------------------------------------------------------------
// Member Context Menu
// ---------------------------------------------------------------------------
export function createMemberContextMenu(
options: MemberContextMenuOptions,
): ContextMenuResult {
export function createMemberContextMenu(options: MemberContextMenuOptions): ContextMenuResult {
const ac = new AbortController();
const menu = createElement("div", { class: "context-menu" });
// Role submenu trigger
const roleItem = createElement("div", {
class: "context-menu__item",
}, "Change Role");
const roleItem = createElement(
"div",
{
class: "context-menu__item",
},
"Change Role",
);
const roleSub = createElement("div", { class: "context-menu__submenu" });
for (const role of options.availableRoles) {
const cls = role === options.currentRole
? "context-menu__item context-menu__item--active"
: "context-menu__item";
const roleOption = createMenuItem(role, cls, () => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
}, ac.signal);
const cls =
role === options.currentRole
? "context-menu__item context-menu__item--active"
: "context-menu__item";
const roleOption = createMenuItem(
role,
cls,
() => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
},
ac.signal,
);
roleSub.appendChild(roleOption);
}
roleItem.addEventListener("mouseenter", () => {
roleSub.style.display = "";
}, { signal: ac.signal });
roleItem.addEventListener("mouseleave", () => {
roleSub.style.display = "none";
}, { signal: ac.signal });
roleItem.addEventListener(
"mouseenter",
() => {
roleSub.style.display = "";
},
{ signal: ac.signal },
);
roleItem.addEventListener(
"mouseleave",
() => {
roleSub.style.display = "none";
},
{ signal: ac.signal },
);
roleSub.style.display = "none";
appendChildren(roleItem, roleSub);
@@ -115,21 +135,39 @@ export function createMemberContextMenu(
menu.appendChild(createSeparator());
// Kick with confirmation
const kickItem = createElement("div", {
class: "context-menu__item context-menu__item--danger",
}, "Kick");
withConfirmation(kickItem, "Are you sure?", () => {
void options.onKick();
}, ac.signal);
const kickItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Kick",
);
withConfirmation(
kickItem,
"Are you sure?",
() => {
void options.onKick();
},
ac.signal,
);
menu.appendChild(kickItem);
// Ban with confirmation
const banItem = createElement("div", {
class: "context-menu__item context-menu__item--danger",
}, "Ban");
withConfirmation(banItem, "Are you sure?", () => {
void options.onBan();
}, ac.signal);
const banItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Ban",
);
withConfirmation(
banItem,
"Are you sure?",
() => {
void options.onBan();
},
ac.signal,
);
menu.appendChild(banItem);
function destroy(): void {
@@ -144,9 +182,7 @@ export function createMemberContextMenu(
// Channel Context Menu
// ---------------------------------------------------------------------------
export function createChannelContextMenu(
options: ChannelContextMenuOptions,
): ContextMenuResult {
export function createChannelContextMenu(options: ChannelContextMenuOptions): ContextMenuResult {
const ac = new AbortController();
const menu = createElement("div", { class: "context-menu" });
@@ -171,12 +207,21 @@ export function createChannelContextMenu(
menu.appendChild(createSeparator());
// Delete Channel with confirmation
const deleteItem = createElement("div", {
class: "context-menu__item context-menu__item--danger",
}, "Delete Channel");
withConfirmation(deleteItem, "Are you sure?", () => {
void options.onDelete();
}, ac.signal);
const deleteItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Delete Channel",
);
withConfirmation(
deleteItem,
"Are you sure?",
() => {
void options.onDelete();
},
ac.signal,
);
menu.appendChild(deleteItem);
function destroy(): void {
@@ -18,9 +18,7 @@ export interface CertMismatchModalOptions {
readonly onReject: () => void;
}
export function createCertMismatchModal(
options: CertMismatchModalOptions,
): MountableComponent {
export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent {
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
const ac = new AbortController();
@@ -52,8 +50,8 @@ export function createCertMismatchModal(
setText(
desc,
"The server's TLS certificate fingerprint has changed. " +
"This could mean the server regenerated its certificate, " +
"or it could indicate a security issue.",
"This could mean the server regenerated its certificate, " +
"or it could indicate a security issue.",
);
const details = createElement("div", { class: "cert-details" });
@@ -110,11 +108,7 @@ export function createCertMismatchModal(
return { mount, destroy };
}
function buildRow(
label: string,
value: string,
isFingerprint: boolean,
): HTMLDivElement {
function buildRow(label: string, value: string, isFingerprint: boolean): HTMLDivElement {
const row = createElement("div", { class: "cert-row" });
const labelEl = createElement("span", { class: "cert-label" });
setText(labelEl, label);
@@ -4,12 +4,7 @@
* Voice channels show connected users and join/leave on click.
*/
import {
createElement,
setText,
clearChildren,
appendChildren,
} from "@lib/dom";
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import {
@@ -17,109 +12,16 @@ import {
getChannelsByCategory,
setActiveChannel,
clearUnread,
updateChannelPosition,
} from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import { authStore, getCurrentUser } from "@stores/auth.store";
import {
uiStore,
toggleCategory,
isCategoryCollapsed,
} from "@stores/ui.store";
import { uiStore, toggleCategory, isCategoryCollapsed } from "@stores/ui.store";
import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
// ---------------------------------------------------------------------------
// Per-user volume context menu (right-click on voice user row)
// ---------------------------------------------------------------------------
function showUserVolumeMenu(
userId: number,
username: string,
x: number,
y: number,
signal: AbortSignal,
): void {
// Remove any existing context menus
document.querySelectorAll(".user-vol-menu").forEach((el) => el.remove());
const menu = createElement("div", { class: "context-menu user-vol-menu" });
const header = createElement("div", {
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
}, username);
menu.appendChild(header);
const sep = createElement("div", { class: "context-menu-sep" });
menu.appendChild(sep);
const currentVol = getUserVolume(userId);
const volLabel = createElement("div", {
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
}, `User Volume: ${currentVol}%`);
menu.appendChild(volLabel);
const sliderRow = createElement("div", {
style: "padding:4px 10px;display:flex;align-items:center;gap:8px",
});
const slider = createElement("input", {
type: "range",
class: "settings-slider",
min: "0",
max: "200",
value: String(currentVol),
style: "flex:1",
});
const valLabel = createElement("span", {
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
}, `${currentVol}%`);
slider.addEventListener("input", () => {
const val = Number(slider.value);
setText(valLabel, `${val}%`);
setText(volLabel, `User Volume: ${val}%`);
setUserVolume(userId, val);
});
appendChildren(sliderRow, slider, valLabel);
menu.appendChild(sliderRow);
const resetBtn = createElement("div", { class: "context-menu-item" }, "Reset Volume");
resetBtn.addEventListener("click", () => {
setUserVolume(userId, 100);
slider.value = "100";
setText(valLabel, "100%");
setText(volLabel, "User Volume: 100%");
});
menu.appendChild(resetBtn);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
document.body.appendChild(menu);
// Close on click outside
const dismissAc = new AbortController();
setTimeout(() => {
if (dismissAc.signal.aborted) return;
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
dismissAc.abort();
}
}, { signal: dismissAc.signal });
}, 0);
// Also clean up if the parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
}
import { showUserVolumeMenu } from "./channel-sidebar/volume-menu";
import { attachChannelContextMenu } from "./channel-sidebar/context-menu";
import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder";
export interface ChannelReorderData {
readonly channelId: number;
@@ -141,16 +43,6 @@ export interface ChannelSidebarOptions {
readonly onWatchStream?: (userId: number) => void;
}
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
interface DragState {
channelId: number;
sourceEl: HTMLElement;
containerEl: HTMLElement;
channels: readonly Channel[];
onReorder: (reorders: readonly ChannelReorderData[]) => void;
}
let activeDrag: DragState | null = null;
const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"];
function pickAvatarColor(username: string): string {
@@ -183,11 +75,7 @@ function renderTextChannelItem(
appendChildren(item, prefix, name);
if (channel.unreadCount > 0) {
const badge = createElement(
"span",
{ class: "unread-badge" },
String(channel.unreadCount),
);
const badge = createElement("span", { class: "unread-badge" }, String(channel.unreadCount));
item.appendChild(badge);
}
@@ -215,9 +103,7 @@ function renderVoiceChannelItem(
const wrapper = createElement("div", {});
const classes = ["channel-item", "voice", isJoined ? "active" : ""]
.filter(Boolean)
.join(" ");
const classes = ["channel-item", "voice", isJoined ? "active" : ""].filter(Boolean).join(" ");
const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` });
item.dataset.channelId = String(channel.id);
@@ -247,23 +133,18 @@ function renderVoiceChannelItem(
if (voiceUsers.length > 0) {
const usersContainer = createElement("div", { class: "voice-users-list" });
for (const user of voiceUsers) {
const rowClasses = user.speaking
? "voice-user-item speaking"
: "voice-user-item";
const row = createElement("div", { class: rowClasses, "data-voice-uid": String(user.userId) });
const rowClasses = user.speaking ? "voice-user-item speaking" : "voice-user-item";
const row = createElement("div", {
class: rowClasses,
"data-voice-uid": String(user.userId),
});
const initial = user.username.length > 0
? user.username.charAt(0).toUpperCase()
: "?";
const initial = user.username.length > 0 ? user.username.charAt(0).toUpperCase() : "?";
const avatar = createElement("div", { class: "vu-avatar" }, initial);
avatar.style.background = pickAvatarColor(user.username);
row.appendChild(avatar);
const nameEl = createElement(
"span",
{ class: "vu-name" },
user.username || "Unknown",
);
const nameEl = createElement("span", { class: "vu-name" }, user.username || "Unknown");
row.appendChild(nameEl);
if (user.camera) {
@@ -299,33 +180,47 @@ function renderVoiceChannelItem(
// Right-click for per-user volume (skip for own user)
const currentUser = getCurrentUser();
if (currentUser === null || currentUser.id !== user.userId) {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
showUserVolumeMenu(user.userId, user.username || "Unknown", e.clientX, e.clientY, signal);
}, { signal });
row.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopPropagation();
showUserVolumeMenu(
user.userId,
user.username || "Unknown",
e.clientX,
e.clientY,
signal,
);
},
{ signal },
);
}
// Click to watch stream (if user has camera or screenshare)
if (onWatchStream !== undefined && (user.camera || user.screenshare)) {
row.addEventListener("click", (e) => {
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
onWatchStream(tileId);
}, { signal });
row.addEventListener(
"click",
(e) => {
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
onWatchStream(tileId);
},
{ signal },
);
row.style.cursor = "pointer";
}
// Hover/focus preview for remote users with video
if ((currentUser === null || currentUser.id !== user.userId)
&& (user.camera || user.screenshare)) {
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
if (
(currentUser === null || currentUser.id !== user.userId) &&
(user.camera || user.screenshare)
) {
const tileId = user.screenshare ? user.userId + SCREENSHARE_TILE_ID_OFFSET : user.userId;
attachStreamPreview(
row,
user.userId,
@@ -335,7 +230,10 @@ function renderVoiceChannelItem(
signal,
() => {
// Placeholder click: join voice channel and watch stream
onVoiceJoin(channel.id);
// Only join if not already in this channel
if (voiceStore.getState().currentChannelId !== channel.id) {
onVoiceJoin(channel.id);
}
if (onWatchStream !== undefined) onWatchStream(tileId);
},
onWatchStream !== undefined ? () => onWatchStream(tileId) : undefined,
@@ -351,266 +249,6 @@ function renderVoiceChannelItem(
return wrapper;
}
/** Attach a right-click context menu to a channel element for edit/delete. */
function attachChannelContextMenu(
el: HTMLElement,
channel: Channel,
signal: AbortSignal,
onEdit?: (channel: Channel) => void,
onDelete?: (channel: Channel) => void,
): void {
if (onEdit === undefined && onDelete === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
return;
}
el.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopPropagation();
// Remove any existing context menu
document.querySelector(".channel-ctx-menu")?.remove();
const menu = createElement("div", {
class: "context-menu channel-ctx-menu",
"data-testid": "channel-context-menu",
});
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
if (onEdit !== undefined) {
const editItem = createElement(
"div",
{ class: "context-menu-item", "data-testid": "ctx-edit-channel" },
"Edit Channel",
);
editItem.addEventListener(
"click",
() => {
menu.remove();
onEdit(channel);
},
{ signal },
);
menu.appendChild(editItem);
}
if (onDelete !== undefined) {
if (onEdit !== undefined) {
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
}
const deleteItem = createElement(
"div",
{ class: "context-menu-item danger", "data-testid": "ctx-delete-channel" },
"Delete Channel",
);
deleteItem.addEventListener(
"click",
() => {
menu.remove();
onDelete(channel);
},
{ signal },
);
menu.appendChild(deleteItem);
}
document.body.appendChild(menu);
// Close menu on click elsewhere
const closeMenu = (): void => {
menu.remove();
document.removeEventListener("click", closeMenu);
};
// Defer so this click event doesn't immediately close it
setTimeout(() => {
document.addEventListener("click", closeMenu, { signal });
}, 0);
},
{ signal },
);
}
/** Global mousemove/mouseup handlers for drag reordering. Registered once.
* Reference-counted so multiple sidebar instances share the same listeners
* and only the last destroy tears them down. */
let globalDragAc: AbortController | null = null;
let globalDragRefCount = 0;
function ensureGlobalDragListeners(): void {
globalDragRefCount++;
if (globalDragAc !== null) {
return;
}
globalDragAc = new AbortController();
document.addEventListener("mousemove", (e) => {
if (activeDrag === null) {
return;
}
// Clear old indicators
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Find which channel item we're hovering over
const items = activeDrag.containerEl.querySelectorAll("[data-drag-channel-id]");
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const targetId = Number((item as HTMLElement).dataset.dragChannelId);
if (targetId !== activeDrag.channelId) {
item.classList.add("channel-drop-indicator");
}
break;
}
}
}, { signal: globalDragAc.signal });
document.addEventListener("mouseup", (e) => {
if (activeDrag === null) {
return;
}
const drag = activeDrag;
activeDrag = null;
// Clean up visual state
drag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
drag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Find drop target
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
let dropBefore = false;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
dropTargetId = Number((item as HTMLElement).dataset.dragChannelId);
dropBefore = e.clientY < rect.top + rect.height / 2;
break;
}
}
if (dropTargetId === null || dropTargetId === drag.channelId) {
return;
}
// Compute new order
const orderedIds = drag.channels.map((ch) => ch.id);
const dragIdx = orderedIds.indexOf(drag.channelId);
if (dragIdx === -1) {
return;
}
orderedIds.splice(dragIdx, 1);
const targetIdx = orderedIds.indexOf(dropTargetId);
if (targetIdx === -1) {
return;
}
const insertIdx = dropBefore ? targetIdx : targetIdx + 1;
orderedIds.splice(insertIdx, 0, drag.channelId);
// Build reorder data and update store immediately
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < orderedIds.length; i++) {
const id = orderedIds[i];
if (id === undefined) {
continue;
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
}
}
if (reorders.length > 0) {
drag.onReorder(reorders);
}
}, { signal: globalDragAc.signal });
}
/** Make a channel element draggable via mousedown (admin/owner only). */
function attachDragHandlers(
el: HTMLElement,
channel: Channel,
containerEl: HTMLElement,
channels: readonly Channel[],
signal: AbortSignal,
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
): void {
if (onReorderChannel === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
return;
}
ensureGlobalDragListeners();
el.classList.add("channel-draggable");
el.dataset.dragChannelId = String(channel.id);
let pendingDrag: { startX: number; startY: number } | null = null;
el.addEventListener(
"mousedown",
(e) => {
if (e.button !== 0) {
return;
}
// Start tracking — only activate drag after movement threshold
pendingDrag = { startX: e.clientX, startY: e.clientY };
},
{ signal },
);
el.addEventListener(
"mousemove",
(e) => {
if (pendingDrag === null || activeDrag !== null) {
return;
}
const dx = Math.abs(e.clientX - pendingDrag.startX);
const dy = Math.abs(e.clientY - pendingDrag.startY);
// Require 5px movement to start drag (avoids hijacking clicks)
if (dx + dy < 5) {
return;
}
pendingDrag = null;
activeDrag = {
channelId: channel.id,
sourceEl: el,
containerEl,
channels,
onReorder: onReorderChannel,
};
el.classList.add("dragging");
document.body.classList.add("channel-reordering");
},
{ signal },
);
el.addEventListener(
"mouseup",
() => {
pendingDrag = null;
},
{ signal },
);
}
function renderChannelItem(
channel: Channel,
isActive: boolean,
@@ -671,11 +309,15 @@ function renderCategoryGroup(
const canManageChannels = role === "owner" || role === "admin";
if (canManageChannels) {
const addBtn = createElement("span", {
class: "category-add-btn",
title: "Create Channel",
"data-testid": `create-channel-${categoryName.toLowerCase().replace(/\s+/g, "-")}`,
}, "+");
const addBtn = createElement(
"span",
{
class: "category-add-btn",
title: "Create Channel",
"data-testid": `create-channel-${categoryName.toLowerCase().replace(/\s+/g, "-")}`,
},
"+",
);
addBtn.addEventListener(
"click",
(e) => {
@@ -702,7 +344,19 @@ function renderCategoryGroup(
const channelsContainer = createElement("div", { class: "category-channels-container" });
for (const ch of channels) {
channelsContainer.appendChild(
renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel, onWatchStream),
renderChannelItem(
ch,
ch.id === activeChannelId,
signal,
onVoiceJoin,
onVoiceLeave,
onEditChannel,
onDeleteChannel,
channelsContainer,
channels,
onReorderChannel,
onWatchStream,
),
);
}
group.appendChild(channelsContainer);
@@ -712,7 +366,19 @@ function renderCategoryGroup(
const channelsContainer = createElement("div", { class: "category-channels-container" });
for (const ch of channels) {
channelsContainer.appendChild(
renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel, onWatchStream),
renderChannelItem(
ch,
ch.id === activeChannelId,
signal,
onVoiceJoin,
onVoiceLeave,
onEditChannel,
onDeleteChannel,
channelsContainer,
channels,
onReorderChannel,
onWatchStream,
),
);
}
group.appendChild(channelsContainer);
@@ -722,7 +388,15 @@ function renderCategoryGroup(
}
export function createChannelSidebar(options: ChannelSidebarOptions): MountableComponent {
const { onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel, onWatchStream } = options;
const {
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
onEditChannel,
onDeleteChannel,
onReorderChannel,
onWatchStream,
} = options;
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let channelList: HTMLDivElement | null = null;
@@ -742,7 +416,11 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
if (grouped.size === 0) {
const emptyState = createElement("div", { class: "channel-list-empty" });
const msg = createElement("p", { class: "channel-list-empty-text" }, "No channels yet");
const hint = createElement("p", { class: "channel-list-empty-hint" }, "Right-click a category to create one");
const hint = createElement(
"p",
{ class: "channel-list-empty-hint" },
"Right-click a category to create one",
);
appendChildren(emptyState, msg, hint);
channelList.appendChild(emptyState);
return;
@@ -750,7 +428,19 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
for (const [category, channels] of grouped) {
channelList.appendChild(
renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel, onWatchStream),
renderCategoryGroup(
category,
channels,
state.activeChannelId,
ac.signal,
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
onEditChannel,
onDeleteChannel,
onReorderChannel,
onWatchStream,
),
);
}
}
@@ -761,11 +451,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
// Header
const header = createElement("div", { class: "channel-sidebar-header" });
const authState = authStore.getState();
serverNameEl = createElement(
"h2",
{},
authState.serverName ?? "Server Name",
);
serverNameEl = createElement("h2", {}, authState.serverName ?? "Server Name");
header.appendChild(serverNameEl);
// Channel list
@@ -831,7 +517,9 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
if (channelList === null) return;
for (const [, users] of state.voiceUsers) {
for (const [uid, u] of users) {
const row = channelList.querySelector<HTMLElement>(`.voice-user-item[data-voice-uid="${uid}"]`);
const row = channelList.querySelector<HTMLElement>(
`.voice-user-item[data-voice-uid="${uid}"]`,
);
if (row !== null) {
row.classList.toggle("speaking", u.speaking);
}
@@ -843,11 +531,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
function destroy(): void {
ac.abort();
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
if (globalDragRefCount === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
releaseGlobalDragListeners(channelList ?? undefined);
for (const unsub of unsubscribers) {
unsub();
}
@@ -27,8 +27,14 @@ const READY_DELAY_MS = 800;
function serverIconColor(name: string): string {
const palette = [
"#5865f2", "#57f287", "#fee75c", "#eb459e",
"#ed4245", "#f0b232", "#2ecc71", "#e74c3c",
"#5865f2",
"#57f287",
"#fee75c",
"#eb459e",
"#ed4245",
"#f0b232",
"#2ecc71",
"#e74c3c",
] as const;
let hash = 0;
for (let i = 0; i < name.length; i++) {
@@ -37,14 +43,15 @@ function serverIconColor(name: string): string {
return palette[Math.abs(hash) % palette.length] ?? palette[0];
}
export function createConnectedOverlay(
options: ConnectedOverlayOptions,
): ConnectedOverlayControl {
export function createConnectedOverlay(options: ConnectedOverlayOptions): ConnectedOverlayControl {
const { serverName, username, motd, onReady } = options;
const ac = new AbortController();
// Root overlay (hidden by default, .visible to show)
const overlay = createElement("div", { class: "connected-overlay", "data-testid": "connected-overlay" });
const overlay = createElement("div", {
class: "connected-overlay",
"data-testid": "connected-overlay",
});
// Server icon with check badge
const iconWrap = createElement("div", { class: "connected-icon-wrap" });
@@ -70,13 +77,21 @@ export function createConnectedOverlay(
appendChildren(iconWrap, srvIcon, checkBadge);
// Text elements
const connectedText = createElement("div", {
class: "connected-text",
}, "Connected!");
const connectedText = createElement(
"div",
{
class: "connected-text",
},
"Connected!",
);
const userText = createElement("div", {
class: "connected-user",
}, `Logged in as ${username}`);
const userText = createElement(
"div",
{
class: "connected-user",
},
`Logged in as ${username}`,
);
const motdEl = createElement("div", { class: "connected-motd" });
if (motd) {
@@ -14,11 +14,7 @@ export interface CreateChannelModalOptions {
/** The category this channel will be created under. */
readonly category: string;
/** Called when the user submits the form. */
readonly onCreate: (data: {
name: string;
type: ChannelType;
category: string;
}) => Promise<void>;
readonly onCreate: (data: { name: string; type: ChannelType; category: string }) => Promise<void>;
/** Called when the modal is closed without creating. */
readonly onClose: () => void;
}
@@ -29,18 +25,14 @@ export function isVoiceCategory(category: string): boolean {
}
/** Returns the allowed channel types for a given category. */
export function allowedTypesForCategory(
category: string,
): readonly ChannelType[] {
export function allowedTypesForCategory(category: string): readonly ChannelType[] {
if (isVoiceCategory(category)) {
return ["voice"] as const;
}
return ["text", "announcement"] as const;
}
export function createCreateChannelModal(
options: CreateChannelModalOptions,
): MountableComponent {
export function createCreateChannelModal(options: CreateChannelModalOptions): MountableComponent {
const { category, onCreate, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
@@ -72,11 +64,7 @@ export function createCreateChannelModal(
// Category (read-only display)
const categoryGroup = createElement("div", { class: "form-group" });
const categoryLabel = createElement(
"label",
{ class: "form-label" },
"Category",
);
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
const categoryDisplay = createElement("div", {
class: "form-input",
style: "opacity: 0.7; cursor: default;",
@@ -104,11 +92,7 @@ export function createCreateChannelModal(
});
for (const t of allowedTypes) {
const opt = createElement(
"option",
{ value: t },
t.charAt(0).toUpperCase() + t.slice(1),
);
const opt = createElement("option", { value: t }, t.charAt(0).toUpperCase() + t.slice(1));
typeSelect.appendChild(opt);
}
appendChildren(typeGroup, typeLabel, typeSelect);
@@ -166,10 +150,7 @@ export function createCreateChannelModal(
});
} catch (err) {
errorEl.style.display = "block";
setText(
errorEl,
err instanceof Error ? err.message : "Failed to create channel",
);
setText(errorEl, err instanceof Error ? err.message : "Failed to create channel");
createBtn.removeAttribute("disabled");
setText(createBtn, "Create Channel");
}
@@ -14,9 +14,7 @@ export interface DeleteChannelModalOptions {
readonly onClose: () => void;
}
export function createDeleteChannelModal(
options: DeleteChannelModalOptions,
): MountableComponent {
export function createDeleteChannelModal(options: DeleteChannelModalOptions): MountableComponent {
const { channelName, onConfirm, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
@@ -88,10 +86,7 @@ export function createDeleteChannelModal(
await onConfirm();
} catch (err) {
errorEl.style.display = "block";
setText(
errorEl,
err instanceof Error ? err.message : "Failed to delete channel",
);
setText(errorEl, err instanceof Error ? err.message : "Failed to delete channel");
deleteBtn.removeAttribute("disabled");
setText(deleteBtn, "Delete Channel");
}
@@ -0,0 +1,374 @@
/**
* DmProfileSidebar -- right-side panel showing the DM partner's profile.
* Appears when clicking the DM header ("@ username" area).
* 340px wide, slides in from the right with a 170ms animation.
*
* Content: 80px avatar, username, status dot + label, about section,
* "Member Since" date, and a local-only editable Note field.
*
* A11y: role="complementary", aria-label="User profile", Esc to close,
* focus first focusable on open.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { isSafeUrl } from "./message-list/attachments";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DmProfileData {
readonly id: number;
readonly username: string;
readonly avatar: string | null;
readonly status: UserStatus;
readonly about?: string | null;
readonly joinDate?: string | null;
}
export interface DmProfileSidebarOptions {
readonly user: DmProfileData;
readonly onClose: () => void;
}
export type DmProfileSidebarComponent = MountableComponent & {
readonly isOpen: () => boolean;
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SIDEBAR_WIDTH = 340;
const ANIMATION_DURATION_MS = 170;
const NOTE_STORAGE_PREFIX = "owncord:dm-note:";
const STATUS_COLORS: Readonly<Record<UserStatus, string>> = {
online: "#3ba55d",
idle: "#faa61a",
dnd: "#ed4245",
offline: "#747f8d",
};
const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
offline: "Offline",
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function loadNote(userId: number): string {
try {
return localStorage.getItem(NOTE_STORAGE_PREFIX + String(userId)) ?? "";
} catch {
return "";
}
}
function saveNote(userId: number, text: string): void {
try {
localStorage.setItem(NOTE_STORAGE_PREFIX + String(userId), text);
} catch {
// localStorage may be unavailable or full -- silently ignore
}
}
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
export function createDmProfileSidebar(
options: DmProfileSidebarOptions,
): DmProfileSidebarComponent {
const ac = new AbortController();
const { signal } = ac;
const { user, onClose } = options;
let panel: HTMLDivElement | null = null;
let open = false;
function isOpen(): boolean {
return open;
}
function buildAvatar(): HTMLDivElement {
const wrapper = createElement("div", {
class: "dps-avatar",
"data-testid": "dps-avatar",
});
wrapper.style.width = "80px";
wrapper.style.height = "80px";
wrapper.style.borderRadius = "50%";
wrapper.style.display = "flex";
wrapper.style.alignItems = "center";
wrapper.style.justifyContent = "center";
wrapper.style.fontSize = "32px";
wrapper.style.fontWeight = "700";
wrapper.style.color = "#fff";
wrapper.style.margin = "24px auto 12px";
wrapper.style.position = "relative";
wrapper.style.flexShrink = "0";
if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
wrapper.style.background = "transparent";
const img = createElement("img", {
src: user.avatar,
alt: user.username,
class: "dps-avatar-img",
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
wrapper.appendChild(img);
} else {
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const text = createElement("span", {}, initial);
wrapper.appendChild(text);
}
// Status dot overlay
const statusDot = createElement("div", { class: "dps-status-dot" });
statusDot.style.position = "absolute";
statusDot.style.bottom = "2px";
statusDot.style.right = "2px";
statusDot.style.width = "16px";
statusDot.style.height = "16px";
statusDot.style.borderRadius = "50%";
statusDot.style.border = "3px solid var(--bg-secondary, #111214)";
statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
statusDot.title = STATUS_LABELS[user.status] ?? "Offline";
wrapper.appendChild(statusDot);
return wrapper;
}
function mount(container: Element): void {
open = true;
panel = createElement("div", {
class: "dm-profile-sidebar",
role: "complementary",
"aria-label": "User profile",
tabindex: "-1",
"data-testid": "dm-profile-sidebar",
});
// Base styles
panel.style.width = `${SIDEBAR_WIDTH}px`;
panel.style.background = "var(--bg-secondary, #111214)";
panel.style.borderLeft = "1px solid var(--border-glow, rgba(0,200,255,0.08))";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.flexShrink = "0";
panel.style.overflow = "hidden";
panel.style.position = "relative";
// Slide-in animation: start offscreen then animate
panel.style.marginRight = `-${SIDEBAR_WIDTH}px`;
panel.style.transition = `margin-right ${ANIMATION_DURATION_MS}ms ease`;
// --- Close button ---
const closeBtn = createElement("button", {
class: "dps-close",
"aria-label": "Close profile sidebar",
"data-testid": "dps-close",
});
closeBtn.style.position = "absolute";
closeBtn.style.top = "8px";
closeBtn.style.right = "8px";
closeBtn.style.background = "none";
closeBtn.style.border = "none";
closeBtn.style.color = "var(--text-muted, #949ba4)";
closeBtn.style.cursor = "pointer";
closeBtn.style.fontSize = "18px";
closeBtn.style.lineHeight = "1";
closeBtn.style.padding = "4px";
closeBtn.style.zIndex = "1";
closeBtn.textContent = "\u2715";
closeBtn.addEventListener(
"click",
() => {
onClose();
},
{ signal },
);
panel.appendChild(closeBtn);
// --- Scrollable content ---
const content = createElement("div", { class: "dps-content" });
content.style.overflowY = "auto";
content.style.flex = "1";
content.style.padding = "0 16px 16px";
// Avatar
content.appendChild(buildAvatar());
// Username
const nameEl = createElement("div", {
class: "dps-username",
"data-testid": "dps-username",
});
nameEl.style.textAlign = "center";
nameEl.style.fontSize = "20px";
nameEl.style.fontWeight = "600";
nameEl.style.color = "var(--text-primary, #f2f3f5)";
nameEl.style.marginBottom = "4px";
setText(nameEl, user.username);
// Status line
const statusLine = createElement("div", {
class: "dps-status",
"data-testid": "dps-status",
});
statusLine.style.display = "flex";
statusLine.style.alignItems = "center";
statusLine.style.justifyContent = "center";
statusLine.style.gap = "6px";
statusLine.style.marginBottom = "16px";
statusLine.style.fontSize = "13px";
statusLine.style.color = "var(--text-muted, #949ba4)";
const statusDotInline = createElement("span", { class: "dps-status-dot-inline" });
statusDotInline.style.width = "8px";
statusDotInline.style.height = "8px";
statusDotInline.style.borderRadius = "50%";
statusDotInline.style.display = "inline-block";
statusDotInline.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
const statusText = createElement("span", {}, STATUS_LABELS[user.status] ?? "Offline");
appendChildren(statusLine, statusDotInline, statusText);
appendChildren(content, nameEl, statusLine);
// Divider helper
const makeDivider = (): HTMLDivElement => {
const d = createElement("div", { class: "dps-divider" });
d.style.height = "1px";
d.style.background = "var(--border-glow, rgba(0,200,255,0.08))";
d.style.margin = "12px 0";
return d;
};
// About section
if (user.about !== undefined && user.about !== null && user.about.length > 0) {
content.appendChild(makeDivider());
const aboutTitle = createElement("div", { class: "dps-section-title" }, "ABOUT ME");
aboutTitle.style.fontSize = "12px";
aboutTitle.style.fontWeight = "700";
aboutTitle.style.color = "var(--text-muted, #949ba4)";
aboutTitle.style.textTransform = "uppercase";
aboutTitle.style.marginBottom = "8px";
const aboutText = createElement("div", {
class: "dps-about-text",
"data-testid": "dps-about",
});
aboutText.style.fontSize = "14px";
aboutText.style.color = "var(--text-secondary, #dbdee1)";
aboutText.style.lineHeight = "1.4";
aboutText.style.wordBreak = "break-word";
setText(aboutText, user.about);
appendChildren(content, aboutTitle, aboutText);
}
// Member Since
if (user.joinDate !== undefined && user.joinDate !== null) {
content.appendChild(makeDivider());
const joinTitle = createElement("div", { class: "dps-section-title" }, "MEMBER SINCE");
joinTitle.style.fontSize = "12px";
joinTitle.style.fontWeight = "700";
joinTitle.style.color = "var(--text-muted, #949ba4)";
joinTitle.style.textTransform = "uppercase";
joinTitle.style.marginBottom = "8px";
const joinText = createElement("div", {
class: "dps-join-text",
"data-testid": "dps-join-date",
});
joinText.style.fontSize = "14px";
joinText.style.color = "var(--text-secondary, #dbdee1)";
setText(joinText, user.joinDate);
appendChildren(content, joinTitle, joinText);
}
// Note section (local-only, persisted to localStorage)
content.appendChild(makeDivider());
const noteTitle = createElement("div", { class: "dps-section-title" }, "NOTE");
noteTitle.style.fontSize = "12px";
noteTitle.style.fontWeight = "700";
noteTitle.style.color = "var(--text-muted, #949ba4)";
noteTitle.style.textTransform = "uppercase";
noteTitle.style.marginBottom = "8px";
const noteInput = createElement("textarea", {
class: "dps-note",
placeholder: "Click to add a note",
"data-testid": "dps-note",
rows: "3",
});
noteInput.style.width = "100%";
noteInput.style.resize = "vertical";
noteInput.style.background = "var(--bg-primary, #1e1f22)";
noteInput.style.border = "none";
noteInput.style.borderRadius = "4px";
noteInput.style.color = "var(--text-primary, #f2f3f5)";
noteInput.style.fontSize = "13px";
noteInput.style.padding = "8px";
noteInput.style.fontFamily = "inherit";
noteInput.value = loadNote(user.id);
noteInput.addEventListener(
"input",
() => {
saveNote(user.id, noteInput.value);
},
{ signal },
);
appendChildren(content, noteTitle, noteInput);
panel.appendChild(content);
container.appendChild(panel);
// Trigger slide-in animation
requestAnimationFrame(() => {
if (panel !== null) {
panel.style.marginRight = "0";
}
});
// Focus panel for a11y
panel.focus();
// Close on Escape
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onClose();
}
},
{ signal },
);
}
function destroy(): void {
open = false;
ac.abort();
if (panel !== null) {
panel.remove();
panel = null;
}
}
return { mount, destroy, isOpen };
}
+22 -18
View File
@@ -8,13 +8,10 @@
* dm-name, dm-close, dm-unread.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { isSafeUrl } from "./message-list/attachments";
export interface DmConversation {
readonly userId: number;
@@ -63,7 +60,7 @@ function renderDmItem(
const avatar = createElement("div", { class: "dm-avatar" });
avatar.style.background = avatarBg;
if (convo.avatar !== null) {
if (convo.avatar !== null && isSafeUrl(convo.avatar)) {
const img = createElement("img", {
src: convo.avatar,
alt: convo.username,
@@ -111,16 +108,20 @@ function renderDmItem(
item.appendChild(unreadDot);
}
item.addEventListener("click", () => {
const parent = item.parentElement;
if (parent !== null) {
for (const sibling of parent.querySelectorAll(".dm-item.active")) {
sibling.classList.remove("active");
item.addEventListener(
"click",
() => {
const parent = item.parentElement;
if (parent !== null) {
for (const sibling of parent.querySelectorAll(".dm-item.active")) {
sibling.classList.remove("active");
}
}
}
item.classList.add("active");
onSelect(convo.userId);
}, { signal });
item.classList.add("active");
onSelect(convo.userId);
},
{ signal },
);
return item;
}
@@ -142,8 +143,11 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
});
const arrow = createElement("span", { class: "dm-back-arrow" }, "\u2190");
const backInfo = createElement("div", { class: "dm-back-info" });
const backTitle = createElement("div", { class: "dm-back-title" },
`Back to ${options.serverName ?? "Server"}`);
const backTitle = createElement(
"div",
{ class: "dm-back-title" },
`Back to ${options.serverName ?? "Server"}`,
);
const backSub = createElement("div", { class: "dm-back-subtitle" }, "Return to channels");
appendChildren(backInfo, backTitle, backSub);
appendChildren(backHeader, arrow, backInfo);
@@ -187,7 +191,7 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
sectionLabel.appendChild(addBtn);
// Conversation list
const sorted = [...options.conversations].sort(
const sorted = [...options.conversations].toSorted(
(a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0),
);
@@ -20,9 +20,7 @@ export interface EditChannelModalOptions {
readonly onClose: () => void;
}
export function createEditChannelModal(
options: EditChannelModalOptions,
): MountableComponent {
export function createEditChannelModal(options: EditChannelModalOptions): MountableComponent {
const { channelName, channelType, onSave, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
@@ -120,10 +118,7 @@ export function createEditChannelModal(
await onSave({ name });
} catch (err) {
errorEl.style.display = "block";
setText(
errorEl,
err instanceof Error ? err.message : "Failed to update channel",
);
setText(errorEl, err instanceof Error ? err.message : "Failed to update channel");
saveBtn.removeAttribute("disabled");
setText(saveBtn, "Save Changes");
}
+443 -104
View File
@@ -35,128 +35,457 @@ const CATEGORIES: readonly EmojiCategory[] = [
{
name: "Smileys",
emoji: [
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "😊",
"😇", "🥰", "😍", "🤩", "😘", "😗", "😋", "😛", "😜", "🤪",
"😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑",
"😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤",
"😴", "😷", "🤒", "🤕", "🤢", "🤮", "🥵", "🥶", "🥴", "😵",
"🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "😮",
"😲", "😳", "🥺", "😢", "😭", "😤", "😠", "😡", "🤬", "💀",
"😀",
"😃",
"😄",
"😁",
"😆",
"😅",
"🤣",
"😂",
"🙂",
"😊",
"😇",
"🥰",
"😍",
"🤩",
"😘",
"😗",
"😋",
"😛",
"😜",
"🤪",
"😝",
"🤑",
"🤗",
"🤭",
"🤫",
"🤔",
"🤐",
"🤨",
"😐",
"😑",
"😶",
"😏",
"😒",
"🙄",
"😬",
"🤥",
"😌",
"😔",
"😪",
"🤤",
"😴",
"😷",
"🤒",
"🤕",
"🤢",
"🤮",
"🥵",
"🥶",
"🥴",
"😵",
"🤯",
"🤠",
"🥳",
"😎",
"🤓",
"🧐",
"😕",
"😟",
"🙁",
"😮",
"😲",
"😳",
"🥺",
"😢",
"😭",
"😤",
"😠",
"😡",
"🤬",
"💀",
],
},
{
name: "People",
emoji: [
"👋", "🤚", "🖐", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞",
"🤟", "🤘", "🤙", "👈", "👉", "👆", "👇", "☝️", "👍", "👎",
"✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏",
"👋",
"🤚",
"🖐",
"✋",
"🖖",
"👌",
"🤌",
"🤏",
"✌️",
"🤞",
"🤟",
"🤘",
"🤙",
"👈",
"👉",
"👆",
"👇",
"☝️",
"👍",
"👎",
"✊",
"👊",
"🤛",
"🤜",
"👏",
"🙌",
"👐",
"🤲",
"🤝",
"🙏",
],
},
{
name: "Nature",
emoji: [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯",
"🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🦄",
"🌸", "🌹", "🌺", "🌻", "🌼", "🌷", "🌱", "🌲", "🌳", "🍀",
"🐶",
"🐱",
"🐭",
"🐹",
"🐰",
"🦊",
"🐻",
"🐼",
"🐨",
"🐯",
"🦁",
"🐮",
"🐷",
"🐸",
"🐵",
"🐔",
"🐧",
"🐦",
"🐤",
"🦄",
"🌸",
"🌹",
"🌺",
"🌻",
"🌼",
"🌷",
"🌱",
"🌲",
"🌳",
"🍀",
],
},
{
name: "Food",
emoji: [
"🍎", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑", "🍍",
"🥝", "🍔", "🍟", "🍕", "🌭", "🍿", "🧀", "🥚", "🍳", "🥓",
"☕", "🍵", "🍺", "🍻", "🥂", "🍷", "🍸", "🍹", "🍾", "🧁",
"🍎",
"🍊",
"🍋",
"🍌",
"🍉",
"🍇",
"🍓",
"🍒",
"🍑",
"🍍",
"🥝",
"🍔",
"🍟",
"🍕",
"🌭",
"🍿",
"🧀",
"🥚",
"🍳",
"🥓",
"☕",
"🍵",
"🍺",
"🍻",
"🥂",
"🍷",
"🍸",
"🍹",
"🍾",
"🧁",
],
},
{
name: "Objects",
emoji: [
"⚽", "🏀", "🏈", "⚾", "🎾", "🎮", "🎲", "🎯", "🎵", "🎶",
"💡", "🔥", "⭐", "🌟", "💫", "✨", "💥", "❤️", "🧡", "💛",
"💚", "💙", "💜", "🖤", "🤍", "💯", "💢", "💬", "👁‍🗨", "🗨",
"⚽",
"🏀",
"🏈",
"⚾",
"🎾",
"🎮",
"🎲",
"🎯",
"🎵",
"🎶",
"💡",
"🔥",
"⭐",
"🌟",
"💫",
"✨",
"💥",
"❤️",
"🧡",
"💛",
"💚",
"💙",
"💜",
"🖤",
"🤍",
"💯",
"💢",
"💬",
"👁‍🗨",
"🗨",
],
},
{
name: "Symbols",
emoji: [
"✅", "❌", "❓", "❗", "‼️", "⁉️", "💤", "💮", "♻️", "🔰",
"⚠️", "🚫", "🔴", "🟠", "🟡", "🟢", "🔵", "🟣", "⚫", "⚪",
"✅",
"",
"❓",
"❗",
"‼️",
"⁉️",
"💤",
"💮",
"♻️",
"🔰",
"⚠️",
"🚫",
"🔴",
"🟠",
"🟡",
"🟢",
"🔵",
"🟣",
"⚫",
"⚪",
],
},
];
/** Emoji name lookup for search. Maps emoji character → searchable keywords. */
const EMOJI_NAMES: Readonly<Record<string, string>> = {
"😀": "grinning face happy smile", "😃": "smiley face happy smile", "😄": "smile happy grin",
"😁": "beaming grin teeth smile", "😆": "laughing happy squint smile", "😅": "sweat smile nervous",
"🤣": "rofl laughing rolling floor", "😂": "joy tears laughing cry happy", "🙂": "slightly smiling",
"😊": "blush happy smile shy", "😇": "innocent angel halo", "🥰": "love hearts face smiling",
"😍": "heart eyes love", "🤩": "star struck excited", "😘": "kiss blowing wink",
"😗": "kissing face", "😋": "yummy delicious tongue food", "😛": "tongue out",
"😜": "wink tongue playful", "🤪": "zany crazy wild", "😝": "squinting tongue",
"🤑": "money face rich dollar", "🤗": "hugging hug hands", "🤭": "hand over mouth oops giggle",
"🤫": "shushing quiet secret shh", "🤔": "thinking hmm wonder", "🤐": "zipper mouth shut secret",
"🤨": "raised eyebrow skeptical", "😐": "neutral face blank", "😑": "expressionless blank",
"😶": "no mouth silent mute", "😏": "smirk smug", "😒": "unamused bored annoyed",
"🙄": "eye roll whatever", "😬": "grimace awkward teeth", "🤥": "lying pinocchio nose",
"😌": "relieved calm peaceful", "😔": "pensive sad thoughtful", "😪": "sleepy tired",
"🤤": "drooling hungry", "😴": "sleeping zzz tired", "😷": "mask sick medical face",
"🤒": "thermometer sick fever", "🤕": "bandage hurt injured", "🤢": "nauseous sick green",
"🤮": "vomiting throw up sick", "🥵": "hot face overheated", "🥶": "cold face freezing",
"🥴": "woozy drunk dizzy", "😵": "dizzy spiral knocked out", "🤯": "mind blown exploding head",
"🤠": "cowboy hat yeehaw", "🥳": "party celebration birthday", "😎": "sunglasses cool",
"🤓": "nerd glasses geek", "🧐": "monocle detective inspect", "😕": "confused puzzled",
"😟": "worried concerned", "🙁": "frowning sad", "😮": "open mouth surprised",
"😲": "astonished shocked wow", "😳": "flushed embarrassed", "🥺": "pleading puppy eyes please",
"😢": "crying sad tear", "😭": "sobbing crying loud", "😤": "steam nose angry huffing",
"😠": "angry mad", "😡": "rage furious red", "🤬": "cursing swearing symbols angry",
"😀": "grinning face happy smile",
"😃": "smiley face happy smile",
"😄": "smile happy grin",
"😁": "beaming grin teeth smile",
"😆": "laughing happy squint smile",
"😅": "sweat smile nervous",
"🤣": "rofl laughing rolling floor",
"😂": "joy tears laughing cry happy",
"🙂": "slightly smiling",
"😊": "blush happy smile shy",
"😇": "innocent angel halo",
"🥰": "love hearts face smiling",
"😍": "heart eyes love",
"🤩": "star struck excited",
"😘": "kiss blowing wink",
"😗": "kissing face",
"😋": "yummy delicious tongue food",
"😛": "tongue out",
"😜": "wink tongue playful",
"🤪": "zany crazy wild",
"😝": "squinting tongue",
"🤑": "money face rich dollar",
"🤗": "hugging hug hands",
"🤭": "hand over mouth oops giggle",
"🤫": "shushing quiet secret shh",
"🤔": "thinking hmm wonder",
"🤐": "zipper mouth shut secret",
"🤨": "raised eyebrow skeptical",
"😐": "neutral face blank",
"😑": "expressionless blank",
"😶": "no mouth silent mute",
"😏": "smirk smug",
"😒": "unamused bored annoyed",
"🙄": "eye roll whatever",
"😬": "grimace awkward teeth",
"🤥": "lying pinocchio nose",
"😌": "relieved calm peaceful",
"😔": "pensive sad thoughtful",
"😪": "sleepy tired",
"🤤": "drooling hungry",
"😴": "sleeping zzz tired",
"😷": "mask sick medical face",
"🤒": "thermometer sick fever",
"🤕": "bandage hurt injured",
"🤢": "nauseous sick green",
"🤮": "vomiting throw up sick",
"🥵": "hot face overheated",
"🥶": "cold face freezing",
"🥴": "woozy drunk dizzy",
"😵": "dizzy spiral knocked out",
"🤯": "mind blown exploding head",
"🤠": "cowboy hat yeehaw",
"🥳": "party celebration birthday",
"😎": "sunglasses cool",
"🤓": "nerd glasses geek",
"🧐": "monocle detective inspect",
"😕": "confused puzzled",
"😟": "worried concerned",
"🙁": "frowning sad",
"😮": "open mouth surprised",
"😲": "astonished shocked wow",
"😳": "flushed embarrassed",
"🥺": "pleading puppy eyes please",
"😢": "crying sad tear",
"😭": "sobbing crying loud",
"😤": "steam nose angry huffing",
"😠": "angry mad",
"😡": "rage furious red",
"🤬": "cursing swearing symbols angry",
"💀": "skull dead death skeleton",
"👋": "wave hello hi bye hand", "🤚": "raised back hand", "🖐": "hand fingers splayed five",
"": "raised hand stop high five", "🖖": "vulcan spock", "👌": "ok okay perfect",
"🤌": "pinched fingers italian", "🤏": "pinching small little", "✌️": "peace victory two",
"🤞": "crossed fingers luck hope", "🤟": "love you gesture rock",
"🤘": "rock on horns metal", "🤙": "call me hang loose shaka", "👈": "pointing left",
"👉": "pointing right", "👆": "pointing up", "👇": "pointing down", "☝️": "index pointing up",
"👍": "thumbs up like good yes", "👎": "thumbs down dislike bad no",
"": "raised fist power", "👊": "fist bump punch", "🤛": "left fist bump",
"🤜": "right fist bump", "👏": "clap applause bravo", "🙌": "raising hands hooray celebrate",
"👐": "open hands jazz", "🤲": "palms up together prayer", "🤝": "handshake deal agreement",
"👋": "wave hello hi bye hand",
"🤚": "raised back hand",
"🖐": "hand fingers splayed five",
"": "raised hand stop high five",
"🖖": "vulcan spock",
"👌": "ok okay perfect",
"🤌": "pinched fingers italian",
"🤏": "pinching small little",
"✌️": "peace victory two",
"🤞": "crossed fingers luck hope",
"🤟": "love you gesture rock",
"🤘": "rock on horns metal",
"🤙": "call me hang loose shaka",
"👈": "pointing left",
"👉": "pointing right",
"👆": "pointing up",
"👇": "pointing down",
"☝️": "index pointing up",
"👍": "thumbs up like good yes",
"👎": "thumbs down dislike bad no",
"✊": "raised fist power",
"👊": "fist bump punch",
"🤛": "left fist bump",
"🤜": "right fist bump",
"👏": "clap applause bravo",
"🙌": "raising hands hooray celebrate",
"👐": "open hands jazz",
"🤲": "palms up together prayer",
"🤝": "handshake deal agreement",
"🙏": "pray thanks please folded hands",
"🐶": "dog puppy pet", "🐱": "cat kitten pet", "🐭": "mouse rat", "🐹": "hamster",
"🐰": "rabbit bunny", "🦊": "fox", "🐻": "bear", "🐼": "panda bear",
"🐨": "koala", "🐯": "tiger", "🦁": "lion king", "🐮": "cow moo",
"🐷": "pig oink", "🐸": "frog toad", "🐵": "monkey face", "🐔": "chicken hen",
"🐧": "penguin", "🐦": "bird", "🐤": "chick baby bird", "🦄": "unicorn magic",
"🌸": "cherry blossom flower pink", "🌹": "rose flower red", "🌺": "hibiscus flower",
"🌻": "sunflower", "🌼": "blossom flower", "🌷": "tulip flower",
"🌱": "seedling sprout plant", "🌲": "evergreen tree pine", "🌳": "tree deciduous", "🍀": "four leaf clover luck",
"🍎": "red apple fruit", "🍊": "orange tangerine fruit", "🍋": "lemon fruit", "🍌": "banana fruit",
"🍉": "watermelon fruit", "🍇": "grapes fruit", "🍓": "strawberry fruit", "🍒": "cherries fruit",
"🍑": "peach fruit butt", "🍍": "pineapple fruit", "🥝": "kiwi fruit",
"🍔": "hamburger burger food", "🍟": "fries french food", "🍕": "pizza food slice",
"🌭": "hot dog food", "🍿": "popcorn snack movie", "🧀": "cheese wedge",
"🥚": "egg", "🍳": "cooking fried egg", "🥓": "bacon",
"": "coffee hot drink", "🍵": "tea hot drink", "🍺": "beer mug drink",
"🍻": "clinking beers cheers drink", "🥂": "champagne toast celebrate drink",
"🍷": "wine glass drink red", "🍸": "cocktail martini drink", "🍹": "tropical drink",
"🍾": "bottle popping champagne celebrate", "🧁": "cupcake dessert sweet",
"": "soccer football ball sport", "🏀": "basketball ball sport", "🏈": "football american sport",
"": "baseball ball sport", "🎾": "tennis ball sport", "🎮": "video game controller gaming",
"🎲": "dice game random", "🎯": "bullseye target dart", "🎵": "music note",
"🎶": "music notes", "💡": "light bulb idea", "🔥": "fire hot flame lit",
"": "star yellow", "🌟": "glowing star sparkle", "💫": "dizzy star shooting",
"": "sparkles magic shine", "💥": "boom collision crash", "❤️": "red heart love",
"🧡": "orange heart love", "💛": "yellow heart love", "💚": "green heart love",
"💙": "blue heart love", "💜": "purple heart love", "🖤": "black heart dark love",
"🤍": "white heart love", "💯": "hundred percent perfect score", "💢": "anger symbol mad",
"💬": "speech bubble chat talk", "👁‍🗨": "eye speech bubble witness", "🗨": "speech balloon left",
"": "check mark yes done complete", "❌": "cross mark no wrong cancel",
"": "question mark red", "❗": "exclamation mark red alert", "‼️": "double exclamation",
"⁉️": "exclamation question", "💤": "sleeping zzz tired", "💮": "white flower",
"♻️": "recycle green environment", "🔰": "beginner new japanese", "⚠️": "warning caution alert",
"🚫": "prohibited forbidden no", "🔴": "red circle", "🟠": "orange circle",
"🟡": "yellow circle", "🟢": "green circle", "🔵": "blue circle",
"🟣": "purple circle", "⚫": "black circle", "⚪": "white circle",
"🐶": "dog puppy pet",
"🐱": "cat kitten pet",
"🐭": "mouse rat",
"🐹": "hamster",
"🐰": "rabbit bunny",
"🦊": "fox",
"🐻": "bear",
"🐼": "panda bear",
"🐨": "koala",
"🐯": "tiger",
"🦁": "lion king",
"🐮": "cow moo",
"🐷": "pig oink",
"🐸": "frog toad",
"🐵": "monkey face",
"🐔": "chicken hen",
"🐧": "penguin",
"🐦": "bird",
"🐤": "chick baby bird",
"🦄": "unicorn magic",
"🌸": "cherry blossom flower pink",
"🌹": "rose flower red",
"🌺": "hibiscus flower",
"🌻": "sunflower",
"🌼": "blossom flower",
"🌷": "tulip flower",
"🌱": "seedling sprout plant",
"🌲": "evergreen tree pine",
"🌳": "tree deciduous",
"🍀": "four leaf clover luck",
"🍎": "red apple fruit",
"🍊": "orange tangerine fruit",
"🍋": "lemon fruit",
"🍌": "banana fruit",
"🍉": "watermelon fruit",
"🍇": "grapes fruit",
"🍓": "strawberry fruit",
"🍒": "cherries fruit",
"🍑": "peach fruit butt",
"🍍": "pineapple fruit",
"🥝": "kiwi fruit",
"🍔": "hamburger burger food",
"🍟": "fries french food",
"🍕": "pizza food slice",
"🌭": "hot dog food",
"🍿": "popcorn snack movie",
"🧀": "cheese wedge",
"🥚": "egg",
"🍳": "cooking fried egg",
"🥓": "bacon",
"☕": "coffee hot drink",
"🍵": "tea hot drink",
"🍺": "beer mug drink",
"🍻": "clinking beers cheers drink",
"🥂": "champagne toast celebrate drink",
"🍷": "wine glass drink red",
"🍸": "cocktail martini drink",
"🍹": "tropical drink",
"🍾": "bottle popping champagne celebrate",
"🧁": "cupcake dessert sweet",
"⚽": "soccer football ball sport",
"🏀": "basketball ball sport",
"🏈": "football american sport",
"⚾": "baseball ball sport",
"🎾": "tennis ball sport",
"🎮": "video game controller gaming",
"🎲": "dice game random",
"🎯": "bullseye target dart",
"🎵": "music note",
"🎶": "music notes",
"💡": "light bulb idea",
"🔥": "fire hot flame lit",
"⭐": "star yellow",
"🌟": "glowing star sparkle",
"💫": "dizzy star shooting",
"✨": "sparkles magic shine",
"💥": "boom collision crash",
"❤️": "red heart love",
"🧡": "orange heart love",
"💛": "yellow heart love",
"💚": "green heart love",
"💙": "blue heart love",
"💜": "purple heart love",
"🖤": "black heart dark love",
"🤍": "white heart love",
"💯": "hundred percent perfect score",
"💢": "anger symbol mad",
"💬": "speech bubble chat talk",
"👁‍🗨": "eye speech bubble witness",
"🗨": "speech balloon left",
"✅": "check mark yes done complete",
"❌": "cross mark no wrong cancel",
"❓": "question mark red",
"❗": "exclamation mark red alert",
"‼️": "double exclamation",
"⁉️": "exclamation question",
"💤": "sleeping zzz tired",
"💮": "white flower",
"♻️": "recycle green environment",
"🔰": "beginner new japanese",
"⚠️": "warning caution alert",
"🚫": "prohibited forbidden no",
"🔴": "red circle",
"🟠": "orange circle",
"🟡": "yellow circle",
"🟢": "green circle",
"🔵": "blue circle",
"🟣": "purple circle",
"⚫": "black circle",
"⚪": "white circle",
};
const MAX_RECENT = 20;
@@ -224,9 +553,7 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
// Build categories with recent + custom
function getAllCategories(): readonly EmojiCategory[] {
const recent = getRecentEmoji();
const cats: EmojiCategory[] = [
{ name: "Recent", emoji: recent },
];
const cats: EmojiCategory[] = [{ name: "Recent", emoji: recent }];
// Custom server emoji
if (options.customEmoji && options.customEmoji.length > 0) {
@@ -292,9 +619,13 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
// If nothing rendered at all, show empty state
if (scrollArea.children.length === 0) {
const empty = createElement("div", {
style: "padding: 24px; text-align: center; color: var(--text-faint); font-size: 13px;",
}, "No emoji found");
const empty = createElement(
"div",
{
style: "padding: 24px; text-align: center; color: var(--text-faint); font-size: 13px;",
},
"No emoji found",
);
scrollArea.appendChild(empty);
}
}
@@ -303,17 +634,25 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
renderAllCategories(getAllCategories());
// Search handler
searchInput.addEventListener("input", () => {
searchQuery = searchInput.value.trim();
renderAllCategories(getAllCategories());
}, { signal });
searchInput.addEventListener(
"input",
() => {
searchQuery = searchInput.value.trim();
renderAllCategories(getAllCategories());
},
{ signal },
);
// Close on Escape
root.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
options.onClose();
}
}, { signal });
root.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") {
options.onClose();
}
},
{ signal },
);
// Focus search on mount
requestAnimationFrame(() => searchInput.focus());
@@ -5,9 +5,26 @@ import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
/** Default allowed MIME types for file uploads. */
const DEFAULT_ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
"text/plain",
];
export interface FileUploadOptions {
readonly onUpload: (file: File) => Promise<void>;
readonly maxSizeMb?: number;
readonly allowedMimeTypes?: readonly string[];
}
const DEFAULT_MAX_SIZE_MB = 10;
@@ -31,6 +48,7 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
let errorDiv: HTMLDivElement;
let uploadAbort: AbortController | null = null;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -62,15 +80,22 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
const url = URL.createObjectURL(file);
thumb.src = url;
thumb.style.display = "block";
thumb.onload = () => URL.revokeObjectURL(url);
thumb.addEventListener("load", () => URL.revokeObjectURL(url));
}
preview.classList.remove("file-upload__preview--hidden");
}
async function handleFile(file: File): Promise<void> {
errorDiv.classList.add("file-upload__error--hidden");
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
if (file.type && !allowed.includes(file.type)) {
showError(`File type "${file.type}" is not allowed.`);
return;
}
if (file.size > maxBytes) {
showError(`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`);
showError(
`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`,
);
return;
}
showPreview(file);
@@ -90,10 +115,20 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
function buildDom(): void {
root = createElement("div", { class: "file-upload" });
dropzone = createElement("div", { class: "file-upload__dropzone file-upload__dropzone--hidden" });
appendChildren(dropzone, createElement("span", { class: "file-upload__droptext" }, "Drop files here"));
dropzone = createElement("div", {
class: "file-upload__dropzone file-upload__dropzone--hidden",
});
appendChildren(
dropzone,
createElement("span", { class: "file-upload__droptext" }, "Drop files here"),
);
fileInput = createElement("input", { class: "file-upload__input", type: "file" });
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
fileInput = createElement("input", {
class: "file-upload__input",
type: "file",
accept: allowed.join(","),
});
fileInput.style.display = "none";
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
@@ -115,38 +150,64 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
}
function attachListeners(): void {
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) { void handleFile(file); fileInput.value = ""; }
}, { signal });
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file) {
void handleFile(file);
fileInput.value = "";
}
},
{ signal },
);
cancelBtn.addEventListener("click", () => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
},
{ signal },
);
let dragCounter = 0;
root!.addEventListener("dragenter", (e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
}, { signal });
root!.addEventListener(
"dragenter",
(e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
},
{ signal },
);
root!.addEventListener("dragleave", (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) { dragCounter = 0; dropzone.classList.add("file-upload__dropzone--hidden"); }
}, { signal });
root!.addEventListener(
"dragleave",
(e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
}
},
{ signal },
);
root!.addEventListener("dragover", (e) => e.preventDefault(), { signal });
root!.addEventListener("drop", (e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
}, { signal });
root!.addEventListener(
"drop",
(e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
},
{ signal },
);
}
function mount(container: Element): void {
@@ -162,7 +223,9 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
root = null;
}
function openPicker(): void { fileInput.click(); }
function openPicker(): void {
fileInput.click();
}
return { mount, destroy, openPicker };
}
+31 -20
View File
@@ -88,10 +88,14 @@ export function createGifPicker(options: GifPickerOptions): {
});
item.appendChild(img);
item.addEventListener("click", () => {
options.onSelect(gif.fullUrl);
options.onClose();
}, { signal });
item.addEventListener(
"click",
() => {
options.onSelect(gif.fullUrl);
options.onClose();
},
{ signal },
);
grid.appendChild(item);
}
@@ -109,9 +113,8 @@ export function createGifPicker(options: GifPickerOptions): {
showLoading();
try {
const gifs = query.length > 0
? await searchGifs(query, GIF_LIMIT)
: await getTrendingGifs(GIF_LIMIT);
const gifs =
query.length > 0 ? await searchGifs(query, GIF_LIMIT) : await getTrendingGifs(GIF_LIMIT);
// Only render if this is still the latest request
if (requestId === currentRequestId) {
@@ -130,20 +133,28 @@ export function createGifPicker(options: GifPickerOptions): {
// ── Event handlers ──
searchInput.addEventListener("input", () => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void loadGifs(searchInput.value.trim());
}, DEBOUNCE_MS);
}, { signal });
searchInput.addEventListener(
"input",
() => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void loadGifs(searchInput.value.trim());
}, DEBOUNCE_MS);
},
{ signal },
);
root.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
options.onClose();
}
}, { signal });
root.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") {
options.onClose();
}
},
{ signal },
);
// Focus search on mount
requestAnimationFrame(() => searchInput.focus());
@@ -39,9 +39,8 @@ function maskCode(code: string): string {
}
function formatInviteInfo(invite: InviteItem): string {
const uses = invite.maxUses !== null
? `${invite.uses}/${invite.maxUses} uses`
: `${invite.uses} uses`;
const uses =
invite.maxUses !== null ? `${invite.uses}/${invite.maxUses} uses` : `${invite.uses} uses`;
return `Created by ${invite.createdBy} \u00B7 ${uses}`;
}
@@ -49,9 +48,7 @@ function formatInviteInfo(invite: InviteItem): string {
// Factory
// ---------------------------------------------------------------------------
export function createInviteManager(
options: InviteManagerOptions,
): MountableComponent {
export function createInviteManager(options: InviteManagerOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let listEl: HTMLDivElement | null = null;
@@ -80,21 +77,32 @@ export function createInviteManager(
const copyBtn = createElement("button", { class: "invite-item__copy" });
copyBtn.appendChild(createIcon("external-link", 14));
copyBtn.appendChild(document.createTextNode(" Copy"));
copyBtn.addEventListener("click", () => {
options.onCopyLink(invite.code);
}, { signal: ac.signal });
copyBtn.addEventListener(
"click",
() => {
options.onCopyLink(invite.code);
},
{ signal: ac.signal },
);
const revokeBtn = createElement("button", { class: "invite-item__revoke" });
revokeBtn.appendChild(createIcon("trash-2", 14));
revokeBtn.appendChild(document.createTextNode(" Revoke"));
revokeBtn.addEventListener("click", () => {
void options.onRevokeInvite(invite.code).then(() => {
invites = invites.filter((i) => i.code !== invite.code);
renderList();
}).catch(() => {
options.onError?.("Failed to revoke invite");
});
}, { signal: ac.signal });
revokeBtn.addEventListener(
"click",
() => {
void options
.onRevokeInvite(invite.code)
.then(() => {
invites = invites.filter((i) => i.code !== invite.code);
renderList();
})
.catch(() => {
options.onError?.("Failed to revoke invite");
});
},
{ signal: ac.signal },
);
appendChildren(actions, copyBtn, revokeBtn);
appendChildren(headerRow, code, actions);
@@ -135,29 +143,44 @@ export function createInviteManager(
const createBtn = createElement("button", { class: "invite-manager__create btn-modal-save" });
createBtn.appendChild(createIcon("external-link", 14));
createBtn.appendChild(document.createTextNode(" Create Invite"));
createBtn.addEventListener("click", () => {
void options.onCreateInvite().then((newInvite) => {
invites = [...invites, newInvite];
renderList();
}).catch(() => {
options.onError?.("Failed to create invite");
});
}, { signal: ac.signal });
createBtn.addEventListener(
"click",
() => {
void options
.onCreateInvite()
.then((newInvite) => {
invites = [...invites, newInvite];
renderList();
})
.catch(() => {
options.onError?.("Failed to create invite");
});
},
{ signal: ac.signal },
);
footer.appendChild(createBtn);
// Escape key
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") {
options.onClose();
}
}, { signal: ac.signal });
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape") {
options.onClose();
}
},
{ signal: ac.signal },
);
// Click overlay to close
root.addEventListener("click", (e) => {
if (e.target === root) {
options.onClose();
}
}, { signal: ac.signal });
root.addEventListener(
"click",
(e) => {
if (e.target === root) {
options.onClose();
}
},
{ signal: ac.signal },
);
appendChildren(modal, header, body, footer);
root.appendChild(modal);
@@ -35,21 +35,31 @@ const ROLE_GROUPS: readonly {
/** Status priority for sorting: lower = higher priority (shown first). */
function statusPriority(status: UserStatus): number {
switch (status) {
case "online": return 0;
case "idle": return 1;
case "dnd": return 2;
case "offline": return 3;
default: return 99;
case "online":
return 0;
case "idle":
return 1;
case "dnd":
return 2;
case "offline":
return 3;
default:
return 99;
}
}
function statusColor(status: UserStatus): string {
switch (status) {
case "online": return "var(--green)";
case "idle": return "var(--yellow)";
case "dnd": return "var(--red)";
case "offline": return "var(--text-micro)";
default: return "#747f8d";
case "online":
return "var(--green)";
case "idle":
return "var(--yellow)";
case "dnd":
return "var(--red)";
case "offline":
return "var(--text-micro)";
default:
return "#747f8d";
}
}
@@ -95,53 +105,54 @@ function createMemberItem(
});
avatar.appendChild(statusDot);
const name = createElement(
"span",
{ class: "mi-name", style: `color: ${colorVar}` },
);
const name = createElement("span", { class: "mi-name", style: `color: ${colorVar}` });
setText(name, member.username);
appendChildren(item, avatar, name);
// Context menu for admin actions
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
item.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
// Don't show context menu for yourself
const currentUserId = authStore.getState().user?.id ?? 0;
if (member.id === currentUserId) return;
// Don't show context menu for yourself
const currentUserId = authStore.getState().user?.id ?? 0;
if (member.id === currentUserId) return;
// Only admins and owners can use admin actions
const role = opts.currentUserRole.toLowerCase();
if (role !== "owner" && role !== "admin") return;
// Only admins and owners can use admin actions
const role = opts.currentUserRole.toLowerCase();
if (role !== "owner" && role !== "admin") return;
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
const availableRoles = ["admin", "moderator", "member"];
const availableRoles = ["admin", "moderator", "member"];
activeMenu = createMemberContextMenu({
userId: member.id,
username: member.username,
currentRole: member.role.toLowerCase(),
availableRoles,
onKick: () => opts.onKick(member.id, member.username),
onBan: () => opts.onBan(member.id, member.username),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
activeMenu = createMemberContextMenu({
userId: member.id,
username: member.username,
currentRole: member.role.toLowerCase(),
availableRoles,
onKick: () => opts.onKick(member.id, member.username),
onBan: () => opts.onBan(member.id, member.username),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
// Position at mouse
activeMenu.element.style.position = "fixed";
activeMenu.element.style.left = `${e.clientX}px`;
activeMenu.element.style.top = `${e.clientY}px`;
activeMenu.element.style.zIndex = "1000";
document.body.appendChild(activeMenu.element);
// Position at mouse
activeMenu.element.style.position = "fixed";
activeMenu.element.style.left = `${e.clientX}px`;
activeMenu.element.style.top = `${e.clientY}px`;
activeMenu.element.style.zIndex = "1000";
document.body.appendChild(activeMenu.element);
// Close on outside click (deferred so this click doesn't close it)
setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 0);
}, { signal });
// Close on outside click (deferred so this click doesn't close it)
setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 0);
},
{ signal },
);
return item;
}
@@ -163,7 +174,7 @@ function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: Abort
for (const group of ROLE_GROUPS) {
const groupMembers = allMembers
.filter((m) => m.role.toLowerCase() === group.role)
.sort((a, b) => statusPriority(a.status) - statusPriority(b.status));
.toSorted((a, b) => statusPriority(a.status) - statusPriority(b.status));
if (groupMembers.length === 0) continue;
@@ -12,7 +12,11 @@ import { createGifPicker } from "@components/GifPicker";
export interface MessageInputOptions {
readonly channelId: number;
readonly channelName: string;
readonly onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => void;
readonly onSend: (
content: string,
replyTo: number | null,
attachments: readonly string[],
) => void;
readonly onUploadFile?: (file: File) => Promise<{ id: string; url: string; filename: string }>;
readonly onTyping: () => void;
readonly onEditMessage: (messageId: number, content: string) => void;
@@ -40,14 +44,14 @@ const ALLOWED_TYPES = [
"application/json",
];
export function createMessageInput(
options: MessageInputOptions,
): MessageInputComponent {
export function createMessageInput(options: MessageInputOptions): MessageInputComponent {
const ac = new AbortController();
const signal = ac.signal;
let root: HTMLDivElement | null = null;
let state = { replyTo: null as { messageId: number; username: string } | null,
editing: null as { messageId: number } | null };
let state = {
replyTo: null as { messageId: number; username: string } | null,
editing: null as { messageId: number } | null,
};
let lastTypingTime = 0;
let lastSendTime = 0;
@@ -58,7 +62,8 @@ export function createMessageInput(
let attachmentPreviewBar: HTMLDivElement | null = null;
/** Pending attachment IDs to send with the next message. */
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = [];
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] =
[];
/** Count of file uploads currently in flight. */
let pendingUploadCount = 0;
/** References to picker close functions, set by mount() for destroy() to call. */
@@ -72,9 +77,15 @@ export function createMessageInput(
replyBar.classList.add("visible");
}
function hideReplyBar(): void { replyBar?.classList.remove("visible"); }
function showEditBar(): void { editBar?.classList.add("visible"); }
function hideEditBar(): void { editBar?.classList.remove("visible"); }
function hideReplyBar(): void {
replyBar?.classList.remove("visible");
}
function showEditBar(): void {
editBar?.classList.add("visible");
}
function hideEditBar(): void {
editBar?.classList.remove("visible");
}
function autoResize(): void {
if (textarea === null) return;
@@ -102,11 +113,18 @@ export function createMessageInput(
function showUploadError(message: string): void {
if (attachmentPreviewBar === null) return;
const errEl = createElement("div", {
class: "attachment-upload-error",
}, message);
const errEl = createElement(
"div",
{
class: "attachment-upload-error",
},
message,
);
attachmentPreviewBar.appendChild(errEl);
const t = setTimeout(() => { activeTimers.delete(t); errEl.remove(); }, 4000);
const t = setTimeout(() => {
activeTimers.delete(t);
errEl.remove();
}, 4000);
activeTimers.add(t);
}
@@ -165,11 +183,12 @@ export function createMessageInput(
}
/** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */
// oxlint-disable-next-line consistent-function-scoping -- co-located with handlePasteFile for readability
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("Failed to read file"));
reader.addEventListener("load", () => resolve(reader.result as string));
reader.addEventListener("error", () => reject(new Error("Failed to read file")));
reader.readAsDataURL(file);
});
}
@@ -183,9 +202,9 @@ export function createMessageInput(
return;
}
// Validate file type (allow empty type for files without MIME info)
if (file.type !== "" && !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) {
showUploadError(`Unsupported file type: ${file.type}`);
// Validate file type — reject files with unknown/empty MIME type
if (file.type === "" || !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) {
showUploadError(`${file.name} is not a supported file type`);
return;
}
@@ -203,13 +222,17 @@ export function createMessageInput(
alt: file.name,
});
item.appendChild(img);
readFileAsDataUrl(file).then((dataUrl) => {
img.src = dataUrl;
}).catch(() => {
// Fallback: show filename
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
readFileAsDataUrl(file)
.then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
})
.catch(() => {
if (signal.aborted) return;
// Fallback: show filename
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
} else {
const icon = createElement("div", { class: "attachment-preview-file" });
icon.appendChild(createIcon("file-text", 16));
@@ -227,10 +250,14 @@ export function createMessageInput(
"data-testid": "attachment-remove",
});
removeBtn.appendChild(createIcon("x", 14));
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
removePreviewItem(tempId);
}, { signal });
removeBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
removePreviewItem(tempId);
},
{ signal },
);
item.appendChild(removeBtn);
attachmentPreviewBar.appendChild(item);
@@ -240,11 +267,14 @@ export function createMessageInput(
pendingUploadCount++;
try {
const result = await options.onUploadFile(file);
// Replace temp ID with real server ID
const att = pendingAttachments.find((a) => a.id === tempId);
if (att !== undefined) {
att.id = result.id;
att.filename = result.filename;
// Replace temp ID with real server ID (immutable update)
const attIdx = pendingAttachments.findIndex((a) => a.id === tempId);
if (attIdx !== -1) {
pendingAttachments[attIdx] = {
...pendingAttachments[attIdx]!,
id: result.id,
filename: result.filename,
};
item.classList.remove("uploading");
spinner.remove();
}
@@ -284,7 +314,10 @@ export function createMessageInput(
function cancelEdit(): void {
state = { ...state, editing: null };
hideEditBar();
if (textarea !== null) { textarea.value = ""; autoResize(); }
if (textarea !== null) {
textarea.value = "";
autoResize();
}
}
function mount(container: Element): void {
@@ -313,8 +346,11 @@ export function createMessageInput(
attachmentPreviewBar = createElement("div", { class: "attachment-preview-bar" });
const inputBox = createElement("div", { class: "message-input-box" });
const attachBtn = createElement("button",
{ class: "input-btn attach-btn", "aria-label": "Attach file" }, "+");
const attachBtn = createElement(
"button",
{ class: "input-btn attach-btn", "aria-label": "Attach file" },
"+",
);
// File picker via attach button
if (options.onUploadFile !== undefined) {
@@ -323,13 +359,17 @@ export function createMessageInput(
style: "display: none;",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
});
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file != null) {
void handlePasteFile(file);
}
fileInput.value = "";
}, { signal });
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file != null) {
void handlePasteFile(file);
}
fileInput.value = "";
},
{ signal },
);
attachBtn.addEventListener("click", () => fileInput.click(), { signal });
root?.appendChild(fileInput);
} else {
@@ -337,42 +377,73 @@ export function createMessageInput(
attachBtn.title = "File uploads not available";
}
textarea = createElement("textarea", {
class: "msg-textarea", placeholder: `Message #${options.channelName}`, rows: "1",
class: "msg-textarea",
placeholder: `Message #${options.channelName}`,
rows: "1",
"data-testid": "msg-textarea",
});
const emojiBtn = createElement("button",
{ class: "input-btn emoji-btn", "aria-label": "Emoji" });
const emojiBtn = createElement("button", {
class: "input-btn emoji-btn",
"aria-label": "Emoji",
});
emojiBtn.appendChild(createIcon("smile", 20));
const gifBtn = createElement("button",
{ class: "input-btn gif-btn", "aria-label": "GIF" }, "GIF");
const sendBtn = createElement("button",
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" });
const gifBtn = createElement(
"button",
{ class: "input-btn gif-btn", "aria-label": "GIF" },
"GIF",
);
const sendBtn = createElement("button", {
class: "input-btn send-btn",
"aria-label": "Send message",
"data-testid": "send-btn",
});
sendBtn.appendChild(createIcon("send", 20));
textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal });
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
if (e.key === "Escape") {
if (state.editing !== null) { cancelEdit(); }
else if (state.replyTo !== null) { clearReply(); }
}
if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) {
root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true }));
}
}, { signal });
textarea.addEventListener(
"input",
() => {
autoResize();
maybeEmitTyping();
},
{ signal },
);
textarea.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
if (e.key === "Escape") {
if (state.editing !== null) {
cancelEdit();
} else if (state.replyTo !== null) {
clearReply();
}
}
if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) {
root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true }));
}
},
{ signal },
);
// Clipboard paste: detect images/files
textarea.addEventListener("paste", (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (items === undefined) return;
for (const item of items) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file === null) continue;
e.preventDefault();
void handlePasteFile(file);
}
}, { signal });
textarea.addEventListener(
"paste",
(e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (items === undefined) return;
for (const item of items) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file === null) continue;
e.preventDefault();
void handlePasteFile(file);
}
},
{ signal },
);
sendBtn.addEventListener("click", handleSend, { signal });
@@ -393,7 +464,11 @@ export function createMessageInput(
if (emojiPicker === null) return;
const target = e.target as Node;
// Close if click is outside both the picker and the emoji button
if (!emojiPicker.element.contains(target) && target !== emojiBtn && !emojiBtn.contains(target)) {
if (
!emojiPicker.element.contains(target) &&
target !== emojiBtn &&
!emojiBtn.contains(target)
) {
closeEmojiPicker();
}
}
@@ -428,7 +503,9 @@ export function createMessageInput(
// Defer so this click doesn't immediately close it
const t1 = setTimeout(() => {
activeTimers.delete(t1);
document.addEventListener("mousedown", handleClickOutside);
if (!signal.aborted) {
document.addEventListener("mousedown", handleClickOutside);
}
}, 0);
activeTimers.add(t1);
}
@@ -477,7 +554,9 @@ export function createMessageInput(
root?.appendChild(gifPicker.element);
const t2 = setTimeout(() => {
activeTimers.delete(t2);
document.addEventListener("mousedown", handleGifClickOutside);
if (!signal.aborted) {
document.addEventListener("mousedown", handleGifClickOutside);
}
}, 0);
activeTimers.add(t2);
}
@@ -485,7 +564,10 @@ export function createMessageInput(
gifBtn.addEventListener("click", toggleGifPicker, { signal });
// Store picker cleanup for destroy()
cleanupPickers = () => { closeEmojiPicker(); closeGifPicker(); };
cleanupPickers = () => {
closeEmojiPicker();
closeGifPicker();
};
appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn);
appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox);
@@ -11,12 +11,7 @@ import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
const log = createLogger("message-list");
import {
shouldGroup,
isSameDay,
renderDayDivider,
renderMessage,
} from "./message-list/renderers";
import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers";
import { FenwickTree } from "./message-list/fenwick";
// -- Options ------------------------------------------------------------------
@@ -120,9 +115,7 @@ function renderEmptyState(channelName: string, channelType?: string): HTMLDivEle
icon.textContent = isDm ? "@" : "#";
const title = createElement("h2", { class: "channel-welcome-title" });
title.textContent = isDm
? channelName
: `Welcome to #${channelName}!`;
title.textContent = isDm ? channelName : `Welcome to #${channelName}!`;
const text = createElement("p", { class: "channel-welcome-text" });
text.textContent = isDm
@@ -277,7 +270,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let renderWindowResetTimer = 0;
function renderWindow(): void {
if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null) return;
if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null)
return;
const scrollTop = root.scrollTop;
const clientHeight = root.clientHeight;
@@ -465,9 +459,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// Load older messages when near top
if (
root.scrollTop < SCROLL_TOP_THRESHOLD
&& !loadingOlder
&& hasMoreMessages(options.channelId)
root.scrollTop < SCROLL_TOP_THRESHOLD &&
!loadingOlder &&
hasMoreMessages(options.channelId)
) {
loadingOlder = true;
options.onScrollTop();
@@ -499,10 +493,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
scrollToBottomBtn = createElement("button", { class: "scroll-to-bottom-btn" });
scrollToBottomBtn.textContent = "↓";
scrollToBottomBtn.addEventListener("click", () => {
scrollToBottom();
updateScrollToBottomBtn();
}, { signal: ac.signal });
scrollToBottomBtn.addEventListener(
"click",
() => {
scrollToBottom();
updateScrollToBottomBtn();
},
{ signal: ac.signal },
);
root.appendChild(topSpacer);
root.appendChild(contentContainer);
@@ -555,21 +553,29 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const initialScrollRaf = requestAnimationFrame(() => scrollToBottom());
ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf));
unsubscribers.push(messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => { renderAll(); },
));
unsubscribers.push(
messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => {
renderAll();
},
),
);
// Only re-render when member roles change, not on presence/typing updates.
// Extract a role-only map so shallowEqual ignores status changes.
unsubscribers.push(membersStore.subscribeSelector(
(s) => {
const roles = new Map<number, string>();
for (const [id, m] of s.members) roles.set(id, m.role);
return roles;
},
() => { renderAll(); },
));
unsubscribers.push(
membersStore.subscribeSelector(
(s) => {
const roles = new Map<number, string>();
for (const [id, m] of s.members) roles.set(id, m.role);
return roles;
},
() => {
renderAll();
},
),
);
}
function destroy(): void {
@@ -591,11 +597,16 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderWindowResetTimer = 0;
}
unsubLoadingReset();
for (const unsub of unsubscribers) { unsub(); }
for (const unsub of unsubscribers) {
unsub();
}
unsubscribers.length = 0;
heightCache.clear();
tree = null;
if (root !== null) { root.remove(); root = null; }
if (root !== null) {
root.remove();
root = null;
}
contentContainer = null;
topSpacer = null;
bottomSpacer = null;
@@ -618,7 +629,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const el = contentContainer.children[localIdx] as HTMLElement | undefined;
if (el !== undefined) {
el.classList.add("highlight-flash");
setTimeout(() => { el.classList.remove("highlight-flash"); }, 1500);
setTimeout(() => {
el.classList.remove("highlight-flash");
}, 1500);
}
}
@@ -3,10 +3,7 @@
* with avatars, hover actions, and entry animation.
*/
import {
createElement,
appendChildren,
} from "@lib/dom";
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -92,9 +89,7 @@ function renderEmptyState(): HTMLDivElement {
return empty;
}
export function createPinnedMessages(
options: PinnedMessagesOptions,
): MountableComponent {
export function createPinnedMessages(options: PinnedMessagesOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
@@ -39,17 +39,24 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
});
// Close on backdrop click (not on modal content)
root.addEventListener("click", (e) => {
if (e.target === root) options.onClose();
}, { signal: ac.signal });
root.addEventListener(
"click",
(e) => {
if (e.target === root) options.onClose();
},
{ signal: ac.signal },
);
const modal = createElement("div", { class: "quick-switch-modal" });
// Header
const header = createElement("div", { class: "quick-switch-header" });
const title = createElement("h2", {}, "Switch Server");
const subtitle = createElement("p", { class: "quick-switch-subtitle" },
"You\u2019ll disconnect from the current server.");
const subtitle = createElement(
"p",
{ class: "quick-switch-subtitle" },
"You\u2019ll disconnect from the current server.",
);
appendChildren(header, title, subtitle);
// Server list
@@ -68,8 +75,11 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const info = createElement("div", { class: "quick-switch-info" });
const nameEl = createElement("div", { class: "quick-switch-name" }, profile.name);
const hostEl = createElement("div", { class: "quick-switch-host" },
`${profile.host}${isCurrent ? " \u00B7 Connected" : ""}`);
const hostEl = createElement(
"div",
{ class: "quick-switch-host" },
`${profile.host}${isCurrent ? " \u00B7 Connected" : ""}`,
);
appendChildren(info, nameEl, hostEl);
if (isCurrent) {
@@ -77,9 +87,13 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
appendChildren(item, icon, info, dot);
} else {
appendChildren(item, icon, info);
item.addEventListener("click", () => {
options.onSwitch(profile.host, profile.name);
}, { signal: ac.signal });
item.addEventListener(
"click",
() => {
options.onSwitch(profile.host, profile.name);
},
{ signal: ac.signal },
);
}
list.appendChild(item);
@@ -93,7 +107,11 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
const addInfo = createElement("div", { class: "quick-switch-info" });
const addName = createElement("div", { class: "quick-switch-name" }, "Add new server");
const addHost = createElement("div", { class: "quick-switch-host" }, "Connect to another OwnCord server");
const addHost = createElement(
"div",
{ class: "quick-switch-host" },
"Connect to another OwnCord server",
);
appendChildren(addInfo, addName, addHost);
appendChildren(addItem, addIcon, addInfo);
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
@@ -107,9 +125,13 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
container.appendChild(root);
// Escape key closes overlay
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") options.onClose();
}, { signal: ac.signal });
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") options.onClose();
},
{ signal: ac.signal },
);
}
function destroy(): void {
@@ -30,7 +30,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
function getFilteredChannels(query: string): readonly Channel[] {
const state = channelsStore.getState();
const all = Array.from(state.channels.values());
const sorted = [...all].sort((a, b) => a.position - b.position);
const sorted = [...all].toSorted((a, b) => a.position - b.position);
if (query.length === 0) return sorted;
@@ -68,10 +68,14 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
appendChildren(item, ...parts);
item.addEventListener("click", () => {
options.onSelectChannel(ch.id);
options.onClose();
}, { signal });
item.addEventListener(
"click",
() => {
options.onSelectChannel(ch.id);
options.onClose();
},
{ signal },
);
resultsDiv.appendChild(item);
}
@@ -144,7 +148,8 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
// Overlay backdrop
root = createElement("div", {
class: "quick-switcher-overlay",
style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;",
style:
"position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;",
});
// Modal container
@@ -175,10 +180,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
document.addEventListener("keydown", handleGlobalKeydown, { signal });
// Subscribe to store changes
unsubscribe = channelsStore.subscribeSelector(
(s) => s.channels,
refreshFromStore,
);
unsubscribe = channelsStore.subscribeSelector((s) => s.channels, refreshFromStore);
// Auto-focus
requestAnimationFrame(() => input.focus());
@@ -13,7 +13,11 @@ import type { SearchResultItem } from "@lib/types";
// ---------------------------------------------------------------------------
export interface SearchOverlayOptions {
readonly onSearch: (query: string, channelId?: number, signal?: AbortSignal) => Promise<readonly SearchResultItem[]>;
readonly onSearch: (
query: string,
channelId?: number,
signal?: AbortSignal,
) => Promise<readonly SearchResultItem[]>;
readonly onSelectResult: (result: SearchResultItem) => void;
readonly onClose: () => void;
readonly currentChannelId?: number;
@@ -25,6 +29,8 @@ export interface SearchOverlayOptions {
const DEBOUNCE_MS = 300;
const MIN_QUERY_LEN = 2;
/** Minimum interval between actual search API calls (rate limiting). */
const MIN_SEARCH_INTERVAL_MS = 500;
// ---------------------------------------------------------------------------
// Factory
@@ -42,12 +48,17 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
let results: readonly SearchResultItem[] = [];
let debounceTimer: number | null = null;
let searchAbort: AbortController | null = null;
let lastSearchTime = 0;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
+ " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return (
d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
);
} catch {
return ts;
}
@@ -62,9 +73,7 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
const isActive = i === activeIndex;
const item = createElement("div", {
class: isActive
? "search-result-item search-result-item--active"
: "search-result-item",
class: isActive ? "search-result-item search-result-item--active" : "search-result-item",
role: "option",
"aria-selected": isActive ? "true" : "false",
"data-testid": `search-result-${i}`,
@@ -84,10 +93,14 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
appendChildren(item, header, content);
item.addEventListener("click", () => {
options.onSelectResult(r);
options.onClose();
}, { signal });
item.addEventListener(
"click",
() => {
options.onSelectResult(r);
options.onClose();
},
{ signal },
);
resultsDiv.appendChild(item);
}
@@ -99,6 +112,10 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
}
function doSearch(): void {
const now = Date.now();
if (now - lastSearchTime < MIN_SEARCH_INTERVAL_MS) return;
lastSearchTime = now;
const query = input.value.trim();
if (query.length < MIN_QUERY_LEN) {
results = [];
@@ -115,7 +132,8 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
setStatus("Searching...");
options.onSearch(query, options.currentChannelId, searchAbort.signal)
options
.onSearch(query, options.currentChannelId, searchAbort.signal)
.then((items) => {
results = items;
activeIndex = 0;
@@ -43,7 +43,16 @@ export interface SettingsOverlayOptions {
isAuthenticated?: boolean;
}
export type TabName = "Account" | "Appearance" | "Notifications" | "Text & Images" | "Accessibility" | "Voice & Audio" | "Keybinds" | "Advanced" | "Logs";
export type TabName =
| "Account"
| "Appearance"
| "Notifications"
| "Text & Images"
| "Accessibility"
| "Voice & Audio"
| "Keybinds"
| "Advanced"
| "Logs";
const TAB_ICONS: Record<TabName, IconName> = {
Account: "user",
@@ -92,8 +101,14 @@ export function applyStoredAppearance(): void {
"compact-mode",
loadPref<boolean>("compactMode", false),
);
document.documentElement.classList.toggle("reduced-motion", loadPref<boolean>("reducedMotion", false));
document.documentElement.classList.toggle("high-contrast", loadPref<boolean>("highContrast", false));
document.documentElement.classList.toggle(
"reduced-motion",
loadPref<boolean>("reducedMotion", false),
);
document.documentElement.classList.toggle(
"high-contrast",
loadPref<boolean>("highContrast", false),
);
document.documentElement.classList.toggle("large-font", loadPref<boolean>("largeFont", false));
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
@@ -178,14 +193,26 @@ export function createSettingsOverlay(
// User profile section at top of sidebar
const user = authStore.getState().user;
const profileSection = createElement("div", { class: "settings-sidebar-profile" });
const avatarEl = createElement("div", { class: "settings-sidebar-avatar" },
(user?.username ?? "U").charAt(0).toUpperCase());
const avatarEl = createElement(
"div",
{ class: "settings-sidebar-avatar" },
(user?.username ?? "U").charAt(0).toUpperCase(),
);
const profileInfo = createElement("div", {});
const profileName = createElement("div", { class: "settings-sidebar-name" },
user?.username ?? "Unknown");
const editProfileLink = createElement("div", { class: "settings-sidebar-edit" }, "Edit Profile");
const profileName = createElement(
"div",
{ class: "settings-sidebar-name" },
user?.username ?? "Unknown",
);
const editProfileLink = createElement(
"div",
{ class: "settings-sidebar-edit" },
"Edit Profile",
);
if (authenticated) {
editProfileLink.addEventListener("click", () => setActiveTab("Account"), { signal: ac.signal });
editProfileLink.addEventListener("click", () => setActiveTab("Account"), {
signal: ac.signal,
});
} else {
editProfileLink.style.display = "none";
}
@@ -214,7 +241,16 @@ export function createSettingsOverlay(
const appSettingsCat = createElement("div", { class: "settings-cat" }, "App Settings");
sidebar.appendChild(appSettingsCat);
const appTabs: readonly TabName[] = ["Appearance", "Notifications", "Text & Images", "Accessibility", "Voice & Audio", "Keybinds", "Advanced", "Logs"];
const appTabs: readonly TabName[] = [
"Appearance",
"Notifications",
"Text & Images",
"Accessibility",
"Voice & Audio",
"Keybinds",
"Advanced",
"Logs",
];
for (const name of appTabs) {
const btn = createElement("button", {
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
@@ -248,27 +284,39 @@ export function createSettingsOverlay(
const closeWrap = createElement("div", { class: "settings-close-wrap" });
const closeBtn = createElement("button", { class: "settings-close-btn" });
closeBtn.appendChild(createIcon("x", 18));
closeBtn.addEventListener("click", () => {
options.onClose();
}, { signal: ac.signal });
closeBtn.addEventListener(
"click",
() => {
options.onClose();
},
{ signal: ac.signal },
);
const escLabel = createElement("div", { class: "settings-esc-label" }, "ESC");
appendChildren(closeWrap, closeBtn, escLabel);
// Escape key
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape" && root?.classList.contains("open")) {
options.onClose();
}
}, { signal: ac.signal });
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && root?.classList.contains("open")) {
options.onClose();
}
},
{ signal: ac.signal },
);
// Inner panel (Discord-style centered card)
const panel = createElement("div", { class: "settings-panel" });
appendChildren(panel, sidebar, contentArea, closeWrap);
// Click backdrop (outside panel) to close
root.addEventListener("click", (e: MouseEvent) => {
if (e.target === root) options.onClose();
}, { signal: ac.signal });
root.addEventListener(
"click",
(e: MouseEvent) => {
if (e.target === root) options.onClose();
},
{ signal: ac.signal },
);
root.appendChild(panel);
renderActiveTab();
@@ -0,0 +1,231 @@
/**
* StatusPicker — compact dropdown for selecting user online status.
* Shows a colored status dot; clicking it opens a floating menu
* with all status options. Intended for use in the UserBar.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface StatusPickerOptions {
readonly currentStatus: UserStatus;
readonly onStatusChange: (status: UserStatus) => void;
}
export type StatusPickerComponent = MountableComponent & {
/** Update the displayed status without recreating the picker. */
setStatus(status: UserStatus): void;
};
// ---------------------------------------------------------------------------
// Status definitions
// ---------------------------------------------------------------------------
interface StatusDef {
readonly value: UserStatus;
readonly label: string;
readonly color: string;
}
const STATUS_DEFS: readonly StatusDef[] = [
{ value: "online", label: "Online", color: "#3ba55d" },
{ value: "idle", label: "Idle", color: "#faa61a" },
{ value: "dnd", label: "Do Not Disturb", color: "#ed4245" },
{ value: "offline", label: "Invisible", color: "#747f8d" },
];
function colorForStatus(status: UserStatus): string {
return STATUS_DEFS.find((d) => d.value === status)?.color ?? "#747f8d";
}
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
export function createStatusPicker(options: StatusPickerOptions): StatusPickerComponent {
const ac = new AbortController();
const { signal } = ac;
let currentStatus: UserStatus = options.currentStatus;
let root: HTMLDivElement | null = null;
let dotEl: HTMLDivElement | null = null;
let dropdownEl: HTMLDivElement | null = null;
let checkEls = new Map<UserStatus, HTMLSpanElement>();
// ---- Dropdown visibility --------------------------------------------------
function isOpen(): boolean {
return dropdownEl?.classList.contains("status-picker-dropdown--open") === true;
}
function openDropdown(): void {
dropdownEl?.classList.add("status-picker-dropdown--open");
dotEl?.setAttribute("aria-expanded", "true");
}
function closeDropdown(): void {
dropdownEl?.classList.remove("status-picker-dropdown--open");
dotEl?.setAttribute("aria-expanded", "false");
}
function toggleDropdown(): void {
if (isOpen()) {
closeDropdown();
} else {
openDropdown();
}
}
// ---- Status update --------------------------------------------------------
function applyStatus(status: UserStatus): void {
currentStatus = status;
// Update trigger dot color
if (dotEl !== null) {
dotEl.style.background = colorForStatus(status);
}
// Update checkmarks
for (const [value, el] of checkEls) {
el.style.display = value === status ? "" : "none";
}
}
// ---- Build DOM ------------------------------------------------------------
function buildOption(def: StatusDef): HTMLDivElement {
const row = createElement("div", {
class: "status-picker-option",
role: "menuitem",
tabindex: "0",
});
const optDot = createElement("span", { class: "status-picker-option-dot" });
optDot.style.background = def.color;
const label = createElement("span", { class: "status-picker-option-label" }, def.label);
const check = createElement("span", { class: "status-picker-option-check" });
check.style.display = def.value === currentStatus ? "" : "none";
const checkIcon = createIcon("check", 16);
check.appendChild(checkIcon);
checkEls.set(def.value, check);
appendChildren(row, optDot, label, check);
row.addEventListener(
"click",
() => {
applyStatus(def.value);
closeDropdown();
options.onStatusChange(def.value);
},
{ signal },
);
row.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
row.click();
}
},
{ signal },
);
return row;
}
// ---- MountableComponent ---------------------------------------------------
function mount(container: Element): void {
root = createElement("div", { class: "status-picker" });
// Trigger dot
dotEl = createElement("div", {
class: "status-picker-dot",
role: "button",
tabindex: "0",
"aria-label": "Change status",
"aria-haspopup": "true",
"aria-expanded": "false",
});
dotEl.style.background = colorForStatus(currentStatus);
dotEl.addEventListener(
"click",
(e: MouseEvent) => {
e.stopPropagation();
toggleDropdown();
},
{ signal },
);
dotEl.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleDropdown();
}
},
{ signal },
);
// Dropdown menu
dropdownEl = createElement("div", {
class: "status-picker-dropdown",
role: "menu",
});
for (const def of STATUS_DEFS) {
dropdownEl.appendChild(buildOption(def));
}
appendChildren(root, dotEl, dropdownEl);
container.appendChild(root);
// Close on outside click
document.addEventListener(
"click",
(e: MouseEvent) => {
if (isOpen() && root !== null && !root.contains(e.target as Node)) {
closeDropdown();
}
},
{ signal },
);
// Close on Escape
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen()) {
closeDropdown();
dotEl?.focus();
}
},
{ signal },
);
}
function destroy(): void {
ac.abort();
checkEls = new Map();
root?.remove();
root = null;
dotEl = null;
dropdownEl = null;
}
function setStatus(status: UserStatus): void {
applyStatus(status);
}
return { mount, destroy, setStatus };
}
@@ -93,6 +93,7 @@ export function createToastContainer(): ToastContainer {
}
function clear(): void {
// oxlint-disable-next-line no-useless-spread -- snapshot needed: removeToast splices the array during iteration
for (const entry of [...toasts]) {
removeToast(entry);
}
@@ -25,9 +25,7 @@ function formatTypingText(users: readonly Member[]): string {
return "Several people are typing...";
}
export function createTypingIndicator(
options: TypingIndicatorOptions,
): MountableComponent {
export function createTypingIndicator(options: TypingIndicatorOptions): MountableComponent {
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
@@ -65,7 +63,9 @@ export function createTypingIndicator(
disposable.onStoreChange(
membersStore,
(s) => s.typingUsers,
() => { updateFromState(); },
() => {
updateFromState();
},
);
container.appendChild(root);
@@ -32,17 +32,26 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
banner = createElement("div", { class: "update-banner" });
const text = createElement("span", { class: "update-banner-text" },
`Update v${version} available`);
const text = createElement(
"span",
{ class: "update-banner-text" },
`Update v${version} available`,
);
const updateBtn = createElement("button", { class: "update-banner-btn update-banner-install" },
"Update Now");
const updateBtn = createElement(
"button",
{ class: "update-banner-btn update-banner-install" },
"Update Now",
);
updateBtn.addEventListener("click", () => {
void installUpdate();
});
const laterBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
"Later");
const laterBtn = createElement(
"button",
{ class: "update-banner-btn update-banner-later" },
"Later",
);
laterBtn.addEventListener("click", () => {
dismissed = true;
removeBanner();
@@ -57,8 +66,11 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
// Replace banner content with progress indicator
while (banner.firstChild) banner.removeChild(banner.firstChild);
const progress = createElement("span", { class: "update-banner-text" },
"Downloading update...");
const progress = createElement(
"span",
{ class: "update-banner-text" },
"Downloading update...",
);
banner.appendChild(progress);
try {
@@ -67,10 +79,16 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
} catch (err) {
log.error("Update install failed", { error: String(err) });
while (banner.firstChild) banner.removeChild(banner.firstChild);
const errorText = createElement("span", { class: "update-banner-text" },
"Update failed. Please try again later.");
const dismissBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
"Dismiss");
const errorText = createElement(
"span",
{ class: "update-banner-text" },
"Update failed. Please try again later.",
);
const dismissBtn = createElement(
"button",
{ class: "update-banner-btn update-banner-later" },
"Dismiss",
);
dismissBtn.addEventListener("click", () => {
dismissed = true;
removeBanner();
@@ -89,7 +107,9 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
function mount(target: Element): void {
container = target;
// Delay the check slightly so the main UI renders first
setTimeout(() => { void performCheck(); }, 3000);
setTimeout(() => {
void performCheck();
}, 3000);
}
function destroy(): void {
+57 -9
View File
@@ -9,9 +9,13 @@ import type { MountableComponent } from "@lib/safe-render";
import { Disposable } from "@lib/disposable";
import { authStore } from "@stores/auth.store";
import { openSettings } from "@stores/ui.store";
import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker";
import type { UserStatus } from "@lib/types";
import type { WsClient } from "@lib/ws";
export interface UserBarOptions {
readonly onDisconnect?: () => void;
readonly ws?: WsClient | null;
}
export function createUserBar(options?: UserBarOptions): MountableComponent {
@@ -23,6 +27,7 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
let avatarTextEl: HTMLSpanElement | null = null;
let nameEl: HTMLSpanElement | null = null;
let statusEl: HTMLSpanElement | null = null;
let statusPicker: StatusPickerComponent | null = null;
function updateFromState(): void {
const state = authStore.getState();
@@ -44,15 +49,16 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
function mount(container: Element): void {
root = createElement("div", { class: "user-bar", "data-testid": "user-bar" });
avatarEl = createElement(
"div",
{ class: "ub-avatar", style: "background: var(--accent); position: relative;" },
);
avatarEl = createElement("div", {
class: "ub-avatar",
style: "background: var(--accent); position: relative;",
});
avatarTextEl = createElement("span", {});
avatarEl.appendChild(avatarTextEl);
const statusDot = createElement("div", {
class: "status-dot",
style: "background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
style:
"background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
});
avatarEl.appendChild(statusDot);
@@ -61,12 +67,52 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
statusEl = createElement("span", { class: "ub-status" });
appendChildren(info, nameEl, statusEl);
// Status picker — anchored below username, opens upward
const statusPickerWrap = createElement("div", {
class: "ub-status-picker-wrap",
"data-testid": "status-picker-wrap",
});
const isWsConnected = (): boolean => {
const ws = options?.ws;
return ws !== undefined && ws !== null && ws.getState() === "connected";
};
statusPicker = createStatusPicker({
currentStatus: "online" as UserStatus,
onStatusChange: (status: UserStatus) => {
const ws = options?.ws;
if (ws !== null && ws !== undefined && isWsConnected()) {
ws.send({ type: "presence_update", payload: { status } } as never);
}
},
});
statusPicker.mount(statusPickerWrap);
// Disable picker when WS is disconnected
const updatePickerDisabled = (): void => {
const ws = options?.ws;
const connected = ws !== undefined && ws !== null && ws.getState() === "connected";
statusPickerWrap.classList.toggle("ub-status-picker--disabled", !connected);
if (!connected) {
statusPickerWrap.title = "Offline";
} else {
statusPickerWrap.title = "";
}
};
updatePickerDisabled();
// Subscribe to WS state changes if ws is provided
if (options?.ws !== undefined && options?.ws !== null) {
const unsub = options.ws.onStateChange(() => updatePickerDisabled());
disposable.addCleanup(unsub);
}
info.appendChild(statusPickerWrap);
const buttons = createElement("div", { class: "ub-controls" });
const settingsBtn = createElement(
"button",
{ title: "Settings", "aria-label": "Settings" },
);
const settingsBtn = createElement("button", { title: "Settings", "aria-label": "Settings" });
settingsBtn.appendChild(createIcon("settings", 18));
disposable.onEvent(settingsBtn, "click", () => {
@@ -104,6 +150,8 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
}
function destroy(): void {
statusPicker?.destroy?.();
statusPicker = null;
disposable.destroy();
if (root !== null) {
root.remove();
@@ -0,0 +1,361 @@
/**
* UserProfilePopup — anchored popover that appears when clicking a username
* in the chat or member list. Shows avatar, username, role badge, status dot,
* about section, join date, and Message/Call action buttons.
*
* Position: anchored to click point, flips if <100px from viewport edge.
* Animation: fade+scale 100ms.
* Close: outside click or Escape.
* A11y: role="dialog", aria-label, focus trap, return focus on close.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { isSafeUrl } from "./message-list/attachments";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface UserProfileData {
readonly id: number;
readonly username: string;
readonly avatar: string | null;
readonly role: string;
readonly status: UserStatus;
readonly about?: string | null;
readonly joinDate?: string | null;
readonly isDeleted?: boolean;
}
export interface UserProfilePopupOptions {
readonly user: UserProfileData;
/** Anchor point — the click event's clientX/clientY. */
readonly anchorX: number;
readonly anchorY: number;
/** Called when the user clicks "Message". */
readonly onMessage?: (userId: number) => void;
/** Called when the user clicks "Call". */
readonly onCall?: (userId: number) => void;
}
export type UserProfilePopupComponent = MountableComponent & {
/** Check if the popup is currently visible. */
isOpen(): boolean;
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const POPUP_WIDTH = 300;
const EDGE_THRESHOLD = 100;
const ANIMATION_DURATION_MS = 100;
const STATUS_COLORS: Record<UserStatus, string> = {
online: "#3ba55d",
idle: "#faa61a",
dnd: "#ed4245",
offline: "#747f8d",
};
const STATUS_LABELS: Record<UserStatus, string> = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
offline: "Offline",
};
const ROLE_COLORS: Record<string, string> = {
owner: "#e74c3c",
admin: "#f39c12",
moderator: "#2ecc71",
member: "#949ba4",
};
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
export function createUserProfilePopup(
options: UserProfilePopupOptions,
): UserProfilePopupComponent {
const ac = new AbortController();
const { signal } = ac;
let overlay: HTMLDivElement | null = null;
let popup: HTMLDivElement | null = null;
let previousFocus: Element | null = null;
function isOpen(): boolean {
return popup !== null && overlay !== null;
}
function close(): void {
if (overlay !== null) {
overlay.remove();
overlay = null;
}
popup = null;
ac.abort();
// Return focus to the element that was focused before opening
if (previousFocus instanceof HTMLElement) {
previousFocus.focus();
}
}
function computePosition(anchorX: number, anchorY: number): { left: number; top: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = anchorX;
let top = anchorY;
// Flip horizontally if too close to right edge
if (vw - anchorX < EDGE_THRESHOLD) {
left = anchorX - POPUP_WIDTH;
}
// Flip vertically if too close to bottom edge
if (vh - anchorY < EDGE_THRESHOLD) {
top = anchorY - 300; // approximate popup height
}
// Clamp to viewport
left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8));
top = Math.max(8, top);
return { left, top };
}
function buildAvatar(user: UserProfileData): HTMLDivElement {
const wrapper = createElement("div", { class: "upp-avatar" });
if (user.isDeleted === true) {
wrapper.style.background = "#4e5058";
const text = createElement("span", {}, "?");
wrapper.appendChild(text);
} else if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
const img = createElement("img", {
src: user.avatar,
alt: user.username,
class: "upp-avatar-img",
});
img.style.width = "64px";
img.style.height = "64px";
img.style.borderRadius = "50%";
wrapper.appendChild(img);
} else {
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const text = createElement("span", {}, initial);
wrapper.appendChild(text);
}
// Status dot overlay
const statusDot = createElement("div", { class: "upp-status-dot" });
statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
statusDot.title = STATUS_LABELS[user.status] ?? "Offline";
wrapper.appendChild(statusDot);
return wrapper;
}
function mount(container: Element): void {
previousFocus = document.activeElement;
const user = options.user;
const displayName = user.isDeleted === true ? "[deleted]" : user.username;
// Overlay for outside-click detection
overlay = createElement("div", {
class: "upp-overlay",
"data-testid": "user-profile-overlay",
});
// Popup container
popup = createElement("div", {
class: "upp-popup",
role: "dialog",
"aria-label": "User profile",
"aria-modal": "true",
tabindex: "-1",
"data-testid": "user-profile-popup",
});
// Position the popup
const pos = computePosition(options.anchorX, options.anchorY);
popup.style.left = `${pos.left}px`;
popup.style.top = `${pos.top}px`;
popup.style.width = `${POPUP_WIDTH}px`;
// Animation: fade + scale
popup.style.opacity = "0";
popup.style.transform = "scale(0.95)";
popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`;
// --- Content ---
// Avatar
const avatar = buildAvatar(user);
// Username
const nameEl = createElement("div", { class: "upp-username" }, displayName);
if (user.isDeleted === true) {
nameEl.style.color = "var(--text-faint, #80848e)";
}
// Role badge
const roleBadge = createElement("span", { class: "upp-role-badge" });
const roleDot = createElement("span", { class: "upp-role-dot" });
roleDot.style.background = ROLE_COLORS[user.role] ?? ROLE_COLORS.member ?? "";
const roleLabel = createElement(
"span",
{},
user.role.charAt(0).toUpperCase() + user.role.slice(1),
);
appendChildren(roleBadge, roleDot, roleLabel);
// Status line
const statusLine = createElement("div", { class: "upp-status-line" });
const statusDotInline = createElement("span", { class: "upp-status-dot-inline" });
statusDotInline.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
const statusText = createElement("span", {}, STATUS_LABELS[user.status] ?? "Offline");
appendChildren(statusLine, statusDotInline, statusText);
// About section (2 lines max)
const aboutSection = createElement("div", { class: "upp-about" });
if (user.about !== undefined && user.about !== null && user.about.length > 0) {
const aboutTitle = createElement("div", { class: "upp-section-title" }, "ABOUT ME");
const aboutText = createElement("div", { class: "upp-about-text" }, user.about);
appendChildren(aboutSection, aboutTitle, aboutText);
}
// Join date
const joinSection = createElement("div", { class: "upp-join-date" });
if (user.joinDate !== undefined && user.joinDate !== null) {
const joinTitle = createElement("div", { class: "upp-section-title" }, "MEMBER SINCE");
const joinText = createElement("div", { class: "upp-join-text" }, user.joinDate);
appendChildren(joinSection, joinTitle, joinText);
}
// Divider
const divider = createElement("div", { class: "upp-divider" });
// Actions
const actions = createElement("div", { class: "upp-actions" });
const messageBtn = createElement("button", {
class: "upp-action-btn",
"data-testid": "upp-message-btn",
});
messageBtn.appendChild(createIcon("send", 16));
messageBtn.appendChild(document.createTextNode(" Message"));
messageBtn.addEventListener(
"click",
() => {
options.onMessage?.(user.id);
close();
},
{ signal },
);
const callBtn = createElement("button", {
class: "upp-action-btn",
"data-testid": "upp-call-btn",
});
callBtn.appendChild(createIcon("phone", 16));
callBtn.appendChild(document.createTextNode(" Call"));
callBtn.addEventListener(
"click",
() => {
options.onCall?.(user.id);
close();
},
{ signal },
);
appendChildren(actions, messageBtn, callBtn);
// Assemble popup
appendChildren(
popup,
avatar,
nameEl,
roleBadge,
statusLine,
aboutSection,
joinSection,
divider,
actions,
);
overlay.appendChild(popup);
container.appendChild(overlay);
// Trigger animation
requestAnimationFrame(() => {
if (popup !== null) {
popup.style.opacity = "1";
popup.style.transform = "scale(1)";
}
});
// Focus the popup for a11y
popup.focus();
// Close on outside click (click on overlay but not on popup)
overlay.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (popup !== null && !popup.contains(e.target as Node)) {
close();
}
},
{ signal },
);
// Close on Escape
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen()) {
close();
}
},
{ signal },
);
// Focus trap: keep focus inside popup
popup.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "Tab" || popup === null) return;
const focusable = popup.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
},
{ signal },
);
}
function destroy(): void {
close();
}
return { mount, destroy, isOpen };
}
@@ -5,7 +5,11 @@
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { muteScreenshareAudio, setUserVolume } from "@lib/livekitSession";
import {
muteScreenshareAudio,
setScreenshareAudioVolume,
setUserVolume,
} from "@lib/livekitSession";
import type { MountableComponent } from "@lib/safe-render";
export interface TileConfig {
@@ -26,9 +30,13 @@ export interface VideoGridComponent extends MountableComponent {
}
/** Create a fresh volume icon element. */
function volumeIcon(): SVGSVGElement { return createIcon("volume-2", 16); }
function volumeIcon(): SVGSVGElement {
return createIcon("volume-2", 16);
}
/** Create a fresh volume-x (muted) icon element. */
function volumeXIcon(): SVGSVGElement { return createIcon("volume-x", 16); }
function volumeXIcon(): SVGSVGElement {
return createIcon("volume-x", 16);
}
/** Replace a button's icon child with a new one. */
function setButtonIcon(btn: HTMLButtonElement, icon: SVGSVGElement): void {
while (btn.firstChild) btn.removeChild(btn.firstChild);
@@ -93,7 +101,10 @@ export function computeGridLayout(
export function createVideoGrid(): VideoGridComponent {
let root: HTMLDivElement | null = null;
const cells = new Map<number, { el: HTMLDivElement; config?: TileConfig }>();
const cells = new Map<
number,
{ el: HTMLDivElement; config?: TileConfig; trackCleanup?: () => void }
>();
let focusedTileId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeRafId = 0;
@@ -113,6 +124,44 @@ export function createVideoGrid(): VideoGridComponent {
}
}
/** Attach ended/mute/unmute listeners on the first video track to handle stale tiles. */
function attachTrackLifecycle(userId: number, stream: MediaStream): void {
// Clean up previous listeners for this tile
const prev = cells.get(userId);
if (prev?.trackCleanup) {
prev.trackCleanup();
prev.trackCleanup = undefined;
}
const track = stream.getVideoTracks()[0];
if (track === undefined) return;
const onTrackEnded = (): void => {
removeStream(userId);
};
const onTrackMute = (): void => {
// Temporarily hide video — track may unmute after network recovery
const cell = cells.get(userId);
if (cell !== undefined) cell.el.classList.add("track-muted");
};
const onTrackUnmute = (): void => {
const cell = cells.get(userId);
if (cell !== undefined) cell.el.classList.remove("track-muted");
};
track.addEventListener("ended", onTrackEnded);
track.addEventListener("mute", onTrackMute);
track.addEventListener("unmute", onTrackUnmute);
const entry = cells.get(userId);
if (entry !== undefined) {
entry.trackCleanup = () => {
track.removeEventListener("ended", onTrackEnded);
track.removeEventListener("mute", onTrackMute);
track.removeEventListener("unmute", onTrackUnmute);
};
}
}
/** Schedule a layout recalculation on the next animation frame. */
function scheduleResize(): void {
if (resizeRafId !== 0) cancelAnimationFrame(resizeRafId);
@@ -191,7 +240,12 @@ export function createVideoGrid(): VideoGridComponent {
applyGridSizes();
}
function addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void {
function addStream(
userId: number,
username: string,
stream: MediaStream,
config?: TileConfig,
): void {
if (root === null) return;
// If a cell already exists for this user, update it in place
@@ -207,6 +261,8 @@ export function createVideoGrid(): VideoGridComponent {
oldTracks.every((t, i) => t.id === newTracks[i]?.id);
if (!tracksMatch) {
video.srcObject = stream;
video.play()?.catch(() => {});
attachTrackLifecycle(userId, stream);
}
}
// Update username label in case it changed
@@ -214,6 +270,8 @@ export function createVideoGrid(): VideoGridComponent {
if (label !== null) {
label.textContent = username;
}
// Sync stream type attribute in case it changed
existing.el.dataset.streamType = config?.isScreenshare ? "screenshare" : "camera";
return;
}
@@ -223,12 +281,15 @@ export function createVideoGrid(): VideoGridComponent {
});
video.muted = true;
video.srcObject = stream;
video.play()?.catch(() => {});
const label = createElement("div", { class: "video-username" }, username);
const streamType = config?.isScreenshare ? "screenshare" : "camera";
const cell = createElement("div", {
class: "video-cell",
"data-user-id": String(userId),
"data-stream-type": streamType,
});
appendChildren(cell, video, label);
@@ -263,7 +324,9 @@ export function createVideoGrid(): VideoGridComponent {
const wasMuted = muted;
muted = currentVolume === 0;
if (config.isScreenshare) {
// BUG-102: Set actual volume, not just mute toggle.
muteScreenshareAudio(config.audioUserId, muted);
setScreenshareAudioVolume(config.audioUserId, currentVolume / 200);
} else {
setUserVolume(config.audioUserId, currentVolume);
}
@@ -310,6 +373,7 @@ export function createVideoGrid(): VideoGridComponent {
}
cells.set(userId, { el: cell, config });
attachTrackLifecycle(userId, stream);
root.appendChild(cell);
if (focusedTileId !== null) {
rebuildFocusLayout();
@@ -322,6 +386,11 @@ export function createVideoGrid(): VideoGridComponent {
const entry = cells.get(userId);
if (entry === undefined) return;
if (entry.trackCleanup) {
entry.trackCleanup();
entry.trackCleanup = undefined;
}
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
@@ -354,7 +423,9 @@ export function createVideoGrid(): VideoGridComponent {
container.appendChild(root);
// Observe container size changes to recalculate tile layout
resizeObserver = new ResizeObserver(() => { scheduleResize(); });
resizeObserver = new ResizeObserver(() => {
scheduleResize();
});
resizeObserver.observe(root);
}
@@ -368,6 +439,10 @@ export function createVideoGrid(): VideoGridComponent {
}
for (const [, entry] of cells) {
if (entry.trackCleanup) {
entry.trackCleanup();
entry.trackCleanup = undefined;
}
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
}
@@ -380,5 +455,13 @@ export function createVideoGrid(): VideoGridComponent {
}
}
return { mount, destroy, addStream, removeStream, hasStreams, setFocusedTile, getFocusedTileId: getFocusedTileIdFn };
return {
mount,
destroy,
addStream,
removeStream,
hasStreams,
setFocusedTile,
getFocusedTileId: getFocusedTileIdFn,
};
}
@@ -11,11 +11,14 @@ import type { VoiceUser } from "@stores/voice.store";
import { membersStore } from "@stores/members.store";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { authStore } from "@stores/auth.store";
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
export interface VoiceChannelOptions {
channelId: number;
channelName: string;
onJoin(): void;
onClickWatch?(tileId: number): void;
}
export interface VoiceChannelResult {
@@ -53,6 +56,9 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
appendChildren(root, channelItem, usersContainer);
// BUG-104: Attach scroll collapse once (not per-update) to avoid listener accumulation.
attachScrollCollapse(usersContainer, ac.signal);
// Click to join
channelItem.addEventListener("click", options.onJoin, { signal: ac.signal });
@@ -77,10 +83,14 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const menu = createElement("div", { class: "context-menu" });
// Header
const header = createElement("div", {
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
}, username);
const header = createElement(
"div",
{
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
},
username,
);
menu.appendChild(header);
const sep = createElement("div", { class: "context-menu-sep" });
@@ -88,10 +98,14 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Volume label
const currentVol = getUserVolume(userId);
const volLabel = createElement("div", {
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
}, `User Volume: ${currentVol}%`);
const volLabel = createElement(
"div",
{
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
},
`User Volume: ${currentVol}%`,
);
menu.appendChild(volLabel);
// Volume slider (0-200%, like Discord)
@@ -106,10 +120,14 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
value: String(currentVol),
style: "flex:1",
});
const valLabel = createElement("span", {
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
}, `${currentVol}%`);
const valLabel = createElement(
"span",
{
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
},
`${currentVol}%`,
);
slider.addEventListener("input", () => {
const val = Number(slider.value);
@@ -142,18 +160,20 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const dismissSignal = menuDismissAc.signal;
setTimeout(() => {
if (dismissSignal.aborted) return;
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
closeContextMenu();
}
}, { signal: dismissSignal });
document.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
closeContextMenu();
}
},
{ signal: dismissSignal },
);
}, 0);
}
function createUserRow(user: VoiceUser, username: string): HTMLDivElement {
const classes = user.speaking
? "voice-user-item speaking"
: "voice-user-item";
const classes = user.speaking ? "voice-user-item speaking" : "voice-user-item";
const row = createElement("div", { class: classes });
const initial = username.length > 0 ? username.charAt(0).toUpperCase() : "?";
@@ -180,11 +200,15 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Right-click for per-user volume (skip for own user)
const currentUser = authStore.getState().user;
if (currentUser === null || currentUser.id !== user.userId) {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
showVolumeMenu(user.userId, username, e.clientX, e.clientY);
}, { signal: ac.signal });
row.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopPropagation();
showVolumeMenu(user.userId, username, e.clientX, e.clientY);
},
{ signal: ac.signal },
);
}
return row;
@@ -215,6 +239,31 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const username = (member as { username?: string } | undefined)?.username ?? "Unknown";
const row = createUserRow(user, username);
usersContainer.appendChild(row);
// Attach stream preview for remote users with active video
const currentUser = authStore.getState().user;
if (
(currentUser === null || currentUser.id !== user.userId) &&
(user.camera || user.screenshare)
) {
const tileId = user.screenshare ? user.userId + SCREENSHARE_TILE_ID_OFFSET : user.userId;
attachStreamPreview(
row,
user.userId,
username,
user.screenshare,
user.camera,
ac.signal,
() => {
// Only join if not already in this channel
if (voiceStore.getState().currentChannelId !== options.channelId) {
options.onJoin();
}
if (options.onClickWatch !== undefined) options.onClickWatch(tileId);
},
options.onClickWatch !== undefined ? () => options.onClickWatch!(tileId) : undefined,
);
}
}
// Mark channel-item active if there are users
@@ -227,8 +276,18 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Initial render and subscribe
update();
unsubs.push(voiceStore.subscribeSelector((s) => s.voiceUsers, () => update()));
unsubs.push(membersStore.subscribeSelector((s) => s.members, () => update()));
unsubs.push(
voiceStore.subscribeSelector(
(s) => s.voiceUsers,
() => update(),
),
);
unsubs.push(
membersStore.subscribeSelector(
(s) => s.members,
() => update(),
),
);
function destroy(): void {
closeContextMenu();
+102 -48
View File
@@ -111,7 +111,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
pingLabel.style.color = color;
// Update expanded stats pane fields if they exist
if (outRateEl) setText(outRateEl, `${formatRate(stats.outRate)} (${formatBitrate(stats.outRate)})`);
if (outRateEl)
setText(outRateEl, `${formatRate(stats.outRate)} (${formatBitrate(stats.outRate)})`);
if (outPacketsEl) setText(outPacketsEl, String(stats.outPackets));
if (rttEl) {
setText(rttEl, stats.rtt > 0 ? `${stats.rtt.toFixed(1)} ms` : "—");
@@ -194,11 +195,30 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
deafenBtn?.classList.toggle("active-ctrl", voice.localDeafened);
cameraBtn?.classList.toggle("active-ctrl", voice.localCamera);
if (muteBtn) { swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic"); muteBtn.setAttribute("aria-pressed", String(voice.localMuted)); }
if (deafenBtn) { swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones"); deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened)); }
if (cameraBtn) { swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera"); cameraBtn.setAttribute("aria-pressed", String(voice.localCamera)); }
if (muteBtn) {
swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic");
muteBtn.setAttribute("aria-pressed", String(voice.localMuted));
}
if (deafenBtn) {
swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones");
deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened));
}
if (cameraBtn) {
swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera");
cameraBtn.setAttribute("aria-pressed", String(voice.localCamera));
}
shareBtn?.classList.toggle("active-ctrl", voice.localScreenshare);
if (shareBtn) { swapIcon(shareBtn, voice.localScreenshare ? "monitor-off" : "monitor"); shareBtn.setAttribute("aria-pressed", String(voice.localScreenshare)); }
shareBtn?.classList.toggle("sharing-active", voice.localScreenshare);
if (shareBtn) {
swapIcon(shareBtn, voice.localScreenshare ? "monitor-off" : "monitor");
shareBtn.setAttribute("aria-pressed", String(voice.localScreenshare));
// Update button label to show "Sharing" when active
const labelSpan = shareBtn.querySelector(".vw-share-label");
if (labelSpan !== null) {
labelSpan.textContent = voice.localScreenshare ? "Sharing" : "";
(labelSpan as HTMLElement).style.display = voice.localScreenshare ? "inline" : "none";
}
}
// Show/hide "Grant Microphone" button based on listen-only state
if (grantMicBtn) {
@@ -235,9 +255,13 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
pingLabel = createElement("span", { class: "vw-ping" }, "—");
pingLabel.style.color = QUALITY_COLORS.excellent;
signalWrap.appendChild(pingLabel);
signalWrap.addEventListener("click", () => {
statsPane?.classList.toggle("visible");
}, { signal: ac.signal });
signalWrap.addEventListener(
"click",
() => {
statsPane?.classList.toggle("visible");
},
{ signal: ac.signal },
);
appendChildren(header, connLabel, timerEl, channelNameEl, signalWrap);
@@ -254,7 +278,11 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
rttEl = createElement("span", {}, "—");
rttEl.style.fontWeight = "600";
const outBody = createElement("div", { class: "vw-stats-row" });
for (const [label, el] of [["Rate: ", outRateEl], ["Packets: ", outPacketsEl], ["RTT: ", rttEl]] as const) {
for (const [label, el] of [
["Rate: ", outRateEl],
["Packets: ", outPacketsEl],
["RTT: ", rttEl],
] as const) {
outBody.appendChild(document.createTextNode(label));
outBody.appendChild(el);
outBody.appendChild(createElement("br", {}));
@@ -267,7 +295,10 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
inRateEl = createElement("span", {}, "0 B/s");
inPacketsEl = createElement("span", {}, "0");
const inBody = createElement("div", { class: "vw-stats-row" });
for (const [label, el] of [["Rate: ", inRateEl], ["Packets: ", inPacketsEl]] as const) {
for (const [label, el] of [
["Rate: ", inRateEl],
["Packets: ", inPacketsEl],
] as const) {
inBody.appendChild(document.createTextNode(label));
inBody.appendChild(el);
inBody.appendChild(createElement("br", {}));
@@ -298,57 +329,80 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
muteBtn = createControlButton("Mute", "mic", options.onMuteToggle);
deafenBtn = createControlButton("Deafen", "headphones", options.onDeafenToggle);
cameraBtn = createControlButton("Camera", "camera", options.onCameraToggle);
shareBtn = createControlButton("Screenshare", "monitor", options.onScreenshareToggle);
shareBtn = createControlButton(
"Screenshare",
"monitor",
options.onScreenshareToggle,
"vw-share-btn",
);
const shareLabelSpan = createElement("span", { class: "vw-share-label" });
shareLabelSpan.style.display = "none";
shareBtn.appendChild(shareLabelSpan);
const disconnectBtn = createControlButton(
"Disconnect", "phone", options.onDisconnect, "disconnect",
"Disconnect",
"phone",
options.onDisconnect,
"disconnect",
);
appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn);
// "Grant Microphone" button for listen-only mode
grantMicBtn = createElement("button", {
class: "vw-grant-mic",
"aria-label": "Grant microphone permission",
}, "Grant Microphone");
grantMicBtn = createElement(
"button",
{
class: "vw-grant-mic",
"aria-label": "Grant microphone permission",
},
"Grant Microphone",
);
grantMicBtn.style.display = "none";
grantMicBtn.addEventListener("click", () => {
if (grantMicBtn) {
grantMicBtn.disabled = true;
setText(grantMicBtn, "Requesting...");
}
void retryMicPermission().finally(() => {
grantMicBtn.addEventListener(
"click",
() => {
if (grantMicBtn) {
grantMicBtn.disabled = false;
setText(grantMicBtn, "Grant Microphone");
grantMicBtn.disabled = true;
setText(grantMicBtn, "Requesting...");
}
});
}, { signal: ac.signal });
void retryMicPermission().finally(() => {
if (grantMicBtn) {
grantMicBtn.disabled = false;
setText(grantMicBtn, "Grant Microphone");
}
});
},
{ signal: ac.signal },
);
appendChildren(root, header, statsPane, grantMicBtn, controls);
render();
unsubs.push(voiceStore.subscribeSelector(
(s) => ({
channelId: s.currentChannelId,
muted: s.localMuted,
deafened: s.localDeafened,
camera: s.localCamera,
screenshare: s.localScreenshare,
listenOnly: s.listenOnly,
}),
() => render(),
(a, b) =>
a.channelId === b.channelId &&
a.muted === b.muted &&
a.deafened === b.deafened &&
a.camera === b.camera &&
a.screenshare === b.screenshare &&
a.listenOnly === b.listenOnly,
));
unsubs.push(channelsStore.subscribeSelector(
(s) => s.channels,
() => render(),
));
unsubs.push(
voiceStore.subscribeSelector(
(s) => ({
channelId: s.currentChannelId,
muted: s.localMuted,
deafened: s.localDeafened,
camera: s.localCamera,
screenshare: s.localScreenshare,
listenOnly: s.listenOnly,
}),
() => render(),
(a, b) =>
a.channelId === b.channelId &&
a.muted === b.muted &&
a.deafened === b.deafened &&
a.camera === b.camera &&
a.screenshare === b.screenshare &&
a.listenOnly === b.listenOnly,
),
);
unsubs.push(
channelsStore.subscribeSelector(
(s) => s.channels,
() => render(),
),
);
container.appendChild(root);
}
@@ -0,0 +1,97 @@
/**
* Channel context menu — right-click on a channel for Edit/Delete actions.
* Only shown to admin/owner roles.
*/
import { createElement } from "@lib/dom";
import type { Channel } from "@stores/channels.store";
import { getCurrentUser } from "@stores/auth.store";
/** Attach a right-click context menu to a channel element for edit/delete. */
export function attachChannelContextMenu(
el: HTMLElement,
channel: Channel,
signal: AbortSignal,
onEdit?: (channel: Channel) => void,
onDelete?: (channel: Channel) => void,
): void {
if (onEdit === undefined && onDelete === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
return;
}
el.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopPropagation();
// Remove any existing context menu
document.querySelector(".channel-ctx-menu")?.remove();
const menu = createElement("div", {
class: "context-menu channel-ctx-menu",
"data-testid": "channel-context-menu",
});
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
if (onEdit !== undefined) {
const editItem = createElement(
"div",
{ class: "context-menu-item", "data-testid": "ctx-edit-channel" },
"Edit Channel",
);
editItem.addEventListener(
"click",
() => {
closeMenu();
onEdit(channel);
},
{ signal },
);
menu.appendChild(editItem);
}
if (onDelete !== undefined) {
if (onEdit !== undefined) {
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
}
const deleteItem = createElement(
"div",
{ class: "context-menu-item danger", "data-testid": "ctx-delete-channel" },
"Delete Channel",
);
deleteItem.addEventListener(
"click",
() => {
closeMenu();
onDelete(channel);
},
{ signal },
);
menu.appendChild(deleteItem);
}
document.body.appendChild(menu);
// Close menu on click elsewhere — use a per-menu AbortController
const menuAc = new AbortController();
const closeMenu = (): void => {
menu.remove();
menuAc.abort();
};
signal.addEventListener("abort", () => menuAc.abort());
// Defer so this click event doesn't immediately close it
setTimeout(() => {
if (menuAc.signal.aborted) return;
document.addEventListener("click", closeMenu, { signal: menuAc.signal });
}, 0);
},
{ signal },
);
}
@@ -0,0 +1,221 @@
/**
* Channel drag-reorder — mouse-based drag-and-drop for channel reordering.
* Uses mousedown/mousemove/mouseup (avoids WebView2 HTML5 DnD issues).
* Admin/owner only.
*/
import { getCurrentUser } from "@stores/auth.store";
import { updateChannelPosition } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { ChannelReorderData } from "../ChannelSidebar";
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
interface DragState {
channelId: number;
sourceEl: HTMLElement;
containerEl: HTMLElement;
channels: readonly Channel[];
onReorder: (reorders: readonly ChannelReorderData[]) => void;
}
let activeDrag: DragState | null = null;
/** Global mousemove/mouseup handlers for drag reordering. Registered once.
* Reference-counted so multiple sidebar instances share the same listeners
* and only the last destroy tears them down. */
let globalDragAc: AbortController | null = null;
let globalDragRefCount = 0;
export function ensureGlobalDragListeners(): void {
globalDragRefCount++;
if (globalDragAc !== null) {
return;
}
globalDragAc = new AbortController();
document.addEventListener(
"mousemove",
(e) => {
if (activeDrag === null) {
return;
}
// Clear old indicators
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Find which channel item we're hovering over
const items = activeDrag.containerEl.querySelectorAll("[data-drag-channel-id]");
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const targetId = Number((item as HTMLElement).dataset.dragChannelId);
if (targetId !== activeDrag.channelId) {
item.classList.add("channel-drop-indicator");
}
break;
}
}
},
{ signal: globalDragAc.signal },
);
document.addEventListener(
"mouseup",
(e) => {
if (activeDrag === null) {
return;
}
const drag = activeDrag;
activeDrag = null;
// Clean up visual state
drag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
drag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Find drop target
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
let dropBefore = false;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
dropTargetId = Number((item as HTMLElement).dataset.dragChannelId);
dropBefore = e.clientY < rect.top + rect.height / 2;
break;
}
}
if (dropTargetId === null || dropTargetId === drag.channelId) {
return;
}
// Compute new order
const orderedIds = drag.channels.map((ch) => ch.id);
const dragIdx = orderedIds.indexOf(drag.channelId);
if (dragIdx === -1) {
return;
}
const withoutDrag = orderedIds.filter((id) => id !== drag.channelId);
const targetIdx = withoutDrag.indexOf(dropTargetId);
if (targetIdx === -1) {
return;
}
const insertIdx = dropBefore ? targetIdx : targetIdx + 1;
const reorderedIds = [
...withoutDrag.slice(0, insertIdx),
drag.channelId,
...withoutDrag.slice(insertIdx),
];
// Build reorder data and update store immediately
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
if (id === undefined) {
continue;
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
}
}
if (reorders.length > 0) {
drag.onReorder(reorders);
}
},
{ signal: globalDragAc.signal },
);
}
/** Make a channel element draggable via mousedown (admin/owner only). */
export function attachDragHandlers(
el: HTMLElement,
channel: Channel,
containerEl: HTMLElement,
channels: readonly Channel[],
signal: AbortSignal,
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
): void {
if (onReorderChannel === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
return;
}
ensureGlobalDragListeners();
el.classList.add("channel-draggable");
el.dataset.dragChannelId = String(channel.id);
let pendingDrag: { startX: number; startY: number } | null = null;
el.addEventListener(
"mousedown",
(e) => {
if (e.button !== 0) {
return;
}
// Start tracking — only activate drag after movement threshold
pendingDrag = { startX: e.clientX, startY: e.clientY };
},
{ signal },
);
el.addEventListener(
"mousemove",
(e) => {
if (pendingDrag === null || activeDrag !== null) {
return;
}
const dx = Math.abs(e.clientX - pendingDrag.startX);
const dy = Math.abs(e.clientY - pendingDrag.startY);
// Require 5px movement to start drag (avoids hijacking clicks)
if (dx + dy < 5) {
return;
}
pendingDrag = null;
activeDrag = {
channelId: channel.id,
sourceEl: el,
containerEl,
channels,
onReorder: onReorderChannel,
};
el.classList.add("dragging");
document.body.classList.add("channel-reordering");
},
{ signal },
);
el.addEventListener(
"mouseup",
() => {
pendingDrag = null;
},
{ signal },
);
}
/** Decrement global drag listener ref-count; tear down when no more sidebars. */
export function releaseGlobalDragListeners(containerEl?: HTMLElement): void {
// Clear stale drag state if the destroyed sidebar owns the active drag
if (containerEl !== undefined && activeDrag?.containerEl === containerEl) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag = null;
}
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
if (globalDragRefCount === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
@@ -0,0 +1,114 @@
/**
* Per-user volume context menu — right-click on a voice user row
* to adjust their playback volume locally.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
export function showUserVolumeMenu(
userId: number,
username: string,
x: number,
y: number,
signal: AbortSignal,
): void {
// Remove any existing context menus and abort their dismiss controllers
document.querySelectorAll(".user-vol-menu").forEach((el) => {
const prev = (el as HTMLElement & { _dismissAc?: AbortController })._dismissAc;
prev?.abort();
el.remove();
});
const menu = createElement("div", { class: "context-menu user-vol-menu" });
const header = createElement(
"div",
{
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
},
username,
);
menu.appendChild(header);
const sep = createElement("div", { class: "context-menu-sep" });
menu.appendChild(sep);
const currentVol = getUserVolume(userId);
const volLabel = createElement(
"div",
{
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
},
`User Volume: ${currentVol}%`,
);
menu.appendChild(volLabel);
const sliderRow = createElement("div", {
style: "padding:4px 10px;display:flex;align-items:center;gap:8px",
});
const slider = createElement("input", {
type: "range",
class: "settings-slider",
min: "0",
max: "200",
value: String(currentVol),
style: "flex:1",
});
const valLabel = createElement(
"span",
{
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
},
`${currentVol}%`,
);
slider.addEventListener("input", () => {
const val = Number(slider.value);
setText(valLabel, `${val}%`);
setText(volLabel, `User Volume: ${val}%`);
setUserVolume(userId, val);
});
appendChildren(sliderRow, slider, valLabel);
menu.appendChild(sliderRow);
const resetBtn = createElement("div", { class: "context-menu-item" }, "Reset Volume");
resetBtn.addEventListener("click", () => {
setUserVolume(userId, 100);
slider.value = "100";
setText(valLabel, "100%");
setText(volLabel, "User Volume: 100%");
});
menu.appendChild(resetBtn);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
document.body.appendChild(menu);
// Close on click outside — store controller on element for cleanup on re-open
const dismissAc = new AbortController();
(menu as HTMLElement & { _dismissAc?: AbortController })._dismissAc = dismissAc;
setTimeout(() => {
if (dismissAc.signal.aborted) return;
document.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
dismissAc.abort();
}
},
{ signal: dismissAc.signal },
);
}, 0);
// Also clean up if the parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
}
@@ -0,0 +1,101 @@
/**
* File upload validation and preview rendering for message input.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit
export const ALLOWED_TYPES = [
"image/",
"video/",
"audio/",
"application/pdf",
"text/",
"application/zip",
"application/x-zip-compressed",
"application/json",
];
/** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */
export function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result as string));
reader.addEventListener("error", () => reject(new Error("Failed to read file")));
reader.readAsDataURL(file);
});
}
/** Validate file size and type. Returns an error message or null. */
export function validateFile(file: File): string | null {
if (file.size > MAX_FILE_SIZE) {
return `File too large: ${file.name} exceeds 100 MB limit`;
}
if (file.type === "" || !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) {
return `${file.name} is not a supported file type`;
}
return null;
}
/** Build a preview item element for a file being uploaded. */
export function buildPreviewItem(
file: File,
signal: AbortSignal,
onRemove: () => void,
): HTMLDivElement {
const isImage = file.type.startsWith("image/");
const item = createElement("div", { class: "attachment-preview-item uploading" });
if (isImage) {
const img = createElement("img", {
class: "attachment-preview-img",
alt: file.name,
});
item.appendChild(img);
readFileAsDataUrl(file)
.then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
})
.catch(() => {
if (signal.aborted) return;
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
} else {
const icon = createElement("div", { class: "attachment-preview-file" });
icon.appendChild(createIcon("file-text", 16));
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
appendChildren(item, icon, nameEl);
}
// Loading spinner overlay
const spinner = createElement("div", { class: "attachment-preview-spinner" });
spinner.appendChild(createIcon("loader", 16));
item.appendChild(spinner);
const removeBtn = createElement("button", {
class: "attachment-preview-remove",
"data-testid": "attachment-remove",
});
removeBtn.appendChild(createIcon("x", 14));
removeBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
onRemove();
},
{ signal },
);
item.appendChild(removeBtn);
return item;
}
/** Mark a preview item as uploaded (removes loading state). */
export function markPreviewUploaded(item: HTMLDivElement): void {
item.classList.remove("uploading");
const spinner = item.querySelector(".attachment-preview-spinner");
spinner?.remove();
}
@@ -0,0 +1,77 @@
/**
* Reusable picker toggle — manages open/close/click-outside lifecycle
* for floating panels (emoji picker, GIF picker, etc.).
*/
export interface PickerInstance {
readonly element: HTMLDivElement;
destroy(): void;
}
export interface PickerToggleOptions {
/** Creates and returns a new picker instance. */
readonly create: () => PickerInstance;
/** The trigger button element — clicks on it won't close the picker. */
readonly triggerEl: HTMLElement;
/** Parent element to append the picker to. */
readonly parentEl: HTMLElement | null;
/** Called before opening — use to close other pickers first. */
readonly onBeforeOpen?: () => void;
/** Timer set for deferred cleanup. */
readonly activeTimers: Set<ReturnType<typeof setTimeout>>;
}
export interface PickerToggleHandle {
toggle(): void;
close(): void;
}
export function createPickerToggle(opts: PickerToggleOptions): PickerToggleHandle {
let instance: PickerInstance | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
function handleClickOutside(e: MouseEvent): void {
if (instance === null) return;
const target = e.target as Node;
if (
!instance.element.contains(target) &&
target !== opts.triggerEl &&
!opts.triggerEl.contains(target)
) {
close();
}
}
function close(): void {
if (pendingTimer !== null) {
clearTimeout(pendingTimer);
opts.activeTimers.delete(pendingTimer);
pendingTimer = null;
}
if (instance !== null) {
instance.element.remove();
instance.destroy();
instance = null;
document.removeEventListener("mousedown", handleClickOutside);
}
}
function toggle(): void {
opts.onBeforeOpen?.();
if (instance !== null) {
close();
return;
}
instance = opts.create();
opts.parentEl?.appendChild(instance.element);
// Defer so this click doesn't immediately close it
pendingTimer = setTimeout(() => {
opts.activeTimers.delete(pendingTimer!);
pendingTimer = null;
document.addEventListener("mousedown", handleClickOutside);
}, 0);
opts.activeTimers.add(pendingTimer);
}
return { toggle, close };
}
@@ -3,10 +3,7 @@
* Also owns the server host state and URL resolution used by other modules.
*/
import {
createElement,
appendChildren,
} from "@lib/dom";
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { observeMedia } from "@lib/media-visibility";
import { loadPref } from "@components/settings/helpers";
@@ -77,10 +74,22 @@ export function clearAttachmentCaches(): void {
}
/** Safe MIME types allowed in data: URIs — blocks script injection via crafted Content-Type. */
// Note: image/svg+xml is intentionally excluded — SVGs can execute JS if
// loaded in <object>, <embed>, or <iframe> contexts. Only raster formats
// are considered safe for data: URI rendering via <img>.
const SAFE_MIME_TYPES = new Set([
"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml",
"image/avif", "image/bmp", "video/mp4", "video/webm", "audio/mpeg",
"audio/ogg", "audio/wav", "application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
]);
/** Sanitize a Content-Type header value for use in a data: URI. */
@@ -124,7 +133,9 @@ export function openCacheDb(): Promise<IDBDatabase | null> {
db.createObjectStore(IDB_STORE);
}
};
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onsuccess = () => resolve(req.result);
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onerror = () => resolve(null);
} catch {
resolve(null);
@@ -134,8 +145,11 @@ export function openCacheDb(): Promise<IDBDatabase | null> {
function closeDbAfterTransaction(tx: IDBTransaction, db: IDBDatabase): void {
const close = (): void => db.close();
// oxlint-disable-next-line prefer-add-event-listener -- IDBTransaction does not support addEventListener
tx.oncomplete = close;
// oxlint-disable-next-line prefer-add-event-listener -- IDBTransaction does not support addEventListener
tx.onabort = close;
// oxlint-disable-next-line prefer-add-event-listener -- IDBTransaction does not support addEventListener
tx.onerror = close;
}
@@ -149,7 +163,9 @@ async function idbGet(url: string): Promise<string | null> {
closeDbAfterTransaction(tx, db);
const store = tx.objectStore(IDB_STORE);
const req = store.get(url);
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null);
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onerror = () => resolve(null);
} catch {
db.close();
@@ -218,7 +234,7 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
try {
const useInsecure = isServerUrl(url);
const fetchOpts: RequestInit = useInsecure
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
? ({ danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit)
: {};
const res = await tauriFetch(url, fetchOpts);
if (!res.ok) return null;
@@ -267,7 +283,8 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
// Reserve space using server-provided dimensions to prevent layout shift.
if (att.width != null && att.height != null && att.width > 0 && att.height > 0) {
const maxW = 400, maxH = 350;
const maxW = 400,
maxH = 350;
const scale = Math.min(1, maxW / att.width, maxH / att.height);
const w = Math.round(att.width * scale);
const h = Math.round(att.height * scale);
@@ -307,10 +324,14 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
alt: att.filename,
});
attachLightbox(img);
img.addEventListener("load", () => {
clearReservation();
if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
}, { once: true });
img.addEventListener(
"load",
() => {
clearReservation();
if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
},
{ once: true },
);
wrap.appendChild(img);
} else {
// Show loading placeholder, then replace with image
@@ -324,10 +345,14 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
alt: att.filename,
});
attachLightbox(img);
img.addEventListener("load", () => {
clearReservation();
if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
}, { once: true });
img.addEventListener(
"load",
() => {
clearReservation();
if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
},
{ once: true },
);
placeholder.replaceWith(img);
} else {
placeholder.classList.remove("loading");
@@ -374,14 +399,19 @@ async function downloadFile(url: string, filename: string): Promise<void> {
// Fetch file data — only accept invalid certs for the OwnCord server
const useInsecure = isServerUrl(url);
const fetchOpts: RequestInit = useInsecure
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
? ({ danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit)
: {};
const res = await tauriFetch(url, fetchOpts);
if (!res.ok) return;
if (!res.ok) {
log.error("Download failed", { filename, status: res.status });
alert(`Download failed: server returned ${res.status}`);
return;
}
const buffer = await res.arrayBuffer();
await writeFile(filePath, new Uint8Array(buffer));
} catch (err) {
log.error("Download failed", { filename, error: String(err) });
alert(`Download failed for ${filename} — check logs for details`);
}
}
@@ -3,10 +3,7 @@
* inline code, code blocks, @mentions, and URL linkification.
*/
import {
createElement,
setText,
} from "@lib/dom";
import { createElement, setText } from "@lib/dom";
import { isSafeUrl } from "./attachments";
// -- Regex constants ----------------------------------------------------------
@@ -48,7 +45,11 @@ export function renderMentions(text: string): DocumentFragment {
if (idx > lastIndex) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx)));
}
const url = match[0];
// Strip trailing punctuation that is likely sentence-level, not part of the URL
const rawUrl = match[0];
const stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
const trailing = rawUrl.slice(stripped.length);
const url = stripped || rawUrl; // fallback if stripping emptied it
if (isSafeUrl(url)) {
const link = createElement("a", {
class: "msg-link",
@@ -58,10 +59,13 @@ export function renderMentions(text: string): DocumentFragment {
});
setText(link, url);
fragment.appendChild(link);
if (trailing) {
fragment.appendChild(document.createTextNode(trailing));
}
} else {
fragment.appendChild(document.createTextNode(url));
fragment.appendChild(document.createTextNode(rawUrl));
}
lastIndex = idx + match[0].length;
lastIndex = idx + rawUrl.length;
}
if (lastIndex < text.length) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex)));
@@ -101,7 +105,7 @@ export function renderMessageContent(content: string): DocumentFragment {
const segment = parts[i]!;
if (i % 2 === 0) {
// Prose segment
const trimmed = i === 0 ? segment : (i === parts.length - 1 ? segment.trim() : segment);
const trimmed = i === 0 ? segment : i === parts.length - 1 ? segment.trim() : segment;
if (trimmed.length > 0) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(trimmed));
@@ -116,13 +120,16 @@ export function renderMessageContent(content: string): DocumentFragment {
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
setText(copyBtn, "Copy");
copyBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(codeContent).then(() => {
setText(copyBtn, "Copied!");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
}).catch(() => {
setText(copyBtn, "Failed");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
});
void navigator.clipboard
.writeText(codeContent)
.then(() => {
setText(copyBtn, "Copied!");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
})
.catch(() => {
setText(copyBtn, "Failed");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
});
});
codeWrap.appendChild(codeBlock);
codeWrap.appendChild(copyBtn);
@@ -3,10 +3,7 @@
* (title, description, image) for generic URLs as compact link cards.
*/
import {
createElement,
setText,
} from "@lib/dom";
import { createElement, setText } from "@lib/dom";
import { observeMedia } from "@lib/media-visibility";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { createLogger } from "@lib/logger";
@@ -52,7 +49,7 @@ export function parseOgTags(html: string): OgMeta {
const escaped = escapeRegex(property);
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
"i",
);
const match = html.match(regex);
@@ -183,10 +180,16 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
const timer = setTimeout(() => controller.abort(), 5000);
const fetchOpts: RequestInit = {
signal: controller.signal,
headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" },
headers: {
"User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
},
};
if (isTrustedServerUrl(url)) {
(fetchOpts as RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } }).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false };
(
fetchOpts as RequestInit & {
danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean };
}
).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false };
}
const res = await tauriFetch(url, fetchOpts);
clearTimeout(timer);
@@ -301,9 +304,8 @@ export function applyOgMeta(
setText(hostEl, meta.siteName);
}
if (meta.description !== null) {
const desc = meta.description.length > 200
? meta.description.slice(0, 197) + "..."
: meta.description;
const desc =
meta.description.length > 200 ? meta.description.slice(0, 197) + "..." : meta.description;
setText(descEl, desc);
descEl.style.display = "";
} else {
@@ -316,7 +318,9 @@ export function applyOgMeta(
try {
const base = new URL(url);
imgSrc = `${base.origin}${imgSrc}`;
} catch { /* keep as-is */ }
} catch {
/* keep as-is */
}
}
if (isSafeUrl(imgSrc) && !isBlockedForPreview(imgSrc)) {
const isGif = imgSrc.toLowerCase().endsWith(".gif");
@@ -334,9 +338,13 @@ export function applyOgMeta(
imageWrap.style.display = "none";
});
if (isGif) {
(img).addEventListener("load", () => {
observeMedia(img, imgSrc, imageWrap);
}, { once: true });
img.addEventListener(
"load",
() => {
observeMedia(img, imgSrc, imageWrap);
},
{ once: true },
);
}
imageWrap.appendChild(img);
imageWrap.style.display = "";
@@ -19,8 +19,8 @@ export class FenwickTree {
const delta = value - prev;
if (delta === 0) return;
this.values[i] = value;
for (let x = i + 1; x <= this.size; x += x & (-x)) {
(this.tree)[x] = (this.tree[x] as number) + delta;
for (let x = i + 1; x <= this.size; x += x & -x) {
this.tree[x] = (this.tree[x] as number) + delta;
}
}
@@ -33,7 +33,7 @@ export class FenwickTree {
prefixSum(i: number): number {
if (i < 0) return 0;
let s = 0;
for (let x = i + 1; x > 0; x -= x & (-x)) {
for (let x = i + 1; x > 0; x -= x & -x) {
s += this.tree[x] as number;
}
return s;
@@ -101,9 +101,13 @@ export function roleColorVar(role: string): string {
return "var(--role-member)";
}
switch (role) {
case "owner": return "var(--role-owner)";
case "admin": return "var(--role-admin)";
case "moderator": return "var(--role-mod)";
default: return "var(--role-member)";
case "owner":
return "var(--role-owner)";
case "admin":
return "var(--role-admin)";
case "moderator":
return "var(--role-mod)";
default:
return "var(--role-member)";
}
}
@@ -3,11 +3,7 @@
* inline image rendering, lightbox overlay, and URL embed orchestration.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { createLogger } from "@lib/logger";
import { observeMedia } from "@lib/media-visibility";
@@ -106,7 +102,11 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
// Validate videoId to prevent injection into iframe src / img src.
if (!YOUTUBE_ID_RE.test(videoId)) {
const fallback = createElement("div", { class: "msg-embed" });
const link = createElement("a", { href: originalUrl, target: "_blank", rel: "noopener noreferrer" });
const link = createElement("a", {
href: originalUrl,
target: "_blank",
rel: "noopener noreferrer",
});
setText(link, originalUrl);
fallback.appendChild(link);
return fallback;
@@ -181,14 +181,22 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
wrap.appendChild(thumbWrap);
// On click thumbnail, replace with iframe player
thumbWrap.addEventListener("click", () => {
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
iframe.setAttribute("allowfullscreen", "");
iframe.setAttribute("allow", "autoplay; encrypted-media");
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
}, { once: true });
thumbWrap.addEventListener(
"click",
() => {
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
iframe.setAttribute("allowfullscreen", "");
iframe.setAttribute("allow", "autoplay; encrypted-media");
iframe.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-presentation allow-popups",
);
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
},
{ once: true },
);
return wrap;
}
@@ -220,7 +228,8 @@ export function renderInlineImage(url: string): HTMLDivElement {
const attrs: Record<string, string> = {
src: url,
alt: "Image",
style: "max-width: 100%; max-height: 350px; display: block; border-radius: 4px; cursor: pointer;",
style:
"max-width: 100%; max-height: 350px; display: block; border-radius: 4px; cursor: pointer;",
};
// Enable CORS for GIFs so canvas capture works for freeze/unfreeze
if (isGifUrl(url)) {
@@ -232,31 +241,47 @@ export function renderInlineImage(url: string): HTMLDivElement {
// height so future virtual-scroll rebuilds start at the correct size.
// Measure synchronously — deferring to rAF loses the race with
// ResizeObserver which can rebuild the DOM before the rAF fires.
img.addEventListener("load", () => {
log.info("Image loaded", { url: url.slice(0, 80), naturalW: (img).naturalWidth, naturalH: (img).naturalHeight });
wrap.style.minHeight = "";
const h = wrap.offsetHeight;
if (h > 0) cacheImageHeight(url, h);
log.debug("Image height cached", { url: url.slice(0, 80), h });
}, { once: true });
img.addEventListener(
"load",
() => {
log.info("Image loaded", {
url: url.slice(0, 80),
naturalW: img.naturalWidth,
naturalH: img.naturalHeight,
});
wrap.style.minHeight = "";
const h = wrap.offsetHeight;
if (h > 0) cacheImageHeight(url, h);
log.debug("Image height cached", { url: url.slice(0, 80), h });
},
{ once: true },
);
// On error: clear min-height so the wrapper collapses instead of
// holding a 200px empty reservation that can oscillate with virtual scroll.
img.addEventListener("error", () => {
log.error("Image failed to load", { url });
wrap.style.minHeight = "";
}, { once: true });
img.addEventListener(
"error",
() => {
log.error("Image failed to load", { url });
wrap.style.minHeight = "";
},
{ once: true },
);
// Observe GIFs for visibility-based freeze/unfreeze + play/pause button.
// When the animateGifs pref is disabled, start frozen so the first frame is
// shown by default; the user can still click the play button to animate.
if (isGifUrl(url)) {
img.addEventListener("load", () => {
log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) });
const startFrozen = !loadPref("animateGifs", true);
observeMedia(img, url, wrap, startFrozen);
log.debug("observeMedia complete", { startFrozen });
}, { once: true });
img.addEventListener(
"load",
() => {
log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) });
const startFrozen = !loadPref("animateGifs", true);
observeMedia(img, url, wrap, startFrozen);
log.debug("observeMedia complete", { startFrozen });
},
{ once: true },
);
}
img.addEventListener("click", () => {
@@ -459,7 +484,12 @@ export function renderUrlEmbeds(content: string): DocumentFragment {
// Direct image/GIF URL — render inline
const isDirect = isDirectImageUrl(url);
const isSafe = isSafeUrl(url);
log.debug("URL classification", { url: url.slice(0, 80), isDirect, isSafe, isGif: isGifUrl(url) });
log.debug("URL classification", {
url: url.slice(0, 80),
isDirect,
isSafe,
isGif: isGifUrl(url),
});
if (isDirect && isSafe) {
if (!inlineMedia) continue;
fragment.appendChild(renderInlineImage(url));
@@ -4,11 +4,7 @@
* renderSystemMessage) that orchestrate pieces from the split modules.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { loadPref } from "@components/settings/helpers";
import type { Message } from "@stores/messages.store";
@@ -58,11 +54,7 @@ export {
} from "./media";
export type { OgMeta } from "./embeds";
export {
parseOgTags,
renderGenericLinkPreview,
applyOgMeta,
} from "./embeds";
export { parseOgTags, renderGenericLinkPreview, applyOgMeta } from "./embeds";
export {
formatFileSize,
@@ -100,19 +92,20 @@ export function renderDayDivider(iso: string): HTMLDivElement {
return divider;
}
function renderReplyRef(
replyToId: number,
allMessages: readonly Message[],
): HTMLDivElement {
function renderReplyRef(replyToId: number, allMessages: readonly Message[]): HTMLDivElement {
const ref = allMessages.find((m) => m.id === replyToId);
const bar = createElement("div", { class: "msg-reply-ref" });
if (ref) {
const preview = ref.deleted ? "[message deleted]" : ref.content.slice(0, 100);
const role = getUserRole(ref.user.id);
const miniAvatar = createElement("div", {
class: "rr-avatar",
style: `background: ${roleColorVar(role)}`,
}, ref.user.username.charAt(0).toUpperCase());
const miniAvatar = createElement(
"div",
{
class: "rr-avatar",
style: `background: ${roleColorVar(role)}`,
},
ref.user.username.charAt(0).toUpperCase(),
);
appendChildren(
bar,
miniAvatar,
@@ -154,17 +147,25 @@ export function renderMessage(
const role = getUserRole(msg.user.id);
const initial = msg.user.username.charAt(0).toUpperCase();
const avatar = createElement("div", {
class: "msg-avatar",
style: `background: ${roleColorVar(role)}`,
}, initial);
const avatar = createElement(
"div",
{
class: "msg-avatar",
style: `background: ${roleColorVar(role)}`,
},
initial,
);
el.appendChild(avatar);
if (isGrouped) {
const hoverTime = createElement("div", {
class: "msg-hover-time",
title: formatFullDate(msg.timestamp),
}, formatTime(msg.timestamp));
const hoverTime = createElement(
"div",
{
class: "msg-hover-time",
title: formatFullDate(msg.timestamp),
},
formatTime(msg.timestamp),
);
el.appendChild(hoverTime);
}
@@ -173,11 +174,19 @@ export function renderMessage(
}
const header = createElement("div", { class: "msg-header" });
const author = createElement("span", {
class: "msg-author",
style: `color: ${roleColorVar(role)}`,
}, msg.user.username);
const time = createElement("span", { class: "msg-time", title: formatFullDate(msg.timestamp) }, formatMessageTimestamp(msg.timestamp));
const author = createElement(
"span",
{
class: "msg-author",
style: `color: ${roleColorVar(role)}`,
},
msg.user.username,
);
const time = createElement(
"span",
{ class: "msg-time", title: formatFullDate(msg.timestamp) },
formatMessageTimestamp(msg.timestamp),
);
appendChildren(header, author, time);
el.appendChild(header);
@@ -235,11 +244,9 @@ export function renderMessage(
});
pinBtn.appendChild(createIcon(msg.pinned ? "pin-off" : "pin", 16));
pinBtn.title = msg.pinned ? "Unpin" : "Pin";
pinBtn.addEventListener(
"click",
() => opts.onPinClick(msg.id, msg.channelId, msg.pinned),
{ signal },
);
pinBtn.addEventListener("click", () => opts.onPinClick(msg.id, msg.channelId, msg.pinned), {
signal,
});
actionsBar.appendChild(pinBtn);
if (msg.user.id === opts.currentUserId) {
@@ -271,9 +278,15 @@ export function renderMessage(
});
copyIdBtn.appendChild(createIcon("hash", 16));
copyIdBtn.title = "Copy ID";
copyIdBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(String(msg.id)).catch(() => { /* clipboard unavailable */ });
}, { signal });
copyIdBtn.addEventListener(
"click",
() => {
void navigator.clipboard.writeText(String(msg.id)).catch(() => {
/* clipboard unavailable */
});
},
{ signal },
);
actionsBar.appendChild(copyIdBtn);
}
@@ -0,0 +1,139 @@
/**
* Virtual scroll manager — manages height estimation, Fenwick-tree-backed
* offset calculations, and spacer management for DOM windowing.
*/
import { FenwickTree } from "./fenwick";
export interface VirtualScrollItem {
readonly kind: string;
}
export interface VirtualScrollOptions {
/** Number of items to render beyond visible viewport in each direction. */
readonly overscan: number;
/** Estimate height for an item at given index. */
readonly estimateHeight: (index: number) => number;
/** Generate a stable cache key for an item at given index. */
readonly itemKey: (index: number) => string;
}
export interface VisibleRange {
readonly start: number;
readonly end: number;
}
export class VirtualScrollManager {
private readonly heightCache = new Map<string, number>();
private tree: FenwickTree | null = null;
private itemCount = 0;
private readonly opts: VirtualScrollOptions;
constructor(opts: VirtualScrollOptions) {
this.opts = opts;
}
/** Rebuild the Fenwick tree for a new item count, preserving cached heights. */
rebuild(count: number): void {
this.itemCount = count;
this.tree = new FenwickTree(count);
for (let i = 0; i < count; i++) {
const key = this.opts.itemKey(i);
const cached = this.heightCache.get(key);
const h = cached !== undefined ? cached : this.opts.estimateHeight(i);
this.tree.set(i, h);
}
}
/** Get height for item at index (cached or estimated). */
getHeight(index: number): number {
const cached = this.heightCache.get(this.opts.itemKey(index));
if (cached !== undefined) return cached;
return this.opts.estimateHeight(index);
}
/** Cache a measured height for an item. */
setMeasured(index: number, height: number): void {
if (height <= 0) return;
const key = this.opts.itemKey(index);
this.heightCache.set(key, height);
if (this.tree !== null && index < this.tree.size) {
this.tree.set(index, height);
}
}
/** Total estimated height of all items. */
totalHeight(): number {
if (this.tree !== null) return this.tree.total();
let h = 0;
for (let i = 0; i < this.itemCount; i++) {
h += this.getHeight(i);
}
return h;
}
/** Sum of heights for items [0, index). */
offsetBefore(index: number): number {
if (this.tree !== null && index > 0) return this.tree.prefixSum(index - 1);
if (this.tree !== null && index <= 0) return 0;
let offset = 0;
for (let i = 0; i < index && i < this.itemCount; i++) {
offset += this.getHeight(i);
}
return offset;
}
/** Find the item index at a given scroll offset. */
offsetToIndex(scrollTop: number): number {
if (this.tree !== null) return this.tree.findIndex(scrollTop);
let offset = 0;
for (let i = 0; i < this.itemCount; i++) {
const h = this.getHeight(i);
if (offset + h > scrollTop) return i;
offset += h;
}
return Math.max(0, this.itemCount - 1);
}
/** Compute the visible range with overscan. */
visibleRange(scrollTop: number, clientHeight: number): VisibleRange {
const firstVisible = this.offsetToIndex(scrollTop);
const lastVisible = this.offsetToIndex(scrollTop + clientHeight);
return {
start: Math.max(0, firstVisible - this.opts.overscan),
end: Math.min(this.itemCount, lastVisible + this.opts.overscan + 1),
};
}
/** Compute spacer heights for a rendered range. */
spacerHeights(start: number, end: number): { top: number; bottom: number } {
const top = this.offsetBefore(start);
let bottom: number;
if (this.tree !== null) {
const totalH = this.tree.total();
const endOffset = end > 0 ? this.tree.prefixSum(end - 1) : 0;
bottom = totalH - endOffset;
} else {
bottom = 0;
for (let i = end; i < this.itemCount; i++) {
bottom += this.getHeight(i);
}
}
return { top, bottom };
}
/** Clear all cached heights. */
clear(): void {
this.heightCache.clear();
this.tree = null;
this.itemCount = 0;
}
get size(): number {
return this.itemCount;
}
get treeSize(): number {
return this.tree?.size ?? 0;
}
}
@@ -44,7 +44,9 @@ const TOGGLES: ReadonlyArray<ToggleItem> = [
label: "Sync with OS",
desc: "Automatically enable reduced motion based on your OS accessibility settings",
fallback: false,
sideEffect: (nowOn) => { syncOsMotionListener(nowOn); },
sideEffect: (nowOn) => {
syncOsMotionListener(nowOn);
},
},
{
key: "largeFont",
@@ -32,7 +32,9 @@ function buildProfileCard(username: string): ProfileCardResult {
// Avatar overlapping the banner
const avatarWrap = createElement("div", { class: "account-avatar-wrap" });
const avatarLarge = createElement("div", { class: "account-avatar-large" },
const avatarLarge = createElement(
"div",
{ class: "account-avatar-large" },
username.charAt(0).toUpperCase(),
);
const statusDot = createElement("div", { class: "account-status-dot" });
@@ -71,54 +73,73 @@ function buildPasswordSection(
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const pwHeader = createElement("div", { class: "settings-section-title" }, "Password and Authentication");
const pwHeader = createElement(
"div",
{ class: "settings-section-title" },
"Password and Authentication",
);
const oldPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "Old password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Old password",
style: "margin-bottom:12px",
});
const newPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "New password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "New password",
style: "margin-bottom:12px",
});
const confirmPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "Confirm new password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Confirm new password",
style: "margin-bottom:12px",
});
const pwError = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:8px",
});
const pwError = createElement("div", { style: "color:var(--red);font-size:13px;margin-bottom:8px" });
const pwBtn = createElement("button", { class: "ac-btn" }, "Change Password");
let pwSuccessTimer: ReturnType<typeof setTimeout> | null = null;
pwBtn.addEventListener("click", () => {
const oldVal = oldPw.value;
const newVal = newPw.value;
const confirmVal = confirmPw.value;
pwBtn.addEventListener(
"click",
() => {
const oldVal = oldPw.value;
const newVal = newPw.value;
const confirmVal = confirmPw.value;
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
}
if (newVal !== confirmVal) {
setText(pwError, "Passwords do not match.");
return;
}
setText(pwError, "");
void options.onChangePassword(oldVal, newVal).then(() => {
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
if (pwSuccessTimer !== null) clearTimeout(pwSuccessTimer);
pwError.style.color = "var(--green)";
setText(pwError, "Password changed successfully.");
pwSuccessTimer = setTimeout(() => {
setText(pwError, "");
pwError.style.color = "var(--red)";
pwSuccessTimer = null;
}, 3000);
}).catch((err: unknown) => {
setText(pwError, err instanceof Error ? err.message : "Failed to change password.");
});
}, { signal });
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
}
if (newVal !== confirmVal) {
setText(pwError, "Passwords do not match.");
return;
}
setText(pwError, "");
void options
.onChangePassword(oldVal, newVal)
.then(() => {
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
if (pwSuccessTimer !== null) clearTimeout(pwSuccessTimer);
pwError.style.color = "var(--green)";
setText(pwError, "Password changed successfully.");
pwSuccessTimer = setTimeout(() => {
setText(pwError, "");
pwError.style.color = "var(--red)";
pwSuccessTimer = null;
}, 3000);
})
.catch((err: unknown) => {
setText(pwError, err instanceof Error ? err.message : "Failed to change password.");
});
},
{ signal },
);
appendChildren(wrapper, separator, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
return wrapper;
@@ -135,19 +156,29 @@ function buildTotpEnrollForm(
): HTMLDivElement {
const wrapper = createElement("div", {});
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Add an extra layer of security to your account.");
const description = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
},
"Add an extra layer of security to your account.",
);
const enableBtn = createElement("button", {
class: "ac-btn",
"data-testid": "totp-enable-btn",
}, "Enable 2FA");
const enableBtn = createElement(
"button",
{
class: "ac-btn",
"data-testid": "totp-enable-btn",
},
"Enable 2FA",
);
const formArea = createElement("div", { style: "display:none" });
const pwInput = createElement("input", {
class: "form-input", type: "password",
placeholder: "Enter your password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Enter your password",
style: "margin-bottom:12px",
"data-testid": "totp-password-input",
});
const errorEl = createElement("div", {
@@ -160,36 +191,47 @@ function buildTotpEnrollForm(
const enrollArea = createElement("div", { style: "display:none" });
enableBtn.addEventListener("click", () => {
enableBtn.style.display = "none";
formArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
}, { signal });
enableBtn.addEventListener(
"click",
() => {
enableBtn.style.display = "none";
formArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
},
{ signal },
);
submitBtn.addEventListener("click", () => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
submitBtn.disabled = true;
setText(submitBtn, "Requesting...");
submitBtn.addEventListener(
"click",
() => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
submitBtn.disabled = true;
setText(submitBtn, "Requesting...");
void options.onEnableTotp(pw).then((result) => {
formArea.style.display = "none";
buildTotpConfirmArea(enrollArea, options, pw, result, signal, onEnrolled);
enrollArea.style.display = "block";
submitBtn.disabled = false;
setText(submitBtn, "Submit");
}).catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to enable 2FA.");
submitBtn.disabled = false;
setText(submitBtn, "Submit");
});
}, { signal });
void options
.onEnableTotp(pw)
.then((result) => {
formArea.style.display = "none";
buildTotpConfirmArea(enrollArea, options, pw, result, signal, onEnrolled);
enrollArea.style.display = "block";
submitBtn.disabled = false;
setText(submitBtn, "Submit");
})
.catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to enable 2FA.");
submitBtn.disabled = false;
setText(submitBtn, "Submit");
});
},
{ signal },
);
appendChildren(wrapper, description, enableBtn, formArea, enrollArea);
return wrapper;
@@ -208,34 +250,54 @@ function buildTotpConfirmArea(
container.removeChild(container.firstChild);
}
const qrLabel = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
}, "Scan this URI with your authenticator app, or copy it manually:");
const qrLabel = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
},
"Scan this URI with your authenticator app, or copy it manually:",
);
const qrUri = createElement("code", {
style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;word-break:break-all;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
"data-testid": "totp-qr-uri",
}, result.qr_uri);
const qrUri = createElement(
"code",
{
style:
"display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;word-break:break-all;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
"data-testid": "totp-qr-uri",
},
result.qr_uri,
);
const elements: HTMLElement[] = [qrLabel, qrUri];
if (result.backup_codes.length > 0) {
const backupLabel = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
}, "Save these backup codes in a safe place:");
const backupList = createElement("code", {
style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
}, result.backup_codes.join("\n"));
const backupLabel = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
},
"Save these backup codes in a safe place:",
);
const backupList = createElement(
"code",
{
style:
"display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
},
result.backup_codes.join("\n"),
);
elements.push(backupLabel, backupList);
}
const codeInput = createElement("input", {
class: "form-input", type: "text",
placeholder: "6-digit code", maxlength: "6",
class: "form-input",
type: "text",
placeholder: "6-digit code",
maxlength: "6",
style: "margin-bottom:12px",
"data-testid": "totp-code-input",
});
@@ -245,29 +307,40 @@ function buildTotpConfirmArea(
"data-testid": "totp-error",
});
const confirmBtn = createElement("button", {
class: "ac-btn",
"data-testid": "totp-confirm-btn",
}, "Verify & Activate");
const confirmBtn = createElement(
"button",
{
class: "ac-btn",
"data-testid": "totp-confirm-btn",
},
"Verify & Activate",
);
confirmBtn.addEventListener("click", () => {
const code = codeInput.value.trim();
if (code.length === 0) {
setText(confirmError, "Please enter the 6-digit code.");
return;
}
setText(confirmError, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Verifying...");
confirmBtn.addEventListener(
"click",
() => {
const code = codeInput.value.trim();
if (!/^\d{6}$/.test(code)) {
setText(confirmError, "Please enter a valid 6-digit code.");
return;
}
setText(confirmError, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Verifying...");
void options.onConfirmTotp(password, code).then(() => {
onEnrolled();
}).catch((err: unknown) => {
setText(confirmError, err instanceof Error ? err.message : "Invalid verification code.");
confirmBtn.disabled = false;
setText(confirmBtn, "Verify & Activate");
});
}, { signal });
void options
.onConfirmTotp(password, code)
.then(() => {
onEnrolled();
})
.catch((err: unknown) => {
setText(confirmError, err instanceof Error ? err.message : "Invalid verification code.");
confirmBtn.disabled = false;
setText(confirmBtn, "Verify & Activate");
});
},
{ signal },
);
elements.push(codeInput, confirmError, confirmBtn);
appendChildren(container, ...elements);
@@ -280,19 +353,29 @@ function buildTotpDisableView(
): HTMLDivElement {
const wrapper = createElement("div", {});
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Your account is protected with 2FA.");
const description = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
},
"Your account is protected with 2FA.",
);
const disableBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "totp-disable-btn",
}, "Disable 2FA");
const disableBtn = createElement(
"button",
{
class: "ac-btn account-delete-btn",
"data-testid": "totp-disable-btn",
},
"Disable 2FA",
);
const confirmArea = createElement("div", { style: "display:none" });
const pwInput = createElement("input", {
class: "form-input", type: "password",
placeholder: "Enter your password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Enter your password",
style: "margin-bottom:12px",
"data-testid": "totp-password-input",
});
const errorEl = createElement("div", {
@@ -300,69 +383,95 @@ function buildTotpDisableView(
"data-testid": "totp-error",
});
const btnRow = createElement("div", { style: "display:flex;gap:8px" });
const confirmBtn = createElement("button", { class: "ac-btn account-delete-btn" }, "Confirm Disable");
const cancelBtn = createElement("button", {
class: "ac-btn", style: "background:var(--bg-active)",
}, "Cancel");
const confirmBtn = createElement(
"button",
{ class: "ac-btn account-delete-btn" },
"Confirm Disable",
);
const cancelBtn = createElement(
"button",
{
class: "ac-btn",
style: "background:var(--bg-active)",
},
"Cancel",
);
appendChildren(btnRow, confirmBtn, cancelBtn);
appendChildren(confirmArea, pwInput, errorEl, btnRow);
disableBtn.addEventListener("click", () => {
disableBtn.style.display = "none";
confirmArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
}, { signal });
disableBtn.addEventListener(
"click",
() => {
disableBtn.style.display = "none";
confirmArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
},
{ signal },
);
cancelBtn.addEventListener("click", () => {
confirmArea.style.display = "none";
disableBtn.style.display = "";
pwInput.value = "";
setText(errorEl, "");
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
confirmArea.style.display = "none";
disableBtn.style.display = "";
pwInput.value = "";
setText(errorEl, "");
},
{ signal },
);
confirmBtn.addEventListener("click", () => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Disabling...");
confirmBtn.addEventListener(
"click",
() => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Disabling...");
void options.onDisableTotp(pw).then(() => {
onDisabled();
}).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Failed to disable 2FA.";
const is403Required = msg.toLowerCase().includes("required");
setText(errorEl, is403Required
? "2FA is required by this server and cannot be disabled"
: msg);
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Disable");
});
}, { signal });
void options
.onDisableTotp(pw)
.then(() => {
onDisabled();
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Failed to disable 2FA.";
const is403Required = msg.toLowerCase().includes("required");
setText(
errorEl,
is403Required ? "2FA is required by this server and cannot be disabled" : msg,
);
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Disable");
});
},
{ signal },
);
appendChildren(wrapper, description, disableBtn, confirmArea);
return wrapper;
}
function buildTotpSection(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
function buildTotpSection(options: SettingsOverlayOptions, signal: AbortSignal): HTMLDivElement {
const wrapper = createElement("div", { "data-testid": "totp-section" });
const separator = createElement("div", { class: "settings-separator" });
const headerRow = createElement("div", {
style: "display:flex;align-items:center;gap:8px;margin-bottom:4px",
});
const header = createElement("div", {
class: "settings-section-title",
style: "margin-bottom:0",
}, "Two-Factor Authentication");
const header = createElement(
"div",
{
class: "settings-section-title",
style: "margin-bottom:0",
},
"Two-Factor Authentication",
);
const statusBadge = createElement("span", {
"data-testid": "totp-status-badge",
@@ -415,16 +524,23 @@ interface StatusOption {
}
const STATUS_OPTIONS: readonly StatusOption[] = [
{ value: "online", label: "Online", description: "", color: "#3ba55d" },
{ value: "idle", label: "Idle", description: "You will appear as idle", color: "#faa61a" },
{ value: "dnd", label: "Do Not Disturb", description: "You will not receive desktop notifications", color: "#ed4245" },
{ value: "offline", label: "Offline", description: "You will appear offline but still have full access", color: "#747f8d" },
{ value: "online", label: "Online", description: "", color: "#3ba55d" },
{ value: "idle", label: "Idle", description: "You will appear as idle", color: "#faa61a" },
{
value: "dnd",
label: "Do Not Disturb",
description: "You will not receive desktop notifications",
color: "#ed4245",
},
{
value: "offline",
label: "Offline",
description: "You will appear offline but still have full access",
color: "#747f8d",
},
];
function buildStatusSelector(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
function buildStatusSelector(options: SettingsOverlayOptions, signal: AbortSignal): HTMLDivElement {
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const sectionTitle = createElement("div", { class: "settings-section-title" }, "Status");
@@ -467,12 +583,16 @@ function buildStatusSelector(
};
row.addEventListener("click", selectStatus, { signal });
row.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectStatus();
}
}, { signal });
row.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectStatus();
}
},
{ signal },
);
rowElements.set(opt.value, row);
optionsList.appendChild(row);
@@ -493,19 +613,31 @@ function buildDeleteAccountSection(
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const header = createElement("div", {
class: "settings-section-title",
style: "color:var(--red)",
}, "Danger Zone");
const header = createElement(
"div",
{
class: "settings-section-title",
style: "color:var(--red)",
},
"Danger Zone",
);
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Permanently delete your account and all associated data.");
const description = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
},
"Permanently delete your account and all associated data.",
);
const deleteBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-trigger",
}, "Delete Account");
const deleteBtn = createElement(
"button",
{
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-trigger",
},
"Delete Account",
);
// Inline confirmation area (hidden by default)
const confirmArea = createElement("div", {
@@ -514,9 +646,13 @@ function buildDeleteAccountSection(
"data-testid": "delete-account-confirm-area",
});
const warningText = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:12px;line-height:1.4",
}, "This action is permanent and cannot be undone. All your data will be deleted. Enter your password to confirm.");
const warningText = createElement(
"div",
{
style: "color:var(--red);font-size:13px;margin-bottom:12px;line-height:1.4",
},
"This action is permanent and cannot be undone. All your data will be deleted. Enter your password to confirm.",
);
const passwordInput = createElement("input", {
class: "form-input",
@@ -532,54 +668,77 @@ function buildDeleteAccountSection(
});
const btnRow = createElement("div", { style: "display:flex;gap:8px" });
const confirmBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-confirm",
}, "Confirm Delete");
const cancelBtn = createElement("button", {
class: "ac-btn",
style: "background:var(--bg-active)",
}, "Cancel");
const confirmBtn = createElement(
"button",
{
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-confirm",
},
"Confirm Delete",
);
const cancelBtn = createElement(
"button",
{
class: "ac-btn",
style: "background:var(--bg-active)",
},
"Cancel",
);
appendChildren(btnRow, confirmBtn, cancelBtn);
appendChildren(confirmArea, warningText, passwordInput, errorEl, btnRow);
// Show confirmation area
deleteBtn.addEventListener("click", () => {
deleteBtn.style.display = "none";
confirmArea.style.display = "block";
passwordInput.value = "";
setText(errorEl, "");
passwordInput.focus();
}, { signal });
deleteBtn.addEventListener(
"click",
() => {
deleteBtn.style.display = "none";
confirmArea.style.display = "block";
passwordInput.value = "";
setText(errorEl, "");
passwordInput.focus();
},
{ signal },
);
// Cancel — hide confirmation
cancelBtn.addEventListener("click", () => {
confirmArea.style.display = "none";
deleteBtn.style.display = "";
passwordInput.value = "";
setText(errorEl, "");
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
confirmArea.style.display = "none";
deleteBtn.style.display = "";
passwordInput.value = "";
setText(errorEl, "");
},
{ signal },
);
// Confirm delete
confirmBtn.addEventListener("click", () => {
const pw = passwordInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Deleting...");
confirmBtn.addEventListener(
"click",
() => {
const pw = passwordInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Deleting...");
void options.onDeleteAccount(pw).then(() => {
// Success — cleanup is handled by the callback (clears auth, navigates away)
}).catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to delete account.");
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Delete");
});
}, { signal });
void options
.onDeleteAccount(pw)
.then(() => {
// Success — cleanup is handled by the callback (clears auth, navigates away)
})
.catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to delete account.");
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Delete");
});
},
{ signal },
);
appendChildren(wrapper, separator, header, description, deleteBtn, confirmArea);
return wrapper;
@@ -608,13 +767,26 @@ export function buildAccountTab(
section.appendChild(buildStatusSelector(options, signal));
// Inline edit form
const editForm = createElement("div", { class: "setting-row", style: "display:none;margin-bottom:16px" });
const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" });
const editForm = createElement("div", {
class: "setting-row",
style: "display:none;margin-bottom:16px",
});
const editInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "New username",
});
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel");
const cancelBtn = createElement(
"button",
{ class: "ac-btn", style: "background:var(--bg-active)" },
"Cancel",
);
appendChildren(editForm, editInput, saveBtn, cancelBtn);
const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" });
const usernameError = createElement("div", {
style: "color:var(--red);font-size:13px;margin-top:4px",
});
editForm.appendChild(usernameError);
const openEditForm = () => {
@@ -626,26 +798,37 @@ export function buildAccountTab(
editUserProfileBtn.addEventListener("click", openEditForm, { signal });
editUsernameBtn.addEventListener("click", openEditForm, { signal });
cancelBtn.addEventListener("click", () => {
editForm.style.display = "none";
setText(usernameError, "");
}, { signal });
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length < 2 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 2\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options.onUpdateProfile(newName).then(() => {
setText(headerName, newName);
setText(usernameValue, newName);
cancelBtn.addEventListener(
"click",
() => {
editForm.style.display = "none";
}).catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
}, { signal });
setText(usernameError, "");
},
{ signal },
);
saveBtn.addEventListener(
"click",
() => {
const newName = editInput.value.trim();
if (newName.length < 2 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 2\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options
.onUpdateProfile(newName)
.then(() => {
setText(headerName, newName);
setText(usernameValue, newName);
editForm.style.display = "none";
})
.catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
},
{ signal },
);
section.appendChild(editForm);
@@ -47,7 +47,9 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
onChange: (nowOn) => {
savePref(item.key, nowOn);
},
});
appendChildren(row, info, toggle);
@@ -68,15 +70,25 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
const devtoolsRow = createElement("div", { class: "setting-row" });
const devtoolsInfo = createElement("div", {});
const devtoolsLabel = createElement("div", { class: "setting-label" }, "Open DevTools");
const devtoolsDesc = createElement("div", { class: "setting-desc" }, "Open the browser developer tools for debugging");
const devtoolsDesc = createElement(
"div",
{ class: "setting-desc" },
"Open the browser developer tools for debugging",
);
appendChildren(devtoolsInfo, devtoolsLabel, devtoolsDesc);
const devtoolsBtn = createElement("button", { class: "ac-btn" }, "Open DevTools");
devtoolsBtn.addEventListener("click", () => {
void invoke("open_devtools").catch((err: unknown) => {
log.warn("DevTools not available", { error: err instanceof Error ? err.message : String(err) });
});
}, { signal });
devtoolsBtn.addEventListener(
"click",
() => {
void invoke("open_devtools").catch((err: unknown) => {
log.warn("DevTools not available", {
error: err instanceof Error ? err.message : String(err),
});
});
},
{ signal },
);
appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn);
section.appendChild(devtoolsRow);
@@ -90,90 +102,111 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
section.appendChild(cacheTitle);
// Clear Image Cache
section.appendChild(buildCacheRow(
"Clear Image Cache",
"Remove cached images and link previews. They will be re-downloaded as needed.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
btn.textContent = "Cleared!";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
} catch (err) {
log.error("Failed to clear image cache", err);
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
}
},
));
section.appendChild(
buildCacheRow(
"Clear Image Cache",
"Remove cached images and link previews. They will be re-downloaded as needed.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
btn.textContent = "Cleared!";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
} catch (err) {
log.error("Failed to clear image cache", err);
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
}
},
),
);
// Clear Log Files
section.appendChild(buildCacheRow(
"Clear Log Files",
"Remove persisted client log files from disk.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearLogFiles();
btn.textContent = "Cleared!";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
} catch (err) {
log.error("Failed to clear log files", err);
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
}
},
));
section.appendChild(
buildCacheRow(
"Clear Log Files",
"Remove persisted client log files from disk.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearLogFiles();
btn.textContent = "Cleared!";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
} catch (err) {
log.error("Failed to clear log files", err);
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
}
},
),
);
// Clear All Cache (nuclear option)
section.appendChild(buildCacheRow(
"Clear All Cache & Restart",
"Remove all cached data (images, logs, WebView storage) and restart the app. "
+ "Server profiles and credentials are preserved.",
"Clear & Restart",
signal,
async (btn) => {
// Two-step confirmation: first click shows warning, second click confirms
if (btn.dataset.confirmPending !== "true") {
btn.dataset.confirmPending = "true";
btn.textContent = "Are you sure? Click again";
btn.classList.add("ac-btn-danger");
const resetTimer = setTimeout(() => {
btn.dataset.confirmPending = "";
btn.textContent = "Clear & Restart";
btn.classList.remove("ac-btn-danger");
}, 3000);
// Store timer ID so it can be cleared if the button is clicked again
btn.dataset.resetTimer = String(resetTimer);
return;
}
// Second click — clear the pending state and proceed
const pendingTimer = btn.dataset.resetTimer;
if (pendingTimer) clearTimeout(Number(pendingTimer));
btn.dataset.confirmPending = "";
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
await clearLogFiles();
clearLocalStoragePreservingUserData();
sessionStorage.clear();
log.info("All cache cleared, restarting app");
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (err) {
log.error("Failed to clear all cache", err);
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Clear & Restart"; btn.removeAttribute("disabled"); }, 2000);
}
},
));
section.appendChild(
buildCacheRow(
"Clear All Cache & Restart",
"Remove all cached data (images, logs, WebView storage) and restart the app. " +
"Server profiles and credentials are preserved.",
"Clear & Restart",
signal,
async (btn) => {
// Two-step confirmation: first click shows warning, second click confirms
if (btn.dataset.confirmPending !== "true") {
btn.dataset.confirmPending = "true";
btn.textContent = "Are you sure? Click again";
btn.classList.add("ac-btn-danger");
const resetTimer = setTimeout(() => {
btn.dataset.confirmPending = "";
btn.textContent = "Clear & Restart";
btn.classList.remove("ac-btn-danger");
}, 3000);
// Store timer ID so it can be cleared if the button is clicked again
btn.dataset.resetTimer = String(resetTimer);
return;
}
// Second click — clear the pending state and proceed
const pendingTimer = btn.dataset.resetTimer;
if (pendingTimer) clearTimeout(Number(pendingTimer));
btn.dataset.confirmPending = "";
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
await clearLogFiles();
clearLocalStoragePreservingUserData();
sessionStorage.clear();
log.info("All cache cleared, restarting app");
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (err) {
log.error("Failed to clear all cache", err);
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Clear & Restart";
btn.removeAttribute("disabled");
}, 2000);
}
},
),
);
return section;
}
@@ -196,7 +229,13 @@ function buildCacheRow(
appendChildren(info, labelEl, descEl);
const btn = createElement("button", { class: "ac-btn" }, btnText);
btn.addEventListener("click", () => { onClick(btn); }, { signal });
btn.addEventListener(
"click",
() => {
onClick(btn);
},
{ signal },
);
appendChildren(row, info, btn);
return row;
@@ -218,12 +257,17 @@ async function clearImageCache(): Promise<void> {
callback();
}
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onsuccess = () => finish(resolve);
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onerror = () => finish(() => reject(req.error));
// oxlint-disable-next-line prefer-add-event-listener -- IDBRequest does not support addEventListener
req.onblocked = () => {
if (blockedTimer !== null) return;
blockedTimer = setTimeout(() => {
finish(() => reject(new Error("Image cache is still in use. Close active media and try again.")));
finish(() =>
reject(new Error("Image cache is still in use. Close active media and try again.")),
);
}, IMAGE_CACHE_DELETE_BLOCK_TIMEOUT_MS);
};
});
@@ -265,6 +309,7 @@ async function clearLogFiles(): Promise<void> {
const entries = await readDir(logDir);
for (const entry of entries) {
if (entry.name?.endsWith(".jsonl") && !entry.isDirectory) {
// eslint-disable-next-line no-await-in-loop -- sequential file deletion to avoid overwhelming the filesystem
await remove(`${logDir}/${entry.name}`);
}
}
@@ -16,17 +16,13 @@ function getDefaultAccent(themeName: string): string {
const customTheme = loadCustomTheme(themeName);
const accent = customTheme?.colors["--accent"];
return typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)
? accent
: FALLBACK_ACCENT;
return typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent) ? accent : FALLBACK_ACCENT;
}
export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const activeThemeName = getActiveThemeName();
const currentTheme = activeThemeName in THEMES
? activeThemeName as ThemeName
: null;
const currentTheme = activeThemeName in THEMES ? (activeThemeName as ThemeName) : null;
const currentFontSize = loadPref<number>("fontSize", 16);
const currentCompact = loadPref<boolean>("compactMode", false);
let hasStoredAccent = localStorage.getItem("owncord:settings:accentColor") !== null;
@@ -37,13 +33,17 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const themeRow = createElement("div", { class: "theme-options", role: "radiogroup" });
for (const name of Object.keys(THEMES) as ThemeName[]) {
const isActive = name === currentTheme;
const btn = createElement("button", {
class: `theme-opt ${name}${isActive ? " active" : ""}`,
role: "radio",
tabindex: "0",
"aria-checked": isActive ? "true" : "false",
"aria-label": name.charAt(0).toUpperCase() + name.slice(1),
}, name.charAt(0).toUpperCase() + name.slice(1));
const btn = createElement(
"button",
{
class: `theme-opt ${name}${isActive ? " active" : ""}`,
role: "radio",
tabindex: "0",
"aria-checked": isActive ? "true" : "false",
"aria-label": name.charAt(0).toUpperCase() + name.slice(1),
},
name.charAt(0).toUpperCase() + name.slice(1),
);
const activateTheme = (): void => {
applyTheme(name);
@@ -60,12 +60,16 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
};
btn.addEventListener("click", activateTheme, { signal });
btn.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateTheme();
}
}, { signal });
btn.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateTheme();
}
},
{ signal },
);
themeRow.appendChild(btn);
}
@@ -82,12 +86,16 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
value: String(currentFontSize),
});
const fontLabel = createElement("span", { class: "slider-val" }, `${currentFontSize}px`);
fontSlider.addEventListener("input", () => {
const size = Number(fontSlider.value);
setText(fontLabel, `${size}px`);
document.documentElement.style.setProperty("--font-size", `${size}px`);
savePref("fontSize", size);
}, { signal });
fontSlider.addEventListener(
"input",
() => {
const size = Number(fontSlider.value);
setText(fontLabel, `${size}px`);
document.documentElement.style.setProperty("--font-size", `${size}px`);
savePref("fontSize", size);
},
{ signal },
);
appendChildren(fontRow, fontSlider, fontLabel);
appendChildren(section, fontHeader, fontRow);
@@ -120,6 +128,7 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const currentAccent = loadPref<string>("accentColor", defaultAccent);
// oxlint-disable-next-line consistent-function-scoping -- co-located with saveAccent for readability
function applyAccent(color: string): void {
// Set on both documentElement and body so the accent wins over
// theme class specificity (body.theme-neon-glow sets --accent)
@@ -177,25 +186,33 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
};
swatch.addEventListener("click", activateSwatch, { signal });
swatch.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateSwatch();
}
}, { signal });
swatch.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateSwatch();
}
},
{ signal },
);
swatchesRow.appendChild(swatch);
}
hexInput.addEventListener("input", () => {
const raw = hexInput.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
hexInput.value = raw;
if (raw.length === 6) {
const color = `#${raw}`;
saveAccent(color);
syncDisplayedAccent(color);
}
}, { signal });
hexInput.addEventListener(
"input",
() => {
const raw = hexInput.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
hexInput.value = raw;
if (raw.length === 6) {
const color = `#${raw}`;
saveAccent(color);
syncDisplayedAccent(color);
}
},
{ signal },
);
appendChildren(hexInputRow, hexPrefix, hexInput);
appendChildren(section, accentHeader, swatchesRow, hexInputRow);
@@ -14,73 +14,99 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
const pttRow = createElement("div", { class: "keybind-row" });
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
let currentVk = loadPref<number>("pttVk", 0);
const pttValue = createElement("button", {
class: "kbd",
style: "cursor: pointer; min-width: 80px; text-align: center;",
title: "Click to set keybind",
"aria-label": "Push to Talk keybind — click to capture",
}, currentVk !== 0 ? vkName(currentVk) : "Not set");
const pttClear = createElement("button", {
class: "ac-btn",
style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${currentVk !== 0 ? "" : "display: none;"}`,
}, "Clear");
const pttValue = createElement(
"button",
{
class: "kbd",
style: "cursor: pointer; min-width: 80px; text-align: center;",
title: "Click to set keybind",
"aria-label": "Push to Talk keybind — click to capture",
},
currentVk !== 0 ? vkName(currentVk) : "Not set",
);
const pttClear = createElement(
"button",
{
class: "ac-btn",
style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${currentVk !== 0 ? "" : "display: none;"}`,
},
"Clear",
);
let capturing = false;
pttValue.addEventListener("click", () => {
if (capturing) return;
capturing = true;
pttValue.textContent = "Press any key...";
pttValue.style.borderColor = "var(--accent)";
pttValue.style.color = "var(--accent)";
pttValue.addEventListener(
"click",
() => {
if (capturing) return;
capturing = true;
pttValue.textContent = "Press any key...";
pttValue.style.borderColor = "var(--accent)";
pttValue.style.color = "var(--accent)";
// Use Rust-side key detection (supports mouse buttons, works globally).
// Returns 0 on timeout (10s) if the user didn't press anything.
void captureKeyPress().then((vk) => {
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
if (vk === 0) {
// Timed out — restore previous value
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
return;
}
currentVk = vk;
setText(pttValue, vkName(vk));
pttClear.style.display = "";
void updatePttKey(vk);
}).catch(() => {
// Fallback: capture via JS keydown (dev mode without Tauri)
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
});
}, { signal });
// Use Rust-side key detection (supports mouse buttons, works globally).
// Returns 0 on timeout (10s) if the user didn't press anything.
void captureKeyPress()
.then((vk) => {
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
if (vk === 0) {
// Timed out — restore previous value
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
return;
}
currentVk = vk;
setText(pttValue, vkName(vk));
pttClear.style.display = "";
void updatePttKey(vk);
})
.catch(() => {
// Fallback: capture via JS keydown (dev mode without Tauri)
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
});
},
{ signal },
);
pttClear.addEventListener("click", (e) => {
e.stopPropagation();
currentVk = 0;
setText(pttValue, "Not set");
pttClear.style.display = "none";
void updatePttKey(0);
}, { signal });
pttClear.addEventListener(
"click",
(e) => {
e.stopPropagation();
currentVk = 0;
setText(pttValue, "Not set");
pttClear.style.display = "none";
void updatePttKey(0);
},
{ signal },
);
appendChildren(pttRow, pttLabel, pttValue, pttClear);
section.appendChild(pttRow);
// PTT hint
const pttHint = createElement("div", {
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 16px 0; line-height: 1.4;",
}, "PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work.");
const pttHint = createElement(
"div",
{
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 16px 0; line-height: 1.4;",
},
"PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work.",
);
section.appendChild(pttHint);
// ── Navigation section ────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const navHeader = createElement("div", {
class: "keybind-section-header",
}, "Navigation");
const navHeader = createElement(
"div",
{
class: "keybind-section-header",
},
"Navigation",
);
section.appendChild(navHeader);
const navBinds: [string, string][] = [
@@ -90,7 +116,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
];
for (const [label, shortcut] of navBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
appendChildren(
row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
@@ -100,9 +127,13 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
// ── Communication section ──────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const commHeader = createElement("div", {
class: "keybind-section-header",
}, "Communication");
const commHeader = createElement(
"div",
{
class: "keybind-section-header",
},
"Communication",
);
section.appendChild(commHeader);
const commBinds: [string, string][] = [
@@ -112,7 +143,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
];
for (const [label, shortcut] of commBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
appendChildren(
row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
@@ -122,9 +154,13 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
// ── Messages section ───────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const msgHeader = createElement("div", {
class: "keybind-section-header",
}, "Messages");
const msgHeader = createElement(
"div",
{
class: "keybind-section-header",
},
"Messages",
);
section.appendChild(msgHeader);
const msgBinds: [string, string][] = [
@@ -133,7 +169,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
];
for (const [label, shortcut] of msgBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
appendChildren(
row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
@@ -7,7 +7,7 @@ import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/
import type { LogEntry, LogLevel } from "@lib/logger";
import type { TabName } from "../SettingsOverlay";
import { getSessionDebugInfo } from "@lib/livekitSession";
import { loadPref, savePref } from "./helpers";
import { savePref } from "./helpers";
// ---------------------------------------------------------------------------
// Constants
@@ -35,16 +35,26 @@ function formatLogEntry(entry: LogEntry): HTMLDivElement {
const time = entry.timestamp.slice(11, 23); // HH:MM:SS.mmm
const level = entry.level.toUpperCase().padEnd(5);
const text = `${time} ${level} [${entry.component}] ${entry.message}`;
const textEl = createElement("span", {
style: `color: ${LOG_LEVEL_COLORS[entry.level]}`,
}, text);
const textEl = createElement(
"span",
{
style: `color: ${LOG_LEVEL_COLORS[entry.level]}`,
},
text,
);
row.appendChild(textEl);
if (entry.data !== undefined) {
const dataStr = typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2);
const dataEl = createElement("pre", {
style: "margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;",
}, dataStr);
const dataStr =
typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2);
const dataEl = createElement(
"pre",
{
style:
"margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;",
},
dataStr,
);
row.appendChild(dataEl);
}
@@ -94,12 +104,13 @@ export interface LogsTabHandle {
cleanup(): void;
}
export function createLogsTab(
getActiveTab: () => TabName,
signal: AbortSignal,
): LogsTabHandle {
export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): LogsTabHandle {
let logListEl: HTMLDivElement | null = null;
let logFilterLevel: LogLevel | "all" = readMigratedStringPref("logs_filter_level", "all", LOG_FILTER_LEVELS);
let logFilterLevel: LogLevel | "all" = readMigratedStringPref(
"logs_filter_level",
"all",
LOG_FILTER_LEVELS,
);
let unsubLogListener: (() => void) | null = null;
function renderLogEntries(): void {
@@ -120,13 +131,23 @@ export function createLogsTab(
const section = createElement("div", { class: "settings-pane active" });
// Version display
const versionEl = createElement("div", {
style: "font-size: 12px; color: var(--text-muted); margin: -8px 0 12px 0;",
}, "Client version: loading...");
const versionEl = createElement(
"div",
{
style: "font-size: 12px; color: var(--text-muted); margin: -8px 0 12px 0;",
},
"Client version: loading...",
);
section.appendChild(versionEl);
void import("@tauri-apps/api/app").then(({ getVersion }) =>
getVersion().then((v) => { versionEl.textContent = `Client version: v${v}`; }),
).catch(() => { versionEl.textContent = "Client version: unknown"; });
void import("@tauri-apps/api/app")
.then(({ getVersion }) =>
getVersion().then((v) => {
versionEl.textContent = `Client version: v${v}`;
}),
)
.catch(() => {
versionEl.textContent = "Client version: unknown";
});
// Controls row
const controls = createElement("div", {
@@ -134,9 +155,14 @@ export function createLogsTab(
});
// Filter dropdown
const filterLabel = createElement("span", { class: "setting-label", style: "margin: 0;" }, "Filter:");
const filterLabel = createElement(
"span",
{ class: "setting-label", style: "margin: 0;" },
"Filter:",
);
const filterSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
style:
"background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
for (const lvl of LOG_FILTER_LEVELS) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
@@ -144,71 +170,116 @@ export function createLogsTab(
filterSelect.appendChild(opt);
}
filterSelect.value = logFilterLevel;
filterSelect.addEventListener("change", () => {
logFilterLevel = filterSelect.value as LogLevel | "all";
savePref("logs_filter_level", logFilterLevel);
renderLogEntries();
}, { signal });
filterSelect.addEventListener(
"change",
() => {
logFilterLevel = filterSelect.value as LogLevel | "all";
savePref("logs_filter_level", logFilterLevel);
renderLogEntries();
},
{ signal },
);
// Log level selector
const levelLabel = createElement("span", { class: "setting-label", style: "margin: 0 0 0 16px;" }, "Min Level:");
const levelLabel = createElement(
"span",
{ class: "setting-label", style: "margin: 0 0 0 16px;" },
"Min Level:",
);
const levelSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
style:
"background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
for (const lvl of LOG_MIN_LEVELS) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
levelSelect.appendChild(opt);
}
const savedMinLevel = readMigratedStringPref<LogLevel | "">("logs_min_level", "", ["", ...LOG_MIN_LEVELS]);
const savedMinLevel = readMigratedStringPref<LogLevel | "">("logs_min_level", "", [
"",
...LOG_MIN_LEVELS,
]);
if (savedMinLevel !== "") {
levelSelect.value = savedMinLevel;
setLogLevel(savedMinLevel);
}
levelSelect.addEventListener("change", () => {
const level = levelSelect.value as LogLevel;
setLogLevel(level);
savePref("logs_min_level", level);
}, { signal });
levelSelect.addEventListener(
"change",
() => {
const level = levelSelect.value as LogLevel;
setLogLevel(level);
savePref("logs_min_level", level);
},
{ signal },
);
// Copy All button
const copyBtn = createElement("button", {
class: "ac-btn",
style: "margin-left: auto;",
}, "Copy All");
copyBtn.addEventListener("click", () => {
const entries = getLogBuffer();
const filtered = logFilterLevel === "all"
? entries
: entries.filter((e) => e.level === logFilterLevel);
const text = filtered.map((e) => {
const time = e.timestamp.slice(11, 23);
const level = e.level.toUpperCase().padEnd(5);
const base = `${time} ${level} [${e.component}] ${e.message}`;
if (e.data === undefined) return base;
const dataStr = typeof e.data === "string" ? e.data : JSON.stringify(e.data, null, 2);
return `${base}\n${dataStr}`;
}).join("\n");
void navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500);
}).catch(() => {
copyBtn.textContent = "Failed to copy";
setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500);
});
}, { signal });
const copyBtn = createElement(
"button",
{
class: "ac-btn",
style: "margin-left: auto;",
},
"Copy All",
);
copyBtn.addEventListener(
"click",
() => {
const entries = getLogBuffer();
const filtered =
logFilterLevel === "all" ? entries : entries.filter((e) => e.level === logFilterLevel);
const text = filtered
.map((e) => {
const time = e.timestamp.slice(11, 23);
const level = e.level.toUpperCase().padEnd(5);
const base = `${time} ${level} [${e.component}] ${e.message}`;
if (e.data === undefined) return base;
const dataStr = typeof e.data === "string" ? e.data : JSON.stringify(e.data, null, 2);
return `${base}\n${dataStr}`;
})
.join("\n");
void navigator.clipboard
.writeText(text)
.then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => {
copyBtn.textContent = "Copy All";
}, 1500);
})
.catch(() => {
copyBtn.textContent = "Failed to copy";
setTimeout(() => {
copyBtn.textContent = "Copy All";
}, 1500);
});
},
{ signal },
);
// Clear button
const clearBtn = createElement("button", { class: "ac-btn" }, "Clear Logs");
clearBtn.addEventListener("click", () => {
clearLogBuffer();
renderLogEntries();
}, { signal });
clearBtn.addEventListener(
"click",
() => {
clearLogBuffer();
renderLogEntries();
},
{ signal },
);
// Refresh button
const refreshBtn = createElement("button", { class: "ac-btn" }, "Refresh");
refreshBtn.addEventListener("click", () => renderLogEntries(), { signal });
appendChildren(controls, filterLabel, filterSelect, levelLabel, levelSelect, copyBtn, clearBtn, refreshBtn);
appendChildren(
controls,
filterLabel,
filterSelect,
levelLabel,
levelSelect,
copyBtn,
clearBtn,
refreshBtn,
);
section.appendChild(controls);
// Voice diagnostics panel
@@ -216,7 +287,8 @@ export function createLogsTab(
section.appendChild(diagHeader);
const diagPanel = createElement("div", {
style: "background: var(--bg-tertiary); border-radius: 8px; padding: 10px; margin-bottom: 12px; font-family: monospace; font-size: 12px; line-height: 1.6; color: var(--text-muted);",
style:
"background: var(--bg-tertiary); border-radius: 8px; padding: 10px; margin-bottom: 12px; font-family: monospace; font-size: 12px; line-height: 1.6; color: var(--text-muted);",
});
function refreshDiag(): void {
@@ -225,19 +297,38 @@ export function createLogsTab(
}
refreshDiag();
const diagRefresh = createElement("button", { class: "ac-btn", style: "margin-top: 6px;" }, "Refresh Diagnostics");
const diagRefresh = createElement(
"button",
{ class: "ac-btn", style: "margin-top: 6px;" },
"Refresh Diagnostics",
);
diagRefresh.addEventListener("click", refreshDiag, { signal });
const diagCopy = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Copy Diagnostics");
diagCopy.addEventListener("click", () => {
void navigator.clipboard.writeText(diagPanel.textContent ?? "").then(() => {
diagCopy.textContent = "Copied!";
setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500);
}).catch(() => {
diagCopy.textContent = "Failed to copy";
setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500);
});
}, { signal });
const diagCopy = createElement(
"button",
{ class: "ac-btn", style: "margin: 6px 0 0 6px;" },
"Copy Diagnostics",
);
diagCopy.addEventListener(
"click",
() => {
void navigator.clipboard
.writeText(diagPanel.textContent ?? "")
.then(() => {
diagCopy.textContent = "Copied!";
setTimeout(() => {
diagCopy.textContent = "Copy Diagnostics";
}, 1500);
})
.catch(() => {
diagCopy.textContent = "Failed to copy";
setTimeout(() => {
diagCopy.textContent = "Copy Diagnostics";
}, 1500);
});
},
{ signal },
);
section.appendChild(diagPanel);
const diagBtns = createElement("div", { style: "display: flex; flex-wrap: wrap;" });
@@ -245,15 +336,20 @@ export function createLogsTab(
section.appendChild(diagBtns);
// Log count
const countEl = createElement("div", {
style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;",
}, `${getLogBuffer().length} entries`);
const countEl = createElement(
"div",
{
style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;",
},
`${getLogBuffer().length} entries`,
);
section.appendChild(countEl);
// Log list (scrollable)
logListEl = createElement("div", {
class: "log-viewer",
style: "max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;",
style:
"max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;",
});
section.appendChild(logListEl);
@@ -9,10 +9,30 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "desktopNotifications", label: "Desktop Notifications", desc: "Show desktop notifications for messages", fallback: true },
{ key: "flashTaskbar", label: "Flash Taskbar", desc: "Flash taskbar on new messages", fallback: true },
{ key: "suppressEveryone", label: "Suppress @everyone", desc: "Mute @everyone and @here mentions", fallback: false },
{ key: "notificationSounds", label: "Notification Sounds", desc: "Play sounds for notifications", fallback: true },
{
key: "desktopNotifications",
label: "Desktop Notifications",
desc: "Show desktop notifications for messages",
fallback: true,
},
{
key: "flashTaskbar",
label: "Flash Taskbar",
desc: "Flash taskbar on new messages",
fallback: true,
},
{
key: "suppressEveryone",
label: "Suppress @everyone",
desc: "Mute @everyone and @here mentions",
fallback: false,
},
{
key: "notificationSounds",
label: "Notification Sounds",
desc: "Play sounds for notifications",
fallback: true,
},
];
for (const item of toggles) {
@@ -25,7 +45,9 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
onChange: (nowOn) => {
savePref(item.key, nowOn);
},
});
appendChildren(row, info, toggle);
@@ -44,7 +44,9 @@ export function buildTextImagesTab(signal: AbortSignal): HTMLDivElement {
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
onChange: (nowOn) => {
savePref(item.key, nowOn);
},
});
appendChildren(row, info, toggle);
section.appendChild(row);
@@ -4,7 +4,14 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, setInputVolume, setOutputVolume, reapplyAudioProcessing } from "@lib/livekitSession";
import {
switchInputDevice,
switchOutputDevice,
setVoiceSensitivity,
setInputVolume,
setOutputVolume,
reapplyAudioProcessing,
} from "@lib/livekitSession";
export interface VoiceAudioTabHandle {
build(): HTMLDivElement;
@@ -19,13 +26,19 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
let invalidateCameraPreviewRequest: (() => void) | null = null;
function cleanupMic(): void {
if (micAnimFrame !== null) { cancelAnimationFrame(micAnimFrame); micAnimFrame = null; }
if (micAnimFrame !== null) {
cancelAnimationFrame(micAnimFrame);
micAnimFrame = null;
}
invalidateCameraPreviewRequest?.();
if (micStream !== null) {
for (const track of micStream.getTracks()) track.stop();
micStream = null;
}
if (micAudioCtx !== null) { void micAudioCtx.close(); micAudioCtx = null; }
if (micAudioCtx !== null) {
void micAudioCtx.close();
micAudioCtx = null;
}
// Also stop camera preview
if (cameraPreviewStream !== null) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
@@ -36,19 +49,24 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
function build(): HTMLDivElement {
// Clean up any previous mic/camera stream before rebuilding
cleanupMic();
return buildVoiceAudioTabInner(signal, (stream, ctx, frame) => {
micStream = stream;
micAudioCtx = ctx;
micAnimFrame = frame;
}, (stream) => {
// Stop old camera tracks before registering new stream
if (cameraPreviewStream !== null && cameraPreviewStream !== stream) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
}
cameraPreviewStream = stream;
}, (invalidate) => {
invalidateCameraPreviewRequest = invalidate;
});
return buildVoiceAudioTabInner(
signal,
(stream, ctx, frame) => {
micStream = stream;
micAudioCtx = ctx;
micAnimFrame = frame;
},
(stream) => {
// Stop old camera tracks before registering new stream
if (cameraPreviewStream !== null && cameraPreviewStream !== stream) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
}
cameraPreviewStream = stream;
},
(invalidate) => {
invalidateCameraPreviewRequest = invalidate;
},
);
}
function cleanup(): void {
@@ -98,11 +116,15 @@ function buildVoiceAudioTabInner(
value: String(savedInputVolume),
});
const inputVolumeLabel = createElement("span", { class: "slider-val" }, `${savedInputVolume}%`);
inputVolumeSlider.addEventListener("input", () => {
const val = Number(inputVolumeSlider.value);
setText(inputVolumeLabel, `${val}%`);
setInputVolume(val);
}, { signal });
inputVolumeSlider.addEventListener(
"input",
() => {
const val = Number(inputVolumeSlider.value);
setText(inputVolumeLabel, `${val}%`);
setInputVolume(val);
},
{ signal },
);
appendChildren(inputVolumeRow, inputVolumeSlider, inputVolumeLabel);
section.appendChild(inputVolumeRow);
@@ -146,22 +168,32 @@ function buildVoiceAudioTabInner(
}
// Drag the threshold handle
meterThreshold.addEventListener("pointerdown", (e: PointerEvent) => {
e.preventDefault();
meterThreshold.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent): void => { applySensitivity(sensitivityFromPointer(ev.clientX)); };
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
}, { signal });
meterThreshold.addEventListener(
"pointerdown",
(e: PointerEvent) => {
e.preventDefault();
meterThreshold.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent): void => {
applySensitivity(sensitivityFromPointer(ev.clientX));
};
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
},
{ signal },
);
// Click on the meter bar to jump the threshold
meterBar.addEventListener("click", (e: MouseEvent) => {
applySensitivity(sensitivityFromPointer(e.clientX));
}, { signal });
meterBar.addEventListener(
"click",
(e: MouseEvent) => {
applySensitivity(sensitivityFromPointer(e.clientX));
},
{ signal },
);
// Output device selector
const outputHeader = createElement("h3", {}, "Output Device");
@@ -188,19 +220,27 @@ function buildVoiceAudioTabInner(
value: String(savedOutputVolume),
});
const outputVolumeLabel = createElement("span", { class: "slider-val" }, `${savedOutputVolume}%`);
outputVolumeSlider.addEventListener("input", () => {
const val = Number(outputVolumeSlider.value);
setText(outputVolumeLabel, `${val}%`);
setOutputVolume(val);
}, { signal });
outputVolumeSlider.addEventListener(
"input",
() => {
const val = Number(outputVolumeSlider.value);
setText(outputVolumeLabel, `${val}%`);
setOutputVolume(val);
},
{ signal },
);
appendChildren(outputVolumeRow, outputVolumeSlider, outputVolumeLabel);
section.appendChild(outputVolumeRow);
// Stream quality selector
const qualityHeader = createElement("h3", {}, "Stream Quality");
const qualityDesc = createElement("p", {
style: "color:var(--text-muted);font-size:12px;margin:0 0 8px",
}, "Applies to camera and screenshare. Higher quality uses more bandwidth. Changes take effect on next voice join.");
const qualityDesc = createElement(
"p",
{
style: "color:var(--text-muted);font-size:12px;margin:0 0 8px",
},
"Applies to camera and screenshare. Higher quality uses more bandwidth. Changes take effect on next voice join.",
);
const qualitySelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:16px",
@@ -218,9 +258,13 @@ function buildVoiceAudioTabInner(
qualitySelect.appendChild(opt);
}
qualitySelect.value = savedQuality;
qualitySelect.addEventListener("change", () => {
savePref("streamQuality", qualitySelect.value);
}, { signal });
qualitySelect.addEventListener(
"change",
() => {
savePref("streamQuality", qualitySelect.value);
},
{ signal },
);
section.appendChild(qualityHeader);
section.appendChild(qualityDesc);
section.appendChild(qualitySelect);
@@ -238,7 +282,8 @@ function buildVoiceAudioTabInner(
// Camera preview
const previewWrap = createElement("div", {
style: "margin-bottom:16px;border-radius:8px;overflow:hidden;background:#1e1f22;aspect-ratio:16/9;max-width:320px",
style:
"margin-bottom:16px;border-radius:8px;overflow:hidden;background:#1e1f22;aspect-ratio:16/9;max-width:320px",
});
const previewVideo = document.createElement("video");
previewVideo.autoplay = true;
@@ -260,18 +305,27 @@ function buildVoiceAudioTabInner(
for (const d of devices) {
if (d.kind === "audioinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`);
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedInput) opt.setAttribute("selected", "");
inputSelect.appendChild(opt);
} else if (d.kind === "audiooutput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`);
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
outputSelect.appendChild(opt);
} else if (d.kind === "videoinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Camera (${d.deviceId.slice(0, 8)})`);
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Camera (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedVideo) opt.setAttribute("selected", "");
videoSelect.appendChild(opt);
}
@@ -282,21 +336,32 @@ function buildVoiceAudioTabInner(
if (savedOutput) outputSelect.value = savedOutput;
if (savedVideo) videoSelect.value = savedVideo;
} catch {
const errOpt = createElement("option", { value: "", disabled: "" },
"Could not enumerate devices");
const errOpt = createElement(
"option",
{ value: "", disabled: "" },
"Could not enumerate devices",
);
inputSelect.appendChild(errOpt);
}
})();
inputSelect.addEventListener("change", () => {
savePref("audioInputDevice", inputSelect.value);
void switchInputDevice(inputSelect.value);
}, { signal });
inputSelect.addEventListener(
"change",
() => {
savePref("audioInputDevice", inputSelect.value);
void switchInputDevice(inputSelect.value);
},
{ signal },
);
outputSelect.addEventListener("change", () => {
savePref("audioOutputDevice", outputSelect.value);
void switchOutputDevice(outputSelect.value);
}, { signal });
outputSelect.addEventListener(
"change",
() => {
savePref("audioOutputDevice", outputSelect.value);
void switchOutputDevice(outputSelect.value);
},
{ signal },
);
// Race guard: prevent stale getUserMedia results from overwriting a newer request
let cameraRequestId = 0;
@@ -348,10 +413,14 @@ function buildVoiceAudioTabInner(
})();
}
videoSelect.addEventListener("change", () => {
savePref("videoInputDevice", videoSelect.value);
startCameraPreview(videoSelect.value);
}, { signal });
videoSelect.addEventListener(
"change",
() => {
savePref("videoInputDevice", videoSelect.value);
startCameraPreview(videoSelect.value);
},
{ signal },
);
// Start initial camera preview only if a device has been explicitly selected
const savedVideoDevice = loadPref<string>("videoInputDevice", "");
@@ -415,11 +484,36 @@ function buildVoiceAudioTabInner(
})();
// ── Audio processing toggles ──────────────────────────────────────
const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true },
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
{ key: "enhancedNoiseSuppression", label: "Enhanced Noise Suppression", desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds", fallback: false },
const audioToggles: ReadonlyArray<{
key: string;
label: string;
desc: string;
fallback: boolean;
}> = [
{
key: "echoCancellation",
label: "Echo Cancellation",
desc: "Reduce echo from speakers feeding back into microphone",
fallback: true,
},
{
key: "noiseSuppression",
label: "Noise Suppression",
desc: "Filter out background noise from your microphone",
fallback: true,
},
{
key: "autoGainControl",
label: "Automatic Gain Control",
desc: "Automatically adjust microphone volume",
fallback: true,
},
{
key: "enhancedNoiseSuppression",
label: "Enhanced Noise Suppression",
desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds",
fallback: false,
},
];
for (const item of audioToggles) {
@@ -12,10 +12,30 @@ import { applyThemeByName } from "@lib/themes";
export const STORAGE_PREFIX = "owncord:settings:";
export const THEMES = {
dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--text-normal": "#dbdee1" },
"neon-glow": { "--bg-primary": "#1a1b1e", "--bg-secondary": "#111214", "--bg-tertiary": "#0d0e10", "--text-normal": "#dbdee1" },
midnight: { "--bg-primary": "#1a1a2e", "--bg-secondary": "#16213e", "--bg-tertiary": "#0f3460", "--text-normal": "#e0e0e0" },
light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" },
dark: {
"--bg-primary": "#313338",
"--bg-secondary": "#2b2d31",
"--bg-tertiary": "#1e1f22",
"--text-normal": "#dbdee1",
},
"neon-glow": {
"--bg-primary": "#1a1b1e",
"--bg-secondary": "#111214",
"--bg-tertiary": "#0d0e10",
"--text-normal": "#dbdee1",
},
midnight: {
"--bg-primary": "#1a1a2e",
"--bg-secondary": "#16213e",
"--bg-tertiary": "#0f3460",
"--text-normal": "#e0e0e0",
},
light: {
"--bg-primary": "#ffffff",
"--bg-secondary": "#f2f3f5",
"--bg-tertiary": "#e3e5e8",
"--text-normal": "#313338",
},
} as const;
export type ThemeName = keyof typeof THEMES;
@@ -72,12 +92,16 @@ export function createToggle(
}
toggle.addEventListener("click", doToggle, { signal: opts.signal });
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
doToggle();
}
}, { signal: opts.signal });
toggle.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
doToggle();
}
},
{ signal: opts.signal },
);
return toggle;
}
+106 -135
View File
@@ -27,6 +27,8 @@ import type {
export interface ApiClientConfig {
readonly host: string;
readonly token?: string;
/** Accept self-signed TLS certificates (for local/dev OwnCord servers). */
readonly allowSelfSigned?: boolean;
}
/** API client error with parsed error body. */
@@ -47,10 +49,12 @@ export type OnUnauthorized = () => void;
const log = createLogger("api");
/** Create the REST API client. */
export function createApiClient(
initialConfig: ApiClientConfig,
onUnauthorized?: OnUnauthorized,
) {
export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: OnUnauthorized) {
// oxlint-disable-next-line consistent-function-scoping -- co-located with createApiClient for encapsulation
function isValidHost(host: string): boolean {
return /^[\w.-]+(:\d+)?$/.test(host) && host.length <= 253;
}
let config = { ...initialConfig };
function baseUrl(): string {
@@ -80,11 +84,15 @@ export function createApiClient(
signal?: AbortSignal,
): Promise<T> {
const url = `${urlBase}${path}`;
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
const init: RequestInit & {
danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean };
} = {
method,
headers: headers(),
signal,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
...(config.allowSelfSigned === true
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } }
: {}),
};
if (body !== undefined) {
init.body = JSON.stringify(body);
@@ -100,7 +108,9 @@ export function createApiClient(
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr), {
cause: fetchErr,
});
}
log.debug(`${label}`, { method, path, status: res.status });
@@ -113,7 +123,13 @@ export function createApiClient(
if (!res.ok) {
const err = await parseError(res);
log.warn(`${label} error`, { method, path, status: res.status, code: err.error, message: err.message });
log.warn(`${label} error`, {
method,
path,
status: res.status,
code: err.error,
message: err.message,
});
throw new ApiClientError(res.status, err.error, err.message);
}
@@ -125,14 +141,25 @@ export function createApiClient(
return res.json() as Promise<T>;
}
function request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
function request<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return doFetch<T>("API", baseUrl(), method, path, body, signal);
}
function adminRequest<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
function adminRequest<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return doFetch<T>("Admin API", adminBaseUrl(), method, path, body, signal);
}
// oxlint-disable-next-line consistent-function-scoping -- co-located with doFetch for encapsulation
async function parseError(res: Response): Promise<ApiError> {
try {
const body = await res.json();
@@ -151,6 +178,10 @@ export function createApiClient(
return {
/** Update the client config (e.g., after login). */
setConfig(newConfig: Partial<ApiClientConfig>): void {
if (newConfig.host !== undefined && !isValidHost(newConfig.host)) {
log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format");
}
config = { ...config, ...newConfig };
},
@@ -161,17 +192,8 @@ export function createApiClient(
// ── Auth ──────────────────────────────────────────────
login(
username: string,
password: string,
signal?: AbortSignal,
): Promise<AuthResponse> {
return request<AuthResponse>(
"POST",
"/auth/login",
{ username, password },
signal,
);
login(username: string, password: string, signal?: AbortSignal): Promise<AuthResponse> {
return request<AuthResponse>("POST", "/auth/login", { username, password }, signal);
},
register(
@@ -199,26 +221,36 @@ export function createApiClient(
): Promise<AuthResponse> {
// Don't mutate shared config — make direct fetch with the partial token
const url = `${baseUrl()}/auth/verify-totp`;
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
const init: RequestInit & {
danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean };
} = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${partialToken}`,
Authorization: `Bearer ${partialToken}`,
},
body: JSON.stringify({ code }),
signal,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
...(config.allowSelfSigned === true
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } }
: {}),
};
let res: Response;
try {
res = await fetch(url, init as RequestInit);
} catch (fetchErr) {
log.error("API fetch failed", { method: "POST", path: "/auth/verify-totp", error: String(fetchErr) });
log.error("API fetch failed", {
method: "POST",
path: "/auth/verify-totp",
error: String(fetchErr),
});
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr), {
cause: fetchErr,
});
}
if (res.status === 401) {
@@ -236,12 +268,7 @@ export function createApiClient(
},
deleteAccount(password: string, signal?: AbortSignal): Promise<void> {
return request<void>(
"DELETE",
"/auth/account",
{ password },
signal,
);
return request<void>("DELETE", "/auth/account", { password }, signal);
},
// ── Users ─────────────────────────────────────────────
@@ -270,7 +297,10 @@ export function createApiClient(
);
},
enableTotp(password: string, signal?: AbortSignal): Promise<{ qr_uri: string; backup_codes: string[] }> {
enableTotp(
password: string,
signal?: AbortSignal,
): Promise<{ qr_uri: string; backup_codes: string[] }> {
return request("POST", "/users/me/totp/enable", { password }, signal);
},
@@ -283,21 +313,11 @@ export function createApiClient(
},
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
return request<SessionResponse[]>(
"GET",
"/users/me/sessions",
undefined,
signal,
);
return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal);
},
revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> {
return request<void>(
"DELETE",
`/users/me/sessions/${sessionId}`,
undefined,
signal,
);
return request<void>("DELETE", `/users/me/sessions/${sessionId}`, undefined, signal);
},
// ── Channels ──────────────────────────────────────────
@@ -320,38 +340,15 @@ export function createApiClient(
},
getPins(channelId: number, signal?: AbortSignal): Promise<MessagesResponse> {
return request<MessagesResponse>(
"GET",
`/channels/${channelId}/pins`,
undefined,
signal,
);
return request<MessagesResponse>("GET", `/channels/${channelId}/pins`, undefined, signal);
},
pinMessage(
channelId: number,
messageId: number,
signal?: AbortSignal,
): Promise<void> {
return request<void>(
"POST",
`/channels/${channelId}/pins/${messageId}`,
undefined,
signal,
);
pinMessage(channelId: number, messageId: number, signal?: AbortSignal): Promise<void> {
return request<void>("POST", `/channels/${channelId}/pins/${messageId}`, undefined, signal);
},
unpinMessage(
channelId: number,
messageId: number,
signal?: AbortSignal,
): Promise<void> {
return request<void>(
"DELETE",
`/channels/${channelId}/pins/${messageId}`,
undefined,
signal,
);
unpinMessage(channelId: number, messageId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/channels/${channelId}/pins/${messageId}`, undefined, signal);
},
// ── Search ────────────────────────────────────────────
@@ -364,20 +361,12 @@ export function createApiClient(
const params = new URLSearchParams({ q: query });
if (options?.channelId !== undefined) params.set("channel_id", String(options.channelId));
if (options?.limit !== undefined) params.set("limit", String(options.limit));
return request<SearchResponse>(
"GET",
`/search?${params.toString()}`,
undefined,
signal,
);
return request<SearchResponse>("GET", `/search?${params.toString()}`, undefined, signal);
},
// ── File Uploads ──────────────────────────────────────
async uploadFile(
file: File,
signal?: AbortSignal,
): Promise<UploadResponse> {
async uploadFile(file: File, signal?: AbortSignal): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", file);
@@ -393,7 +382,9 @@ export function createApiClient(
headers: h,
body: formData,
signal,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
...(config.allowSelfSigned === true
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } }
: {}),
} as RequestInit);
if (!res.ok) {
@@ -417,8 +408,8 @@ export function createApiClient(
return request<InviteResponse>("POST", "/invites", data, signal);
},
revokeInvite(inviteId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/invites/${inviteId}`, undefined, signal);
revokeInvite(code: string, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/invites/${code}`, undefined, signal);
},
// ── Emoji ─────────────────────────────────────────────
@@ -449,16 +440,8 @@ export function createApiClient(
},
/** Create or get a DM channel with a user. */
createDm(
recipientId: number,
signal?: AbortSignal,
): Promise<CreateDmResponse> {
return request<CreateDmResponse>(
"POST",
"/dms",
{ recipient_id: recipientId },
signal,
);
createDm(recipientId: number, signal?: AbortSignal): Promise<CreateDmResponse> {
return request<CreateDmResponse>("POST", "/dms", { recipient_id: recipientId }, signal);
},
/** Close a DM (hide from sidebar). */
@@ -468,30 +451,22 @@ export function createApiClient(
// ── Voice ─────────────────────────────────────────────
getVoiceCredentials(
signal?: AbortSignal,
): Promise<VoiceCredentialsResponse> {
return request<VoiceCredentialsResponse>(
"GET",
"/voice/credentials",
undefined,
signal,
);
getVoiceCredentials(signal?: AbortSignal): Promise<VoiceCredentialsResponse> {
return request<VoiceCredentialsResponse>("GET", "/voice/credentials", undefined, signal);
},
// ── Health ────────────────────────────────────────────
async getHealth(
host?: string,
timeoutMs = 3000,
): Promise<HealthResponse> {
async getHealth(host?: string, timeoutMs = 3000): Promise<HealthResponse> {
const targetHost = host ?? config.host;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`https://${targetHost}/api/v1/health`, {
signal: controller.signal,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
...(config.allowSelfSigned === true
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } }
: {}),
} as RequestInit);
if (!res.ok) {
throw new ApiClientError(res.status, "HEALTH_CHECK_FAILED", "Health check failed");
@@ -531,41 +506,37 @@ export function createApiClient(
return adminRequest<ChannelResponse>("PATCH", `/channels/${id}`, data, signal);
},
adminDeleteChannel(
id: number,
signal?: AbortSignal,
): Promise<void> {
adminDeleteChannel(id: number, signal?: AbortSignal): Promise<void> {
return adminRequest<void>("DELETE", `/channels/${id}`, undefined, signal);
},
// ── Admin: Members ──────────────────────────────────────
adminKickMember(
userId: number,
signal?: AbortSignal,
): Promise<void> {
adminKickMember(userId: number, signal?: AbortSignal): Promise<void> {
return adminRequest<void>("DELETE", `/users/${userId}/sessions`, undefined, signal);
},
adminBanMember(
userId: number,
reason?: string,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("PATCH", `/users/${userId}`, {
banned: true,
ban_reason: reason ?? "",
}, signal);
adminBanMember(userId: number, reason?: string, signal?: AbortSignal): Promise<void> {
return adminRequest<void>(
"PATCH",
`/users/${userId}`,
{
banned: true,
ban_reason: reason ?? "",
},
signal,
);
},
adminChangeRole(
userId: number,
roleId: number,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("PATCH", `/users/${userId}`, {
role_id: roleId,
}, signal);
adminChangeRole(userId: number, roleId: number, signal?: AbortSignal): Promise<void> {
return adminRequest<void>(
"PATCH",
`/users/${userId}`,
{
role_id: roleId,
},
signal,
);
},
};
}
+34 -5
View File
@@ -13,6 +13,7 @@ import {
import { loadPref, savePref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { parseUserId } from "@lib/livekitSession";
import { voiceStore } from "@stores/voice.store";
const log = createLogger("audioElements");
@@ -64,6 +65,14 @@ export class AudioElements {
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void {
// Guard: do not attach any remote audio while locally deafened.
// applyRemoteAudioSubscriptionState() only covers participants present at
// the time of deafen — this guard catches participants who join afterward.
if (voiceStore.getState().localDeafened) {
publication.setSubscribed(false);
return;
}
const userId = parseUserId(participant.identity);
if (publication.source === Track.Source.ScreenShareAudio) {
// Screenshare audio: manage via HTMLAudioElement volume (not participant.setVolume)
@@ -124,7 +133,10 @@ export class AudioElements {
for (const el of detachedEls) audioEls.delete(el);
if (audioEls.size === 0) this.screenshareAudioElements.delete(userId);
}
log.debug("Screenshare audio track unsubscribed and detached", { userId, trackSid: track.sid });
log.debug("Screenshare audio track unsubscribed and detached", {
userId,
trackSid: track.sid,
});
} else {
for (const el of track.detach()) el.remove();
if (track.sid !== undefined) this.remoteMicAudioElements.delete(track.sid);
@@ -166,7 +178,9 @@ export class AudioElements {
}
}
getUserVolume(userId: number): number { return getSavedUserVolume(userId); }
getUserVolume(userId: number): number {
return getSavedUserVolume(userId);
}
setOutputVolume(volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
@@ -208,14 +222,29 @@ export class AudioElements {
// --- Cleanup ---
/** Remove all remote audio elements from the DOM and clear tracking maps. */
/** Remove all remote audio elements from the DOM and clear tracking maps.
* Preserves screenshare mute state so reconnecting tracks inherit user intent. */
cleanupAllAudioElements(): void {
for (const el of this.remoteMicAudioElements.values()) el.remove();
// BUG-107: Fully release audio elements — pause, clear srcObject, then remove.
for (const el of this.remoteMicAudioElements.values()) {
el.pause();
el.srcObject = null;
el.remove();
}
this.remoteMicAudioElements.clear();
for (const audioEls of this.screenshareAudioElements.values()) {
for (const el of audioEls) el.remove();
for (const el of audioEls) {
el.pause();
el.srcObject = null;
el.remove();
}
}
this.screenshareAudioElements.clear();
}
/** Full cleanup including screenshare mute state — used on intentional leave. */
cleanupAllAudioElementsFull(): void {
this.cleanupAllAudioElements();
this.screenshareAudioMutedByUser.clear();
}
}
+78 -22
View File
@@ -19,6 +19,9 @@ const log = createLogger("audioPipeline");
export class AudioPipeline {
private room: Room | null = null;
/** Monotonic counter incremented on teardown — used to discard stale async results. */
private _pipelineGeneration = 0;
// Pipeline nodes
private audioPipelineCtx: AudioContext | null = null;
private audioPipelineGain: GainNode | null = null;
@@ -121,12 +124,22 @@ export class AudioPipeline {
this.audioPipelineAnalyser = analyser;
this.audioPipelineDest = dest;
// Replace the WebRTC sender's track with the pipeline output
// Replace the WebRTC sender's track with the pipeline output.
// BUG-106: Guard with generation counter to discard stale replaceTrack
// if teardown races ahead of this setup.
const adjustedTrack = dest.stream.getAudioTracks()[0];
const gen = this._pipelineGeneration;
if (adjustedTrack !== undefined && micPub.track.sender) {
void micPub.track.sender.replaceTrack(adjustedTrack).catch((err) => {
log.warn("Failed to replace sender track with pipeline output", err);
});
void micPub.track.sender
.replaceTrack(adjustedTrack)
.then(() => {
if (this._pipelineGeneration !== gen) {
log.debug("replaceTrack (setup) completed after generation change — stale");
}
})
.catch((err) => {
log.warn("Failed to replace sender track with pipeline output", err);
});
}
log.info("Audio pipeline created", { inputGain: this.currentInputGain });
@@ -140,21 +153,44 @@ export class AudioPipeline {
/** Tear down the audio pipeline and restore the original sender track. */
teardownAudioPipeline(): void {
this._pipelineGeneration++;
this.stopVadPolling();
// Restore original mic track on the WebRTC sender
// Restore original mic track on the WebRTC sender.
// BUG-106: Guard with generation counter so a stale teardown replaceTrack
// cannot overwrite a subsequent setup's pipeline track.
const gen = this._pipelineGeneration;
if (this.room !== null) {
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track?.sender !== undefined) {
const originalTrack = micPub.track.mediaStreamTrack;
void micPub.track.sender.replaceTrack(originalTrack).catch((err) => log.debug("Failed to replace track during teardown", err));
void micPub.track.sender
.replaceTrack(originalTrack)
.then(() => {
if (this._pipelineGeneration !== gen) {
log.debug("replaceTrack (teardown) completed after generation change — stale");
}
})
.catch((err) => log.debug("Failed to replace track during teardown", err));
}
}
if (this.audioPipelineGain !== null) { this.audioPipelineGain.disconnect(); this.audioPipelineGain = null; }
if (this.audioPipelineAnalyser !== null) { this.audioPipelineAnalyser.disconnect(); this.audioPipelineAnalyser = null; }
if (this.audioPipelineDest !== null) { this.audioPipelineDest.disconnect(); this.audioPipelineDest = null; }
if (this.audioPipelineCtx !== null) { void this.audioPipelineCtx.close(); this.audioPipelineCtx = null; }
if (this.audioPipelineGain !== null) {
this.audioPipelineGain.disconnect();
this.audioPipelineGain = null;
}
if (this.audioPipelineAnalyser !== null) {
this.audioPipelineAnalyser.disconnect();
this.audioPipelineAnalyser = null;
}
if (this.audioPipelineDest !== null) {
this.audioPipelineDest.disconnect();
this.audioPipelineDest = null;
}
if (this.audioPipelineCtx !== null) {
void this.audioPipelineCtx.close();
this.audioPipelineCtx = null;
}
this.vadGated = false;
}
@@ -163,7 +199,11 @@ export class AudioPipeline {
updatePipelineGain(): void {
if (this.audioPipelineGain === null || this.audioPipelineCtx === null) return;
const effectiveGain = this.vadGated ? 0 : this.currentInputGain;
this.audioPipelineGain.gain.setTargetAtTime(effectiveGain, this.audioPipelineCtx.currentTime, 0.015);
this.audioPipelineGain.gain.setTargetAtTime(
effectiveGain,
this.audioPipelineCtx.currentTime,
0.015,
);
}
// --- Volume/sensitivity ---
@@ -188,7 +228,10 @@ export class AudioPipeline {
this.stopVadPolling();
if (clamped >= 100) {
// Ensure ungated
if (this.vadGated) { this.vadGated = false; this.updatePipelineGain(); }
if (this.vadGated) {
this.vadGated = false;
this.updatePipelineGain();
}
} else {
this.startVadPolling();
}
@@ -207,9 +250,13 @@ export class AudioPipeline {
private _vadUsingWorklet = false;
/** Latest RMS value from VAD (for UI indicator bar). */
get lastVadRms(): number { return this._lastVadRms; }
get lastVadRms(): number {
return this._lastVadRms;
}
/** Whether VAD is using AudioWorklet (true) or setTimeout fallback (false). */
get vadUsingWorklet(): boolean { return this._vadUsingWorklet; }
get vadUsingWorklet(): boolean {
return this._vadUsingWorklet;
}
/** Start VAD — tries AudioWorklet first, falls back to setTimeout polling. */
startVadPolling(): void {
@@ -219,16 +266,22 @@ export class AudioPipeline {
const sensitivity = loadPref<number>("voiceSensitivity", 50);
if (sensitivity >= 100) return;
const threshold = ((100 - sensitivity) / 100) * 0.10;
const threshold = ((100 - sensitivity) / 100) * 0.1;
// Try AudioWorklet first
this.audioPipelineCtx.audioWorklet.addModule("/vad-worklet.js").then(() => {
if (this.audioPipelineCtx === null) return; // Torn down while loading
this.startVadWorklet(threshold);
}).catch((err) => {
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
this.startVadFallback(threshold);
});
const gen = this._pipelineGeneration;
this.audioPipelineCtx.audioWorklet
.addModule("/vad-worklet.js")
.then(() => {
if (gen !== this._pipelineGeneration) return; // Torn down while loading
if (this.audioPipelineCtx === null) return;
this.startVadWorklet(threshold);
})
.catch((err) => {
if (gen !== this._pipelineGeneration) return;
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
this.startVadFallback(threshold);
});
}
/** Start VAD via AudioWorklet (preferred — runs on audio thread). */
@@ -245,8 +298,10 @@ export class AudioPipeline {
}
// Don't connect workletNode output to anything — it's analysis-only
// oxlint-disable-next-line require-post-message-target-origin -- MessagePort.postMessage, not Window.postMessage
workletNode.port.postMessage({ type: "config", threshold });
// oxlint-disable-next-line prefer-add-event-listener -- MessagePort does not support addEventListener
workletNode.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "gate") {
const gated = event.data.gated as boolean;
@@ -341,6 +396,7 @@ export class AudioPipeline {
}
// Stop AudioWorklet
if (this.vadWorkletNode !== null) {
// oxlint-disable-next-line require-post-message-target-origin -- MessagePort.postMessage, not Window.postMessage
this.vadWorkletNode.port.postMessage({ type: "stop" });
this.vadWorkletNode.disconnect();
this.vadWorkletNode = null;
+23 -13
View File
@@ -53,9 +53,7 @@ interface PrevSnapshot {
/** Collect stats from both publisher and subscriber PeerConnections.
* RTT is typically on the subscriber PC in LiveKit's SFU model. */
async function collectAllStats(
room: Room,
): Promise<RTCStatsReport[]> {
async function collectAllStats(room: Room): Promise<RTCStatsReport[]> {
try {
const engine = room.engine as unknown as Record<string, unknown>;
const pcManager = engine.pcManager as
@@ -103,8 +101,10 @@ function extractMetrics(reports: RTCStatsReport[]): {
rtt = rawRtt * 1000;
}
// Use max across candidate-pairs (avoid double-counting across PCs)
if (typeof entry.bytesSent === "number" && entry.bytesSent > totalUp) totalUp = entry.bytesSent;
if (typeof entry.bytesReceived === "number" && entry.bytesReceived > totalDown) totalDown = entry.bytesReceived;
if (typeof entry.bytesSent === "number" && entry.bytesSent > totalUp)
totalUp = entry.bytesSent;
if (typeof entry.bytesReceived === "number" && entry.bytesReceived > totalDown)
totalDown = entry.bytesReceived;
}
if (entry.type === "outbound-rtp") {
@@ -122,14 +122,14 @@ function extractMetrics(reports: RTCStatsReport[]): {
return { rtt, totalUp, totalDown, outPackets, inPackets, outBytes, inBytes };
}
export function createConnectionStatsPoller(
getRoom: () => Room | null,
): ConnectionStatsPoller {
export function createConnectionStatsPoller(getRoom: () => Room | null): ConnectionStatsPoller {
let current: ConnectionStats = EMPTY_STATS;
let prev: PrevSnapshot = { timestamp: Date.now(), outBytes: 0, inBytes: 0 };
let intervalId: ReturnType<typeof setInterval> | null = null;
const listeners = new Set<(stats: ConnectionStats) => void>();
const qualityChangeListeners = new Set<(quality: QualityLevel, prevQuality: QualityLevel) => void>();
const qualityChangeListeners = new Set<
(quality: QualityLevel, prevQuality: QualityLevel) => void
>();
let lastQuality: QualityLevel = "excellent";
let qualityDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const QUALITY_DEBOUNCE_MS = 3000;
@@ -169,9 +169,9 @@ export function createConnectionStatsPoller(
if (qualityDebounceTimer !== null) clearTimeout(qualityDebounceTimer);
qualityDebounceTimer = setTimeout(() => {
if (current.quality !== lastQuality) {
const prev = lastQuality;
const prevQuality = lastQuality;
lastQuality = current.quality;
qualityChangeListeners.forEach((cb) => cb(current.quality, prev));
qualityChangeListeners.forEach((cb) => cb(current.quality, prevQuality));
}
}, QUALITY_DEBOUNCE_MS);
}
@@ -190,6 +190,12 @@ export function createConnectionStatsPoller(
log.info("Stopping connection stats poller");
clearInterval(intervalId);
intervalId = null;
// BUG-071B: Clear pending quality debounce timer to prevent it firing
// after the poller is stopped (would call listeners against a dead room).
if (qualityDebounceTimer !== null) {
clearTimeout(qualityDebounceTimer);
qualityDebounceTimer = null;
}
current = EMPTY_STATS;
prev = { timestamp: Date.now(), outBytes: 0, inBytes: 0 };
}
@@ -205,9 +211,13 @@ export function createConnectionStatsPoller(
};
}
function onQualityChanged(cb: (quality: QualityLevel, prevQuality: QualityLevel) => void): () => void {
function onQualityChanged(
cb: (quality: QualityLevel, prevQuality: QualityLevel) => void,
): () => void {
qualityChangeListeners.add(cb);
return () => { qualityChangeListeners.delete(cb); };
return () => {
qualityChangeListeners.delete(cb);
};
}
return { start, stop, getStats, onUpdate, onQualityChanged };
+3 -5
View File
@@ -10,7 +10,8 @@ const log = createLogger("credentials");
export interface SavedCredential {
readonly username: string;
readonly token: string;
readonly password?: string;
// Note: password is no longer returned from the Rust backend over IPC
// to limit credential exposure in the JS heap.
}
/** Dynamically import Tauri invoke to avoid errors in test/browser. */
@@ -53,9 +54,7 @@ export async function saveCredential(
* Load a credential from Windows Credential Manager.
* Returns null if not found or Tauri unavailable.
*/
export async function loadCredential(
host: string,
): Promise<SavedCredential | null> {
export async function loadCredential(host: string): Promise<SavedCredential | null> {
const invoke = await getInvoke();
if (!invoke) {
return null;
@@ -68,7 +67,6 @@ export async function loadCredential(
return {
username: cred.username,
token: cred.token,
...(typeof cred.password === "string" ? { password: cred.password } : {}),
};
}
}
+2 -2
View File
@@ -78,7 +78,7 @@ export class DeviceManager {
const savedInput = loadPref<string>("audioInputDevice", "");
// Check if the saved input device was removed
if (savedInput !== "" && !devices.some(d => d.deviceId === savedInput)) {
if (savedInput !== "" && !devices.some((d) => d.deviceId === savedInput)) {
log.warn("Saved audio input device removed — falling back to default", { savedInput });
// Reset to default
savePref("audioInputDevice", "");
@@ -102,7 +102,7 @@ export class DeviceManager {
// Check output device
const outputDevices = await Room.getLocalDevices("audiooutput");
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput !== "" && !outputDevices.some(d => d.deviceId === savedOutput)) {
if (savedOutput !== "" && !outputDevices.some((d) => d.deviceId === savedOutput)) {
log.warn("Saved audio output device removed — falling back to default", { savedOutput });
savePref("audioOutputDevice", "");
this.onToast?.("Audio output device disconnected — switched to default");
+37 -17
View File
@@ -27,6 +27,7 @@ import {
addMember,
removeMember,
updateMemberRole,
updateMemberProfile,
updatePresence,
setTyping,
} from "@stores/members.store";
@@ -49,7 +50,7 @@ import {
} from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import type { DmChannelPayload } from "./types";
import { handleVoiceToken } from "@lib/livekitSession";
import { handleVoiceToken, isVoiceConnected } from "@lib/livekitSession";
import { notifyIncomingMessage } from "./notifications";
import { createLogger } from "./logger";
import { ServerMessageType as S } from "./protocolTypes";
@@ -87,12 +88,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on(S.AUTH_OK, (payload) => {
setAuth(
authStore.getState().token ?? "",
payload.user,
payload.server_name,
payload.motd,
);
setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd);
}),
);
@@ -113,6 +109,19 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
setMembers(payload.members);
setVoiceStates(payload.voice_states);
// Defense-in-depth: if the ready payload shows us in a voice channel
// but we have no LiveKit room connection (e.g. after F5 reload),
// send voice_leave to clean up the stale state. The server should
// have already cleaned this up, but this handles edge cases.
const currentUserId = authStore.getState().user?.id ?? 0;
const inVoicePerReady =
currentUserId !== 0 && payload.voice_states.some((vs) => vs.user_id === currentUserId);
if (inVoicePerReady && !isVoiceConnected()) {
log.warn("Stale voice state detected in ready payload — sending voice_leave");
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
}
// Auto-select the first text channel if none is active
const currentActive = channelsStore.select((s) => s.activeChannelId);
if (currentActive === null && payload.channels.length > 0) {
@@ -163,9 +172,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
user: payload.user.username,
});
addMessage(payload);
const activeId = channelsStore.select(
(s) => s.activeChannelId,
);
const activeId = channelsStore.select((s) => s.activeChannelId);
// Check if this is a DM channel and whether the message is from self.
const dmChannels = dmStore.getState().channels;
@@ -196,12 +203,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
payload.timestamp,
);
} else {
updateDmLastMessage(
payload.channel_id,
payload.id,
payload.content,
payload.timestamp,
);
updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp);
}
}
@@ -278,7 +280,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
const remaining = channelsStore.select((s) => s.channels);
const sorted = [...remaining.values()]
.filter((ch) => ch.type === "text")
.sort((a, b) => a.position - b.position);
.toSorted((a, b) => a.position - b.position);
const firstTextId = sorted.length > 0 ? sorted[0]!.id : null;
setActiveChannel(firstTextId);
log.info("Active channel deleted, redirected", { deletedId: payload.id });
@@ -316,6 +318,24 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
}),
);
unsubs.push(
ws.on(S.USER_UPDATE, (payload) => {
log.info("User profile updated", { userId: payload.user_id, username: payload.username });
updateMemberProfile(payload.user_id, payload.username, payload.avatar);
// Update auth store if the current user changed their own profile.
const currentUser = authStore.getState().user;
if (currentUser && payload.user_id === currentUser.id) {
setAuth(
authStore.getState().token ?? "",
{ ...currentUser, username: payload.username, avatar: payload.avatar },
authStore.getState().serverName ?? "",
authStore.getState().motd ?? "",
);
}
}),
);
// ── Voice ─────────────────────────────────────────────
unsubs.push(
+1 -4
View File
@@ -51,10 +51,7 @@ export function setText(el: Element, text: string): void {
/**
* Append multiple children to a parent element.
*/
export function appendChildren(
parent: Element,
...children: (Element | string)[]
): void {
export function appendChildren(parent: Element, ...children: (Element | string)[]): void {
for (const child of children) {
if (typeof child === "string") {
parent.appendChild(document.createTextNode(child));
+7 -8
View File
@@ -232,20 +232,19 @@ export function createIcon(name: IconName, size = 24): SVGSVGElement {
svg.setAttribute("data-icon", name);
svg.classList.add("icon");
// Safe: path data comes entirely from the static ICON_PATHS constant above,
// never from user-provided input.
svg.innerHTML = ICON_PATHS[name];
// INVARIANT: ICON_PATHS values are static SVG path strings from Lucide.
// They must NEVER contain user data or dynamically-loaded content.
// This is the only safe use of innerHTML in the codebase — do not copy this pattern.
const pathData = ICON_PATHS[name];
if (pathData === undefined) return svg;
svg.innerHTML = pathData;
return svg;
}
/** Create a signal-strength icon with per-bar coloring based on quality level.
* Bars are colored by the quality thresholds; unfilled bars use --bg-active. */
export function createSignalIcon(
barsLit: number,
color: string,
size = 16,
): SVGSVGElement {
export function createSignalIcon(barsLit: number, color: string, size = 16): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", String(size));
svg.setAttribute("height", String(size));
@@ -0,0 +1,184 @@
// LiveKit diagnostics — ICE connection logging and session debug info
import type { Room } from "livekit-client";
import { RoomEvent, Track } from "livekit-client";
import { createLogger } from "@lib/logger";
import { parseUserId } from "@lib/livekitSession";
import type { AudioPipeline } from "@lib/audioPipeline";
import type { AudioElements } from "@lib/audioElements";
const log = createLogger("livekitDiagnostics");
/** Attach lightweight diagnostic-only event listeners to a Room. */
export function attachDiagnosticListeners(room: Room): void {
room.on(RoomEvent.Reconnecting, () => {
log.warn("LiveKit room reconnecting");
});
room.on(RoomEvent.Reconnected, () => {
log.info("LiveKit room reconnected");
});
room.on(RoomEvent.SignalReconnecting, () => {
log.debug("LiveKit signal reconnecting");
});
room.on(RoomEvent.MediaDevicesError, (error: Error) => {
log.error("LiveKit media device error", { error: error.message });
});
room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
if (participant.isLocal) {
log.debug("Local connection quality changed", { quality });
}
});
}
// --- ICE helpers (no instance state) ---
/** Log ICE connection details for debugging cross-network voice issues. */
export function logIceConnectionInfo(room: Room | null): void {
if (room === null) return;
// Access the underlying RTCPeerConnection via LiveKit's engine.
// LiveKit exposes the PeerConnection via room.engine.subscriber/publisher.
try {
const engine = (room as unknown as Record<string, unknown>).engine as
| Record<string, unknown>
| undefined;
if (!engine) return;
const subscriber = engine.subscriber as Record<string, unknown> | undefined;
const publisher = engine.publisher as Record<string, unknown> | undefined;
const pcs: Array<{ label: string; pc: RTCPeerConnection }> = [];
if (subscriber?.pc) pcs.push({ label: "subscriber", pc: subscriber.pc as RTCPeerConnection });
if (publisher?.pc) pcs.push({ label: "publisher", pc: publisher.pc as RTCPeerConnection });
for (const { label, pc } of pcs) {
log.info(`ICE ${label} connection state`, {
iceConnectionState: pc.iceConnectionState,
iceGatheringState: pc.iceGatheringState,
connectionState: pc.connectionState,
signalingState: pc.signalingState,
});
// Log selected candidate pair
pc.getStats()
.then((stats) => {
stats.forEach((report) => {
if (report.type === "candidate-pair" && report.state === "succeeded") {
const localId = report.localCandidateId;
const remoteId = report.remoteCandidateId;
let localType = "unknown";
let remoteType = "unknown";
let localProtocol = "unknown";
stats.forEach((s) => {
if (s.id === localId && s.type === "local-candidate") {
localType = s.candidateType ?? "unknown";
localProtocol = s.protocol ?? "unknown";
}
if (s.id === remoteId && s.type === "remote-candidate") {
remoteType = s.candidateType ?? "unknown";
}
});
log.info(`ICE ${label} selected candidate pair`, {
localType,
remoteType,
localProtocol,
});
}
});
})
.catch((err) => {
log.debug("Failed to get ICE stats", { error: String(err) });
});
}
} catch (err) {
log.debug("Failed to access ICE connection info", { error: String(err) });
}
}
/** Get ICE connection state summary for debug panel. */
export function getIceConnectionState(room: Room | null): Record<string, unknown> | null {
if (room === null) return null;
try {
const engine = (room as unknown as Record<string, unknown>).engine as
| Record<string, unknown>
| undefined;
if (!engine) return null;
const subscriber = engine.subscriber as Record<string, unknown> | undefined;
const publisher = engine.publisher as Record<string, unknown> | undefined;
const result: Record<string, unknown> = {};
if (subscriber?.pc) {
const pc = subscriber.pc as RTCPeerConnection;
result.subscriber = {
iceConnectionState: pc.iceConnectionState,
connectionState: pc.connectionState,
};
}
if (publisher?.pc) {
const pc = publisher.pc as RTCPeerConnection;
result.publisher = {
iceConnectionState: pc.iceConnectionState,
connectionState: pc.connectionState,
};
}
return result;
} catch {
return null;
}
}
// --- Debug info ---
export interface SessionDebugDeps {
readonly room: Room | null;
readonly currentChannelId: number | null;
readonly outputVolumeMultiplier: number;
readonly audioPipeline: AudioPipeline;
readonly audioElements: AudioElements;
}
export function buildSessionDebugInfo(deps: SessionDebugDeps): Record<string, unknown> {
const { room, currentChannelId, outputVolumeMultiplier, audioPipeline, audioElements } = deps;
if (room === null) {
return { hasRoom: false, hasRNNoiseProcessor: false, currentChannelId };
}
const remoteParticipants = [...room.remoteParticipants.values()].map((p) => {
const userId = parseUserId(p.identity);
return {
identity: p.identity,
userId,
volume: p.getVolume(),
effectiveVolume: audioElements.getEffectiveVolume(userId),
tracks: [...p.trackPublications.values()].map((pub) => ({
sid: pub.trackSid,
source: pub.source,
kind: pub.kind,
subscribed: pub.isSubscribed,
enabled: pub.isEnabled,
})),
};
});
const localTracks = [...room.localParticipant.trackPublications.values()].map((pub) => ({
sid: pub.trackSid,
source: pub.source,
kind: pub.kind,
isMuted: pub.isMuted,
}));
return {
hasRoom: true,
roomName: room.name,
roomState: room.state,
hasRNNoiseProcessor:
room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track?.getProcessor() !==
undefined,
currentChannelId,
outputVolumeMultiplier,
audioPipelineActive: audioPipeline.isActive,
audioPipelineGain: audioPipeline.gainValue,
audioPipelineCtxState: audioPipeline.ctxState,
vadGated: audioPipeline.isVadGated,
currentInputGain: audioPipeline.inputGain,
localParticipant: room.localParticipant.identity,
localTracks,
remoteParticipants,
iceConnectionState: getIceConnectionState(room),
};
}
File diff suppressed because it is too large Load Diff
+8 -22
View File
@@ -5,14 +5,7 @@
// Rotation: keeps the most recent MAX_LOG_FILES days of logs.
import { appLogDir, join } from "@tauri-apps/api/path";
import {
mkdir,
writeTextFile,
readDir,
remove,
exists,
readTextFile,
} from "@tauri-apps/plugin-fs";
import { mkdir, writeTextFile, readDir, remove, exists, readTextFile } from "@tauri-apps/plugin-fs";
import { type LogEntry, addLogListener, createLogger } from "./logger";
const log = createLogger("logPersistence");
@@ -95,19 +88,14 @@ async function rotateOldFiles(): Promise<void> {
try {
const entries = await readDir(logDir);
const jsonlFiles = entries
.filter(
(e) =>
e.name?.endsWith(".jsonl") && !e.isDirectory,
)
.filter((e) => e.name?.endsWith(".jsonl") && !e.isDirectory)
.map((e) => e.name)
.sort();
.toSorted((a, b) => a.localeCompare(b));
if (jsonlFiles.length > MAX_LOG_FILES) {
const toRemove = jsonlFiles.slice(
0,
jsonlFiles.length - MAX_LOG_FILES,
);
const toRemove = jsonlFiles.slice(0, jsonlFiles.length - MAX_LOG_FILES);
for (const file of toRemove) {
// eslint-disable-next-line no-await-in-loop -- sequential file deletion to avoid overwhelming the filesystem
await remove(`${logDir}/${file}`);
}
}
@@ -193,15 +181,13 @@ export async function readAllPersistedLogs(): Promise<string> {
try {
const entries = await readDir(logDir);
const jsonlFiles = entries
.filter(
(e) =>
e.name?.endsWith(".jsonl") && !e.isDirectory,
)
.filter((e) => e.name?.endsWith(".jsonl") && !e.isDirectory)
.map((e) => e.name)
.sort();
.toSorted((a, b) => a.localeCompare(b));
const parts: string[] = [];
for (const file of jsonlFiles) {
// eslint-disable-next-line no-await-in-loop -- files must be read in sorted order for correct log concatenation
const content = await readTextFile(`${logDir}/${file}`);
parts.push(content);
}
+1 -3
View File
@@ -36,9 +36,7 @@ function serializeData(data: unknown): unknown {
if (typeof data === "object" && data !== null) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
result[key] = value instanceof Error
? { error: value.message, stack: value.stack }
: value;
result[key] = value instanceof Error ? { error: value.message, stack: value.stack } : value;
}
return result;
}
+14 -12
View File
@@ -68,9 +68,7 @@ export function createModal(
const overlay = createElement("div", overlayBaseAttrs);
// Build modal container
const modalClass = className !== undefined
? `modal ${className}`
: "modal";
const modalClass = className !== undefined ? `modal ${className}` : "modal";
const modal = createElement("div", { class: modalClass });
modal.appendChild(content);
overlay.appendChild(modal);
@@ -115,16 +113,20 @@ export function createModal(
// If an external signal is provided, clean up when it aborts
if (signal !== undefined) {
signal.addEventListener("abort", () => {
if (!closed) {
closed = true;
overlay.remove();
onClose?.();
if (!ac.signal.aborted) {
ac.abort();
signal.addEventListener(
"abort",
() => {
if (!closed) {
closed = true;
overlay.remove();
onClose?.();
if (!ac.signal.aborted) {
ac.abort();
}
}
}
}, { signal: ac.signal });
},
{ signal: ac.signal },
);
}
container.appendChild(overlay);
@@ -53,9 +53,11 @@ async function loadRNNoise(): Promise<RNNoiseModule> {
/** Check if AudioWorklet is available in this browser context. */
function supportsAudioWorklet(): boolean {
try {
return typeof AudioWorkletNode !== "undefined"
&& typeof AudioContext !== "undefined"
&& "audioWorklet" in AudioContext.prototype;
return (
typeof AudioWorkletNode !== "undefined" &&
typeof AudioContext !== "undefined" &&
"audioWorklet" in AudioContext.prototype
);
} catch {
return false;
}
@@ -88,11 +90,13 @@ async function createWorkletPipeline(
});
const initPromise = new Promise<void>((resolve, reject) => {
// oxlint-disable-next-line prefer-add-event-listener -- MessagePort does not support addEventListener
workletNode.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "ready") resolve();
else if (event.data.type === "error") reject(new Error(event.data.message));
};
});
// oxlint-disable-next-line require-post-message-target-origin -- MessagePort.postMessage, not Window.postMessage
workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]);
await initPromise;
@@ -104,6 +108,7 @@ async function createWorkletPipeline(
return {
processedTrack: dest.stream.getAudioTracks()[0]!,
destroy() {
// oxlint-disable-next-line require-post-message-target-origin -- MessagePort.postMessage, not Window.postMessage
workletNode.port.postMessage({ type: "destroy" });
workletNode.disconnect();
source.disconnect();
@@ -127,7 +132,7 @@ async function createScriptProcessorPipeline(
let inputRingOffset = 0;
const OUT_RING_CAPACITY = 50;
const outRing: Float32Array[] = new Array(OUT_RING_CAPACITY);
const outRing: Float32Array[] = Array.from({ length: OUT_RING_CAPACITY });
let outWriteIdx = 0;
let outReadIdx = 0;
let outCount = 0;
+22 -6
View File
@@ -52,10 +52,16 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
}
const channelName = getChannelName(payload.channel_id);
const title = `${payload.user.username} in #${channelName}`;
const body = payload.content.length > 100
? payload.content.slice(0, 100) + "..."
: payload.content;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function sanitizeNotif(s: string, maxLen: number): string {
// eslint-disable-next-line no-control-regex -- intentional: strip control chars from user-provided strings
const cleaned = s.replace(/[\x00-\x1F\x7F]/g, "");
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned;
}
const title = sanitizeNotif(`${payload.user.username} in #${channelName}`, 80);
const body = sanitizeNotif(payload.content, 100);
// Desktop notification
if (loadPref<boolean>("desktopNotifications", true)) {
@@ -93,11 +99,11 @@ function fireDesktopNotification(title: string, body: string): void {
// Fallback to Web Notification API (dev mode / non-Tauri)
try {
if (Notification.permission === "granted") {
new Notification(title, { body });
void new Notification(title, { body });
} else if (Notification.permission !== "denied") {
const result = await Notification.requestPermission();
if (result === "granted") {
new Notification(title, { body });
void new Notification(title, { body });
}
}
} catch {
@@ -123,6 +129,16 @@ function flashTaskbar(): void {
// Simple notification sound using Web Audio API
let notifAudioCtx: AudioContext | null = null;
/** Close and release the notification AudioContext. Call on logout/cleanup. */
export function cleanupNotificationAudio(): void {
if (notifAudioCtx !== null) {
notifAudioCtx.close().catch((err) => {
log.warn("Failed to close notification AudioContext", err);
});
notifAudioCtx = null;
}
}
/** Play a brief notification chime. */
function playNotificationSound(): void {
try {
+12 -4
View File
@@ -19,7 +19,11 @@ export function syncOsMotionListener(enabled: boolean): void {
const raw = localStorage.getItem("owncord:settings:reducedMotion");
let manual = false;
if (raw !== null) {
try { manual = JSON.parse(raw) === true; } catch { /* corrupted — default false */ }
try {
manual = JSON.parse(raw) === true;
} catch {
/* corrupted — default false */
}
}
document.documentElement.classList.toggle("reduced-motion", manual);
return;
@@ -28,7 +32,11 @@ export function syncOsMotionListener(enabled: boolean): void {
ac = new AbortController();
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
document.documentElement.classList.toggle("reduced-motion", mq.matches);
mq.addEventListener("change", (e: MediaQueryListEvent) => {
document.documentElement.classList.toggle("reduced-motion", e.matches);
}, { signal: ac.signal });
mq.addEventListener(
"change",
(e: MediaQueryListEvent) => {
document.documentElement.classList.toggle("reduced-motion", e.matches);
},
{ signal: ac.signal },
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Permission } from './types';
import { Permission } from "./types";
/** Bitmask with every permission bit set. */
const ALL_PERMISSIONS = 0x7FFFFFFF;
const ALL_PERMISSIONS = 0x7fffffff;
/**
* Returns true if `userPerms` includes the given permission bit.
+4 -6
View File
@@ -31,6 +31,7 @@ export const ServerMessageType = {
MEMBER_JOIN: "member_join",
MEMBER_LEAVE: "member_leave",
MEMBER_UPDATE: "member_update",
USER_UPDATE: "user_update",
MEMBER_BAN: "member_ban",
SERVER_RESTART: "server_restart",
ERROR: "error",
@@ -40,8 +41,7 @@ export const ServerMessageType = {
DM_CHANNEL_CLOSE: "dm_channel_close",
} as const;
export type ServerMessageTypeValue =
(typeof ServerMessageType)[keyof typeof ServerMessageType];
export type ServerMessageTypeValue = (typeof ServerMessageType)[keyof typeof ServerMessageType];
// ---------------------------------------------------------------------------
// Client → Server message types
@@ -68,8 +68,7 @@ export const ClientMessageType = {
VOICE_TOKEN_REFRESH: "voice_token_refresh",
} as const;
export type ClientMessageTypeValue =
(typeof ClientMessageType)[keyof typeof ClientMessageType];
export type ClientMessageTypeValue = (typeof ClientMessageType)[keyof typeof ClientMessageType];
// ---------------------------------------------------------------------------
// Unified MessageType — all message types in one object for convenience
@@ -80,5 +79,4 @@ export const MessageType = {
...ClientMessageType,
} as const;
export type MessageTypeValue =
(typeof MessageType)[keyof typeof MessageType];
export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType];
+57 -16
View File
@@ -12,23 +12,57 @@ import { createLogger } from "./logger";
const log = createLogger("ptt");
let listening = false;
let pttUnsubscribe: (() => void) | null = null;
// Well-known virtual key code names for display
const VK_NAMES: ReadonlyMap<number, string> = new Map([
[0x01, "Mouse Left"], [0x02, "Mouse Right"], [0x04, "Mouse Middle"],
[0x05, "Mouse 4"], [0x06, "Mouse 5"],
[0x08, "Backspace"], [0x09, "Tab"], [0x0D, "Enter"], [0x1B, "Escape"],
[0x20, "Space"], [0x21, "Page Up"], [0x22, "Page Down"],
[0x23, "End"], [0x24, "Home"],
[0x25, "Arrow Left"], [0x26, "Arrow Up"], [0x27, "Arrow Right"], [0x28, "Arrow Down"],
[0x2D, "Insert"], [0x2E, "Delete"],
[0x70, "F1"], [0x71, "F2"], [0x72, "F3"], [0x73, "F4"],
[0x74, "F5"], [0x75, "F6"], [0x76, "F7"], [0x77, "F8"],
[0x78, "F9"], [0x79, "F10"], [0x7A, "F11"], [0x7B, "F12"],
[0x7C, "F13"], [0x7D, "F14"], [0x7E, "F15"], [0x7F, "F16"],
[0xC0, "`"], [0xBD, "-"], [0xBB, "="],
[0xDB, "["], [0xDD, "]"], [0xDC, "\\"],
[0xBA, ";"], [0xDE, "'"], [0xBC, ","], [0xBE, "."], [0xBF, "/"],
[0x01, "Mouse Left"],
[0x02, "Mouse Right"],
[0x04, "Mouse Middle"],
[0x05, "Mouse 4"],
[0x06, "Mouse 5"],
[0x08, "Backspace"],
[0x09, "Tab"],
[0x0d, "Enter"],
[0x1b, "Escape"],
[0x20, "Space"],
[0x21, "Page Up"],
[0x22, "Page Down"],
[0x23, "End"],
[0x24, "Home"],
[0x25, "Arrow Left"],
[0x26, "Arrow Up"],
[0x27, "Arrow Right"],
[0x28, "Arrow Down"],
[0x2d, "Insert"],
[0x2e, "Delete"],
[0x70, "F1"],
[0x71, "F2"],
[0x72, "F3"],
[0x73, "F4"],
[0x74, "F5"],
[0x75, "F6"],
[0x76, "F7"],
[0x77, "F8"],
[0x78, "F9"],
[0x79, "F10"],
[0x7a, "F11"],
[0x7b, "F12"],
[0x7c, "F13"],
[0x7d, "F14"],
[0x7e, "F15"],
[0x7f, "F16"],
[0xc0, "`"],
[0xbd, "-"],
[0xbb, "="],
[0xdb, "["],
[0xdd, "]"],
[0xdc, "\\"],
[0xba, ";"],
[0xde, "'"],
[0xbc, ","],
[0xbe, "."],
[0xbf, "/"],
]);
/** Get a human-readable name for a virtual key code. */
@@ -37,7 +71,7 @@ export function vkName(vk: number): string {
// 0-9 keys
if (vk >= 0x30 && vk <= 0x39) return String.fromCharCode(vk);
// A-Z keys
if (vk >= 0x41 && vk <= 0x5A) return String.fromCharCode(vk);
if (vk >= 0x41 && vk <= 0x5a) return String.fromCharCode(vk);
// Numpad 0-9
if (vk >= 0x60 && vk <= 0x69) return `Numpad ${vk - 0x60}`;
return `Key 0x${vk.toString(16).toUpperCase()}`;
@@ -56,8 +90,12 @@ export async function initPtt(): Promise<void> {
await invoke("ptt_set_key", { vkCode: vk });
await invoke("ptt_start");
// Clean up previous listener if any
pttUnsubscribe?.();
pttUnsubscribe = null;
// Listen for press/release events
await listen<boolean>("ptt-state", (event) => {
const unsub = await listen<boolean>("ptt-state", (event) => {
// Only toggle mute when in a voice channel
const channelId = voiceStore.getState().currentChannelId;
if (channelId === null) return;
@@ -65,6 +103,7 @@ export async function initPtt(): Promise<void> {
setMuted(!event.payload);
log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted");
});
pttUnsubscribe = unsub;
listening = true;
log.info("PTT started", { vk, name: vkName(vk) });
@@ -78,6 +117,8 @@ export async function initPtt(): Promise<void> {
export async function stopPtt(): Promise<void> {
if (!listening) return;
try {
pttUnsubscribe?.();
pttUnsubscribe = null;
const { invoke } = await import("@tauri-apps/api/core");
await invoke("ptt_stop");
listening = false;
@@ -169,11 +169,6 @@ export function createVideoCameraLimiter(): RateLimiter {
return createRateLimiter(2, 1_000);
}
/** Soundboard: 1 per 3 seconds. */
export function createSoundboardLimiter(): RateLimiter {
return createRateLimiter(1, 3_000);
}
// ---------------------------------------------------------------------------
// Bundled set of all protocol limiters
// ---------------------------------------------------------------------------
@@ -185,7 +180,6 @@ export interface RateLimiterSet {
readonly reactions: RateLimiter;
readonly voice: RateLimiter;
readonly voiceVideo: RateLimiter;
readonly soundboard: RateLimiter;
}
export function createRateLimiterSet(): RateLimiterSet {
@@ -196,6 +190,5 @@ export function createRateLimiterSet(): RateLimiterSet {
reactions: createReactionLimiter(),
voice: createVoiceLimiter(),
voiceVideo: createVideoCameraLimiter(),
soundboard: createSoundboardLimiter(),
});
}
@@ -0,0 +1,207 @@
// LiveKit room event handler factories — extracted from livekitSession.ts
import {
Track,
type RemoteTrack,
type RemoteTrackPublication,
type RemoteParticipant,
type Participant,
type LocalTrackPublication,
DisconnectReason,
} from "livekit-client";
import { voiceStore, setSpeakers, leaveVoiceChannel } from "@stores/voice.store";
import { createLogger } from "@lib/logger";
import { parseUserId } from "@lib/livekitSession";
import type { AudioElements } from "@lib/audioElements";
const log = createLogger("roomEventHandlers");
// --- Callback types ---
type RemoteVideoCallback = (userId: number, stream: MediaStream, isScreenshare: boolean) => void;
type RemoteVideoRemovedCallback = (userId: number, isScreenshare: boolean) => void;
// --- Dependencies passed from LiveKitSession ---
export interface RoomEventDeps {
getRoom: () => import("livekit-client").Room | null;
setRoom: (room: import("livekit-client").Room | null) => void;
getCurrentChannelId: () => number | null;
getAudioElements: () => AudioElements;
getOnRemoteVideoCallback: () => RemoteVideoCallback | null;
getOnRemoteVideoRemovedCallback: () => RemoteVideoRemovedCallback | null;
getOnErrorCallback: () => ((message: string) => void) | null;
isConnecting: () => boolean;
getLatestToken: () => string | null;
getLastUrl: () => string | null;
getLastDirectUrl: () => string | undefined;
setReconnectAc: (ac: AbortController | null) => void;
syncModuleRooms: () => void;
teardownForReconnect: () => void;
leaveVoice: (sendWs: boolean) => void;
applyMicMuteState: (muted: boolean) => Promise<void>;
attemptAutoReconnect: (
token: string,
url: string,
channelId: number,
directUrl: string | undefined,
signal: AbortSignal,
) => Promise<void>;
}
// --- Factory: creates bound event handler arrow functions ---
export interface RoomEventHandlers {
readonly handleLocalTrackPublished: (publication: LocalTrackPublication) => void;
readonly handleTrackSubscribed: (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
) => void;
readonly handleTrackUnsubscribed: (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
) => void;
readonly handleActiveSpeakersChanged: (speakers: Participant[]) => void;
readonly handleAudioPlaybackChanged: () => void;
readonly handleDisconnected: (reason?: DisconnectReason) => void;
readonly removeAutoplayUnlock: () => void;
}
export function createRoomEventHandlers(deps: RoomEventDeps): RoomEventHandlers {
let autoplayUnlockHandler: (() => void) | null = null;
function removeAutoplayUnlock(): void {
if (autoplayUnlockHandler !== null) {
document.removeEventListener("click", autoplayUnlockHandler);
autoplayUnlockHandler = null;
}
}
const handleLocalTrackPublished = (publication: LocalTrackPublication): void => {
if (publication.source === Track.Source.Microphone) {
const { localMuted, localDeafened } = voiceStore.getState();
if (localMuted || localDeafened) {
deps.applyMicMuteState(true).catch((e) => log.warn("applyMicMuteState failed", e));
log.debug("LocalTrackPublished: re-applied mute to mic track");
}
}
};
const handleTrackSubscribed = (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void => {
const userId = parseUserId(participant.identity);
if (track.kind === Track.Kind.Audio) {
deps.getAudioElements().handleTrackSubscribedAudio(track, publication, participant);
} else if (track.kind === Track.Kind.Video) {
const cb = deps.getOnRemoteVideoCallback();
if (userId > 0 && cb !== null) {
const stream = new MediaStream([track.mediaStreamTrack]);
const isScreenshare = publication.source === Track.Source.ScreenShare;
cb(userId, stream, isScreenshare);
}
log.debug("Remote video track subscribed", { userId, trackSid: track.sid });
}
};
const handleTrackUnsubscribed = (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void => {
const userId = parseUserId(participant.identity);
if (track.kind === Track.Kind.Audio) {
deps.getAudioElements().handleTrackUnsubscribedAudio(track, publication, participant);
} else if (track.kind === Track.Kind.Video) {
track.detach();
const isScreenshare = publication.source === Track.Source.ScreenShare;
if (userId > 0) deps.getOnRemoteVideoRemovedCallback()?.(userId, isScreenshare);
log.debug("Remote video track unsubscribed", { userId, trackSid: track.sid });
}
};
const handleActiveSpeakersChanged = (speakers: Participant[]): void => {
const channelId = deps.getCurrentChannelId();
if (channelId === null) return;
const speakerIds: number[] = [];
for (const speaker of speakers) {
const userId = parseUserId(speaker.identity);
if (userId > 0) speakerIds.push(userId);
}
speakerIds.sort((x, y) => x - y);
setSpeakers({ channel_id: channelId, speakers: speakerIds });
};
const handleAudioPlaybackChanged = (): void => {
const room = deps.getRoom();
if (room === null) return;
if (room.canPlaybackAudio) {
log.info("Audio playback is now allowed");
removeAutoplayUnlock();
return;
}
log.warn("Audio playback blocked by browser — registering click-to-unlock");
removeAutoplayUnlock();
autoplayUnlockHandler = () => {
const r = deps.getRoom();
if (r !== null) {
void r.startAudio().then(() => {
log.info("Audio playback unlocked via user gesture");
});
}
removeAutoplayUnlock();
};
document.addEventListener("click", autoplayUnlockHandler, { once: true });
};
const handleDisconnected = (reason?: DisconnectReason): void => {
log.info("LiveKit room disconnected", { reason });
if (deps.isConnecting()) {
log.info("Disconnect during initial connect — deferring to retry loop");
return;
}
const isUnexpected = reason !== DisconnectReason.CLIENT_INITIATED;
if (
isUnexpected &&
deps.getLatestToken() !== null &&
deps.getCurrentChannelId() !== null &&
deps.getLastUrl() !== null
) {
const token = deps.getLatestToken()!;
const url = deps.getLastUrl()!;
const channelId = deps.getCurrentChannelId()!;
const directUrl = deps.getLastDirectUrl();
// Clean up current room without sending WS leave (we're reconnecting, not leaving).
deps.teardownForReconnect();
removeAutoplayUnlock();
deps.getAudioElements().cleanupAllAudioElements();
const room = deps.getRoom();
if (room !== null) {
deps.setRoom(null);
deps.syncModuleRooms();
room.removeAllListeners();
room.disconnect().catch((err) => log.warn("Failed to disconnect stale room", err));
}
const ac = new AbortController();
deps.setReconnectAc(ac);
void deps.attemptAutoReconnect(token, url, channelId, directUrl, ac.signal);
return;
}
deps.leaveVoice(false);
leaveVoiceChannel();
if (isUnexpected) deps.getOnErrorCallback()?.("Voice connection lost — disconnected");
};
return {
handleLocalTrackPublished,
handleTrackSubscribed,
handleTrackUnsubscribed,
handleActiveSpeakersChanged,
handleAudioPlaybackChanged,
handleDisconnected,
removeAutoplayUnlock,
};
}
+2 -5
View File
@@ -16,10 +16,7 @@ export interface MountableComponent {
* Safely mount a component, catching any errors during rendering.
* On failure, displays a fallback UI instead of crashing the app.
*/
export function safeMount(
component: MountableComponent,
container: Element,
): void {
export function safeMount(component: MountableComponent, container: Element): void {
try {
component.mount(container);
} catch (err) {
@@ -67,7 +64,7 @@ export function installGlobalErrorHandlers(): void {
window.addEventListener("unhandledrejection", (event) => {
const reason =
event.reason instanceof Error
? event.reason.stack ?? event.reason.message
? (event.reason.stack ?? event.reason.message)
: String(event.reason);
// Tauri plugin-http GC cleanup: when a consumed Response body is finalized,
+313
View File
@@ -0,0 +1,313 @@
/**
* Screen share and camera track management — extracted from livekitSession.ts.
*
* Provides stream quality presets and functions for publishing/unpublishing
* local camera and screenshare tracks via a LiveKit Room.
*/
import {
Track,
VideoPresets,
ScreenSharePresets,
createLocalScreenTracks,
createLocalVideoTrack,
type Room,
type LocalVideoTrack,
type LocalTrack,
type VideoCaptureOptions,
type ScreenShareCaptureOptions,
} from "livekit-client";
import type { WsClient } from "@lib/ws";
import { setLocalCamera, setLocalScreenshare } from "@stores/voice.store";
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
const log = createLogger("screenShare");
// ---------------------------------------------------------------------------
// Stream quality presets
// ---------------------------------------------------------------------------
export type StreamQuality = "low" | "medium" | "high" | "source";
export const CAMERA_PRESETS: Record<StreamQuality, VideoCaptureOptions> = {
low: { resolution: VideoPresets.h360.resolution },
medium: { resolution: VideoPresets.h720.resolution },
high: { resolution: VideoPresets.h1080.resolution },
source: { resolution: VideoPresets.h1080.resolution },
};
export const CAMERA_PUBLISH_BITRATES: Record<StreamQuality, number> = {
low: 600_000,
medium: 1_700_000,
high: 4_000_000,
source: 8_000_000,
};
export const SCREENSHARE_PRESETS: Record<StreamQuality, ScreenShareCaptureOptions> = {
low: { audio: true, resolution: ScreenSharePresets.h720fps5.resolution },
medium: {
audio: true,
resolution: ScreenSharePresets.h1080fps15.resolution,
contentHint: "detail",
},
high: {
audio: true,
resolution: ScreenSharePresets.h1080fps30.resolution,
contentHint: "detail",
},
source: { audio: true, contentHint: "detail" }, // no resolution cap — use native source resolution
};
export const SCREENSHARE_PUBLISH_BITRATES: Record<StreamQuality, number> = {
low: 1_500_000,
medium: 3_000_000,
high: 6_000_000,
source: 10_000_000,
};
export function getStreamQuality(): StreamQuality {
const saved = loadPref<string>("streamQuality", "high");
if (saved === "low" || saved === "medium" || saved === "high" || saved === "source") return saved;
return "high";
}
// ---------------------------------------------------------------------------
// Dependencies injected by the caller (LiveKitSession)
// ---------------------------------------------------------------------------
export interface VideoTrackDeps {
readonly getRoom: () => Room | null;
readonly getWs: () => WsClient | null;
readonly onError: (message: string) => void;
/** Called after publishing a track to re-apply the audio pipeline. */
readonly reapplyAudioPipeline: () => void;
}
// ---------------------------------------------------------------------------
// Camera track state
// ---------------------------------------------------------------------------
/** Mutable state for the manually published camera track. */
export interface CameraTrackState {
manualCameraTrack: LocalVideoTrack | null;
}
export function stopManualCameraTrack(state: CameraTrackState, room: Room | null): void {
if (state.manualCameraTrack === null || room === null) return;
const track = state.manualCameraTrack;
state.manualCameraTrack = null;
try {
void room.localParticipant.unpublishTrack(track.mediaStreamTrack);
} catch {
/* already unpublished */
}
track.stop();
}
export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps): Promise<void> {
const room = deps.getRoom();
const ws = deps.getWs();
if (room === null || ws === null) {
log.warn("Cannot enable camera: no active voice session");
deps.onError("Join a voice channel first");
return;
}
setLocalCamera(true);
const quality = getStreamQuality();
try {
const savedVideoDevice = loadPref<string>("videoInputDevice", "");
stopManualCameraTrack(state, room);
const videoTrack = await createLocalVideoTrack({
...CAMERA_PRESETS[quality],
...(savedVideoDevice ? { deviceId: savedVideoDevice } : {}),
});
state.manualCameraTrack = videoTrack;
await room.localParticipant.publishTrack(videoTrack, {
source: Track.Source.Camera,
simulcast: quality !== "source",
videoEncoding: {
maxBitrate: CAMERA_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 15 : 30,
},
});
ws.send({ type: "voice_camera", payload: { enabled: true } });
deps.reapplyAudioPipeline();
log.info("Camera enabled", { quality, maxBitrate: CAMERA_PUBLISH_BITRATES[quality] });
} catch (err) {
// BUG-100: Stop the created track to release the camera if publish failed.
if (state.manualCameraTrack !== null) {
state.manualCameraTrack.stop();
state.manualCameraTrack = null;
}
setLocalCamera(false);
log.error("Failed to enable camera", err);
if (err instanceof DOMException && err.name === "NotAllowedError") {
deps.onError("Camera permission denied");
} else if (err instanceof DOMException && err.name === "NotFoundError") {
deps.onError("No camera found");
} else {
deps.onError("Failed to start camera");
}
}
}
export async function disableCamera(state: CameraTrackState, deps: VideoTrackDeps): Promise<void> {
const room = deps.getRoom();
try {
stopManualCameraTrack(state, room);
if (room !== null) await room.localParticipant.setCameraEnabled(false);
} catch (err) {
log.warn("Failed to disable camera track (non-fatal)", err);
} finally {
setLocalCamera(false);
const ws = deps.getWs();
if (ws !== null) ws.send({ type: "voice_camera", payload: { enabled: false } });
log.info("Camera disabled");
}
}
// ---------------------------------------------------------------------------
// Screenshare track state
// ---------------------------------------------------------------------------
/** Mutable state for the manually published screenshare tracks. */
export interface ScreenTrackState {
manualScreenTracks: LocalTrack[];
}
export function stopManualScreenTracks(state: ScreenTrackState, room: Room | null): void {
if (state.manualScreenTracks.length === 0 || room === null) return;
const tracks = state.manualScreenTracks;
state.manualScreenTracks = [];
for (const track of tracks) {
try {
void room.localParticipant.unpublishTrack(track.mediaStreamTrack);
} catch {
/* already unpublished */
}
track.stop();
}
}
export async function enableScreenshare(
state: ScreenTrackState,
deps: VideoTrackDeps,
): Promise<void> {
const room = deps.getRoom();
const ws = deps.getWs();
if (room === null || ws === null) {
log.warn("Cannot enable screenshare: no active voice session");
deps.onError("Join a voice channel first");
return;
}
setLocalScreenshare(true);
const quality = getStreamQuality();
try {
stopManualScreenTracks(state, room);
const screenTracks = await createLocalScreenTracks(SCREENSHARE_PRESETS[quality]);
state.manualScreenTracks = screenTracks;
for (const track of screenTracks) {
const isVideo = track.kind === Track.Kind.Video;
// eslint-disable-next-line no-await-in-loop -- tracks must be published sequentially to maintain correct order
await room.localParticipant.publishTrack(track, {
source: isVideo ? Track.Source.ScreenShare : Track.Source.ScreenShareAudio,
simulcast: false,
...(isVideo
? {
videoEncoding: {
maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30,
},
}
: {}),
});
}
// BUG-101: Listen for OS "Stop sharing" so the app runs the full disable path.
const videoTrack = screenTracks.find((t) => t.kind === Track.Kind.Video);
if (videoTrack) {
videoTrack.mediaStreamTrack.addEventListener(
"ended",
() => {
log.info("Screen track ended externally (OS stop-sharing)");
void disableScreenshare(state, deps);
},
{ once: true },
);
}
ws.send({ type: "voice_screenshare", payload: { enabled: true } });
deps.reapplyAudioPipeline();
log.info("Screenshare enabled", { quality, maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality] });
} catch (err) {
// BUG-100: Stop created tracks to release screen capture if publish failed.
for (const t of state.manualScreenTracks) {
t.stop();
}
state.manualScreenTracks = [];
setLocalScreenshare(false);
log.error("Failed to enable screenshare", err);
if (err instanceof DOMException && err.name === "NotAllowedError") {
deps.onError("Screen sharing permission denied");
} else {
deps.onError("Failed to start screen sharing");
}
}
}
export async function disableScreenshare(
state: ScreenTrackState,
deps: VideoTrackDeps,
): Promise<void> {
const room = deps.getRoom();
try {
stopManualScreenTracks(state, room);
if (room !== null) await room.localParticipant.setScreenShareEnabled(false);
} catch (err) {
log.warn("Failed to disable screenshare track (non-fatal)", err);
} finally {
setLocalScreenshare(false);
const ws = deps.getWs();
if (ws !== null) ws.send({ type: "voice_screenshare", payload: { enabled: false } });
log.info("Screenshare disabled");
}
}
// ---------------------------------------------------------------------------
// Stream getters
// ---------------------------------------------------------------------------
export function getLocalCameraStream(room: Room | null): MediaStream | null {
if (room === null) return null;
const cameraPub = room.localParticipant.getTrackPublication(Track.Source.Camera);
if (cameraPub?.track?.mediaStreamTrack)
return new MediaStream([cameraPub.track.mediaStreamTrack]);
return null;
}
export function getLocalScreenshareStream(room: Room | null): MediaStream | null {
if (room === null) return null;
const screenPub = room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack)
return new MediaStream([screenPub.track.mediaStreamTrack]);
return null;
}
export function getRemoteVideoStream(
room: Room | null,
userId: number,
type: "camera" | "screenshare",
): MediaStream | null {
if (room === null) return null;
const source = type === "screenshare" ? Track.Source.ScreenShare : Track.Source.Camera;
// Iterate remote participants — identity may include a ":token" suffix
// (e.g. "user-42:abc123") so exact getParticipantByIdentity won't match.
for (const participant of room.remoteParticipants.values()) {
const match = participant.identity.match(/^user-(\d+)(?::|$)/);
if (match !== null && parseInt(match[1]!, 10) === userId) {
const pub = participant.getTrackPublication(source);
if (pub?.track?.mediaStreamTrack) return new MediaStream([pub.track.mediaStreamTrack]);
return null;
}
}
return null;
}
+25 -2
View File
@@ -104,18 +104,41 @@ export function createStore<T>(initialState: T): Store<T> {
const listeners: Set<(state: T) => void> = new Set();
let notifyScheduled = false;
/** Re-entrancy guard: true while a subscriber notification is running. */
let updating = false;
/** Updaters queued by re-entrant setState calls during notification. */
const pendingUpdates: Array<(prev: T) => T> = [];
function getState(): T {
return state;
}
function setState(updater: (prev: T) => T): void {
if (updating) {
// Re-entrant call from within a subscriber — queue for later.
pendingUpdates.push(updater);
return;
}
state = updater(state);
if (!notifyScheduled) {
notifyScheduled = true;
queueMicrotask(() => {
notifyScheduled = false;
for (const listener of listeners) {
listener(state);
updating = true;
try {
for (const listener of listeners) {
listener(state);
}
// Drain any updates queued by re-entrant setState during notification.
while (pendingUpdates.length > 0) {
const queued = pendingUpdates.shift()!;
state = queued(state);
for (const listener of listeners) {
listener(state);
}
}
} finally {
updating = false;
}
});
}
+57 -21
View File
@@ -12,6 +12,7 @@
import { createElement } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { getRemoteVideoStream } from "@lib/livekitSession";
import { voiceStore } from "@stores/voice.store";
/** Internal state tracked per voice-user-item row for cleanup. */
interface PreviewState {
@@ -118,15 +119,24 @@ function showPreview(
}
previewDiv.appendChild(video);
} else {
previewDiv.appendChild(createPlaceholder(onClickJoin));
const isInChannel = voiceStore.getState().currentChannelId !== null;
if (isInChannel) {
previewDiv.appendChild(createUnavailablePlaceholder(onClickWatch));
} else {
previewDiv.appendChild(createPlaceholder(onClickJoin));
}
}
// Screen reader announcement
const announcement = createElement("span", {
role: "status",
"aria-live": "polite",
class: "sr-only",
}, `Showing stream preview for ${username}`);
const announcement = createElement(
"span",
{
role: "status",
"aria-live": "polite",
class: "sr-only",
},
`Showing stream preview for ${username}`,
);
previewDiv.appendChild(announcement);
// Close when mouse leaves the preview div (but not if moving back to row)
@@ -165,6 +175,26 @@ function createPlaceholder(onClickJoin?: () => void): HTMLElement {
return placeholder;
}
function createUnavailablePlaceholder(onClickWatch?: () => void): HTMLElement {
const placeholder = createElement("div", {
class: "vu-preview-placeholder",
role: "button",
"aria-label": "Stream unavailable",
});
const icon = createIcon("monitor-off", 14);
icon.style.color = "var(--text-faint)";
placeholder.appendChild(icon);
const text = createElement("span", {}, "Stream unavailable");
placeholder.appendChild(text);
if (onClickWatch !== undefined) {
placeholder.addEventListener("click", (e) => {
e.stopPropagation();
onClickWatch();
});
}
return placeholder;
}
function hidePreview(row: HTMLElement): void {
const state = previewTimers.get(row);
if (state !== undefined) {
@@ -177,7 +207,8 @@ function hidePreview(row: HTMLElement): void {
// Preview is a sibling after the row
const next = row.nextElementSibling;
const previewDiv = (next !== null && next.classList.contains("vu-preview")) ? next as HTMLElement : null;
const previewDiv =
next !== null && next.classList.contains("vu-preview") ? (next as HTMLElement) : null;
if (previewDiv === null) {
previewTimers.delete(row);
return;
@@ -236,7 +267,11 @@ export function attachStreamPreview(
state.animation = window.setTimeout(() => {
// Check if mouse is now over the preview sibling
const preview = row.nextElementSibling;
if (preview !== null && preview.classList.contains("vu-preview") && preview.matches(":hover")) {
if (
preview !== null &&
preview.classList.contains("vu-preview") &&
preview.matches(":hover")
) {
return; // Mouse moved to preview — keep it open
}
hidePreview(row);
@@ -266,18 +301,19 @@ export function attachStreamPreview(
* any open previews when the user scrolls. WebView2 doesn't always
* fire mouseleave on scroll.
*/
export function attachScrollCollapse(
container: HTMLElement,
signal: AbortSignal,
): void {
container.addEventListener("scroll", () => {
const openPreviews = container.querySelectorAll<HTMLElement>(".vu-preview");
for (const preview of openPreviews) {
// Preview is a sibling after the row — get the preceding voice-user-item
const row = preview.previousElementSibling;
if (row !== null && row.classList.contains("voice-user-item")) {
hidePreview(row as HTMLElement);
export function attachScrollCollapse(container: HTMLElement, signal: AbortSignal): void {
container.addEventListener(
"scroll",
() => {
const openPreviews = container.querySelectorAll<HTMLElement>(".vu-preview");
for (const preview of openPreviews) {
// Preview is a sibling after the row — get the preceding voice-user-item
const row = preview.previousElementSibling;
if (row !== null && row.classList.contains("voice-user-item")) {
hidePreview(row as HTMLElement);
}
}
}
}, { signal, passive: true });
},
{ signal, passive: true },
);
}

Some files were not shown because too many files have changed in this diff Show More