mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Merge branch 'main' of https://github.com/J3vb/OwnCord
# Conflicts: # README.md
This commit is contained in:
+71
-13
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
src-tauri/
|
||||
node_modules/
|
||||
public/
|
||||
coverage/
|
||||
*.html
|
||||
@@ -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
|
||||
}
|
||||
Generated
+3789
-2
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+324
-2
@@ -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"
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -57,9 +57,14 @@ pub fn ptt_stop() {
|
||||
}
|
||||
|
||||
/// Set the PTT virtual key code. Pass 0 to disable.
|
||||
/// Valid range: 0 (disabled) or 1–254 (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}"))?;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user