mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in OwnCord
|
||||
title: "bug: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Clear description of the bug -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- What should happen -->
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<!-- What actually happens -->
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS**: Windows 11 (version)
|
||||
- **OwnCord Version**:
|
||||
- **Component**: Server / Client / Both
|
||||
|
||||
## Screenshots / Logs
|
||||
|
||||
<!-- Paste relevant logs or screenshots -->
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for OwnCord
|
||||
title: "feat: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
<!-- What problem does this solve? -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!-- How should it work? -->
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
<!-- Other approaches you thought about -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Mockups, links, or related issues -->
|
||||
@@ -0,0 +1,27 @@
|
||||
# Pull Request
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? 1-3 bullet points -->
|
||||
|
||||
-
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- List the key changes made -->
|
||||
|
||||
-
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit tests pass (`npm test` / `go test ./...`)
|
||||
- [ ] TypeScript check passes (`npx tsc --noEmit`)
|
||||
- [ ] Manual testing done (describe below)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If UI changes, add before/after screenshots -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link any related issues: Fixes #123, Relates to #456 -->
|
||||
@@ -0,0 +1,54 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Go server dependencies
|
||||
- package-ecosystem: gomod
|
||||
directory: /Server
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- go
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Tauri client npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /Client/tauri-client
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- npm
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Tauri Rust/Cargo dependencies
|
||||
- package-ecosystem: cargo
|
||||
directory: /Client/tauri-client/src-tauri
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- rust
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "ci(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,110 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
server-build-test:
|
||||
name: Server Build & Test
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Server/
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- 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: Upload Go coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: go-coverage
|
||||
path: Server/coverage.out
|
||||
retention-days: 7
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.11.3
|
||||
working-directory: Server/
|
||||
|
||||
client-check:
|
||||
name: Client Typecheck & Test
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: TypeScript check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: npx vitest run --coverage --reporter=default
|
||||
|
||||
- name: Upload client coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: client-coverage
|
||||
path: Client/tauri-client/coverage/
|
||||
retention-days: 7
|
||||
|
||||
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
|
||||
tauri-build:
|
||||
name: Tauri Full Build
|
||||
needs: client-check
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri build
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
- name: Extract version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build server
|
||||
shell: bash
|
||||
run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" .
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: Client/tauri-client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Locate artifacts
|
||||
id: artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis"
|
||||
INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1)
|
||||
echo "installer_path=$INSTALLER" >> "$GITHUB_OUTPUT"
|
||||
echo "installer_name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT"
|
||||
# Updater artifacts (produced when TAURI_SIGNING_PRIVATE_KEY is set)
|
||||
NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1)
|
||||
NSIS_SIG=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip.sig" | head -1)
|
||||
echo "nsis_zip=${NSIS_ZIP:-}" >> "$GITHUB_OUTPUT"
|
||||
echo "nsis_sig=${NSIS_SIG:-}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
shell: pwsh
|
||||
run: |
|
||||
$lines = @()
|
||||
$serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$serverHash chatserver.exe"
|
||||
$installerPath = "${{ steps.artifacts.outputs.installer_path }}"
|
||||
$installerName = "${{ steps.artifacts.outputs.installer_name }}"
|
||||
$clientHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$clientHash $installerName"
|
||||
$nsisZip = "${{ steps.artifacts.outputs.nsis_zip }}"
|
||||
if ($nsisZip -and (Test-Path $nsisZip)) {
|
||||
$zipName = Split-Path $nsisZip -Leaf
|
||||
$zipHash = (Get-FileHash -Path $nsisZip -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$zipHash $zipName"
|
||||
}
|
||||
$lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
ASSETS=(
|
||||
Server/chatserver.exe
|
||||
"${{ steps.artifacts.outputs.installer_path }}"
|
||||
checksums.sha256
|
||||
)
|
||||
# Include updater artifacts if signing key was available
|
||||
if [ -n "${{ steps.artifacts.outputs.nsis_zip }}" ]; then
|
||||
ASSETS+=("${{ steps.artifacts.outputs.nsis_zip }}")
|
||||
fi
|
||||
if [ -n "${{ steps.artifacts.outputs.nsis_sig }}" ]; then
|
||||
ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}")
|
||||
fi
|
||||
gh release create ${{ github.ref_name }} \
|
||||
--generate-notes \
|
||||
"${ASSETS[@]}"
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Claude Code local config
|
||||
.claude/
|
||||
Client/.claude/
|
||||
Server/.claude/
|
||||
|
||||
# Claude Code skills
|
||||
skills/
|
||||
|
||||
# AI-specific / internal planning docs
|
||||
Obsidian-Brain/
|
||||
IMPROVEMENTS.md
|
||||
STOAT-RESEARCH.md
|
||||
PROMPTS.md
|
||||
SKILL.md
|
||||
AUDIT.md
|
||||
LANGUAGE-REVIEW.md
|
||||
MIGRATION-PLAN.md
|
||||
TESTING-STRATEGY.md
|
||||
CLIENT-ARCHITECTURE.md
|
||||
docs/superpowers/
|
||||
docs/brain/
|
||||
# Server runtime artifacts
|
||||
Server/chatserver.exe
|
||||
Server/chatserver.exe~
|
||||
Server/config.yaml
|
||||
Server/data/
|
||||
|
||||
# Test coverage artifacts
|
||||
*.out
|
||||
Server/cov.out
|
||||
Server/cover.out
|
||||
Server/coverage.out
|
||||
Server/ws_cover.out
|
||||
Server/ws_cov.out
|
||||
|
||||
# Client build artifacts
|
||||
Client/publish/
|
||||
Client/publish-single/
|
||||
Client/publish-release/
|
||||
|
||||
# HTML mockups (large design reference files)
|
||||
Client/login-mockup.html
|
||||
Client/ui-mockup.html
|
||||
.gstack/
|
||||
@@ -1,237 +0,0 @@
|
||||
# REST API Spec
|
||||
|
||||
Base URL: `https://{server}:{port}/api`
|
||||
|
||||
Auth: session token in cookie `session` (set on login) or `Authorization: Bearer {token}` header for programmatic access.
|
||||
|
||||
All responses are JSON. Errors return `{ "error": "CODE", "message": "Human-readable detail" }`.
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| POST | `/api/auth/register` | None (requires invite code) | Create account |
|
||||
| POST | `/api/auth/login` | None | Login, returns session token |
|
||||
| POST | `/api/auth/logout` | Yes | Invalidate current session |
|
||||
| POST | `/api/auth/verify-totp` | Partial (after login with 2FA) | Submit TOTP code |
|
||||
|
||||
### POST /api/auth/register
|
||||
```json
|
||||
// Request
|
||||
{ "username": "alex", "password": "strongpassword", "invite_code": "abc123" }
|
||||
// Response 201
|
||||
{ "user": { "id": 1, "username": "alex" }, "token": "session-token" }
|
||||
```
|
||||
|
||||
### POST /api/auth/login
|
||||
```json
|
||||
// Request
|
||||
{ "username": "alex", "password": "strongpassword" }
|
||||
// Response 200 (no 2FA)
|
||||
{ "token": "session-token", "requires_2fa": false }
|
||||
// Response 200 (2FA required)
|
||||
{ "partial_token": "temp-token", "requires_2fa": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/users/me` | Yes | Get current user profile |
|
||||
| PATCH | `/api/users/me` | Yes | Update own profile (username, avatar) |
|
||||
| PUT | `/api/users/me/password` | Yes | Change password |
|
||||
| POST | `/api/users/me/totp/enable` | Yes | Start 2FA setup, returns QR URI + backup codes |
|
||||
| POST | `/api/users/me/totp/confirm` | Yes | Confirm 2FA with first TOTP code |
|
||||
| DELETE | `/api/users/me/totp` | Yes | Disable 2FA |
|
||||
| GET | `/api/users/me/sessions` | Yes | List active sessions |
|
||||
| DELETE | `/api/users/me/sessions/{id}` | Yes | Revoke a session |
|
||||
|
||||
---
|
||||
|
||||
## Channels
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/channels` | Yes | List all channels user can see |
|
||||
| GET | `/api/channels/{id}/messages` | Yes | Paginated message history |
|
||||
| GET | `/api/channels/{id}/pins` | Yes | Get pinned messages |
|
||||
| POST | `/api/channels/{id}/pins/{msg_id}` | Yes (mod) | Pin a message |
|
||||
| DELETE | `/api/channels/{id}/pins/{msg_id}` | Yes (mod) | Unpin a message |
|
||||
|
||||
### GET /api/channels/{id}/messages
|
||||
Query params: `before` (message ID), `limit` (1-100, default 50)
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": 1042,
|
||||
"channel_id": 5,
|
||||
"user": { "id": 1, "username": "alex", "avatar": "uuid.png" },
|
||||
"content": "Hello!",
|
||||
"reply_to": null,
|
||||
"attachments": [],
|
||||
"reactions": [{ "emoji": "👍", "count": 2, "me": true }],
|
||||
"pinned": false,
|
||||
"edited_at": null,
|
||||
"deleted": false,
|
||||
"timestamp": "2026-03-14T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Uploads
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| POST | `/api/uploads` | Yes | Upload a file (multipart) |
|
||||
| GET | `/api/files/{uuid}` | Yes | Download a file |
|
||||
|
||||
### POST /api/uploads
|
||||
Multipart form data. Field: `file`. Max size from server config (default 25MB).
|
||||
|
||||
```json
|
||||
// Response 201
|
||||
{ "id": "upload-uuid", "filename": "photo.jpg", "size": 204800, "mime": "image/jpeg", "url": "/api/files/upload-uuid" }
|
||||
```
|
||||
|
||||
Server validates: magic bytes, rejects executables, strips EXIF, stores with UUID filename.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/search` | Yes | Full-text search across accessible channels |
|
||||
|
||||
Query params: `q` (search query), `channel_id` (optional filter), `limit` (default 25)
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"message_id": 1042,
|
||||
"channel_id": 5,
|
||||
"channel_name": "general",
|
||||
"user": { "id": 1, "username": "alex" },
|
||||
"content": "...matched text...",
|
||||
"timestamp": "2026-03-14T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invites
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/invites` | Yes (admin) | List all invites |
|
||||
| POST | `/api/invites` | Yes (manage_invites) | Create an invite |
|
||||
| DELETE | `/api/invites/{id}` | Yes (manage_invites) | Revoke an invite |
|
||||
|
||||
### POST /api/invites
|
||||
```json
|
||||
// Request
|
||||
{ "max_uses": 5, "expires_in_hours": 48 }
|
||||
// Response 201
|
||||
{ "id": 1, "code": "abc123def", "url": "chatserver://invite/abc123def", "max_uses": 5, "expires_at": "2026-03-16T10:30:00Z" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin Endpoints (admin panel uses these)
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/admin/stats` | Admin | Server stats (users, messages, disk, uptime) |
|
||||
| GET | `/api/admin/users` | Admin | List all users with details |
|
||||
| PATCH | `/api/admin/users/{id}` | Admin | Update user (role, ban/unban) |
|
||||
| DELETE | `/api/admin/users/{id}/sessions` | Admin | Force logout a user |
|
||||
| POST | `/api/admin/channels` | Admin | Create channel |
|
||||
| PATCH | `/api/admin/channels/{id}` | Admin | Update channel |
|
||||
| DELETE | `/api/admin/channels/{id}` | Admin | Delete channel |
|
||||
| GET | `/api/admin/audit-log` | Admin | View audit log (paginated) |
|
||||
| POST | `/api/admin/backup` | Owner | Trigger manual backup |
|
||||
| GET | `/api/admin/backups` | Owner | List available backups |
|
||||
| POST | `/api/admin/backups/{id}/restore` | Owner | Restore from backup |
|
||||
| GET | `/api/admin/settings` | Admin | Get server settings |
|
||||
| PATCH | `/api/admin/settings` | Admin | Update server settings |
|
||||
| GET | `/api/admin/update-check` | Admin | Check for new server version |
|
||||
|
||||
---
|
||||
|
||||
## WebRTC / TURN Credentials
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/voice/credentials` | Yes | Get time-limited TURN credentials |
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"ice_servers": [
|
||||
{ "urls": "stun:server:3478" },
|
||||
{ "urls": "turn:server:3478", "username": "timestamp:userid", "credential": "hmac-hash" }
|
||||
],
|
||||
"expires_in": 86400
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Emoji
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/emoji` | Yes | List all custom emoji |
|
||||
| POST | `/api/emoji` | Yes (admin) | Upload new emoji |
|
||||
| DELETE | `/api/emoji/{id}` | Yes (admin) | Delete emoji |
|
||||
|
||||
---
|
||||
|
||||
## Soundboard
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/sounds` | Yes | List all soundboard sounds |
|
||||
| POST | `/api/sounds` | Yes (permission) | Upload a sound |
|
||||
| DELETE | `/api/sounds/{id}` | Yes (admin) | Delete a sound |
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/api/health` | None | Returns 200 if server is running |
|
||||
|
||||
```json
|
||||
{ "status": "ok", "version": "1.0.0", "uptime": 86400 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | HTTP Status | Meaning |
|
||||
|------|-------------|---------|
|
||||
| `UNAUTHORIZED` | 401 | Missing or invalid session |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions |
|
||||
| `NOT_FOUND` | 404 | Resource not found |
|
||||
| `RATE_LIMITED` | 429 | Too many requests (includes `retry_after`) |
|
||||
| `INVALID_INPUT` | 400 | Bad request body or params |
|
||||
| `CONFLICT` | 409 | e.g. username already taken |
|
||||
| `TOO_LARGE` | 413 | File exceeds upload limit |
|
||||
| `SERVER_ERROR` | 500 | Internal server error |
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
# ChatServer — Self-Hosted Windows Chat Platform
|
||||
|
||||
Native Windows desktop client + self-hosted server. Two executables: `chatserver.exe` (server) and `chatclient.exe` (client app). Server operator runs the server, friends install the client.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Server (`chatserver.exe`)
|
||||
- **Go** — Single exe, no dependencies. Embeds admin web UI via `go embed`.
|
||||
- **SQLite** — Single `.db` file. WAL mode. Zero config.
|
||||
- **Pion** — Pure Go WebRTC. Voice/video/TURN built into the exe.
|
||||
- **Admin panel** — Web-based only, served at `/admin`. Browser access, not part of the client.
|
||||
|
||||
### Client (`chatclient.exe`)
|
||||
Choose the best language and framework for a native Windows desktop app based on these requirements:
|
||||
- Must be a native desktop application, NOT browser-based (no Electron)
|
||||
- Small install size (~20-40MB) and low RAM usage (~50-100MB idle)
|
||||
- WebSocket client for real-time chat
|
||||
- WebRTC integration for voice/video
|
||||
- Low-latency audio I/O (WASAPI or equivalent)
|
||||
- Global keyboard hooks for push-to-talk that work in fullscreen games
|
||||
- System tray with badge overlay
|
||||
- Windows toast notifications
|
||||
- DXGI Desktop Duplication for screen capture
|
||||
- Windows Credential Manager for secure token storage
|
||||
- Installer via NSIS or WiX
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SERVER (chatserver.exe) — runs on the host machine
|
||||
├── REST API (Go net/http)
|
||||
├── WebSocket Hub (real-time messages, presence, typing)
|
||||
├── WebRTC SFU + TURN Relay (Pion)
|
||||
├── SQLite Database (data/chatserver.db)
|
||||
├── File Storage (data/uploads/)
|
||||
├── Admin Web UI (embedded, browser-based, /admin)
|
||||
└── config.yaml
|
||||
|
||||
CLIENT (chatclient.exe) — installed by each friend
|
||||
├── Native Windows UI
|
||||
├── WebSocket Client (chat connection)
|
||||
├── WebRTC Client (voice/video)
|
||||
├── Audio Engine (device management, noise suppression)
|
||||
├── Local Settings (connection profiles, keybinds, audio config)
|
||||
└── System Tray Integration
|
||||
```
|
||||
|
||||
### How It Works
|
||||
1. Server operator runs `chatserver.exe` on their PC/home server
|
||||
2. Friends download and install `chatclient.exe`
|
||||
3. Client connects to the server via IP/domain + port
|
||||
4. All chat, voice, video, and file transfers go through the server
|
||||
5. Admin manages the server through a browser at `https://server-ip:port/admin`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Protocol & Server Core (2–3 weeks)
|
||||
|
||||
- [ ] Define client-server protocol over WebSocket (JSON messages with type/payload structure)
|
||||
- [ ] Message types: auth, chat, typing, presence, channel_update, voice_signal, file_transfer
|
||||
- [ ] Server: Go project with `go embed` for admin panel static files only
|
||||
- [ ] SQLite setup with migrations on startup (users, channels, messages, sessions, roles, invites)
|
||||
- [ ] config.yaml generation on first run (port, server name, max upload size, voice quality, TLS mode)
|
||||
- [ ] Server systray icon (getlantern/systray) — minimize to tray, status indicator, open admin panel, quit
|
||||
- [ ] Windows Firewall handling on first launch
|
||||
- [ ] Optional: register as Windows Service for headless operation
|
||||
|
||||
## Phase 2: Auth & Security (2–3 weeks)
|
||||
|
||||
- [ ] Invite-only registration — server generates invite codes, client has "Redeem Invite" flow
|
||||
- [ ] bcrypt (cost 12+) passwords, server-side session tokens (256-bit random)
|
||||
- [ ] Client stores auth token securely via Windows Credential Manager / DPAPI
|
||||
- [ ] Login rate limiting: 5 attempts/min/IP, lockout after 10 failures
|
||||
- [ ] Optional TOTP 2FA (`pquerna/otp`) — client shows QR code during setup, prompts on login
|
||||
- [ ] Roles: Owner, Admin, Moderator, Member + custom roles with bitfield permissions
|
||||
- [ ] Per-channel permission overrides, enforced server-side on every action
|
||||
- [ ] TLS modes: self-signed (default, auto-generated), Let's Encrypt, manual cert, off (Tailscale)
|
||||
- [ ] Client: certificate pinning or trust-on-first-use (TOFU) for self-signed certs
|
||||
|
||||
## Phase 3: Client App — Core UI (3–4 weeks)
|
||||
|
||||
- [ ] Connection dialog: server address, port, login/register, invite code entry
|
||||
- [ ] Save server profiles (connect to multiple servers like TeamSpeak)
|
||||
- [ ] Main window layout: server list sidebar → channel list → message area → member list
|
||||
- [ ] Channel tree view with categories, text channels, voice channels
|
||||
- [ ] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions
|
||||
- [ ] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop
|
||||
- [ ] Unread indicators, @mention badges per channel
|
||||
- [ ] System tray: minimize to tray, notification popups, badge count
|
||||
- [ ] Keyboard shortcuts: Ctrl+K quick switcher, Escape to close panels, customizable push-to-talk key
|
||||
- [ ] Settings window: account, appearance (light/dark theme), notifications, audio devices, keybinds
|
||||
|
||||
## Phase 4: Real-Time Chat Features (2–3 weeks)
|
||||
|
||||
- [ ] WebSocket client with auto-reconnect, exponential backoff, message replay on reconnect
|
||||
- [ ] Send/receive messages in real-time, append to scrollback
|
||||
- [ ] Message history: paginated from server on channel switch, scroll-to-load-more
|
||||
- [ ] Threads, replies (inline preview), reactions (emoji), edit, delete
|
||||
- [ ] Typing indicators ("X is typing..." below input)
|
||||
- [ ] Online/offline/idle/DnD presence with status icons in member list
|
||||
- [ ] File uploads: drag-and-drop or clipboard paste, progress bar, inline image previews
|
||||
- [ ] Client-side file validation before upload (size check, warn on large files)
|
||||
- [ ] Search: query server FTS5 endpoint, display results with jump-to-message
|
||||
- [ ] Windows toast notifications with action buttons (reply, mark read)
|
||||
- [ ] Notification sounds (configurable, per-channel mute/override)
|
||||
|
||||
## Phase 5: Voice & Video (3–5 weeks)
|
||||
|
||||
- [ ] WebRTC integration in native client for voice/video
|
||||
- [ ] Audio device selection: input/output dropdowns in settings, live preview
|
||||
- [ ] Voice channels: click to join/leave, show connected users with speaking indicators
|
||||
- [ ] Voice controls: mute (button + keybind), deafen, per-user volume sliders
|
||||
- [ ] Push-to-talk: configurable global hotkey that works in fullscreen games
|
||||
- [ ] Voice activity detection with configurable sensitivity
|
||||
- [ ] Noise suppression (RNNoise or equivalent, bundled with client)
|
||||
- [ ] Server-side: Pion SFU with DTLS-SRTP, built-in TURN relay with per-session credentials
|
||||
- [ ] Voice quality: low (32kbps) / medium (64kbps) / high (128kbps Opus)
|
||||
- [ ] Screen sharing via DXGI Desktop Duplication, sent as video track
|
||||
- [ ] Video calls: camera capture, displayed in voice channel panel
|
||||
- [ ] Soundboard: short clips, hotkey triggers, role-based permissions, play cooldown
|
||||
|
||||
## Phase 6: Admin Panel — Web-Based (1–2 weeks)
|
||||
|
||||
- [ ] Served by server at `/admin`, browser-only access
|
||||
- [ ] Auth: admin credentials, session-based
|
||||
- [ ] Dashboard: connected users, message count, disk usage, CPU/RAM, uptime
|
||||
- [ ] User management: list all, edit roles, ban/unban, reset password, force disconnect
|
||||
- [ ] Channel management: create, rename, reorder, set permissions, archive
|
||||
- [ ] Invite management: generate, view active, set expiry/use limit, revoke
|
||||
- [ ] Server settings: name, icon, MOTD, max upload size, voice quality, TLS config
|
||||
- [ ] Moderation: kick, ban, temp ban, slow mode, mute, word filter, audit log
|
||||
- [ ] Backup: trigger manual backup, configure schedule, view/restore from admin panel
|
||||
- [ ] Built with simple HTML/CSS/JS embedded in the server binary
|
||||
|
||||
## Phase 7: Distribution & Updates (1–2 weeks)
|
||||
|
||||
- [ ] **Server:** GitHub Actions builds `chatserver.exe` (amd64), SHA256 checksum, GitHub Release
|
||||
- [ ] **Client:** NSIS or WiX installer — Program Files, Start Menu shortcut, optional auto-start, protocol handler for `chatserver://` invite links
|
||||
- [ ] Client auto-update: check GitHub releases on launch, prompt to download + install
|
||||
- [ ] Server update: admin panel shows available update, one-click download + restart
|
||||
- [ ] Docs: Quick Start, Port Forwarding guide, Tailscale guide, Client install guide
|
||||
- [ ] Security hardening checklist for server operators
|
||||
- [ ] SECURITY.md, README.md, CONTRIBUTING.md
|
||||
|
||||
---
|
||||
|
||||
## Windows-Specific Details
|
||||
|
||||
### Client
|
||||
- **Installer:** NSIS or WiX (~20-40MB). Registers `chatserver://` protocol handler for invite links.
|
||||
- **Auto-start:** Registry key `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
|
||||
- **Credentials:** Auth tokens stored in Windows Credential Manager (DPAPI).
|
||||
- **Push-to-talk:** Global keyboard hook via `SetWindowsHookEx` — works in fullscreen games.
|
||||
- **Audio:** WASAPI for low-latency capture/playback.
|
||||
- **Screen capture:** DXGI Desktop Duplication API.
|
||||
- **Notifications:** Windows Toast notifications with action buttons.
|
||||
- **Tray:** System tray icon with unread badge overlay.
|
||||
|
||||
### Server
|
||||
- **Firewall:** Prompt on first run. Installer can pre-register firewall rule.
|
||||
- **SmartScreen:** Unsigned exe shows warning. Code signing cert resolves this.
|
||||
- **Data path:** `data/` next to exe. Installer version uses `%APPDATA%/ChatServer/`.
|
||||
- **Logs:** `data/logs/` with daily rotation, viewable from admin panel.
|
||||
- **Service mode:** `chatserver.exe --service install` to register as Windows Service.
|
||||
|
||||
## Security Priorities
|
||||
|
||||
**Critical:** Invite-only registration, bcrypt auth, TLS (self-signed minimum), file upload validation (magic bytes, block executables), input sanitization server-side, credential storage via DPAPI, backup system.
|
||||
|
||||
**High:** Rate limiting, TOTP 2FA, role permissions, WebSocket auth, TURN credentials, cert pinning/TOFU, update integrity (SHA256).
|
||||
|
||||
## Server Libraries (Go)
|
||||
|
||||
| Purpose | Library |
|
||||
|---|---|
|
||||
| HTTP/routing | `net/http` + `chi` |
|
||||
| WebSocket | `nhooyr.io/websocket` |
|
||||
| WebRTC/TURN | `pion/webrtc` + `pion/turn` |
|
||||
| SQLite | `modernc.org/sqlite` (pure Go) |
|
||||
| Auth | `golang.org/x/crypto/bcrypt` |
|
||||
| TOTP | `pquerna/otp` |
|
||||
| Sanitization | `bluemonday` |
|
||||
| TLS | `golang.org/x/crypto/acme/autocert` |
|
||||
| Systray | `getlantern/systray` |
|
||||
| Config | `koanf` |
|
||||
| Logging | `log/slog` |
|
||||
@@ -0,0 +1,19 @@
|
||||
# Client Code Review Findings
|
||||
|
||||
Date: 2026-03-16
|
||||
|
||||
Scope: `Client/tauri-client/src`
|
||||
|
||||
## High
|
||||
|
||||
- Auth token is never set in `authStore` after login. The `auth_ok` handler calls `setAuth(authStore.getState().token ?? "", ...)`, so the store token becomes an empty string. Any future logic that relies on `authStore.token` (re-auth, API helpers, telemetry) will be wrong. File: `Client/tauri-client/src/lib/dispatcher.ts`.
|
||||
- If Tauri APIs are unavailable, `ws.connect` logs an error and returns early but leaves the connection state as `connecting` and never schedules a retry or notifies the UI. This can hang the client in a pseudo-connecting state in browser/test contexts. File: `Client/tauri-client/src/lib/ws.ts`.
|
||||
|
||||
## Medium
|
||||
|
||||
- Server-driven voice disconnects do not clear `currentChannelId`. The dispatcher handles `voice_leave` by removing users only; it does not call `leaveVoiceChannel()` when the current user is removed, so the voice widget can stay visible after kicks/disconnects. Files: `Client/tauri-client/src/lib/dispatcher.ts`, `Client/tauri-client/src/stores/voice.store.ts`, `Client/tauri-client/src/components/VoiceWidget.ts`.
|
||||
- Theme/font-size/compact-mode preferences are applied only when the Settings overlay is opened. On app start, stored preferences are not applied, causing UI to render in default theme until the user opens Settings. File: `Client/tauri-client/src/components/SettingsOverlay.ts`.
|
||||
|
||||
## Low
|
||||
|
||||
- Infinite scroll throttling in the message list uses a fixed `500ms` timeout to reset `loadingOlder`, independent of the fetch completion. On slow responses this can trigger overlapping loads or repeated requests. File: `Client/tauri-client/src/components/MessageList.ts`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
*.tsbuildinfo
|
||||
.vite/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OwnCord</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
|
||||
"test:e2e:native": "playwright test --config playwright.config.native.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"jsdom": "^29.0.0",
|
||||
"typescript": "^5.7",
|
||||
"vite": "^6",
|
||||
"vitest": "^3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jitsi/rnnoise-wasm": "^0.2.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright config for testing against the REAL Tauri production app.
|
||||
*
|
||||
* Connects to the WebView2 window via Chrome DevTools Protocol (CDP).
|
||||
* The custom fixture in tests/e2e/native-fixture.ts launches the Tauri
|
||||
* exe with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port
|
||||
* and connects Playwright to it via chromium.connectOverCDP().
|
||||
*
|
||||
* Requirements:
|
||||
* - Built Tauri exe: npm run tauri build
|
||||
* - Running server: Server/chatserver.exe (or set OWNCORD_SERVER_URL)
|
||||
*
|
||||
* Usage: npm run test:e2e:native
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/native",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
// Native tests are slower (real app startup) — run sequentially
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 2,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/native-junit.xml" }]]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
|
||||
// No webServer — we launch the Tauri app ourselves in the fixture.
|
||||
// No projects — we connect directly to WebView2 via CDP, not via browser launch.
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright config for testing against the PRODUCTION build.
|
||||
* Uses `vite preview` to serve the built dist/ folder — the same
|
||||
* HTML/CSS/JS that Tauri bundles into the exe.
|
||||
*
|
||||
* Usage: npm run test:e2e:prod
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/native/**"],
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
baseURL: "http://localhost:4173",
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
video: "on-first-retry",
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "npm run preview",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/native/**"],
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
baseURL: "http://localhost:1420",
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
video: "on-first-retry",
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:1420",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
// =============================================================================
|
||||
// RNNoise AudioWorklet Processor
|
||||
//
|
||||
// Runs on the audio rendering thread. Receives WASM module bytes from the
|
||||
// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz.
|
||||
// =============================================================================
|
||||
|
||||
const FRAME_SIZE = 480;
|
||||
|
||||
class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/** @type {WebAssembly.Instance | null} */
|
||||
this._instance = null;
|
||||
/** @type {number} */
|
||||
this._state = 0;
|
||||
/** @type {number} */
|
||||
this._inputPtr = 0;
|
||||
/** @type {number} */
|
||||
this._outputPtr = 0;
|
||||
/** @type {Float32Array | null} */
|
||||
this._heapF32 = null;
|
||||
/** @type {boolean} */
|
||||
this._ready = false;
|
||||
/** @type {boolean} */
|
||||
this._destroyed = false;
|
||||
|
||||
// Ring buffer to accumulate 480-sample frames
|
||||
this._inputRing = new Float32Array(FRAME_SIZE);
|
||||
this._inputRingOffset = 0;
|
||||
|
||||
// Output ring buffer (fixed-size, prevents unbounded growth)
|
||||
this._outCapacity = 50;
|
||||
this._outRing = new Array(this._outCapacity);
|
||||
this._outWriteIdx = 0;
|
||||
this._outReadIdx = 0;
|
||||
this._outCount = 0;
|
||||
this._outSampleOffset = 0;
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.type === "init") {
|
||||
this._initWasm(event.data.wasmBytes);
|
||||
} else if (event.data.type === "destroy") {
|
||||
this._cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async _initWasm(wasmBytes) {
|
||||
try {
|
||||
const memory = new WebAssembly.Memory({ initial: 256 });
|
||||
const importObject = {
|
||||
env: {
|
||||
memory,
|
||||
emscripten_notify_memory_growth: () => {
|
||||
this._heapF32 = new Float32Array(memory.buffer);
|
||||
},
|
||||
},
|
||||
wasi_snapshot_preview1: {
|
||||
proc_exit: () => {},
|
||||
fd_close: () => 0,
|
||||
fd_write: () => 0,
|
||||
fd_seek: () => 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Try instantiating with the raw WASM bytes
|
||||
const { instance } = await WebAssembly.instantiate(wasmBytes, importObject);
|
||||
this._instance = instance;
|
||||
this._heapF32 = new Float32Array(memory.buffer);
|
||||
|
||||
// Call RNNoise C API
|
||||
const exports = instance.exports;
|
||||
this._state = exports.rnnoise_create();
|
||||
this._inputPtr = exports.malloc(FRAME_SIZE * 4);
|
||||
this._outputPtr = exports.malloc(FRAME_SIZE * 4);
|
||||
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: "ready" });
|
||||
} catch (err) {
|
||||
// Fallback: the WASM module may use Emscripten-style exports
|
||||
// that need the full runtime. Signal failure so the main thread
|
||||
// can fall back to ScriptProcessorNode.
|
||||
this.port.postMessage({ type: "error", message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
_processFrame() {
|
||||
if (!this._instance || !this._heapF32) return;
|
||||
const exports = this._instance.exports;
|
||||
|
||||
const inOff = this._inputPtr / 4;
|
||||
for (let i = 0; i < FRAME_SIZE; i++) {
|
||||
this._heapF32[inOff + i] = this._inputRing[i] * 32768;
|
||||
}
|
||||
|
||||
exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr);
|
||||
|
||||
const outOff = this._outputPtr / 4;
|
||||
const result = new Float32Array(FRAME_SIZE);
|
||||
for (let i = 0; i < FRAME_SIZE; i++) {
|
||||
result[i] = this._heapF32[outOff + i] / 32768;
|
||||
}
|
||||
|
||||
// Write to ring buffer, dropping oldest if full
|
||||
if (this._outCount >= this._outCapacity) {
|
||||
this._outReadIdx = (this._outReadIdx + 1) % this._outCapacity;
|
||||
this._outCount--;
|
||||
this._outSampleOffset = 0;
|
||||
}
|
||||
this._outRing[this._outWriteIdx] = result;
|
||||
this._outWriteIdx = (this._outWriteIdx + 1) % this._outCapacity;
|
||||
this._outCount++;
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
if (this._instance && this._state) {
|
||||
try {
|
||||
const exports = this._instance.exports;
|
||||
exports.rnnoise_destroy(this._state);
|
||||
exports.free(this._inputPtr);
|
||||
exports.free(this._outputPtr);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
this._ready = false;
|
||||
this._destroyed = true;
|
||||
this._state = 0;
|
||||
}
|
||||
|
||||
process(inputs, outputs) {
|
||||
if (this._destroyed) return false;
|
||||
if (!this._ready) {
|
||||
// Pass through until WASM is ready
|
||||
const input = inputs[0];
|
||||
const output = outputs[0];
|
||||
if (input && output && input[0] && output[0]) {
|
||||
output[0].set(input[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const input = inputs[0];
|
||||
const output = outputs[0];
|
||||
if (!input || !output || !input[0] || !output[0]) return true;
|
||||
|
||||
const inData = input[0];
|
||||
const outData = output[0];
|
||||
|
||||
// Feed input into ring buffer, process complete frames
|
||||
let inIdx = 0;
|
||||
while (inIdx < inData.length) {
|
||||
const needed = FRAME_SIZE - this._inputRingOffset;
|
||||
const toCopy = Math.min(needed, inData.length - inIdx);
|
||||
this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset);
|
||||
this._inputRingOffset += toCopy;
|
||||
inIdx += toCopy;
|
||||
|
||||
if (this._inputRingOffset >= FRAME_SIZE) {
|
||||
this._processFrame();
|
||||
this._inputRingOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain processed frames into output
|
||||
let outIdx = 0;
|
||||
while (outIdx < outData.length && this._outCount > 0) {
|
||||
const chunk = this._outRing[this._outReadIdx];
|
||||
const available = chunk.length - this._outSampleOffset;
|
||||
const toWrite = Math.min(available, outData.length - outIdx);
|
||||
outData.set(chunk.subarray(this._outSampleOffset, this._outSampleOffset + toWrite), outIdx);
|
||||
outIdx += toWrite;
|
||||
this._outSampleOffset += toWrite;
|
||||
if (this._outSampleOffset >= chunk.length) {
|
||||
this._outReadIdx = (this._outReadIdx + 1) % this._outCapacity;
|
||||
this._outCount--;
|
||||
this._outSampleOffset = 0;
|
||||
}
|
||||
}
|
||||
// Fill remaining with silence
|
||||
if (outIdx < outData.length) {
|
||||
outData.fill(0, outIdx);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("rnnoise-processor", RNNoiseProcessor);
|
||||
Binary file not shown.
Generated
+6512
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "OwnCord Desktop Client"
|
||||
|
||||
[lib]
|
||||
name = "owncord_client_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-settings"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
url = "2"
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3.32"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
ring = "0.17"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capability granting core permissions to the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-is-visible",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-outer-size",
|
||||
"store:default",
|
||||
"global-shortcut:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-unregister-all",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"notification:default",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-is-permission-granted",
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*:*"
|
||||
},
|
||||
{
|
||||
"url": "https://*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "http:allow-fetch-send",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*:*"
|
||||
},
|
||||
{
|
||||
"url": "https://*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "http:allow-fetch-read-body",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*:*"
|
||||
},
|
||||
{
|
||||
"url": "https://*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"http:allow-fetch-cancel",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"fs:default",
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 361 B |
Binary file not shown.
|
After Width: | Height: | Size: 858 B |
Binary file not shown.
|
After Width: | Height: | Size: 105 B |
Binary file not shown.
|
After Width: | Height: | Size: 127 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,90 @@
|
||||
use serde_json::Value;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SETTINGS_STORE: &str = "settings.json";
|
||||
const CERTS_STORE: &str = "certs.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(app: tauri::AppHandle) -> Result<Value, String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("failed to open settings store: {e}"))?;
|
||||
|
||||
let keys = store.keys();
|
||||
let mut map = serde_json::Map::new();
|
||||
for key in keys {
|
||||
if let Some(val) = store.get(&key) {
|
||||
map.insert(key, val);
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("failed to open settings store: {e}"))?;
|
||||
|
||||
store.set(&key, value);
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("failed to persist settings: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Certificate fingerprint commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn store_cert_fingerprint(
|
||||
app: tauri::AppHandle,
|
||||
host: String,
|
||||
fingerprint: String,
|
||||
) -> Result<(), String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
if fingerprint.is_empty() {
|
||||
return Err("fingerprint must not be empty".into());
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
|
||||
store.set(&host, Value::String(fingerprint));
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("failed to persist cert fingerprint: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cert_fingerprint(
|
||||
app: tauri::AppHandle,
|
||||
host: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
|
||||
let value = store.get(&host).and_then(|v| {
|
||||
if let Value::String(s) = v {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use serde::Serialize;
|
||||
use std::ptr;
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::ERROR_NOT_FOUND;
|
||||
use windows::Win32::Security::Credentials::{
|
||||
CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS,
|
||||
CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC,
|
||||
};
|
||||
|
||||
/// Data returned from `load_credential`.
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct CredentialData {
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
/// Optional saved password (only present when user opted in).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the target name used in Windows Credential Manager.
|
||||
fn target_name(host: &str) -> Vec<u16> {
|
||||
let name = format!("OwnCord/{host}");
|
||||
name.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Encode a Rust string as a null-terminated UTF-16 vector.
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Save a credential (username + token) to Windows Credential Manager.
|
||||
///
|
||||
/// Target name: `OwnCord/{host}`
|
||||
/// Blob: JSON `{"username":"...","token":"..."}`
|
||||
#[tauri::command]
|
||||
pub fn save_credential(host: String, username: String, token: String, password: Option<String>) -> Result<(), String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
if token.is_empty() {
|
||||
return Err("token must not be empty".into());
|
||||
}
|
||||
if username.is_empty() {
|
||||
return Err("username must not be empty".into());
|
||||
}
|
||||
|
||||
let target = target_name(&host);
|
||||
let wide_user = to_wide(&username);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"username": username,
|
||||
"token": token,
|
||||
});
|
||||
if let Some(ref pw) = password {
|
||||
payload["password"] = serde_json::Value::String(pw.clone());
|
||||
}
|
||||
let blob = payload.to_string().into_bytes();
|
||||
|
||||
let mut cred = CREDENTIALW {
|
||||
Flags: CRED_FLAGS(0),
|
||||
Type: CRED_TYPE_GENERIC,
|
||||
TargetName: PWSTR(target.as_ptr() as *mut u16),
|
||||
Comment: PWSTR::null(),
|
||||
LastWritten: Default::default(),
|
||||
CredentialBlobSize: blob.len() as u32,
|
||||
CredentialBlob: blob.as_ptr() as *mut u8,
|
||||
Persist: CRED_PERSIST_LOCAL_MACHINE,
|
||||
AttributeCount: 0,
|
||||
Attributes: ptr::null_mut(),
|
||||
TargetAlias: PWSTR::null(),
|
||||
UserName: PWSTR(wide_user.as_ptr() as *mut u16),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
CredWriteW(&mut cred, 0)
|
||||
.map_err(|e| format!("CredWriteW failed: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a credential from Windows Credential Manager.
|
||||
///
|
||||
/// Returns `None` when no credential exists for the given host.
|
||||
#[tauri::command]
|
||||
pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
|
||||
let target = target_name(&host);
|
||||
let mut pcred: *mut CREDENTIALW = ptr::null_mut();
|
||||
|
||||
let read_result = unsafe {
|
||||
CredReadW(
|
||||
PCWSTR(target.as_ptr()),
|
||||
CRED_TYPE_GENERIC,
|
||||
0,
|
||||
&mut pcred,
|
||||
)
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
if e.code() == ERROR_NOT_FOUND.to_hresult() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("CredReadW failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: `pcred` is valid after a successful CredReadW call.
|
||||
let result = unsafe {
|
||||
let cred = &*pcred;
|
||||
let blob_slice = std::slice::from_raw_parts(
|
||||
cred.CredentialBlob,
|
||||
cred.CredentialBlobSize as usize,
|
||||
);
|
||||
let json_str = String::from_utf8(blob_slice.to_vec())
|
||||
.map_err(|e| format!("credential blob is not valid UTF-8: {e}"))?;
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_str)
|
||||
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
|
||||
|
||||
let username = parsed
|
||||
.get("username")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let token = parsed
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let password = parsed
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Free the credential memory allocated by Windows.
|
||||
CredFree(pcred as *const std::ffi::c_void);
|
||||
|
||||
Ok(Some(CredentialData { username, token, password }))
|
||||
};
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Delete a credential from Windows Credential Manager.
|
||||
#[tauri::command]
|
||||
pub fn delete_credential(host: String) -> Result<(), String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
|
||||
let target = target_name(&host);
|
||||
|
||||
let delete_result = unsafe {
|
||||
CredDeleteW(
|
||||
PCWSTR(target.as_ptr()),
|
||||
CRED_TYPE_GENERIC,
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
match delete_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if e.code() == ERROR_NOT_FOUND.to_hresult() {
|
||||
// Deleting a non-existent credential is not an error.
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("CredDeleteW failed: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
mod commands;
|
||||
mod credentials;
|
||||
mod hotkeys;
|
||||
mod tray;
|
||||
mod update_commands;
|
||||
mod ws_proxy;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ws_proxy::WsState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::store_cert_fingerprint,
|
||||
commands::get_cert_fingerprint,
|
||||
ws_proxy::ws_connect,
|
||||
ws_proxy::ws_send,
|
||||
ws_proxy::ws_disconnect,
|
||||
ws_proxy::accept_cert_fingerprint,
|
||||
credentials::save_credential,
|
||||
credentials::load_credential,
|
||||
credentials::delete_credential,
|
||||
update_commands::check_client_update,
|
||||
update_commands::download_and_install_update,
|
||||
])
|
||||
.setup(|app| {
|
||||
tray::create_tray(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
owncord_client_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, Submenu},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager, Runtime,
|
||||
};
|
||||
|
||||
const SHOW_HIDE_ID: &str = "show_hide";
|
||||
const STATUS_ONLINE_ID: &str = "status_online";
|
||||
const STATUS_IDLE_ID: &str = "status_idle";
|
||||
const STATUS_DND_ID: &str = "status_dnd";
|
||||
const STATUS_OFFLINE_ID: &str = "status_offline";
|
||||
const QUIT_ID: &str = "quit";
|
||||
|
||||
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::Error> {
|
||||
let show_hide = MenuItem::with_id(app, SHOW_HIDE_ID, "Show/Hide", true, None::<&str>)?;
|
||||
|
||||
let status_online =
|
||||
MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
|
||||
let status_idle = MenuItem::with_id(app, STATUS_IDLE_ID, "Idle", true, None::<&str>)?;
|
||||
let status_dnd = MenuItem::with_id(app, STATUS_DND_ID, "Do Not Disturb", true, None::<&str>)?;
|
||||
let status_offline =
|
||||
MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
|
||||
|
||||
let status_submenu = Submenu::with_items(
|
||||
app,
|
||||
"Status",
|
||||
true,
|
||||
&[
|
||||
&status_online,
|
||||
&status_idle,
|
||||
&status_dnd,
|
||||
&status_offline,
|
||||
],
|
||||
)?;
|
||||
|
||||
let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_hide, &status_submenu, &quit])?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
let app_handle_menu = app.clone();
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().cloned().unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1)))
|
||||
.menu(&menu)
|
||||
.tooltip("OwnCord")
|
||||
.on_tray_icon_event(move |_tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
toggle_window_visibility(&app_handle);
|
||||
}
|
||||
})
|
||||
.on_menu_event(move |_tray, event| {
|
||||
handle_menu_event(&app_handle_menu, event.id().as_ref());
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_window_visibility<R: Runtime>(app: &tauri::AppHandle<R>) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_menu_event<R: Runtime>(app_handle: &tauri::AppHandle<R>, id: &str) {
|
||||
match id {
|
||||
SHOW_HIDE_ID => toggle_window_visibility(app_handle),
|
||||
STATUS_ONLINE_ID => emit_status_change(app_handle, "online"),
|
||||
STATUS_IDLE_ID => emit_status_change(app_handle, "idle"),
|
||||
STATUS_DND_ID => emit_status_change(app_handle, "dnd"),
|
||||
STATUS_OFFLINE_ID => emit_status_change(app_handle, "offline"),
|
||||
QUIT_ID => {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_status_change<R: Runtime>(app: &tauri::AppHandle<R>, status: &str) {
|
||||
let _ = app.emit("status-change", status);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateCheckResult {
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tauri::command]
|
||||
pub async fn check_client_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<UpdateCheckResult, String> {
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
);
|
||||
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("update check failed: {e}"))?;
|
||||
|
||||
match update {
|
||||
Some(u) => Ok(UpdateCheckResult {
|
||||
available: true,
|
||||
version: Some(u.version.clone()),
|
||||
body: Some(u.body.clone().unwrap_or_default()),
|
||||
}),
|
||||
None => Ok(UpdateCheckResult {
|
||||
available: false,
|
||||
version: None,
|
||||
body: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Download and install a pending update, then signal the frontend.
|
||||
/// The frontend should call `relaunch()` from @tauri-apps/plugin-process
|
||||
/// after this completes.
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<(), String> {
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
);
|
||||
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("update check failed: {e}"))?;
|
||||
|
||||
match update {
|
||||
Some(u) => {
|
||||
u.download_and_install(|_chunk_len, _total| {}, || {})
|
||||
.await
|
||||
.map_err(|e| format!("download/install failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
None => Err("no update available".into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// WebSocket proxy — routes WSS through Rust to bypass self-signed cert rejection.
|
||||
// JS sends/receives messages via Tauri events instead of native WebSocket.
|
||||
//
|
||||
// Implements TOFU (Trust On First Use) certificate pinning:
|
||||
// - On first connect to a host, the cert SHA-256 fingerprint is stored.
|
||||
// - On subsequent connects, the fingerprint is compared with the stored value.
|
||||
// - If the fingerprint changes, the connection is rejected (potential MitM).
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Maximum time to wait for the WebSocket handshake to complete.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Tauri store file for certificate fingerprints.
|
||||
const CERTS_STORE: &str = "certs.json";
|
||||
|
||||
/// Sender half kept in Tauri state so `ws_send` can push messages.
|
||||
pub struct WsState {
|
||||
tx: Mutex<Option<mpsc::Sender<String>>>,
|
||||
}
|
||||
|
||||
impl WsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tx: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared fingerprint captured during TLS handshake.
|
||||
type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>;
|
||||
|
||||
/// TOFU certificate verifier that captures the server cert fingerprint
|
||||
/// during the TLS handshake. Still accepts self-signed certs (required
|
||||
/// for self-hosted servers), but records the fingerprint for comparison
|
||||
/// with the stored value after the connection is established.
|
||||
#[derive(Debug)]
|
||||
struct TofuVerifier {
|
||||
captured: CapturedFingerprint,
|
||||
}
|
||||
|
||||
impl TofuVerifier {
|
||||
fn new() -> (Self, CapturedFingerprint) {
|
||||
let fp = Arc::new(std::sync::Mutex::new(None));
|
||||
(Self { captured: fp.clone() }, fp)
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for TofuVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
// Compute SHA-256 fingerprint of the DER-encoded leaf certificate.
|
||||
let hash = digest(&SHA256, end_entity.as_ref());
|
||||
let hex = hash
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":");
|
||||
|
||||
if let Ok(mut guard) = self.captured.lock() {
|
||||
*guard = Some(hex);
|
||||
}
|
||||
|
||||
// Accept the cert — TOFU check happens after the handshake completes.
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the host (with port) from a wss:// URL.
|
||||
fn extract_host(url: &str) -> String {
|
||||
url.strip_prefix("wss://")
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Perform TOFU fingerprint check against the Tauri cert store.
|
||||
/// Returns Ok(()) if trusted, Err(message) if fingerprint mismatch.
|
||||
fn tofu_check<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
host: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<String, String> {
|
||||
let store = app
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
|
||||
let stored = store.get(host).and_then(|v| {
|
||||
if let Value::String(s) = v {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
match stored {
|
||||
None => {
|
||||
// First use — store the fingerprint.
|
||||
store.set(host, Value::String(fingerprint.to_string()));
|
||||
if let Err(e) = store.save() {
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
}
|
||||
Ok("trusted_first_use".to_string())
|
||||
}
|
||||
Some(ref stored_fp) if stored_fp == fingerprint => {
|
||||
Ok("trusted".to_string())
|
||||
}
|
||||
Some(stored_fp) => {
|
||||
Err(format!(
|
||||
"Certificate fingerprint changed for {host}.\n\
|
||||
Stored: {stored_fp}\n\
|
||||
Current: {fingerprint}\n\
|
||||
This may indicate a man-in-the-middle attack or a server certificate rotation.\n\
|
||||
Use accept_cert_fingerprint to trust the new certificate."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// - Emits `cert-tofu` events for TOFU fingerprint status
|
||||
/// - Reads from an mpsc channel for outgoing messages
|
||||
#[tauri::command]
|
||||
pub async fn ws_connect<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: tauri::State<'_, WsState>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
// Drop any existing connection
|
||||
{
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
*tx_lock = None;
|
||||
}
|
||||
|
||||
// Only allow secure WebSocket connections
|
||||
if !url.starts_with("wss://") {
|
||||
return Err("Only wss:// connections are permitted".into());
|
||||
}
|
||||
|
||||
let _ = app.emit("ws-state", "connecting");
|
||||
|
||||
// Create TOFU verifier that captures the cert fingerprint during handshake.
|
||||
let (verifier, captured_fp) = TofuVerifier::new();
|
||||
|
||||
let tls_config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(verifier))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector =
|
||||
tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
|
||||
|
||||
let connect_future = tokio_tungstenite::connect_async_tls_with_config(
|
||||
&url,
|
||||
None,
|
||||
false,
|
||||
Some(connector),
|
||||
);
|
||||
|
||||
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
|
||||
.await
|
||||
.map_err(|_| format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()))?
|
||||
.map_err(|e| format!("ws connect failed: {e}"))?;
|
||||
|
||||
// ── TOFU check ───────────────────────────────────────────────────────
|
||||
let host = extract_host(&url);
|
||||
let fingerprint = captured_fp
|
||||
.lock()
|
||||
.map_err(|e| format!("failed to read captured fingerprint: {e}"))?
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
|
||||
if fingerprint.is_empty() {
|
||||
return Err("TLS handshake completed but no certificate fingerprint was captured".into());
|
||||
}
|
||||
|
||||
match tofu_check(&app, &host, &fingerprint) {
|
||||
Ok(status) => {
|
||||
let _ = app.emit(
|
||||
"cert-tofu",
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(mismatch_msg) => {
|
||||
let _ = app.emit(
|
||||
"cert-tofu",
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "mismatch",
|
||||
"message": mismatch_msg,
|
||||
}),
|
||||
);
|
||||
// Reject the connection — do not proceed.
|
||||
return Err(mismatch_msg);
|
||||
}
|
||||
}
|
||||
// ── End TOFU check ───────────────────────────────────────────────────
|
||||
|
||||
let _ = app.emit("ws-state", "open");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
// Channel for JS → server messages (bounded for backpressure)
|
||||
let (tx, mut rx) = mpsc::channel::<String>(256);
|
||||
{
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
*tx_lock = Some(tx);
|
||||
}
|
||||
|
||||
let app_read = app.clone();
|
||||
let app_state = app.clone();
|
||||
|
||||
// Task: forward server → JS
|
||||
let mut read_task = tokio::spawn(async move {
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
let _ = app_read.emit("ws-message", text.to_string());
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Err(e) => {
|
||||
let _ = app_read.emit("ws-error", format!("{e}"));
|
||||
break;
|
||||
}
|
||||
_ => {} // ignore binary/ping/pong
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Task: forward JS → server
|
||||
let mut write_task = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if sink.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// When either task ends, abort sibling and emit closed
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = &mut read_task => { write_task.abort(); }
|
||||
_ = &mut write_task => { read_task.abort(); }
|
||||
}
|
||||
let _ = app_state.emit("ws-state", "closed");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a text message through the proxy WebSocket.
|
||||
#[tauri::command]
|
||||
pub async fn ws_send(
|
||||
state: tauri::State<'_, WsState>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let tx_lock = state.tx.lock().await;
|
||||
if let Some(tx) = tx_lock.as_ref() {
|
||||
tx.try_send(message).map_err(|e| format!("ws send failed: {e}"))
|
||||
} else {
|
||||
Err("WebSocket not connected".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect the proxy WebSocket.
|
||||
#[tauri::command]
|
||||
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
*tx_lock = None; // dropping the sender closes the channel → write task ends
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a changed certificate fingerprint for a host.
|
||||
/// Call this after the user acknowledges a cert-mismatch warning.
|
||||
#[tauri::command]
|
||||
pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
host: String,
|
||||
fingerprint: String,
|
||||
) -> Result<(), String> {
|
||||
if host.is_empty() || fingerprint.is_empty() {
|
||||
return Err("host and fingerprint must not be empty".into());
|
||||
}
|
||||
|
||||
// Validate SHA-256 colon-hex format: XX:XX:XX:... (32 pairs = 95 chars)
|
||||
let valid = fingerprint.len() == 95
|
||||
&& fingerprint.bytes().enumerate().all(|(i, b)| {
|
||||
if (i + 1) % 3 == 0 {
|
||||
b == b':'
|
||||
} else {
|
||||
b.is_ascii_hexdigit()
|
||||
}
|
||||
});
|
||||
if !valid {
|
||||
return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into());
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(CERTS_STORE)
|
||||
.map_err(|e| format!("failed to open certs store: {e}"))?;
|
||||
|
||||
store.set(&host, Value::String(fingerprint));
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("failed to persist cert fingerprint: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OwnCord",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 940,
|
||||
"minHeight": 500,
|
||||
"decorations": true,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: wss:; img-src 'self' https: data:; frame-src https://www.youtube.com https://youtube.com"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"displayLanguageSelector": false,
|
||||
"installerIcon": "icons/icon.ico"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMkZGMTUzMDBBNkFCNDAKUldSQXE2WUFVL0V2Z2NjekFXaVI1elpQYVBmOUdKNmZrTzZwRC80RHlBMkQyYzZWYXdTK00xK0wK",
|
||||
"endpoints": [],
|
||||
"dangerousAcceptInvalidCerts": true,
|
||||
"dangerousAcceptInvalidHostnames": true,
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* AdminActions — context menu helpers for admin operations on members and channels.
|
||||
* Provides confirmation steps for destructive actions (kick, ban, delete).
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemberContextMenuOptions {
|
||||
userId: number;
|
||||
username: string;
|
||||
currentRole: string;
|
||||
availableRoles: readonly string[];
|
||||
onKick(): Promise<void>;
|
||||
onBan(): Promise<void>;
|
||||
onChangeRole(newRole: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChannelContextMenuOptions {
|
||||
channelId: number;
|
||||
channelName: string;
|
||||
onEdit(): void;
|
||||
onDelete(): Promise<void>;
|
||||
onCreate(): void;
|
||||
}
|
||||
|
||||
interface ContextMenuResult {
|
||||
readonly element: HTMLDivElement;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMenuItem(
|
||||
label: string,
|
||||
className: string,
|
||||
onClick: () => void,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const item = createElement("div", { class: className }, label);
|
||||
item.addEventListener("click", onClick, { signal });
|
||||
return item;
|
||||
}
|
||||
|
||||
function createSeparator(): HTMLDivElement {
|
||||
return createElement("div", { class: "context-menu__separator" });
|
||||
}
|
||||
|
||||
function withConfirmation(
|
||||
item: HTMLDivElement,
|
||||
confirmLabel: string,
|
||||
onConfirm: () => void,
|
||||
signal: AbortSignal,
|
||||
): void {
|
||||
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 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Member Context Menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 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);
|
||||
roleSub.appendChild(roleOption);
|
||||
}
|
||||
|
||||
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);
|
||||
menu.appendChild(roleItem);
|
||||
|
||||
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);
|
||||
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);
|
||||
menu.appendChild(banItem);
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
menu.remove();
|
||||
}
|
||||
|
||||
return { element: menu, destroy };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel Context Menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createChannelContextMenu(
|
||||
options: ChannelContextMenuOptions,
|
||||
): ContextMenuResult {
|
||||
const ac = new AbortController();
|
||||
const menu = createElement("div", { class: "context-menu" });
|
||||
|
||||
// Edit Channel
|
||||
const editItem = createMenuItem(
|
||||
"Edit Channel",
|
||||
"context-menu__item",
|
||||
() => options.onEdit(),
|
||||
ac.signal,
|
||||
);
|
||||
menu.appendChild(editItem);
|
||||
|
||||
// Create Channel
|
||||
const createItem = createMenuItem(
|
||||
"Create Channel",
|
||||
"context-menu__item",
|
||||
() => options.onCreate(),
|
||||
ac.signal,
|
||||
);
|
||||
menu.appendChild(createItem);
|
||||
|
||||
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);
|
||||
menu.appendChild(deleteItem);
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
menu.remove();
|
||||
}
|
||||
|
||||
return { element: menu, destroy };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* CertMismatchModal — shows a warning when the server TLS certificate
|
||||
* fingerprint has changed (TOFU mismatch). Gives the user the choice
|
||||
* to accept the new certificate or disconnect.
|
||||
*
|
||||
* Uses the existing .modal-overlay / .cert-* CSS classes from login.css.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface CertMismatchModalOptions {
|
||||
readonly host: string;
|
||||
readonly storedFingerprint: string;
|
||||
readonly newFingerprint: string;
|
||||
readonly onAccept: () => void;
|
||||
readonly onReject: () => void;
|
||||
}
|
||||
|
||||
export function createCertMismatchModal(
|
||||
options: CertMismatchModalOptions,
|
||||
): MountableComponent {
|
||||
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
const ac = new AbortController();
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", { class: "modal-overlay visible" });
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Certificate Warning");
|
||||
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
|
||||
setText(closeBtn, "\u2715");
|
||||
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = createElement("div", { class: "modal-body" });
|
||||
|
||||
const warning = createElement("div", { class: "cert-warning" });
|
||||
warning.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
||||
|
||||
const certTitle = createElement("div", { class: "cert-title" });
|
||||
setText(certTitle, "Certificate Changed");
|
||||
|
||||
const desc = createElement("div", { class: "cert-desc" });
|
||||
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.",
|
||||
);
|
||||
|
||||
const details = createElement("div", { class: "cert-details" });
|
||||
|
||||
const hostRow = buildRow("Host", host, false);
|
||||
const storedRow = buildRow("Previous", storedFingerprint, true);
|
||||
const newRow = buildRow("Current", newFingerprint, true);
|
||||
appendChildren(details, hostRow, storedRow, newRow);
|
||||
|
||||
appendChildren(body, warning, certTitle, desc, details);
|
||||
|
||||
// Footer
|
||||
const footer = createElement("div", { class: "modal-footer" });
|
||||
|
||||
const rejectBtn = createElement("button", {
|
||||
class: "btn-ghost",
|
||||
type: "button",
|
||||
});
|
||||
setText(rejectBtn, "Disconnect");
|
||||
rejectBtn.addEventListener("click", onReject, { signal: ac.signal });
|
||||
|
||||
const acceptBtn = createElement("button", {
|
||||
class: "btn-danger",
|
||||
type: "button",
|
||||
});
|
||||
setText(acceptBtn, "Accept New Certificate");
|
||||
acceptBtn.addEventListener("click", onAccept, { signal: ac.signal });
|
||||
|
||||
appendChildren(footer, rejectBtn, acceptBtn);
|
||||
|
||||
appendChildren(modal, header, body, footer);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (e.target === overlay) onReject();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (overlay !== null) {
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
|
||||
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);
|
||||
const valueClass = isFingerprint ? "cert-value cert-fingerprint" : "cert-value";
|
||||
const valueEl = createElement("span", { class: valueClass });
|
||||
setText(valueEl, value || "Unknown");
|
||||
appendChildren(row, labelEl, valueEl);
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
/**
|
||||
* ChannelSidebar component — channel list sidebar with categories,
|
||||
* unread indicators, and collapse/expand behavior.
|
||||
* Voice channels show connected users and join/leave on click.
|
||||
*/
|
||||
|
||||
import {
|
||||
createElement,
|
||||
setText,
|
||||
clearChildren,
|
||||
appendChildren,
|
||||
} from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import {
|
||||
channelsStore,
|
||||
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 { voiceStore, getChannelVoiceUsers } from "@stores/voice.store";
|
||||
import { setUserVolume, getUserVolume } from "@lib/voiceSession";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
const combinedSignal = signal;
|
||||
setTimeout(() => {
|
||||
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
|
||||
combinedSignal.addEventListener("abort", () => {
|
||||
menu.remove();
|
||||
dismissAc.abort();
|
||||
});
|
||||
}
|
||||
|
||||
export interface ChannelReorderData {
|
||||
readonly channelId: number;
|
||||
readonly newPosition: number;
|
||||
}
|
||||
|
||||
export interface ChannelSidebarOptions {
|
||||
readonly onVoiceJoin: (channelId: number) => void;
|
||||
readonly onVoiceLeave: () => void;
|
||||
/** Called when the user clicks the "+" on a category header. */
|
||||
readonly onCreateChannel?: (category: string) => void;
|
||||
/** Called when the user right-clicks a channel and selects Edit. */
|
||||
readonly onEditChannel?: (channel: Channel) => void;
|
||||
/** Called when the user right-clicks a channel and selects Delete. */
|
||||
readonly onDeleteChannel?: (channel: Channel) => void;
|
||||
/** Called when the user drags a channel to a new position. */
|
||||
readonly onReorderChannel?: (reorders: readonly ChannelReorderData[]) => 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 {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
hash = (hash * 31 + username.charCodeAt(i)) | 0;
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2";
|
||||
}
|
||||
|
||||
function renderTextChannelItem(
|
||||
channel: Channel,
|
||||
isActive: boolean,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const classes = [
|
||||
"channel-item",
|
||||
isActive ? "active" : "",
|
||||
channel.unreadCount > 0 ? "unread" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` });
|
||||
item.dataset.channelId = String(channel.id);
|
||||
|
||||
const prefix = createElement("span", { class: "ch-icon" }, "#");
|
||||
const name = createElement("span", { class: "ch-name" }, channel.name);
|
||||
|
||||
appendChildren(item, prefix, name);
|
||||
|
||||
if (channel.unreadCount > 0) {
|
||||
const badge = createElement(
|
||||
"span",
|
||||
{ class: "unread-badge" },
|
||||
String(channel.unreadCount),
|
||||
);
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
setActiveChannel(channel.id);
|
||||
clearUnread(channel.id);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderVoiceChannelItem(
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
): HTMLDivElement {
|
||||
const voiceState = voiceStore.getState();
|
||||
const isJoined = voiceState.currentChannelId === channel.id;
|
||||
|
||||
const wrapper = createElement("div", {});
|
||||
|
||||
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);
|
||||
|
||||
const prefix = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A");
|
||||
const name = createElement("span", { class: "ch-name" }, channel.name);
|
||||
|
||||
appendChildren(item, prefix, name);
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
if (isJoined) {
|
||||
onVoiceLeave();
|
||||
} else {
|
||||
onVoiceJoin(channel.id);
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
wrapper.appendChild(item);
|
||||
|
||||
// Render connected voice users below the channel
|
||||
const voiceUsers = getChannelVoiceUsers(channel.id);
|
||||
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 });
|
||||
|
||||
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",
|
||||
);
|
||||
row.appendChild(nameEl);
|
||||
|
||||
if (user.deafened) {
|
||||
// Deafened: show both crossed mic and crossed headphone
|
||||
const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4");
|
||||
const deafIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA7");
|
||||
row.appendChild(muteIcon);
|
||||
row.appendChild(deafIcon);
|
||||
} else if (user.muted) {
|
||||
// Muted only: show crossed mic
|
||||
const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4");
|
||||
row.appendChild(muteIcon);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
usersContainer.appendChild(row);
|
||||
}
|
||||
wrapper.appendChild(usersContainer);
|
||||
}
|
||||
|
||||
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. */
|
||||
let globalDragListenersAttached = false;
|
||||
|
||||
function ensureGlobalDragListeners(): void {
|
||||
if (globalDragListenersAttached) {
|
||||
return;
|
||||
}
|
||||
globalDragListenersAttached = true;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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,
|
||||
signal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onEditChannel?: (channel: Channel) => void,
|
||||
onDeleteChannel?: (channel: Channel) => void,
|
||||
containerEl?: HTMLElement,
|
||||
channels?: readonly Channel[],
|
||||
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
|
||||
): HTMLDivElement {
|
||||
let el: HTMLDivElement;
|
||||
if (channel.type === "voice") {
|
||||
el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave);
|
||||
} else {
|
||||
el = renderTextChannelItem(channel, isActive, signal);
|
||||
}
|
||||
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel);
|
||||
if (containerEl !== undefined && channels !== undefined) {
|
||||
attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderCategoryGroup(
|
||||
categoryName: string | null,
|
||||
channels: readonly Channel[],
|
||||
activeChannelId: number | null,
|
||||
signal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onCreateChannel?: (category: string) => void,
|
||||
onEditChannel?: (channel: Channel) => void,
|
||||
onDeleteChannel?: (channel: Channel) => void,
|
||||
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
|
||||
): HTMLDivElement {
|
||||
const group = createElement("div", {});
|
||||
|
||||
if (categoryName !== null) {
|
||||
const collapsed = isCategoryCollapsed(categoryName);
|
||||
const header = createElement("div", {
|
||||
class: collapsed ? "category collapsed" : "category",
|
||||
});
|
||||
header.dataset.category = categoryName;
|
||||
|
||||
const arrow = createElement(
|
||||
"span",
|
||||
{ class: "category-arrow" },
|
||||
collapsed ? "\u25B6" : "\u25BC",
|
||||
);
|
||||
const label = createElement("span", { class: "category-name" }, categoryName);
|
||||
|
||||
appendChildren(header, arrow, label);
|
||||
|
||||
if (onCreateChannel !== undefined) {
|
||||
const user = getCurrentUser();
|
||||
const role = user?.role?.toLowerCase() ?? "";
|
||||
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, "-")}`,
|
||||
}, "+");
|
||||
addBtn.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateChannel(categoryName);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
header.appendChild(addBtn);
|
||||
}
|
||||
}
|
||||
|
||||
header.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
toggleCategory(categoryName);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
group.appendChild(header);
|
||||
|
||||
if (!collapsed) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
group.appendChild(channelsContainer);
|
||||
}
|
||||
} else {
|
||||
// Uncategorized channels render directly
|
||||
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),
|
||||
);
|
||||
}
|
||||
group.appendChild(channelsContainer);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createChannelSidebar(options: ChannelSidebarOptions): MountableComponent {
|
||||
const { onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel } = options;
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let channelList: HTMLDivElement | null = null;
|
||||
let serverNameEl: HTMLSpanElement | null = null;
|
||||
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
function renderChannels(): void {
|
||||
if (channelList === null) {
|
||||
return;
|
||||
}
|
||||
clearChildren(channelList);
|
||||
|
||||
const grouped = getChannelsByCategory();
|
||||
const state = channelsStore.getState();
|
||||
|
||||
for (const [category, channels] of grouped) {
|
||||
channelList.appendChild(
|
||||
renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "channel-sidebar-header" });
|
||||
const authState = authStore.getState();
|
||||
serverNameEl = createElement(
|
||||
"h2",
|
||||
{},
|
||||
authState.serverName ?? "Server Name",
|
||||
);
|
||||
header.appendChild(serverNameEl);
|
||||
|
||||
// Channel list
|
||||
channelList = createElement("div", { class: "channel-list" });
|
||||
|
||||
appendChildren(root, header, channelList);
|
||||
container.appendChild(root);
|
||||
|
||||
// Initial render
|
||||
renderChannels();
|
||||
|
||||
// Subscribe to channels store changes
|
||||
const unsubChannels = channelsStore.subscribe(() => {
|
||||
renderChannels();
|
||||
});
|
||||
unsubscribers.push(unsubChannels);
|
||||
|
||||
// Subscribe to auth store for server name updates
|
||||
const unsubAuth = authStore.subscribe((state) => {
|
||||
if (serverNameEl !== null) {
|
||||
setText(serverNameEl, state.serverName ?? "Server Name");
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsubAuth);
|
||||
|
||||
// Subscribe to UI store for category collapse changes
|
||||
const unsubUi = uiStore.subscribe(() => {
|
||||
renderChannels();
|
||||
});
|
||||
unsubscribers.push(unsubUi);
|
||||
|
||||
// Subscribe to voice store for connected user updates
|
||||
const unsubVoice = voiceStore.subscribe(() => {
|
||||
renderChannels();
|
||||
});
|
||||
unsubscribers.push(unsubVoice);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub();
|
||||
}
|
||||
unsubscribers.length = 0;
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
channelList = null;
|
||||
serverNameEl = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* ConnectedOverlay — full-screen overlay shown after auth_ok,
|
||||
* displays server info while waiting for the ready event.
|
||||
* Matches login-mockup.html connected overlay structure.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
|
||||
export interface ConnectedOverlayOptions {
|
||||
readonly serverName: string;
|
||||
readonly username: string;
|
||||
readonly motd: string;
|
||||
readonly onReady: () => void;
|
||||
}
|
||||
|
||||
export interface ConnectedOverlayControl {
|
||||
readonly element: HTMLDivElement;
|
||||
/** Call when ready payload is received. */
|
||||
markReady(): void;
|
||||
/** Show the overlay (adds .visible class). */
|
||||
show(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const READY_DELAY_MS = 800;
|
||||
|
||||
function serverIconColor(name: string): string {
|
||||
const palette = [
|
||||
"#5865f2", "#57f287", "#fee75c", "#eb459e",
|
||||
"#ed4245", "#f0b232", "#2ecc71", "#e74c3c",
|
||||
] as const;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return palette[Math.abs(hash) % palette.length] ?? palette[0];
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
// Server icon with check badge
|
||||
const iconWrap = createElement("div", { class: "connected-icon-wrap" });
|
||||
const srvIcon = createElement("div", {
|
||||
class: "connected-srv-icon",
|
||||
style: `background:${serverIconColor(serverName)}`,
|
||||
});
|
||||
setText(srvIcon, serverName.charAt(0).toUpperCase());
|
||||
|
||||
// SVG checkmark badge (matches mockup)
|
||||
const checkBadge = createElement("div", { class: "connected-check-badge" });
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "3");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||
polyline.setAttribute("points", "20 6 9 17 4 12");
|
||||
svg.appendChild(polyline);
|
||||
checkBadge.appendChild(svg);
|
||||
appendChildren(iconWrap, srvIcon, checkBadge);
|
||||
|
||||
// Text elements
|
||||
const connectedText = createElement("div", {
|
||||
class: "connected-text",
|
||||
}, "Connected!");
|
||||
|
||||
const userText = createElement("div", {
|
||||
class: "connected-user",
|
||||
}, `Logged in as ${username}`);
|
||||
|
||||
const motdEl = createElement("div", { class: "connected-motd" });
|
||||
if (motd) {
|
||||
setText(motdEl, motd);
|
||||
}
|
||||
|
||||
// Loader with spinner
|
||||
const loader = createElement("div", { class: "connected-loader" });
|
||||
const spinner = createElement("div", { class: "spinner" });
|
||||
const loaderText = createElement("span", {}, "Loading server data...");
|
||||
appendChildren(loader, spinner, loaderText);
|
||||
|
||||
appendChildren(overlay, iconWrap, connectedText, userText, motdEl, loader);
|
||||
|
||||
function show(): void {
|
||||
overlay.classList.add("visible");
|
||||
}
|
||||
|
||||
function markReady(): void {
|
||||
if (ac.signal.aborted) return;
|
||||
|
||||
spinner.style.display = "none";
|
||||
setText(loaderText, "\u2714 Ready!");
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!ac.signal.aborted) {
|
||||
onReady();
|
||||
}
|
||||
}, READY_DELAY_MS);
|
||||
|
||||
ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
overlay.remove();
|
||||
}
|
||||
|
||||
return { element: overlay, markReady, show, destroy };
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* CreateChannelModal — modal for creating a new channel under a specific
|
||||
* category. The channel type is automatically restricted based on the
|
||||
* category: voice categories only allow voice channels, text categories
|
||||
* allow text and announcement channels.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { ChannelType } from "@lib/types";
|
||||
|
||||
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>;
|
||||
/** Called when the modal is closed without creating. */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
/** Returns true if the category name indicates a voice section. */
|
||||
export function isVoiceCategory(category: string): boolean {
|
||||
return category.toLowerCase().includes("voice");
|
||||
}
|
||||
|
||||
/** Returns the allowed channel types for a given category. */
|
||||
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 {
|
||||
const { category, onCreate, onClose } = options;
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
|
||||
const allowedTypes = allowedTypesForCategory(category);
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
class: "modal-overlay visible",
|
||||
"data-testid": "create-channel-modal",
|
||||
});
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Create Channel");
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
});
|
||||
setText(closeBtn, "\u2715");
|
||||
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = createElement("div", { class: "modal-body" });
|
||||
|
||||
// Category (read-only display)
|
||||
const categoryGroup = createElement("div", { class: "form-group" });
|
||||
const categoryLabel = createElement(
|
||||
"label",
|
||||
{ class: "form-label" },
|
||||
"Category",
|
||||
);
|
||||
const categoryDisplay = createElement("div", {
|
||||
class: "form-input",
|
||||
style: "opacity: 0.7; cursor: default;",
|
||||
});
|
||||
setText(categoryDisplay, category);
|
||||
appendChildren(categoryGroup, categoryLabel, categoryDisplay);
|
||||
|
||||
// Channel name
|
||||
const nameGroup = createElement("div", { class: "form-group" });
|
||||
const nameLabel = createElement("label", { class: "form-label" }, "Name");
|
||||
const nameInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: isVoiceCategory(category) ? "lounge" : "general",
|
||||
"data-testid": "channel-name-input",
|
||||
}) as HTMLInputElement;
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
|
||||
// Channel type
|
||||
const typeGroup = createElement("div", { class: "form-group" });
|
||||
const typeLabel = createElement("label", { class: "form-label" }, "Type");
|
||||
const typeSelect = createElement("select", {
|
||||
class: "form-input",
|
||||
"data-testid": "channel-type-select",
|
||||
}) as HTMLSelectElement;
|
||||
|
||||
for (const t of allowedTypes) {
|
||||
const opt = createElement(
|
||||
"option",
|
||||
{ value: t },
|
||||
t.charAt(0).toUpperCase() + t.slice(1),
|
||||
);
|
||||
typeSelect.appendChild(opt);
|
||||
}
|
||||
appendChildren(typeGroup, typeLabel, typeSelect);
|
||||
|
||||
// Error display
|
||||
const errorEl = createElement("div", {
|
||||
class: "form-group",
|
||||
style: "color: var(--red); font-size: 13px; display: none;",
|
||||
"data-testid": "channel-create-error",
|
||||
});
|
||||
|
||||
appendChildren(body, categoryGroup, nameGroup, typeGroup, errorEl);
|
||||
|
||||
// Footer
|
||||
const footer = createElement("div", { class: "modal-footer" });
|
||||
const cancelBtn = createElement(
|
||||
"button",
|
||||
{ class: "btn-modal-cancel", type: "button" },
|
||||
"Cancel",
|
||||
);
|
||||
cancelBtn.addEventListener("click", onClose, { signal: ac.signal });
|
||||
|
||||
const createBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn-modal-save",
|
||||
type: "button",
|
||||
"data-testid": "channel-create-submit",
|
||||
},
|
||||
"Create Channel",
|
||||
);
|
||||
|
||||
createBtn.addEventListener(
|
||||
"click",
|
||||
async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (name === "") {
|
||||
errorEl.style.display = "block";
|
||||
setText(errorEl, "Channel name is required");
|
||||
nameInput.classList.add("error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous errors
|
||||
errorEl.style.display = "none";
|
||||
nameInput.classList.remove("error");
|
||||
createBtn.setAttribute("disabled", "true");
|
||||
setText(createBtn, "Creating...");
|
||||
|
||||
try {
|
||||
await onCreate({
|
||||
name,
|
||||
type: typeSelect.value as ChannelType,
|
||||
category,
|
||||
});
|
||||
} catch (err) {
|
||||
errorEl.style.display = "block";
|
||||
setText(
|
||||
errorEl,
|
||||
err instanceof Error ? err.message : "Failed to create channel",
|
||||
);
|
||||
createBtn.removeAttribute("disabled");
|
||||
setText(createBtn, "Create Channel");
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(footer, cancelBtn, createBtn);
|
||||
appendChildren(modal, header, body, footer);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (e.target === overlay) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
|
||||
// Focus the name input
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (overlay !== null) {
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* DeleteChannelModal — confirmation dialog for deleting a channel.
|
||||
* Shows channel name and requires explicit confirmation.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface DeleteChannelModalOptions {
|
||||
readonly channelId: number;
|
||||
readonly channelName: string;
|
||||
readonly onConfirm: () => Promise<void>;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function createDeleteChannelModal(
|
||||
options: DeleteChannelModalOptions,
|
||||
): MountableComponent {
|
||||
const { channelName, onConfirm, onClose } = options;
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
class: "modal-overlay visible",
|
||||
"data-testid": "delete-channel-modal",
|
||||
});
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Delete Channel");
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
});
|
||||
setText(closeBtn, "\u2715");
|
||||
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = createElement("div", { class: "modal-body" });
|
||||
const warning = createElement("div", { class: "modal-danger-text" });
|
||||
warning.innerHTML = `Are you sure you want to delete <strong>#${channelName}</strong>? This action cannot be undone and all messages in this channel will be lost.`;
|
||||
body.appendChild(warning);
|
||||
|
||||
// Error display
|
||||
const errorEl = createElement("div", {
|
||||
style: "color: var(--red); font-size: 13px; display: none; margin-top: 8px;",
|
||||
"data-testid": "delete-channel-error",
|
||||
});
|
||||
body.appendChild(errorEl);
|
||||
|
||||
// Footer
|
||||
const footer = createElement("div", { class: "modal-footer" });
|
||||
const cancelBtn = createElement(
|
||||
"button",
|
||||
{ class: "btn-modal-cancel", type: "button" },
|
||||
"Cancel",
|
||||
);
|
||||
cancelBtn.addEventListener("click", onClose, { signal: ac.signal });
|
||||
|
||||
const deleteBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn-danger",
|
||||
type: "button",
|
||||
"data-testid": "delete-channel-confirm",
|
||||
},
|
||||
"Delete Channel",
|
||||
);
|
||||
|
||||
deleteBtn.addEventListener(
|
||||
"click",
|
||||
async () => {
|
||||
deleteBtn.setAttribute("disabled", "true");
|
||||
setText(deleteBtn, "Deleting...");
|
||||
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch (err) {
|
||||
errorEl.style.display = "block";
|
||||
setText(
|
||||
errorEl,
|
||||
err instanceof Error ? err.message : "Failed to delete channel",
|
||||
);
|
||||
deleteBtn.removeAttribute("disabled");
|
||||
setText(deleteBtn, "Delete Channel");
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(footer, cancelBtn, deleteBtn);
|
||||
appendChildren(modal, header, body, footer);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (e.target === overlay) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (overlay !== null) {
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* DmSidebar component — direct messages sidebar showing conversations
|
||||
* sorted by most recent, with unread indicators.
|
||||
*
|
||||
* Uses the `channel-sidebar` container class (shared with channel sidebar)
|
||||
* and DM-specific classes from app.css: dm-sidebar-header, dm-search,
|
||||
* dm-nav-item, dm-section-label, dm-add, dm-item, dm-avatar, dm-status,
|
||||
* dm-name, dm-close, dm-unread.
|
||||
*/
|
||||
|
||||
import {
|
||||
createElement,
|
||||
setText,
|
||||
clearChildren,
|
||||
appendChildren,
|
||||
} from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface DmConversation {
|
||||
readonly userId: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
readonly avatarColor?: string;
|
||||
readonly status?: "online" | "idle" | "dnd" | "offline";
|
||||
readonly lastMessage: string;
|
||||
readonly timestamp: string;
|
||||
readonly unread: boolean;
|
||||
readonly active?: boolean;
|
||||
}
|
||||
|
||||
export interface DmSidebarOptions {
|
||||
readonly conversations: readonly DmConversation[];
|
||||
readonly onSelectConversation: (userId: number) => void;
|
||||
readonly onNewDm: () => void;
|
||||
readonly onCloseDm?: (userId: number) => void;
|
||||
readonly onFriendsClick?: () => void;
|
||||
readonly friendsActive?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: "var(--green)",
|
||||
idle: "var(--yellow)",
|
||||
dnd: "var(--red)",
|
||||
offline: "var(--text-micro)",
|
||||
};
|
||||
|
||||
function renderDmItem(
|
||||
convo: DmConversation,
|
||||
onSelect: (userId: number) => void,
|
||||
onClose: ((userId: number) => void) | undefined,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const item = createElement("div", { class: "dm-item" });
|
||||
if (convo.active === true) {
|
||||
item.classList.add("active");
|
||||
}
|
||||
item.dataset.userId = String(convo.userId);
|
||||
|
||||
// Avatar with status dot
|
||||
const avatarBg = convo.avatarColor ?? "#5865F2";
|
||||
const avatar = createElement("div", { class: "dm-avatar" });
|
||||
avatar.style.background = avatarBg;
|
||||
|
||||
if (convo.avatar !== null) {
|
||||
const img = createElement("img", {
|
||||
src: convo.avatar,
|
||||
alt: convo.username,
|
||||
});
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.borderRadius = "50%";
|
||||
avatar.appendChild(img);
|
||||
} else {
|
||||
setText(avatar, convo.username.charAt(0).toUpperCase());
|
||||
}
|
||||
|
||||
// Status indicator dot
|
||||
const statusKey = convo.status ?? "offline";
|
||||
const statusDot = createElement("span", { class: "dm-status" });
|
||||
statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)";
|
||||
avatar.appendChild(statusDot);
|
||||
|
||||
// Username
|
||||
const name = createElement("span", { class: "dm-name" }, convo.username);
|
||||
|
||||
// Close button (hidden by default, shown on hover via CSS)
|
||||
const closeBtn = createElement("button", {
|
||||
class: "dm-close",
|
||||
title: "Close DM",
|
||||
});
|
||||
setText(closeBtn, "\u00d7");
|
||||
closeBtn.addEventListener(
|
||||
"click",
|
||||
(e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (onClose !== undefined) {
|
||||
onClose(convo.userId);
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
appendChildren(item, avatar, name, closeBtn);
|
||||
|
||||
// Unread dot
|
||||
if (convo.unread) {
|
||||
const unreadDot = createElement("span", { class: "dm-unread" });
|
||||
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.classList.add("active");
|
||||
onSelect(convo.userId);
|
||||
}, { signal });
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
// Reuse channel-sidebar container class per mockup
|
||||
root = createElement("div", { class: "channel-sidebar" });
|
||||
|
||||
// Search header
|
||||
const header = createElement("div", { class: "dm-sidebar-header" });
|
||||
const searchInput = createElement("input", {
|
||||
class: "dm-search",
|
||||
placeholder: "Find a conversation",
|
||||
});
|
||||
header.appendChild(searchInput);
|
||||
|
||||
// Friends nav item
|
||||
const friendsNav = createElement("div", { class: "dm-nav-item" });
|
||||
if (options.friendsActive === true) {
|
||||
friendsNav.classList.add("active");
|
||||
}
|
||||
setText(friendsNav, "Friends");
|
||||
friendsNav.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
if (options.onFriendsClick !== undefined) {
|
||||
options.onFriendsClick();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
// Section label with + button
|
||||
const sectionLabel = createElement("div", { class: "dm-section-label" });
|
||||
setText(sectionLabel, "Direct Messages");
|
||||
const addBtn = createElement("button", {
|
||||
class: "dm-add",
|
||||
title: "New DM",
|
||||
});
|
||||
setText(addBtn, "+");
|
||||
addBtn.addEventListener("click", () => options.onNewDm(), { signal: ac.signal });
|
||||
sectionLabel.appendChild(addBtn);
|
||||
|
||||
// Conversation list
|
||||
const sorted = [...options.conversations].sort(
|
||||
(a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0),
|
||||
);
|
||||
|
||||
const items = sorted.map((convo) =>
|
||||
renderDmItem(convo, options.onSelectConversation, options.onCloseDm, ac.signal),
|
||||
);
|
||||
|
||||
appendChildren(root, header, friendsNav, sectionLabel, ...items);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* EditChannelModal — modal for editing an existing channel's name and topic.
|
||||
* Only visible to admin/owner users.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface EditChannelModalOptions {
|
||||
/** Current channel ID. */
|
||||
readonly channelId: number;
|
||||
/** Current channel name. */
|
||||
readonly channelName: string;
|
||||
/** Current channel type (displayed, not editable). */
|
||||
readonly channelType: string;
|
||||
/** Called when the user saves changes. */
|
||||
readonly onSave: (data: { name: string }) => Promise<void>;
|
||||
/** Called when the modal is closed. */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function createEditChannelModal(
|
||||
options: EditChannelModalOptions,
|
||||
): MountableComponent {
|
||||
const { channelName, channelType, onSave, onClose } = options;
|
||||
const ac = new AbortController();
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
overlay = createElement("div", {
|
||||
class: "modal-overlay visible",
|
||||
"data-testid": "edit-channel-modal",
|
||||
});
|
||||
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Edit Channel");
|
||||
const closeBtn = createElement("button", {
|
||||
class: "modal-close",
|
||||
type: "button",
|
||||
});
|
||||
setText(closeBtn, "\u2715");
|
||||
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = createElement("div", { class: "modal-body" });
|
||||
|
||||
// Channel type (read-only)
|
||||
const typeGroup = createElement("div", { class: "form-group" });
|
||||
const typeLabel = createElement("label", { class: "form-label" }, "Type");
|
||||
const typeDisplay = createElement("div", {
|
||||
class: "form-input",
|
||||
style: "opacity: 0.7; cursor: default;",
|
||||
});
|
||||
setText(typeDisplay, channelType.charAt(0).toUpperCase() + channelType.slice(1));
|
||||
appendChildren(typeGroup, typeLabel, typeDisplay);
|
||||
|
||||
// Channel name
|
||||
const nameGroup = createElement("div", { class: "form-group" });
|
||||
const nameLabel = createElement("label", { class: "form-label" }, "Name");
|
||||
const nameInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
value: channelName,
|
||||
"data-testid": "edit-channel-name-input",
|
||||
}) as HTMLInputElement;
|
||||
nameInput.value = channelName;
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
|
||||
// Error display
|
||||
const errorEl = createElement("div", {
|
||||
class: "form-group",
|
||||
style: "color: var(--red); font-size: 13px; display: none;",
|
||||
"data-testid": "edit-channel-error",
|
||||
});
|
||||
|
||||
appendChildren(body, typeGroup, nameGroup, errorEl);
|
||||
|
||||
// Footer
|
||||
const footer = createElement("div", { class: "modal-footer" });
|
||||
const cancelBtn = createElement(
|
||||
"button",
|
||||
{ class: "btn-modal-cancel", type: "button" },
|
||||
"Cancel",
|
||||
);
|
||||
cancelBtn.addEventListener("click", onClose, { signal: ac.signal });
|
||||
|
||||
const saveBtn = createElement(
|
||||
"button",
|
||||
{
|
||||
class: "btn-modal-save",
|
||||
type: "button",
|
||||
"data-testid": "edit-channel-submit",
|
||||
},
|
||||
"Save Changes",
|
||||
);
|
||||
|
||||
saveBtn.addEventListener(
|
||||
"click",
|
||||
async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (name === "") {
|
||||
errorEl.style.display = "block";
|
||||
setText(errorEl, "Channel name is required");
|
||||
nameInput.classList.add("error");
|
||||
return;
|
||||
}
|
||||
|
||||
errorEl.style.display = "none";
|
||||
nameInput.classList.remove("error");
|
||||
saveBtn.setAttribute("disabled", "true");
|
||||
setText(saveBtn, "Saving...");
|
||||
|
||||
try {
|
||||
await onSave({ name });
|
||||
} catch (err) {
|
||||
errorEl.style.display = "block";
|
||||
setText(
|
||||
errorEl,
|
||||
err instanceof Error ? err.message : "Failed to update channel",
|
||||
);
|
||||
saveBtn.removeAttribute("disabled");
|
||||
setText(saveBtn, "Save Changes");
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(footer, cancelBtn, saveBtn);
|
||||
appendChildren(modal, header, body, footer);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
// Close on backdrop click
|
||||
overlay.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (e.target === overlay) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
container.appendChild(overlay);
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (overlay !== null) {
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// EmojiPicker — grid-based emoji selector with search and scrollable categories.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CustomEmoji {
|
||||
readonly shortcode: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface EmojiPickerOptions {
|
||||
readonly customEmoji?: readonly CustomEmoji[];
|
||||
readonly onSelect: (emoji: string) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in emoji data (common subset by category)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EmojiCategory {
|
||||
readonly name: string;
|
||||
readonly emoji: readonly string[];
|
||||
}
|
||||
|
||||
const CATEGORIES: readonly EmojiCategory[] = [
|
||||
{
|
||||
name: "Recent",
|
||||
emoji: [], // populated at runtime from localStorage
|
||||
},
|
||||
{
|
||||
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",
|
||||
"💀": "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",
|
||||
"🙏": "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",
|
||||
};
|
||||
|
||||
const MAX_RECENT = 20;
|
||||
const RECENT_KEY = "owncord:recent-emoji";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent emoji persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getRecentEmoji(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((e): e is string => typeof e === "string").slice(0, MAX_RECENT);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentEmoji(emoji: string): void {
|
||||
const recent = getRecentEmoji().filter((e) => e !== emoji);
|
||||
recent.unshift(emoji);
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));
|
||||
} catch {
|
||||
// localStorage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmojiPicker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
readonly element: HTMLDivElement;
|
||||
destroy(): void;
|
||||
} {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
let searchQuery = "";
|
||||
|
||||
// Build DOM — matches mockup structure:
|
||||
// .emoji-picker.open > .ep-header > input.ep-search
|
||||
// then repeating: .ep-category-label + .ep-grid > span.ep-emoji
|
||||
const root = createElement("div", { class: "emoji-picker open" });
|
||||
|
||||
const header = createElement("div", { class: "ep-header" });
|
||||
const searchInput = createElement("input", {
|
||||
class: "ep-search",
|
||||
type: "text",
|
||||
placeholder: "Search emoji...",
|
||||
});
|
||||
header.appendChild(searchInput);
|
||||
root.appendChild(header);
|
||||
|
||||
// Scrollable content area (holds category labels + grids)
|
||||
const scrollArea = createElement("div", {
|
||||
style: "overflow-y: auto; max-height: 320px;",
|
||||
});
|
||||
root.appendChild(scrollArea);
|
||||
|
||||
// Build categories with recent + custom
|
||||
function getAllCategories(): readonly EmojiCategory[] {
|
||||
const recent = getRecentEmoji();
|
||||
const cats: EmojiCategory[] = [
|
||||
{ name: "Recent", emoji: recent },
|
||||
];
|
||||
|
||||
// Custom server emoji
|
||||
if (options.customEmoji && options.customEmoji.length > 0) {
|
||||
cats.push({
|
||||
name: "Custom",
|
||||
emoji: options.customEmoji.map((e) => `:${e.shortcode}:`),
|
||||
});
|
||||
}
|
||||
|
||||
// Add built-in categories (skip the empty "Recent" placeholder)
|
||||
for (const cat of CATEGORIES) {
|
||||
if (cat.name === "Recent") continue;
|
||||
cats.push(cat);
|
||||
}
|
||||
|
||||
return cats;
|
||||
}
|
||||
|
||||
function handleEmojiClick(emoji: string): void {
|
||||
addRecentEmoji(emoji);
|
||||
options.onSelect(emoji);
|
||||
}
|
||||
|
||||
function buildEmojiSpan(emoji: string): HTMLSpanElement {
|
||||
const span = createElement("span", {
|
||||
class: "ep-emoji",
|
||||
title: emoji,
|
||||
});
|
||||
setText(span, emoji);
|
||||
span.addEventListener("click", () => handleEmojiClick(emoji), { signal });
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderAllCategories(categories: readonly EmojiCategory[]): void {
|
||||
clearChildren(scrollArea);
|
||||
|
||||
for (const cat of categories) {
|
||||
if (cat.emoji.length === 0) continue;
|
||||
|
||||
const filtered = searchQuery
|
||||
? cat.emoji.filter((e) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
// Match against emoji name/keywords, or the character itself
|
||||
const name = EMOJI_NAMES[e];
|
||||
if (name !== undefined && name.includes(q)) return true;
|
||||
// Also match custom emoji shortcodes like :wave:
|
||||
return e.toLowerCase().includes(q);
|
||||
})
|
||||
: cat.emoji;
|
||||
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
const label = createElement("div", { class: "ep-category-label" });
|
||||
setText(label, cat.name);
|
||||
scrollArea.appendChild(label);
|
||||
|
||||
const grid = createElement("div", { class: "ep-grid" });
|
||||
for (const emoji of filtered) {
|
||||
grid.appendChild(buildEmojiSpan(emoji));
|
||||
}
|
||||
scrollArea.appendChild(grid);
|
||||
}
|
||||
|
||||
// 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");
|
||||
scrollArea.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderAllCategories(getAllCategories());
|
||||
|
||||
// Search handler
|
||||
searchInput.addEventListener("input", () => {
|
||||
searchQuery = searchInput.value.trim();
|
||||
renderAllCategories(getAllCategories());
|
||||
}, { signal });
|
||||
|
||||
// Close on Escape
|
||||
root.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
options.onClose();
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
// Focus search on mount
|
||||
requestAnimationFrame(() => searchInput.focus());
|
||||
|
||||
function destroy(): void {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
return { element: root, destroy };
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Step 8.59 — File upload component with drag-and-drop, preview, and progress.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface FileUploadOptions {
|
||||
readonly onUpload: (file: File) => Promise<void>;
|
||||
readonly maxSizeMb?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SIZE_MB = 10;
|
||||
|
||||
export type FileUploadComponent = MountableComponent & { openPicker(): void };
|
||||
|
||||
export function createFileUpload(options: FileUploadOptions): FileUploadComponent {
|
||||
const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
|
||||
let root: HTMLDivElement | null = null;
|
||||
let dropzone: HTMLDivElement;
|
||||
let fileInput: HTMLInputElement;
|
||||
let preview: HTMLDivElement;
|
||||
let thumb: HTMLImageElement;
|
||||
let nameSpan: HTMLSpanElement;
|
||||
let sizeSpan: HTMLSpanElement;
|
||||
let progressBar: HTMLDivElement;
|
||||
let cancelBtn: HTMLButtonElement;
|
||||
let errorDiv: HTMLDivElement;
|
||||
let uploadAbort: AbortController | null = null;
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
setText(errorDiv, message);
|
||||
errorDiv.classList.remove("file-upload__error--hidden");
|
||||
preview.classList.add("file-upload__preview--hidden");
|
||||
}
|
||||
|
||||
function resetPreview(): void {
|
||||
preview.classList.add("file-upload__preview--hidden");
|
||||
thumb.src = "";
|
||||
thumb.style.display = "none";
|
||||
setText(nameSpan, "");
|
||||
setText(sizeSpan, "");
|
||||
progressBar.style.width = "0%";
|
||||
uploadAbort = null;
|
||||
errorDiv.classList.add("file-upload__error--hidden");
|
||||
}
|
||||
|
||||
function showPreview(file: File): void {
|
||||
resetPreview();
|
||||
setText(nameSpan, file.name);
|
||||
setText(sizeSpan, formatSize(file.size));
|
||||
if (file.type.startsWith("image/")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
thumb.src = url;
|
||||
thumb.style.display = "block";
|
||||
thumb.onload = () => URL.revokeObjectURL(url);
|
||||
}
|
||||
preview.classList.remove("file-upload__preview--hidden");
|
||||
}
|
||||
|
||||
async function handleFile(file: File): Promise<void> {
|
||||
errorDiv.classList.add("file-upload__error--hidden");
|
||||
if (file.size > maxBytes) {
|
||||
showError(`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`);
|
||||
return;
|
||||
}
|
||||
showPreview(file);
|
||||
uploadAbort = new AbortController();
|
||||
try {
|
||||
progressBar.style.width = "50%";
|
||||
await options.onUpload(file);
|
||||
progressBar.style.width = "100%";
|
||||
setTimeout(() => resetPreview(), 1500);
|
||||
} catch (err) {
|
||||
if (uploadAbort?.signal.aborted) return;
|
||||
showError(err instanceof Error ? err.message : "Upload failed");
|
||||
resetPreview();
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
fileInput = createElement("input", { class: "file-upload__input", type: "file" }) as HTMLInputElement;
|
||||
fileInput.style.display = "none";
|
||||
|
||||
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
|
||||
thumb = createElement("img", { class: "file-upload__thumb" }) as HTMLImageElement;
|
||||
thumb.style.display = "none";
|
||||
thumb.alt = "";
|
||||
nameSpan = createElement("span", { class: "file-upload__name" }) as HTMLSpanElement;
|
||||
sizeSpan = createElement("span", { class: "file-upload__size" }) as HTMLSpanElement;
|
||||
const progressContainer = createElement("div", { class: "file-upload__progress" });
|
||||
progressBar = createElement("div", { class: "file-upload__progress-bar" });
|
||||
progressBar.style.width = "0%";
|
||||
appendChildren(progressContainer, progressBar);
|
||||
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" }, "\u00d7") as HTMLButtonElement;
|
||||
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
|
||||
|
||||
errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" });
|
||||
appendChildren(root, dropzone, fileInput, preview, errorDiv);
|
||||
}
|
||||
|
||||
function attachListeners(): void {
|
||||
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 });
|
||||
|
||||
let dragCounter = 0;
|
||||
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("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 });
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
buildDom();
|
||||
attachListeners();
|
||||
container.appendChild(root!);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (uploadAbort !== null) uploadAbort.abort();
|
||||
root?.remove();
|
||||
root = null;
|
||||
}
|
||||
|
||||
function openPicker(): void { fileInput.click(); }
|
||||
|
||||
return { mount, destroy, openPicker };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* InviteManager component — modal overlay for managing server invites.
|
||||
* Create, copy, and revoke invite codes.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InviteItem {
|
||||
readonly code: string;
|
||||
readonly createdBy: string;
|
||||
readonly createdAt: string;
|
||||
readonly uses: number;
|
||||
readonly maxUses: number | null;
|
||||
readonly expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface InviteManagerOptions {
|
||||
invites: readonly InviteItem[];
|
||||
onCreateInvite(): Promise<InviteItem>;
|
||||
onRevokeInvite(code: string): Promise<void>;
|
||||
onCopyLink(code: string): void;
|
||||
onClose(): void;
|
||||
onError?(message: string): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function maskCode(code: string): string {
|
||||
if (code.length <= 6) return code;
|
||||
return `${code.slice(0, 3)}...${code.slice(-3)}`;
|
||||
}
|
||||
|
||||
function formatInviteInfo(invite: InviteItem): string {
|
||||
const uses = invite.maxUses !== null
|
||||
? `${invite.uses}/${invite.maxUses} uses`
|
||||
: `${invite.uses} uses`;
|
||||
return `Created by ${invite.createdBy} \u00B7 ${uses}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createInviteManager(
|
||||
options: InviteManagerOptions,
|
||||
): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let listEl: HTMLDivElement | null = null;
|
||||
let emptyEl: HTMLDivElement | null = null;
|
||||
let invites: readonly InviteItem[] = options.invites;
|
||||
|
||||
function renderList(): void {
|
||||
if (listEl === null || emptyEl === null) return;
|
||||
clearChildren(listEl);
|
||||
|
||||
if (invites.length === 0) {
|
||||
emptyEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
|
||||
for (const invite of invites) {
|
||||
const row = createElement("div", { class: "invite-item" });
|
||||
const code = createElement("span", { class: "invite-item__code" }, maskCode(invite.code));
|
||||
const info = createElement("span", { class: "invite-item__info" }, formatInviteInfo(invite));
|
||||
|
||||
const copyBtn = createElement("button", { class: "invite-item__copy" }, "Copy");
|
||||
copyBtn.addEventListener("click", () => {
|
||||
options.onCopyLink(invite.code);
|
||||
}, { signal: ac.signal });
|
||||
|
||||
const revokeBtn = createElement("button", { class: "invite-item__revoke" }, "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 });
|
||||
|
||||
appendChildren(row, code, info, copyBtn, revokeBtn);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", {
|
||||
class: "invite-manager-overlay",
|
||||
style: "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;display:flex;justify-content:center;align-items:center;",
|
||||
});
|
||||
|
||||
const modal = createElement("div", {
|
||||
class: "invite-manager",
|
||||
style: "background:var(--bg-secondary,#2f3136);border-radius:8px;padding:16px;min-width:400px;max-width:520px;",
|
||||
});
|
||||
|
||||
// Header
|
||||
const header = createElement("div", { class: "invite-manager__header" });
|
||||
const title = createElement("h2", {}, "Server Invites");
|
||||
const closeBtn = createElement("button", { class: "invite-manager__close" }, "\u00D7");
|
||||
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
// Create button
|
||||
const createBtn = createElement("button", { class: "invite-manager__create" }, "Create Invite");
|
||||
createBtn.addEventListener("click", () => {
|
||||
void options.onCreateInvite().then((newInvite) => {
|
||||
invites = [...invites, newInvite];
|
||||
renderList();
|
||||
}).catch(() => {
|
||||
options.onError?.("Failed to create invite");
|
||||
});
|
||||
}, { signal: ac.signal });
|
||||
|
||||
// List
|
||||
listEl = createElement("div", { class: "invite-manager__list" });
|
||||
emptyEl = createElement("div", { class: "invite-manager__empty" }, "No active invites");
|
||||
|
||||
// Escape key
|
||||
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 });
|
||||
|
||||
appendChildren(modal, header, createBtn, listEl, emptyEl);
|
||||
root.appendChild(modal);
|
||||
renderList();
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
listEl = null;
|
||||
emptyEl = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* MemberList component — shows server members grouped by role with online status.
|
||||
* Subscribes to membersStore for reactive updates.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { membersStore, type Member } from "@stores/members.store";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
|
||||
/** Ordered role groups with display names and CSS color variables. */
|
||||
const ROLE_GROUPS: readonly {
|
||||
readonly role: string;
|
||||
readonly label: string;
|
||||
readonly colorVar: string;
|
||||
}[] = [
|
||||
{ role: "owner", label: "OWNER", colorVar: "var(--role-owner, #e74c3c)" },
|
||||
{ role: "admin", label: "ADMIN", colorVar: "var(--role-admin, #f39c12)" },
|
||||
{ role: "moderator", label: "MODERATOR", colorVar: "var(--role-mod, #2ecc71)" },
|
||||
{ role: "member", label: "MEMBER", colorVar: "var(--role-member, #949ba4)" },
|
||||
] as const;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
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)";
|
||||
}
|
||||
}
|
||||
|
||||
function createMemberItem(member: Member, colorVar: string): HTMLDivElement {
|
||||
const item = createElement("div", {
|
||||
class: member.status === "offline" ? "member-item offline" : "member-item",
|
||||
"data-testid": `member-${member.id}`,
|
||||
});
|
||||
|
||||
const initial = member.username.charAt(0).toUpperCase() || "?";
|
||||
const avatar = createElement(
|
||||
"div",
|
||||
{ class: "mi-avatar", style: `background: ${colorVar}` },
|
||||
initial,
|
||||
);
|
||||
|
||||
const statusDot = createElement("div", {
|
||||
class: "mi-status",
|
||||
style: `background: ${statusColor(member.status)}`,
|
||||
});
|
||||
avatar.appendChild(statusDot);
|
||||
|
||||
const name = createElement(
|
||||
"span",
|
||||
{ class: "mi-name", style: `color: ${colorVar}` },
|
||||
);
|
||||
setText(name, member.username);
|
||||
|
||||
appendChildren(item, avatar, name);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderList(root: HTMLDivElement): void {
|
||||
clearChildren(root);
|
||||
|
||||
const state = membersStore.getState();
|
||||
const allMembers = Array.from(state.members.values());
|
||||
|
||||
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));
|
||||
|
||||
if (groupMembers.length === 0) continue;
|
||||
|
||||
const header = createElement(
|
||||
"div",
|
||||
{ class: "member-role-group" },
|
||||
`${group.label} \u2014 ${groupMembers.length}`,
|
||||
);
|
||||
root.appendChild(header);
|
||||
|
||||
for (const member of groupMembers) {
|
||||
root.appendChild(createMemberItem(member, group.colorVar));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemberList(): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
|
||||
renderList(root);
|
||||
|
||||
unsubscribe = membersStore.subscribe(() => {
|
||||
if (root !== null) {
|
||||
renderList(root);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (unsubscribe !== null) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* MessageInput component — textarea with send, reply bar, and edit mode.
|
||||
* Step 5.42 of the Tauri v2 migration.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { createEmojiPicker } from "@components/EmojiPicker";
|
||||
|
||||
export interface MessageInputOptions {
|
||||
readonly channelId: number;
|
||||
readonly channelName: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export type MessageInputComponent = MountableComponent & {
|
||||
setReplyTo(messageId: number, username: string): void;
|
||||
clearReply(): void;
|
||||
startEdit(messageId: number, content: string): void;
|
||||
cancelEdit(): void;
|
||||
};
|
||||
|
||||
const TYPING_THROTTLE_MS = 3_000;
|
||||
const MAX_TEXTAREA_HEIGHT = 200;
|
||||
const SEND_DEBOUNCE_MS = 200;
|
||||
|
||||
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 lastTypingTime = 0;
|
||||
let lastSendTime = 0;
|
||||
|
||||
let textarea: HTMLTextAreaElement | null = null;
|
||||
let replyBar: HTMLDivElement | null = null;
|
||||
let replyText: HTMLSpanElement | null = null;
|
||||
let editBar: HTMLDivElement | null = null;
|
||||
let attachmentPreviewBar: HTMLDivElement | null = null;
|
||||
|
||||
/** Pending attachment IDs to send with the next message. */
|
||||
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = [];
|
||||
|
||||
function showReplyBar(username: string): void {
|
||||
if (replyBar === null || replyText === null) return;
|
||||
setText(replyText, `Replying to @${username}`);
|
||||
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 autoResize(): void {
|
||||
if (textarea === null) return;
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`;
|
||||
}
|
||||
|
||||
function maybeEmitTyping(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastTypingTime >= TYPING_THROTTLE_MS) {
|
||||
lastTypingTime = now;
|
||||
options.onTyping();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingAttachments(): void {
|
||||
for (const att of pendingAttachments) {
|
||||
att.previewEl.remove();
|
||||
}
|
||||
pendingAttachments.length = 0;
|
||||
if (attachmentPreviewBar !== null) {
|
||||
attachmentPreviewBar.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSend(): void {
|
||||
if (textarea === null) return;
|
||||
const content = textarea.value.trim();
|
||||
const hasAttachments = pendingAttachments.length > 0;
|
||||
if (content.length === 0 && !hasAttachments) return;
|
||||
|
||||
// Debounce to prevent double-click duplicate sends
|
||||
const now = Date.now();
|
||||
if (now - lastSendTime < SEND_DEBOUNCE_MS) return;
|
||||
lastSendTime = now;
|
||||
|
||||
if (state.editing !== null) {
|
||||
options.onEditMessage(state.editing.messageId, content);
|
||||
cancelEdit();
|
||||
} else {
|
||||
// Only include attachments that have finished uploading (have a real server ID)
|
||||
const attachmentIds = pendingAttachments
|
||||
.filter((a) => !a.id.startsWith("pending-"))
|
||||
.map((a) => a.id);
|
||||
options.onSend(content, state.replyTo?.messageId ?? null, attachmentIds);
|
||||
clearReply();
|
||||
clearPendingAttachments();
|
||||
}
|
||||
|
||||
textarea.value = "";
|
||||
autoResize();
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
/** Unique counter for preview items (before upload completes and we have a server ID). */
|
||||
let previewCounter = 0;
|
||||
|
||||
function removePreviewItem(tempId: string): void {
|
||||
const idx = pendingAttachments.findIndex((a) => a.id === tempId);
|
||||
const att = idx !== -1 ? pendingAttachments[idx] : undefined;
|
||||
if (att !== undefined) {
|
||||
const img = att.previewEl.querySelector("img");
|
||||
if (img !== null && img.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
att.previewEl.remove();
|
||||
pendingAttachments.splice(idx, 1);
|
||||
if (pendingAttachments.length === 0) {
|
||||
attachmentPreviewBar?.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */
|
||||
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.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePasteFile(file: File): Promise<void> {
|
||||
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
|
||||
|
||||
const tempId = `pending-${++previewCounter}`;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
attachmentPreviewBar.classList.add("visible");
|
||||
|
||||
const item = createElement("div", { class: "attachment-preview-item uploading" });
|
||||
|
||||
if (isImage) {
|
||||
// Read file as data URL for preview (works reliably in WebView2)
|
||||
const img = createElement("img", {
|
||||
class: "attachment-preview-img",
|
||||
alt: file.name,
|
||||
}) as HTMLImageElement;
|
||||
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);
|
||||
});
|
||||
} else {
|
||||
const icon = createElement("div", { class: "attachment-preview-file" }, "\uD83D\uDCC4");
|
||||
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" }, "\u23F3");
|
||||
item.appendChild(spinner);
|
||||
|
||||
const removeBtn = createElement("button", {
|
||||
class: "attachment-preview-remove",
|
||||
"data-testid": "attachment-remove",
|
||||
}, "\u00D7");
|
||||
removeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
removePreviewItem(tempId);
|
||||
}, { signal });
|
||||
item.appendChild(removeBtn);
|
||||
|
||||
attachmentPreviewBar.appendChild(item);
|
||||
pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item });
|
||||
|
||||
// Upload in background
|
||||
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;
|
||||
item.classList.remove("uploading");
|
||||
spinner.remove();
|
||||
}
|
||||
} catch (err) {
|
||||
// Upload failed — remove preview and show error
|
||||
removePreviewItem(tempId);
|
||||
const errMsg = err instanceof Error ? err.message : "Upload failed";
|
||||
// Show error inline since we may not have toast access here
|
||||
const errEl = createElement("div", {
|
||||
class: "attachment-upload-error",
|
||||
}, `Upload failed: ${errMsg}`);
|
||||
attachmentPreviewBar.appendChild(errEl);
|
||||
setTimeout(() => errEl.remove(), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function setReplyTo(messageId: number, username: string): void {
|
||||
if (state.editing !== null) hideEditBar();
|
||||
state = { replyTo: { messageId, username }, editing: null };
|
||||
showReplyBar(username);
|
||||
textarea?.focus();
|
||||
}
|
||||
|
||||
function clearReply(): void {
|
||||
state = { ...state, replyTo: null };
|
||||
hideReplyBar();
|
||||
}
|
||||
|
||||
function startEdit(messageId: number, content: string): void {
|
||||
if (state.replyTo !== null) hideReplyBar();
|
||||
state = { replyTo: null, editing: { messageId } };
|
||||
showEditBar();
|
||||
if (textarea !== null) {
|
||||
textarea.value = content;
|
||||
autoResize();
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
state = { ...state, editing: null };
|
||||
hideEditBar();
|
||||
if (textarea !== null) { textarea.value = ""; autoResize(); }
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "message-input-wrap", "data-testid": "message-input" });
|
||||
|
||||
replyBar = createElement("div", { class: "reply-bar" });
|
||||
const replyInner = createElement("div", { class: "reply-bar-inner" });
|
||||
replyText = createElement("strong", {});
|
||||
replyInner.appendChild(replyText);
|
||||
const replyClose = createElement("button", { class: "reply-close" }, "\u00D7");
|
||||
replyClose.addEventListener("click", clearReply, { signal });
|
||||
replyInner.appendChild(replyClose);
|
||||
replyBar.appendChild(replyInner);
|
||||
|
||||
editBar = createElement("div", { class: "reply-bar" });
|
||||
const editInner = createElement("div", { class: "reply-bar-inner" });
|
||||
const editText = createElement("strong", {}, "Editing message");
|
||||
editInner.appendChild(editText);
|
||||
const editClose = createElement("button", { class: "reply-close" }, "\u00D7");
|
||||
editClose.addEventListener("click", () => cancelEdit(), { signal });
|
||||
editInner.appendChild(editClose);
|
||||
editBar.appendChild(editInner);
|
||||
|
||||
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" }, "+");
|
||||
|
||||
// File picker via attach button
|
||||
if (options.onUploadFile !== undefined) {
|
||||
const fileInput = createElement("input", {
|
||||
type: "file",
|
||||
style: "display: none;",
|
||||
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
|
||||
}) as HTMLInputElement;
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file !== undefined) {
|
||||
void handlePasteFile(file);
|
||||
}
|
||||
fileInput.value = "";
|
||||
}, { signal });
|
||||
attachBtn.addEventListener("click", () => fileInput.click(), { signal });
|
||||
root?.appendChild(fileInput);
|
||||
} else {
|
||||
attachBtn.setAttribute("disabled", "true");
|
||||
attachBtn.title = "File uploads not available";
|
||||
}
|
||||
textarea = createElement("textarea", {
|
||||
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" }, "\uD83D\uDE00");
|
||||
const sendBtn = createElement("button",
|
||||
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4");
|
||||
|
||||
textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal });
|
||||
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||
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 });
|
||||
|
||||
sendBtn.addEventListener("click", handleSend, { signal });
|
||||
|
||||
// Emoji picker toggle
|
||||
let emojiPicker: { element: HTMLDivElement; destroy(): void } | null = null;
|
||||
|
||||
function closeEmojiPicker(): void {
|
||||
if (emojiPicker !== null) {
|
||||
emojiPicker.element.remove();
|
||||
emojiPicker.destroy();
|
||||
emojiPicker = null;
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent): void {
|
||||
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)) {
|
||||
closeEmojiPicker();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEmojiPicker(): void {
|
||||
if (emojiPicker !== null) {
|
||||
closeEmojiPicker();
|
||||
return;
|
||||
}
|
||||
emojiPicker = createEmojiPicker({
|
||||
onSelect: (emoji: string) => {
|
||||
if (textarea !== null) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const before = textarea.value.slice(0, start);
|
||||
const after = textarea.value.slice(end);
|
||||
textarea.value = before + emoji + after;
|
||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.length;
|
||||
textarea.focus();
|
||||
}
|
||||
closeEmojiPicker();
|
||||
},
|
||||
onClose: () => {
|
||||
closeEmojiPicker();
|
||||
},
|
||||
});
|
||||
root?.appendChild(emojiPicker.element);
|
||||
// Defer so this click doesn't immediately close it
|
||||
setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
emojiBtn.addEventListener("click", toggleEmojiPicker, { signal });
|
||||
|
||||
appendChildren(inputBox, attachBtn, textarea, emojiBtn, sendBtn);
|
||||
appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox);
|
||||
container.appendChild(root);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
// Revoke any blob URLs for image previews
|
||||
for (const att of pendingAttachments) {
|
||||
const img = att.previewEl.querySelector("img");
|
||||
if (img !== null && img.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
}
|
||||
pendingAttachments.length = 0;
|
||||
root?.remove();
|
||||
root = null;
|
||||
textarea = null;
|
||||
replyBar = null;
|
||||
replyText = null;
|
||||
editBar = null;
|
||||
attachmentPreviewBar = null;
|
||||
}
|
||||
|
||||
return { mount, destroy, setReplyTo, clearReply, startEdit, cancelEdit };
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* MessageList component — renders chat messages with grouping, day dividers,
|
||||
* role-colored usernames, @mention highlighting, infinite scroll, and
|
||||
* virtual scrolling (DOM windowing) for performance with large message counts.
|
||||
*/
|
||||
import { createElement, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { messagesStore, getChannelMessages, hasMoreMessages } from "@stores/messages.store";
|
||||
import type { Message } from "@stores/messages.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import {
|
||||
shouldGroup,
|
||||
isSameDay,
|
||||
renderDayDivider,
|
||||
renderMessage,
|
||||
} from "./message-list/renderers";
|
||||
|
||||
// -- Options ------------------------------------------------------------------
|
||||
|
||||
export interface MessageListOptions {
|
||||
readonly channelId: number;
|
||||
readonly currentUserId: number;
|
||||
readonly onScrollTop: () => void;
|
||||
readonly onReplyClick: (messageId: number) => void;
|
||||
readonly onEditClick: (messageId: number) => void;
|
||||
readonly onDeleteClick: (messageId: number) => void;
|
||||
readonly onReactionClick: (messageId: number, emoji: string) => void;
|
||||
}
|
||||
|
||||
// -- Constants ----------------------------------------------------------------
|
||||
|
||||
const SCROLL_TOP_THRESHOLD = 50;
|
||||
const SCROLL_BOTTOM_THRESHOLD = 100;
|
||||
|
||||
/** Number of items to render beyond visible viewport in each direction. */
|
||||
const OVERSCAN = 10;
|
||||
|
||||
/** Estimated pixel height per row (message or day divider) for initial layout. */
|
||||
const ESTIMATED_ROW_HEIGHT = 52;
|
||||
|
||||
// -- Virtual item types -------------------------------------------------------
|
||||
|
||||
interface VirtualItemMessage {
|
||||
readonly kind: "message";
|
||||
readonly message: Message;
|
||||
readonly isGrouped: boolean;
|
||||
}
|
||||
|
||||
interface VirtualItemDivider {
|
||||
readonly kind: "divider";
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
type VirtualItem = VirtualItemMessage | VirtualItemDivider;
|
||||
|
||||
// -- Pre-process messages into virtual items ----------------------------------
|
||||
|
||||
function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] {
|
||||
const items: VirtualItem[] = [];
|
||||
let lastTimestamp: string | null = null;
|
||||
let prevMsg: Message | null = null;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) {
|
||||
items.push({ kind: "divider", timestamp: msg.timestamp });
|
||||
}
|
||||
const isGrouped = prevMsg !== null && shouldGroup(prevMsg, msg);
|
||||
items.push({ kind: "message", message: msg, isGrouped });
|
||||
lastTimestamp = msg.timestamp;
|
||||
prevMsg = msg;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// -- Factory ------------------------------------------------------------------
|
||||
|
||||
export type MessageListComponent = MountableComponent & {
|
||||
/** Scroll to a message by ID. Returns false if the message is not in the loaded window. */
|
||||
scrollToMessage(messageId: number): boolean;
|
||||
};
|
||||
|
||||
export function createMessageList(options: MessageListOptions): MessageListComponent {
|
||||
const ac = new AbortController();
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
let root: HTMLDivElement | null = null;
|
||||
let wasAtBottom = true;
|
||||
|
||||
// Virtual scroll state
|
||||
let virtualItems: readonly VirtualItem[] = [];
|
||||
let allMessages: readonly Message[] = [];
|
||||
const heightCache = new Map<string, number>(); // itemKey → measured px
|
||||
let topSpacer: HTMLDivElement | null = null;
|
||||
let bottomSpacer: HTMLDivElement | null = null;
|
||||
let contentContainer: HTMLDivElement | null = null;
|
||||
let renderedStart = 0;
|
||||
let renderedEnd = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Height estimation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function itemKey(index: number): string {
|
||||
const item = virtualItems[index];
|
||||
if (item === undefined) return `idx-${index}`;
|
||||
if (item.kind === "divider") return `div-${item.timestamp}`;
|
||||
return `msg-${item.message.id}`;
|
||||
}
|
||||
|
||||
function getItemHeight(index: number): number {
|
||||
return heightCache.get(itemKey(index)) ?? ESTIMATED_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
function totalHeight(): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < virtualItems.length; i++) {
|
||||
h += getItemHeight(i);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function offsetToIndex(scrollTop: number): number {
|
||||
let offset = 0;
|
||||
for (let i = 0; i < virtualItems.length; i++) {
|
||||
const h = getItemHeight(i);
|
||||
if (offset + h > scrollTop) return i;
|
||||
offset += h;
|
||||
}
|
||||
return virtualItems.length - 1;
|
||||
}
|
||||
|
||||
function offsetBefore(index: number): number {
|
||||
let offset = 0;
|
||||
for (let i = 0; i < index && i < virtualItems.length; i++) {
|
||||
offset += getItemHeight(i);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isNearBottom(): boolean {
|
||||
if (root === null) return true;
|
||||
const { scrollTop, scrollHeight, clientHeight } = root;
|
||||
return scrollHeight - scrollTop - clientHeight < SCROLL_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
function scrollToBottom(): void {
|
||||
if (root === null) return;
|
||||
root.scrollTop = root.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render visible window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function measureRendered(): void {
|
||||
if (contentContainer === null) return;
|
||||
const children = contentContainer.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const globalIdx = renderedStart + i;
|
||||
const el = children[i] as HTMLElement;
|
||||
const h = el.offsetHeight;
|
||||
if (h > 0) {
|
||||
heightCache.set(itemKey(globalIdx), h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderWindow(): void {
|
||||
if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null) return;
|
||||
|
||||
const scrollTop = root.scrollTop;
|
||||
const clientHeight = root.clientHeight;
|
||||
|
||||
if (virtualItems.length === 0) {
|
||||
clearChildren(contentContainer);
|
||||
topSpacer.style.height = "0px";
|
||||
bottomSpacer.style.height = "0px";
|
||||
renderedStart = 0;
|
||||
renderedEnd = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine visible range
|
||||
const firstVisible = offsetToIndex(scrollTop);
|
||||
const lastVisible = offsetToIndex(scrollTop + clientHeight);
|
||||
|
||||
const start = Math.max(0, firstVisible - OVERSCAN);
|
||||
const end = Math.min(virtualItems.length, lastVisible + OVERSCAN + 1);
|
||||
|
||||
// Skip re-render if the range hasn't changed
|
||||
if (start === renderedStart && end === renderedEnd) return;
|
||||
|
||||
// Measure current elements before replacing
|
||||
measureRendered();
|
||||
|
||||
renderedStart = start;
|
||||
renderedEnd = end;
|
||||
|
||||
// Rebuild content
|
||||
clearChildren(contentContainer);
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = start; i < end; i++) {
|
||||
const item = virtualItems[i]!;
|
||||
if (item.kind === "divider") {
|
||||
fragment.appendChild(renderDayDivider(item.timestamp));
|
||||
} else {
|
||||
fragment.appendChild(
|
||||
renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal),
|
||||
);
|
||||
}
|
||||
}
|
||||
contentContainer.appendChild(fragment);
|
||||
|
||||
// Set spacer heights
|
||||
topSpacer.style.height = `${offsetBefore(start)}px`;
|
||||
|
||||
let bottomHeight = 0;
|
||||
for (let i = end; i < virtualItems.length; i++) {
|
||||
bottomHeight += getItemHeight(i);
|
||||
}
|
||||
bottomSpacer.style.height = `${bottomHeight}px`;
|
||||
|
||||
// Measure newly rendered elements
|
||||
measureRendered();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full rebuild (on data change)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rebuildItems(): void {
|
||||
allMessages = getChannelMessages(options.channelId);
|
||||
virtualItems = buildVirtualItems(allMessages);
|
||||
}
|
||||
|
||||
function renderAll(): void {
|
||||
if (root === null) return;
|
||||
wasAtBottom = isNearBottom();
|
||||
|
||||
rebuildItems();
|
||||
|
||||
// Reset rendered range to force full re-render
|
||||
renderedStart = -1;
|
||||
renderedEnd = -1;
|
||||
|
||||
renderWindow();
|
||||
|
||||
if (wasAtBottom) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll / load-more handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let loadingOlder = false;
|
||||
let prevMessageCount = 0;
|
||||
|
||||
const unsubLoadingReset = messagesStore.subscribe(() => {
|
||||
const msgs = getChannelMessages(options.channelId);
|
||||
if (msgs.length !== prevMessageCount) {
|
||||
prevMessageCount = msgs.length;
|
||||
loadingOlder = false;
|
||||
}
|
||||
});
|
||||
|
||||
let scrollRafId = 0;
|
||||
|
||||
function handleScroll(): void {
|
||||
if (root === null) return;
|
||||
|
||||
// Load older messages when near top
|
||||
if (
|
||||
root.scrollTop < SCROLL_TOP_THRESHOLD
|
||||
&& !loadingOlder
|
||||
&& hasMoreMessages(options.channelId)
|
||||
) {
|
||||
loadingOlder = true;
|
||||
options.onScrollTop();
|
||||
}
|
||||
|
||||
// Debounce virtual window updates to animation frames
|
||||
if (scrollRafId === 0) {
|
||||
scrollRafId = requestAnimationFrame(() => {
|
||||
scrollRafId = 0;
|
||||
renderWindow();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount / Destroy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mount(parentContainer: Element): void {
|
||||
root = createElement("div", { class: "messages-container" });
|
||||
|
||||
topSpacer = createElement("div", { class: "virtual-spacer-top" });
|
||||
contentContainer = createElement("div", { class: "virtual-content" });
|
||||
bottomSpacer = createElement("div", { class: "virtual-spacer-bottom" });
|
||||
|
||||
root.appendChild(topSpacer);
|
||||
root.appendChild(contentContainer);
|
||||
root.appendChild(bottomSpacer);
|
||||
|
||||
root.addEventListener("scroll", handleScroll, {
|
||||
signal: ac.signal,
|
||||
passive: true,
|
||||
});
|
||||
|
||||
parentContainer.appendChild(root);
|
||||
|
||||
renderAll();
|
||||
// Scroll to bottom on initial mount — use multiple deferred calls to handle
|
||||
// layout shifts from images/embeds loading after the initial render.
|
||||
scrollToBottom();
|
||||
requestAnimationFrame(() => scrollToBottom());
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
setTimeout(() => scrollToBottom(), 500);
|
||||
|
||||
unsubscribers.push(messagesStore.subscribe(() => { renderAll(); }));
|
||||
|
||||
// Only re-render when member roles change, not on typing updates
|
||||
let prevMembers = membersStore.getState().members;
|
||||
unsubscribers.push(membersStore.subscribe((state) => {
|
||||
if (state.members !== prevMembers) {
|
||||
prevMembers = state.members;
|
||||
renderAll();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (scrollRafId !== 0) {
|
||||
cancelAnimationFrame(scrollRafId);
|
||||
scrollRafId = 0;
|
||||
}
|
||||
unsubLoadingReset();
|
||||
for (const unsub of unsubscribers) { unsub(); }
|
||||
unsubscribers.length = 0;
|
||||
heightCache.clear();
|
||||
if (root !== null) { root.remove(); root = null; }
|
||||
contentContainer = null;
|
||||
topSpacer = null;
|
||||
bottomSpacer = null;
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: number): boolean {
|
||||
if (root === null) return false;
|
||||
const idx = virtualItems.findIndex(
|
||||
(item) => item.kind === "message" && item.message.id === messageId,
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
|
||||
root.scrollTop = offsetBefore(idx);
|
||||
renderWindow();
|
||||
|
||||
// Briefly highlight the target message element
|
||||
if (contentContainer !== null) {
|
||||
const localIdx = idx - renderedStart;
|
||||
const el = contentContainer.children[localIdx] as HTMLElement | undefined;
|
||||
if (el !== undefined) {
|
||||
el.classList.add("highlight-flash");
|
||||
setTimeout(() => { el.classList.remove("highlight-flash"); }, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return { mount, destroy, scrollToMessage };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* PinnedMessages component — slide-out panel showing pinned messages
|
||||
* for a channel with jump-to and unpin actions.
|
||||
*/
|
||||
|
||||
import {
|
||||
createElement,
|
||||
setText,
|
||||
clearChildren,
|
||||
appendChildren,
|
||||
} from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface PinnedMessage {
|
||||
readonly id: number;
|
||||
readonly content: string;
|
||||
readonly author: string;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export interface PinnedMessagesOptions {
|
||||
readonly channelId: number;
|
||||
readonly pinnedMessages: readonly PinnedMessage[];
|
||||
readonly onUnpin: (messageId: number) => void;
|
||||
readonly onJumpToMessage: (messageId: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
function renderPinnedItem(
|
||||
msg: PinnedMessage,
|
||||
options: PinnedMessagesOptions,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const item = createElement("div", { class: "pinned-msg" });
|
||||
item.dataset.messageId = String(msg.id);
|
||||
|
||||
const author = createElement("div", { class: "pinned-msg__author" }, msg.author);
|
||||
const content = createElement("div", { class: "pinned-msg__content" }, msg.content);
|
||||
const time = createElement("div", { class: "pinned-msg__time" }, msg.timestamp);
|
||||
|
||||
const actions = createElement("div", { class: "pinned-msg__actions" });
|
||||
const jumpBtn = createElement("button", {}, "Jump");
|
||||
const unpinBtn = createElement("button", {}, "Unpin");
|
||||
|
||||
jumpBtn.addEventListener("click", () => options.onJumpToMessage(msg.id), { signal });
|
||||
unpinBtn.addEventListener("click", () => options.onUnpin(msg.id), { signal });
|
||||
|
||||
appendChildren(actions, jumpBtn, unpinBtn);
|
||||
appendChildren(item, author, content, time, actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function createPinnedMessages(
|
||||
options: PinnedMessagesOptions,
|
||||
): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "pinned-panel" });
|
||||
|
||||
const header = createElement("div", { class: "pinned-panel__header" });
|
||||
const title = createElement("h3", {}, "Pinned Messages");
|
||||
const closeBtn = createElement("button", { class: "pinned-panel__close" }, "\u00D7");
|
||||
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
const list = createElement("div", { class: "pinned-panel__list" });
|
||||
const empty = createElement("div", { class: "pinned-panel__empty" }, "No pinned messages");
|
||||
|
||||
if (options.pinnedMessages.length === 0) {
|
||||
empty.style.display = "";
|
||||
list.style.display = "none";
|
||||
} else {
|
||||
empty.style.display = "none";
|
||||
list.style.display = "";
|
||||
for (const msg of options.pinnedMessages) {
|
||||
list.appendChild(renderPinnedItem(msg, options, ac.signal));
|
||||
}
|
||||
}
|
||||
|
||||
appendChildren(root, header, list, empty);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import type { Channel } from "@stores/channels.store";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export interface QuickSwitcherOptions {
|
||||
readonly onSelectChannel: (channelId: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function createQuickSwitcher(options: QuickSwitcherOptions): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
|
||||
let root: HTMLDivElement | null = null;
|
||||
let resultsDiv: HTMLDivElement;
|
||||
let input: HTMLInputElement;
|
||||
let activeIndex = 0;
|
||||
let filteredChannels: readonly Channel[] = [];
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function getChannelIcon(ch: Channel): string {
|
||||
return ch.type === "voice" ? "\ud83d\udd0a" : "#";
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (query.length === 0) return sorted;
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
return sorted.filter((ch) => ch.name.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
function renderResults(): void {
|
||||
clearChildren(resultsDiv);
|
||||
activeIndex = Math.min(activeIndex, Math.max(0, filteredChannels.length - 1));
|
||||
|
||||
for (let i = 0; i < filteredChannels.length; i++) {
|
||||
const ch = filteredChannels[i]!;
|
||||
const isActive = i === activeIndex;
|
||||
|
||||
const item = createElement("div", {
|
||||
class: isActive
|
||||
? "quick-switcher__item quick-switcher__item--active"
|
||||
: "quick-switcher__item",
|
||||
"data-channelid": String(ch.id),
|
||||
});
|
||||
|
||||
const icon = createElement("span", { class: "quick-switcher__icon" }, getChannelIcon(ch));
|
||||
const name = createElement("span", { class: "quick-switcher__name" });
|
||||
setText(name, ch.name);
|
||||
|
||||
const parts: (Element | string)[] = [icon, name];
|
||||
|
||||
if (ch.category !== null) {
|
||||
const category = createElement("span", { class: "quick-switcher__category" });
|
||||
setText(category, ch.category);
|
||||
parts.push(category);
|
||||
}
|
||||
|
||||
appendChildren(item, ...parts);
|
||||
|
||||
item.addEventListener("click", () => {
|
||||
options.onSelectChannel(ch.id);
|
||||
options.onClose();
|
||||
}, { signal });
|
||||
|
||||
resultsDiv.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(): void {
|
||||
const query = input.value.trim();
|
||||
filteredChannels = getFilteredChannels(query);
|
||||
activeIndex = 0;
|
||||
renderResults();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
options.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (filteredChannels.length > 0) {
|
||||
activeIndex = (activeIndex + 1) % filteredChannels.length;
|
||||
renderResults();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (filteredChannels.length > 0) {
|
||||
activeIndex = (activeIndex - 1 + filteredChannels.length) % filteredChannels.length;
|
||||
renderResults();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const selected = filteredChannels[activeIndex];
|
||||
if (selected !== undefined) {
|
||||
options.onSelectChannel(selected.id);
|
||||
options.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent): void {
|
||||
if (e.target === root) {
|
||||
options.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent): void {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (root !== null && root.parentNode !== null) {
|
||||
options.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshFromStore(): void {
|
||||
const query = input?.value.trim() ?? "";
|
||||
filteredChannels = getFilteredChannels(query);
|
||||
renderResults();
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
// 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;",
|
||||
});
|
||||
|
||||
// Modal container
|
||||
const modal = createElement("div", { class: "quick-switcher" });
|
||||
|
||||
// Search input
|
||||
input = createElement("input", {
|
||||
class: "quick-switcher__input",
|
||||
type: "text",
|
||||
placeholder: "Where do you want to go?",
|
||||
}) as HTMLInputElement;
|
||||
|
||||
// Results list
|
||||
resultsDiv = createElement("div", { class: "quick-switcher__results" });
|
||||
|
||||
appendChildren(modal, input, resultsDiv);
|
||||
root.appendChild(modal);
|
||||
container.appendChild(root);
|
||||
|
||||
// Initial render
|
||||
filteredChannels = getFilteredChannels("");
|
||||
renderResults();
|
||||
|
||||
// Event listeners
|
||||
input.addEventListener("input", handleInput, { signal });
|
||||
input.addEventListener("keydown", handleKeydown, { signal });
|
||||
root.addEventListener("click", handleBackdropClick, { signal });
|
||||
document.addEventListener("keydown", handleGlobalKeydown, { signal });
|
||||
|
||||
// Subscribe to store changes
|
||||
unsubscribe = channelsStore.subscribe(refreshFromStore);
|
||||
|
||||
// Auto-focus
|
||||
requestAnimationFrame(() => input.focus());
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (unsubscribe !== null) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
root?.remove();
|
||||
root = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* ServerBanner component — top-of-app banner for server restart
|
||||
* countdown and reconnecting state.
|
||||
*/
|
||||
|
||||
import { createElement, setText } from "@lib/dom";
|
||||
|
||||
export interface ServerBannerControl {
|
||||
readonly element: HTMLDivElement;
|
||||
showRestart(seconds: number): void;
|
||||
showReconnecting(): void;
|
||||
hide(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createServerBanner(): ServerBannerControl {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const root = createElement("div", { class: "reconnecting-banner" });
|
||||
|
||||
function clearCountdown(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showRestart(seconds: number): void {
|
||||
clearCountdown();
|
||||
let remaining = seconds;
|
||||
root.classList.add("visible");
|
||||
setText(root, `Server restarting in ${remaining} seconds...`);
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearCountdown();
|
||||
showReconnecting();
|
||||
return;
|
||||
}
|
||||
setText(root, `Server restarting in ${remaining} seconds...`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function showReconnecting(): void {
|
||||
clearCountdown();
|
||||
root.classList.add("visible");
|
||||
setText(root, "Reconnecting...");
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
clearCountdown();
|
||||
root.classList.remove("visible");
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
clearCountdown();
|
||||
root.remove();
|
||||
}
|
||||
|
||||
return { element: root, showRestart, showReconnecting, hide, destroy };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* ServerStrip component — vertical strip on the far left showing server icons.
|
||||
* Single-server for now: Home button, separator, add server button.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export function createServerStrip(): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "server-strip", "data-testid": "server-strip" });
|
||||
|
||||
const homeIcon = createElement(
|
||||
"div",
|
||||
{ class: "server-icon active", style: "background: var(--accent)" },
|
||||
"O",
|
||||
);
|
||||
|
||||
const separator = createElement("div", { class: "server-separator" });
|
||||
|
||||
const addIcon = createElement("div", { class: "server-icon add" }, "+");
|
||||
|
||||
// Add server button click — placeholder for future multi-server support
|
||||
addIcon.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
// No-op for single-server mode
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
appendChildren(root, homeIcon, separator, addIcon);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* SettingsOverlay component — full-screen overlay with tabbed settings panels.
|
||||
* Tabs: Account, Appearance, Notifications, Voice & Audio, Keybinds, Logs.
|
||||
* Subscribes to uiStore for settingsOpen state.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { uiStore } from "@stores/ui.store";
|
||||
import { loadPref, applyTheme } from "./settings/helpers";
|
||||
import type { ThemeName } from "./settings/helpers";
|
||||
import { buildAccountTab } from "./settings/AccountTab";
|
||||
import { buildAppearanceTab } from "./settings/AppearanceTab";
|
||||
import { buildNotificationsTab } from "./settings/NotificationsTab";
|
||||
import { buildVoiceAudioTab } from "./settings/VoiceAudioTab";
|
||||
import { buildKeybindsTab } from "./settings/KeybindsTab";
|
||||
import { createLogsTab } from "./settings/LogsTab";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SettingsOverlayOptions {
|
||||
onClose(): void;
|
||||
onChangePassword(oldPassword: string, newPassword: string): Promise<void>;
|
||||
onUpdateProfile(username: string): Promise<void>;
|
||||
onLogout(): void;
|
||||
}
|
||||
|
||||
export type TabName = "Account" | "Appearance" | "Notifications" | "Voice & Audio" | "Keybinds" | "Logs";
|
||||
|
||||
const TAB_NAMES: readonly TabName[] = [
|
||||
"Account",
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Voice & Audio",
|
||||
"Keybinds",
|
||||
"Logs",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply stored appearance (called at app startup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply stored appearance preferences (theme, font size, compact mode).
|
||||
* Call at app startup so the UI doesn't flash default styles.
|
||||
*/
|
||||
export function applyStoredAppearance(): void {
|
||||
applyTheme(loadPref<ThemeName>("theme", "dark"));
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-size",
|
||||
`${loadPref<number>("fontSize", 16)}px`,
|
||||
);
|
||||
document.documentElement.classList.toggle(
|
||||
"compact-mode",
|
||||
loadPref<boolean>("compactMode", false),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSettingsOverlay(
|
||||
options: SettingsOverlayOptions,
|
||||
): MountableComponent & { open(): void; close(): void } {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let contentArea: HTMLDivElement | null = null;
|
||||
let activeTab: TabName = "Account";
|
||||
const tabButtons = new Map<TabName, HTMLButtonElement>();
|
||||
let unsubUi: (() => void) | null = null;
|
||||
|
||||
// Logs tab has stateful cleanup needs — create once via factory
|
||||
const logsTab = createLogsTab(() => activeTab, ac.signal);
|
||||
|
||||
// ---- Tab content builders -------------------------------------------------
|
||||
|
||||
const TAB_BUILDERS: Readonly<Record<TabName, () => HTMLDivElement>> = {
|
||||
Account: () => buildAccountTab(options, ac.signal),
|
||||
Appearance: () => buildAppearanceTab(ac.signal),
|
||||
Notifications: () => buildNotificationsTab(ac.signal),
|
||||
"Voice & Audio": () => buildVoiceAudioTab(ac.signal),
|
||||
Keybinds: () => buildKeybindsTab(),
|
||||
Logs: () => logsTab.build(),
|
||||
};
|
||||
|
||||
// ---- Core methods ---------------------------------------------------------
|
||||
|
||||
function renderActiveTab(): void {
|
||||
if (contentArea === null) return;
|
||||
clearChildren(contentArea);
|
||||
const builder = TAB_BUILDERS[activeTab];
|
||||
contentArea.appendChild(builder());
|
||||
}
|
||||
|
||||
function setActiveTab(tab: TabName): void {
|
||||
if (tab === activeTab) return;
|
||||
activeTab = tab;
|
||||
for (const [name, btn] of tabButtons) {
|
||||
btn.classList.toggle("active", name === tab);
|
||||
}
|
||||
renderActiveTab();
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
root?.classList.add("open");
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
root?.classList.remove("open");
|
||||
}
|
||||
|
||||
// ---- MountableComponent ---------------------------------------------------
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" });
|
||||
|
||||
// Sidebar
|
||||
const sidebar = createElement("div", { class: "settings-sidebar" });
|
||||
const catLabel = createElement("div", { class: "settings-cat" }, "User Settings");
|
||||
sidebar.appendChild(catLabel);
|
||||
for (const name of TAB_NAMES) {
|
||||
const btn = createElement("button", {
|
||||
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
|
||||
}, name);
|
||||
btn.addEventListener("click", () => setActiveTab(name), { signal: ac.signal });
|
||||
tabButtons.set(name, btn);
|
||||
sidebar.appendChild(btn);
|
||||
}
|
||||
|
||||
// Content
|
||||
contentArea = createElement("div", { class: "settings-content" });
|
||||
|
||||
// Close button
|
||||
const closeBtn = createElement("button", { class: "settings-close-btn" }, "\u00D7");
|
||||
closeBtn.addEventListener("click", () => {
|
||||
options.onClose();
|
||||
}, { signal: ac.signal });
|
||||
|
||||
// Escape key
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && root?.classList.contains("open")) {
|
||||
options.onClose();
|
||||
}
|
||||
}, { signal: ac.signal });
|
||||
|
||||
appendChildren(root, sidebar, contentArea, closeBtn);
|
||||
renderActiveTab();
|
||||
|
||||
// Subscribe to uiStore for open/close
|
||||
unsubUi = uiStore.subscribe((state) => {
|
||||
if (state.settingsOpen) {
|
||||
show();
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Sync initial state
|
||||
if (uiStore.getState().settingsOpen) {
|
||||
show();
|
||||
}
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (unsubUi !== null) {
|
||||
unsubUi();
|
||||
unsubUi = null;
|
||||
}
|
||||
logsTab.cleanup();
|
||||
tabButtons.clear();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
contentArea = null;
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
show();
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
hide();
|
||||
}
|
||||
|
||||
return { mount, destroy, open, close };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Step 6.53 — Soundboard component.
|
||||
* Grid of sound buttons with cooldown enforcement (1 play per 3s).
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SoundItem {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
export interface SoundboardOptions {
|
||||
readonly sounds: readonly SoundItem[];
|
||||
readonly onPlaySound: (soundId: number) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COOLDOWN_MS = 3_000;
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSoundboard(options: SoundboardOptions): MountableComponent {
|
||||
let root: HTMLDivElement | null = null;
|
||||
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const ac = new AbortController();
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "soundboard" });
|
||||
|
||||
if (options.sounds.length === 0) {
|
||||
const empty = createElement(
|
||||
"div",
|
||||
{ class: "soundboard__empty" },
|
||||
"No sounds available",
|
||||
);
|
||||
root.appendChild(empty);
|
||||
container.appendChild(root);
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = createElement("div", { class: "soundboard__grid" });
|
||||
const buttons: HTMLButtonElement[] = [];
|
||||
|
||||
for (const sound of options.sounds) {
|
||||
const btn = createElement("button", { class: "sound-btn", type: "button" });
|
||||
const nameSpan = createElement("span", { class: "sound-btn__name" }, sound.name);
|
||||
const durSpan = createElement(
|
||||
"span",
|
||||
{ class: "sound-btn__duration" },
|
||||
formatDuration(sound.durationMs),
|
||||
);
|
||||
|
||||
appendChildren(btn, nameSpan, durSpan);
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
if (btn.disabled) return;
|
||||
options.onPlaySound(sound.id);
|
||||
startCooldown(buttons);
|
||||
}, { signal: ac.signal });
|
||||
|
||||
buttons.push(btn);
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
|
||||
root.appendChild(grid);
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function startCooldown(buttons: readonly HTMLButtonElement[]): void {
|
||||
for (const btn of buttons) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("sound-btn--cooldown");
|
||||
}
|
||||
|
||||
if (cooldownTimer !== null) {
|
||||
clearTimeout(cooldownTimer);
|
||||
}
|
||||
|
||||
cooldownTimer = setTimeout(() => {
|
||||
cooldownTimer = null;
|
||||
for (const btn of buttons) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("sound-btn--cooldown");
|
||||
}
|
||||
}, COOLDOWN_MS);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (cooldownTimer !== null) {
|
||||
clearTimeout(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Toast notification system — shows stacking notifications at bottom-right.
|
||||
* Supports info, error, and success types with auto-dismiss.
|
||||
*/
|
||||
|
||||
import { createElement, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
export type ToastType = "info" | "error" | "success";
|
||||
|
||||
const MAX_TOASTS = 5;
|
||||
const DEFAULT_DURATION_MS = 5000;
|
||||
|
||||
interface ToastEntry {
|
||||
readonly el: HTMLDivElement;
|
||||
readonly timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface ToastContainer extends MountableComponent {
|
||||
show(message: string, type?: ToastType, durationMs?: number): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export function createToastContainer(): ToastContainer {
|
||||
let root: HTMLDivElement | null = null;
|
||||
const toasts: ToastEntry[] = [];
|
||||
|
||||
function removeToast(entry: ToastEntry): void {
|
||||
const idx = toasts.indexOf(entry);
|
||||
if (idx === -1) return;
|
||||
|
||||
clearTimeout(entry.timer);
|
||||
toasts.splice(idx, 1);
|
||||
|
||||
// Remove .show first and wait for the CSS opacity transition to finish
|
||||
entry.el.classList.remove("show");
|
||||
entry.el.addEventListener(
|
||||
"transitionend",
|
||||
() => {
|
||||
if (entry.el.parentNode !== null) {
|
||||
entry.el.remove();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// Fallback removal in case transitionend never fires
|
||||
setTimeout(() => {
|
||||
if (entry.el.parentNode !== null) {
|
||||
entry.el.remove();
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function show(
|
||||
message: string,
|
||||
type: ToastType = "info",
|
||||
durationMs: number = DEFAULT_DURATION_MS,
|
||||
): void {
|
||||
if (root === null) return;
|
||||
|
||||
// Evict oldest toasts when at capacity
|
||||
while (toasts.length >= MAX_TOASTS) {
|
||||
const oldest = toasts[0];
|
||||
if (oldest !== undefined) {
|
||||
removeToast(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const el = createElement("div", {
|
||||
class: `toast toast-${type}`,
|
||||
"data-testid": "toast",
|
||||
});
|
||||
setText(el, message);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const entry = toasts.find((t) => t.el === el);
|
||||
if (entry !== undefined) {
|
||||
removeToast(entry);
|
||||
}
|
||||
}, durationMs);
|
||||
|
||||
const entry: ToastEntry = { el, timer };
|
||||
toasts.push(entry);
|
||||
root.appendChild(el);
|
||||
|
||||
// Trigger .show on the next frame so the CSS opacity transition plays
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
el.classList.add("show");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
for (const entry of [...toasts]) {
|
||||
removeToast(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "toast-container", "data-testid": "toast-container" });
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
clear();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy, show, clear };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Step 5.43 — TypingIndicator component.
|
||||
* Subscribes to membersStore and displays who is typing in a channel.
|
||||
* Uses mockup's .typing-bar and .typing-dots classes.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { membersStore, getTypingUsers } from "@stores/members.store";
|
||||
import type { Member } from "@stores/members.store";
|
||||
|
||||
export interface TypingIndicatorOptions {
|
||||
readonly channelId: number;
|
||||
readonly currentUserId: number;
|
||||
}
|
||||
|
||||
function formatTypingText(users: readonly Member[]): string {
|
||||
if (users.length === 1) {
|
||||
return `${users[0]?.username ?? "Someone"} is typing...`;
|
||||
}
|
||||
if (users.length === 2) {
|
||||
return `${users[0]?.username ?? "Someone"} and ${users[1]?.username ?? "Someone"} are typing...`;
|
||||
}
|
||||
return "Several people are typing...";
|
||||
}
|
||||
|
||||
export function createTypingIndicator(
|
||||
options: TypingIndicatorOptions,
|
||||
): MountableComponent {
|
||||
let root: HTMLDivElement | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function updateFromState(): void {
|
||||
if (root === null) return;
|
||||
|
||||
const allTyping = getTypingUsers(options.channelId);
|
||||
const filtered = allTyping.filter((u) => u.id !== options.currentUserId);
|
||||
|
||||
clearChildren(root);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
// Animated dots
|
||||
const dots = createElement("span", { class: "typing-dots" });
|
||||
appendChildren(
|
||||
dots,
|
||||
createElement("span", {}),
|
||||
createElement("span", {}),
|
||||
createElement("span", {}),
|
||||
);
|
||||
root.appendChild(dots);
|
||||
|
||||
// Text
|
||||
const textNode = document.createTextNode(` ${formatTypingText(filtered)}`);
|
||||
root.appendChild(textNode);
|
||||
}
|
||||
// When empty, .typing-bar:empty CSS rule hides it (height: 0)
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "typing-bar" });
|
||||
|
||||
updateFromState();
|
||||
|
||||
unsubscribe = membersStore.subscribe(() => {
|
||||
updateFromState();
|
||||
});
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (unsubscribe !== null) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// UpdateNotifier — shows a non-modal banner when a client update is available.
|
||||
// Mounts at the top of the main page and allows the user to update or dismiss.
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
const log = createLogger("update-notifier");
|
||||
|
||||
export interface UpdateNotifierOptions {
|
||||
readonly serverUrl: string;
|
||||
}
|
||||
|
||||
export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent {
|
||||
const { serverUrl } = options;
|
||||
let container: Element | null = null;
|
||||
let banner: HTMLDivElement | null = null;
|
||||
let dismissed = false;
|
||||
|
||||
async function performCheck(): Promise<void> {
|
||||
if (dismissed) return;
|
||||
|
||||
const result = await checkForUpdate(serverUrl);
|
||||
if (!result.available || result.version === null) return;
|
||||
|
||||
showBanner(result.version, result.body ?? "");
|
||||
}
|
||||
|
||||
function showBanner(version: string, notes: string): void {
|
||||
if (container === null || banner !== null) return;
|
||||
|
||||
banner = createElement("div", { class: "update-banner" });
|
||||
|
||||
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");
|
||||
updateBtn.addEventListener("click", () => {
|
||||
void installUpdate();
|
||||
});
|
||||
|
||||
const laterBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
|
||||
"Later");
|
||||
laterBtn.addEventListener("click", () => {
|
||||
dismissed = true;
|
||||
removeBanner();
|
||||
});
|
||||
|
||||
appendChildren(banner, text, updateBtn, laterBtn);
|
||||
container.prepend(banner);
|
||||
}
|
||||
|
||||
async function installUpdate(): Promise<void> {
|
||||
if (banner === null) return;
|
||||
|
||||
// Replace banner content with progress indicator
|
||||
while (banner.firstChild) banner.removeChild(banner.firstChild);
|
||||
const progress = createElement("span", { class: "update-banner-text" },
|
||||
"Downloading update...");
|
||||
banner.appendChild(progress);
|
||||
|
||||
try {
|
||||
await downloadAndInstallUpdate(serverUrl);
|
||||
// App will relaunch — this code won't execute after relaunch()
|
||||
} 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");
|
||||
dismissBtn.addEventListener("click", () => {
|
||||
dismissed = true;
|
||||
removeBanner();
|
||||
});
|
||||
appendChildren(banner, errorText, dismissBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBanner(): void {
|
||||
if (banner !== null) {
|
||||
banner.remove();
|
||||
banner = null;
|
||||
}
|
||||
}
|
||||
|
||||
function mount(target: Element): void {
|
||||
container = target;
|
||||
// Delay the check slightly so the main UI renders first
|
||||
setTimeout(() => { void performCheck(); }, 3000);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
removeBanner();
|
||||
container = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* UserBar component — shows current user info at the bottom of the sidebar.
|
||||
* Subscribes to authStore for user data. Settings button opens settings overlay.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { openSettings } from "@stores/ui.store";
|
||||
|
||||
export type UserBarOptions = Record<string, never>;
|
||||
|
||||
export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// Element references for targeted updates
|
||||
let avatarEl: HTMLDivElement | null = null;
|
||||
let avatarTextEl: HTMLSpanElement | null = null;
|
||||
let nameEl: HTMLSpanElement | null = null;
|
||||
let statusEl: HTMLSpanElement | null = null;
|
||||
|
||||
function updateFromState(): void {
|
||||
const state = authStore.getState();
|
||||
const user = state.user;
|
||||
const username = user?.username ?? "Unknown";
|
||||
const initial = username.charAt(0).toUpperCase() || "?";
|
||||
|
||||
if (avatarTextEl !== null) {
|
||||
setText(avatarTextEl, initial);
|
||||
}
|
||||
if (nameEl !== null) {
|
||||
setText(nameEl, username);
|
||||
}
|
||||
if (statusEl !== null) {
|
||||
setText(statusEl, state.isAuthenticated ? "Online" : "Offline");
|
||||
}
|
||||
}
|
||||
|
||||
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;" },
|
||||
);
|
||||
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;",
|
||||
});
|
||||
avatarEl.appendChild(statusDot);
|
||||
|
||||
const info = createElement("div", { class: "ub-info" });
|
||||
nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" });
|
||||
statusEl = createElement("span", { class: "ub-status" });
|
||||
appendChildren(info, nameEl, statusEl);
|
||||
|
||||
const buttons = createElement("div", { class: "ub-controls" });
|
||||
|
||||
const settingsBtn = createElement(
|
||||
"button",
|
||||
{ title: "Settings", "aria-label": "Settings" },
|
||||
"\u2699",
|
||||
);
|
||||
|
||||
settingsBtn.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
openSettings();
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
|
||||
buttons.appendChild(settingsBtn);
|
||||
appendChildren(root, avatarEl, info, buttons);
|
||||
|
||||
// Initial render
|
||||
updateFromState();
|
||||
|
||||
// Subscribe to auth changes
|
||||
unsubscribe = authStore.subscribe(() => {
|
||||
updateFromState();
|
||||
});
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
if (unsubscribe !== null) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
avatarEl = null;
|
||||
avatarTextEl = null;
|
||||
nameEl = null;
|
||||
statusEl = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* VoiceChannel component — renders a voice channel item with connected users.
|
||||
* Returns an HTMLDivElement (not a MountableComponent).
|
||||
* Step 6.51
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import type { VoiceUser } from "@stores/voice.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { setUserVolume, getUserVolume } from "@lib/voiceSession";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
export interface VoiceChannelOptions {
|
||||
channelId: number;
|
||||
channelName: string;
|
||||
onJoin(): void;
|
||||
}
|
||||
|
||||
export interface VoiceChannelResult {
|
||||
element: HTMLDivElement;
|
||||
update(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"];
|
||||
|
||||
function pickAvatarColor(username: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
hash = (hash * 31 + username.charCodeAt(i)) | 0;
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2";
|
||||
}
|
||||
|
||||
export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelResult {
|
||||
const ac = new AbortController();
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// Wrapper div to hold the channel-item and voice-users-list as siblings
|
||||
const root = createElement("div");
|
||||
|
||||
// Channel item row (same structure as text channels)
|
||||
const channelItem = createElement("div", { class: "channel-item voice" });
|
||||
const icon = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A");
|
||||
const nameEl = createElement("span", { class: "ch-name" }, options.channelName);
|
||||
appendChildren(channelItem, icon, nameEl);
|
||||
|
||||
// Users container
|
||||
const usersContainer = createElement("div", { class: "voice-users-list" });
|
||||
|
||||
appendChildren(root, channelItem, usersContainer);
|
||||
|
||||
// Click to join
|
||||
channelItem.addEventListener("click", options.onJoin, { signal: ac.signal });
|
||||
|
||||
// Track active context menu for cleanup
|
||||
let activeCtxMenu: HTMLDivElement | null = null;
|
||||
let menuDismissAc: AbortController | null = null;
|
||||
|
||||
function closeContextMenu(): void {
|
||||
if (menuDismissAc !== null) {
|
||||
menuDismissAc.abort();
|
||||
menuDismissAc = null;
|
||||
}
|
||||
if (activeCtxMenu !== null) {
|
||||
activeCtxMenu.remove();
|
||||
activeCtxMenu = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showVolumeMenu(userId: number, username: string, x: number, y: number): void {
|
||||
closeContextMenu();
|
||||
|
||||
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);
|
||||
menu.appendChild(header);
|
||||
|
||||
const sep = createElement("div", { class: "context-menu-sep" });
|
||||
menu.appendChild(sep);
|
||||
|
||||
// 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}%`);
|
||||
menu.appendChild(volLabel);
|
||||
|
||||
// Volume slider (0-200%, like Discord)
|
||||
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);
|
||||
|
||||
// Reset button
|
||||
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);
|
||||
|
||||
// Position and show
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
document.body.appendChild(menu);
|
||||
activeCtxMenu = menu;
|
||||
|
||||
// Close on click outside — uses AbortController so cleanup on destroy works
|
||||
menuDismissAc = new AbortController();
|
||||
const dismissSignal = menuDismissAc.signal;
|
||||
setTimeout(() => {
|
||||
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 row = createElement("div", { class: classes });
|
||||
|
||||
const initial = username.length > 0 ? username.charAt(0).toUpperCase() : "?";
|
||||
const color = pickAvatarColor(username);
|
||||
const avatar = createElement("div", { class: "vu-avatar" }, initial);
|
||||
avatar.style.background = color;
|
||||
row.appendChild(avatar);
|
||||
|
||||
const name = createElement("span", { class: "vu-name" }, username);
|
||||
row.appendChild(name);
|
||||
|
||||
if (user.muted || user.deafened) {
|
||||
const mutedIcon = user.deafened ? "\uD83D\uDD08" : "\uD83D\uDD07";
|
||||
const mutedEl = createElement("span", { class: "vu-muted" }, mutedIcon);
|
||||
row.appendChild(mutedEl);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// Track previous Map reference to skip unnecessary re-renders
|
||||
let prevChannelUsers: ReadonlyMap<number, VoiceUser> | undefined;
|
||||
let prevMembers: ReadonlyMap<number, unknown> | undefined;
|
||||
|
||||
function update(): void {
|
||||
const channelUsers = voiceStore.getState().voiceUsers.get(options.channelId);
|
||||
const members = membersStore.getState().members;
|
||||
|
||||
// Skip re-render if neither the channel's user map nor members changed
|
||||
if (channelUsers === prevChannelUsers && members === prevMembers) return;
|
||||
prevChannelUsers = channelUsers;
|
||||
prevMembers = members;
|
||||
|
||||
clearChildren(usersContainer);
|
||||
|
||||
if (channelUsers === undefined) {
|
||||
channelItem.classList.remove("active");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of channelUsers.values()) {
|
||||
const member = members.get(user.userId);
|
||||
const username = (member as { username?: string } | undefined)?.username ?? "Unknown";
|
||||
const row = createUserRow(user, username);
|
||||
usersContainer.appendChild(row);
|
||||
}
|
||||
|
||||
// Mark channel-item active if there are users
|
||||
if (channelUsers.size > 0) {
|
||||
channelItem.classList.add("active");
|
||||
} else {
|
||||
channelItem.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
// Initial render and subscribe
|
||||
update();
|
||||
unsubs.push(voiceStore.subscribe(() => update()));
|
||||
unsubs.push(membersStore.subscribe(() => update()));
|
||||
|
||||
function destroy(): void {
|
||||
closeContextMenu();
|
||||
ac.abort();
|
||||
for (const unsub of unsubs) {
|
||||
unsub();
|
||||
}
|
||||
unsubs.length = 0;
|
||||
}
|
||||
|
||||
return { element: root, update, destroy };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* VoiceWidget component — shows active voice channel info with controls.
|
||||
* Hidden when not connected to a voice channel.
|
||||
* Users are displayed under the voice channel in the sidebar, NOT here.
|
||||
* Step 6.50
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
|
||||
export interface VoiceWidgetOptions {
|
||||
onDisconnect(): void;
|
||||
onMuteToggle(): void;
|
||||
onDeafenToggle(): void;
|
||||
onCameraToggle(): void;
|
||||
onScreenshareToggle(): void;
|
||||
}
|
||||
|
||||
export function createVoiceWidget(options: VoiceWidgetOptions): MountableComponent {
|
||||
const ac = new AbortController();
|
||||
let root: HTMLDivElement | null = null;
|
||||
let channelNameEl: HTMLSpanElement | null = null;
|
||||
let muteBtn: HTMLButtonElement | null = null;
|
||||
let deafenBtn: HTMLButtonElement | null = null;
|
||||
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
function render(): void {
|
||||
if (root === null || channelNameEl === null) return;
|
||||
|
||||
const voice = voiceStore.getState();
|
||||
const channelId = voice.currentChannelId;
|
||||
|
||||
if (channelId === null) {
|
||||
root.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add("visible");
|
||||
|
||||
// Channel name
|
||||
const channel = channelsStore.getState().channels.get(channelId);
|
||||
setText(channelNameEl, channel?.name ?? "Voice Channel");
|
||||
|
||||
// Toggle button active states
|
||||
muteBtn?.classList.toggle("active-ctrl", voice.localMuted);
|
||||
deafenBtn?.classList.toggle("active-ctrl", voice.localDeafened);
|
||||
}
|
||||
|
||||
function createControlButton(
|
||||
label: string,
|
||||
icon: string,
|
||||
handler: () => void,
|
||||
extraClass?: string,
|
||||
): HTMLButtonElement {
|
||||
const btn = createElement("button", {
|
||||
class: extraClass ?? "",
|
||||
"aria-label": label,
|
||||
}, icon);
|
||||
btn.addEventListener("click", handler, { signal: ac.signal });
|
||||
return btn;
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "voice-widget", "data-testid": "voice-widget" });
|
||||
|
||||
const header = createElement("div", { class: "vw-header" });
|
||||
const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected");
|
||||
channelNameEl = createElement("span", { class: "vw-channel" }, "Voice Channel");
|
||||
appendChildren(header, connLabel, channelNameEl);
|
||||
|
||||
const controls = createElement("div", { class: "vw-controls" });
|
||||
muteBtn = createControlButton("Mute", "\uD83C\uDFA4", options.onMuteToggle);
|
||||
deafenBtn = createControlButton("Deafen", "\uD83C\uDFA7", options.onDeafenToggle);
|
||||
const cameraBtn = createControlButton("Camera", "\uD83D\uDCF7", options.onCameraToggle);
|
||||
const shareBtn = createControlButton("Screenshare", "\uD83D\uDDA5", options.onScreenshareToggle);
|
||||
const disconnectBtn = createControlButton(
|
||||
"Disconnect", "\u260E", options.onDisconnect, "disconnect",
|
||||
);
|
||||
appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn);
|
||||
|
||||
appendChildren(root, header, controls);
|
||||
|
||||
render();
|
||||
|
||||
unsubs.push(voiceStore.subscribe(() => render()));
|
||||
unsubs.push(channelsStore.subscribe(() => render()));
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
ac.abort();
|
||||
for (const unsub of unsubs) {
|
||||
unsub();
|
||||
}
|
||||
unsubs.length = 0;
|
||||
root?.remove();
|
||||
root = null;
|
||||
channelNameEl = null;
|
||||
muteBtn = null;
|
||||
deafenBtn = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Account settings tab — profile editing, password change, logout.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import type { SettingsOverlayOptions } from "../SettingsOverlay";
|
||||
|
||||
export function buildAccountTab(
|
||||
options: SettingsOverlayOptions,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const user = authStore.getState().user;
|
||||
|
||||
// Account card
|
||||
const accountCard = createElement("div", { class: "account-card" });
|
||||
const acAvatar = createElement("div", {
|
||||
class: "ac-avatar",
|
||||
style: "background: var(--accent)",
|
||||
}, (user?.username ?? "U").charAt(0).toUpperCase());
|
||||
const acInfo = createElement("div", {});
|
||||
const acName = createElement("div", { class: "ac-name" }, user?.username ?? "Unknown");
|
||||
const acId = createElement("div", { class: "ac-id" }, `ID: ${user?.id ?? "?"}`);
|
||||
appendChildren(acInfo, acName, acId);
|
||||
const editBtn = createElement("button", { class: "ac-btn" }, "Edit Profile");
|
||||
appendChildren(accountCard, acAvatar, acInfo, editBtn);
|
||||
section.appendChild(accountCard);
|
||||
|
||||
const editForm = createElement("div", { class: "setting-row", style: "display:none" });
|
||||
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 usernameValue = acName;
|
||||
appendChildren(editForm, editInput, saveBtn, cancelBtn);
|
||||
|
||||
editBtn.addEventListener("click", () => {
|
||||
editForm.style.display = "flex";
|
||||
editInput.value = user?.username ?? "";
|
||||
editInput.focus();
|
||||
}, { signal });
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
editForm.style.display = "none";
|
||||
}, { signal });
|
||||
|
||||
saveBtn.addEventListener("click", () => {
|
||||
const newName = editInput.value.trim();
|
||||
if (newName.length > 0) {
|
||||
void options.onUpdateProfile(newName).then(() => {
|
||||
setText(usernameValue, newName);
|
||||
editForm.style.display = "none";
|
||||
});
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
section.appendChild(editForm);
|
||||
|
||||
// Change password
|
||||
const pwHeader = createElement("h3", {}, "Change Password");
|
||||
const oldPw = createElement("input", { class: "form-input", type: "password", placeholder: "Old password", style: "margin-bottom:8px" });
|
||||
const newPw = createElement("input", { class: "form-input", type: "password", placeholder: "New password", style: "margin-bottom:8px" });
|
||||
const confirmPw = createElement("input", { class: "form-input", type: "password", placeholder: "Confirm new password", style: "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");
|
||||
|
||||
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 = "";
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
appendChildren(section, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
|
||||
|
||||
// Logout
|
||||
const logoutBtn = createElement("button", {
|
||||
class: "settings-nav-item danger",
|
||||
style: "margin-top:16px;width:auto;padding:8px 16px",
|
||||
}, "Log Out");
|
||||
logoutBtn.addEventListener("click", () => options.onLogout(), { signal });
|
||||
section.appendChild(logoutBtn);
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Appearance settings tab — theme, font size, compact mode.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { loadPref, savePref, applyTheme, THEMES } from "./helpers";
|
||||
import type { ThemeName } from "./helpers";
|
||||
import { setTheme } from "@stores/ui.store";
|
||||
|
||||
export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const currentTheme = loadPref<ThemeName>("theme", "dark");
|
||||
const currentFontSize = loadPref<number>("fontSize", 16);
|
||||
const currentCompact = loadPref<boolean>("compactMode", false);
|
||||
|
||||
// Theme selector
|
||||
const themeHeader = createElement("h3", {}, "Theme");
|
||||
const themeRow = createElement("div", { class: "theme-options" });
|
||||
for (const name of Object.keys(THEMES) as ThemeName[]) {
|
||||
const btn = createElement("div", {
|
||||
class: `theme-opt ${name}${name === currentTheme ? " active" : ""}`,
|
||||
}, name.charAt(0).toUpperCase() + name.slice(1));
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
applyTheme(name);
|
||||
savePref("theme", name);
|
||||
setTheme(name);
|
||||
const prev = themeRow.querySelector(".theme-opt.active");
|
||||
if (prev) prev.classList.remove("active");
|
||||
btn.classList.add("active");
|
||||
}, { signal });
|
||||
|
||||
themeRow.appendChild(btn);
|
||||
}
|
||||
appendChildren(section, themeHeader, themeRow);
|
||||
|
||||
// Font size slider
|
||||
const fontHeader = createElement("h3", {}, "Font Size");
|
||||
const fontRow = createElement("div", { class: "slider-row" });
|
||||
const fontSlider = createElement("input", {
|
||||
class: "settings-slider",
|
||||
type: "range",
|
||||
min: "12",
|
||||
max: "20",
|
||||
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 });
|
||||
appendChildren(fontRow, fontSlider, fontLabel);
|
||||
appendChildren(section, fontHeader, fontRow);
|
||||
|
||||
// Compact mode toggle
|
||||
const compactRow = createElement("div", { class: "setting-row" });
|
||||
const compactLabel = createElement("span", { class: "setting-label" }, "Compact Mode");
|
||||
const compactToggle = createElement("div", {
|
||||
class: currentCompact ? "toggle on" : "toggle",
|
||||
});
|
||||
compactToggle.addEventListener("click", () => {
|
||||
const isNowCompact = !compactToggle.classList.contains("on");
|
||||
compactToggle.classList.toggle("on", isNowCompact);
|
||||
savePref("compactMode", isNowCompact);
|
||||
document.documentElement.classList.toggle("compact-mode", isNowCompact);
|
||||
}, { signal });
|
||||
appendChildren(compactRow, compactLabel, compactToggle);
|
||||
section.appendChild(compactRow);
|
||||
|
||||
// Apply stored preferences on render
|
||||
applyTheme(currentTheme);
|
||||
document.documentElement.style.setProperty("--font-size", `${currentFontSize}px`);
|
||||
document.documentElement.classList.toggle("compact-mode", currentCompact);
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Keybinds settings tab — push-to-talk and quick switcher bindings.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { loadPref } from "./helpers";
|
||||
|
||||
export function buildKeybindsTab(): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const header = createElement("h1", {}, "Keybinds");
|
||||
section.appendChild(header);
|
||||
|
||||
const pttRow = createElement("div", { class: "keybind-row" });
|
||||
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
|
||||
const pttValue = createElement("span", { class: "kbd" }, loadPref<string>("pttKey", "Not set"));
|
||||
appendChildren(pttRow, pttLabel, pttValue);
|
||||
section.appendChild(pttRow);
|
||||
|
||||
const searchRow = createElement("div", { class: "keybind-row" });
|
||||
const searchLabel = createElement("span", { class: "setting-label" }, "Quick Switcher");
|
||||
const searchValue = createElement("span", { class: "kbd" }, "Ctrl + K");
|
||||
appendChildren(searchRow, searchLabel, searchValue);
|
||||
section.appendChild(searchRow);
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Logs settings tab — log viewer with filtering, level control, live updates.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger";
|
||||
import type { LogEntry, LogLevel } from "@lib/logger";
|
||||
import type { TabName } from "../SettingsOverlay";
|
||||
import { getSessionDebugInfo, measureStreamLevel, getRemoteStreams, getLocalProcessedStream } from "@lib/voiceSession";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
|
||||
debug: "#888",
|
||||
info: "#3ba55d",
|
||||
warn: "#faa61a",
|
||||
error: "#ed4245",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatLogEntry(entry: LogEntry): HTMLDivElement {
|
||||
const row = createElement("div", {
|
||||
class: "log-entry",
|
||||
style: `border-left: 3px solid ${LOG_LEVEL_COLORS[entry.level]}; padding: 4px 8px; margin: 2px 0; font-family: monospace; font-size: 12px; line-height: 1.4;`,
|
||||
});
|
||||
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);
|
||||
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);
|
||||
row.appendChild(dataEl);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LogsTabHandle {
|
||||
build(): HTMLDivElement;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export function createLogsTab(
|
||||
getActiveTab: () => TabName,
|
||||
signal: AbortSignal,
|
||||
): LogsTabHandle {
|
||||
let logListEl: HTMLDivElement | null = null;
|
||||
let logFilterLevel: LogLevel | "all" = "all";
|
||||
let unsubLogListener: (() => void) | null = null;
|
||||
|
||||
function renderLogEntries(): void {
|
||||
if (logListEl === null) return;
|
||||
clearChildren(logListEl);
|
||||
|
||||
const entries = getLogBuffer();
|
||||
for (const entry of entries) {
|
||||
if (logFilterLevel !== "all" && entry.level !== logFilterLevel) continue;
|
||||
logListEl.appendChild(formatLogEntry(entry));
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logListEl.scrollTop = logListEl.scrollHeight;
|
||||
}
|
||||
|
||||
function build(): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const header = createElement("h1", {}, "Logs");
|
||||
section.appendChild(header);
|
||||
|
||||
// Controls row
|
||||
const controls = createElement("div", {
|
||||
style: "display: flex; gap: 8px; margin-bottom: 8px; align-items: center;",
|
||||
});
|
||||
|
||||
// Filter dropdown
|
||||
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;",
|
||||
});
|
||||
const levels: Array<LogLevel | "all"> = ["all", "debug", "info", "warn", "error"];
|
||||
for (const lvl of levels) {
|
||||
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
|
||||
if (lvl === logFilterLevel) opt.setAttribute("selected", "");
|
||||
filterSelect.appendChild(opt);
|
||||
}
|
||||
filterSelect.addEventListener("change", () => {
|
||||
logFilterLevel = filterSelect.value as LogLevel | "all";
|
||||
renderLogEntries();
|
||||
}, { signal });
|
||||
|
||||
// Log level selector
|
||||
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;",
|
||||
});
|
||||
const minLevels: LogLevel[] = ["debug", "info", "warn", "error"];
|
||||
for (const lvl of minLevels) {
|
||||
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
|
||||
levelSelect.appendChild(opt);
|
||||
}
|
||||
levelSelect.addEventListener("change", () => {
|
||||
setLogLevel(levelSelect.value as LogLevel);
|
||||
}, { 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);
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Clear button
|
||||
const clearBtn = createElement("button", { class: "ac-btn" }, "Clear Logs");
|
||||
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);
|
||||
section.appendChild(controls);
|
||||
|
||||
// Voice diagnostics panel
|
||||
const diagHeader = createElement("h3", { style: "margin: 12px 0 6px 0;" }, "Voice Diagnostics");
|
||||
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);",
|
||||
});
|
||||
|
||||
function refreshDiag(): void {
|
||||
const info = getSessionDebugInfo();
|
||||
const ctx = info.sharedAudioCtx as { state: string; sampleRate: number } | null;
|
||||
const localTracks = info.localTracks as Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
|
||||
const remoteEls = info.remoteAudioElements as Array<{
|
||||
streamId: string; userId: number; audioPaused: boolean; audioMuted: boolean;
|
||||
audioVolume: number; audioReadyState: number; hasSrcObject: boolean;
|
||||
gainValue: number | string;
|
||||
tracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
|
||||
}>;
|
||||
const webrtcStreams = info.webrtcRemoteStreams as Array<{
|
||||
streamId: string; trackCount: number;
|
||||
audioTracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
|
||||
}>;
|
||||
|
||||
const lines: string[] = [
|
||||
`=== Session ===`,
|
||||
`WebRTC: ${info.hasWebrtc} VAD: ${info.hasVad} Suppressor: ${info.hasNoiseSuppressor}`,
|
||||
`Join in progress: ${info.joinInProgress} Silence suppression: ${info.silenceSuppressionEnabled}`,
|
||||
`SharedAudioCtx: ${ctx ? `${ctx.state} @ ${ctx.sampleRate}Hz` : "none"}`,
|
||||
``,
|
||||
`=== Local Audio ===`,
|
||||
`Stream: ${info.hasLocalStream} Processed: ${info.hasProcessedStream}`,
|
||||
];
|
||||
for (const t of localTracks) {
|
||||
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
|
||||
}
|
||||
|
||||
lines.push(``, `=== WebRTC Remote Streams ===`);
|
||||
if (webrtcStreams.length === 0) lines.push(` (none)`);
|
||||
for (const s of webrtcStreams) {
|
||||
lines.push(` Stream ${s.streamId}: ${s.trackCount} tracks`);
|
||||
for (const t of s.audioTracks) {
|
||||
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(``, `=== Remote Audio Elements ===`);
|
||||
if (remoteEls.length === 0) lines.push(` (none)`);
|
||||
for (const el of remoteEls) {
|
||||
lines.push(` [user ${el.userId}] stream=${el.streamId}`);
|
||||
lines.push(` <audio> paused=${el.audioPaused} muted=${el.audioMuted} volume=${el.audioVolume} readyState=${el.audioReadyState} srcObject=${el.hasSrcObject}`);
|
||||
lines.push(` GainNode: ${typeof el.gainValue === "number" ? el.gainValue.toFixed(2) : el.gainValue}`);
|
||||
for (const t of el.tracks) {
|
||||
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
|
||||
}
|
||||
}
|
||||
|
||||
diagPanel.textContent = lines.join("\n");
|
||||
}
|
||||
|
||||
refreshDiag();
|
||||
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);
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Live audio level probe — measures actual signal flowing through streams
|
||||
const levelBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Probe Audio Levels");
|
||||
const levelResult = createElement("pre", {
|
||||
style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;",
|
||||
});
|
||||
levelBtn.addEventListener("click", () => {
|
||||
levelBtn.textContent = "Probing...";
|
||||
levelResult.textContent = "";
|
||||
|
||||
const info = getSessionDebugInfo();
|
||||
const promises: Array<Promise<string>> = [];
|
||||
|
||||
// 1. Probe local mic (what we're sending)
|
||||
const localStream = getLocalProcessedStream();
|
||||
if (localStream) {
|
||||
promises.push(
|
||||
measureStreamLevel(localStream).then((lvl) => `Local mic (outgoing): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
|
||||
);
|
||||
} else {
|
||||
promises.push(Promise.resolve("Local mic: no stream"));
|
||||
}
|
||||
|
||||
// 2. Probe raw WebRTC remote streams (before GainNode)
|
||||
const rawRemoteStreams = getRemoteStreams();
|
||||
rawRemoteStreams.forEach((s, i) => {
|
||||
promises.push(
|
||||
measureStreamLevel(s).then((lvl) => `Remote [${i}] (raw WebRTC ${s.id}): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
|
||||
);
|
||||
});
|
||||
|
||||
// 3. Probe GainNode output (what <audio> element plays)
|
||||
const audioContainer = document.getElementById("voice-audio-container");
|
||||
const audioEls = audioContainer?.querySelectorAll("audio") ?? [];
|
||||
audioEls.forEach((el, i) => {
|
||||
const a = el as HTMLAudioElement;
|
||||
const src = a.srcObject as MediaStream | null;
|
||||
if (src) {
|
||||
promises.push(
|
||||
measureStreamLevel(src).then((lvl) => `Remote [${i}] (GainNode output): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (promises.length === 0) {
|
||||
levelResult.textContent = "No audio streams to probe";
|
||||
levelBtn.textContent = "Probe Audio Levels";
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.all(promises).then((results) => {
|
||||
levelResult.textContent = results.join("\n");
|
||||
levelBtn.textContent = "Probe Audio Levels";
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
section.appendChild(diagPanel);
|
||||
const diagBtns = createElement("div", { style: "display: flex; flex-wrap: wrap;" });
|
||||
appendChildren(diagBtns, diagRefresh, diagCopy, levelBtn);
|
||||
section.appendChild(diagBtns);
|
||||
section.appendChild(levelResult);
|
||||
|
||||
// Direct playback test — bypasses GainNode pipeline entirely
|
||||
const directBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Test Direct Playback");
|
||||
const directResult = createElement("pre", {
|
||||
style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;",
|
||||
});
|
||||
directBtn.addEventListener("click", () => {
|
||||
const rawStreams = getRemoteStreams();
|
||||
if (rawStreams.length === 0) {
|
||||
directResult.textContent = "No remote streams to test";
|
||||
return;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const s of rawStreams) {
|
||||
const testAudio = document.createElement("audio");
|
||||
testAudio.srcObject = s;
|
||||
testAudio.autoplay = true;
|
||||
testAudio.volume = 1.0;
|
||||
document.body.appendChild(testAudio);
|
||||
testAudio.play().then(() => {
|
||||
lines.push(`Stream ${s.id}: play() succeeded, paused=${testAudio.paused}, readyState=${testAudio.readyState}`);
|
||||
lines.push(` tracks: ${s.getAudioTracks().map((t) => `${t.id.slice(0,8)} enabled=${t.enabled} muted=${t.muted} readyState=${t.readyState}`).join(", ")}`);
|
||||
directResult.textContent = lines.join("\n") + "\n\nDirect <audio> element added — can you hear audio now? (playing raw WebRTC stream, no GainNode)";
|
||||
// Clean up after 10 seconds
|
||||
setTimeout(() => { testAudio.srcObject = null; testAudio.remove(); }, 10000);
|
||||
}).catch((err) => {
|
||||
lines.push(`Stream ${s.id}: play() FAILED — ${err instanceof Error ? err.message : String(err)}`);
|
||||
directResult.textContent = lines.join("\n");
|
||||
testAudio.remove();
|
||||
});
|
||||
}
|
||||
}, { signal });
|
||||
diagBtns.appendChild(directBtn);
|
||||
section.appendChild(directResult);
|
||||
|
||||
// Log count
|
||||
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;",
|
||||
});
|
||||
section.appendChild(logListEl);
|
||||
|
||||
renderLogEntries();
|
||||
|
||||
// Live update: subscribe to new log entries
|
||||
unsubLogListener?.();
|
||||
unsubLogListener = addLogListener(() => {
|
||||
if (getActiveTab() === "Logs") {
|
||||
renderLogEntries();
|
||||
countEl.textContent = `${getLogBuffer().length} entries`;
|
||||
}
|
||||
});
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
unsubLogListener?.();
|
||||
unsubLogListener = null;
|
||||
logListEl = null;
|
||||
}
|
||||
|
||||
return { build, cleanup };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Notifications settings tab — desktop notifications, taskbar flash, sounds.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { loadPref, savePref } from "./helpers";
|
||||
|
||||
export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const header = createElement("h1", {}, "Notifications");
|
||||
section.appendChild(header);
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
for (const item of toggles) {
|
||||
const row = createElement("div", { class: "setting-row" });
|
||||
const info = createElement("div", {});
|
||||
const label = createElement("div", { class: "setting-label" }, item.label);
|
||||
const desc = createElement("div", { class: "setting-desc" }, item.desc);
|
||||
appendChildren(info, label, desc);
|
||||
|
||||
const isOn = loadPref<boolean>(item.key, item.fallback);
|
||||
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
|
||||
toggle.addEventListener("click", () => {
|
||||
const nowOn = !toggle.classList.contains("on");
|
||||
toggle.classList.toggle("on", nowOn);
|
||||
savePref(item.key, nowOn);
|
||||
}, { signal });
|
||||
|
||||
appendChildren(row, info, toggle);
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Voice & Audio settings tab — input/output device, sensitivity, audio processing.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { loadPref, savePref } from "./helpers";
|
||||
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession";
|
||||
import { sensitivityToThreshold } from "@lib/vad";
|
||||
|
||||
export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const header = createElement("h1", {}, "Voice & Audio");
|
||||
section.appendChild(header);
|
||||
|
||||
// Input device selector
|
||||
const inputHeader = createElement("h3", {}, "Input Device");
|
||||
const inputSelect = createElement("select", {
|
||||
class: "form-input",
|
||||
style: "width:100%;margin-bottom:12px",
|
||||
});
|
||||
const defaultInputOpt = createElement("option", { value: "" }, "Default");
|
||||
inputSelect.appendChild(defaultInputOpt);
|
||||
section.appendChild(inputHeader);
|
||||
section.appendChild(inputSelect);
|
||||
|
||||
// Output device selector
|
||||
const outputHeader = createElement("h3", {}, "Output Device");
|
||||
const outputSelect = createElement("select", {
|
||||
class: "form-input",
|
||||
style: "width:100%;margin-bottom:12px",
|
||||
});
|
||||
const defaultOutputOpt = createElement("option", { value: "" }, "Default");
|
||||
outputSelect.appendChild(defaultOutputOpt);
|
||||
section.appendChild(outputHeader);
|
||||
section.appendChild(outputSelect);
|
||||
|
||||
// Populate devices asynchronously
|
||||
void (async () => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const savedInput = loadPref<string>("audioInputDevice", "");
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
|
||||
for (const d of devices) {
|
||||
if (d.kind === "audioinput") {
|
||||
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)})`);
|
||||
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
|
||||
outputSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore saved selections
|
||||
if (savedInput) inputSelect.value = savedInput;
|
||||
if (savedOutput) outputSelect.value = savedOutput;
|
||||
} catch {
|
||||
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 });
|
||||
|
||||
outputSelect.addEventListener("change", () => {
|
||||
savePref("audioOutputDevice", outputSelect.value);
|
||||
void switchOutputDevice(outputSelect.value);
|
||||
}, { signal });
|
||||
|
||||
// ── Mic level meter + sensitivity slider ──────────────────────────
|
||||
const sensitivityHeader = createElement("h3", {}, "Input Sensitivity");
|
||||
section.appendChild(sensitivityHeader);
|
||||
|
||||
// Real-time mic level bar
|
||||
const meterWrap = createElement("div", { class: "mic-meter-wrap" });
|
||||
const meterBar = createElement("div", { class: "mic-meter-bar" });
|
||||
const meterLevel = createElement("div", { class: "mic-meter-level" });
|
||||
const meterThreshold = createElement("div", { class: "mic-meter-threshold" });
|
||||
meterBar.appendChild(meterLevel);
|
||||
meterBar.appendChild(meterThreshold);
|
||||
meterWrap.appendChild(meterBar);
|
||||
section.appendChild(meterWrap);
|
||||
|
||||
// Sensitivity slider
|
||||
const sensitivityRow = createElement("div", { class: "slider-row" });
|
||||
const savedSensitivity = loadPref<number>("voiceSensitivity", 50);
|
||||
const sensitivitySlider = createElement("input", {
|
||||
class: "settings-slider",
|
||||
type: "range",
|
||||
min: "0",
|
||||
max: "100",
|
||||
value: String(savedSensitivity),
|
||||
});
|
||||
const sensitivityLabel = createElement("span", { class: "slider-val" }, `${savedSensitivity}%`);
|
||||
|
||||
// Position threshold indicator
|
||||
function updateThresholdIndicator(sensitivity: number): void {
|
||||
const threshold = sensitivityToThreshold(sensitivity);
|
||||
// Map threshold (0-0.15) to percentage position (0-100%)
|
||||
const pct = Math.min((threshold / 0.15) * 100, 100);
|
||||
meterThreshold.style.left = `${pct}%`;
|
||||
}
|
||||
updateThresholdIndicator(savedSensitivity);
|
||||
|
||||
sensitivitySlider.addEventListener("input", () => {
|
||||
const val = Number(sensitivitySlider.value);
|
||||
setText(sensitivityLabel, `${val}%`);
|
||||
savePref("voiceSensitivity", val);
|
||||
setVoiceSensitivity(val);
|
||||
updateThresholdIndicator(val);
|
||||
}, { signal });
|
||||
appendChildren(sensitivityRow, sensitivitySlider, sensitivityLabel);
|
||||
section.appendChild(sensitivityRow);
|
||||
|
||||
// Start mic level monitoring for visual feedback
|
||||
let micStream: MediaStream | null = null;
|
||||
let micAudioCtx: AudioContext | null = null;
|
||||
let micAnalyser: AnalyserNode | null = null;
|
||||
let micAnimFrame: number | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const savedDevice = loadPref<string>("audioInputDevice", "");
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: savedDevice ? { deviceId: { exact: savedDevice } } : true,
|
||||
video: false,
|
||||
};
|
||||
micStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
micAudioCtx = new AudioContext();
|
||||
micAnalyser = micAudioCtx.createAnalyser();
|
||||
micAnalyser.fftSize = 256;
|
||||
micAnalyser.smoothingTimeConstant = 0.5;
|
||||
const source = micAudioCtx.createMediaStreamSource(micStream);
|
||||
source.connect(micAnalyser);
|
||||
|
||||
const dataArray = new Uint8Array(micAnalyser.frequencyBinCount);
|
||||
|
||||
function updateMeter(): void {
|
||||
if (micAnalyser === null || signal.aborted) return;
|
||||
micAnalyser.getByteFrequencyData(dataArray);
|
||||
// Compute RMS normalized to 0-1
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = (dataArray[i] ?? 0) / 255;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / dataArray.length);
|
||||
// Scale for visual: use sqrt for more visible quiet sounds
|
||||
const visual = Math.min(Math.sqrt(rms) * 2, 1);
|
||||
meterLevel.style.width = `${visual * 100}%`;
|
||||
|
||||
// Color: green if above threshold, yellow/red if below
|
||||
const threshold = sensitivityToThreshold(Number(sensitivitySlider.value));
|
||||
const normalizedRms = rms;
|
||||
if (normalizedRms >= threshold) {
|
||||
meterLevel.style.background = "#43b581"; // green — voice detected
|
||||
} else {
|
||||
meterLevel.style.background = "#faa61a"; // yellow — below threshold
|
||||
}
|
||||
|
||||
micAnimFrame = requestAnimationFrame(updateMeter);
|
||||
}
|
||||
micAnimFrame = requestAnimationFrame(updateMeter);
|
||||
} catch {
|
||||
// Mic access denied or unavailable — meter stays empty
|
||||
}
|
||||
})();
|
||||
|
||||
// Cleanup mic monitoring when settings tab is closed
|
||||
signal.addEventListener("abort", () => {
|
||||
if (micAnimFrame !== null) cancelAnimationFrame(micAnimFrame);
|
||||
if (micStream !== null) {
|
||||
for (const track of micStream.getTracks()) track.stop();
|
||||
}
|
||||
if (micAudioCtx !== null) void micAudioCtx.close();
|
||||
});
|
||||
|
||||
// ── 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 },
|
||||
{ key: "silenceSuppression", label: "Silence Suppression", desc: "Stop sending audio during silence to save bandwidth", fallback: true },
|
||||
];
|
||||
|
||||
for (const item of audioToggles) {
|
||||
const row = createElement("div", { class: "setting-row" });
|
||||
const info = createElement("div", {});
|
||||
const label = createElement("div", { class: "setting-label" }, item.label);
|
||||
const desc = createElement("div", { class: "setting-desc" }, item.desc);
|
||||
appendChildren(info, label, desc);
|
||||
|
||||
const isOn = loadPref<boolean>(item.key, item.fallback);
|
||||
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
|
||||
toggle.addEventListener("click", () => {
|
||||
const nowOn = !toggle.classList.contains("on");
|
||||
toggle.classList.toggle("on", nowOn);
|
||||
savePref(item.key, nowOn);
|
||||
if (item.key === "silenceSuppression") {
|
||||
// Silence suppression takes effect on next VAD tick — no device switch needed
|
||||
updateSilenceSuppressionPref();
|
||||
} else {
|
||||
// Re-acquire mic with new constraints if in an active voice session
|
||||
const currentDevice = loadPref<string>("audioInputDevice", "");
|
||||
void switchInputDevice(currentDevice);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
appendChildren(row, info, toggle);
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared helpers and constants for settings tabs.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STORAGE_PREFIX = "owncord:settings:";
|
||||
|
||||
export const THEMES = {
|
||||
dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preference helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function loadPref<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + key);
|
||||
return raw !== null ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePref(key: string, value: unknown): void {
|
||||
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyTheme(name: ThemeName): void {
|
||||
const vars = THEMES[name];
|
||||
const root = document.documentElement;
|
||||
for (const [prop, val] of Object.entries(vars)) {
|
||||
root.style.setProperty(prop, val);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
// Step 2.13 — REST API Client
|
||||
// Uses Tauri's HTTP plugin fetch to bypass self-signed cert rejection in webview.
|
||||
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { createLogger } from "./logger";
|
||||
import type {
|
||||
AuthResponse,
|
||||
RegisterResponse,
|
||||
HealthResponse,
|
||||
MessagesResponse,
|
||||
SearchResponse,
|
||||
ApiError,
|
||||
ChannelType,
|
||||
ChannelResponse,
|
||||
EmojiResponse,
|
||||
SoundResponse,
|
||||
InviteResponse,
|
||||
SessionResponse,
|
||||
UploadResponse,
|
||||
VoiceCredentialsResponse,
|
||||
MemberResponse,
|
||||
} from "./types";
|
||||
|
||||
/** Configuration for the API client. */
|
||||
export interface ApiClientConfig {
|
||||
readonly host: string;
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
/** API client error with parsed error body. */
|
||||
export class ApiClientError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type OnUnauthorized = () => void;
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
/** Create the REST API client. */
|
||||
export function createApiClient(
|
||||
initialConfig: ApiClientConfig,
|
||||
onUnauthorized?: OnUnauthorized,
|
||||
) {
|
||||
let config = { ...initialConfig };
|
||||
|
||||
function baseUrl(): string {
|
||||
return `https://${config.host}/api/v1`;
|
||||
}
|
||||
|
||||
function adminBaseUrl(): string {
|
||||
return `https://${config.host}/admin/api`;
|
||||
}
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (config.token) {
|
||||
h["Authorization"] = `Bearer ${config.token}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const url = `${baseUrl()}${path}`;
|
||||
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
|
||||
method,
|
||||
headers: headers(),
|
||||
signal,
|
||||
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
log.debug("API →", { method, path });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, init as RequestInit);
|
||||
} catch (fetchErr) {
|
||||
// Tauri plugin errors may not be standard Error instances
|
||||
log.error("API fetch failed", { method, path, error: String(fetchErr) });
|
||||
if (fetchErr instanceof Error) {
|
||||
throw fetchErr;
|
||||
}
|
||||
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
|
||||
}
|
||||
|
||||
log.debug("API ←", { method, path, status: res.status });
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.();
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(401, err.error, err.message);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await parseError(res);
|
||||
log.warn("API error", { method, path, status: res.status, code: err.error, message: err.message });
|
||||
throw new ApiClientError(res.status, err.error, err.message);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function adminRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const url = `${adminBaseUrl()}${path}`;
|
||||
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
|
||||
method,
|
||||
headers: headers(),
|
||||
signal,
|
||||
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
log.debug("Admin API →", { method, path });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, init as RequestInit);
|
||||
} catch (fetchErr) {
|
||||
log.error("Admin API fetch failed", { method, path, error: String(fetchErr) });
|
||||
if (fetchErr instanceof Error) {
|
||||
throw fetchErr;
|
||||
}
|
||||
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
|
||||
}
|
||||
|
||||
log.debug("Admin API ←", { method, path, status: res.status });
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.();
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(401, err.error, err.message);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await parseError(res);
|
||||
log.warn("Admin API error", { method, path, status: res.status, code: err.error, message: err.message });
|
||||
throw new ApiClientError(res.status, err.error, err.message);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return {
|
||||
error: body.error ?? "UNKNOWN",
|
||||
message: body.message ?? res.statusText,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
error: "UNKNOWN",
|
||||
message: res.statusText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Update the client config (e.g., after login). */
|
||||
setConfig(newConfig: Partial<ApiClientConfig>): void {
|
||||
config = { ...config, ...newConfig };
|
||||
},
|
||||
|
||||
/** Get current config (for debugging). Token is redacted. */
|
||||
getConfig(): Readonly<ApiClientConfig> {
|
||||
return { ...config, token: config.token ? "[redacted]" : undefined };
|
||||
},
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
login(
|
||||
username: string,
|
||||
password: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthResponse> {
|
||||
return request<AuthResponse>(
|
||||
"POST",
|
||||
"/auth/login",
|
||||
{ username, password },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
register(
|
||||
username: string,
|
||||
password: string,
|
||||
inviteCode: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RegisterResponse> {
|
||||
return request<RegisterResponse>(
|
||||
"POST",
|
||||
"/auth/register",
|
||||
{ username, password, invite_code: inviteCode },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
logout(signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("POST", "/auth/logout", undefined, signal);
|
||||
},
|
||||
|
||||
verifyTotp(
|
||||
code: string,
|
||||
partialToken: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthResponse> {
|
||||
// Temporarily set token for this request; restore in .finally()
|
||||
const prevToken = config.token;
|
||||
config = { ...config, token: partialToken };
|
||||
return request<AuthResponse>(
|
||||
"POST",
|
||||
"/auth/verify-totp",
|
||||
{ code },
|
||||
signal,
|
||||
).finally(() => {
|
||||
config = { ...config, token: prevToken };
|
||||
});
|
||||
},
|
||||
|
||||
// ── Users ─────────────────────────────────────────────
|
||||
|
||||
getMe(signal?: AbortSignal): Promise<MemberResponse> {
|
||||
return request<MemberResponse>("GET", "/users/me", undefined, signal);
|
||||
},
|
||||
|
||||
updateProfile(
|
||||
data: { username?: string; avatar?: string },
|
||||
signal?: AbortSignal,
|
||||
): Promise<MemberResponse> {
|
||||
return request<MemberResponse>("PATCH", "/users/me", data, signal);
|
||||
},
|
||||
|
||||
changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
"PUT",
|
||||
"/users/me/password",
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
enableTotp(signal?: AbortSignal): Promise<{ qr_uri: string; backup_codes: string[] }> {
|
||||
return request("POST", "/users/me/totp/enable", undefined, signal);
|
||||
},
|
||||
|
||||
confirmTotp(code: string, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("POST", "/users/me/totp/confirm", { code }, signal);
|
||||
},
|
||||
|
||||
disableTotp(signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", "/users/me/totp", undefined, signal);
|
||||
},
|
||||
|
||||
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
|
||||
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,
|
||||
);
|
||||
},
|
||||
|
||||
// ── Channels ──────────────────────────────────────────
|
||||
|
||||
getMessages(
|
||||
channelId: number,
|
||||
options?: { before?: number; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<MessagesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
const qs = params.toString();
|
||||
return request<MessagesResponse>(
|
||||
"GET",
|
||||
`/channels/${channelId}/messages${qs ? `?${qs}` : ""}`,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
getPins(channelId: number, signal?: AbortSignal): Promise<MessagesResponse> {
|
||||
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,
|
||||
);
|
||||
},
|
||||
|
||||
unpinMessage(
|
||||
channelId: number,
|
||||
messageId: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
"DELETE",
|
||||
`/channels/${channelId}/pins/${messageId}`,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
// ── Search ────────────────────────────────────────────
|
||||
|
||||
search(
|
||||
query: string,
|
||||
options?: { channelId?: number; limit?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResponse> {
|
||||
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,
|
||||
);
|
||||
},
|
||||
|
||||
// ── File Uploads ──────────────────────────────────────
|
||||
|
||||
async uploadFile(
|
||||
file: File,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const url = `${baseUrl()}/uploads`;
|
||||
const h: Record<string, string> = {};
|
||||
if (config.token) {
|
||||
h["Authorization"] = `Bearer ${config.token}`;
|
||||
}
|
||||
// Don't set Content-Type — browser sets multipart boundary
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: h,
|
||||
body: formData,
|
||||
signal,
|
||||
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
|
||||
} as RequestInit);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(res.status, err.error, err.message);
|
||||
}
|
||||
|
||||
return res.json() as Promise<UploadResponse>;
|
||||
},
|
||||
|
||||
// ── Invites ───────────────────────────────────────────
|
||||
|
||||
getInvites(signal?: AbortSignal): Promise<InviteResponse[]> {
|
||||
return request<InviteResponse[]>("GET", "/invites", undefined, signal);
|
||||
},
|
||||
|
||||
createInvite(
|
||||
data: { max_uses?: number; expires_in_hours?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<InviteResponse> {
|
||||
return request<InviteResponse>("POST", "/invites", data, signal);
|
||||
},
|
||||
|
||||
revokeInvite(inviteId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/invites/${inviteId}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Emoji ─────────────────────────────────────────────
|
||||
|
||||
getEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> {
|
||||
return request<EmojiResponse[]>("GET", "/emoji", undefined, signal);
|
||||
},
|
||||
|
||||
deleteEmoji(emojiId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Sounds ────────────────────────────────────────────
|
||||
|
||||
getSounds(signal?: AbortSignal): Promise<SoundResponse[]> {
|
||||
return request<SoundResponse[]>("GET", "/sounds", undefined, signal);
|
||||
},
|
||||
|
||||
deleteSound(soundId: number, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── Voice ─────────────────────────────────────────────
|
||||
|
||||
getVoiceCredentials(
|
||||
signal?: AbortSignal,
|
||||
): Promise<VoiceCredentialsResponse> {
|
||||
return request<VoiceCredentialsResponse>(
|
||||
"GET",
|
||||
"/voice/credentials",
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
// ── Health ────────────────────────────────────────────
|
||||
|
||||
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 },
|
||||
} as RequestInit);
|
||||
if (!res.ok) {
|
||||
throw new ApiClientError(res.status, "HEALTH_CHECK_FAILED", "Health check failed");
|
||||
}
|
||||
return res.json() as Promise<HealthResponse>;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Admin: Channels ──────────────────────────────────────
|
||||
|
||||
adminCreateChannel(
|
||||
data: {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
category: string;
|
||||
topic?: string;
|
||||
position?: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChannelResponse> {
|
||||
return adminRequest<ChannelResponse>("POST", "/channels", data, signal);
|
||||
},
|
||||
|
||||
adminUpdateChannel(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
topic?: string;
|
||||
slow_mode?: number;
|
||||
position?: number;
|
||||
archived?: boolean;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChannelResponse> {
|
||||
return adminRequest<ChannelResponse>("PATCH", `/channels/${id}`, data, signal);
|
||||
},
|
||||
|
||||
adminDeleteChannel(
|
||||
id: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
return adminRequest<void>("DELETE", `/channels/${id}`, undefined, signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||
@@ -0,0 +1,155 @@
|
||||
// =============================================================================
|
||||
// Audio Device Manager — enumerate devices, acquire streams, set output
|
||||
// =============================================================================
|
||||
|
||||
import { loadPref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("audio");
|
||||
|
||||
export interface AudioDevice {
|
||||
readonly deviceId: string;
|
||||
readonly label: string;
|
||||
readonly kind: "audioinput" | "audiooutput";
|
||||
}
|
||||
|
||||
export interface AudioManager {
|
||||
enumerateDevices(): Promise<readonly AudioDevice[]>;
|
||||
getUserMedia(deviceId?: string): Promise<MediaStream>;
|
||||
setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void>;
|
||||
getInputDeviceId(): string | null;
|
||||
getOutputDeviceId(): string | null;
|
||||
onDeviceChange(callback: (devices: readonly AudioDevice[]) => void): () => void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
type DeviceChangeCallback = (devices: readonly AudioDevice[]) => void;
|
||||
|
||||
function toAudioDevice(info: MediaDeviceInfo): AudioDevice | null {
|
||||
if (info.kind !== "audioinput" && info.kind !== "audiooutput") return null;
|
||||
return {
|
||||
deviceId: info.deviceId,
|
||||
label: info.label || `${info.kind === "audioinput" ? "Microphone" : "Speaker"} (${info.deviceId.slice(0, 8)})`,
|
||||
kind: info.kind,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAudioManager(): AudioManager {
|
||||
let currentInputDeviceId: string | null = null;
|
||||
let currentOutputDeviceId: string | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const activeStreams = new Set<MediaStream>();
|
||||
const deviceChangeCallbacks = new Set<DeviceChangeCallback>();
|
||||
|
||||
async function listAudioDevices(): Promise<readonly AudioDevice[]> {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioDevices: AudioDevice[] = [];
|
||||
for (const d of devices) {
|
||||
const mapped = toAudioDevice(d);
|
||||
if (mapped !== null) {
|
||||
audioDevices.push(mapped);
|
||||
}
|
||||
}
|
||||
return audioDevices;
|
||||
}
|
||||
|
||||
function handleDeviceChange(): void {
|
||||
if (destroyed) return;
|
||||
void listAudioDevices().then((devices) => {
|
||||
log.info("Audio device change detected", {
|
||||
inputs: devices.filter((d) => d.kind === "audioinput").length,
|
||||
outputs: devices.filter((d) => d.kind === "audiooutput").length,
|
||||
});
|
||||
for (const cb of deviceChangeCallbacks) {
|
||||
cb(devices);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
return {
|
||||
async enumerateDevices(): Promise<readonly AudioDevice[]> {
|
||||
if (destroyed) throw new Error("AudioManager has been destroyed");
|
||||
return listAudioDevices();
|
||||
},
|
||||
|
||||
async getUserMedia(deviceId?: string): Promise<MediaStream> {
|
||||
if (destroyed) throw new Error("AudioManager has been destroyed");
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: {
|
||||
deviceId: deviceId !== undefined ? { exact: deviceId } : undefined,
|
||||
echoCancellation: loadPref<boolean>("echoCancellation", true),
|
||||
noiseSuppression: loadPref<boolean>("noiseSuppression", true),
|
||||
autoGainControl: loadPref<boolean>("autoGainControl", true),
|
||||
},
|
||||
video: false,
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
activeStreams.add(stream);
|
||||
|
||||
// Determine actual device ID from the track settings
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (audioTrack !== undefined) {
|
||||
const settings = audioTrack.getSettings();
|
||||
currentInputDeviceId = settings.deviceId ?? deviceId ?? null;
|
||||
log.info("Microphone acquired", {
|
||||
deviceId: currentInputDeviceId,
|
||||
sampleRate: settings.sampleRate,
|
||||
channelCount: settings.channelCount,
|
||||
echoCancellation: settings.echoCancellation,
|
||||
noiseSuppression: settings.noiseSuppression,
|
||||
autoGainControl: settings.autoGainControl,
|
||||
});
|
||||
}
|
||||
|
||||
return stream;
|
||||
},
|
||||
|
||||
async setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void> {
|
||||
if (destroyed) throw new Error("AudioManager has been destroyed");
|
||||
|
||||
// setSinkId is not available in all browsers; check before calling
|
||||
if (typeof element.setSinkId !== "function") {
|
||||
throw new Error("Audio output device selection is not supported in this browser");
|
||||
}
|
||||
await element.setSinkId(deviceId);
|
||||
currentOutputDeviceId = deviceId;
|
||||
},
|
||||
|
||||
getInputDeviceId(): string | null {
|
||||
return currentInputDeviceId;
|
||||
},
|
||||
|
||||
getOutputDeviceId(): string | null {
|
||||
return currentOutputDeviceId;
|
||||
},
|
||||
|
||||
onDeviceChange(callback: DeviceChangeCallback): () => void {
|
||||
deviceChangeCallbacks.add(callback);
|
||||
return () => { deviceChangeCallbacks.delete(callback); };
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
|
||||
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
// Stop all tracks on all active streams
|
||||
log.debug("AudioManager destroying", { activeStreams: activeStreams.size });
|
||||
for (const stream of activeStreams) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
activeStreams.clear();
|
||||
deviceChangeCallbacks.clear();
|
||||
currentInputDeviceId = null;
|
||||
currentOutputDeviceId = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Credential storage — wraps Tauri IPC commands for Windows Credential Manager.
|
||||
* Falls back to no-op in non-Tauri environments (tests, browser).
|
||||
*/
|
||||
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("credentials");
|
||||
|
||||
export interface SavedCredential {
|
||||
readonly username: string;
|
||||
readonly token: string;
|
||||
readonly password?: string;
|
||||
}
|
||||
|
||||
/** Dynamically import Tauri invoke to avoid errors in test/browser. */
|
||||
async function getInvoke(): Promise<
|
||||
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
|
||||
> {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a credential to Windows Credential Manager.
|
||||
* Target: OwnCord/{host}
|
||||
*/
|
||||
export async function saveCredential(
|
||||
host: string,
|
||||
username: string,
|
||||
token: string,
|
||||
password?: string,
|
||||
): Promise<boolean> {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
log.warn("Tauri not available — credential not saved");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await invoke("save_credential", { host, username, token, password: password ?? null });
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error("Failed to save credential", { host, error: String(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a credential from Windows Credential Manager.
|
||||
* Returns null if not found or Tauri unavailable.
|
||||
*/
|
||||
export async function loadCredential(
|
||||
host: string,
|
||||
): Promise<SavedCredential | null> {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const result = await invoke("load_credential", { host });
|
||||
if (result && typeof result === "object") {
|
||||
const cred = result as Record<string, unknown>;
|
||||
if (typeof cred.username === "string" && typeof cred.token === "string") {
|
||||
const saved: SavedCredential = {
|
||||
username: cred.username,
|
||||
token: cred.token,
|
||||
...(typeof cred.password === "string" ? { password: cred.password } : {}),
|
||||
};
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
log.error("Failed to load credential", { host, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a credential from Windows Credential Manager.
|
||||
*/
|
||||
export async function deleteCredential(host: string): Promise<boolean> {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await invoke("delete_credential", { host });
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error("Failed to delete credential", { host, error: String(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Step 2.26 — WebSocket Dispatcher
|
||||
// Wires WS client events to store updates.
|
||||
// Each server message type maps to one or more store actions.
|
||||
|
||||
import type { WsClient } from "./ws";
|
||||
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
|
||||
import { setTransientError } from "@stores/ui.store";
|
||||
import {
|
||||
setChannels,
|
||||
setActiveChannel,
|
||||
addChannel,
|
||||
updateChannel,
|
||||
removeChannel,
|
||||
incrementUnread,
|
||||
} from "@stores/channels.store";
|
||||
import { channelsStore } from "@stores/channels.store";
|
||||
import {
|
||||
addMessage,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
updateReaction,
|
||||
confirmSend,
|
||||
} from "@stores/messages.store";
|
||||
import {
|
||||
setMembers,
|
||||
addMember,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
updatePresence,
|
||||
setTyping,
|
||||
} from "@stores/members.store";
|
||||
import {
|
||||
setVoiceStates,
|
||||
updateVoiceState,
|
||||
removeVoiceUser,
|
||||
setVoiceConfig,
|
||||
setSpeakers,
|
||||
joinVoiceChannel,
|
||||
leaveVoiceChannel,
|
||||
} from "@stores/voice.store";
|
||||
import {
|
||||
handleServerOffer,
|
||||
handleServerAnswer,
|
||||
handleServerIce,
|
||||
} from "@lib/voiceSession";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("dispatcher");
|
||||
|
||||
/** Unsubscribe all listeners. */
|
||||
export type DispatcherCleanup = () => void;
|
||||
|
||||
/**
|
||||
* Wire a WsClient to all domain stores.
|
||||
* Returns a cleanup function that removes all listeners.
|
||||
*/
|
||||
export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("auth_ok", (payload) => {
|
||||
setAuth(
|
||||
authStore.getState().token ?? "",
|
||||
payload.user,
|
||||
payload.server_name,
|
||||
payload.motd,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("auth_error", (payload) => {
|
||||
log.error("Auth failed", { message: payload.message });
|
||||
setTransientError(payload.message);
|
||||
clearAuth();
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Ready (initial state dump) ────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("ready", (payload) => {
|
||||
setChannels(payload.channels);
|
||||
setMembers(payload.members);
|
||||
setVoiceStates(payload.voice_states);
|
||||
|
||||
// Auto-select the first text channel if none is active
|
||||
const currentActive = channelsStore.select((s) => s.activeChannelId);
|
||||
if (currentActive === null && payload.channels.length > 0) {
|
||||
const firstText = payload.channels.find((ch) => ch.type === "text");
|
||||
if (firstText !== undefined) {
|
||||
setActiveChannel(firstText.id);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Ready payload applied", {
|
||||
channels: payload.channels.length,
|
||||
members: payload.members.length,
|
||||
voiceStates: payload.voice_states.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Chat Messages ─────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_message", (payload) => {
|
||||
log.debug("chat_message received", {
|
||||
id: payload.id,
|
||||
channelId: payload.channel_id,
|
||||
user: payload.user.username,
|
||||
});
|
||||
addMessage(payload);
|
||||
// Increment unread for non-active channels
|
||||
const activeId = channelsStore.select(
|
||||
(s) => s.activeChannelId,
|
||||
);
|
||||
if (payload.channel_id !== activeId) {
|
||||
incrementUnread(payload.channel_id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_edited", (payload) => {
|
||||
editMessage(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_deleted", (payload) => {
|
||||
deleteMessage(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_send_ok", (payload, id) => {
|
||||
if (id) {
|
||||
confirmSend(id, payload.message_id, payload.timestamp);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Reactions ───────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("reaction_update", (payload) => {
|
||||
const userId = authStore.getState().user?.id ?? 0;
|
||||
updateReaction(payload, userId);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Typing ────────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("typing", (payload) => {
|
||||
setTyping(payload.channel_id, payload.user_id);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Presence ──────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("presence", (payload) => {
|
||||
updatePresence(payload.user_id, payload.status);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Channels ──────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("channel_create", (payload) => {
|
||||
addChannel(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("channel_update", (payload) => {
|
||||
updateChannel(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("channel_delete", (payload) => {
|
||||
// If the deleted channel is the active one, redirect to the first text channel.
|
||||
const activeId = channelsStore.select((s) => s.activeChannelId);
|
||||
removeChannel(payload.id);
|
||||
if (payload.id === activeId) {
|
||||
const remaining = channelsStore.select((s) => s.channels);
|
||||
const sorted = [...remaining.values()]
|
||||
.filter((ch) => ch.type === "text")
|
||||
.sort((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 });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Members ───────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_join", (payload) => {
|
||||
log.info("Member joined", { userId: payload.user.id, username: payload.user.username });
|
||||
addMember(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_leave", (payload) => {
|
||||
log.info("Member left", { userId: payload.user_id });
|
||||
removeMember(payload.user_id);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_ban", (payload) => {
|
||||
log.info("Member banned", { userId: payload.user_id });
|
||||
removeMember(payload.user_id);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_update", (payload) => {
|
||||
log.info("Member role updated", { userId: payload.user_id, role: payload.role });
|
||||
updateMemberRole(payload.user_id, payload.role);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Voice ─────────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_state", (payload) => {
|
||||
updateVoiceState(payload);
|
||||
// Auto-join voice channel if the event is for the current user
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
if (payload.user_id === currentUserId) {
|
||||
joinVoiceChannel(payload.channel_id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_leave", (payload) => {
|
||||
removeVoiceUser(payload);
|
||||
// Clear local voice state if the current user was removed (kick/disconnect)
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
if (payload.user_id === currentUserId) {
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_config", (payload) => {
|
||||
setVoiceConfig(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_speakers", (payload) => {
|
||||
setSpeakers(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_offer", (payload) => {
|
||||
handleServerOffer(payload.sdp, payload.channel_id);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_answer", (payload) => {
|
||||
handleServerAnswer(payload.sdp);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_ice", (payload) => {
|
||||
handleServerIce(payload.candidate);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Server Events ─────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("server_restart", (payload) => {
|
||||
log.warn("Server restarting", {
|
||||
reason: payload.reason,
|
||||
delaySeconds: payload.delay_seconds,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("error", (payload) => {
|
||||
log.error("Server error", {
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const unsub of unsubs) {
|
||||
unsub();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Step 1.11 — Safe DOM utilities
|
||||
// NEVER use innerHTML with user-provided content.
|
||||
// All user content must go through these helpers.
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS.
|
||||
* Use this when building HTML strings that include user data.
|
||||
*/
|
||||
export function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an element with optional attributes and text content.
|
||||
* Text is set via textContent (safe from XSS).
|
||||
*/
|
||||
export function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs?: Record<string, string>,
|
||||
textContent?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === "class") {
|
||||
el.className = value;
|
||||
} else if (key.startsWith("data-")) {
|
||||
el.dataset[key.slice(5)] = value;
|
||||
} else if (key.startsWith("aria-")) {
|
||||
el.setAttribute(key, value);
|
||||
} else {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (textContent !== undefined) {
|
||||
el.textContent = textContent;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set text content safely on an element.
|
||||
* Always prefer this over innerHTML for user content.
|
||||
*/
|
||||
export function setText(el: Element, text: string): void {
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append multiple children to a parent element.
|
||||
*/
|
||||
export function appendChildren(
|
||||
parent: Element,
|
||||
...children: (Element | string)[]
|
||||
): void {
|
||||
for (const child of children) {
|
||||
if (typeof child === "string") {
|
||||
parent.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
parent.appendChild(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all children from an element safely.
|
||||
*/
|
||||
export function clearChildren(el: Element): void {
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query a single element with type safety.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
export function qs<K extends keyof HTMLElementTagNameMap>(
|
||||
selector: K,
|
||||
parent?: Element,
|
||||
): HTMLElementTagNameMap[K] | null;
|
||||
export function qs(selector: string, parent?: Element): Element | null;
|
||||
export function qs(selector: string, parent?: Element): Element | null {
|
||||
return (parent ?? document).querySelector(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all matching elements as an array.
|
||||
*/
|
||||
export function qsa(selector: string, parent?: Element): Element[] {
|
||||
return Array.from((parent ?? document).querySelectorAll(selector));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Step 1.12 — Structured client-side logger
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogEntry {
|
||||
readonly timestamp: string;
|
||||
readonly level: LogLevel;
|
||||
readonly component: string;
|
||||
readonly message: string;
|
||||
readonly data?: unknown;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const MAX_LOG_BUFFER = 500;
|
||||
const logBuffer: LogEntry[] = [];
|
||||
|
||||
let currentLevel: LogLevel = "debug";
|
||||
const listeners: Array<(entry: LogEntry) => void> = [];
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[currentLevel];
|
||||
}
|
||||
|
||||
/** Convert Error objects (and nested ones) into serializable form.
|
||||
* Error.message and Error.stack don't appear in JSON.stringify by default. */
|
||||
function serializeData(data: unknown): unknown {
|
||||
if (data instanceof Error) {
|
||||
return { error: data.message, stack: data.stack };
|
||||
}
|
||||
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;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function createEntry(
|
||||
level: LogLevel,
|
||||
component: string,
|
||||
message: string,
|
||||
data?: unknown,
|
||||
): LogEntry {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
component,
|
||||
message,
|
||||
data: data !== undefined ? serializeData(data) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function emit(entry: LogEntry): void {
|
||||
// Store in circular buffer
|
||||
logBuffer.push(entry);
|
||||
if (logBuffer.length > MAX_LOG_BUFFER) {
|
||||
logBuffer.shift();
|
||||
}
|
||||
|
||||
// Console output
|
||||
const prefix = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.component}]`;
|
||||
switch (entry.level) {
|
||||
case "debug":
|
||||
console.debug(prefix, entry.message, entry.data ?? "");
|
||||
break;
|
||||
case "info":
|
||||
console.info(prefix, entry.message, entry.data ?? "");
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(prefix, entry.message, entry.data ?? "");
|
||||
break;
|
||||
case "error":
|
||||
console.error(prefix, entry.message, entry.data ?? "");
|
||||
break;
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
for (const listener of listeners) {
|
||||
listener(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scoped logger for a specific component.
|
||||
*/
|
||||
export function createLogger(component: string) {
|
||||
return {
|
||||
debug(message: string, data?: unknown): void {
|
||||
if (shouldLog("debug")) emit(createEntry("debug", component, message, data));
|
||||
},
|
||||
info(message: string, data?: unknown): void {
|
||||
if (shouldLog("info")) emit(createEntry("info", component, message, data));
|
||||
},
|
||||
warn(message: string, data?: unknown): void {
|
||||
if (shouldLog("warn")) emit(createEntry("warn", component, message, data));
|
||||
},
|
||||
error(message: string, data?: unknown): void {
|
||||
if (shouldLog("error")) emit(createEntry("error", component, message, data));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the minimum log level. Messages below this level are silenced.
|
||||
*/
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener for log entries (e.g., to write to file via Tauri).
|
||||
*/
|
||||
export function addLogListener(listener: (entry: LogEntry) => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snapshot of the in-memory log buffer (most recent MAX_LOG_BUFFER entries).
|
||||
*/
|
||||
export function getLogBuffer(): readonly LogEntry[] {
|
||||
return logBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the log buffer.
|
||||
*/
|
||||
export function clearLogBuffer(): void {
|
||||
logBuffer.length = 0;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// =============================================================================
|
||||
// Noise Suppression — RNNoise ML-based noise removal via Web Audio API
|
||||
//
|
||||
// Inserts between getUserMedia stream and the PeerConnection to clean audio.
|
||||
// RNNoise processes 480-sample frames at 48kHz (10ms).
|
||||
//
|
||||
// Uses AudioWorklet (modern, runs on audio thread) with ScriptProcessorNode
|
||||
// fallback (deprecated but widely supported).
|
||||
// =============================================================================
|
||||
|
||||
import { createRNNWasmModule } from "@jitsi/rnnoise-wasm";
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("noise-suppression");
|
||||
|
||||
const RNNOISE_FRAME_SIZE = 480;
|
||||
const SCRIPT_PROCESSOR_BUFFER = 4096;
|
||||
|
||||
export interface NoiseSuppressor {
|
||||
process(input: MediaStream): Promise<MediaStream>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared WASM module cache (used by ScriptProcessorNode fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RNNoiseModule {
|
||||
_rnnoise_create: () => number;
|
||||
_rnnoise_destroy: (state: number) => void;
|
||||
_rnnoise_process_frame: (state: number, out: number, inp: number) => number;
|
||||
_malloc: (bytes: number) => number;
|
||||
_free: (ptr: number) => void;
|
||||
HEAPF32: Float32Array;
|
||||
ready: Promise<unknown>;
|
||||
}
|
||||
|
||||
let cachedModule: RNNoiseModule | null = null;
|
||||
|
||||
async function loadRNNoise(): Promise<RNNoiseModule> {
|
||||
if (cachedModule !== null) return cachedModule;
|
||||
const startMs = performance.now();
|
||||
const mod = (createRNNWasmModule as (opts: Record<string, unknown>) => unknown)({
|
||||
locateFile: (file: string) => {
|
||||
if (file.endsWith(".wasm")) return "/rnnoise.wasm";
|
||||
return file;
|
||||
},
|
||||
}) as RNNoiseModule;
|
||||
await mod.ready;
|
||||
cachedModule = mod;
|
||||
log.info("RNNoise WASM loaded", { durationMs: Math.round(performance.now() - startMs) });
|
||||
return mod;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioWorklet-based suppressor (preferred, runs on audio thread)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createWorkletSuppressor(): NoiseSuppressor {
|
||||
let audioContext: AudioContext | null = null;
|
||||
let sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
let destNode: MediaStreamAudioDestinationNode | null = null;
|
||||
let workletNode: AudioWorkletNode | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
return {
|
||||
async process(input: MediaStream): Promise<MediaStream> {
|
||||
if (destroyed) throw new Error("NoiseSuppressor destroyed");
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
|
||||
// Load the worklet processor module
|
||||
await audioContext.audioWorklet.addModule("/rnnoise-worklet.js");
|
||||
|
||||
// Fetch WASM bytes to send to the worklet thread
|
||||
const wasmResponse = await fetch("/rnnoise.wasm");
|
||||
const wasmBytes = await wasmResponse.arrayBuffer();
|
||||
|
||||
sourceNode = audioContext.createMediaStreamSource(input);
|
||||
destNode = audioContext.createMediaStreamDestination();
|
||||
|
||||
workletNode = new AudioWorkletNode(audioContext, "rnnoise-processor", {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [1],
|
||||
});
|
||||
|
||||
// Wait for WASM init in the worklet
|
||||
const initPromise = new Promise<void>((resolve, reject) => {
|
||||
if (workletNode === null) { reject(new Error("No worklet")); return; }
|
||||
workletNode.port.onmessage = (event: MessageEvent) => {
|
||||
if (event.data.type === "ready") {
|
||||
resolve();
|
||||
} else if (event.data.type === "error") {
|
||||
reject(new Error(event.data.message));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Send WASM bytes to the worklet for initialization
|
||||
workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]);
|
||||
await initPromise;
|
||||
|
||||
sourceNode.connect(workletNode);
|
||||
workletNode.connect(destNode);
|
||||
|
||||
log.info("RNNoise AudioWorklet processing active");
|
||||
return destNode.stream;
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
|
||||
if (workletNode !== null) {
|
||||
workletNode.port.postMessage({ type: "destroy" });
|
||||
workletNode.disconnect();
|
||||
workletNode = null;
|
||||
}
|
||||
if (sourceNode !== null) {
|
||||
sourceNode.disconnect();
|
||||
sourceNode = null;
|
||||
}
|
||||
if (destNode !== null) {
|
||||
destNode.disconnect();
|
||||
destNode = null;
|
||||
}
|
||||
if (audioContext !== null) {
|
||||
void audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
log.info("RNNoise AudioWorklet destroyed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScriptProcessorNode fallback (deprecated but universal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createScriptProcessorSuppressor(): NoiseSuppressor {
|
||||
let audioContext: AudioContext | null = null;
|
||||
let sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
let destNode: MediaStreamAudioDestinationNode | null = null;
|
||||
let processorNode: ScriptProcessorNode | null = null;
|
||||
let rnnoiseState: number = 0;
|
||||
let inputPtr: number = 0;
|
||||
let outputPtr: number = 0;
|
||||
let wasmModule: RNNoiseModule | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const inputRing = new Float32Array(RNNOISE_FRAME_SIZE);
|
||||
let inputRingOffset = 0;
|
||||
|
||||
const OUT_RING_CAPACITY = 50;
|
||||
const outRing: Float32Array[] = new Array(OUT_RING_CAPACITY);
|
||||
let outWriteIdx = 0;
|
||||
let outReadIdx = 0;
|
||||
let outCount = 0;
|
||||
let outSampleOffset = 0;
|
||||
|
||||
function processFrame(): void {
|
||||
if (wasmModule === null) return;
|
||||
const inOff = inputPtr / 4;
|
||||
for (let i = 0; i < RNNOISE_FRAME_SIZE; i++) {
|
||||
wasmModule.HEAPF32[inOff + i] = (inputRing[i] ?? 0) * 32768;
|
||||
}
|
||||
wasmModule._rnnoise_process_frame(rnnoiseState, outputPtr, inputPtr);
|
||||
const outOff = outputPtr / 4;
|
||||
const result = new Float32Array(RNNOISE_FRAME_SIZE);
|
||||
for (let i = 0; i < RNNOISE_FRAME_SIZE; i++) {
|
||||
result[i] = (wasmModule.HEAPF32[outOff + i] ?? 0) / 32768;
|
||||
}
|
||||
if (outCount >= OUT_RING_CAPACITY) {
|
||||
outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY;
|
||||
outCount--;
|
||||
outSampleOffset = 0;
|
||||
}
|
||||
outRing[outWriteIdx] = result;
|
||||
outWriteIdx = (outWriteIdx + 1) % OUT_RING_CAPACITY;
|
||||
outCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
async process(input: MediaStream): Promise<MediaStream> {
|
||||
if (destroyed) throw new Error("NoiseSuppressor destroyed");
|
||||
|
||||
wasmModule = await loadRNNoise();
|
||||
rnnoiseState = wasmModule._rnnoise_create();
|
||||
inputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
|
||||
outputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
sourceNode = audioContext.createMediaStreamSource(input);
|
||||
destNode = audioContext.createMediaStreamDestination();
|
||||
processorNode = audioContext.createScriptProcessor(SCRIPT_PROCESSOR_BUFFER, 1, 1);
|
||||
|
||||
processorNode.onaudioprocess = (event: AudioProcessingEvent) => {
|
||||
const inData = event.inputBuffer.getChannelData(0);
|
||||
const outData = event.outputBuffer.getChannelData(0);
|
||||
|
||||
let inIdx = 0;
|
||||
while (inIdx < inData.length) {
|
||||
const needed = RNNOISE_FRAME_SIZE - inputRingOffset;
|
||||
const toCopy = Math.min(needed, inData.length - inIdx);
|
||||
inputRing.set(inData.subarray(inIdx, inIdx + toCopy), inputRingOffset);
|
||||
inputRingOffset += toCopy;
|
||||
inIdx += toCopy;
|
||||
if (inputRingOffset >= RNNOISE_FRAME_SIZE) {
|
||||
processFrame();
|
||||
inputRingOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let outIdx = 0;
|
||||
while (outIdx < outData.length && outCount > 0) {
|
||||
const chunk = outRing[outReadIdx]!;
|
||||
const available = chunk.length - outSampleOffset;
|
||||
const toWrite = Math.min(available, outData.length - outIdx);
|
||||
outData.set(chunk.subarray(outSampleOffset, outSampleOffset + toWrite), outIdx);
|
||||
outIdx += toWrite;
|
||||
outSampleOffset += toWrite;
|
||||
if (outSampleOffset >= chunk.length) {
|
||||
outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY;
|
||||
outCount--;
|
||||
outSampleOffset = 0;
|
||||
}
|
||||
}
|
||||
if (outIdx < outData.length) {
|
||||
outData.fill(0, outIdx);
|
||||
}
|
||||
};
|
||||
|
||||
sourceNode.connect(processorNode);
|
||||
processorNode.connect(destNode);
|
||||
|
||||
log.info("RNNoise ScriptProcessor processing active (fallback)");
|
||||
return destNode.stream;
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
|
||||
if (processorNode !== null) {
|
||||
processorNode.onaudioprocess = null;
|
||||
processorNode.disconnect();
|
||||
processorNode = null;
|
||||
}
|
||||
if (sourceNode !== null) {
|
||||
sourceNode.disconnect();
|
||||
sourceNode = null;
|
||||
}
|
||||
if (destNode !== null) {
|
||||
destNode.disconnect();
|
||||
destNode = null;
|
||||
}
|
||||
if (audioContext !== null) {
|
||||
void audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
if (wasmModule !== null && rnnoiseState !== 0) {
|
||||
wasmModule._rnnoise_destroy(rnnoiseState);
|
||||
wasmModule._free(inputPtr);
|
||||
wasmModule._free(outputPtr);
|
||||
rnnoiseState = 0;
|
||||
}
|
||||
outWriteIdx = 0;
|
||||
outReadIdx = 0;
|
||||
outCount = 0;
|
||||
outSampleOffset = 0;
|
||||
log.info("RNNoise ScriptProcessor destroyed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory — tries AudioWorklet first, falls back to ScriptProcessorNode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if AudioWorklet is available in this browser context. */
|
||||
function supportsAudioWorklet(): boolean {
|
||||
try {
|
||||
return typeof AudioWorkletNode !== "undefined"
|
||||
&& typeof AudioContext !== "undefined"
|
||||
&& "audioWorklet" in AudioContext.prototype;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createNoiseSuppressor(): NoiseSuppressor {
|
||||
log.debug("Creating noise suppressor", { audioWorkletSupported: supportsAudioWorklet() });
|
||||
if (supportsAudioWorklet()) {
|
||||
// Wrap in a facade that falls back to ScriptProcessor on failure
|
||||
const worklet = createWorkletSuppressor();
|
||||
let fallback: NoiseSuppressor | null = null;
|
||||
let activeSuppressor: NoiseSuppressor = worklet;
|
||||
|
||||
return {
|
||||
async process(input: MediaStream): Promise<MediaStream> {
|
||||
try {
|
||||
return await worklet.process(input);
|
||||
} catch (err) {
|
||||
log.warn("AudioWorklet failed, falling back to ScriptProcessorNode", err);
|
||||
worklet.destroy();
|
||||
fallback = createScriptProcessorSuppressor();
|
||||
activeSuppressor = fallback;
|
||||
return fallback.process(input);
|
||||
}
|
||||
},
|
||||
destroy(): void {
|
||||
activeSuppressor.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
log.info("AudioWorklet not supported, using ScriptProcessorNode");
|
||||
return createScriptProcessorSuppressor();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Permission } from './types';
|
||||
|
||||
/** Bitmask with every permission bit set. */
|
||||
const ALL_PERMISSIONS = 0x7FFFFFFF;
|
||||
|
||||
/**
|
||||
* Returns true if `userPerms` includes the given permission bit.
|
||||
* Users with the ADMINISTRATOR bit always pass.
|
||||
*/
|
||||
export function hasPermission(userPerms: number, perm: Permission): boolean {
|
||||
if ((userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) {
|
||||
return true;
|
||||
}
|
||||
return (userPerms & perm) === perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `userPerms` includes **any** of the listed permissions.
|
||||
* ADMINISTRATOR bit causes an automatic pass.
|
||||
*/
|
||||
export function hasAnyPermission(userPerms: number, ...perms: Permission[]): boolean {
|
||||
if ((userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) {
|
||||
return true;
|
||||
}
|
||||
return perms.some((p) => (userPerms & p) === p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `userPerms` includes **all** of the listed permissions.
|
||||
* ADMINISTRATOR bit causes an automatic pass.
|
||||
*/
|
||||
export function hasAllPermissions(userPerms: number, ...perms: Permission[]): boolean {
|
||||
if ((userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) {
|
||||
return true;
|
||||
}
|
||||
return perms.every((p) => (userPerms & p) === p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective permissions after applying channel-level overrides.
|
||||
*
|
||||
* - If the base permissions contain ADMINISTRATOR the result is all bits set
|
||||
* (deny/allow are ignored).
|
||||
* - Otherwise: start with `basePerms`, add `allow` bits, then remove `deny` bits.
|
||||
* Deny takes precedence over allow.
|
||||
*/
|
||||
export function computeEffective(basePerms: number, allow: number, deny: number): number {
|
||||
if ((basePerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) {
|
||||
return ALL_PERMISSIONS;
|
||||
}
|
||||
return (basePerms | allow) & ~deny;
|
||||
}
|
||||
|
||||
/** Shorthand check for the ADMINISTRATOR bit. */
|
||||
export function isAdministrator(userPerms: number): boolean {
|
||||
return (userPerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Server profiles management module.
|
||||
*
|
||||
* Manages saved server connection profiles for the OwnCord login page.
|
||||
* Uses the createStore reactive pattern for state and Tauri invoke
|
||||
* commands for persistence (mockable via dependency injection).
|
||||
*/
|
||||
|
||||
import { createStore, type Store } from "./store";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STORAGE_KEY = "owncord:profiles";
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
const HEALTH_TIMEOUT_MS = 3000;
|
||||
const SLOW_THRESHOLD_MS = 1500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServerProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly host: string;
|
||||
readonly username: string;
|
||||
readonly autoConnect: boolean;
|
||||
readonly rememberPassword: boolean;
|
||||
readonly color: string;
|
||||
readonly lastConnected: string | null;
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
readonly status: "online" | "slow" | "offline" | "checking";
|
||||
readonly latencyMs: number | null;
|
||||
readonly version: string | null;
|
||||
}
|
||||
|
||||
export interface ProfilesState {
|
||||
readonly profiles: readonly ServerProfile[];
|
||||
readonly healthStatuses: ReadonlyMap<string, HealthStatus>;
|
||||
}
|
||||
|
||||
export type CreateProfileData = Omit<ServerProfile, "id" | "lastConnected">;
|
||||
|
||||
export type UpdateProfileData = Partial<Omit<ServerProfile, "id">>;
|
||||
|
||||
/** Schema-versioned persistence envelope. */
|
||||
interface StoredData {
|
||||
readonly schemaVersion: number;
|
||||
readonly profiles: readonly ServerProfile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence backend abstraction.
|
||||
* In production, wraps Tauri `invoke("save_settings", ...)` / `invoke("get_settings")`.
|
||||
* In tests, can be replaced with a synchronous Map-backed implementation.
|
||||
*/
|
||||
export interface PersistenceBackend {
|
||||
load(): Promise<StoredData | null>;
|
||||
save(data: StoredData): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch function type matching the Tauri HTTP plugin signature.
|
||||
* Allows injection of a mock in tests.
|
||||
*/
|
||||
export type FetchFn = typeof globalThis.fetch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isValidProfileShape(item: unknown): item is ServerProfile {
|
||||
if (typeof item !== "object" || item === null) return false;
|
||||
const obj = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.id === "string" &&
|
||||
typeof obj.name === "string" &&
|
||||
obj.name.length > 0 &&
|
||||
typeof obj.host === "string" &&
|
||||
obj.host.length > 0 &&
|
||||
typeof obj.username === "string" &&
|
||||
typeof obj.color === "string" &&
|
||||
typeof obj.autoConnect === "boolean" &&
|
||||
(obj.rememberPassword === undefined || typeof obj.rememberPassword === "boolean") &&
|
||||
(obj.lastConnected === null || typeof obj.lastConnected === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isValidStoredData(data: unknown): data is StoredData {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.schemaVersion === "number" &&
|
||||
Array.isArray(obj.profiles) &&
|
||||
obj.profiles.every(isValidProfileShape)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default Tauri persistence backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createTauriBackend(): PersistenceBackend {
|
||||
return {
|
||||
async load(): Promise<StoredData | null> {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = (await invoke("get_settings")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const raw = settings[STORAGE_KEY];
|
||||
if (raw === undefined || raw === null) return null;
|
||||
if (isValidStoredData(raw)) return raw;
|
||||
return null;
|
||||
},
|
||||
async save(data: StoredData): Promise<void> {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("save_settings", { key: STORAGE_KEY, value: data });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProfileManager {
|
||||
/** Reactive store — subscribe for state changes. */
|
||||
readonly store: Store<ProfilesState>;
|
||||
|
||||
/** Load profiles from persistence backend into store. */
|
||||
loadProfiles(): Promise<void>;
|
||||
|
||||
/** Save current profiles to persistence backend. */
|
||||
saveProfiles(): Promise<void>;
|
||||
|
||||
/** Get all profiles (snapshot). */
|
||||
getAll(): readonly ServerProfile[];
|
||||
|
||||
/** Get a profile by id. */
|
||||
getById(id: string): ServerProfile | null;
|
||||
|
||||
/** Add a new profile. Returns the created profile. */
|
||||
addProfile(data: CreateProfileData): ServerProfile;
|
||||
|
||||
/** Update an existing profile. Returns updated profile or null if not found. */
|
||||
updateProfile(id: string, data: UpdateProfileData): ServerProfile | null;
|
||||
|
||||
/** Remove a profile by id. Returns true if removed. */
|
||||
removeProfile(id: string): boolean;
|
||||
|
||||
/** Returns the first profile with autoConnect=true, or null. */
|
||||
getAutoConnectProfile(): ServerProfile | null;
|
||||
|
||||
/** Set lastConnected to current ISO timestamp. */
|
||||
setLastConnected(id: string): void;
|
||||
|
||||
/** Check health of a single profile by id. Updates healthStatuses. */
|
||||
checkHealth(profileId: string): Promise<HealthStatus>;
|
||||
|
||||
/** Check health of all profiles in parallel. Updates healthStatuses. */
|
||||
checkAllHealth(): Promise<ReadonlyMap<string, HealthStatus>>;
|
||||
|
||||
/** Export all profiles as a JSON string. */
|
||||
exportProfiles(): string;
|
||||
|
||||
/** Import profiles from JSON string. Merges by host (skips duplicates). */
|
||||
importProfiles(json: string): { imported: number; skipped: number };
|
||||
}
|
||||
|
||||
export function createProfileManager(
|
||||
backend: PersistenceBackend,
|
||||
fetchFn?: FetchFn,
|
||||
): ProfileManager {
|
||||
const initialState: ProfilesState = {
|
||||
profiles: [],
|
||||
healthStatuses: new Map(),
|
||||
};
|
||||
|
||||
const store = createStore<ProfilesState>(initialState);
|
||||
|
||||
// Resolve which fetch to use: injected mock, Tauri plugin, or global
|
||||
const doFetch: FetchFn = fetchFn ?? (fetch as unknown as FetchFn);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
function currentProfiles(): readonly ServerProfile[] {
|
||||
return store.getState().profiles;
|
||||
}
|
||||
|
||||
function setProfiles(profiles: readonly ServerProfile[]): void {
|
||||
store.setState((prev) => ({
|
||||
...prev,
|
||||
profiles,
|
||||
}));
|
||||
}
|
||||
|
||||
function setHealthStatus(profileId: string, status: HealthStatus): void {
|
||||
store.setState((prev) => {
|
||||
const next = new Map(prev.healthStatuses);
|
||||
next.set(profileId, status);
|
||||
return { ...prev, healthStatuses: next };
|
||||
});
|
||||
}
|
||||
|
||||
function toStoredData(): StoredData {
|
||||
return {
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
profiles: [...currentProfiles()],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Health check implementation ────────────────────────────
|
||||
|
||||
async function pingHost(host: string): Promise<HealthStatus> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await doFetch(`https://${host}/api/v1/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
return { status: "offline", latencyMs: elapsed, version: null };
|
||||
}
|
||||
|
||||
const body = (await res.json()) as { version?: string };
|
||||
const version = typeof body.version === "string" ? body.version : null;
|
||||
const status = elapsed > SLOW_THRESHOLD_MS ? "slow" : "online";
|
||||
|
||||
return { status, latencyMs: elapsed, version };
|
||||
} catch {
|
||||
return { status: "offline", latencyMs: null, version: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────
|
||||
|
||||
const manager: ProfileManager = {
|
||||
store,
|
||||
|
||||
async loadProfiles(): Promise<void> {
|
||||
const data = await backend.load();
|
||||
if (data !== null) {
|
||||
setProfiles(data.profiles);
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfiles(): Promise<void> {
|
||||
await backend.save(toStoredData());
|
||||
},
|
||||
|
||||
getAll(): readonly ServerProfile[] {
|
||||
return [...currentProfiles()];
|
||||
},
|
||||
|
||||
getById(id: string): ServerProfile | null {
|
||||
return currentProfiles().find((p) => p.id === id) ?? null;
|
||||
},
|
||||
|
||||
addProfile(data: CreateProfileData): ServerProfile {
|
||||
const profile: ServerProfile = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
lastConnected: null,
|
||||
};
|
||||
setProfiles([...currentProfiles(), profile]);
|
||||
return profile;
|
||||
},
|
||||
|
||||
updateProfile(id: string, data: UpdateProfileData): ServerProfile | null {
|
||||
const profiles = currentProfiles();
|
||||
const index = profiles.findIndex((p) => p.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const existing = profiles[index]!;
|
||||
const updated: ServerProfile = { ...existing, ...data };
|
||||
setProfiles(profiles.map((p) => (p.id === id ? updated : p)));
|
||||
return updated;
|
||||
},
|
||||
|
||||
removeProfile(id: string): boolean {
|
||||
const profiles = currentProfiles();
|
||||
const filtered = profiles.filter((p) => p.id !== id);
|
||||
if (filtered.length === profiles.length) return false;
|
||||
setProfiles(filtered);
|
||||
return true;
|
||||
},
|
||||
|
||||
getAutoConnectProfile(): ServerProfile | null {
|
||||
return currentProfiles().find((p) => p.autoConnect) ?? null;
|
||||
},
|
||||
|
||||
setLastConnected(id: string): void {
|
||||
const profiles = currentProfiles();
|
||||
const index = profiles.findIndex((p) => p.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const existing = profiles[index]!;
|
||||
const updated: ServerProfile = {
|
||||
...existing,
|
||||
lastConnected: new Date().toISOString(),
|
||||
};
|
||||
setProfiles(profiles.map((p) => (p.id === id ? updated : p)));
|
||||
},
|
||||
|
||||
async checkHealth(profileId: string): Promise<HealthStatus> {
|
||||
const profile = currentProfiles().find((p) => p.id === profileId);
|
||||
if (!profile) {
|
||||
const offline: HealthStatus = {
|
||||
status: "offline",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
};
|
||||
return offline;
|
||||
}
|
||||
|
||||
setHealthStatus(profileId, {
|
||||
status: "checking",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
});
|
||||
|
||||
const result = await pingHost(profile.host);
|
||||
setHealthStatus(profileId, result);
|
||||
return result;
|
||||
},
|
||||
|
||||
async checkAllHealth(): Promise<ReadonlyMap<string, HealthStatus>> {
|
||||
const profiles = currentProfiles();
|
||||
|
||||
// Set all to "checking" first
|
||||
for (const profile of profiles) {
|
||||
setHealthStatus(profile.id, {
|
||||
status: "checking",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Ping all in parallel
|
||||
const results = await Promise.all(
|
||||
profiles.map(async (profile) => {
|
||||
const result = await pingHost(profile.host);
|
||||
setHealthStatus(profile.id, result);
|
||||
return [profile.id, result] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
return new Map(results);
|
||||
},
|
||||
|
||||
exportProfiles(): string {
|
||||
return JSON.stringify(toStoredData());
|
||||
},
|
||||
|
||||
importProfiles(json: string): { imported: number; skipped: number } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return { imported: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
// Accept either StoredData envelope or a bare array
|
||||
let incoming: unknown[];
|
||||
if (isValidStoredData(parsed)) {
|
||||
incoming = [...parsed.profiles];
|
||||
} else if (Array.isArray(parsed)) {
|
||||
incoming = parsed;
|
||||
} else {
|
||||
return { imported: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const existingHosts = new Set(currentProfiles().map((p) => p.host));
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const newProfiles: ServerProfile[] = [];
|
||||
|
||||
for (const raw of incoming) {
|
||||
if (!isValidProfileShape(raw)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (existingHosts.has(raw.host)) {
|
||||
skipped++;
|
||||
} else {
|
||||
const profile: ServerProfile = {
|
||||
id: crypto.randomUUID(),
|
||||
name: raw.name,
|
||||
host: raw.host,
|
||||
username: raw.username,
|
||||
color: raw.color,
|
||||
autoConnect: raw.autoConnect,
|
||||
rememberPassword: raw.rememberPassword ?? false,
|
||||
lastConnected: null,
|
||||
};
|
||||
newProfiles.push(profile);
|
||||
existingHosts.add(profile.host);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (imported > 0) {
|
||||
setProfiles([...currentProfiles(), ...newProfiles]);
|
||||
}
|
||||
|
||||
return { imported, skipped };
|
||||
},
|
||||
};
|
||||
|
||||
return manager;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Window-based rate limiter with per-key tracking.
|
||||
*
|
||||
* Uses a sliding window algorithm: each key stores an array of timestamps.
|
||||
* Expired entries are pruned on every public call. No external dependencies.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RateLimiterConfig {
|
||||
/** Maximum number of actions allowed per window. */
|
||||
readonly maxTokens: number;
|
||||
/** Window duration in milliseconds. */
|
||||
readonly windowMs: number;
|
||||
}
|
||||
|
||||
interface KeyState {
|
||||
readonly timestamps: readonly number[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default key used when callers omit the key argument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_KEY = "__default__" as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RateLimiter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class RateLimiter {
|
||||
private readonly config: Readonly<RateLimiterConfig>;
|
||||
private state: ReadonlyMap<string, KeyState>;
|
||||
|
||||
constructor(config: RateLimiterConfig) {
|
||||
if (config.maxTokens < 1) {
|
||||
throw new Error("maxTokens must be >= 1");
|
||||
}
|
||||
if (config.windowMs < 1) {
|
||||
throw new Error("windowMs must be >= 1");
|
||||
}
|
||||
this.config = Object.freeze({ ...config });
|
||||
this.state = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to consume one token for the given key.
|
||||
* Returns `true` if the action is allowed, `false` if rate-limited.
|
||||
*/
|
||||
tryConsume(key?: string): boolean {
|
||||
const k = key ?? DEFAULT_KEY;
|
||||
const now = Date.now();
|
||||
const cleaned = this.pruneAll(now);
|
||||
const entry = cleaned.get(k);
|
||||
const timestamps = entry?.timestamps ?? [];
|
||||
|
||||
if (timestamps.length >= this.config.maxTokens) {
|
||||
this.state = cleaned;
|
||||
return false;
|
||||
}
|
||||
|
||||
const newEntry: KeyState = { timestamps: [...timestamps, now] };
|
||||
const next = new Map(cleaned);
|
||||
next.set(k, Object.freeze(newEntry));
|
||||
this.state = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reset state for a single key (or the default key when omitted). */
|
||||
reset(key?: string): void {
|
||||
const k = key ?? DEFAULT_KEY;
|
||||
const next = new Map(this.state);
|
||||
next.delete(k);
|
||||
this.state = next;
|
||||
}
|
||||
|
||||
/** Clear all tracked state across every key. */
|
||||
resetAll(): void {
|
||||
this.state = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns milliseconds until the next request would be allowed for the key.
|
||||
* Returns 0 if a request is allowed right now.
|
||||
*/
|
||||
getRemainingMs(key?: string): number {
|
||||
const k = key ?? DEFAULT_KEY;
|
||||
const now = Date.now();
|
||||
const cleaned = this.pruneAll(now);
|
||||
this.state = cleaned;
|
||||
|
||||
const entry = cleaned.get(k);
|
||||
const timestamps = entry?.timestamps ?? [];
|
||||
|
||||
if (timestamps.length < this.config.maxTokens) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const oldest = timestamps[0];
|
||||
if (oldest === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, oldest + this.config.windowMs - now);
|
||||
}
|
||||
|
||||
/** Return a new map with expired timestamps removed from every key. */
|
||||
private pruneAll(now: number): ReadonlyMap<string, KeyState> {
|
||||
const cutoff = now - this.config.windowMs;
|
||||
const next = new Map<string, KeyState>();
|
||||
|
||||
for (const [key, entry] of this.state) {
|
||||
const filtered = entry.timestamps.filter((t) => t > cutoff);
|
||||
if (filtered.length > 0) {
|
||||
next.set(key, Object.freeze({ timestamps: filtered }));
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a `RateLimiter` from explicit config values.
|
||||
*
|
||||
* @param maxTokens Maximum actions per window.
|
||||
* @param windowMs Window length in milliseconds.
|
||||
*/
|
||||
export function createRateLimiter(maxTokens: number, windowMs: number): RateLimiter {
|
||||
return new RateLimiter({ maxTokens, windowMs });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-configured limiters (PROTOCOL.md - Rate Limits)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Chat messages: 10 per second. */
|
||||
export function createChatLimiter(): RateLimiter {
|
||||
return createRateLimiter(10, 1_000);
|
||||
}
|
||||
|
||||
/** Typing events: 1 per 3 seconds (use channel id as key). */
|
||||
export function createTypingLimiter(): RateLimiter {
|
||||
return createRateLimiter(1, 3_000);
|
||||
}
|
||||
|
||||
/** Presence updates: 1 per 10 seconds. */
|
||||
export function createPresenceLimiter(): RateLimiter {
|
||||
return createRateLimiter(1, 10_000);
|
||||
}
|
||||
|
||||
/** Reactions: 5 per second. */
|
||||
export function createReactionLimiter(): RateLimiter {
|
||||
return createRateLimiter(5, 1_000);
|
||||
}
|
||||
|
||||
/** Voice signaling: 20 per second. */
|
||||
export function createVoiceLimiter(): RateLimiter {
|
||||
return createRateLimiter(20, 1_000);
|
||||
}
|
||||
|
||||
/** Voice camera / screenshare toggle: 2 per second. */
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RateLimiterSet {
|
||||
readonly chat: RateLimiter;
|
||||
readonly typing: RateLimiter;
|
||||
readonly presence: RateLimiter;
|
||||
readonly reactions: RateLimiter;
|
||||
readonly voice: RateLimiter;
|
||||
readonly voiceVideo: RateLimiter;
|
||||
readonly soundboard: RateLimiter;
|
||||
}
|
||||
|
||||
export function createRateLimiterSet(): RateLimiterSet {
|
||||
return Object.freeze({
|
||||
chat: createChatLimiter(),
|
||||
typing: createTypingLimiter(),
|
||||
presence: createPresenceLimiter(),
|
||||
reactions: createReactionLimiter(),
|
||||
voice: createVoiceLimiter(),
|
||||
voiceVideo: createVideoCameraLimiter(),
|
||||
soundboard: createSoundboardLimiter(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Router — simple page router for desktop SPA (no URL routing).
|
||||
// Tracks current page and notifies listeners. Does NOT manipulate the DOM.
|
||||
|
||||
export type PageId = "connect" | "main";
|
||||
|
||||
export type NavigateListener = (page: PageId) => void;
|
||||
|
||||
export interface Router {
|
||||
/** Navigate to a page. Notifies all listeners if the page changed. */
|
||||
navigate(page: PageId): void;
|
||||
|
||||
/** Returns the currently active page. */
|
||||
getCurrentPage(): PageId;
|
||||
|
||||
/**
|
||||
* Register a listener that fires when the page changes.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onNavigate(listener: NavigateListener): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new router instance.
|
||||
* @param initialPage - The page to start on (defaults to "connect").
|
||||
*/
|
||||
export function createRouter(initialPage: PageId = "connect"): Router {
|
||||
let currentPage: PageId = initialPage;
|
||||
const listeners: Set<NavigateListener> = new Set();
|
||||
|
||||
function navigate(page: PageId): void {
|
||||
if (page === currentPage) {
|
||||
return;
|
||||
}
|
||||
currentPage = page;
|
||||
for (const listener of listeners) {
|
||||
listener(page);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPage(): PageId {
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
function onNavigate(listener: NavigateListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
return { navigate, getCurrentPage, onNavigate };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Step 1.13 — Error boundary / safe render utility
|
||||
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("safe-render");
|
||||
|
||||
/**
|
||||
* Minimal component interface for safe mounting.
|
||||
*/
|
||||
export interface MountableComponent {
|
||||
mount(container: Element): void;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
try {
|
||||
component.mount(container);
|
||||
} catch (err) {
|
||||
log.error("Component mount failed", err);
|
||||
renderFallback(container, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a minimal fallback UI when a component fails.
|
||||
*/
|
||||
function renderFallback(container: Element, error: unknown): void {
|
||||
container.textContent = "";
|
||||
const fallback = document.createElement("div");
|
||||
fallback.style.cssText =
|
||||
"padding:16px;color:#f23f43;background:#2b2d31;border-radius:8px;font-size:13px;margin:8px;";
|
||||
fallback.textContent = "Something went wrong rendering this section.";
|
||||
container.appendChild(fallback);
|
||||
|
||||
// Log the actual error for debugging
|
||||
if (error instanceof Error) {
|
||||
const detail = document.createElement("pre");
|
||||
detail.style.cssText =
|
||||
"color:#949ba4;font-size:11px;margin-top:8px;white-space:pre-wrap;word-break:break-all;";
|
||||
detail.textContent = error.message;
|
||||
fallback.appendChild(detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install global error handlers.
|
||||
* Call once at app startup.
|
||||
*/
|
||||
export function installGlobalErrorHandlers(): void {
|
||||
window.addEventListener("error", (event) => {
|
||||
log.error("Uncaught error", {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
error: event.error instanceof Error ? event.error.stack : String(event.error),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason =
|
||||
event.reason instanceof Error
|
||||
? event.reason.stack ?? event.reason.message
|
||||
: String(event.reason);
|
||||
|
||||
// Tauri plugin-http GC cleanup: when a consumed Response body is finalized,
|
||||
// Tauri tries to drop the Rust resource which may already be freed.
|
||||
// This is cosmetic — downgrade to debug instead of polluting error logs.
|
||||
if (typeof reason === "string" && /resource id .+ is invalid/.test(reason)) {
|
||||
log.debug("Tauri resource already freed (benign)", { reason });
|
||||
return;
|
||||
}
|
||||
|
||||
log.error("Unhandled promise rejection", { reason });
|
||||
});
|
||||
|
||||
log.info("Global error handlers installed");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Generic reactive store foundation for OwnCord Tauri client.
|
||||
* Immutable state updates only — setState receives an updater
|
||||
* that must return a NEW state object.
|
||||
*/
|
||||
|
||||
export interface Store<T> {
|
||||
/** Returns the current state (immutable reference). */
|
||||
getState(): T;
|
||||
|
||||
/**
|
||||
* Update state via an updater function. Subscriber notifications are
|
||||
* batched via queueMicrotask — multiple rapid setState calls result
|
||||
* in a single notification with the final state.
|
||||
*/
|
||||
setState(updater: (prev: T) => T): void;
|
||||
|
||||
/**
|
||||
* Subscribe to state changes. The listener receives the new state
|
||||
* after every setState batch. Returns an unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: (state: T) => void): () => void;
|
||||
|
||||
/** Derive a value from the current state using a selector function. */
|
||||
select<S>(selector: (state: T) => S): S;
|
||||
|
||||
/** Flush pending notifications synchronously (useful in tests). */
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
export function createStore<T>(initialState: T): Store<T> {
|
||||
let state: T = initialState;
|
||||
const listeners: Set<(state: T) => void> = new Set();
|
||||
let notifyScheduled = false;
|
||||
|
||||
function getState(): T {
|
||||
return state;
|
||||
}
|
||||
|
||||
function setState(updater: (prev: T) => T): void {
|
||||
state = updater(state);
|
||||
if (!notifyScheduled) {
|
||||
notifyScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
notifyScheduled = false;
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: (state: T) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function select<S>(selector: (state: T) => S): S {
|
||||
return selector(state);
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (notifyScheduled) {
|
||||
notifyScheduled = false;
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { getState, setState, subscribe, select, flush };
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
// =============================================================================
|
||||
// OwnCord Protocol Types
|
||||
// All WebSocket message types, REST response types, and permission definitions.
|
||||
// Source of truth: PROTOCOL.md, API.md, SCHEMA.md
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Common / Shared Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Status values allowed by the protocol. */
|
||||
export type UserStatus = "online" | "idle" | "dnd" | "offline";
|
||||
|
||||
/** Channel types supported by the server. */
|
||||
export type ChannelType = "text" | "voice" | "announcement";
|
||||
|
||||
/** Voice quality presets. */
|
||||
export type VoiceQuality = "low" | "medium" | "high";
|
||||
|
||||
/** Voice threshold mode. CRITICAL: always "threshold_mode", never "mode". */
|
||||
export type ThresholdMode = "forwarding" | "selective";
|
||||
|
||||
/** Reaction action direction. */
|
||||
export type ReactionAction = "add" | "remove";
|
||||
|
||||
/** WebSocket error codes returned by the server. */
|
||||
export type WsErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "RATE_LIMITED"
|
||||
| "INVALID_INPUT"
|
||||
| "SERVER_ERROR"
|
||||
| "CHANNEL_FULL"
|
||||
| "INVALID_SDP"
|
||||
| "VOICE_ERROR"
|
||||
| "VIDEO_LIMIT";
|
||||
|
||||
/** REST API error codes. */
|
||||
export type ApiErrorCode =
|
||||
| "UNAUTHORIZED"
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "RATE_LIMITED"
|
||||
| "INVALID_INPUT"
|
||||
| "CONFLICT"
|
||||
| "TOO_LARGE"
|
||||
| "SERVER_ERROR"
|
||||
| "UNKNOWN";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Embedded Objects (used inside payloads)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Minimal user object embedded in messages and member payloads. */
|
||||
export interface MessageUser {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
}
|
||||
|
||||
/** User object with role, used in auth_ok and member_join. */
|
||||
export interface UserWithRole extends MessageUser {
|
||||
readonly role: string;
|
||||
}
|
||||
|
||||
/** Attachment on a chat message. */
|
||||
export interface Attachment {
|
||||
readonly id: string;
|
||||
readonly filename: string;
|
||||
readonly size: number;
|
||||
readonly mime: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/** Reaction summary on a REST message response. */
|
||||
export interface ReactionSummary {
|
||||
readonly emoji: string;
|
||||
readonly count: number;
|
||||
readonly me: boolean;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ready Payload Nested Objects
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Channel object in the ready payload. */
|
||||
export interface ReadyChannel {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
readonly position: number;
|
||||
readonly unread_count?: number;
|
||||
readonly last_message_id?: number;
|
||||
}
|
||||
|
||||
/** Member object in the ready payload. */
|
||||
export interface ReadyMember {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
}
|
||||
|
||||
/** Voice state object in the ready payload. */
|
||||
export interface ReadyVoiceState {
|
||||
readonly channel_id: number;
|
||||
readonly user_id: number;
|
||||
readonly muted: boolean;
|
||||
readonly deafened: boolean;
|
||||
}
|
||||
|
||||
/** Role object in the ready payload. */
|
||||
export interface ReadyRole {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly color: string | null;
|
||||
readonly permissions: number;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Permission Bitfield (from SCHEMA.md)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export enum Permission {
|
||||
SEND_MESSAGES = 0x1,
|
||||
READ_MESSAGES = 0x2,
|
||||
ATTACH_FILES = 0x20,
|
||||
ADD_REACTIONS = 0x40,
|
||||
USE_SOUNDBOARD = 0x100,
|
||||
CONNECT_VOICE = 0x200,
|
||||
SPEAK_VOICE = 0x400,
|
||||
USE_VIDEO = 0x800,
|
||||
SHARE_SCREEN = 0x1000,
|
||||
MANAGE_MESSAGES = 0x10000,
|
||||
MANAGE_CHANNELS = 0x20000,
|
||||
KICK_MEMBERS = 0x40000,
|
||||
BAN_MEMBERS = 0x80000,
|
||||
MUTE_MEMBERS = 0x100000,
|
||||
MANAGE_ROLES = 0x1000000,
|
||||
MANAGE_SERVER = 0x2000000,
|
||||
MANAGE_INVITES = 0x4000000,
|
||||
VIEW_AUDIT_LOG = 0x8000000,
|
||||
ADMINISTRATOR = 0x40000000,
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// WebSocket Envelope
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Generic WebSocket message envelope. */
|
||||
export interface WsEnvelope<T> {
|
||||
readonly type: string;
|
||||
readonly id?: string;
|
||||
readonly payload: T;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Server → Client Payloads
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface AuthOkPayload {
|
||||
readonly user: UserWithRole;
|
||||
readonly server_name: string;
|
||||
readonly motd: string;
|
||||
}
|
||||
|
||||
export interface AuthErrorPayload {
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface ReadyPayload {
|
||||
readonly channels: readonly ReadyChannel[];
|
||||
readonly members: readonly ReadyMember[];
|
||||
readonly voice_states: readonly ReadyVoiceState[];
|
||||
readonly roles: readonly ReadyRole[];
|
||||
}
|
||||
|
||||
export interface ChatMessagePayload {
|
||||
readonly id: number;
|
||||
readonly channel_id: number;
|
||||
readonly user: MessageUser;
|
||||
readonly content: string;
|
||||
readonly reply_to: number | null;
|
||||
readonly attachments: readonly Attachment[];
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export interface ChatSendOkPayload {
|
||||
readonly message_id: number;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export interface ChatEditedPayload {
|
||||
readonly message_id: number;
|
||||
readonly channel_id: number;
|
||||
readonly content: string;
|
||||
readonly edited_at: string;
|
||||
}
|
||||
|
||||
export interface ChatDeletedPayload {
|
||||
readonly message_id: number;
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
export interface ReactionUpdatePayload {
|
||||
readonly message_id: number;
|
||||
readonly channel_id: number;
|
||||
readonly emoji: string;
|
||||
readonly user_id: number;
|
||||
readonly action: ReactionAction;
|
||||
}
|
||||
|
||||
export interface TypingPayload {
|
||||
readonly channel_id: number;
|
||||
readonly user_id: number;
|
||||
readonly username: string;
|
||||
}
|
||||
|
||||
export interface PresencePayload {
|
||||
readonly user_id: number;
|
||||
readonly status: UserStatus;
|
||||
}
|
||||
|
||||
export interface ChannelCreatePayload {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
readonly position: number;
|
||||
}
|
||||
|
||||
export interface ChannelUpdatePayload {
|
||||
readonly id: number;
|
||||
readonly name?: string;
|
||||
readonly position?: number;
|
||||
}
|
||||
|
||||
export interface ChannelDeletePayload {
|
||||
readonly id: number;
|
||||
}
|
||||
|
||||
export interface VoiceStatePayload {
|
||||
readonly channel_id: number;
|
||||
readonly user_id: number;
|
||||
readonly username: string;
|
||||
readonly muted: boolean;
|
||||
readonly deafened: boolean;
|
||||
readonly speaking: boolean;
|
||||
readonly camera: boolean;
|
||||
readonly screenshare: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceLeavePayload {
|
||||
readonly channel_id: number;
|
||||
readonly user_id: number;
|
||||
}
|
||||
|
||||
/** CRITICAL: uses threshold_mode, NOT mode. */
|
||||
export interface VoiceConfigPayload {
|
||||
readonly channel_id: number;
|
||||
readonly quality: VoiceQuality;
|
||||
readonly bitrate: number;
|
||||
readonly threshold_mode: ThresholdMode;
|
||||
readonly mixing_threshold: number;
|
||||
readonly top_speakers: number;
|
||||
readonly max_users: number;
|
||||
}
|
||||
|
||||
/** CRITICAL: uses threshold_mode, NOT mode. */
|
||||
export interface VoiceSpeakersPayload {
|
||||
readonly channel_id: number;
|
||||
readonly speakers: readonly number[];
|
||||
readonly threshold_mode: ThresholdMode;
|
||||
}
|
||||
|
||||
export interface VoiceOfferPayload {
|
||||
readonly channel_id: number;
|
||||
readonly sdp: string;
|
||||
}
|
||||
|
||||
export interface VoiceAnswerPayload {
|
||||
readonly channel_id: number;
|
||||
readonly sdp: string;
|
||||
}
|
||||
|
||||
export interface VoiceIcePayload {
|
||||
readonly channel_id: number;
|
||||
readonly candidate: RTCIceCandidateInit;
|
||||
}
|
||||
|
||||
export interface MemberJoinPayload {
|
||||
readonly user: UserWithRole;
|
||||
}
|
||||
|
||||
export interface MemberLeavePayload {
|
||||
readonly user_id: number;
|
||||
}
|
||||
|
||||
export interface MemberUpdatePayload {
|
||||
readonly user_id: number;
|
||||
readonly role: string;
|
||||
}
|
||||
|
||||
export interface MemberBanPayload {
|
||||
readonly user_id: number;
|
||||
}
|
||||
|
||||
export interface ServerRestartPayload {
|
||||
readonly reason: string;
|
||||
readonly delay_seconds: number;
|
||||
}
|
||||
|
||||
export interface ErrorPayload {
|
||||
readonly code: WsErrorCode;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Client → Server Payloads
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface AuthPayload {
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface ChatSendPayload {
|
||||
readonly channel_id: number;
|
||||
readonly content: string;
|
||||
readonly reply_to: number | null;
|
||||
readonly attachments: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChatEditPayload {
|
||||
readonly message_id: number;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface ChatDeletePayload {
|
||||
readonly message_id: number;
|
||||
}
|
||||
|
||||
export interface ReactionAddPayload {
|
||||
readonly message_id: number;
|
||||
readonly emoji: string;
|
||||
}
|
||||
|
||||
export interface ReactionRemovePayload {
|
||||
readonly message_id: number;
|
||||
readonly emoji: string;
|
||||
}
|
||||
|
||||
export interface TypingStartPayload {
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
export interface ChannelFocusPayload {
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
export interface PresenceUpdatePayload {
|
||||
readonly status: UserStatus;
|
||||
}
|
||||
|
||||
export interface VoiceJoinPayload {
|
||||
readonly channel_id: number;
|
||||
}
|
||||
|
||||
/** Client → Server: leave current voice channel (no payload needed). */
|
||||
export type VoiceLeaveClientPayload = Record<string, never>;
|
||||
|
||||
export interface VoiceMutePayload {
|
||||
readonly muted: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceDeafenPayload {
|
||||
readonly deafened: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceCameraPayload {
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceScreensharePayload {
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SoundboardPlayPayload {
|
||||
readonly sound_id: string;
|
||||
}
|
||||
|
||||
// Note: VoiceOfferPayload, VoiceAnswerPayload, VoiceIcePayload are
|
||||
// bidirectional — the same interface is used for both client→server
|
||||
// and server→client directions. See definitions above.
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Discriminated Union: Server → Client Messages
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type ServerMessage =
|
||||
| (WsEnvelope<AuthOkPayload> & { readonly type: "auth_ok" })
|
||||
| (WsEnvelope<AuthErrorPayload> & { readonly type: "auth_error" })
|
||||
| (WsEnvelope<ReadyPayload> & { readonly type: "ready" })
|
||||
| (WsEnvelope<ChatMessagePayload> & { readonly type: "chat_message" })
|
||||
| (WsEnvelope<ChatSendOkPayload> & { readonly type: "chat_send_ok" })
|
||||
| (WsEnvelope<ChatEditedPayload> & { readonly type: "chat_edited" })
|
||||
| (WsEnvelope<ChatDeletedPayload> & { readonly type: "chat_deleted" })
|
||||
| (WsEnvelope<ReactionUpdatePayload> & { readonly type: "reaction_update" })
|
||||
| (WsEnvelope<TypingPayload> & { readonly type: "typing" })
|
||||
| (WsEnvelope<PresencePayload> & { readonly type: "presence" })
|
||||
| (WsEnvelope<ChannelCreatePayload> & { readonly type: "channel_create" })
|
||||
| (WsEnvelope<ChannelUpdatePayload> & { readonly type: "channel_update" })
|
||||
| (WsEnvelope<ChannelDeletePayload> & { readonly type: "channel_delete" })
|
||||
| (WsEnvelope<VoiceStatePayload> & { readonly type: "voice_state" })
|
||||
| (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" })
|
||||
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
|
||||
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
|
||||
| (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" })
|
||||
| (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" })
|
||||
| (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" })
|
||||
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
|
||||
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
|
||||
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
|
||||
| (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" })
|
||||
| (WsEnvelope<ServerRestartPayload> & { readonly type: "server_restart" })
|
||||
| (WsEnvelope<ErrorPayload> & { readonly type: "error" });
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Discriminated Union: Client → Server Messages
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type ClientMessage =
|
||||
| (WsEnvelope<AuthPayload> & { readonly type: "auth" })
|
||||
| (WsEnvelope<ChatSendPayload> & { readonly type: "chat_send" })
|
||||
| (WsEnvelope<ChatEditPayload> & { readonly type: "chat_edit" })
|
||||
| (WsEnvelope<ChatDeletePayload> & { readonly type: "chat_delete" })
|
||||
| (WsEnvelope<ReactionAddPayload> & { readonly type: "reaction_add" })
|
||||
| (WsEnvelope<ReactionRemovePayload> & { readonly type: "reaction_remove" })
|
||||
| (WsEnvelope<TypingStartPayload> & { readonly type: "typing_start" })
|
||||
| (WsEnvelope<ChannelFocusPayload> & { readonly type: "channel_focus" })
|
||||
| (WsEnvelope<PresenceUpdatePayload> & { readonly type: "presence_update" })
|
||||
| (WsEnvelope<VoiceJoinPayload> & { readonly type: "voice_join" })
|
||||
| (WsEnvelope<VoiceLeaveClientPayload> & { readonly type: "voice_leave" })
|
||||
| (WsEnvelope<VoiceMutePayload> & { readonly type: "voice_mute" })
|
||||
| (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" })
|
||||
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
|
||||
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
|
||||
| (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" })
|
||||
| (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" })
|
||||
| (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" })
|
||||
| (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" });
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// REST API Response Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** POST /api/auth/login response. */
|
||||
export interface AuthResponse {
|
||||
readonly token?: string;
|
||||
readonly partial_token?: string;
|
||||
readonly requires_2fa: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/auth/register response. */
|
||||
export interface RegisterResponse {
|
||||
readonly user: { readonly id: number; readonly username: string };
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
/** GET /api/health response. */
|
||||
export interface HealthResponse {
|
||||
readonly status: string;
|
||||
readonly version: string;
|
||||
readonly uptime: number;
|
||||
}
|
||||
|
||||
/** Single channel object from REST API. */
|
||||
export interface ChannelResponse {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
readonly position: number;
|
||||
}
|
||||
|
||||
/** Single message object from GET /api/channels/{id}/messages. */
|
||||
export interface MessageResponse {
|
||||
readonly id: number;
|
||||
readonly channel_id: number;
|
||||
readonly user: MessageUser;
|
||||
readonly content: string;
|
||||
readonly reply_to: number | null;
|
||||
readonly attachments: readonly Attachment[];
|
||||
readonly reactions: readonly ReactionSummary[];
|
||||
readonly pinned: boolean;
|
||||
readonly edited_at: string | null;
|
||||
readonly deleted: boolean;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
/** Paginated messages response. */
|
||||
export interface MessagesResponse {
|
||||
readonly messages: readonly MessageResponse[];
|
||||
readonly has_more: boolean;
|
||||
}
|
||||
|
||||
/** Member object from REST API. */
|
||||
export interface MemberResponse {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
}
|
||||
|
||||
/** Search result item. */
|
||||
export interface SearchResultItem {
|
||||
readonly message_id: number;
|
||||
readonly channel_id: number;
|
||||
readonly channel_name: string;
|
||||
readonly user: MessageUser;
|
||||
readonly content: string;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
/** GET /api/search response. */
|
||||
export interface SearchResponse {
|
||||
readonly results: readonly SearchResultItem[];
|
||||
}
|
||||
|
||||
/** REST API error response body. */
|
||||
export interface ApiError {
|
||||
readonly error: ApiErrorCode;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Single emoji object from GET /api/emoji. */
|
||||
export interface EmojiResponse {
|
||||
readonly id: number;
|
||||
readonly shortcode: string;
|
||||
readonly filename: string;
|
||||
readonly uploaded_by: number;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
/** Single sound object from GET /api/sounds. */
|
||||
export interface SoundResponse {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly filename: string;
|
||||
readonly duration_ms: number;
|
||||
readonly uploaded_by: number;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
/** Single invite object from GET/POST /api/invites. */
|
||||
export interface InviteResponse {
|
||||
readonly id: number;
|
||||
readonly code: string;
|
||||
readonly url: string;
|
||||
readonly max_uses: number | null;
|
||||
readonly use_count?: number;
|
||||
readonly expires_at: string | null;
|
||||
}
|
||||
|
||||
/** Single session object from GET /api/users/me/sessions. */
|
||||
export interface SessionResponse {
|
||||
readonly id: number;
|
||||
readonly device: string | null;
|
||||
readonly ip_address: string | null;
|
||||
readonly created_at: string;
|
||||
readonly last_used: string;
|
||||
readonly expires_at: string;
|
||||
}
|
||||
|
||||
/** Upload response from POST /api/uploads. */
|
||||
export interface UploadResponse {
|
||||
readonly id: string;
|
||||
readonly filename: string;
|
||||
readonly size: number;
|
||||
readonly mime: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/** TURN/STUN credentials from GET /api/voice/credentials. */
|
||||
export interface IceServer {
|
||||
readonly urls: string;
|
||||
readonly username?: string;
|
||||
readonly credential?: string;
|
||||
}
|
||||
|
||||
export interface VoiceCredentialsResponse {
|
||||
readonly ice_servers: readonly IceServer[];
|
||||
readonly expires_in: number;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// updater.ts — Client auto-update service.
|
||||
// Uses custom Tauri commands that build the updater with a dynamic server URL
|
||||
// at runtime (required because OwnCord is self-hosted).
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("updater");
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
readonly available: boolean;
|
||||
readonly version: string | null;
|
||||
readonly body: string | null;
|
||||
}
|
||||
|
||||
/** Check if a newer client version is available on the connected server. */
|
||||
export async function checkForUpdate(serverUrl: string): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const result = await invoke<UpdateCheckResult>("check_client_update", {
|
||||
serverUrl,
|
||||
});
|
||||
if (result.available) {
|
||||
log.info("Update available", { version: result.version });
|
||||
} else {
|
||||
log.debug("No update available");
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
log.error("Update check failed", { error: String(err) });
|
||||
return { available: false, version: null, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and install a pending update, then relaunch the app. */
|
||||
export async function downloadAndInstallUpdate(serverUrl: string): Promise<void> {
|
||||
log.info("Downloading and installing update...");
|
||||
await invoke("download_and_install_update", { serverUrl });
|
||||
log.info("Update installed, relaunching...");
|
||||
await relaunch();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// =============================================================================
|
||||
// Voice Activity Detection — Web Audio API based speech detection
|
||||
// =============================================================================
|
||||
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("vad");
|
||||
|
||||
export interface VadOptions {
|
||||
/** Audio volume threshold (0-1) to detect speech. Default 0.01 */
|
||||
readonly threshold?: number;
|
||||
/** How often to check volume in ms. Default 50 */
|
||||
readonly intervalMs?: number;
|
||||
/** Minimum consecutive detections before triggering. Default 3 */
|
||||
readonly minConsecutive?: number;
|
||||
}
|
||||
|
||||
export interface VadDetector {
|
||||
start(stream: MediaStream): void;
|
||||
stop(): void;
|
||||
setThreshold(threshold: number): void;
|
||||
onSpeakingChange(callback: (speaking: boolean) => void): () => void;
|
||||
isSpeaking(): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
type SpeakingCallback = (speaking: boolean) => void;
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.01;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const DEFAULT_MIN_CONSECUTIVE = 3;
|
||||
|
||||
/** Max VAD threshold value. Sensitivity 0% maps to this threshold. */
|
||||
const MAX_THRESHOLD = 0.15;
|
||||
|
||||
/** Convert sensitivity slider (0-100) to VAD threshold (0-MAX_THRESHOLD).
|
||||
* High sensitivity = low threshold (picks up quiet sounds).
|
||||
* 0% sensitivity = threshold 0.15 (only loud sounds trigger).
|
||||
* 100% sensitivity = threshold 0.0 (everything triggers). */
|
||||
export function sensitivityToThreshold(sensitivity: number): number {
|
||||
return ((100 - sensitivity) / 100) * MAX_THRESHOLD;
|
||||
}
|
||||
// Require more silence samples than speech samples to prevent flicker
|
||||
const SILENCE_MULTIPLIER = 2;
|
||||
|
||||
function computeRms(data: Uint8Array): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const val = data[i];
|
||||
if (val === undefined) continue;
|
||||
// getByteFrequencyData returns 0-255 where 0 = silence, 255 = max.
|
||||
// Normalize to 0-1 range.
|
||||
const normalized = val / 255;
|
||||
sum += normalized * normalized;
|
||||
}
|
||||
return Math.sqrt(sum / data.length);
|
||||
}
|
||||
|
||||
export function createVadDetector(options?: VadOptions): VadDetector {
|
||||
let threshold = options?.threshold ?? DEFAULT_THRESHOLD;
|
||||
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
const minConsecutive = options?.minConsecutive ?? DEFAULT_MIN_CONSECUTIVE;
|
||||
const silenceRequired = minConsecutive * SILENCE_MULTIPLIER;
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
let speaking = false;
|
||||
let consecutiveAbove = 0;
|
||||
let consecutiveBelow = 0;
|
||||
|
||||
const callbacks = new Set<SpeakingCallback>();
|
||||
|
||||
function emitChange(newState: boolean): void {
|
||||
if (speaking === newState) return;
|
||||
speaking = newState;
|
||||
for (const cb of callbacks) {
|
||||
cb(speaking);
|
||||
}
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
if (analyser === null) return;
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(data);
|
||||
const rms = computeRms(data);
|
||||
|
||||
if (rms >= threshold) {
|
||||
consecutiveAbove++;
|
||||
consecutiveBelow = 0;
|
||||
if (!speaking && consecutiveAbove >= minConsecutive) {
|
||||
emitChange(true);
|
||||
}
|
||||
} else {
|
||||
consecutiveBelow++;
|
||||
consecutiveAbove = 0;
|
||||
if (speaking && consecutiveBelow >= silenceRequired) {
|
||||
emitChange(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
if (sourceNode !== null) {
|
||||
sourceNode.disconnect();
|
||||
sourceNode = null;
|
||||
}
|
||||
if (analyser !== null) {
|
||||
analyser.disconnect();
|
||||
analyser = null;
|
||||
}
|
||||
if (audioContext !== null) {
|
||||
void audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
consecutiveAbove = 0;
|
||||
consecutiveBelow = 0;
|
||||
if (speaking) {
|
||||
emitChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start(stream: MediaStream): void {
|
||||
if (destroyed) throw new Error("VadDetector has been destroyed");
|
||||
// Stop any existing monitoring first
|
||||
cleanup();
|
||||
|
||||
// Force 48kHz so FFT bins cover the voice-frequency range (0-24kHz)
|
||||
// consistently regardless of the system audio device's native rate.
|
||||
// At high native rates (e.g. 192kHz), most bins would be above voice
|
||||
// frequencies, making the RMS calculation artificially low.
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
|
||||
sourceNode = audioContext.createMediaStreamSource(stream);
|
||||
sourceNode.connect(analyser);
|
||||
|
||||
intervalId = setInterval(tick, intervalMs);
|
||||
log.debug("VAD started", { threshold, intervalMs, minConsecutive, sampleRate: audioContext.sampleRate });
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (destroyed) return;
|
||||
cleanup();
|
||||
},
|
||||
|
||||
setThreshold(newThreshold: number): void {
|
||||
if (newThreshold < 0 || newThreshold > 1) {
|
||||
throw new Error("Threshold must be between 0 and 1");
|
||||
}
|
||||
log.debug("VAD threshold changed", { old: threshold, new: newThreshold });
|
||||
threshold = newThreshold;
|
||||
},
|
||||
|
||||
onSpeakingChange(callback: SpeakingCallback): () => void {
|
||||
callbacks.add(callback);
|
||||
return () => { callbacks.delete(callback); };
|
||||
},
|
||||
|
||||
isSpeaking(): boolean {
|
||||
return speaking;
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
cleanup();
|
||||
callbacks.clear();
|
||||
log.debug("VAD destroyed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
// =============================================================================
|
||||
// Voice Session — lifecycle orchestrator for voice chat
|
||||
//
|
||||
// Manages audio capture, WebRTC connection, remote audio playback, and
|
||||
// WS signaling. Singleton module: only one voice session at a time.
|
||||
// =============================================================================
|
||||
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { VoiceConfigPayload, IceServer } from "@lib/types";
|
||||
import type { WebRtcService } from "@lib/webrtc";
|
||||
import type { AudioManager } from "@lib/audio";
|
||||
import type { VadDetector } from "@lib/vad";
|
||||
import { createWebRtcService } from "@lib/webrtc";
|
||||
import { createAudioManager } from "@lib/audio";
|
||||
import { createVadDetector, sensitivityToThreshold } from "@lib/vad";
|
||||
import { createNoiseSuppressor } from "@lib/noise-suppression";
|
||||
import type { NoiseSuppressor } from "@lib/noise-suppression";
|
||||
import { voiceStore, setLocalMuted, setLocalDeafened, setLocalSpeaking } from "@stores/voice.store";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("voiceSession");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level state (singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let audioManager: AudioManager | null = null;
|
||||
let webrtcService: WebRtcService | null = null;
|
||||
let vadDetector: VadDetector | null = null;
|
||||
let noiseSuppressor: NoiseSuppressor | null = null;
|
||||
let localStream: MediaStream | null = null;
|
||||
/** The stream actually sent to WebRTC (may be noise-suppressed). */
|
||||
let processedStream: MediaStream | null = null;
|
||||
let ws: WsClient | null = null;
|
||||
const audioElements = new Map<string, HTMLAudioElement>();
|
||||
/** Shared AudioContext for all remote audio processing (avoids browser limit of ~6 contexts). */
|
||||
let sharedAudioCtx: AudioContext | null = null;
|
||||
|
||||
function getSharedAudioContext(): AudioContext {
|
||||
if (sharedAudioCtx === null || sharedAudioCtx.state === "closed") {
|
||||
// Force 48kHz to match WebRTC Opus output. At high native rates
|
||||
// (e.g. 192kHz), the Web Audio resampling pipeline can introduce
|
||||
// issues or silence when bridging MediaStream → GainNode → destination.
|
||||
sharedAudioCtx = new AudioContext({ sampleRate: 48000 });
|
||||
}
|
||||
return sharedAudioCtx;
|
||||
}
|
||||
|
||||
/** Map userId → GainNode for per-user volume control (legacy, unused — kept for diagnostics). */
|
||||
const userGainNodes = new Map<number, GainNode>();
|
||||
/** Map userId → HTMLAudioElement for per-user volume control via element.volume. */
|
||||
const userAudioElements = new Map<number, HTMLAudioElement>();
|
||||
/** Map stream.id → userId (parsed from server's "user-{id}" stream label). */
|
||||
const streamUserMap = new Map<string, number>();
|
||||
let audioContainer: HTMLDivElement | null = null;
|
||||
|
||||
// Optional error callback for UI feedback (e.g. toast on WebRTC failure)
|
||||
let onErrorCallback: ((message: string) => void) | null = null;
|
||||
|
||||
// Track event-unsubscribe functions for cleanup
|
||||
let unsubIce: (() => void) | null = null;
|
||||
let unsubTrack: (() => void) | null = null;
|
||||
let unsubState: (() => void) | null = null;
|
||||
let unsubIceState: (() => void) | null = null;
|
||||
let unsubVad: (() => void) | null = null;
|
||||
|
||||
// ICE restart state
|
||||
const ICE_RESTART_DELAY_MS = 5000;
|
||||
let iceRestartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let currentChannelId: number | null = null;
|
||||
|
||||
// Guard against concurrent joinVoice calls
|
||||
let joinInProgress = false;
|
||||
|
||||
// Cached silence suppression preference (avoid localStorage reads in hot path)
|
||||
let silenceSuppressionEnabled = true;
|
||||
|
||||
/** Update cached silence suppression preference. Called from settings. */
|
||||
export function updateSilenceSuppressionPref(): void {
|
||||
silenceSuppressionEnabled = loadPref<boolean>("silenceSuppression", true);
|
||||
}
|
||||
|
||||
/** Shared VAD speaking callback — includes silence suppression logic. */
|
||||
function onVadSpeakingChange(speaking: boolean): void {
|
||||
setLocalSpeaking(speaking);
|
||||
if (silenceSuppressionEnabled && webrtcService !== null) {
|
||||
webrtcService.setSilenced(!speaking);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pipe a raw mic stream through noise suppression if enabled, returning the
|
||||
* stream to send to WebRTC. Destroys any existing suppressor first. */
|
||||
async function applyNoiseSuppression(raw: MediaStream): Promise<MediaStream> {
|
||||
if (noiseSuppressor !== null) {
|
||||
noiseSuppressor.destroy();
|
||||
noiseSuppressor = null;
|
||||
}
|
||||
if (!loadPref<boolean>("enhancedNoiseSuppression", false)) return raw;
|
||||
try {
|
||||
noiseSuppressor = createNoiseSuppressor();
|
||||
const cleaned = await noiseSuppressor.process(raw);
|
||||
log.info("Enhanced noise suppression enabled");
|
||||
return cleaned;
|
||||
} catch (err) {
|
||||
log.warn("Failed to init noise suppression, using raw stream", err);
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (or restart) VAD on the stream that's actually sent to WebRTC.
|
||||
* When noise suppression is active, this is the processed stream so the
|
||||
* threshold matches what's transmitted (not raw mic noise). */
|
||||
function startVad(stream: MediaStream): void {
|
||||
// Destroy old detector to avoid reusing a closed AudioContext
|
||||
if (vadDetector !== null) {
|
||||
if (unsubVad !== null) { unsubVad(); unsubVad = null; }
|
||||
vadDetector.destroy();
|
||||
vadDetector = null;
|
||||
}
|
||||
const sensitivity = loadPref<number>("voiceSensitivity", 50);
|
||||
vadDetector = createVadDetector({ threshold: sensitivityToThreshold(sensitivity) });
|
||||
vadDetector.start(stream);
|
||||
unsubVad = vadDetector.onSpeakingChange(onVadSpeakingChange);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get or create the hidden container for remote audio elements. */
|
||||
function getOrCreateAudioContainer(): HTMLDivElement {
|
||||
if (audioContainer !== null) return audioContainer;
|
||||
|
||||
const existing = document.getElementById("voice-audio-container");
|
||||
if (existing instanceof HTMLDivElement) {
|
||||
audioContainer = existing;
|
||||
return audioContainer;
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.id = "voice-audio-container";
|
||||
div.style.display = "none";
|
||||
document.body.appendChild(div);
|
||||
audioContainer = div;
|
||||
return audioContainer;
|
||||
}
|
||||
|
||||
/** Parse userId from server's stream label "user-{id}". Returns 0 if unparseable. */
|
||||
function parseUserIdFromStream(stream: MediaStream): number {
|
||||
// The server creates tracks with streamID = "user-{userID}"
|
||||
const match = stream.id.match(/^user-(\d+)$/);
|
||||
if (match !== null && match[1] !== undefined) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
// Fallback: check track labels "audio-{userID}"
|
||||
for (const track of stream.getTracks()) {
|
||||
const trackMatch = track.id.match(/^audio-(\d+)$/);
|
||||
if (trackMatch !== null && trackMatch[1] !== undefined) {
|
||||
return Number(trackMatch[1]);
|
||||
}
|
||||
}
|
||||
log.warn("Could not parse userId from remote stream", {
|
||||
streamId: stream.id,
|
||||
trackIds: stream.getTracks().map((t) => t.id),
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Get saved per-user volume (0-200 range, default 100). */
|
||||
function getSavedUserVolume(userId: number): number {
|
||||
return loadPref<number>(`userVolume_${userId}`, 100);
|
||||
}
|
||||
|
||||
/** Add a remote MediaStream as an <audio> element with per-user volume.
|
||||
* Uses HTMLAudioElement.volume directly instead of Web Audio GainNode —
|
||||
* WebView2/Chromium silences remote WebRTC streams routed through
|
||||
* createMediaStreamSource → GainNode → createMediaStreamDestination. */
|
||||
function addRemoteStream(stream: MediaStream): void {
|
||||
if (audioElements.has(stream.id)) return;
|
||||
|
||||
const container = getOrCreateAudioContainer();
|
||||
const userId = parseUserIdFromStream(stream);
|
||||
if (userId > 0) {
|
||||
streamUserMap.set(stream.id, userId);
|
||||
}
|
||||
|
||||
const audio = document.createElement("audio");
|
||||
audio.autoplay = true;
|
||||
audio.setAttribute("playsinline", "");
|
||||
audio.srcObject = stream;
|
||||
|
||||
// Apply saved per-user volume via HTMLAudioElement.volume (0.0-1.0 range).
|
||||
// We clamp the stored 0-200 range to 0-100 for element volume.
|
||||
const savedVolume = userId > 0 ? getSavedUserVolume(userId) : 100;
|
||||
audio.volume = Math.min(savedVolume, 100) / 100;
|
||||
|
||||
if (userId > 0) {
|
||||
userAudioElements.set(userId, audio);
|
||||
}
|
||||
log.debug("Remote audio stream attached (direct playback)", { userId, volume: audio.volume });
|
||||
|
||||
// Monitor playback state — autoplay may be blocked by browser policy
|
||||
audio.addEventListener("playing", () => {
|
||||
log.info("Remote audio playing", { streamId: stream.id, userId });
|
||||
});
|
||||
audio.addEventListener("pause", () => {
|
||||
// Ignore pause events that fire before the element is attached to DOM
|
||||
if (!audio.parentElement) return;
|
||||
log.warn("Remote audio paused", { streamId: stream.id, userId });
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
log.error("Remote audio element error", {
|
||||
streamId: stream.id,
|
||||
userId,
|
||||
error: audio.error?.message ?? "unknown",
|
||||
code: audio.error?.code,
|
||||
});
|
||||
});
|
||||
|
||||
// Apply saved output device
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput !== "" && typeof audio.setSinkId === "function") {
|
||||
audio.setSinkId(savedOutput).catch((err) => {
|
||||
log.warn("Failed to set output device on remote audio", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-remove when all tracks end
|
||||
stream.onremovetrack = () => {
|
||||
if (stream.getTracks().length === 0) {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
audioElements.delete(stream.id);
|
||||
streamUserMap.delete(stream.id);
|
||||
if (userId > 0) {
|
||||
userGainNodes.delete(userId);
|
||||
userAudioElements.delete(userId);
|
||||
}
|
||||
log.debug("Removed remote audio element", { streamId: stream.id, userId });
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(audio);
|
||||
audioElements.set(stream.id, audio);
|
||||
log.debug("Added remote audio element", { streamId: stream.id, userId });
|
||||
|
||||
// Kick playback after DOM attachment — avoids "interrupted by a new load request"
|
||||
// race between autoplay and srcObject assignment.
|
||||
queueMicrotask(() => {
|
||||
if (audio.paused && audio.srcObject !== null) {
|
||||
audio.play().catch((err) => {
|
||||
log.error("Remote audio play() rejected", {
|
||||
streamId: stream.id,
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Attempt to acquire the microphone, falling back to system default. */
|
||||
async function acquireMicrophone(): Promise<MediaStream | null> {
|
||||
if (audioManager === null) {
|
||||
audioManager = createAudioManager();
|
||||
}
|
||||
|
||||
const savedDevice = loadPref<string>("audioInputDevice", "");
|
||||
|
||||
// Try saved device first
|
||||
if (savedDevice !== "") {
|
||||
try {
|
||||
return await audioManager.getUserMedia(savedDevice);
|
||||
} catch (err) {
|
||||
log.warn("Failed to use saved input device, trying default", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to system default
|
||||
try {
|
||||
return await audioManager.getUserMedia();
|
||||
} catch (err) {
|
||||
log.warn("Failed to acquire microphone — entering listen-only mode", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up all remote audio elements and per-user gain nodes. */
|
||||
function cleanupAudioElements(): void {
|
||||
for (const el of audioElements.values()) {
|
||||
el.srcObject = null;
|
||||
el.remove();
|
||||
}
|
||||
audioElements.clear();
|
||||
userGainNodes.clear();
|
||||
userAudioElements.clear();
|
||||
streamUserMap.clear();
|
||||
|
||||
// Close the shared AudioContext (will be re-created on next join)
|
||||
if (sharedAudioCtx !== null) {
|
||||
void sharedAudioCtx.close();
|
||||
sharedAudioCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt ICE restart by creating a new offer with iceRestart flag. */
|
||||
async function attemptIceRestart(): Promise<void> {
|
||||
if (webrtcService === null || ws === null || currentChannelId === null) {
|
||||
log.warn("Cannot ICE restart — no active session");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
log.info("Attempting ICE restart", { channelId: currentChannelId });
|
||||
const offerSdp = await webrtcService.createOffer(true);
|
||||
ws.send({
|
||||
type: "voice_offer",
|
||||
payload: { channel_id: currentChannelId, sdp: offerSdp },
|
||||
});
|
||||
log.info("ICE restart offer sent");
|
||||
} catch (err) {
|
||||
log.error("ICE restart failed", err);
|
||||
onErrorCallback?.("Voice reconnection failed — please rejoin");
|
||||
leaveVoice();
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribe WebRTC and VAD event handlers. */
|
||||
function cleanupWebrtcSubs(): void {
|
||||
if (unsubIce !== null) {
|
||||
unsubIce();
|
||||
unsubIce = null;
|
||||
}
|
||||
if (unsubTrack !== null) {
|
||||
unsubTrack();
|
||||
unsubTrack = null;
|
||||
}
|
||||
if (unsubState !== null) {
|
||||
unsubState();
|
||||
unsubState = null;
|
||||
}
|
||||
if (unsubIceState !== null) {
|
||||
unsubIceState();
|
||||
unsubIceState = null;
|
||||
}
|
||||
if (unsubVad !== null) {
|
||||
unsubVad();
|
||||
unsubVad = null;
|
||||
}
|
||||
// Cancel any pending ICE restart
|
||||
if (iceRestartTimer !== null) {
|
||||
clearTimeout(iceRestartTimer);
|
||||
iceRestartTimer = null;
|
||||
}
|
||||
currentChannelId = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Set the WS client reference used for signaling. */
|
||||
export function setWsClient(client: WsClient): void {
|
||||
ws = client;
|
||||
}
|
||||
|
||||
/** Set error callback for UI feedback (e.g. toast on WebRTC failure). */
|
||||
export function setOnError(cb: (message: string) => void): void {
|
||||
onErrorCallback = cb;
|
||||
}
|
||||
|
||||
/** Clear the error callback (call on component destroy to avoid stale refs). */
|
||||
export function clearOnError(): void {
|
||||
onErrorCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ICE servers (TURN/STUN credentials) for WebRTC.
|
||||
* Falls back to empty array on failure so voice still works on LAN.
|
||||
*/
|
||||
export type IceServerFetcher = () => Promise<readonly IceServer[]>;
|
||||
|
||||
/** Join a voice channel: acquire mic, set up WebRTC, send offer. */
|
||||
export async function joinVoice(
|
||||
channelId: number,
|
||||
config: VoiceConfigPayload,
|
||||
fetchIceServers?: IceServerFetcher,
|
||||
): Promise<void> {
|
||||
if (ws === null) {
|
||||
log.error("Cannot join voice: WS client not set");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent join attempts
|
||||
if (joinInProgress) {
|
||||
log.warn("Join already in progress, ignoring");
|
||||
return;
|
||||
}
|
||||
joinInProgress = true;
|
||||
|
||||
// Clean up any existing voice session to prevent stale callbacks
|
||||
// from killing the new session (don't send voice_leave — server
|
||||
// already handled the channel switch).
|
||||
if (webrtcService !== null) {
|
||||
leaveVoice(false);
|
||||
}
|
||||
|
||||
// Cache silence suppression pref at join time
|
||||
silenceSuppressionEnabled = loadPref<boolean>("silenceSuppression", true);
|
||||
|
||||
try {
|
||||
// 1. Acquire microphone and ICE servers in parallel
|
||||
const [stream, iceServers] = await Promise.all([
|
||||
acquireMicrophone(),
|
||||
fetchIceServers
|
||||
? fetchIceServers().catch((err) => {
|
||||
log.warn("Failed to fetch ICE servers, falling back to direct", err);
|
||||
return [] as readonly IceServer[];
|
||||
})
|
||||
: Promise.resolve([] as readonly IceServer[]),
|
||||
]);
|
||||
localStream = stream;
|
||||
|
||||
// 2. Create WebRTC peer connection with TURN/STUN servers
|
||||
webrtcService = createWebRtcService();
|
||||
webrtcService.createConnection({
|
||||
iceServers: iceServers.map((s) => ({
|
||||
urls: s.urls,
|
||||
username: s.username,
|
||||
credential: s.credential,
|
||||
})),
|
||||
opusBitrate: config.bitrate,
|
||||
});
|
||||
|
||||
// 3. Apply noise suppression if enabled, then attach to WebRTC
|
||||
processedStream = localStream !== null
|
||||
? await applyNoiseSuppression(localStream)
|
||||
: null;
|
||||
|
||||
// 4. Attach local stream if available
|
||||
if (processedStream !== null) {
|
||||
webrtcService.setLocalStream(processedStream);
|
||||
}
|
||||
|
||||
// 5. Wire ICE candidate forwarding
|
||||
unsubIce = webrtcService.onIceCandidate((candidate) => {
|
||||
if (ws === null) return;
|
||||
ws.send({
|
||||
type: "voice_ice",
|
||||
payload: { channel_id: channelId, candidate },
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Wire remote track playback
|
||||
unsubTrack = webrtcService.onRemoteTrack((stream) => {
|
||||
addRemoteStream(stream);
|
||||
});
|
||||
|
||||
// 7. Wire connection state monitoring
|
||||
unsubState = webrtcService.onStateChange((state) => {
|
||||
log.info("WebRTC connection state changed", { state });
|
||||
if (state === "failed") {
|
||||
log.error("WebRTC connection failed, leaving voice");
|
||||
onErrorCallback?.("Voice connection failed — disconnected");
|
||||
leaveVoice();
|
||||
}
|
||||
});
|
||||
|
||||
// 7b. Wire ICE connection state for automatic ICE restart
|
||||
currentChannelId = channelId;
|
||||
unsubIceState = webrtcService.onIceStateChange((state) => {
|
||||
log.info("ICE connection state changed", { state });
|
||||
|
||||
if (state === "disconnected") {
|
||||
// Start timer — ICE may self-recover. If not, restart after delay.
|
||||
if (iceRestartTimer === null) {
|
||||
log.info("ICE disconnected, scheduling restart", { delayMs: ICE_RESTART_DELAY_MS });
|
||||
iceRestartTimer = setTimeout(() => {
|
||||
iceRestartTimer = null;
|
||||
void attemptIceRestart();
|
||||
}, ICE_RESTART_DELAY_MS);
|
||||
}
|
||||
} else if (state === "connected" || state === "completed") {
|
||||
// ICE recovered on its own — cancel pending restart
|
||||
if (iceRestartTimer !== null) {
|
||||
log.info("ICE recovered, cancelling restart timer");
|
||||
clearTimeout(iceRestartTimer);
|
||||
iceRestartTimer = null;
|
||||
}
|
||||
} else if (state === "failed") {
|
||||
// ICE failed — attempt restart immediately
|
||||
if (iceRestartTimer !== null) {
|
||||
clearTimeout(iceRestartTimer);
|
||||
iceRestartTimer = null;
|
||||
}
|
||||
void attemptIceRestart();
|
||||
}
|
||||
});
|
||||
|
||||
// 8. Start VAD on the processed stream (so threshold matches what's sent)
|
||||
if (localStream !== null && processedStream !== null) {
|
||||
startVad(processedStream);
|
||||
}
|
||||
|
||||
// 9. Create and send SDP offer (guard: session may have been destroyed
|
||||
// by a connection-failed event firing between wire-up and offer)
|
||||
if (webrtcService === null) {
|
||||
log.warn("WebRTC service destroyed before offer — aborting join");
|
||||
return;
|
||||
}
|
||||
// If the server already sent us an offer (renegotiation arrived before we
|
||||
// could create ours), we're in have-remote-offer state — skip our offer.
|
||||
// The handleServerOffer path will send an answer instead.
|
||||
try {
|
||||
const offerSdp = await webrtcService.createOffer();
|
||||
ws.send({
|
||||
type: "voice_offer",
|
||||
payload: { channel_id: channelId, sdp: offerSdp },
|
||||
});
|
||||
} catch (offerErr) {
|
||||
// Likely "Called in wrong state: have-remote-offer" — server renegotiation
|
||||
// arrived first. The onRemoteTrack + handleServerOffer path handles it.
|
||||
log.info("Skipping initial offer — server offer arrived first", {
|
||||
error: offerErr instanceof Error ? offerErr.message : String(offerErr),
|
||||
});
|
||||
}
|
||||
|
||||
log.info("Joined voice channel", { channelId });
|
||||
} catch (err) {
|
||||
log.error("Failed to join voice channel", err);
|
||||
leaveVoice();
|
||||
} finally {
|
||||
joinInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the current voice session and clean up all resources.
|
||||
* If sendWs is true (default), also notifies the server via voice_leave.
|
||||
* Pass sendWs=false when the server already knows (e.g. explicit UI leave
|
||||
* that sends voice_leave separately).
|
||||
*/
|
||||
export function leaveVoice(sendWs = true): void {
|
||||
// Notify server so it cleans up our voice state
|
||||
if (sendWs && ws !== null) {
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
}
|
||||
// Clear join guard so a new join can proceed after leave
|
||||
joinInProgress = false;
|
||||
|
||||
// Stop processedStream tracks first (before nulling localStream so guard works)
|
||||
if (processedStream !== null && processedStream !== localStream) {
|
||||
for (const track of processedStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
processedStream = null;
|
||||
|
||||
// Stop all local media tracks
|
||||
if (localStream !== null) {
|
||||
for (const track of localStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
localStream = null;
|
||||
}
|
||||
|
||||
// Destroy VAD
|
||||
if (vadDetector !== null) {
|
||||
vadDetector.destroy();
|
||||
vadDetector = null;
|
||||
}
|
||||
|
||||
// Clean up WebRTC subscriptions before destroying
|
||||
cleanupWebrtcSubs();
|
||||
|
||||
// Destroy noise suppressor
|
||||
if (noiseSuppressor !== null) {
|
||||
noiseSuppressor.destroy();
|
||||
noiseSuppressor = null;
|
||||
}
|
||||
|
||||
// Destroy WebRTC
|
||||
if (webrtcService !== null) {
|
||||
webrtcService.destroy();
|
||||
webrtcService = null;
|
||||
}
|
||||
|
||||
// Clean up remote audio playback
|
||||
cleanupAudioElements();
|
||||
|
||||
// Destroy audio manager
|
||||
if (audioManager !== null) {
|
||||
audioManager.destroy();
|
||||
audioManager = null;
|
||||
}
|
||||
|
||||
log.info("Left voice session");
|
||||
}
|
||||
|
||||
/** Mute or unmute the local microphone. */
|
||||
export function setMuted(muted: boolean): void {
|
||||
setLocalMuted(muted);
|
||||
if (webrtcService !== null) {
|
||||
// Mute via track.enabled — no renegotiation, instant, no race conditions.
|
||||
webrtcService.setMuted(muted);
|
||||
} else {
|
||||
// Fallback for listen-only mode (no WebRTC): disable raw mic tracks
|
||||
if (localStream !== null) {
|
||||
for (const track of localStream.getAudioTracks()) {
|
||||
track.enabled = !muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deafen or undeafen — mutes all remote audio playback. */
|
||||
export function setDeafened(deafened: boolean): void {
|
||||
setLocalDeafened(deafened);
|
||||
for (const el of audioElements.values()) {
|
||||
el.muted = deafened;
|
||||
}
|
||||
log.debug("Deafen state changed", { deafened, audioElements: audioElements.size });
|
||||
}
|
||||
|
||||
/** Switch the input (microphone) device on an active session. */
|
||||
export async function switchInputDevice(deviceId: string): Promise<void> {
|
||||
// Don't acquire microphone if there's no active voice session
|
||||
if (webrtcService === null) {
|
||||
log.debug("Skipping input device switch — no active voice session");
|
||||
return;
|
||||
}
|
||||
if (audioManager === null) {
|
||||
audioManager = createAudioManager();
|
||||
}
|
||||
|
||||
// Save old state so we can roll back on failure
|
||||
const oldLocalStream = localStream;
|
||||
const oldProcessedStream = processedStream;
|
||||
const oldSuppressor = noiseSuppressor;
|
||||
|
||||
try {
|
||||
const newStream = await audioManager.getUserMedia(deviceId || undefined);
|
||||
if (newStream === null) return;
|
||||
|
||||
// Guard: session may have ended during the async getUserMedia call
|
||||
if (webrtcService === null) {
|
||||
for (const track of newStream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily detach old suppressor so applyNoiseSuppression doesn't
|
||||
// destroy it — we need the old pipeline alive for rollback on failure.
|
||||
noiseSuppressor = null;
|
||||
let newProcessed: MediaStream;
|
||||
try {
|
||||
newProcessed = await applyNoiseSuppression(newStream);
|
||||
} catch (err) {
|
||||
// Noise suppression failed — restore old suppressor, stop new stream
|
||||
noiseSuppressor = oldSuppressor;
|
||||
log.warn("Noise suppression failed during device switch, keeping old device", err);
|
||||
for (const track of newStream.getTracks()) track.stop();
|
||||
onErrorCallback?.("Failed to switch microphone — noise suppression error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: session may have ended during noise suppression setup
|
||||
if (webrtcService === null) {
|
||||
if (newProcessed !== newStream) {
|
||||
for (const track of newProcessed.getTracks()) track.stop();
|
||||
}
|
||||
for (const track of newStream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap track on WebRTC sender — no renegotiation needed
|
||||
await webrtcService.replaceTrack(newProcessed);
|
||||
|
||||
// Success — update module state and stop old tracks
|
||||
localStream = newStream;
|
||||
processedStream = newProcessed;
|
||||
|
||||
// Restart VAD on processed stream with full silence suppression
|
||||
startVad(newProcessed);
|
||||
|
||||
// NOW clean up old resources (after new pipeline is fully wired)
|
||||
if (oldSuppressor !== null && oldSuppressor !== noiseSuppressor) {
|
||||
oldSuppressor.destroy();
|
||||
}
|
||||
if (oldProcessedStream !== null && oldProcessedStream !== oldLocalStream) {
|
||||
for (const track of oldProcessedStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
if (oldLocalStream !== null) {
|
||||
for (const track of oldLocalStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Switched input device", { deviceId });
|
||||
} catch (err) {
|
||||
log.error("Failed to switch input device", err);
|
||||
onErrorCallback?.("Failed to switch microphone");
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch the output (speaker) device on an active session. */
|
||||
export async function switchOutputDevice(deviceId: string): Promise<void> {
|
||||
let hadError = false;
|
||||
for (const el of audioElements.values()) {
|
||||
if (typeof el.setSinkId === "function") {
|
||||
try {
|
||||
await el.setSinkId(deviceId);
|
||||
} catch (err) {
|
||||
log.error("Failed to set output device on audio element", err);
|
||||
hadError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hadError) {
|
||||
onErrorCallback?.("Failed to switch some audio to new speaker");
|
||||
}
|
||||
log.info("Switched output device", { deviceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set per-user volume (0-200%). Persisted to localStorage.
|
||||
* Like Discord, this only affects YOUR playback of that user's audio.
|
||||
* Note: HTMLAudioElement.volume only supports 0.0-1.0, so volumes above
|
||||
* 100% are clamped. For boost beyond 100%, a Web Audio GainNode would
|
||||
* be needed, but WebView2 silences remote WebRTC streams through GainNode.
|
||||
*/
|
||||
export function setUserVolume(userId: number, volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref(`userVolume_${userId}`, clamped);
|
||||
|
||||
const audioEl = userAudioElements.get(userId);
|
||||
if (audioEl !== undefined) {
|
||||
audioEl.volume = Math.min(clamped, 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the current per-user volume (0-200%, default 100). */
|
||||
export function getUserVolume(userId: number): number {
|
||||
return getSavedUserVolume(userId);
|
||||
}
|
||||
|
||||
/** Update the VAD sensitivity threshold on an active session. */
|
||||
export function setVoiceSensitivity(sensitivity: number): void {
|
||||
if (vadDetector === null) return;
|
||||
vadDetector.setThreshold(sensitivityToThreshold(sensitivity));
|
||||
}
|
||||
|
||||
/** Get raw WebRTC remote streams for diagnostics (before GainNode). */
|
||||
export function getRemoteStreams(): readonly MediaStream[] {
|
||||
return webrtcService?.getRemoteStreams() ?? [];
|
||||
}
|
||||
|
||||
/** Get the local processed stream for diagnostics. */
|
||||
export function getLocalProcessedStream(): MediaStream | null {
|
||||
return processedStream;
|
||||
}
|
||||
|
||||
/** Handle an SDP offer from the server (re-negotiation). */
|
||||
export async function handleServerOffer(
|
||||
sdp: string,
|
||||
channelId: number,
|
||||
): Promise<void> {
|
||||
if (webrtcService === null) {
|
||||
log.warn("Received server offer but no WebRTC service active");
|
||||
return;
|
||||
}
|
||||
if (ws === null) {
|
||||
log.warn("Received server offer but no WS client set");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const answerSdp = await webrtcService.handleServerOffer(sdp);
|
||||
ws.send({
|
||||
type: "voice_answer",
|
||||
payload: { channel_id: channelId, sdp: answerSdp },
|
||||
});
|
||||
log.debug("Responded to server offer with answer", { channelId });
|
||||
} catch (err) {
|
||||
log.error("Failed to handle server offer", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle an SDP answer from the server. */
|
||||
export async function handleServerAnswer(sdp: string): Promise<void> {
|
||||
if (webrtcService === null) {
|
||||
log.warn("Received server answer but no WebRTC service active");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await webrtcService.handleAnswer(sdp);
|
||||
log.debug("Applied server answer");
|
||||
} catch (err) {
|
||||
log.error("Failed to handle server answer", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Measure RMS audio level on a MediaStream (0-1). Returns 0 if no data. */
|
||||
export function measureStreamLevel(stream: MediaStream): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const ctx = new AudioContext({ sampleRate: 48000 });
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
// Wait a few frames for data to flow
|
||||
let attempts = 0;
|
||||
const check = (): void => {
|
||||
analyser.getByteFrequencyData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = (data[i] ?? 0) / 255;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / data.length);
|
||||
attempts++;
|
||||
if (rms > 0 || attempts >= 10) {
|
||||
source.disconnect();
|
||||
void ctx.close();
|
||||
resolve(Math.round(rms * 1000) / 1000);
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
setTimeout(check, 100);
|
||||
} catch {
|
||||
resolve(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Snapshot of current voice session state for debugging. */
|
||||
export function getSessionDebugInfo(): Record<string, unknown> {
|
||||
// Gather detailed remote track info
|
||||
const remoteTrackDetails: Record<string, unknown>[] = [];
|
||||
for (const [streamId, audioEl] of audioElements.entries()) {
|
||||
const userId = streamUserMap.get(streamId) ?? 0;
|
||||
const gainNode = userId > 0 ? userGainNodes.get(userId) : undefined;
|
||||
const srcObj = audioEl.srcObject as MediaStream | null;
|
||||
const tracks = srcObj?.getAudioTracks() ?? [];
|
||||
const trackInfo = tracks.map((t) => ({
|
||||
id: t.id,
|
||||
enabled: t.enabled,
|
||||
muted: t.muted,
|
||||
readyState: t.readyState,
|
||||
}));
|
||||
|
||||
remoteTrackDetails.push({
|
||||
streamId,
|
||||
userId,
|
||||
audioPaused: audioEl.paused,
|
||||
audioMuted: audioEl.muted,
|
||||
audioVolume: audioEl.volume,
|
||||
audioReadyState: audioEl.readyState,
|
||||
hasSrcObject: audioEl.srcObject !== null,
|
||||
gainValue: gainNode?.gain.value ?? "no-node",
|
||||
tracks: trackInfo,
|
||||
});
|
||||
}
|
||||
|
||||
// Local track info
|
||||
const localTracks = processedStream?.getAudioTracks() ?? [];
|
||||
const localTrackInfo = localTracks.map((t) => ({
|
||||
id: t.id,
|
||||
enabled: t.enabled,
|
||||
muted: t.muted,
|
||||
readyState: t.readyState,
|
||||
}));
|
||||
|
||||
// WebRTC remote streams info
|
||||
const webrtcRemoteStreams = webrtcService?.getRemoteStreams() ?? [];
|
||||
const webrtcRemoteInfo = webrtcRemoteStreams.map((s) => ({
|
||||
streamId: s.id,
|
||||
trackCount: s.getTracks().length,
|
||||
audioTracks: s.getAudioTracks().map((t) => ({
|
||||
id: t.id,
|
||||
enabled: t.enabled,
|
||||
muted: t.muted,
|
||||
readyState: t.readyState,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
hasAudioManager: audioManager !== null,
|
||||
hasWebrtc: webrtcService !== null,
|
||||
hasVad: vadDetector !== null,
|
||||
hasNoiseSuppressor: noiseSuppressor !== null,
|
||||
hasLocalStream: localStream !== null,
|
||||
hasProcessedStream: processedStream !== null,
|
||||
joinInProgress,
|
||||
silenceSuppressionEnabled,
|
||||
sharedAudioCtx: sharedAudioCtx !== null
|
||||
? { state: sharedAudioCtx.state, sampleRate: sharedAudioCtx.sampleRate }
|
||||
: null,
|
||||
localTracks: localTrackInfo,
|
||||
remoteAudioElements: remoteTrackDetails,
|
||||
webrtcRemoteStreams: webrtcRemoteInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Handle an ICE candidate from the server. */
|
||||
export async function handleServerIce(
|
||||
candidate: RTCIceCandidateInit,
|
||||
): Promise<void> {
|
||||
if (webrtcService === null) {
|
||||
log.warn("Received ICE candidate but no WebRTC service active");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await webrtcService.handleIceCandidate(candidate);
|
||||
log.debug("Added server ICE candidate");
|
||||
} catch (err) {
|
||||
log.error("Failed to handle server ICE candidate", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// =============================================================================
|
||||
// WebRTC Service — peer connection management for voice communication
|
||||
// =============================================================================
|
||||
|
||||
import { createLogger } from "@lib/logger";
|
||||
|
||||
const log = createLogger("webrtc");
|
||||
|
||||
export interface WebRtcConfig {
|
||||
readonly iceServers: readonly RTCIceServer[];
|
||||
readonly opusBitrate?: number;
|
||||
}
|
||||
|
||||
export interface WebRtcService {
|
||||
createConnection(config: WebRtcConfig): void;
|
||||
handleOffer(sdp: string): Promise<string>;
|
||||
handleAnswer(sdp: string): Promise<void>;
|
||||
handleServerOffer(sdp: string): Promise<string>;
|
||||
createOffer(iceRestart?: boolean): Promise<string>;
|
||||
handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
|
||||
setLocalStream(stream: MediaStream): void;
|
||||
/** Swap the media track on existing senders without SDP renegotiation. */
|
||||
replaceTrack(stream: MediaStream): Promise<void>;
|
||||
getRemoteStreams(): readonly MediaStream[];
|
||||
setMuted(muted: boolean): void;
|
||||
setSilenced(silenced: boolean): void;
|
||||
onIceCandidate(callback: (candidate: RTCIceCandidateInit) => void): () => void;
|
||||
onRemoteTrack(callback: (stream: MediaStream) => void): () => void;
|
||||
onStateChange(callback: (state: RTCPeerConnectionState) => void): () => void;
|
||||
onIceStateChange(callback: (state: RTCIceConnectionState) => void): () => void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
type IceCandidateCallback = (candidate: RTCIceCandidateInit) => void;
|
||||
type RemoteTrackCallback = (stream: MediaStream) => void;
|
||||
type StateChangeCallback = (state: RTCPeerConnectionState) => void;
|
||||
type IceStateCallback = (state: RTCIceConnectionState) => void;
|
||||
|
||||
/** Apply Opus bitrate and FEC constraints via SDP munging. */
|
||||
function applyOpusSettings(sdp: string, bitrate: number | undefined): string {
|
||||
const lines = sdp.split("\r\n");
|
||||
const result: string[] = [];
|
||||
let inAudioSection = false;
|
||||
let bitrateInserted = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
if (line === undefined) continue;
|
||||
|
||||
// Track which media section we're in
|
||||
if (line.startsWith("m=audio")) {
|
||||
inAudioSection = true;
|
||||
bitrateInserted = false;
|
||||
} else if (line.startsWith("m=")) {
|
||||
inAudioSection = false;
|
||||
}
|
||||
|
||||
// Enable Opus in-band FEC for packet loss resilience
|
||||
if (line.startsWith("a=fmtp:111 ")) {
|
||||
if (!line.includes("useinbandfec=")) {
|
||||
line += ";useinbandfec=1";
|
||||
}
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
|
||||
// Insert b=AS after m=audio line (always present, unlike c= which may
|
||||
// only exist at session level)
|
||||
if (inAudioSection && !bitrateInserted && bitrate !== undefined && line.startsWith("m=audio")) {
|
||||
result.push(`b=AS:${Math.round(bitrate / 1000)}`);
|
||||
bitrateInserted = true;
|
||||
}
|
||||
}
|
||||
return result.join("\r\n");
|
||||
}
|
||||
|
||||
export function createWebRtcService(): WebRtcService {
|
||||
let pc: RTCPeerConnection | null = null;
|
||||
let localSenders: readonly RTCRtpSender[] = [];
|
||||
let isMuted = false;
|
||||
let isSilenced = false;
|
||||
let remoteStreams: readonly MediaStream[] = [];
|
||||
let opusBitrate: number | undefined;
|
||||
let destroyed = false;
|
||||
/** True once setRemoteDescription has been called (ICE candidates are safe). */
|
||||
let hasRemoteDescription = false;
|
||||
/** Queue ICE candidates that arrive before the remote description is set. */
|
||||
const pendingIceCandidates: RTCIceCandidateInit[] = [];
|
||||
|
||||
const iceCandidateCallbacks = new Set<IceCandidateCallback>();
|
||||
const remoteTrackCallbacks = new Set<RemoteTrackCallback>();
|
||||
const stateChangeCallbacks = new Set<StateChangeCallback>();
|
||||
const iceStateCallbacks = new Set<IceStateCallback>();
|
||||
|
||||
function assertConnection(): RTCPeerConnection {
|
||||
if (destroyed) throw new Error("WebRTC service has been destroyed");
|
||||
if (pc === null) throw new Error("No peer connection created");
|
||||
return pc;
|
||||
}
|
||||
|
||||
/** Flush queued ICE candidates now that the remote description is set. */
|
||||
async function flushIceCandidates(conn: RTCPeerConnection): Promise<void> {
|
||||
hasRemoteDescription = true;
|
||||
const queued = pendingIceCandidates.splice(0);
|
||||
if (queued.length > 0) {
|
||||
log.debug("Flushing queued ICE candidates", { count: queued.length });
|
||||
}
|
||||
for (const c of queued) {
|
||||
await conn.addIceCandidate(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply track.enabled based on current mute + silence state. */
|
||||
function applyTrackEnabled(): void {
|
||||
for (const sender of localSenders) {
|
||||
const track = sender.track;
|
||||
if (track !== null) {
|
||||
track.enabled = !isMuted && !isSilenced;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleIceCandidateEvent(event: RTCPeerConnectionIceEvent): void {
|
||||
if (event.candidate === null) {
|
||||
log.debug("ICE gathering complete");
|
||||
return;
|
||||
}
|
||||
const c = event.candidate;
|
||||
log.debug("Local ICE candidate", {
|
||||
type: c.type,
|
||||
address: c.address,
|
||||
port: c.port,
|
||||
protocol: c.protocol,
|
||||
candidate: c.candidate,
|
||||
});
|
||||
const init: RTCIceCandidateInit = {
|
||||
candidate: c.candidate,
|
||||
sdpMid: c.sdpMid,
|
||||
sdpMLineIndex: c.sdpMLineIndex,
|
||||
};
|
||||
for (const cb of iceCandidateCallbacks) {
|
||||
cb(init);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackEvent(event: RTCTrackEvent): void {
|
||||
const stream = event.streams[0];
|
||||
if (stream === undefined) {
|
||||
log.warn("Remote track event with no stream");
|
||||
return;
|
||||
}
|
||||
if (remoteStreams.some((s) => s.id === stream.id)) {
|
||||
log.debug("Duplicate remote stream ignored", { streamId: stream.id });
|
||||
return;
|
||||
}
|
||||
log.info("Remote track received", {
|
||||
streamId: stream.id,
|
||||
trackId: event.track.id,
|
||||
kind: event.track.kind,
|
||||
totalStreams: remoteStreams.length + 1,
|
||||
});
|
||||
remoteStreams = [...remoteStreams, stream];
|
||||
for (const cb of remoteTrackCallbacks) {
|
||||
cb(stream);
|
||||
}
|
||||
}
|
||||
|
||||
function handleConnectionStateChange(): void {
|
||||
if (pc === null) return;
|
||||
const state = pc.connectionState;
|
||||
for (const cb of stateChangeCallbacks) {
|
||||
cb(state);
|
||||
}
|
||||
}
|
||||
|
||||
function handleIceConnectionStateChange(): void {
|
||||
if (pc === null) return;
|
||||
const state = pc.iceConnectionState;
|
||||
for (const cb of iceStateCallbacks) {
|
||||
cb(state);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNegotiationNeeded(): void {
|
||||
// Log canary — if this fires, something triggered SDP renegotiation
|
||||
// that our explicit offer/answer flow didn't handle. Upgrade to a
|
||||
// full handler (auto-create offer) if this shows up in production.
|
||||
console.warn("[WebRTC] negotiationneeded fired unexpectedly — signalingState:", pc?.signalingState);
|
||||
}
|
||||
|
||||
function mungeIfNeeded(sdp: string | undefined): string {
|
||||
if (sdp === undefined) return "";
|
||||
return applyOpusSettings(sdp, opusBitrate);
|
||||
}
|
||||
|
||||
return {
|
||||
createConnection(config: WebRtcConfig): void {
|
||||
if (destroyed) throw new Error("WebRTC service has been destroyed");
|
||||
if (pc !== null) {
|
||||
pc.close();
|
||||
}
|
||||
opusBitrate = config.opusBitrate;
|
||||
remoteStreams = [];
|
||||
localSenders = [];
|
||||
isMuted = false;
|
||||
isSilenced = false;
|
||||
hasRemoteDescription = false;
|
||||
pendingIceCandidates.length = 0;
|
||||
|
||||
pc = new RTCPeerConnection({
|
||||
iceServers: [...config.iceServers],
|
||||
});
|
||||
pc.addEventListener("icecandidate", handleIceCandidateEvent);
|
||||
pc.addEventListener("track", handleTrackEvent);
|
||||
pc.addEventListener("connectionstatechange", handleConnectionStateChange);
|
||||
pc.addEventListener("iceconnectionstatechange", handleIceConnectionStateChange);
|
||||
pc.addEventListener("negotiationneeded", handleNegotiationNeeded);
|
||||
log.info("PeerConnection created", {
|
||||
iceServerCount: config.iceServers.length,
|
||||
opusBitrate: config.opusBitrate,
|
||||
});
|
||||
},
|
||||
|
||||
async handleOffer(sdp: string): Promise<string> {
|
||||
const conn = assertConnection();
|
||||
await conn.setRemoteDescription({ type: "offer", sdp });
|
||||
await flushIceCandidates(conn);
|
||||
const answer = await conn.createAnswer();
|
||||
const mungedSdp = mungeIfNeeded(answer.sdp);
|
||||
await conn.setLocalDescription({ type: "answer", sdp: mungedSdp });
|
||||
return mungedSdp;
|
||||
},
|
||||
|
||||
async handleAnswer(sdp: string): Promise<void> {
|
||||
const conn = assertConnection();
|
||||
await conn.setRemoteDescription({ type: "answer", sdp });
|
||||
await flushIceCandidates(conn);
|
||||
},
|
||||
|
||||
async handleServerOffer(sdp: string): Promise<string> {
|
||||
const conn = assertConnection();
|
||||
if (conn.signalingState === "have-local-offer") {
|
||||
log.info("Rolling back local offer for server renegotiation (glare)");
|
||||
await conn.setLocalDescription({ type: "rollback" });
|
||||
}
|
||||
await conn.setRemoteDescription({ type: "offer", sdp });
|
||||
await flushIceCandidates(conn);
|
||||
const answer = await conn.createAnswer();
|
||||
const mungedSdp = mungeIfNeeded(answer.sdp);
|
||||
await conn.setLocalDescription({ type: "answer", sdp: mungedSdp });
|
||||
return mungedSdp;
|
||||
},
|
||||
|
||||
async createOffer(iceRestart = false): Promise<string> {
|
||||
const conn = assertConnection();
|
||||
const offer = await conn.createOffer({ iceRestart });
|
||||
const mungedSdp = mungeIfNeeded(offer.sdp);
|
||||
await conn.setLocalDescription({ type: "offer", sdp: mungedSdp });
|
||||
return mungedSdp;
|
||||
},
|
||||
|
||||
async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
const conn = assertConnection();
|
||||
if (!hasRemoteDescription) {
|
||||
pendingIceCandidates.push(candidate);
|
||||
log.debug("ICE candidate queued (no remote description yet)", { queueDepth: pendingIceCandidates.length });
|
||||
return;
|
||||
}
|
||||
await conn.addIceCandidate(candidate);
|
||||
},
|
||||
|
||||
setLocalStream(stream: MediaStream): void {
|
||||
const conn = assertConnection();
|
||||
const removedCount = localSenders.length;
|
||||
for (const sender of localSenders) {
|
||||
conn.removeTrack(sender);
|
||||
}
|
||||
|
||||
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
|
||||
localSenders = newSenders;
|
||||
|
||||
// Apply current mute/silence state to new tracks
|
||||
applyTrackEnabled();
|
||||
log.debug("Local stream set", { removedSenders: removedCount, addedTracks: newSenders.length });
|
||||
},
|
||||
|
||||
async replaceTrack(stream: MediaStream): Promise<void> {
|
||||
assertConnection();
|
||||
const newTracks = stream.getAudioTracks();
|
||||
if (newTracks.length === 0) {
|
||||
log.warn("replaceTrack called with no audio tracks");
|
||||
return;
|
||||
}
|
||||
const newTrack = newTracks[0]!;
|
||||
|
||||
if (localSenders.length > 0) {
|
||||
// Swap track on existing sender — no SDP renegotiation needed
|
||||
for (const sender of localSenders) {
|
||||
await sender.replaceTrack(newTrack);
|
||||
}
|
||||
log.debug("Track replaced on existing senders", { senderCount: localSenders.length, trackId: newTrack.id });
|
||||
} else {
|
||||
// No existing senders — fall back to addTrack (initial attach)
|
||||
log.debug("replaceTrack fallback: no senders, using addTrack");
|
||||
const conn = assertConnection();
|
||||
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
|
||||
localSenders = newSenders;
|
||||
}
|
||||
|
||||
// Apply current mute/silence state to the new track
|
||||
applyTrackEnabled();
|
||||
},
|
||||
|
||||
getRemoteStreams(): readonly MediaStream[] {
|
||||
return remoteStreams;
|
||||
},
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
isMuted = muted;
|
||||
applyTrackEnabled();
|
||||
},
|
||||
|
||||
setSilenced(silenced: boolean): void {
|
||||
isSilenced = silenced;
|
||||
applyTrackEnabled();
|
||||
},
|
||||
|
||||
onIceCandidate(callback: IceCandidateCallback): () => void {
|
||||
iceCandidateCallbacks.add(callback);
|
||||
return () => { iceCandidateCallbacks.delete(callback); };
|
||||
},
|
||||
|
||||
onRemoteTrack(callback: RemoteTrackCallback): () => void {
|
||||
remoteTrackCallbacks.add(callback);
|
||||
return () => { remoteTrackCallbacks.delete(callback); };
|
||||
},
|
||||
|
||||
onStateChange(callback: StateChangeCallback): () => void {
|
||||
stateChangeCallbacks.add(callback);
|
||||
return () => { stateChangeCallbacks.delete(callback); };
|
||||
},
|
||||
|
||||
onIceStateChange(callback: IceStateCallback): () => void {
|
||||
iceStateCallbacks.add(callback);
|
||||
return () => { iceStateCallbacks.delete(callback); };
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
log.debug("WebRTC service destroying", { remoteStreams: remoteStreams.length, localSenders: localSenders.length });
|
||||
if (pc !== null) {
|
||||
pc.removeEventListener("icecandidate", handleIceCandidateEvent);
|
||||
pc.removeEventListener("track", handleTrackEvent);
|
||||
pc.removeEventListener("connectionstatechange", handleConnectionStateChange);
|
||||
pc.removeEventListener("iceconnectionstatechange", handleIceConnectionStateChange);
|
||||
pc.removeEventListener("negotiationneeded", handleNegotiationNeeded);
|
||||
pc.close();
|
||||
pc = null;
|
||||
}
|
||||
localSenders = [];
|
||||
remoteStreams = [];
|
||||
pendingIceCandidates.length = 0;
|
||||
iceCandidateCallbacks.clear();
|
||||
remoteTrackCallbacks.clear();
|
||||
stateChangeCallbacks.clear();
|
||||
iceStateCallbacks.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Window state persistence — saves/restores window position and size.
|
||||
* Uses Tauri IPC commands backed by tauri-plugin-store.
|
||||
*/
|
||||
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("window-state");
|
||||
|
||||
export interface WindowState {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly maximized: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "windowState";
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
const invokePromise: Promise<
|
||||
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
|
||||
> = import("@tauri-apps/api/core")
|
||||
.then((m) => m.invoke)
|
||||
.catch(() => null);
|
||||
|
||||
/**
|
||||
* Save the current window state to the Tauri settings store.
|
||||
*/
|
||||
async function saveState(state: WindowState): Promise<void> {
|
||||
const invoke = await invokePromise;
|
||||
if (!invoke) return;
|
||||
try {
|
||||
await invoke("save_settings", { key: STORAGE_KEY, value: state });
|
||||
} catch (err) {
|
||||
log.error("Failed to save window state", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previously saved window state.
|
||||
*/
|
||||
async function loadState(): Promise<WindowState | null> {
|
||||
const invoke = await invokePromise;
|
||||
if (!invoke) return null;
|
||||
try {
|
||||
const all = (await invoke("get_settings")) as Record<string, unknown>;
|
||||
const raw = all[STORAGE_KEY];
|
||||
if (raw && typeof raw === "object") {
|
||||
const s = raw as Record<string, unknown>;
|
||||
if (
|
||||
typeof s.x === "number" &&
|
||||
typeof s.y === "number" &&
|
||||
typeof s.width === "number" &&
|
||||
typeof s.height === "number" &&
|
||||
typeof s.maximized === "boolean"
|
||||
) {
|
||||
return {
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
maximized: s.maximized,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
log.error("Failed to load window state", { error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize window state persistence.
|
||||
* Restores saved position/size on startup and listens for changes.
|
||||
* Returns a cleanup function.
|
||||
*/
|
||||
export async function initWindowState(): Promise<() => void> {
|
||||
let tauriWindow: typeof import("@tauri-apps/api/window") | undefined;
|
||||
try {
|
||||
tauriWindow = await import("@tauri-apps/api/window");
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const win = tauriWindow.getCurrentWindow();
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
// Restore saved state
|
||||
const saved = await loadState();
|
||||
if (saved !== null) {
|
||||
try {
|
||||
if (saved.maximized) {
|
||||
await win.maximize();
|
||||
} else {
|
||||
const pos = new tauriWindow.PhysicalPosition(saved.x, saved.y);
|
||||
const size = new tauriWindow.PhysicalSize(saved.width, saved.height);
|
||||
await win.setPosition(pos);
|
||||
await win.setSize(size);
|
||||
}
|
||||
log.info("Restored window state", { x: saved.x, y: saved.y, width: saved.width, height: saved.height });
|
||||
} catch (err) {
|
||||
log.warn("Failed to restore window state", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced save on move/resize
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function debouncedSave(): void {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer);
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const pos = await win.outerPosition();
|
||||
const size = await win.outerSize();
|
||||
const maximized = await win.isMaximized();
|
||||
await saveState({
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
maximized,
|
||||
});
|
||||
} catch {
|
||||
// Window may have been closed during save
|
||||
}
|
||||
})();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
try {
|
||||
const unlistenMoved = await win.onMoved(() => debouncedSave());
|
||||
cleanups.push(unlistenMoved);
|
||||
} catch {
|
||||
// onMoved may not be available
|
||||
}
|
||||
|
||||
try {
|
||||
const unlistenResized = await win.onResized(() => debouncedSave());
|
||||
cleanups.push(unlistenResized);
|
||||
} catch {
|
||||
// onResized may not be available
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (saveTimer !== null) {
|
||||
clearTimeout(saveTimer);
|
||||
}
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Step 2.15 — WebSocket Client
|
||||
// Uses Tauri IPC (ws_connect/ws_send/ws_disconnect commands + events)
|
||||
// to proxy WSS through Rust, bypassing self-signed cert issues in webview.
|
||||
|
||||
import type { ServerMessage, ClientMessage } from "./types";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("ws");
|
||||
|
||||
// Tauri IPC imports — resolved at runtime in Tauri context
|
||||
let tauriInvoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
||||
let tauriListen: ((event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>) | null = null;
|
||||
|
||||
// Dynamically load Tauri APIs (avoids import errors in test/browser env)
|
||||
async function ensureTauriApis(): Promise<void> {
|
||||
if (tauriInvoke !== null) return;
|
||||
try {
|
||||
const core = await import("@tauri-apps/api/core");
|
||||
const event = await import("@tauri-apps/api/event");
|
||||
tauriInvoke = core.invoke;
|
||||
tauriListen = event.listen;
|
||||
} catch {
|
||||
log.warn("Tauri APIs not available — WebSocket proxy will not work");
|
||||
}
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "authenticating"
|
||||
| "connected"
|
||||
| "reconnecting";
|
||||
|
||||
export type WsListener<T extends ServerMessage["type"]> = (
|
||||
payload: Extract<ServerMessage, { type: T }>["payload"],
|
||||
id?: string,
|
||||
) => void;
|
||||
|
||||
/** TOFU certificate event emitted by the Rust WS proxy. */
|
||||
export interface CertTofuEvent {
|
||||
readonly host: string;
|
||||
readonly fingerprint: string;
|
||||
readonly status: "trusted_first_use" | "trusted" | "mismatch";
|
||||
readonly message?: string;
|
||||
readonly storedFingerprint?: string;
|
||||
}
|
||||
|
||||
/** Parse the stored fingerprint from the Rust cert-tofu message string. */
|
||||
export function parseStoredFingerprint(message?: string): string | undefined {
|
||||
if (!message) return undefined;
|
||||
const match = /Stored:\s+(\S+)/.exec(message);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export type CertMismatchListener = (event: CertTofuEvent) => void;
|
||||
|
||||
export interface WsClientConfig {
|
||||
readonly host: string;
|
||||
readonly token: string;
|
||||
readonly maxReconnectDelayMs?: number;
|
||||
readonly maxMessageSizeBytes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RECONNECT_DELAY = 30_000;
|
||||
const DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; // 1MB
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function createWsClient() {
|
||||
let config: WsClientConfig | null = null;
|
||||
let state: ConnectionState = "disconnected";
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let intentionalClose = false;
|
||||
let certMismatchBlock = false; // blocks reconnect on TOFU mismatch
|
||||
let proxyOpen = false;
|
||||
|
||||
// Tauri event unsubscribe functions
|
||||
const eventUnsubs: Array<() => void> = [];
|
||||
|
||||
// Type-safe listener registry
|
||||
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
|
||||
|
||||
// State change listeners
|
||||
const stateListeners = new Set<(state: ConnectionState) => void>();
|
||||
|
||||
// TOFU cert mismatch listeners
|
||||
const certMismatchListeners = new Set<CertMismatchListener>();
|
||||
|
||||
function setState(newState: ConnectionState): void {
|
||||
if (state !== newState) {
|
||||
state = newState;
|
||||
for (const listener of stateListeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getReconnectDelay(): number {
|
||||
const maxDelay = config?.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY;
|
||||
return Math.min(1000 * Math.pow(2, reconnectAttempt), maxDelay);
|
||||
}
|
||||
|
||||
function startHeartbeat(): void {
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (proxyOpen) {
|
||||
try {
|
||||
sendRaw(JSON.stringify({ type: "ping", payload: {} }));
|
||||
} catch {
|
||||
// Connection may have dropped
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopHeartbeat(): void {
|
||||
if (heartbeatTimer !== null) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (intentionalClose || certMismatchBlock || !config) return;
|
||||
const delay = getReconnectDelay();
|
||||
log.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`);
|
||||
setState("reconnecting");
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectAttempt++;
|
||||
connect(config!);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function cancelReconnect(): void {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(raw: string): void {
|
||||
const maxSize = config?.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE;
|
||||
|
||||
if (raw.length > maxSize) {
|
||||
log.warn("Message exceeds size limit, dropping", { size: raw.length });
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: { type?: string; payload?: unknown; id?: string };
|
||||
try {
|
||||
parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string };
|
||||
} catch {
|
||||
log.warn("Failed to parse WS message", { data: raw });
|
||||
return;
|
||||
}
|
||||
|
||||
// Server pong messages have no payload — silently ignore.
|
||||
if (parsed.type === "pong") return;
|
||||
|
||||
if (!parsed.type || parsed.payload === undefined) {
|
||||
log.warn("Invalid WS message: missing type or payload", { parsed });
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = parsed as unknown as ServerMessage;
|
||||
|
||||
log.debug("WS ←", { type: msg.type, id: msg.id });
|
||||
|
||||
// auth_error — non-recoverable
|
||||
if (msg.type === "auth_error") {
|
||||
log.error("Authentication failed", { message: msg.payload.message });
|
||||
intentionalClose = true;
|
||||
dispatch(msg);
|
||||
void disconnectProxy();
|
||||
setState("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
// auth_ok — mark as connected
|
||||
if (msg.type === "auth_ok") {
|
||||
setState("connected");
|
||||
reconnectAttempt = 0;
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
dispatch(msg);
|
||||
}
|
||||
|
||||
function dispatch(msg: ServerMessage): void {
|
||||
const typeListeners = listeners.get(msg.type);
|
||||
if (!typeListeners || typeListeners.size === 0) {
|
||||
log.debug("WS dispatch: no listeners", { type: msg.type });
|
||||
return;
|
||||
}
|
||||
for (const listener of typeListeners) {
|
||||
try {
|
||||
(listener as WsListener<typeof msg.type>)(
|
||||
msg.payload as Extract<ServerMessage, { type: typeof msg.type }>["payload"],
|
||||
msg.id,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(`Listener error for ${msg.type}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupEventListeners(): Promise<void> {
|
||||
if (tauriListen === null) return;
|
||||
|
||||
// Server messages
|
||||
const unsubMsg = await tauriListen("ws-message", (e) => {
|
||||
handleMessage(e.payload as string);
|
||||
});
|
||||
eventUnsubs.push(unsubMsg);
|
||||
|
||||
// Connection state changes from Rust
|
||||
const unsubState = await tauriListen("ws-state", (e) => {
|
||||
const rustState = e.payload as string;
|
||||
log.debug("Rust WS state", { state: rustState });
|
||||
|
||||
if (rustState === "open") {
|
||||
proxyOpen = true;
|
||||
log.info("WebSocket open, sending auth");
|
||||
setState("authenticating");
|
||||
send({ type: "auth", payload: { token: config!.token } });
|
||||
} else if (rustState === "closed") {
|
||||
proxyOpen = false;
|
||||
log.info("WebSocket closed (proxy)");
|
||||
stopHeartbeat();
|
||||
if (!intentionalClose) {
|
||||
scheduleReconnect();
|
||||
} else {
|
||||
setState("disconnected");
|
||||
}
|
||||
}
|
||||
});
|
||||
eventUnsubs.push(unsubState);
|
||||
|
||||
// Errors
|
||||
const unsubErr = await tauriListen("ws-error", (e) => {
|
||||
log.warn("WebSocket error (proxy)", { error: e.payload });
|
||||
});
|
||||
eventUnsubs.push(unsubErr);
|
||||
|
||||
// TOFU certificate events
|
||||
const unsubCert = await tauriListen("cert-tofu", (e) => {
|
||||
const raw = e.payload as CertTofuEvent;
|
||||
log.info("TOFU cert event", { host: raw.host, status: raw.status });
|
||||
|
||||
if (raw.status === "mismatch") {
|
||||
const evt: CertTofuEvent = {
|
||||
...raw,
|
||||
storedFingerprint: parseStoredFingerprint(raw.message),
|
||||
};
|
||||
log.error("Certificate fingerprint mismatch!", {
|
||||
host: evt.host,
|
||||
fingerprint: evt.fingerprint,
|
||||
storedFingerprint: evt.storedFingerprint,
|
||||
});
|
||||
certMismatchBlock = true;
|
||||
setState("disconnected");
|
||||
for (const listener of certMismatchListeners) {
|
||||
listener(evt);
|
||||
}
|
||||
}
|
||||
});
|
||||
eventUnsubs.push(unsubCert);
|
||||
}
|
||||
|
||||
function cleanupEventListeners(): void {
|
||||
for (const unsub of eventUnsubs) {
|
||||
try {
|
||||
// Unsub may return a rejected promise if the Tauri resource
|
||||
// was already invalidated after disconnect — safe to ignore.
|
||||
const result = unsub() as unknown;
|
||||
if (result instanceof Promise) {
|
||||
result.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Sync errors also safe to ignore.
|
||||
}
|
||||
}
|
||||
eventUnsubs.length = 0;
|
||||
}
|
||||
|
||||
async function connect(cfg: WsClientConfig): Promise<void> {
|
||||
config = cfg;
|
||||
intentionalClose = false;
|
||||
cancelReconnect();
|
||||
|
||||
setState("connecting");
|
||||
|
||||
await ensureTauriApis();
|
||||
if (tauriInvoke === null) {
|
||||
log.error("Tauri APIs not available, cannot connect WebSocket");
|
||||
setState("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${cfg.host}/api/v1/ws`;
|
||||
log.info("Connecting to", { url: wsUrl });
|
||||
|
||||
// Set up event listeners before connecting
|
||||
cleanupEventListeners();
|
||||
await setupEventListeners();
|
||||
|
||||
try {
|
||||
await tauriInvoke("ws_connect", { url: wsUrl });
|
||||
} catch (err) {
|
||||
log.error("ws_connect failed", err);
|
||||
proxyOpen = false;
|
||||
|
||||
// Cert mismatch is handled by the cert-tofu event listener
|
||||
// (which sets certMismatchBlock before this catch runs).
|
||||
// scheduleReconnect() checks certMismatchBlock and will no-op if set.
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function sendRaw(json: string): void {
|
||||
if (tauriInvoke === null || !proxyOpen) {
|
||||
log.warn("Cannot send, WebSocket not open");
|
||||
return;
|
||||
}
|
||||
tauriInvoke("ws_send", { message: json }).catch((err) => {
|
||||
log.error("ws_send failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function send(msg: ClientMessage | { type: string; payload: unknown }): string {
|
||||
const id = uuid();
|
||||
const envelope = { ...msg, id };
|
||||
log.debug("WS →", { type: msg.type, id });
|
||||
sendRaw(JSON.stringify(envelope));
|
||||
return id;
|
||||
}
|
||||
|
||||
async function disconnectProxy(): Promise<void> {
|
||||
if (tauriInvoke !== null) {
|
||||
try {
|
||||
await tauriInvoke("ws_disconnect");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
proxyOpen = false;
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
intentionalClose = true;
|
||||
certMismatchBlock = false;
|
||||
cancelReconnect();
|
||||
stopHeartbeat();
|
||||
cleanupEventListeners();
|
||||
void disconnectProxy();
|
||||
setState("disconnected");
|
||||
}
|
||||
|
||||
return {
|
||||
connect(cfg: WsClientConfig): void {
|
||||
void connect(cfg);
|
||||
},
|
||||
|
||||
disconnect,
|
||||
|
||||
send(msg: ClientMessage): string {
|
||||
return send(msg);
|
||||
},
|
||||
|
||||
on<T extends ServerMessage["type"]>(
|
||||
type: T,
|
||||
listener: WsListener<T>,
|
||||
): () => void {
|
||||
if (!listeners.has(type)) {
|
||||
listeners.set(type, new Set());
|
||||
}
|
||||
const set = listeners.get(type)!;
|
||||
set.add(listener as unknown as WsListener<ServerMessage["type"]>);
|
||||
return () => {
|
||||
set.delete(listener as unknown as WsListener<ServerMessage["type"]>);
|
||||
};
|
||||
},
|
||||
|
||||
onStateChange(listener: (state: ConnectionState) => void): () => void {
|
||||
stateListeners.add(listener);
|
||||
return () => stateListeners.delete(listener);
|
||||
},
|
||||
|
||||
/** Register a listener for TOFU certificate mismatch events. */
|
||||
onCertMismatch(listener: CertMismatchListener): () => void {
|
||||
certMismatchListeners.add(listener);
|
||||
return () => certMismatchListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Accept a changed certificate fingerprint for a host.
|
||||
* Call after the user acknowledges a cert mismatch warning,
|
||||
* then reconnect.
|
||||
*/
|
||||
async acceptCertFingerprint(host: string, fingerprint: string): Promise<void> {
|
||||
await ensureTauriApis();
|
||||
if (tauriInvoke === null) {
|
||||
throw new Error("Tauri APIs not available");
|
||||
}
|
||||
await tauriInvoke("accept_cert_fingerprint", { host, fingerprint });
|
||||
certMismatchBlock = false;
|
||||
log.info("Accepted new cert fingerprint", { host });
|
||||
},
|
||||
|
||||
getState(): ConnectionState {
|
||||
return state;
|
||||
},
|
||||
|
||||
/** @internal for testing */
|
||||
_getWs(): WebSocket | null {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type WsClient = ReturnType<typeof createWsClient>;
|
||||
@@ -0,0 +1,345 @@
|
||||
// OwnCord Tauri v2 Client — Entry Point
|
||||
|
||||
import "@styles/tokens.css";
|
||||
import "@styles/base.css";
|
||||
import "@styles/login.css";
|
||||
import "@styles/app.css";
|
||||
|
||||
import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render";
|
||||
import { createRouter } from "@lib/router";
|
||||
import { createApiClient } from "@lib/api";
|
||||
import { createWsClient } from "@lib/ws";
|
||||
import { wireDispatcher } from "@lib/dispatcher";
|
||||
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
|
||||
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
|
||||
import { leaveVoice as voiceSessionLeave } from "@lib/voiceSession";
|
||||
import { createConnectPage } from "@pages/ConnectPage";
|
||||
import { createMainPage } from "@pages/MainPage";
|
||||
import { applyStoredAppearance } from "@components/SettingsOverlay";
|
||||
import { createConnectedOverlay } from "@components/ConnectedOverlay";
|
||||
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { saveCredential, deleteCredential } from "@lib/credentials";
|
||||
import { initWindowState } from "@lib/window-state";
|
||||
import { createCertMismatchModal } from "@components/CertMismatchModal";
|
||||
import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
import type { CertTofuEvent } from "@lib/ws";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
const log = createLogger("main");
|
||||
|
||||
// Disable the default browser context menu globally.
|
||||
document.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Open external links (target="_blank") in the user's default browser.
|
||||
document.addEventListener("click", (e) => {
|
||||
const link = (e.target as HTMLElement).closest("a[target='_blank']") as HTMLAnchorElement | null;
|
||||
if (link === null) return;
|
||||
e.preventDefault();
|
||||
const href = link.href;
|
||||
if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
|
||||
void openUrl(href);
|
||||
}
|
||||
});
|
||||
|
||||
// Install global error handlers first
|
||||
installGlobalErrorHandlers();
|
||||
|
||||
// Apply stored theme/font/compact preferences before first render
|
||||
applyStoredAppearance();
|
||||
|
||||
const appEl = document.getElementById("app");
|
||||
if (!appEl) {
|
||||
throw new Error("Missing #app element");
|
||||
}
|
||||
|
||||
// Create core services
|
||||
const router = createRouter("connect");
|
||||
const api = createApiClient({ host: "" }, () => {
|
||||
log.warn("Session expired (401), clearing auth");
|
||||
clearAuth();
|
||||
});
|
||||
const ws = createWsClient();
|
||||
const profileManager = createProfileManager(createTauriBackend());
|
||||
let dispatcherCleanup: (() => void) | null = null;
|
||||
let connectedOverlay: ConnectedOverlayControl | null = null;
|
||||
let lastConnectHost = "";
|
||||
let lastConnectToken = "";
|
||||
|
||||
// Certificate mismatch modal handler
|
||||
let certModalActive = false;
|
||||
ws.onCertMismatch((evt: CertTofuEvent) => {
|
||||
if (certModalActive) return;
|
||||
certModalActive = true;
|
||||
|
||||
const modal = createCertMismatchModal({
|
||||
host: evt.host,
|
||||
storedFingerprint: evt.storedFingerprint ?? "Unknown",
|
||||
newFingerprint: evt.fingerprint,
|
||||
onAccept: () => {
|
||||
modal.destroy?.();
|
||||
certModalActive = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
|
||||
if (lastConnectHost && lastConnectToken) {
|
||||
ws.connect({ host: lastConnectHost, token: lastConnectToken });
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to accept cert fingerprint", err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
onReject: () => {
|
||||
modal.destroy?.();
|
||||
certModalActive = false;
|
||||
ws.disconnect();
|
||||
clearAuth();
|
||||
router.navigate("connect");
|
||||
},
|
||||
});
|
||||
modal.mount(document.body);
|
||||
});
|
||||
|
||||
// Current page component reference for cleanup
|
||||
let currentPage: { destroy?(): void } | null = null;
|
||||
|
||||
/** Run health checks for a list of profiles and update the connect page. */
|
||||
function runHealthChecks(
|
||||
connectPage: { updateHealthStatus(host: string, status: { status: string; latencyMs: number | null; version: string | null }): void },
|
||||
profiles: readonly { host: string }[],
|
||||
): void {
|
||||
for (const profile of profiles) {
|
||||
void (async () => {
|
||||
try {
|
||||
connectPage.updateHealthStatus(profile.host, {
|
||||
status: "checking",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
});
|
||||
const start = performance.now();
|
||||
const health = await api.getHealth(profile.host, 3000);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
connectPage.updateHealthStatus(profile.host, {
|
||||
status: elapsed > 1500 ? "slow" : "online",
|
||||
latencyMs: elapsed,
|
||||
version: health.version,
|
||||
});
|
||||
} catch {
|
||||
connectPage.updateHealthStatus(profile.host, {
|
||||
status: "offline",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
// Render the appropriate page based on router state
|
||||
function renderPage(pageId: "connect" | "main"): void {
|
||||
log.info("Navigating to page", { pageId });
|
||||
// Destroy previous page
|
||||
currentPage?.destroy?.();
|
||||
currentPage = null;
|
||||
appEl!.textContent = "";
|
||||
|
||||
// Shared helper for post-auth WS connect + overlay flow
|
||||
function wirePostAuth(host: string, token: string, username: string, password?: string): void {
|
||||
log.info("Post-auth wiring", { host, username });
|
||||
api.setConfig({ token });
|
||||
// Store token in authStore so the dispatcher's auth_ok handler has it
|
||||
authStore.setState((prev) => ({ ...prev, token }));
|
||||
lastConnectHost = host;
|
||||
lastConnectToken = token;
|
||||
ws.connect({ host, token });
|
||||
dispatcherCleanup = wireDispatcher(ws);
|
||||
log.info("Dispatcher wired, connecting WS");
|
||||
|
||||
// Save credential for auto-reconnect (fire-and-forget)
|
||||
void saveCredential(host, username, token, password);
|
||||
|
||||
const unsubState = ws.onStateChange((wsState) => {
|
||||
log.debug("WS state change", { state: wsState });
|
||||
if (wsState === "connected") {
|
||||
unsubState();
|
||||
const auth = authStore.getState();
|
||||
connectedOverlay = createConnectedOverlay({
|
||||
serverName: auth.serverName ?? host,
|
||||
username: auth.user?.username ?? username,
|
||||
motd: auth.motd ?? "",
|
||||
onReady: () => {
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = null;
|
||||
router.navigate("main");
|
||||
},
|
||||
});
|
||||
appEl!.appendChild(connectedOverlay.element);
|
||||
connectedOverlay.show();
|
||||
|
||||
const unsubReady = ws.on("ready", () => {
|
||||
unsubReady();
|
||||
connectedOverlay?.markReady();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track partial auth state for TOTP flow
|
||||
let pendingTotpHost = "";
|
||||
let pendingTotpPartialToken = "";
|
||||
let pendingTotpUsername = "";
|
||||
|
||||
if (pageId === "connect") {
|
||||
// Helper to get the profile list for the ConnectPage
|
||||
function getProfileList(): readonly { name: string; host: string; id?: string; username?: string }[] {
|
||||
const saved = profileManager.getAll();
|
||||
if (saved.length > 0) return saved;
|
||||
// Fallback: show a default local server entry
|
||||
return [{ name: "Local Server", host: "localhost:8443" }];
|
||||
}
|
||||
|
||||
// Auto-save a profile for a host after successful login (if not already saved)
|
||||
function ensureProfileExists(host: string, username: string): void {
|
||||
const existing = profileManager.getAll().find((p) => p.host === host);
|
||||
if (existing) {
|
||||
// Update username and lastConnected
|
||||
profileManager.updateProfile(existing.id, { username });
|
||||
profileManager.setLastConnected(existing.id);
|
||||
} else {
|
||||
const created = profileManager.addProfile({
|
||||
name: host.split(":")[0] ?? host,
|
||||
host,
|
||||
username,
|
||||
autoConnect: false,
|
||||
rememberPassword: false,
|
||||
color: "#5865F2",
|
||||
});
|
||||
profileManager.setLastConnected(created.id);
|
||||
}
|
||||
void profileManager.saveProfiles();
|
||||
}
|
||||
|
||||
const connectPage = createConnectPage({
|
||||
async onLogin(host, username, password) {
|
||||
api.setConfig({ host });
|
||||
const result = await api.login(username, password);
|
||||
if (result.requires_2fa) {
|
||||
pendingTotpHost = host;
|
||||
pendingTotpPartialToken = result.partial_token ?? "";
|
||||
pendingTotpUsername = username;
|
||||
connectPage.showTotp();
|
||||
return;
|
||||
}
|
||||
if (result.token) {
|
||||
const savedPassword = connectPage.getRememberPassword() ? password : undefined;
|
||||
ensureProfileExists(host, username);
|
||||
wirePostAuth(host, result.token, username, savedPassword);
|
||||
}
|
||||
},
|
||||
async onRegister(host, username, password, inviteCode) {
|
||||
api.setConfig({ host });
|
||||
const result = await api.register(username, password, inviteCode);
|
||||
const savedPassword = connectPage.getRememberPassword() ? password : undefined;
|
||||
ensureProfileExists(host, username);
|
||||
wirePostAuth(host, result.token, username, savedPassword);
|
||||
},
|
||||
async onTotpSubmit(code) {
|
||||
if (!pendingTotpPartialToken) {
|
||||
log.error("TOTP submit without pending partial token");
|
||||
return;
|
||||
}
|
||||
const result = await api.verifyTotp(code, pendingTotpPartialToken);
|
||||
if (result.token) {
|
||||
const savedPassword = connectPage.getRememberPassword() ? connectPage.getPassword() : undefined;
|
||||
ensureProfileExists(pendingTotpHost, pendingTotpUsername);
|
||||
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword);
|
||||
}
|
||||
},
|
||||
onAddProfile(name, host) {
|
||||
profileManager.addProfile({
|
||||
name,
|
||||
host,
|
||||
username: "",
|
||||
autoConnect: false,
|
||||
rememberPassword: false,
|
||||
color: "#5865F2",
|
||||
});
|
||||
void profileManager.saveProfiles();
|
||||
connectPage.refreshProfiles(getProfileList());
|
||||
// Check health for the new profile
|
||||
runHealthChecks(connectPage, getProfileList());
|
||||
},
|
||||
onDeleteProfile(profileId) {
|
||||
profileManager.removeProfile(profileId);
|
||||
void profileManager.saveProfiles();
|
||||
connectPage.refreshProfiles(getProfileList());
|
||||
},
|
||||
}, getProfileList());
|
||||
|
||||
safeMount(connectPage, appEl!);
|
||||
currentPage = connectPage;
|
||||
|
||||
// Load saved profiles and kick off health checks
|
||||
void (async () => {
|
||||
try {
|
||||
await profileManager.loadProfiles();
|
||||
const profiles = getProfileList();
|
||||
connectPage.refreshProfiles(profiles);
|
||||
runHealthChecks(connectPage, profiles);
|
||||
} catch (err) {
|
||||
log.warn("Failed to load profiles, using defaults", err);
|
||||
runHealthChecks(connectPage, getProfileList());
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
const mainPage = createMainPage({ ws, api });
|
||||
safeMount(mainPage, appEl!);
|
||||
currentPage = mainPage;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for navigation changes
|
||||
router.onNavigate(renderPage);
|
||||
|
||||
// Handle logout / disconnect
|
||||
authStore.subscribe((state) => {
|
||||
if (!state.isAuthenticated && router.getCurrentPage() === "main") {
|
||||
// Leave voice channel before disconnecting so other clients see it immediately
|
||||
const voice = voiceStore.getState();
|
||||
if (voice.currentChannelId !== null) {
|
||||
voiceSessionLeave(false); // false: we send voice_leave below
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
leaveVoiceChannel();
|
||||
}
|
||||
dispatcherCleanup?.();
|
||||
dispatcherCleanup = null;
|
||||
ws.disconnect();
|
||||
// Clear stored credential on logout
|
||||
const host = api.getConfig().host;
|
||||
if (host) {
|
||||
void deleteCredential(host);
|
||||
}
|
||||
router.navigate("connect");
|
||||
}
|
||||
});
|
||||
|
||||
// Send voice_leave on window close (best-effort — server readPump defer is the safety net)
|
||||
window.addEventListener("beforeunload", () => {
|
||||
const voice = voiceStore.getState();
|
||||
if (voice.currentChannelId !== null) {
|
||||
voiceSessionLeave(false); // false: we send voice_leave below
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
}
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderPage(router.getCurrentPage());
|
||||
|
||||
// Initialize window state persistence (fire-and-forget)
|
||||
void initWindowState();
|
||||
|
||||
log.info("OwnCord client initialized");
|
||||
@@ -0,0 +1,838 @@
|
||||
// ConnectPage — login/register page component.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import {
|
||||
createElement,
|
||||
setText,
|
||||
appendChildren,
|
||||
clearChildren,
|
||||
qs,
|
||||
} from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store";
|
||||
import { createSettingsOverlay } from "@components/SettingsOverlay";
|
||||
import type { HealthStatus, ServerProfile } from "@lib/profiles";
|
||||
import { loadCredential } from "@lib/credentials";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Form state machine states. */
|
||||
export type FormState = "idle" | "loading" | "totp" | "connecting" | "error";
|
||||
|
||||
/** Form mode: login or register. */
|
||||
export type FormMode = "login" | "register";
|
||||
|
||||
/** Callbacks for external wiring (API integration added later). */
|
||||
export interface ConnectPageCallbacks {
|
||||
onLogin(host: string, username: string, password: string): Promise<void>;
|
||||
onRegister(
|
||||
host: string,
|
||||
username: string,
|
||||
password: string,
|
||||
inviteCode: string,
|
||||
): Promise<void>;
|
||||
onTotpSubmit(code: string): Promise<void>;
|
||||
onAddProfile?(name: string, host: string): void;
|
||||
onDeleteProfile?(profileId: string): void;
|
||||
}
|
||||
|
||||
/** Minimal profile shape for the default profile list (backward compat). */
|
||||
export interface SimpleProfile {
|
||||
readonly name: string;
|
||||
readonly host: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
const DEFAULT_PROFILES: readonly SimpleProfile[] = [
|
||||
{ name: "Local Server", host: "localhost:8443" },
|
||||
];
|
||||
|
||||
/** Color palette for server icons. */
|
||||
const ICON_COLORS = [
|
||||
"#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245",
|
||||
"#3BA55D", "#FAA61A", "#5865F2",
|
||||
];
|
||||
|
||||
function getIconColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length] ?? "#5865f2";
|
||||
}
|
||||
|
||||
function getIconInitials(name: string): string {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConnectPage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createConnectPage(
|
||||
callbacks: ConnectPageCallbacks,
|
||||
initialProfiles: readonly SimpleProfile[] = DEFAULT_PROFILES,
|
||||
): MountableComponent & {
|
||||
showTotp(): void;
|
||||
showConnecting(): void;
|
||||
showError(message: string): void;
|
||||
resetToIdle(): void;
|
||||
updateHealthStatus(host: string, status: HealthStatus): void;
|
||||
getRememberPassword(): boolean;
|
||||
getPassword(): string;
|
||||
/** Re-render the server profile list with updated data. */
|
||||
refreshProfiles(profiles: readonly SimpleProfile[]): void;
|
||||
} {
|
||||
// --- internal state (mutable, local to this instance) ---
|
||||
let formState: FormState = "idle";
|
||||
let formMode: FormMode = "login";
|
||||
let errorMessage = "";
|
||||
let container: Element | null = null;
|
||||
|
||||
// Cleanup tracking
|
||||
const abortController = new AbortController();
|
||||
|
||||
// --- cached DOM references (set during build) ---
|
||||
let root: HTMLDivElement;
|
||||
let serverListEl: HTMLDivElement;
|
||||
let formTitle: HTMLHeadingElement;
|
||||
let hostInput: HTMLInputElement;
|
||||
let usernameInput: HTMLInputElement;
|
||||
let passwordInput: HTMLInputElement;
|
||||
let inviteGroup: HTMLDivElement;
|
||||
let inviteInput: HTMLInputElement;
|
||||
let submitBtn: HTMLButtonElement;
|
||||
let submitBtnText: HTMLSpanElement;
|
||||
let toggleModeBtn: HTMLAnchorElement;
|
||||
let errorBanner: HTMLDivElement;
|
||||
let totpOverlay: HTMLDivElement;
|
||||
let totpInput: HTMLInputElement;
|
||||
let totpSubmitBtn: HTMLButtonElement;
|
||||
let rememberPasswordCheckbox: HTMLInputElement;
|
||||
let statusBar: HTMLDivElement;
|
||||
let statusBarFill: HTMLDivElement;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRoot(): HTMLDivElement {
|
||||
root = createElement("div", { class: "connect-page" });
|
||||
|
||||
const leftPanel = buildServerPanel();
|
||||
const rightPanel = buildFormPanel();
|
||||
|
||||
appendChildren(root, leftPanel, rightPanel);
|
||||
|
||||
// Status bar at bottom (hidden by default, shown with .visible class)
|
||||
statusBar = createElement("div", { class: "status-bar" });
|
||||
statusBarFill = createElement("div", { class: "status-bar-fill" });
|
||||
statusBar.appendChild(statusBarFill);
|
||||
root.appendChild(statusBar);
|
||||
|
||||
// TOTP overlay (hidden by default)
|
||||
totpOverlay = buildTotpOverlay();
|
||||
root.appendChild(totpOverlay);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildServerPanel(): HTMLDivElement {
|
||||
const panel = createElement("div", { class: "server-panel" });
|
||||
|
||||
const header = createElement("div", { class: "server-panel-header" });
|
||||
const heading = createElement("h2", {}, "Servers");
|
||||
header.appendChild(heading);
|
||||
|
||||
serverListEl = createElement("div", { class: "server-list" });
|
||||
|
||||
renderServerProfiles(initialProfiles);
|
||||
|
||||
// Footer with "Add Server" button
|
||||
const footer = createElement("div", { class: "server-panel-footer" });
|
||||
const addBtn = createElement("button", {
|
||||
class: "btn-add-server",
|
||||
type: "button",
|
||||
});
|
||||
setText(addBtn, "+ Add Server");
|
||||
addBtn.addEventListener("click", handleAddServer, { signal: abortController.signal });
|
||||
footer.appendChild(addBtn);
|
||||
|
||||
appendChildren(panel, header, serverListEl, footer);
|
||||
return panel;
|
||||
}
|
||||
|
||||
// Map of host -> DOM elements for health status updates
|
||||
const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>();
|
||||
|
||||
function renderServerProfiles(profiles: readonly SimpleProfile[]): void {
|
||||
clearChildren(serverListEl);
|
||||
healthElements.clear();
|
||||
for (const profile of profiles) {
|
||||
const item = createElement("div", {
|
||||
class: "server-item",
|
||||
"data-host": profile.host,
|
||||
});
|
||||
|
||||
const icon = createElement("div", {
|
||||
class: "srv-icon",
|
||||
style: `background:${getIconColor(profile.name)}`,
|
||||
});
|
||||
setText(icon, getIconInitials(profile.name));
|
||||
|
||||
// Health status dot on the icon
|
||||
const statusDot = createElement("div", { class: "srv-status-dot unknown" });
|
||||
icon.appendChild(statusDot);
|
||||
|
||||
const info = createElement("div", { class: "srv-info" });
|
||||
const name = createElement("div", { class: "srv-name" }, profile.name);
|
||||
const meta = createElement("div", { class: "srv-meta" });
|
||||
const host = createElement("span", { class: "srv-host" }, profile.host);
|
||||
const latency = createElement("span", { class: "srv-latency" });
|
||||
appendChildren(meta, host, latency);
|
||||
|
||||
// Show username if available (full profile has it)
|
||||
const fullProfile = profile as Partial<ServerProfile>;
|
||||
if (fullProfile.username) {
|
||||
const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username);
|
||||
appendChildren(meta, usernameEl);
|
||||
}
|
||||
|
||||
appendChildren(info, name, meta);
|
||||
|
||||
healthElements.set(profile.host, { dot: statusDot, latency });
|
||||
|
||||
// Delete button (only for full profiles that have an id)
|
||||
const actions = createElement("div", { class: "srv-actions" });
|
||||
if (fullProfile.id && callbacks.onDeleteProfile) {
|
||||
const deleteBtn = createElement("button", {
|
||||
class: "srv-btn danger",
|
||||
type: "button",
|
||||
"aria-label": "Delete server",
|
||||
});
|
||||
setText(deleteBtn, "\u2715");
|
||||
deleteBtn.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
callbacks.onDeleteProfile!(fullProfile.id!);
|
||||
},
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
actions.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
appendChildren(item, icon, info, actions);
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
hostInput.value = profile.host;
|
||||
// Auto-fill username from profile
|
||||
if (fullProfile.username) {
|
||||
usernameInput.value = fullProfile.username;
|
||||
}
|
||||
// Auto-fill credentials from credential store
|
||||
const requestedHost = profile.host;
|
||||
void (async () => {
|
||||
const cred = await loadCredential(requestedHost);
|
||||
// Guard: user may have clicked a different profile while loading
|
||||
if (cred && hostInput.value === requestedHost) {
|
||||
usernameInput.value = cred.username;
|
||||
if (cred.password) {
|
||||
passwordInput.value = cred.password;
|
||||
rememberPasswordCheckbox.checked = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
serverListEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function updateHealthStatus(host: string, status: HealthStatus): void {
|
||||
const els = healthElements.get(host);
|
||||
if (!els) return;
|
||||
|
||||
// Update status dot
|
||||
els.dot.className = `srv-status-dot ${status.status}`;
|
||||
|
||||
// Update latency badge
|
||||
if (status.latencyMs !== null) {
|
||||
const ms = status.latencyMs;
|
||||
setText(els.latency, `${ms}ms`);
|
||||
els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`;
|
||||
} else {
|
||||
setText(els.latency, "");
|
||||
els.latency.className = "srv-latency";
|
||||
}
|
||||
}
|
||||
|
||||
function buildFormPanel(): HTMLDivElement {
|
||||
const panel = createElement("div", { class: "form-panel" });
|
||||
|
||||
// Settings gear (top right)
|
||||
const settingsBtn = createElement("button", {
|
||||
class: "settings-gear",
|
||||
type: "button",
|
||||
"aria-label": "Settings",
|
||||
});
|
||||
setText(settingsBtn, "\u2699");
|
||||
settingsBtn.addEventListener("click", () => openSettings(), { signal: abortController.signal });
|
||||
|
||||
// Form container
|
||||
const formContainer = createElement("div", { class: "form-container" });
|
||||
|
||||
// Logo section
|
||||
const formLogo = createElement("div", { class: "form-logo" });
|
||||
const logoMark = createElement("div", { class: "form-logo-mark" }, "OC");
|
||||
const logoTitle = createElement("h1", {}, "OwnCord");
|
||||
const logoSubtitle = createElement("p", {}, "Connect to your server");
|
||||
appendChildren(formLogo, logoMark, logoTitle, logoSubtitle);
|
||||
|
||||
// Form title
|
||||
formTitle = createElement("h1", {}, "Login");
|
||||
|
||||
// Error banner (hidden by default via CSS display:none, shown with .visible)
|
||||
errorBanner = createElement("div", {
|
||||
class: "error-banner",
|
||||
role: "alert",
|
||||
});
|
||||
|
||||
// Form
|
||||
const form = createElement("form", { class: "connect-form" });
|
||||
form.setAttribute("novalidate", "");
|
||||
|
||||
// Host
|
||||
const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443");
|
||||
hostInput = qs("input", hostGroup) as HTMLInputElement;
|
||||
|
||||
// Username
|
||||
const usernameGroup = buildFormGroup("username", "Username", "text", "");
|
||||
usernameInput = qs("input", usernameGroup) as HTMLInputElement;
|
||||
|
||||
// Password
|
||||
const passwordGroup = buildFormGroup("password", "Password", "password", "");
|
||||
passwordInput = qs("input", passwordGroup) as HTMLInputElement;
|
||||
|
||||
// Remember password checkbox
|
||||
const rememberGroup = createElement("div", { class: "form-group remember-password-group" });
|
||||
rememberPasswordCheckbox = createElement("input", {
|
||||
type: "checkbox",
|
||||
id: "remember-password",
|
||||
});
|
||||
const rememberLabel = createElement("label", {
|
||||
for: "remember-password",
|
||||
class: "remember-password-label",
|
||||
}, "Remember password");
|
||||
appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel);
|
||||
|
||||
// Invite code (register only, hidden by default)
|
||||
inviteGroup = buildFormGroup("invite", "Invite Code", "text", "");
|
||||
inviteGroup.classList.add("form-group--hidden");
|
||||
inviteInput = qs("input", inviteGroup) as HTMLInputElement;
|
||||
|
||||
// Submit button
|
||||
submitBtn = createElement("button", {
|
||||
class: "btn-primary",
|
||||
type: "submit",
|
||||
});
|
||||
submitBtnText = createElement("span", { class: "btn-text" }, "Login");
|
||||
const spinnerWrapper = createElement("span", { class: "btn-spinner" });
|
||||
const spinner = createElement("div", { class: "spinner" });
|
||||
spinnerWrapper.appendChild(spinner);
|
||||
appendChildren(submitBtn, spinnerWrapper, submitBtnText);
|
||||
|
||||
// Toggle mode link
|
||||
const formSwitch = createElement("div", { class: "form-switch" });
|
||||
toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement;
|
||||
formSwitch.appendChild(toggleModeBtn);
|
||||
|
||||
appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch);
|
||||
|
||||
// Wire form events
|
||||
form.addEventListener("submit", handleFormSubmit, { signal: abortController.signal });
|
||||
toggleModeBtn.addEventListener("click", handleToggleMode, { signal: abortController.signal });
|
||||
|
||||
appendChildren(formContainer, formLogo, errorBanner, form);
|
||||
appendChildren(panel, settingsBtn, formContainer);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function buildFormGroup(
|
||||
id: string,
|
||||
labelText: string,
|
||||
inputType: string,
|
||||
placeholder: string,
|
||||
): HTMLDivElement {
|
||||
const group = createElement("div", { class: "form-group" });
|
||||
const label = createElement("label", { class: "form-label", for: id }, labelText);
|
||||
const input = createElement("input", {
|
||||
class: "form-input",
|
||||
id,
|
||||
name: id,
|
||||
type: inputType,
|
||||
placeholder,
|
||||
autocomplete: inputType === "password" ? "current-password" : "off",
|
||||
});
|
||||
if (id === "host") {
|
||||
input.setAttribute("required", "");
|
||||
}
|
||||
if (id === "username" || id === "password") {
|
||||
input.setAttribute("required", "");
|
||||
}
|
||||
|
||||
if (inputType === "password") {
|
||||
const wrapper = createElement("div", { class: "password-wrapper" });
|
||||
const toggle = createElement("button", {
|
||||
class: "password-toggle",
|
||||
type: "button",
|
||||
"aria-label": "Toggle password visibility",
|
||||
}, "\uD83D\uDC41");
|
||||
toggle.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
const isPassword = input.getAttribute("type") === "password";
|
||||
input.setAttribute("type", isPassword ? "text" : "password");
|
||||
},
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
appendChildren(wrapper, input, toggle);
|
||||
appendChildren(group, label, wrapper);
|
||||
} else {
|
||||
appendChildren(group, label, input);
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
function buildTotpOverlay(): HTMLDivElement {
|
||||
const overlay = createElement("div", { class: "totp-overlay totp-overlay--hidden" });
|
||||
const card = createElement("div", { class: "totp-card" });
|
||||
const title = createElement("h2", { class: "totp-title" }, "Two-Factor Authentication");
|
||||
const description = createElement("p", {
|
||||
class: "totp-subtitle",
|
||||
}, "Enter the 6-digit code from your authenticator app.");
|
||||
|
||||
totpInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
maxlength: "6",
|
||||
placeholder: "000000",
|
||||
inputmode: "numeric",
|
||||
pattern: "[0-9]{6}",
|
||||
autocomplete: "one-time-code",
|
||||
});
|
||||
|
||||
totpSubmitBtn = createElement("button", {
|
||||
class: "btn-primary",
|
||||
type: "button",
|
||||
}, "Verify");
|
||||
|
||||
const cancelBtn = createElement("button", {
|
||||
class: "totp-back",
|
||||
type: "button",
|
||||
}, "Cancel");
|
||||
|
||||
totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal: abortController.signal });
|
||||
cancelBtn.addEventListener("click", handleTotpCancel, { signal: abortController.signal });
|
||||
|
||||
// Allow Enter key in TOTP input
|
||||
totpInput.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTotpSubmit();
|
||||
}
|
||||
},
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
appendChildren(card, title, description, totpInput, totpSubmitBtn, cancelBtn);
|
||||
overlay.appendChild(card);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add Server modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleAddServer(): void {
|
||||
if (!callbacks.onAddProfile) return;
|
||||
|
||||
const overlay = createElement("div", { class: "modal-overlay visible" });
|
||||
const modal = createElement("div", { class: "modal" });
|
||||
|
||||
const header = createElement("div", { class: "modal-header" });
|
||||
const title = createElement("h3", {}, "Add Server");
|
||||
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
|
||||
setText(closeBtn, "\u2715");
|
||||
appendChildren(header, title, closeBtn);
|
||||
|
||||
const body = createElement("div", { class: "modal-body" });
|
||||
const nameGroup = createElement("div", { class: "form-group" });
|
||||
const nameLabel = createElement("label", { class: "form-label" }, "Server Name");
|
||||
const nameInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: "My Server",
|
||||
});
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
|
||||
const hostGroup = createElement("div", { class: "form-group" });
|
||||
const hostLabel = createElement("label", { class: "form-label" }, "Host Address");
|
||||
const hostAddrInput = createElement("input", {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
placeholder: "example.com:8443",
|
||||
});
|
||||
appendChildren(hostGroup, hostLabel, hostAddrInput);
|
||||
|
||||
appendChildren(body, nameGroup, hostGroup);
|
||||
|
||||
const footer = createElement("div", { class: "modal-footer" });
|
||||
const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" });
|
||||
setText(cancelBtn, "Cancel");
|
||||
const saveBtn = createElement("button", { class: "btn-primary", type: "button" });
|
||||
setText(saveBtn, "Add Server");
|
||||
appendChildren(footer, cancelBtn, saveBtn);
|
||||
|
||||
appendChildren(modal, header, body, footer);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
function closeModal(): void {
|
||||
overlay.remove();
|
||||
}
|
||||
|
||||
function handleSave(): void {
|
||||
const name = (nameInput as HTMLInputElement).value.trim();
|
||||
const addr = (hostAddrInput as HTMLInputElement).value.trim();
|
||||
if (!name || !addr) return;
|
||||
callbacks.onAddProfile!(name, addr);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
closeBtn.addEventListener("click", closeModal, { signal: abortController.signal });
|
||||
cancelBtn.addEventListener("click", closeModal, { signal: abortController.signal });
|
||||
saveBtn.addEventListener("click", handleSave, { signal: abortController.signal });
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) closeModal();
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
// Allow backdrop stop propagation on modal body
|
||||
modal.addEventListener("click", (e) => e.stopPropagation(), { signal: abortController.signal });
|
||||
|
||||
// Enter key submits
|
||||
hostAddrInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") handleSave();
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
root.appendChild(overlay);
|
||||
(nameInput as HTMLInputElement).focus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function transitionTo(state: FormState, error?: string): void {
|
||||
formState = state;
|
||||
errorMessage = error ?? "";
|
||||
|
||||
// Update UI based on state
|
||||
updateSubmitButton();
|
||||
updateErrorBanner();
|
||||
updateStatusBar();
|
||||
updateTotpOverlay();
|
||||
updateFormInputsDisabled();
|
||||
}
|
||||
|
||||
function updateSubmitButton(): void {
|
||||
const isLoading = formState === "loading" || formState === "connecting";
|
||||
submitBtn.disabled = isLoading;
|
||||
submitBtn.classList.toggle("loading", isLoading);
|
||||
|
||||
if (formState === "connecting") {
|
||||
setText(submitBtnText, "Connecting\u2026");
|
||||
} else if (formState === "loading") {
|
||||
setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026");
|
||||
} else {
|
||||
setText(submitBtnText, formMode === "login" ? "Login" : "Register");
|
||||
}
|
||||
}
|
||||
|
||||
function updateErrorBanner(): void {
|
||||
if (formState === "error" && errorMessage) {
|
||||
setText(errorBanner, errorMessage);
|
||||
errorBanner.classList.add("visible");
|
||||
// The shakeX animation plays automatically via CSS on .error-banner
|
||||
// Re-trigger animation by removing and re-adding the element
|
||||
errorBanner.style.animation = "none";
|
||||
// Force reflow to restart animation
|
||||
void errorBanner.offsetWidth;
|
||||
errorBanner.style.animation = "";
|
||||
} else {
|
||||
errorBanner.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusBar(): void {
|
||||
switch (formState) {
|
||||
case "idle":
|
||||
statusBar.classList.remove("visible", "indeterminate");
|
||||
break;
|
||||
case "loading":
|
||||
statusBar.classList.add("visible", "indeterminate");
|
||||
break;
|
||||
case "totp":
|
||||
statusBar.classList.remove("visible", "indeterminate");
|
||||
break;
|
||||
case "connecting":
|
||||
statusBar.classList.add("visible", "indeterminate");
|
||||
break;
|
||||
case "error":
|
||||
statusBar.classList.remove("visible", "indeterminate");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTotpOverlay(): void {
|
||||
if (formState === "totp") {
|
||||
totpOverlay.classList.remove("totp-overlay--hidden");
|
||||
totpInput.value = "";
|
||||
totpInput.focus();
|
||||
} else {
|
||||
totpOverlay.classList.add("totp-overlay--hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function updateFormInputsDisabled(): void {
|
||||
const disable = formState === "loading" || formState === "connecting";
|
||||
hostInput.disabled = disable;
|
||||
usernameInput.disabled = disable;
|
||||
passwordInput.disabled = disable;
|
||||
inviteInput.disabled = disable;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleToggleMode(): void {
|
||||
formMode = formMode === "login" ? "register" : "login";
|
||||
|
||||
setText(formTitle, formMode === "login" ? "Login" : "Register");
|
||||
setText(submitBtnText, formMode === "login" ? "Login" : "Register");
|
||||
setText(
|
||||
toggleModeBtn,
|
||||
formMode === "login" ? "Need an account? Register" : "Already have an account? Login",
|
||||
);
|
||||
|
||||
inviteGroup.classList.toggle("form-group--hidden", formMode === "login");
|
||||
|
||||
// Clear any existing error
|
||||
if (formState === "error") {
|
||||
transitionTo("idle");
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
const host = hostInput.value.trim();
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
if (!host) {
|
||||
return "Server address is required.";
|
||||
}
|
||||
if (!username) {
|
||||
return "Username is required.";
|
||||
}
|
||||
if (!password) {
|
||||
return "Password is required.";
|
||||
}
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`;
|
||||
}
|
||||
if (formMode === "register") {
|
||||
const inviteCode = inviteInput.value.trim();
|
||||
if (!inviteCode) {
|
||||
return "Invite code is required for registration.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFormSubmit(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
|
||||
if (formState === "loading" || formState === "connecting") {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateForm();
|
||||
if (validationError !== null) {
|
||||
transitionTo("error", validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = hostInput.value.trim();
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
transitionTo("loading");
|
||||
|
||||
try {
|
||||
if (formMode === "login") {
|
||||
await callbacks.onLogin(host, username, password);
|
||||
} else {
|
||||
const inviteCode = inviteInput.value.trim();
|
||||
await callbacks.onRegister(host, username, password, inviteCode);
|
||||
}
|
||||
// If the callback didn't throw, the caller handles navigation.
|
||||
// The caller may also call showTotp() or showError() on this page.
|
||||
} catch (err: unknown) {
|
||||
let message: string;
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
} else if (typeof err === "string") {
|
||||
message = err;
|
||||
} else if (err !== null && typeof err === "object" && "message" in err) {
|
||||
message = String((err as { message: unknown }).message);
|
||||
} else {
|
||||
message = String(err);
|
||||
}
|
||||
transitionTo("error", message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpSubmit(): Promise<void> {
|
||||
const code = totpInput.value.trim();
|
||||
if (code.length !== 6 || !/^\d{6}$/.test(code)) {
|
||||
// Simple inline feedback — add error class to the input
|
||||
totpInput.classList.add("error");
|
||||
setTimeout(() => totpInput.classList.remove("error"), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
totpSubmitBtn.disabled = true;
|
||||
setText(totpSubmitBtn, "Verifying\u2026");
|
||||
|
||||
try {
|
||||
await callbacks.onTotpSubmit(code);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Verification failed.";
|
||||
transitionTo("error", message);
|
||||
} finally {
|
||||
totpSubmitBtn.disabled = false;
|
||||
setText(totpSubmitBtn, "Verify");
|
||||
}
|
||||
}
|
||||
|
||||
function handleTotpCancel(): void {
|
||||
transitionTo("idle");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API for external state control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Called externally when login returns requires_2fa. */
|
||||
function showTotp(): void {
|
||||
transitionTo("totp");
|
||||
}
|
||||
|
||||
/** Called externally to show a connection-in-progress state. */
|
||||
function showConnecting(): void {
|
||||
transitionTo("connecting");
|
||||
}
|
||||
|
||||
/** Called externally to display an error. */
|
||||
function showError(message: string): void {
|
||||
transitionTo("error", message);
|
||||
}
|
||||
|
||||
/** Reset form to idle state. */
|
||||
function resetToIdle(): void {
|
||||
transitionTo("idle");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MountableComponent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Settings overlay instance
|
||||
let settingsOverlay: ReturnType<typeof createSettingsOverlay> | null = null;
|
||||
|
||||
function mount(target: Element): void {
|
||||
container = target;
|
||||
const rootEl = buildRoot();
|
||||
container.appendChild(rootEl);
|
||||
|
||||
// Mount settings overlay on the connect page
|
||||
settingsOverlay = createSettingsOverlay({
|
||||
onClose: () => closeSettings(),
|
||||
onChangePassword: async () => { /* no-op on connect page */ },
|
||||
onUpdateProfile: async () => { /* no-op on connect page */ },
|
||||
onLogout: () => { /* no-op on connect page */ },
|
||||
});
|
||||
settingsOverlay.mount(rootEl);
|
||||
|
||||
// Show any pending auth error (e.g. "already connected from another client")
|
||||
const pendingError = uiStore.getState().transientError;
|
||||
if (pendingError) {
|
||||
transitionTo("error", pendingError);
|
||||
setTransientError(null);
|
||||
}
|
||||
|
||||
// Focus the first input
|
||||
hostInput.focus();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
// Abort all event listeners registered with the signal
|
||||
abortController.abort();
|
||||
settingsOverlay?.destroy?.();
|
||||
settingsOverlay = null;
|
||||
|
||||
if (container && root) {
|
||||
container.removeChild(root);
|
||||
}
|
||||
container = null;
|
||||
}
|
||||
|
||||
return {
|
||||
mount,
|
||||
destroy,
|
||||
// Extended API for external control
|
||||
showTotp,
|
||||
showConnecting,
|
||||
showError,
|
||||
resetToIdle,
|
||||
updateHealthStatus,
|
||||
/** Whether the "Remember Password" checkbox is checked. */
|
||||
getRememberPassword(): boolean {
|
||||
return rememberPasswordCheckbox?.checked ?? false;
|
||||
},
|
||||
/** Get the current password input value (for saving when remember is checked). */
|
||||
getPassword(): string {
|
||||
return passwordInput?.value ?? "";
|
||||
},
|
||||
/** Re-render the server profile list with updated data. */
|
||||
refreshProfiles(profiles: readonly SimpleProfile[]): void {
|
||||
renderServerProfiles(profiles);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ConnectPage = ReturnType<typeof createConnectPage>;
|
||||
@@ -0,0 +1,747 @@
|
||||
// MainPage — primary app layout after login.
|
||||
// Composes standalone components; never sets innerHTML with user content.
|
||||
|
||||
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { createRateLimiterSet } from "@lib/rate-limiter";
|
||||
import { createServerStrip } from "@components/ServerStrip";
|
||||
import { createChannelSidebar } from "@components/ChannelSidebar";
|
||||
import { createCreateChannelModal } from "@components/CreateChannelModal";
|
||||
import { createEditChannelModal } from "@components/EditChannelModal";
|
||||
import { createDeleteChannelModal } from "@components/DeleteChannelModal";
|
||||
import { createUserBar } from "@components/UserBar";
|
||||
import { createVoiceWidget } from "@components/VoiceWidget";
|
||||
import { createMemberList } from "@components/MemberList";
|
||||
import { createMessageList } from "@components/MessageList";
|
||||
import type { MessageListComponent } from "@components/MessageList";
|
||||
import { createMessageInput } from "@components/MessageInput";
|
||||
import type { MessageInputComponent } from "@components/MessageInput";
|
||||
import { createTypingIndicator } from "@components/TypingIndicator";
|
||||
import { createServerBanner } from "@components/ServerBanner";
|
||||
import type { ServerBannerControl } from "@components/ServerBanner";
|
||||
import { createSettingsOverlay } from "@components/SettingsOverlay";
|
||||
import { createToastContainer } from "@components/Toast";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import { authStore, clearAuth, updateUser } from "@stores/auth.store";
|
||||
import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store";
|
||||
import { channelsStore, getActiveChannel, setActiveChannel } from "@stores/channels.store";
|
||||
import {
|
||||
voiceStore,
|
||||
joinVoiceChannel,
|
||||
leaveVoiceChannel,
|
||||
setLocalCamera,
|
||||
setLocalScreenshare,
|
||||
} from "@stores/voice.store";
|
||||
import {
|
||||
joinVoice,
|
||||
leaveVoice as voiceSessionLeave,
|
||||
setMuted as voiceSessionSetMuted,
|
||||
setDeafened as voiceSessionSetDeafened,
|
||||
setWsClient,
|
||||
setOnError as setVoiceOnError,
|
||||
clearOnError as clearVoiceOnError,
|
||||
} from "@lib/voiceSession";
|
||||
import {
|
||||
setMessages,
|
||||
prependMessages,
|
||||
isChannelLoaded,
|
||||
getChannelMessages,
|
||||
} from "@stores/messages.store";
|
||||
import { buildChatHeader } from "./main-page/ChatHeader";
|
||||
import { setServerHost } from "@components/message-list/renderers";
|
||||
import {
|
||||
createQuickSwitcherManager,
|
||||
createInviteManagerController,
|
||||
createPinnedPanelController,
|
||||
} from "./main-page/OverlayManagers";
|
||||
import { createUpdateNotifier } from "@components/UpdateNotifier";
|
||||
|
||||
const log = createLogger("main-page");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MainPageOptions {
|
||||
readonly ws: WsClient;
|
||||
readonly api: ApiClient;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MainPage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
const { ws, api } = options;
|
||||
|
||||
// Let voiceSession send signaling messages over this WS connection
|
||||
setWsClient(ws);
|
||||
|
||||
// Set server host for resolving relative attachment URLs
|
||||
const apiConfig = api.getConfig();
|
||||
if (apiConfig.host) {
|
||||
setServerHost(apiConfig.host);
|
||||
}
|
||||
|
||||
const limiters = createRateLimiterSet();
|
||||
|
||||
let container: Element | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
// Child components tracked for cleanup
|
||||
const children: MountableComponent[] = [];
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
// Refs we need to update reactively
|
||||
let banner: ServerBannerControl | null = null;
|
||||
let messageList: MessageListComponent | null = null;
|
||||
let messageInput: MessageInputComponent | null = null;
|
||||
let typingIndicator: MountableComponent | null = null;
|
||||
let chatHeaderName: HTMLSpanElement | null = null;
|
||||
|
||||
// Containers for swappable sub-components
|
||||
let messagesSlot: HTMLDivElement | null = null;
|
||||
let typingSlot: HTMLDivElement | null = null;
|
||||
let inputSlot: HTMLDivElement | null = null;
|
||||
|
||||
// Track currently mounted channel to avoid redundant rebuilds
|
||||
let currentChannelId: number | null = null;
|
||||
|
||||
// Pending delete confirmations (double-click to delete pattern)
|
||||
const pendingDeletes = new Map<number, number>();
|
||||
|
||||
// Abort controller for channel-scoped async operations (e.g. message fetch)
|
||||
let channelAbort: AbortController | null = null;
|
||||
|
||||
// Toast container for user-facing error feedback
|
||||
let toast: ToastContainer | null = null;
|
||||
|
||||
// Overlay controllers — created in mount()
|
||||
let pinnedCtrl: ReturnType<typeof createPinnedPanelController> | null = null;
|
||||
let inviteCtrl: ReturnType<typeof createInviteManagerController> | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getCurrentUserId(): number {
|
||||
return authStore.getState().user?.id ?? 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message loading (REST)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadMessages(channelId: number, signal: AbortSignal): Promise<void> {
|
||||
if (isChannelLoaded(channelId)) {
|
||||
log.debug("Messages already loaded", { channelId });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await api.getMessages(channelId, { limit: 50 }, signal);
|
||||
if (!signal.aborted) {
|
||||
log.info("Messages loaded", { channelId, count: resp.messages.length, hasMore: resp.has_more });
|
||||
setMessages(channelId, resp.messages, resp.has_more);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
log.error("Failed to load messages", { channelId, error: String(err) });
|
||||
toast?.show("Failed to load messages", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlderMessages(channelId: number, signal: AbortSignal): Promise<void> {
|
||||
const messages = getChannelMessages(channelId);
|
||||
if (messages.length === 0) return;
|
||||
const oldest = messages[0];
|
||||
if (oldest === undefined) return;
|
||||
try {
|
||||
const resp = await api.getMessages(
|
||||
channelId,
|
||||
{ before: oldest.id, limit: 50 },
|
||||
signal,
|
||||
);
|
||||
if (!signal.aborted) {
|
||||
prependMessages(channelId, resp.messages, resp.has_more);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
log.error("Failed to load older messages", { channelId, error: String(err) });
|
||||
toast?.show("Failed to load older messages", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel switching — rebuild channel-dependent components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mountChannelComponents(channelId: number, channelName: string): void {
|
||||
if (currentChannelId === channelId) return;
|
||||
|
||||
destroyChannelComponents();
|
||||
currentChannelId = channelId;
|
||||
|
||||
log.info("Switching channel", { channelId, channelName });
|
||||
|
||||
// Notify server which channel we're viewing so channel-scoped
|
||||
// broadcasts (chat_message, typing, etc.) are delivered to us.
|
||||
ws.send({
|
||||
type: "channel_focus",
|
||||
payload: { channel_id: channelId },
|
||||
});
|
||||
|
||||
channelAbort = new AbortController();
|
||||
const signal = channelAbort.signal;
|
||||
const userId = getCurrentUserId();
|
||||
|
||||
void loadMessages(channelId, signal);
|
||||
|
||||
// MessageList
|
||||
messageList = createMessageList({
|
||||
channelId,
|
||||
currentUserId: userId,
|
||||
onScrollTop: () => {
|
||||
if (channelAbort !== null) {
|
||||
void loadOlderMessages(channelId, channelAbort.signal);
|
||||
}
|
||||
},
|
||||
onReplyClick: (msgId: number) => {
|
||||
const msgs = getChannelMessages(channelId);
|
||||
const msg = msgs.find((m) => m.id === msgId);
|
||||
messageInput?.setReplyTo(msgId, msg?.user.username ?? "");
|
||||
},
|
||||
onEditClick: (msgId: number) => {
|
||||
const msgs = getChannelMessages(channelId);
|
||||
const msg = msgs.find((m) => m.id === msgId);
|
||||
if (msg !== undefined) {
|
||||
messageInput?.startEdit(msgId, msg.content);
|
||||
}
|
||||
},
|
||||
onDeleteClick: (msgId: number) => {
|
||||
if (pendingDeletes.has(msgId)) {
|
||||
window.clearTimeout(pendingDeletes.get(msgId));
|
||||
pendingDeletes.delete(msgId);
|
||||
ws.send({
|
||||
type: "chat_delete",
|
||||
payload: { message_id: msgId },
|
||||
});
|
||||
toast?.show("Message deleted", "success");
|
||||
} else {
|
||||
toast?.show("Click delete again to confirm", "info");
|
||||
const tid = window.setTimeout(() => pendingDeletes.delete(msgId), 5000);
|
||||
pendingDeletes.set(msgId, tid);
|
||||
}
|
||||
},
|
||||
onReactionClick: (msgId: number, emoji: string) => {
|
||||
if (emoji === "") return;
|
||||
if (!limiters.reactions.tryConsume()) {
|
||||
toast?.show("Slow down! Please wait before reacting again.", "error");
|
||||
return;
|
||||
}
|
||||
const msgs = getChannelMessages(channelId);
|
||||
const msg = msgs.find((m) => m.id === msgId);
|
||||
const existing = msg?.reactions.find((r) => r.emoji === emoji);
|
||||
const type = existing?.me ? "reaction_remove" : "reaction_add";
|
||||
ws.send({ type, payload: { message_id: msgId, emoji } });
|
||||
},
|
||||
});
|
||||
if (messagesSlot !== null) {
|
||||
messageList.mount(messagesSlot);
|
||||
}
|
||||
children.push(messageList);
|
||||
|
||||
// TypingIndicator
|
||||
typingIndicator = createTypingIndicator({
|
||||
channelId,
|
||||
currentUserId: userId,
|
||||
});
|
||||
if (typingSlot !== null) {
|
||||
typingIndicator.mount(typingSlot);
|
||||
}
|
||||
children.push(typingIndicator);
|
||||
|
||||
// MessageInput
|
||||
messageInput = createMessageInput({
|
||||
channelId,
|
||||
channelName,
|
||||
onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => {
|
||||
if (ws.getState() !== "connected") {
|
||||
log.warn("Cannot send message: not connected");
|
||||
toast?.show("Not connected — message not sent", "error");
|
||||
return;
|
||||
}
|
||||
ws.send({
|
||||
type: "chat_send",
|
||||
payload: {
|
||||
channel_id: channelId,
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
},
|
||||
onUploadFile: async (file: File) => {
|
||||
const result = await api.uploadFile(file);
|
||||
return { id: result.id, url: result.url, filename: result.filename };
|
||||
},
|
||||
onTyping: () => {
|
||||
if (limiters.typing.tryConsume(String(channelId))) {
|
||||
ws.send({
|
||||
type: "typing_start",
|
||||
payload: { channel_id: channelId },
|
||||
});
|
||||
}
|
||||
},
|
||||
onEditMessage: (messageId: number, content: string) => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed === "") {
|
||||
toast?.show("Message cannot be empty", "error");
|
||||
return;
|
||||
}
|
||||
const msgs = getChannelMessages(channelId);
|
||||
const original = msgs.find((m) => m.id === messageId);
|
||||
if (original !== undefined && original.content === trimmed) {
|
||||
return;
|
||||
}
|
||||
ws.send({
|
||||
type: "chat_edit",
|
||||
payload: { message_id: messageId, content: trimmed },
|
||||
});
|
||||
toast?.show("Message edited", "success");
|
||||
},
|
||||
});
|
||||
if (inputSlot !== null) {
|
||||
messageInput.mount(inputSlot);
|
||||
}
|
||||
children.push(messageInput);
|
||||
|
||||
// Update header
|
||||
if (chatHeaderName !== null) {
|
||||
setText(chatHeaderName, channelName);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyChannelComponents(): void {
|
||||
for (const tid of pendingDeletes.values()) {
|
||||
window.clearTimeout(tid);
|
||||
}
|
||||
pendingDeletes.clear();
|
||||
|
||||
if (channelAbort !== null) {
|
||||
channelAbort.abort();
|
||||
channelAbort = null;
|
||||
}
|
||||
|
||||
if (messageList !== null) {
|
||||
messageList.destroy?.();
|
||||
const idx = children.indexOf(messageList);
|
||||
if (idx !== -1) children.splice(idx, 1);
|
||||
messageList = null;
|
||||
}
|
||||
if (typingIndicator !== null) {
|
||||
typingIndicator.destroy?.();
|
||||
const idx = children.indexOf(typingIndicator);
|
||||
if (idx !== -1) children.splice(idx, 1);
|
||||
typingIndicator = null;
|
||||
}
|
||||
if (messageInput !== null) {
|
||||
messageInput.destroy?.();
|
||||
const idx = children.indexOf(messageInput as MountableComponent);
|
||||
if (idx !== -1) children.splice(idx, 1);
|
||||
messageInput = null;
|
||||
}
|
||||
if (messagesSlot !== null) { clearChildren(messagesSlot); }
|
||||
if (typingSlot !== null) { clearChildren(typingSlot); }
|
||||
if (inputSlot !== null) { clearChildren(inputSlot); }
|
||||
|
||||
currentChannelId = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount / Destroy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mount(target: Element): void {
|
||||
log.info("MainPage mounting");
|
||||
container = target;
|
||||
|
||||
root = createElement("div", {
|
||||
style: "display:flex;flex-direction:column;height:100vh;width:100%",
|
||||
});
|
||||
|
||||
// --- Reconnect banner ---
|
||||
banner = createServerBanner();
|
||||
root.appendChild(banner.element);
|
||||
|
||||
unsubscribers.push(
|
||||
ws.onStateChange((wsState) => {
|
||||
if (banner === null) return;
|
||||
if (wsState === "reconnecting") {
|
||||
banner.showReconnecting();
|
||||
} else if (wsState === "connected") {
|
||||
banner.hide();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
ws.on("server_restart", (payload) => {
|
||||
if (banner !== null) {
|
||||
banner.showRestart(payload.delay_seconds);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Voice config: trigger WebRTC join flow ---
|
||||
unsubscribers.push(
|
||||
ws.on("voice_config", (payload) => {
|
||||
void joinVoice(payload.channel_id, payload, async () => {
|
||||
const creds = await api.getVoiceCredentials();
|
||||
return creds.ice_servers;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Main .app row ---
|
||||
const app = createElement("div", { class: "app", "data-testid": "app-layout" });
|
||||
|
||||
// Server strip
|
||||
const serverStripSlot = createElement("div", {});
|
||||
const serverStrip = createServerStrip();
|
||||
serverStrip.mount(serverStripSlot);
|
||||
children.push(serverStrip);
|
||||
|
||||
// Channel sidebar (composed: sidebar + voice widget + user bar)
|
||||
const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
|
||||
|
||||
const channelSidebarSlot = createElement("div", {});
|
||||
let activeModal: MountableComponent | null = null;
|
||||
|
||||
const channelSidebar = createChannelSidebar({
|
||||
onVoiceJoin: (channelId) => {
|
||||
log.info("Joining voice channel", { channelId });
|
||||
joinVoiceChannel(channelId);
|
||||
ws.send({ type: "voice_join", payload: { channel_id: channelId } });
|
||||
},
|
||||
onVoiceLeave: () => {
|
||||
log.info("Leaving voice channel");
|
||||
voiceSessionLeave(false); // false: we send voice_leave below
|
||||
leaveVoiceChannel();
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
},
|
||||
onCreateChannel: (category) => {
|
||||
if (activeModal !== null) {
|
||||
return;
|
||||
}
|
||||
const modal = createCreateChannelModal({
|
||||
category,
|
||||
onCreate: async (data) => {
|
||||
await api.adminCreateChannel(data);
|
||||
// Server broadcasts channel_create via WS — store updates automatically
|
||||
modal.destroy?.();
|
||||
activeModal = null;
|
||||
},
|
||||
onClose: () => {
|
||||
modal.destroy?.();
|
||||
activeModal = null;
|
||||
},
|
||||
});
|
||||
activeModal = modal;
|
||||
modal.mount(document.body);
|
||||
},
|
||||
onEditChannel: (channel) => {
|
||||
if (activeModal !== null) {
|
||||
return;
|
||||
}
|
||||
const modal = createEditChannelModal({
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
channelType: channel.type,
|
||||
onSave: async (data) => {
|
||||
await api.adminUpdateChannel(channel.id, data);
|
||||
// Server broadcasts channel_update via WS — store updates automatically
|
||||
modal.destroy?.();
|
||||
activeModal = null;
|
||||
},
|
||||
onClose: () => {
|
||||
modal.destroy?.();
|
||||
activeModal = null;
|
||||
},
|
||||
});
|
||||
activeModal = modal;
|
||||
modal.mount(document.body);
|
||||
},
|
||||
onDeleteChannel: (channel) => {
|
||||
if (activeModal !== null) {
|
||||
return;
|
||||
}
|
||||
const modal = createDeleteChannelModal({
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
onConfirm: async () => {
|
||||
await api.adminDeleteChannel(channel.id);
|
||||
// Server broadcasts channel_delete via WS — store updates automatically
|
||||
modal.destroy?.();
|
||||
activeModal = null;
|
||||
},
|
||||
onClose: () => {
|
||||
modal.destroy?.();
|
||||
activeModal = null;
|
||||
},
|
||||
});
|
||||
activeModal = modal;
|
||||
modal.mount(document.body);
|
||||
},
|
||||
onReorderChannel: (reorders) => {
|
||||
for (const r of reorders) {
|
||||
void api.adminUpdateChannel(r.channelId, { position: r.newPosition });
|
||||
}
|
||||
},
|
||||
});
|
||||
channelSidebar.mount(channelSidebarSlot);
|
||||
children.push(channelSidebar);
|
||||
|
||||
const mountedSidebar = channelSidebarSlot.firstElementChild;
|
||||
if (mountedSidebar !== null) {
|
||||
while (mountedSidebar.firstChild !== null) {
|
||||
sidebarWrapper.appendChild(mountedSidebar.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Invite button in sidebar header
|
||||
inviteCtrl = createInviteManagerController({
|
||||
api,
|
||||
getRoot: () => root,
|
||||
getToast: () => toast,
|
||||
});
|
||||
const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header");
|
||||
if (sidebarHeader !== null) {
|
||||
const inviteBtn = createElement("button", {
|
||||
class: "invite-btn",
|
||||
title: "Invite",
|
||||
}, "Invite");
|
||||
inviteBtn.addEventListener("click", () => {
|
||||
void inviteCtrl!.open();
|
||||
});
|
||||
sidebarHeader.appendChild(inviteBtn);
|
||||
}
|
||||
unsubscribers.push(() => { inviteCtrl?.cleanup(); });
|
||||
|
||||
// Voice widget
|
||||
const voiceWidgetSlot = createElement("div", {});
|
||||
const voiceWidget = createVoiceWidget({
|
||||
onDisconnect: () => {
|
||||
if (voiceStore.getState().currentChannelId === null) return;
|
||||
log.info("Leaving voice channel (widget disconnect)");
|
||||
voiceSessionLeave(false); // false: we send voice_leave below
|
||||
leaveVoiceChannel();
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
},
|
||||
onMuteToggle: () => {
|
||||
if (!limiters.voice.tryConsume()) return;
|
||||
const state = voiceStore.getState();
|
||||
if (state.localMuted) {
|
||||
// Unmuting: also undeafen if deafened
|
||||
voiceSessionSetMuted(false);
|
||||
ws.send({ type: "voice_mute", payload: { muted: false } });
|
||||
if (state.localDeafened) {
|
||||
voiceSessionSetDeafened(false);
|
||||
ws.send({ type: "voice_deafen", payload: { deafened: false } });
|
||||
}
|
||||
} else {
|
||||
voiceSessionSetMuted(true);
|
||||
ws.send({ type: "voice_mute", payload: { muted: true } });
|
||||
}
|
||||
},
|
||||
onDeafenToggle: () => {
|
||||
if (!limiters.voice.tryConsume()) return;
|
||||
const state = voiceStore.getState();
|
||||
if (state.localDeafened) {
|
||||
// Undeafening: also unmute mic
|
||||
voiceSessionSetDeafened(false);
|
||||
ws.send({ type: "voice_deafen", payload: { deafened: false } });
|
||||
voiceSessionSetMuted(false);
|
||||
ws.send({ type: "voice_mute", payload: { muted: false } });
|
||||
} else {
|
||||
// Deafening: also mute mic
|
||||
voiceSessionSetDeafened(true);
|
||||
ws.send({ type: "voice_deafen", payload: { deafened: true } });
|
||||
if (!state.localMuted) {
|
||||
voiceSessionSetMuted(true);
|
||||
ws.send({ type: "voice_mute", payload: { muted: true } });
|
||||
}
|
||||
}
|
||||
},
|
||||
onCameraToggle: () => {
|
||||
if (!limiters.voiceVideo.tryConsume()) return;
|
||||
const next = !voiceStore.getState().localCamera;
|
||||
setLocalCamera(next);
|
||||
ws.send({ type: "voice_camera", payload: { enabled: next } });
|
||||
},
|
||||
onScreenshareToggle: () => {
|
||||
if (!limiters.voiceVideo.tryConsume()) return;
|
||||
const next = !voiceStore.getState().localScreenshare;
|
||||
setLocalScreenshare(next);
|
||||
ws.send({ type: "voice_screenshare", payload: { enabled: next } });
|
||||
},
|
||||
});
|
||||
voiceWidget.mount(voiceWidgetSlot);
|
||||
children.push(voiceWidget);
|
||||
sidebarWrapper.appendChild(voiceWidgetSlot);
|
||||
|
||||
// User bar
|
||||
const userBarSlot = createElement("div", {});
|
||||
const userBar = createUserBar();
|
||||
userBar.mount(userBarSlot);
|
||||
children.push(userBar);
|
||||
sidebarWrapper.appendChild(userBarSlot);
|
||||
|
||||
// Chat area
|
||||
const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" });
|
||||
|
||||
pinnedCtrl = createPinnedPanelController({
|
||||
api,
|
||||
getRoot: () => root,
|
||||
getToast: () => toast,
|
||||
getCurrentChannelId: () => currentChannelId,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
if (messageList === null) return false;
|
||||
return messageList.scrollToMessage(msgId);
|
||||
},
|
||||
});
|
||||
unsubscribers.push(() => { pinnedCtrl?.cleanup(); });
|
||||
|
||||
const chatHeader = buildChatHeader({
|
||||
onTogglePins: () => { void pinnedCtrl!.toggle(); },
|
||||
onToggleMembers: () => toggleMemberList(),
|
||||
});
|
||||
chatHeaderName = chatHeader.refs.nameEl;
|
||||
chatArea.appendChild(chatHeader.element);
|
||||
|
||||
messagesSlot = createElement("div", { class: "messages-slot", "data-testid": "messages-slot" });
|
||||
typingSlot = createElement("div", { class: "typing-slot", "data-testid": "typing-slot" });
|
||||
inputSlot = createElement("div", { class: "input-slot", "data-testid": "input-slot" });
|
||||
appendChildren(chatArea, messagesSlot, typingSlot, inputSlot);
|
||||
|
||||
// Member list
|
||||
const memberListSlot = createElement("div", {});
|
||||
const memberList = createMemberList();
|
||||
memberList.mount(memberListSlot);
|
||||
children.push(memberList);
|
||||
|
||||
const memberListEl = memberListSlot.querySelector(".member-list");
|
||||
const unsubMemberList = uiStore.subscribe((state) => {
|
||||
if (memberListEl !== null) {
|
||||
memberListEl.classList.toggle("hidden", !state.memberListVisible);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsubMemberList);
|
||||
|
||||
appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot);
|
||||
root.appendChild(app);
|
||||
|
||||
// Settings overlay
|
||||
const settingsOverlay = createSettingsOverlay({
|
||||
onClose: () => closeSettings(),
|
||||
onChangePassword: async (oldPassword, newPassword) => {
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
toast?.show("Password changed successfully", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to change password";
|
||||
toast?.show(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onUpdateProfile: async (username) => {
|
||||
try {
|
||||
const updated = await api.updateProfile({ username });
|
||||
updateUser({ username: updated.username });
|
||||
toast?.show("Profile updated", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to update profile";
|
||||
toast?.show(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onLogout: () => clearAuth(),
|
||||
});
|
||||
settingsOverlay.mount(root);
|
||||
children.push(settingsOverlay);
|
||||
|
||||
// Quick switcher (Ctrl+K)
|
||||
const qsManager = createQuickSwitcherManager(() => root);
|
||||
unsubscribers.push(qsManager.attach());
|
||||
|
||||
// Toast container
|
||||
toast = createToastContainer();
|
||||
toast.mount(root);
|
||||
children.push(toast);
|
||||
|
||||
// Wire voice error callback to toast
|
||||
setVoiceOnError((msg) => toast?.show(msg, "error"));
|
||||
|
||||
// Auto-update notifier — checks server for newer client version
|
||||
if (apiConfig.host) {
|
||||
const serverUrl = `https://${apiConfig.host}`;
|
||||
const updateNotifier = createUpdateNotifier({ serverUrl });
|
||||
updateNotifier.mount(root);
|
||||
children.push(updateNotifier);
|
||||
}
|
||||
|
||||
container.appendChild(root);
|
||||
|
||||
// --- Subscribe to channel changes ---
|
||||
const unsubChannels = channelsStore.subscribe(() => {
|
||||
const active = getActiveChannel();
|
||||
if (active !== null) {
|
||||
mountChannelComponents(active.id, active.name);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsubChannels);
|
||||
|
||||
const active = getActiveChannel();
|
||||
if (active !== null) {
|
||||
mountChannelComponents(active.id, active.name);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
log.info("MainPage destroying");
|
||||
// Clean up voice session before destroying UI — prevents stale
|
||||
// module-level state persisting across logout/reconnect cycles.
|
||||
voiceSessionLeave(false);
|
||||
clearVoiceOnError();
|
||||
destroyChannelComponents();
|
||||
|
||||
for (const child of children) {
|
||||
child.destroy?.();
|
||||
}
|
||||
children.length = 0;
|
||||
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub();
|
||||
}
|
||||
unsubscribers.length = 0;
|
||||
|
||||
if (banner !== null) {
|
||||
banner.destroy();
|
||||
banner = null;
|
||||
}
|
||||
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
container = null;
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
|
||||
export type MainPage = ReturnType<typeof createMainPage>;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ChatHeader — builds the channel header bar with name, topic, pins, search,
|
||||
* and member-list toggle.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChatHeaderRefs {
|
||||
readonly nameEl: HTMLSpanElement;
|
||||
readonly topicEl: HTMLSpanElement;
|
||||
}
|
||||
|
||||
export interface ChatHeaderOptions {
|
||||
readonly onTogglePins: () => void;
|
||||
readonly onToggleMembers: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildChatHeader(
|
||||
opts: ChatHeaderOptions,
|
||||
): { element: HTMLDivElement; refs: ChatHeaderRefs } {
|
||||
const header = createElement("div", { class: "chat-header", "data-testid": "chat-header" });
|
||||
const hash = createElement("span", { class: "ch-hash" }, "#");
|
||||
const nameEl = createElement("span", { class: "ch-name", "data-testid": "chat-header-name" }, "general");
|
||||
const divider = createElement("div", { class: "ch-divider" });
|
||||
const topicEl = createElement("span", { class: "ch-topic" }, "");
|
||||
|
||||
const tools = createElement("div", { class: "ch-tools" });
|
||||
const pinBtn = createElement("button", {
|
||||
type: "button",
|
||||
class: "pin-btn",
|
||||
title: "Pins",
|
||||
"aria-label": "Pins",
|
||||
"data-testid": "pin-btn",
|
||||
}, "\uD83D\uDCCC");
|
||||
pinBtn.addEventListener("click", () => { opts.onTogglePins(); });
|
||||
const searchInput = createElement("input", {
|
||||
class: "search-input",
|
||||
type: "text",
|
||||
placeholder: "Search...",
|
||||
});
|
||||
const membersToggle = createElement("button", {
|
||||
type: "button",
|
||||
"aria-label": "Toggle member list",
|
||||
"data-testid": "members-toggle",
|
||||
}, "\uD83D\uDC65");
|
||||
membersToggle.addEventListener("click", () => opts.onToggleMembers());
|
||||
appendChildren(tools, searchInput, pinBtn, membersToggle);
|
||||
|
||||
appendChildren(header, hash, nameEl, divider, topicEl, tools);
|
||||
return { element: header, refs: { nameEl, topicEl } };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Overlay managers — quick switcher, invite manager, and pinned messages panel.
|
||||
* Each factory returns an open/toggle + cleanup pair for use in MainPage.
|
||||
*/
|
||||
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { createQuickSwitcher } from "@components/QuickSwitcher";
|
||||
import { createInviteManager } from "@components/InviteManager";
|
||||
import type { InviteItem } from "@components/InviteManager";
|
||||
import type { InviteResponse } from "@lib/types";
|
||||
import { createPinnedMessages } from "@components/PinnedMessages";
|
||||
import type { PinnedMessage } from "@components/PinnedMessages";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import { setActiveChannel } from "@stores/channels.store";
|
||||
|
||||
const log = createLogger("overlays");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite response mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mapInviteResponse(r: InviteResponse): InviteItem {
|
||||
const extra = r as unknown as Record<string, unknown>;
|
||||
const createdBy = typeof extra["created_by"] === "object"
|
||||
&& extra["created_by"] !== null
|
||||
? (extra["created_by"] as { username?: string }).username ?? "unknown"
|
||||
: "unknown";
|
||||
const uses = r.use_count
|
||||
?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0);
|
||||
return {
|
||||
code: r.code,
|
||||
createdBy,
|
||||
createdAt: r.expires_at ?? "",
|
||||
uses,
|
||||
maxUses: r.max_uses,
|
||||
expiresAt: r.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pinned message mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mapToPinnedMessage(msg: {
|
||||
readonly id: number;
|
||||
readonly user: { readonly username: string };
|
||||
readonly content: string;
|
||||
readonly created_at?: string;
|
||||
readonly timestamp?: string;
|
||||
}): PinnedMessage {
|
||||
return {
|
||||
id: msg.id,
|
||||
author: msg.user.username,
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at ?? msg.timestamp ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Switcher Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface QuickSwitcherManager {
|
||||
/** Attach Ctrl+K handler; returns cleanup function. */
|
||||
attach(): () => void;
|
||||
}
|
||||
|
||||
export function createQuickSwitcherManager(
|
||||
getRoot: () => HTMLDivElement | null,
|
||||
): QuickSwitcherManager {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
function open(): void {
|
||||
const root = getRoot();
|
||||
if (instance !== null || root === null) return;
|
||||
instance = createQuickSwitcher({
|
||||
onSelectChannel: (channelId: number) => {
|
||||
setActiveChannel(channelId);
|
||||
},
|
||||
onClose: close,
|
||||
});
|
||||
instance.mount(root);
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (instance !== null) {
|
||||
instance.destroy?.();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
function attach(): () => void {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (instance !== null) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handler);
|
||||
close();
|
||||
};
|
||||
}
|
||||
|
||||
return { attach };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite Manager Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InviteManagerController {
|
||||
open(): Promise<void>;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export function createInviteManagerController(opts: {
|
||||
readonly api: ApiClient;
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
}): InviteManagerController {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
function close(): void {
|
||||
if (instance !== null) {
|
||||
instance.destroy?.();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(): Promise<void> {
|
||||
const root = opts.getRoot();
|
||||
if (instance !== null || root === null) return;
|
||||
try {
|
||||
const raw = await opts.api.getInvites();
|
||||
const invites = raw.map(mapInviteResponse);
|
||||
instance = createInviteManager({
|
||||
invites,
|
||||
onCreateInvite: async () => {
|
||||
const created = await opts.api.createInvite({});
|
||||
return mapInviteResponse(created);
|
||||
},
|
||||
onRevokeInvite: async (code: string) => {
|
||||
try {
|
||||
const raw2 = await opts.api.getInvites();
|
||||
const match = raw2.find((i) => i.code === code);
|
||||
if (match !== undefined) {
|
||||
await opts.api.revokeInvite(match.id);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Invite revoke failed", { code, error: String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onCopyLink: (code: string) => {
|
||||
void navigator.clipboard.writeText(code);
|
||||
},
|
||||
onClose: close,
|
||||
onError: (message: string) => {
|
||||
log.error(message);
|
||||
opts.getToast()?.show(message, "error");
|
||||
},
|
||||
});
|
||||
if (root !== null) {
|
||||
instance.mount(root);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to open invite manager", { error: String(err) });
|
||||
opts.getToast()?.show("Failed to load invites", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return { open, cleanup: close };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pinned Panel Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PinnedPanelController {
|
||||
toggle(): Promise<void>;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export function createPinnedPanelController(opts: {
|
||||
readonly api: ApiClient;
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
readonly getCurrentChannelId: () => number | null;
|
||||
readonly onJumpToMessage?: (messageId: number) => boolean;
|
||||
}): PinnedPanelController {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
function close(): void {
|
||||
if (instance !== null) {
|
||||
instance.destroy?.();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(): Promise<void> {
|
||||
if (instance !== null) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const root = opts.getRoot();
|
||||
const channelId = opts.getCurrentChannelId();
|
||||
if (root === null || channelId === null) return;
|
||||
try {
|
||||
const resp = await opts.api.getPins(channelId);
|
||||
const pins = resp.messages.map(mapToPinnedMessage);
|
||||
instance = createPinnedMessages({
|
||||
channelId,
|
||||
pinnedMessages: pins,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
if (opts.onJumpToMessage !== undefined) {
|
||||
const found = opts.onJumpToMessage(msgId);
|
||||
if (found) {
|
||||
close();
|
||||
} else {
|
||||
opts.getToast()?.show("Message not in loaded window", "info");
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
onUnpin: (msgId: number) => {
|
||||
void opts.api.unpinMessage(channelId, msgId).then(() => {
|
||||
close();
|
||||
}).catch((err: unknown) => {
|
||||
log.error("Failed to unpin message", { msgId, error: String(err) });
|
||||
opts.getToast()?.show("Failed to unpin message", "error");
|
||||
});
|
||||
},
|
||||
onClose: close,
|
||||
});
|
||||
if (root !== null) {
|
||||
instance.mount(root);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to load pinned messages", { error: String(err) });
|
||||
opts.getToast()?.show("Failed to load pinned messages", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return { toggle, cleanup: close };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Auth store — holds authentication state after login/auth_ok.
|
||||
* Immutable state updates only.
|
||||
*/
|
||||
|
||||
import { createStore } from "@lib/store";
|
||||
import type { UserWithRole } from "@lib/types";
|
||||
import { resetVoiceStore } from "@stores/voice.store";
|
||||
import { leaveVoice } from "@lib/voiceSession";
|
||||
|
||||
export interface AuthState {
|
||||
readonly token: string | null;
|
||||
readonly user: UserWithRole | null;
|
||||
readonly serverName: string | null;
|
||||
readonly motd: string | null;
|
||||
readonly isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: AuthState = {
|
||||
token: null,
|
||||
user: null,
|
||||
serverName: null,
|
||||
motd: null,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
export const authStore = createStore<AuthState>(INITIAL_STATE);
|
||||
|
||||
/** Populate auth state after a successful auth_ok message. */
|
||||
export function setAuth(
|
||||
token: string,
|
||||
user: UserWithRole,
|
||||
serverName: string,
|
||||
motd: string,
|
||||
): void {
|
||||
authStore.setState(() => ({
|
||||
token,
|
||||
user,
|
||||
serverName,
|
||||
motd,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Reset auth state (logout / disconnect). Also cleans up the voice
|
||||
* session (WebRTC, AudioContext, streams) and clears voice store state.
|
||||
* Safe to call even if no voice session is active — leaveVoice is idempotent. */
|
||||
export function clearAuth(): void {
|
||||
leaveVoice(false);
|
||||
resetVoiceStore();
|
||||
authStore.setState(() => ({ ...INITIAL_STATE }));
|
||||
}
|
||||
|
||||
/** Shorthand selector for the current token. */
|
||||
export function getToken(): string | null {
|
||||
return authStore.select((s) => s.token);
|
||||
}
|
||||
|
||||
/** Update the current user fields (e.g. after profile edit). */
|
||||
export function updateUser(patch: Partial<UserWithRole>): void {
|
||||
authStore.setState((prev) => ({
|
||||
...prev,
|
||||
user: prev.user ? { ...prev.user, ...patch } : prev.user,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Shorthand selector for the current user. */
|
||||
export function getCurrentUser(): UserWithRole | null {
|
||||
return authStore.select((s) => s.user);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Channels store — holds channel list, active channel, and unread counts.
|
||||
* Immutable state updates only.
|
||||
*/
|
||||
|
||||
import { createStore } from "@lib/store";
|
||||
import type {
|
||||
ReadyChannel,
|
||||
ChannelCreatePayload,
|
||||
ChannelUpdatePayload,
|
||||
ChannelType,
|
||||
} from "@lib/types";
|
||||
|
||||
export interface Channel {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly type: ChannelType;
|
||||
readonly category: string | null;
|
||||
readonly position: number;
|
||||
readonly unreadCount: number;
|
||||
readonly lastMessageId: number | null;
|
||||
}
|
||||
|
||||
export interface ChannelsState {
|
||||
readonly channels: ReadonlyMap<number, Channel>;
|
||||
readonly activeChannelId: number | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: ChannelsState = {
|
||||
channels: new Map(),
|
||||
activeChannelId: null,
|
||||
};
|
||||
|
||||
export const channelsStore = createStore<ChannelsState>(INITIAL_STATE);
|
||||
|
||||
/** Bulk set channels from the ready payload. Converts ReadyChannel[] to Map. */
|
||||
export function setChannels(channels: readonly ReadyChannel[]): void {
|
||||
const map = new Map<number, Channel>();
|
||||
for (const ch of channels) {
|
||||
map.set(ch.id, {
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
category: ch.category,
|
||||
position: ch.position,
|
||||
unreadCount: ch.unread_count ?? 0,
|
||||
lastMessageId: ch.last_message_id ?? null,
|
||||
});
|
||||
}
|
||||
channelsStore.setState((prev) => ({
|
||||
...prev,
|
||||
channels: map,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Add a single channel from a channel_create event. */
|
||||
export function addChannel(channel: ChannelCreatePayload): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const next = new Map(prev.channels);
|
||||
next.set(channel.id, {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
category: channel.category,
|
||||
position: channel.position,
|
||||
unreadCount: 0,
|
||||
lastMessageId: null,
|
||||
});
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a channel's name and/or position immutably. */
|
||||
export function updateChannel(update: ChannelUpdatePayload): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const existing = prev.channels.get(update.id);
|
||||
if (existing === undefined) {
|
||||
return prev;
|
||||
}
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
...(update.name !== undefined ? { name: update.name } : {}),
|
||||
...(update.position !== undefined ? { position: update.position } : {}),
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(update.id, updated);
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a single channel's position immutably. */
|
||||
export function updateChannelPosition(id: number, position: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const existing = prev.channels.get(id);
|
||||
if (existing === undefined || existing.position === position) {
|
||||
return prev;
|
||||
}
|
||||
const updated: Channel = { ...existing, position };
|
||||
const next = new Map(prev.channels);
|
||||
next.set(id, updated);
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a channel. Clears activeChannelId if it was the removed channel. */
|
||||
export function removeChannel(id: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const next = new Map(prev.channels);
|
||||
next.delete(id);
|
||||
return {
|
||||
...prev,
|
||||
channels: next,
|
||||
activeChannelId: prev.activeChannelId === id ? null : prev.activeChannelId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the active channel by id (or null to deselect). Clears unread count for the activated channel. */
|
||||
export function setActiveChannel(id: number | null): void {
|
||||
channelsStore.setState((prev) => {
|
||||
if (id === null) {
|
||||
return { ...prev, activeChannelId: null };
|
||||
}
|
||||
const existing = prev.channels.get(id);
|
||||
if (existing === undefined || existing.unreadCount === 0) {
|
||||
return { ...prev, activeChannelId: id };
|
||||
}
|
||||
const updated: Channel = { ...existing, unreadCount: 0 };
|
||||
const next = new Map(prev.channels);
|
||||
next.set(id, updated);
|
||||
return { ...prev, activeChannelId: id, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the currently active Channel object, or null. */
|
||||
export function getActiveChannel(): Channel | null {
|
||||
return channelsStore.select((s) => {
|
||||
if (s.activeChannelId === null) {
|
||||
return null;
|
||||
}
|
||||
return s.channels.get(s.activeChannelId) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Group channels by category, sorted by position within each group. */
|
||||
export function getChannelsByCategory(): Map<string | null, Channel[]> {
|
||||
return channelsStore.select((s) => {
|
||||
const grouped = new Map<string | null, Channel[]>();
|
||||
for (const channel of s.channels.values()) {
|
||||
const existing = grouped.get(channel.category);
|
||||
if (existing !== undefined) {
|
||||
existing.push(channel);
|
||||
} else {
|
||||
grouped.set(channel.category, [channel]);
|
||||
}
|
||||
}
|
||||
for (const channels of grouped.values()) {
|
||||
channels.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
}
|
||||
|
||||
/** Increment unread count for a channel, unless it is the active channel. */
|
||||
export function incrementUnread(channelId: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
if (prev.activeChannelId === channelId) {
|
||||
return prev;
|
||||
}
|
||||
const existing = prev.channels.get(channelId);
|
||||
if (existing === undefined) {
|
||||
return prev;
|
||||
}
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
unreadCount: existing.unreadCount + 1,
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(channelId, updated);
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear unread count for a channel. */
|
||||
export function clearUnread(channelId: number): void {
|
||||
channelsStore.setState((prev) => {
|
||||
const existing = prev.channels.get(channelId);
|
||||
if (existing === undefined) {
|
||||
return prev;
|
||||
}
|
||||
const updated: Channel = {
|
||||
...existing,
|
||||
unreadCount: 0,
|
||||
};
|
||||
const next = new Map(prev.channels);
|
||||
next.set(channelId, updated);
|
||||
return { ...prev, channels: next };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Members store — holds all server members, presence, and typing state.
|
||||
* Immutable state updates only.
|
||||
*/
|
||||
|
||||
import { createStore } from "@lib/store";
|
||||
import type {
|
||||
ReadyMember,
|
||||
MemberJoinPayload,
|
||||
UserStatus,
|
||||
} from "@lib/types";
|
||||
|
||||
export interface Member {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly avatar: string | null;
|
||||
readonly role: string;
|
||||
readonly status: UserStatus;
|
||||
}
|
||||
|
||||
export interface MembersState {
|
||||
readonly members: ReadonlyMap<number, Member>;
|
||||
readonly typingUsers: ReadonlyMap<number, ReadonlySet<number>>; // channelId -> Set<userId>
|
||||
}
|
||||
|
||||
const INITIAL_STATE: MembersState = {
|
||||
members: new Map(),
|
||||
typingUsers: new Map(),
|
||||
};
|
||||
|
||||
export const membersStore = createStore<MembersState>(INITIAL_STATE);
|
||||
|
||||
/** Track active typing timeouts so they can be cleared. */
|
||||
const typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function typingKey(channelId: number, userId: number): string {
|
||||
return `${channelId}:${userId}`;
|
||||
}
|
||||
|
||||
/** Bulk set members from the ready payload. */
|
||||
export function setMembers(members: readonly ReadyMember[]): void {
|
||||
const map = new Map<number, Member>();
|
||||
for (const m of members) {
|
||||
map.set(m.id, {
|
||||
id: m.id,
|
||||
username: m.username,
|
||||
avatar: m.avatar,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
});
|
||||
}
|
||||
membersStore.setState((prev) => ({
|
||||
...prev,
|
||||
members: map,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Add a member from a member_join event. */
|
||||
export function addMember(payload: MemberJoinPayload): void {
|
||||
membersStore.setState((prev) => {
|
||||
const next = new Map(prev.members);
|
||||
next.set(payload.user.id, {
|
||||
id: payload.user.id,
|
||||
username: payload.user.username,
|
||||
avatar: payload.user.avatar,
|
||||
role: payload.user.role,
|
||||
status: "online" as UserStatus,
|
||||
});
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a member from a member_leave event. */
|
||||
export function removeMember(userId: number): void {
|
||||
membersStore.setState((prev) => {
|
||||
const next = new Map(prev.members);
|
||||
next.delete(userId);
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a member's role from a member_update event. */
|
||||
export function updateMemberRole(userId: number, role: string): void {
|
||||
membersStore.setState((prev) => {
|
||||
const existing = prev.members.get(userId);
|
||||
if (!existing) return prev;
|
||||
const next = new Map(prev.members);
|
||||
next.set(userId, { ...existing, role });
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a member's presence status. */
|
||||
export function updatePresence(userId: number, status: UserStatus): void {
|
||||
membersStore.setState((prev) => {
|
||||
const existing = prev.members.get(userId);
|
||||
if (!existing) return prev;
|
||||
const next = new Map(prev.members);
|
||||
next.set(userId, { ...existing, status });
|
||||
return { ...prev, members: next };
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a user as typing in a channel. Auto-clears after 5 seconds. */
|
||||
export function setTyping(channelId: number, userId: number): void {
|
||||
const key = typingKey(channelId, userId);
|
||||
|
||||
// Clear any existing timer for this user+channel
|
||||
const existing = typingTimers.get(key);
|
||||
if (existing !== undefined) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
membersStore.setState((prev) => {
|
||||
const nextTyping = new Map(prev.typingUsers);
|
||||
const channelSet = prev.typingUsers.get(channelId);
|
||||
const nextSet = new Set(channelSet ?? []);
|
||||
nextSet.add(userId);
|
||||
nextTyping.set(channelId, nextSet);
|
||||
return { ...prev, typingUsers: nextTyping };
|
||||
});
|
||||
|
||||
// Auto-clear after 5 seconds
|
||||
const timer = setTimeout(() => {
|
||||
typingTimers.delete(key);
|
||||
clearTyping(channelId, userId);
|
||||
}, 5000);
|
||||
typingTimers.set(key, timer);
|
||||
}
|
||||
|
||||
/** Remove a user from the typing set for a channel. */
|
||||
export function clearTyping(channelId: number, userId: number): void {
|
||||
const key = typingKey(channelId, userId);
|
||||
const existing = typingTimers.get(key);
|
||||
if (existing !== undefined) {
|
||||
clearTimeout(existing);
|
||||
typingTimers.delete(key);
|
||||
}
|
||||
|
||||
membersStore.setState((prev) => {
|
||||
const channelSet = prev.typingUsers.get(channelId);
|
||||
if (!channelSet || !channelSet.has(userId)) return prev;
|
||||
|
||||
const nextTyping = new Map(prev.typingUsers);
|
||||
const nextSet = new Set(channelSet);
|
||||
nextSet.delete(userId);
|
||||
|
||||
if (nextSet.size === 0) {
|
||||
nextTyping.delete(channelId);
|
||||
} else {
|
||||
nextTyping.set(channelId, nextSet);
|
||||
}
|
||||
|
||||
return { ...prev, typingUsers: nextTyping };
|
||||
});
|
||||
}
|
||||
|
||||
/** Selector: members where status is not "offline". */
|
||||
export function getOnlineMembers(): readonly Member[] {
|
||||
return membersStore.select((s) => {
|
||||
const result: Member[] = [];
|
||||
for (const member of s.members.values()) {
|
||||
if (member.status !== "offline") {
|
||||
result.push(member);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Selector: array of Member objects currently typing in a channel. */
|
||||
export function getTypingUsers(channelId: number): readonly Member[] {
|
||||
return membersStore.select((s) => {
|
||||
const userIds = s.typingUsers.get(channelId);
|
||||
if (!userIds || userIds.size === 0) return [];
|
||||
|
||||
const result: Member[] = [];
|
||||
for (const userId of userIds) {
|
||||
const member = s.members.get(userId);
|
||||
if (member) {
|
||||
result.push(member);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user