diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70a0bb21..c0ea4991 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -278,6 +278,48 @@ jobs: Client/tauri-client/test-results/ retention-days: 7 + # Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering + # the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW + # gate, group DMs, role change, custom-emoji autocomplete, voice moderation). + # These are new and authored green, so unlike the full legacy suite above they + # gate PRs: a regression on one of these features must fail CI. Kept as its own + # job (not folded into the non-blocking suite) so the legacy suite can keep + # earning its "few green pushes" before it too graduates to blocking. + client-e2e-parity: + name: Client E2E (parity subset, blocking) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: Client/tauri-client/ + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: Client/tauri-client/package-lock.json + + - name: Install npm dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run parity e2e specs + run: npx playwright test --config=playwright.config.ts --grep "@parity" + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report-parity + path: | + Client/tauri-client/playwright-report/ + Client/tauri-client/test-results/ + retention-days: 7 + # Image build is verification only, so it is skipped on dev to keep day-to-day # work on the fast check suite. Runs for main pushes and PRs targeting main. server-docker-build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c325e27..5ce6b717 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,6 +135,29 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run tauri build -- --bundles appimage,deb + # linuxdeploy bundles the runner's libwayland-* into the AppImage, which + # breaks Mesa EGL init on newer hosts (white window on Arch/Fedora — + # EGL_BAD_PARAMETER). Strip them and regenerate the updater artifact + + # signatures for the patched image. + - name: Strip host-incompatible libs from AppImage and re-sign + working-directory: Client/tauri-client + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + BUNDLE_DIR="src-tauri/target/release/bundle/appimage" + APPIMAGE=$(find "$BUNDLE_DIR" -name "*.AppImage" ! -name "*.sig" | head -1) + bash scripts/strip-appimage-bundled-libs.sh "$APPIMAGE" + TARBALL="$APPIMAGE.tar.gz" + rm -f "$TARBALL" "$APPIMAGE.sig" "$TARBALL.sig" + tar czf "$TARBALL" -C "$(dirname "$APPIMAGE")" "$(basename "$APPIMAGE")" + KEY_PATH=$(mktemp) + printf '%s' "$TAURI_SIGNING_PRIVATE_KEY" > "$KEY_PATH" + trap 'rm -f "$KEY_PATH"' EXIT + npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$APPIMAGE" + npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$TARBALL" + - name: Stage Linux release assets shell: bash run: | @@ -266,6 +289,26 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run tauri build -- --bundles appimage,deb + # Same strip + re-sign as the x86_64 job — see the comment there. + - name: Strip host-incompatible libs from AppImage and re-sign + working-directory: Client/tauri-client + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + BUNDLE_DIR="src-tauri/target/release/bundle/appimage" + APPIMAGE=$(find "$BUNDLE_DIR" -name "*.AppImage" ! -name "*.sig" | head -1) + bash scripts/strip-appimage-bundled-libs.sh "$APPIMAGE" + TARBALL="$APPIMAGE.tar.gz" + rm -f "$TARBALL" "$APPIMAGE.sig" "$TARBALL.sig" + tar czf "$TARBALL" -C "$(dirname "$APPIMAGE")" "$(basename "$APPIMAGE")" + KEY_PATH=$(mktemp) + printf '%s' "$TAURI_SIGNING_PRIVATE_KEY" > "$KEY_PATH" + trap 'rm -f "$KEY_PATH"' EXIT + npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$APPIMAGE" + npx tauri signer sign -f "$KEY_PATH" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$TARBALL" + - name: Stage Linux ARM64 release assets shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 855e26d9..68227642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,118 @@ tooling (`npm run changelog`) auto-generates entries from commit messages on each release; this file is the curated counterpart that calls out behavioural changes operators must know about. -## Unreleased — v1.1.0-alpha series (Phase B + C) +## v1.2.0-alpha.1 — Discord feature parity > **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is -> superseded; versioning continues forward as `v1.1.0-alpha.N` so deployed -> servers and clients keep receiving updates. Releases are published to this +> superseded; versioning continues forward from `v1.1.0-alpha.N` so deployed +> servers and clients keep receiving updates. This release bumps the minor to +> `v1.2.0-alpha.1` to mark a large feature drop. Releases are published to this > repository's [Releases](https://github.com/J3vb/OwnCord/releases) page, > including a full source snapshot with every release. +This release closes most of the feature gap against basic Discord (see +[docs/plans/discord-parity.md](docs/plans/discord-parity.md) for the full +gap analysis and per-item detail). The work landed as six phases plus a +pre-release security and performance review. + +### Messaging & mentions + +- **Real mentions.** `@username` is now resolved server-side against unique + usernames (address-shaped text like `mail@example` is rejected), stored per + message, and carried on the wire — so a mention notifies, highlights the + message, and drives a red per-channel mention badge distinct from the plain + unread count. `@everyone` / `@here` are gated on a new `MENTION_EVERYONE` + permission (`@here` skips offline and invisible users). `#channel` names + render as clickable navigation chips, and the composer gains an `@` + autocomplete. +- **Markdown rendering.** Messages render Discord-flavoured markdown — bold, + italic, underline, strikethrough, spoilers, block quotes, headings, lists, + masked links (`http(s)` only), and fenced code blocks with a language tag + and lightweight syntax highlighting. Rendering is a strict DOM builder with + no `innerHTML`. `Ctrl+B/I/U` wrap the selection in the composer. +- **Custom emoji.** Server emoji can be uploaded and managed (admin panel, + `MANAGE_SERVER`); `:shortcode:` renders inline in messages (jumbo when a + message is emoji-only), appears in the picker and a `:`-autocomplete, and can + be used as a reaction. +- **Message navigation.** Search results, pinned messages, reply previews, and + message permalinks (`owncord://message/…`, copyable from the hover bar) all + jump to the target — fetching a window around it when it is not loaded, with + a "Jump to Present" affordance. Reactions show a who-reacted tooltip on + hover, video and audio attachments get inline players, and a "NEW" divider + plus explicit Mark as Read / Mark All as Read round out read state. +- **Bulk delete.** `POST /channels/{id}/messages/purge` soft-deletes the newest + N messages (`MANAGE_MESSAGES`), broadcasting one `chat_bulk_deleted` event. + +### Roles, permissions & moderation + +- **Role management.** Roles are now first-class: create, edit, delete, reorder, + and edit permission masks and colours from the admin panel, all gated on + `MANAGE_ROLES` and bounded by the actor's own position (you cannot touch a + role at or above your rank, nor grant a permission bit your own role lacks). +- **The permission bits are live.** The six previously-decorative bits + (`MANAGE_CHANNELS`, `KICK_MEMBERS`, `MUTE_MEMBERS`, `MANAGE_ROLES`, + `MANAGE_SERVER`, `VIEW_AUDIT_LOG`) are now enforced per admin route group, so + a Moderator role can actually moderate without being a full Administrator. +- **Per-user channel overrides.** Channel permissions resolve in Discord's + order — base role → role override → user override — with a tri-state override + matrix editor (role or user) in the admin panel. +- **Voice moderation.** Holders of `MUTE_MEMBERS` can server-mute, server-deafen, + move, or disconnect a lower-ranked user; a server mute is enforced at the SFU. +- **Channel management from the desktop client.** Topics render and are editable, + plus slowmode, an NSFW flag (with a per-session age gate), and voice + user/video limits. Categories are now free text (any type under any name). + +### Social & profiles + +- **Profiles.** Avatar uploads (replacing letter-initials everywhere), display + names (with the `@username` handle preserved for mentions), an about/bio, and + a custom status line. +- **Presence.** Invisible is now a real status that never leaks to other users + and survives a reconnect (the previous flash-online-on-connect bug is fixed); + a 10-minute auto-idle that never overrides a manual status. +- **Group DMs** (2–10 participants, name, leave), **DM calls** with ringing + (Call button + incoming-call banner over the existing DM voice path), and + **per-channel notification mutes** (mentions still notify; other noise is + silenced). +- **Quick wins from phase 1.** Block/unblock from the member menu, temporary + bans, server-driven role colours, a mounted profile popup, and archived + channels that actually hide. + +### Security & performance review (pre-release) + +- Channel-override endpoints now enforce grantability: a `MANAGE_CHANNELS` + holder cannot grant itself or a user a permission bit its own role lacks, + closing a privilege-escalation path. +- DM voice events (`voice_state`/`voice_leave`) are delivered only to the DM's + participants instead of every user with base `READ_MESSAGES`. +- Voice moderation cannot reach a private DM call the actor is not part of. +- Mention-count bookkeeping is batched (one writer exec per 500 readers instead + of one per reader) and resolved against a set; the markdown parser's + bracket matching is amortized-linear; video/audio attachment blobs are + LRU-capped and revoked, and cleared on logout. + +### Test hardening (pre-release) + +The hostile-input surface is now covered by Go native fuzzers and +client-side property tests (mention/emoji parsing, FTS query sanitizing, +permission resolution, markdown tokenizing, filename/path sanitizing, +content sanitizing, credential validation, avatar URLs, LiveKit webhook +identities), which found and fixed two real bugs: + +- **Zero-dimension images are rejected.** A GIF decoding to height 0, and a + VP8 keyframe with an all-zero size field, both passed the image size guard + as "small". `imageDimensions` now rejects non-positive dimensions centrally. +- **Upload filenames stay safe basenames.** `/` survived sanitizing verbatim + (`filepath.Base("/")` is `"/"`), and over-length names were truncated + mid-rune into invalid UTF-8. Both are fixed at the sanitizer. + +Also added: a full migration-chain and pre-parity (019) upgrade round-trip +test, a protocol-schema/generated-constant drift test, a 200-client hub +load/soak test with `goleak` verification, and a blocking `@parity` +Playwright job covering the new parity features. Separately, a test-quality +audit rewired tests that asserted nothing (or a tautology) to assert their +claimed behaviour — no product code changed and no assertion weakened. + ### Phase B — Acceleration - **Event persistence layer (Step 7).** A new `events` table backs the @@ -134,6 +238,18 @@ behavioural changes operators must know about. - **Plugin admin endpoints require admin session auth in addition to the existing IP restriction.** A previous prerelease shipped with only the IP gate; that has been corrected. +- **The parity work adds nine database migrations (`020`–`028`) that apply + automatically on first boot.** They add the `message_mentions`, + `channel_user_overrides`, and emoji-supporting tables/columns, per-user + profile fields (`display_name`, `about`, `custom_status`), channel flags + (`nsfw`, `is_group`), and the `server_muted`/`server_deafened` voice-state + columns; a migration also seeds the new `MENTION_EVERYONE` permission bit + into the Owner/Admin/Moderator roles. No manual step is required, but take a + backup before upgrading as usual. The release also introduces new WebSocket + message types (`roles_update`, `emoji_update`, `chat_bulk_deleted`, + `voice_mod_*`, `voice_moved`, `voice_disconnected`, `mark_read`, + `call_ring`/`call_incoming`/`call_decline`); older clients ignore unknown + types, and older servers omit the new fields (the client fails safe). ### Deferred work diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index a486d67b..71d70d25 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "owncord-client", - "version": "1.1.0-alpha.5", + "version": "1.2.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "owncord-client", - "version": "1.1.0-alpha.5", + "version": "1.2.0-alpha.1", "dependencies": { "@jitsi/rnnoise-wasm": "^0.2.1", "@tauri-apps/api": "^2.10.1", @@ -31,6 +31,7 @@ "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3", "eslint": "^10.8.0", + "fast-check": "^4.9.0", "jsdom": "^29.1.1", "knip": "^6.1.1", "oxlint": "^1.76.0", @@ -5028,6 +5029,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6554,6 +6578,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index 21a9fd9a..d52d7ac6 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -1,7 +1,7 @@ { "name": "owncord-client", "private": true, - "version": "1.1.0-alpha.5", + "version": "1.2.0-alpha.1", "type": "module", "scripts": { "dev": "vite", @@ -40,6 +40,7 @@ "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3", "eslint": "^10.8.0", + "fast-check": "^4.9.0", "jsdom": "^29.1.1", "knip": "^6.1.1", "oxlint": "^1.76.0", diff --git a/Client/tauri-client/scripts/strip-appimage-bundled-libs.sh b/Client/tauri-client/scripts/strip-appimage-bundled-libs.sh new file mode 100755 index 00000000..ce5fea96 --- /dev/null +++ b/Client/tauri-client/scripts/strip-appimage-bundled-libs.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Strip host-incompatible libraries from a Tauri-built AppImage. +# +# linuxdeploy bundles the build host's (Ubuntu 22.04) libwayland-* into the +# AppImage and AppRun forces them onto LD_LIBRARY_PATH. Newer hosts' Mesa +# dlopens libwayland-client during EGL init — picking up the stale bundled +# copy makes eglGetDisplay fail (EGL_BAD_PARAMETER) and WebKit aborts, +# leaving a white window. Every supported distro ships libwayland >= the +# 1.20 the client links against, so the host copy is always the right one. +# Verified 2026-07-31: stock alpha.5 AppImage white-screens on Arch; the +# same image with these libs removed renders normally on Arch and Ubuntu. +# +# Usage: strip-appimage-bundled-libs.sh +# Rewrites the AppImage in place (same filename). Signatures and updater +# tar.gz artifacts must be regenerated afterwards by the caller. +set -euo pipefail + +APPIMAGE_PATH="${1:?usage: $0 }" +APPIMAGE_PATH="$(readlink -f "$APPIMAGE_PATH")" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +ARCH="$(uname -m)" +APPIMAGETOOL="$WORKDIR/appimagetool" +curl -fsSL -o "$APPIMAGETOOL" \ + "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage" +chmod +x "$APPIMAGETOOL" + +cd "$WORKDIR" +"$APPIMAGE_PATH" --appimage-extract > /dev/null + +removed=0 +for lib in squashfs-root/usr/lib/libwayland-*.so*; do + [ -e "$lib" ] || continue + echo "removing bundled $(basename "$lib")" + rm -f "$lib" + removed=$((removed + 1)) +done +if [ "$removed" -eq 0 ]; then + echo "::warning::no bundled libwayland-* found in $APPIMAGE_PATH — linuxdeploy may have stopped bundling it; strip step is now a no-op" + exit 0 +fi + +# --appimage-extract-and-run: run without FUSE (CI containers/runners). +# ARCH is required when repacking on a host arch that differs from the +# payload naming; here it always matches the runner. +ARCH="$ARCH" "$APPIMAGETOOL" --appimage-extract-and-run --no-appstream \ + squashfs-root "$WORKDIR/repacked.AppImage" +mv "$WORKDIR/repacked.AppImage" "$APPIMAGE_PATH" +echo "stripped $removed bundled wayland libs from $(basename "$APPIMAGE_PATH")" diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index 6fbf3776..e09c557e 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "owncord-client" -version = "1.1.0-alpha.5" +version = "1.2.0-alpha.1" dependencies = [ "base64 0.22.1", "device_query", @@ -3306,6 +3306,7 @@ dependencies = [ "tokio-rustls", "tokio-tungstenite", "url", + "webkit2gtk", "webpki-roots 1.0.9", "windows 0.58.0", "windows-sys 0.60.2", diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 7697b180..3d001158 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owncord-client" -version = "1.1.0-alpha.5" +version = "1.2.0-alpha.1" edition = "2021" # Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate # cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver @@ -117,3 +117,11 @@ windows-sys = { version = "0.60", features = [ [target.'cfg(target_os = "linux")'.dependencies] device_query = "2" +# Direct access to the WebKitGTK webview for voice/video support. WebKitGTK +# denies getUserMedia/enumerateDevices permission requests by default (wry +# installs no handler on Linux, unlike its macOS backend which auto-grants), +# and ships with media-stream/WebRTC settings off — so microphones and cameras +# are invisible to the webview without this hook. Version-pinned to match +# wry's own `=2.0.2` pin so both link the same crate build; v2_38 gates the +# enable-webrtc setting. +webkit2gtk = { version = "=2.0.2", features = ["v2_38"] } diff --git a/Client/tauri-client/src-tauri/src/constants.rs b/Client/tauri-client/src-tauri/src/constants.rs index 6e7496cd..265f2563 100644 --- a/Client/tauri-client/src-tauri/src/constants.rs +++ b/Client/tauri-client/src-tauri/src/constants.rs @@ -8,6 +8,16 @@ pub const IDENTITY_PINS_STORE: &str = "identity_pins.json"; pub const SETTINGS_STORE: &str = "settings.json"; /// Tauri store file for the degraded-mode credential fallback (see -/// `secret_store`). Values are DPAPI ciphertext, never plaintext, and the file -/// only exists on a machine whose OS credential store failed a round-trip. +/// `secret_store`). Values are ciphertext (DPAPI on Windows, ChaCha20-Poly1305 +/// elsewhere), never plaintext, and the file only exists on a machine whose OS +/// credential store failed a round-trip. pub const CREDENTIAL_FALLBACK_STORE: &str = "credential_fallback.json"; + +/// Per-install key that seals the non-Windows credential fallback entries +/// (see `fallback_crypto`). Written once, owner-only (0600). +/// +/// Gated to match its only consumer: `fallback_crypto` is `cfg(not(windows))` +/// because Windows seals fallback entries with DPAPI instead, so on Windows +/// this constant would be dead code and `-D warnings` fails the build. +#[cfg(not(windows))] +pub const CREDENTIAL_FALLBACK_KEY_FILE: &str = "credential_fallback.key"; diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index 0281f33b..7f578694 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -161,9 +161,10 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> { /// Save the long-term identity private key for `host`. /// /// The write is read back before this returns. A machine whose credential store -/// accepts writes without keeping them falls through to the DPAPI file; if that -/// is also unavailable this returns an error rather than reporting a success -/// that would leave peers rejecting the user's voice announce after a restart. +/// accepts writes without keeping them falls through to the encrypted fallback +/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also +/// unavailable this returns an error rather than reporting a success that would +/// leave peers rejecting the user's voice announce after a restart. #[tauri::command] pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> { require_non_empty(&host, "host")?; diff --git a/Client/tauri-client/src-tauri/src/fallback_crypto.rs b/Client/tauri-client/src-tauri/src/fallback_crypto.rs new file mode 100644 index 00000000..69b1ba77 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/fallback_crypto.rs @@ -0,0 +1,235 @@ +//! Encryption for the non-Windows credential fallback file. +//! +//! Windows parks fallback secrets behind DPAPI, whose key lives with the OS. +//! macOS and Linux have no DPAPI equivalent that works while the Keychain / +//! Secret Service itself is the thing that failed, so this module seals +//! secrets with ChaCha20-Poly1305 (via `ring`, already in the tree) under a +//! per-install random key stored next to the app data (mode 0600). +//! +//! This is damage control, not a vault: an attacker who can read both the key +//! file and the fallback store as this user has the secrets, exactly as they +//! would with DPAPI under the same user account. What it buys is (a) secrets +//! at rest are never plaintext, (b) a copied fallback store is useless without +//! the key file beside it, and (c) an entry cannot be moved between accounts +//! — the account name is bound in as AEAD associated data, mirroring the DPAPI +//! entropy on Windows. The OS credential store always remains the primary +//! store; this file only ever holds entries whose keychain write failed a +//! verified round-trip (see `secret_store`). + +use std::fs; +use std::io::Write; +use std::path::Path; + +use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305, NONCE_LEN}; +use ring::rand::{SecureRandom, SystemRandom}; + +use crate::constants::CREDENTIAL_FALLBACK_KEY_FILE; + +/// Size of the sealing key in bytes (ChaCha20-Poly1305). +pub const KEY_LEN: usize = 32; + +/// Load the per-install sealing key from `dir`, creating it on first use. +/// +/// The key file is written with owner-only permissions (0600) and never +/// rewritten once it exists — losing it orphans every sealed entry, which the +/// caller treats the same as an absent entry. +pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> { + let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE); + + match fs::read(&path) { + Ok(bytes) => { + let key: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| { + format!( + "credential fallback key file has {} bytes, expected {KEY_LEN} — \ + refusing to use it", + bytes.len() + ) + })?; + return Ok(key); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("failed to read credential fallback key: {e}")), + } + + let mut key = [0u8; KEY_LEN]; + SystemRandom::new() + .fill(&mut key) + .map_err(|_| "system RNG failed generating the fallback key".to_string())?; + + fs::create_dir_all(dir) + .map_err(|e| format!("failed to create app data dir for fallback key: {e}"))?; + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(mut file) => { + file.write_all(&key) + .and_then(|()| file.sync_all()) + .map_err(|e| format!("failed to write credential fallback key: {e}"))?; + Ok(key) + } + // Lost the create race to another thread — use the winner's key. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let bytes = fs::read(&path) + .map_err(|e| format!("failed to re-read credential fallback key: {e}"))?; + bytes + .as_slice() + .try_into() + .map_err(|_| "concurrently written fallback key has the wrong size".to_string()) + } + Err(e) => Err(format!("failed to create credential fallback key: {e}")), + } +} + +/// Seal `plaintext` under `key`, binding `aad` (the service + account name). +/// +/// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random +/// per call; at the fallback store's write volume (a handful per login) the +/// birthday bound on 96-bit nonces is not a concern. +pub fn protect(key: &[u8; KEY_LEN], plaintext: &[u8], aad: &[u8]) -> Result, String> { + let unbound = UnboundKey::new(&CHACHA20_POLY1305, key) + .map_err(|_| "failed to build the fallback sealing key".to_string())?; + let sealing = LessSafeKey::new(unbound); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + SystemRandom::new() + .fill(&mut nonce_bytes) + .map_err(|_| "system RNG failed generating a nonce".to_string())?; + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + + let mut in_out = plaintext.to_vec(); + sealing + .seal_in_place_append_tag(nonce, Aad::from(aad), &mut in_out) + .map_err(|_| "sealing the fallback entry failed".to_string())?; + + let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len()); + blob.extend_from_slice(&nonce_bytes); + blob.append(&mut in_out); + Ok(blob) +} + +/// Open a blob produced by [`protect`]. Fails on tampering, a wrong key, or a +/// blob moved to a different account's slot (AAD mismatch). +pub fn unprotect(key: &[u8; KEY_LEN], blob: &[u8], aad: &[u8]) -> Result, String> { + if blob.len() < NONCE_LEN + CHACHA20_POLY1305.tag_len() { + return Err("fallback entry is too short to be a sealed blob".to_string()); + } + let unbound = UnboundKey::new(&CHACHA20_POLY1305, key) + .map_err(|_| "failed to build the fallback sealing key".to_string())?; + let opening = LessSafeKey::new(unbound); + + let nonce_bytes: [u8; NONCE_LEN] = blob[..NONCE_LEN].try_into().expect("length checked"); + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + + let mut in_out = blob[NONCE_LEN..].to_vec(); + let plaintext = opening + .open_in_place(nonce, Aad::from(aad), &mut in_out) + .map_err(|_| { + "fallback entry failed authentication — wrong key, tampered data, or an entry \ + moved between accounts" + .to_string() + })?; + Ok(plaintext.to_vec()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> [u8; KEY_LEN] { + let mut key = [0u8; KEY_LEN]; + SystemRandom::new().fill(&mut key).unwrap(); + key + } + + #[test] + fn round_trips_a_secret() { + let key = test_key(); + let blob = protect(&key, b"hunter2", b"aad").unwrap(); + assert_ne!(&blob[NONCE_LEN..], b"hunter2", "blob must not be plaintext"); + assert_eq!(unprotect(&key, &blob, b"aad").unwrap(), b"hunter2"); + } + + #[test] + fn rejects_a_foreign_aad() { + // A blob moved to another account's slot must not decrypt — the same + // property dpapi_entropy provides on Windows. + let key = test_key(); + let blob = protect(&key, b"secret", b"com.owncord.client\x01a.example").unwrap(); + assert!(unprotect(&key, &blob, b"com.owncord.client\x01b.example").is_err()); + } + + #[test] + fn rejects_a_wrong_key_and_tampering() { + let key = test_key(); + let blob = protect(&key, b"secret", b"aad").unwrap(); + + let other = test_key(); + assert!(unprotect(&other, &blob, b"aad").is_err()); + + let mut tampered = blob.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0x01; + assert!(unprotect(&key, &tampered, b"aad").is_err()); + + assert!(unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(), "truncated blob"); + } + + #[test] + fn nonces_are_unique_per_seal() { + let key = test_key(); + let a = protect(&key, b"same", b"aad").unwrap(); + let b = protect(&key, b"same", b"aad").unwrap(); + assert_ne!(a, b, "two seals of the same plaintext must differ"); + } + + #[test] + fn creates_and_reuses_the_key_file() { + let dir = std::env::temp_dir().join(format!( + "owncord-fallback-key-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + + let first = load_or_create_key(&dir).unwrap(); + let second = load_or_create_key(&dir).unwrap(); + assert_eq!(first, second, "the key must be stable across loads"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(dir.join(CREDENTIAL_FALLBACK_KEY_FILE)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "key file must be owner-only"); + } + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_a_corrupt_key_file() { + let dir = std::env::temp_dir().join(format!( + "owncord-fallback-badkey-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(CREDENTIAL_FALLBACK_KEY_FILE), b"short").unwrap(); + + let err = load_or_create_key(&dir).unwrap_err(); + assert!(err.contains("expected 32"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 8ccb4ec5..56d4cf01 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -3,7 +3,11 @@ mod constants; mod credentials; #[cfg(windows)] mod dpapi; +#[cfg(not(windows))] +mod fallback_crypto; mod http_proxy; +#[cfg(target_os = "linux")] +mod linux_media; mod livekit_proxy; mod ptt; mod secret_store; @@ -135,6 +139,10 @@ pub fn run() { // persistent store, every later credential symptom follows from it. secret_store::log_compiled_backend(); tray::create_tray(app.handle())?; + // WebKitGTK denies mic/camera access by default — grant it so + // voice/video works on Linux (no-op elsewhere; see linux_media). + #[cfg(target_os = "linux")] + linux_media::enable_media_capture(app.handle()); Ok(()) }) .build(tauri::generate_context!()) diff --git a/Client/tauri-client/src-tauri/src/linux_media.rs b/Client/tauri-client/src-tauri/src/linux_media.rs new file mode 100644 index 00000000..444ca5f0 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/linux_media.rs @@ -0,0 +1,54 @@ +//! Linux-only WebKitGTK media capture support. +//! +//! On Windows and macOS the webview grants media capture itself (wry's +//! WKWebView delegate auto-grants; WebView2 prompts). WebKitGTK does +//! neither: `enable-media-stream` and `enable-webrtc` default to off, and +//! any `permission-request` signal without a handler is denied. The result +//! is that `navigator.mediaDevices.getUserMedia` fails and +//! `enumerateDevices` returns nothing — no microphones or cameras are ever +//! detected on Linux without this hook. +//! +//! Only media-related permission requests are granted here; everything else +//! (geolocation, web notifications, …) falls through to WebKit's default +//! deny so this hook does not widen the webview's surface beyond capture. + +use tauri::{AppHandle, Manager}; + +/// Enable media streams / WebRTC on the main window's WebKitGTK webview and +/// auto-grant its microphone/camera permission requests. +pub fn enable_media_capture(app: &AppHandle) { + let Some(window) = app.get_webview_window("main") else { + log::error!("linux_media: main window not found; media capture stays unavailable"); + return; + }; + let result = window.with_webview(|webview| { + use webkit2gtk::glib::prelude::Cast; + use webkit2gtk::{ + DeviceInfoPermissionRequest, PermissionRequestExt, SettingsExt, + UserMediaPermissionRequest, WebViewExt, + }; + + let webview = webview.inner(); + if let Some(settings) = webview.settings() { + settings.set_enable_media_stream(true); + settings.set_enable_webrtc(true); + } else { + log::error!("linux_media: webview has no settings object"); + } + webview.connect_permission_request(|_, request| { + // UserMediaPermissionRequest covers getUserMedia (mic/camera); + // DeviceInfoPermissionRequest covers enumerateDevices labels. + let is_media = request.downcast_ref::().is_some() + || request.downcast_ref::().is_some(); + if is_media { + request.allow(); + return true; + } + // Unhandled — WebKit applies its default (deny). + false + }); + }); + if let Err(e) = result { + log::error!("linux_media: failed to configure webview media capture: {e}"); + } +} diff --git a/Client/tauri-client/src-tauri/src/secret_store.rs b/Client/tauri-client/src-tauri/src/secret_store.rs index feb2f7c4..6d7453fb 100644 --- a/Client/tauri-client/src-tauri/src/secret_store.rs +++ b/Client/tauri-client/src-tauri/src/secret_store.rs @@ -32,15 +32,20 @@ //! //! The keychain is the right store; the fallback is damage control, not a //! default. It engages only after a write has been proven not to round-trip, -//! and only on Windows, where DPAPI can protect the file at rest with a -//! user-scoped key. On macOS and Linux a failing Keychain / Secret Service is -//! reported as an error rather than silently downgraded to a file — writing a -//! login password or an identity private key to plaintext disk there would be a -//! worse outcome than not persisting it. +//! on every desktop platform. On Windows the fallback file is protected by +//! DPAPI (user-scoped, key held by the OS). On macOS and Linux — where the +//! thing that failed *is* the OS secret store, so no OS-held key is available +//! — entries are sealed with ChaCha20-Poly1305 under a per-install random key +//! file (owner-only, see [`crate::fallback_crypto`]). That is honest +//! damage-control, not a vault: same-user malware can read both files, exactly +//! as it could call DPAPI. What it fixes is the real-world failure this module +//! kept hitting — a Linux desktop with no Secret Service provider (no +//! gnome-keyring / KWallet) or a locked macOS Keychain previously had nowhere +//! to save at all, so credentials and the voice-E2EE identity key silently +//! never survived a restart. Secrets at rest are never plaintext, and the OS +//! credential store always wins again the moment it starts round-tripping. use serde::Serialize; -// Only the DPAPI fallback stores JSON values, and that is Windows-only. -#[cfg(windows)] use serde_json::Value; use tauri::AppHandle; use tauri_plugin_store::StoreExt; @@ -59,11 +64,24 @@ pub const SERVICE: &str = "com.owncord.client"; pub enum Backend { /// The OS credential store. The expected answer on every healthy machine. Keyring, - /// DPAPI-protected file under the app data dir, used only after the OS - /// credential store accepted a write and then failed to return it. + /// DPAPI-protected file under the app data dir (Windows), used only after + /// the OS credential store accepted a write and then failed to return it. + // Constructed only on its own platform; both variants exist everywhere so + // the serialized Backend union is identical across OS builds. + #[cfg_attr(not(windows), allow(dead_code))] DpapiFile, + /// ChaCha20-Poly1305-sealed file under the app data dir (macOS/Linux), + /// engaged under the same failed-round-trip condition as `DpapiFile`. + #[cfg_attr(windows, allow(dead_code))] + EncryptedFile, } +/// The fallback backend this platform's build parks degraded secrets in. +#[cfg(windows)] +const FALLBACK_BACKEND: Backend = Backend::DpapiFile; +#[cfg(not(windows))] +const FALLBACK_BACKEND: Backend = Backend::EncryptedFile; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -114,10 +132,10 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result Result<(), String> { } // --------------------------------------------------------------------------- -// Degraded-mode fallback (Windows only, DPAPI-protected) +// Degraded-mode fallback (all desktop platforms; sealing differs per OS) // --------------------------------------------------------------------------- -/// Entropy bound into the DPAPI blob for `account`. +/// Associated data bound into the sealed blob for `account` (the DPAPI +/// "entropy" on Windows, the AEAD AAD elsewhere). /// /// Including the service and account means a ciphertext lifted from one entry /// cannot be pasted over another and still decrypt — the identity key for one /// host cannot be made to load as another's. -#[cfg(windows)] -fn dpapi_entropy(account: &str) -> Vec { +fn fallback_aad(account: &str) -> Vec { format!("{SERVICE}\u{1}{account}").into_bytes() } +/// Seal `secret` for the fallback store. Windows: DPAPI (user-scoped, OS-held +/// key). Elsewhere: ChaCha20-Poly1305 under the per-install key file. #[cfg(windows)] +fn protect_secret(_app: &AppHandle, account: &str, secret: &str) -> Result, String> { + crate::dpapi::protect(secret.as_bytes(), &fallback_aad(account)) + .map_err(|code| format!("DPAPI protect failed (Win32 error {code})")) +} + +#[cfg(not(windows))] +fn protect_secret(app: &AppHandle, account: &str, secret: &str) -> Result, String> { + use tauri::Manager; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("cannot resolve the app data dir for the fallback key: {e}"))?; + let key = crate::fallback_crypto::load_or_create_key(&dir)?; + crate::fallback_crypto::protect(&key, secret.as_bytes(), &fallback_aad(account)) +} + +/// Open a blob written by [`protect_secret`]. Errors are logged by the caller. +#[cfg(windows)] +fn unprotect_secret(_app: &AppHandle, account: &str, blob: &[u8]) -> Result, String> { + crate::dpapi::unprotect(blob, &fallback_aad(account)).map_err(|code| { + format!( + "DPAPI unprotect failed (Win32 error {code}) — the entry was written by a \ + different Windows user or on a different machine" + ) + }) +} + +#[cfg(not(windows))] +fn unprotect_secret(app: &AppHandle, account: &str, blob: &[u8]) -> Result, String> { + use tauri::Manager; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("cannot resolve the app data dir for the fallback key: {e}"))?; + let key = crate::fallback_crypto::load_or_create_key(&dir)?; + crate::fallback_crypto::unprotect(&key, blob, &fallback_aad(account)) +} + fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), String> { use base64::Engine as _; - let blob = crate::dpapi::protect(secret.as_bytes(), &dpapi_entropy(account)) - .map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))?; + let blob = protect_secret(app, account, secret)?; let encoded = base64::engine::general_purpose::STANDARD.encode(blob); let store = app @@ -258,20 +315,6 @@ fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), Stri Ok(()) } -#[cfg(not(windows))] -fn set_fallback(_app: &AppHandle, account: &str, _secret: &str) -> Result<(), String> { - // Deliberately no file fallback here: see the module header. The Keychain - // and Secret Service are the right stores on these platforms, and a - // plaintext file holding a login password or an identity private key is a - // worse outcome than failing to persist. - Err(format!( - "the OS credential store did not accept '{account}' and there is no fallback store on \ - this platform — check that the Keychain (macOS) or a Secret Service provider such as \ - gnome-keyring / KWallet (Linux) is running and unlocked" - )) -} - -#[cfg(windows)] fn get_fallback(app: &AppHandle, account: &str) -> Option { use base64::Engine as _; @@ -287,22 +330,14 @@ fn get_fallback(app: &AppHandle, account: &str) -> Option { .decode(encoded) .map_err(|e| log::warn!("credential fallback entry for '{account}' is not base64: {e}")) .ok()?; - let plaintext = crate::dpapi::unprotect(&blob, &dpapi_entropy(account)) - .map_err(|code| { - log::warn!("DPAPI unprotect failed for '{account}' (Win32 error {code}) — the entry \ - was written by a different Windows user or on a different machine") - }) + let plaintext = unprotect_secret(app, account, &blob) + .map_err(|e| log::warn!("credential fallback entry for '{account}' did not open: {e}")) .ok()?; String::from_utf8(plaintext) .map_err(|_| log::warn!("credential fallback entry for '{account}' is not valid UTF-8")) .ok() } -#[cfg(not(windows))] -fn get_fallback(_app: &AppHandle, _account: &str) -> Option { - None -} - /// Drop any fallback copy of `account`. Best-effort: a failure here is logged, /// never propagated, because it must not mask the outcome of the real store. fn clear_fallback(app: &AppHandle, account: &str) { @@ -353,9 +388,9 @@ mod tests { } /// Pins the IPC wire format to the variant names, which is what - /// `tauri-typegen` emits into `generated/types.ts` as - /// `type Backend = "Keyring" | "DpapiFile"`. Renaming a variant, or adding - /// a serde rename, desyncs the generated union from the runtime value. + /// `tauri-typegen` emits into `generated/types.ts`. Renaming a variant, or + /// adding a serde rename, desyncs the generated union from the runtime + /// value. #[test] fn backend_serializes_as_its_variant_name() { assert_eq!( @@ -366,26 +401,29 @@ mod tests { serde_json::to_string(&Backend::DpapiFile).unwrap(), "\"DpapiFile\"" ); + assert_eq!( + serde_json::to_string(&Backend::EncryptedFile).unwrap(), + "\"EncryptedFile\"" + ); } - #[cfg(windows)] #[test] - fn dpapi_entropy_is_account_specific() { - assert_ne!(dpapi_entropy("host.example"), dpapi_entropy("identity:host.example")); - assert_eq!(dpapi_entropy("host.example"), dpapi_entropy("host.example")); + fn fallback_aad_is_account_specific() { + assert_ne!(fallback_aad("host.example"), fallback_aad("identity:host.example")); + assert_eq!(fallback_aad("host.example"), fallback_aad("host.example")); } #[cfg(windows)] #[test] fn dpapi_round_trips_and_rejects_foreign_entropy() { let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0"; - let blob = crate::dpapi::protect(secret, &dpapi_entropy("identity:a.example")).unwrap(); + let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap(); assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext"); - let back = crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:a.example")).unwrap(); + let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap(); assert_eq!(back, secret); // A blob moved to another account's slot must not decrypt. - assert!(crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:b.example")).is_err()); + assert!(crate::dpapi::unprotect(&blob, &fallback_aad("identity:b.example")).is_err()); } } diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index d1d19905..926263cb 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "OwnCord", - "version": "1.1.0-alpha.5", + "version": "1.2.0-alpha.1", "identifier": "com.owncord.client", "build": { "frontendDist": "../dist", diff --git a/Client/tauri-client/src/components/AdminActions.ts b/Client/tauri-client/src/components/AdminActions.ts index 2a6b5985..82ebe222 100644 --- a/Client/tauri-client/src/components/AdminActions.ts +++ b/Client/tauri-client/src/components/AdminActions.ts @@ -1,9 +1,10 @@ /** * AdminActions — context menu helpers for admin operations on members and channels. - * Provides confirmation steps for destructive actions (kick, ban, delete). + * Provides confirmation steps for destructive actions (force logout, ban, delete). */ import { createElement, appendChildren, setText } from "@lib/dom"; +import { appendPurgeSection } from "./purge-prompt"; // --------------------------------------------------------------------------- // Types @@ -14,18 +15,51 @@ export interface MemberContextMenuOptions { username: string; currentRole: string; availableRoles: readonly string[]; + /** When false, only the non-admin actions (block/unblock) are rendered. */ + showAdminActions: boolean; + /** + * Per-action gates, each defaulting to `showAdminActions`. They mirror the + * server's KICK_MEMBERS / BAN_MEMBERS / MANAGE_ROLES bits so a moderator + * sees only the actions its role actually holds. canKick gates "Force + * Logout" — the KICK_MEMBERS bit buys session revocation, not removal. + */ + canKick?: boolean; + canBan?: boolean; + canManageRoles?: boolean; + /** Whether the local user currently blocks this member (labels the toggle). */ + isBlocked: boolean; + onToggleBlock(): Promise; + /** Revokes every session the target holds (the "Force Logout" item). */ onKick(): Promise; - /** The reason is stored and displayed by the server; empty means "no reason given". */ - onBan(reason: string): Promise; + /** + * The reason is stored and displayed by the server; empty means "no reason + * given". durationHours 0 = permanent, otherwise the ban auto-expires. + */ + onBan(reason: string, durationHours: number): Promise; onChangeRole(newRole: string): Promise; } +/** Ban duration choices offered in the ban flow (label → hours; 0 = permanent). */ +const BAN_DURATIONS: readonly { readonly label: string; readonly hours: number }[] = [ + { label: "Forever", hours: 0 }, + { label: "1 hour", hours: 1 }, + { label: "1 day", hours: 24 }, + { label: "7 days", hours: 24 * 7 }, + { label: "30 days", hours: 24 * 30 }, +] as const; + export interface ChannelContextMenuOptions { channelId: number; channelName: string; onEdit(): void; onDelete(): Promise; onCreate(): void; + /** + * Bulk-delete the newest `count` messages. Omitted when the local user's + * role lacks MANAGE_MESSAGES — the section is then not rendered at all, + * mirroring the server's gate. + */ + onPurge?(count: number): Promise; } interface ContextMenuResult { @@ -60,7 +94,7 @@ const CONFIRM_TIMEOUT_MS = 4000; * * The armed state auto-disarms after a few seconds so a menu left open doesn't * turn a stray second click into a ban, and the item shows progress while the - * request is running — a slow kick used to look like nothing happened. + * request is running — a slow force logout used to look like nothing happened. */ function withConfirmation( item: HTMLDivElement, @@ -130,66 +164,155 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont const ac = new AbortController(); const menu = createElement("div", { class: "context-menu" }); - // Role submenu trigger - const roleItem = createElement( + // Block / Unblock — available to every member, not just admins. Blocking is + // disruptive (kills DMs both ways) so it confirms; unblocking is one click. + const blockItem = createElement( "div", { - class: "context-menu__item", + class: options.isBlocked + ? "context-menu__item" + : "context-menu__item context-menu__item--danger", + "data-testid": "block-toggle", }, - "Change Role", + options.isBlocked ? "Unblock" : "Block", ); - - 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); - } + if (options.isBlocked) { + let unblockRunning = false; + blockItem.addEventListener( + "click", + (e) => { + e.stopPropagation(); + if (unblockRunning) return; + unblockRunning = true; + setText(blockItem, "Unblocking..."); + blockItem.classList.add("context-menu__item--pending"); + const done = (): void => { + unblockRunning = false; + blockItem.classList.remove("context-menu__item--pending"); + setText(blockItem, "Unblock"); + }; + void options.onToggleBlock().then(done, done); }, + { signal: ac.signal }, + ); + } else { + withConfirmation( + blockItem, + "Are you sure?", + () => options.onToggleBlock(), ac.signal, + "Blocking...", ); - roleSub.appendChild(roleOption); } - roleItem.addEventListener( - "mouseenter", - () => { - roleSub.style.display = ""; - }, - { signal: ac.signal }, - ); - roleItem.addEventListener( - "mouseleave", - () => { - roleSub.style.display = "none"; - }, - { signal: ac.signal }, - ); + const canManageRoles = options.canManageRoles ?? options.showAdminActions; + const canKick = options.canKick ?? options.showAdminActions; + const canBan = options.canBan ?? options.showAdminActions; - roleSub.style.display = "none"; - appendChildren(roleItem, roleSub); - menu.appendChild(roleItem); + if (!options.showAdminActions || (!canManageRoles && !canKick && !canBan)) { + menu.appendChild(blockItem); + return { + element: menu, + destroy(): void { + ac.abort(); + menu.remove(); + }, + }; + } + + // Role submenu trigger + if (canManageRoles) { + 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()); + } + + // Force Logout with confirmation. Named for what it does: the server revokes + // the target's sessions (KICK_MEMBERS), it does not remove a membership — + // there is no membership model — so the user can sign straight back in. + if (canKick) { + const kickItem = createElement( + "div", + { + class: "context-menu__item context-menu__item--danger", + "data-testid": "force-logout", + }, + "Force Logout", + ); + withConfirmation( + kickItem, + "Log them out?", + () => options.onKick(), + ac.signal, + "Logging out...", + ); + menu.appendChild(kickItem); + } + + if (canBan) appendBanFlow(menu, options, ac.signal); menu.appendChild(createSeparator()); + menu.appendChild(blockItem); - // Kick with confirmation - const kickItem = createElement( - "div", - { - class: "context-menu__item context-menu__item--danger", - }, - "Kick", - ); - withConfirmation(kickItem, "Are you sure?", () => options.onKick(), ac.signal, "Kicking..."); - menu.appendChild(kickItem); + function destroy(): void { + ac.abort(); + menu.remove(); + } + return { element: menu, destroy }; +} + +/** Ban entry plus its reason/duration form. Split out so the member menu can + * omit it wholesale for an actor without BAN_MEMBERS. */ +function appendBanFlow( + menu: HTMLDivElement, + options: MemberContextMenuOptions, + signal: AbortSignal, +): void { // Ban — collects the reason the server stores and displays alongside the ban. const banItem = createElement( "div", @@ -210,12 +333,21 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont "data-testid": "ban-reason-input", style: "width:100%;font-size:12px", }); + const banDurationSelect = createElement("select", { + class: "form-input", + "data-testid": "ban-duration-select", + style: "width:100%;font-size:12px;margin-top:4px", + }); + for (const d of BAN_DURATIONS) { + const opt = createElement("option", { value: String(d.hours) }, d.label); + banDurationSelect.appendChild(opt); + } const banConfirm = createElement( "div", { class: "context-menu__item context-menu__item--danger", "data-testid": "ban-confirm" }, "Confirm Ban", ); - appendChildren(banReasonRow, banReasonInput, banConfirm); + appendChildren(banReasonRow, banReasonInput, banDurationSelect, banConfirm); banItem.addEventListener( "click", @@ -225,12 +357,16 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont banReasonRow.style.display = ""; banReasonInput.focus(); }, - { signal: ac.signal }, + { signal }, ); // Typing a reason must not close the menu or trigger the outside-click guard. - banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal: ac.signal }); - banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal: ac.signal }); + banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal }); + banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal }); + banDurationSelect.addEventListener("click", (e) => e.stopPropagation(), { signal }); + banDurationSelect.addEventListener("mousedown", (e) => e.stopPropagation(), { + signal, + }); let banRunning = false; function submitBan(): void { @@ -243,7 +379,8 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont banConfirm.classList.remove("context-menu__item--pending"); setText(banConfirm, "Confirm Ban"); }; - void options.onBan(banReasonInput.value.trim()).then(done, done); + const durationHours = Number.parseInt(banDurationSelect.value, 10) || 0; + void options.onBan(banReasonInput.value.trim(), durationHours).then(done, done); } banConfirm.addEventListener( @@ -252,7 +389,7 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont e.stopPropagation(); submitBan(); }, - { signal: ac.signal }, + { signal }, ); banReasonInput.addEventListener( "keydown", @@ -262,17 +399,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont submitBan(); } }, - { signal: ac.signal }, + { signal }, ); appendChildren(menu, banItem, banReasonRow); - - function destroy(): void { - ac.abort(); - menu.remove(); - } - - return { element: menu, destroy }; } // --------------------------------------------------------------------------- @@ -314,6 +444,17 @@ export function createChannelContextMenu(options: ChannelContextMenuOptions): Co withConfirmation(deleteItem, "Are you sure?", () => options.onDelete(), ac.signal, "Deleting..."); menu.appendChild(deleteItem); + const onPurge = options.onPurge; + if (onPurge !== undefined) { + appendPurgeSection(menu, { + itemClass: "context-menu__item", + dangerItemClass: "context-menu__item context-menu__item--danger", + separatorClass: "context-menu__separator", + onPurge: (count) => onPurge(count), + signal: ac.signal, + }); + } + function destroy(): void { ac.abort(); menu.remove(); diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 33e2ce79..f8c2448b 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -7,26 +7,28 @@ import { createElement, setText, clearChildren, appendChildren } from "@lib/dom"; import { createIcon, type IconName } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; -import { - channelsStore, - getChannelsByCategory, - setActiveChannel, - clearUnread, -} from "@stores/channels.store"; +import { channelsStore, getChannelsByCategory } from "@stores/channels.store"; +import { navigateToChannel } from "@lib/channel-navigation"; +import { markAllRead, unreadChannelIds } from "@lib/read-state"; +import { isChannelMuted } from "@lib/channel-mutes"; +import { dmStore } from "@stores/dm.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, getPeerVerification } from "@stores/voice.store"; -import type { PeerVerification } from "@stores/voice.store"; +import type { PeerVerification, VoiceUser } from "@stores/voice.store"; import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants"; import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview"; import { showUserVolumeMenu } from "./channel-sidebar/volume-menu"; -import { attachChannelContextMenu } from "./channel-sidebar/context-menu"; +import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu"; +import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu"; import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder"; import { rePinPeerIdentity } from "@lib/livekitSession"; import { createIdentityMismatchModal } from "./CertMismatchModal"; import { createLogger } from "@lib/logger"; import { membersStore } from "@stores/members.store"; +import { roleHasPermission, canManageChannels } from "@lib/permissions"; +import { Permission } from "@lib/types"; import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto"; const log = createLogger("ChannelSidebar"); @@ -139,9 +141,30 @@ export interface ChannelReorderData { readonly newPosition: number; } +/** Moderator actions on another user's voice session. Supplied by the page, + * which owns the WS socket; the sidebar only decides whether to offer them. */ +export interface VoiceModerationCallbacks { + readonly onServerMute: (channelId: number, userId: number, muted: boolean) => void; + readonly onServerDeafen: (channelId: number, userId: number, deafened: boolean) => void; + readonly onMove: (userId: number, toChannelId: number) => void; + readonly onDisconnect: (userId: number) => void; +} + +/** Whether the signed-in user's role holds MUTE_MEMBERS. The server enforces + * it (and the rank rule the client cannot evaluate); this only decides whether + * the menu is worth offering. Derived through the same helper as the + * member-list moderation gates so the two cannot disagree about who is a + * moderator. */ +export function canModerateVoice(): boolean { + const role = getCurrentUser()?.role ?? ""; + return roleHasPermission(role, Permission.MUTE_MEMBERS); +} + export interface ChannelSidebarOptions { readonly onVoiceJoin: (channelId: number) => void; readonly onVoiceLeave: () => void; + /** Voice moderation wiring; the moderation menu section is hidden without it. */ + readonly onVoiceModerate?: VoiceModerationCallbacks; /** 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. */ @@ -150,6 +173,8 @@ export interface ChannelSidebarOptions { readonly onDeleteChannel?: (channel: Channel) => void; /** Called when the user drags a channel to a new position. */ readonly onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void; + /** Bulk-delete the newest `count` messages; gated on MANAGE_MESSAGES. */ + readonly onPurgeChannel?: (channel: Channel, count: number) => Promise; /** Called when the user clicks a voice user row to watch their stream. */ readonly onWatchStream?: (userId: number) => void; } @@ -164,6 +189,39 @@ function pickAvatarColor(username: string): string { return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2"; } +/** + * The marker on an age-restricted channel row. + * + * A glyph plus a title rather than a coloured name: the flag is information + * about the channel, and recolouring the name would collide with the unread + * and mention states the row already encodes that way. + */ +function nsfwIndicator(channelId: number): HTMLSpanElement { + const badge = createElement("span", { + class: "ch-nsfw", + "data-testid": `channel-nsfw-${channelId}`, + "aria-label": "Age restricted", + }); + badge.title = "Age-restricted channel"; + badge.appendChild(createIcon("shield-alert", 13)); + return badge; +} + +/** + * "3/5" for a voice channel that has a user limit, or null when it is + * unlimited (0) — a count with no ceiling is already shown by the participant + * rows underneath, and "3/0" would read as a bug. + * + * Purely a readout: the server owns capacity and refuses a join over the limit + * with CHANNEL_FULL. The client never blocks the click, because its copy of + * the participant list can lag and a join it refused locally would be a + * mistake nobody could correct. + */ +function voiceCapacityLabel(channel: Channel, connected: number): string | null { + if (channel.voiceMaxUsers <= 0) return null; + return `${connected}/${channel.voiceMaxUsers}`; +} + function renderTextChannelItem( channel: Channel, isActive: boolean, @@ -173,6 +231,7 @@ function renderTextChannelItem( "channel-item", isActive ? "active" : "", channel.unreadCount > 0 ? "unread" : "", + channel.mentionCount > 0 ? "mentioned" : "", ] .filter(Boolean) .join(" "); @@ -190,29 +249,77 @@ function renderTextChannelItem( appendChildren(item, prefix, name); - if (channel.unreadCount > 0) { - const badge = createElement("span", { class: "unread-badge" }, String(channel.unreadCount)); + // Age-restricted marker. Next to the name rather than replacing the "#", so + // the channel still reads as a channel and the mark is visible whether or + // not the reader has already accepted the gate this session. + if (channel.nsfw) { + item.appendChild(nsfwIndicator(channel.id)); + } + + // A muted channel still counts its unreads — it has not stopped existing, + // it has stopped shouting — so the badge dims rather than disappearing. The + // mention badge is deliberately left alone: a mute silences chatter, never + // something addressed to the reader. + const muted = isChannelMuted(channel.id); + if (muted) { + item.classList.add("muted"); + } + + // A mention badge outranks the plain unread badge: only one is shown, and + // it counts the mentions, not the messages. + if (channel.mentionCount > 0) { + const badge = createElement( + "span", + { class: "mention-badge", "data-testid": `channel-mentions-${channel.id}` }, + String(channel.mentionCount), + ); + badge.title = `${channel.mentionCount} mention${channel.mentionCount === 1 ? "" : "s"}`; + item.appendChild(badge); + } else if (channel.unreadCount > 0) { + const badge = createElement( + "span", + { class: muted ? "unread-badge muted" : "unread-badge" }, + String(channel.unreadCount), + ); item.appendChild(badge); } - item.addEventListener( - "click", - () => { - setActiveChannel(channel.id); - clearUnread(channel.id); - }, - { signal }, - ); + item.addEventListener("click", () => navigateToChannel(channel.id), { signal }); return item; } +/** Moderation section for one participant row, or undefined when the local + * user may not moderate voice (which hides the section entirely). Move targets + * are the other voice channels; the server re-checks that the TARGET may + * connect to the one picked. */ +function buildVoiceModOptions( + channelId: number, + user: VoiceUser, + cb?: VoiceModerationCallbacks, +): VoiceModMenuOptions | undefined { + if (cb === undefined || !canModerateVoice()) return undefined; + const moveTargets = Array.from(channelsStore.getState().channels.values()) + .filter((ch) => ch.type === "voice" && ch.id !== channelId) + .map((ch) => ({ id: ch.id, name: ch.name })); + return { + serverMuted: user.serverMuted === true, + serverDeafened: user.serverDeafened === true, + moveTargets, + onServerMute: (muted) => cb.onServerMute(channelId, user.userId, muted), + onServerDeafen: (deafened) => cb.onServerDeafen(channelId, user.userId, deafened), + onMove: (toChannelId) => cb.onMove(user.userId, toChannelId), + onDisconnect: () => cb.onDisconnect(user.userId), + }; +} + function renderVoiceChannelItem( channel: Channel, signal: AbortSignal, onVoiceJoin: (channelId: number) => void, onVoiceLeave: () => void, onWatchStream?: (userId: number) => void, + onVoiceModerate?: VoiceModerationCallbacks, ): HTMLDivElement { const voiceState = voiceStore.getState(); const isJoined = voiceState.currentChannelId === channel.id; @@ -243,6 +350,22 @@ function renderVoiceChannelItem( appendChildren(item, prefix, name); + if (channel.nsfw) { + item.appendChild(nsfwIndicator(channel.id)); + } + + const voiceUsers = getChannelVoiceUsers(channel.id); + const capacity = voiceCapacityLabel(channel, voiceUsers.length); + if (capacity !== null) { + const badge = createElement( + "span", + { class: "ch-capacity", "data-testid": `channel-capacity-${channel.id}` }, + capacity, + ); + badge.title = `${voiceUsers.length} of ${channel.voiceMaxUsers} connected`; + item.appendChild(badge); + } + item.addEventListener( "click", () => { @@ -260,7 +383,6 @@ function renderVoiceChannelItem( 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) { @@ -293,17 +415,26 @@ function renderVoiceChannelItem( row.appendChild(liveBadge); } + // A moderator-imposed mute/deafen gets its own class and tooltip: the + // same mic-off glyph would otherwise read as an ordinary self-mute. if (user.deafened) { - // Deafened: show both mic-off and headphones-off - const muteIcon = createElement("span", { class: "vu-muted" }); + const muteIcon = createElement("span", { + class: user.serverMuted === true ? "vu-muted vu-server-muted" : "vu-muted", + }); + if (user.serverMuted === true) muteIcon.title = "Muted by a moderator"; muteIcon.appendChild(createIcon("mic-off", 14)); - const deafIcon = createElement("span", { class: "vu-muted" }); + const deafIcon = createElement("span", { + class: user.serverDeafened === true ? "vu-muted vu-server-muted" : "vu-muted", + }); + if (user.serverDeafened === true) deafIcon.title = "Deafened by a moderator"; deafIcon.appendChild(createIcon("headphones-off", 14)); row.appendChild(muteIcon); row.appendChild(deafIcon); } else if (user.muted) { - // Muted only: show mic-off - const muteIcon = createElement("span", { class: "vu-muted" }); + const muteIcon = createElement("span", { + class: user.serverMuted === true ? "vu-muted vu-server-muted" : "vu-muted", + }); + if (user.serverMuted === true) muteIcon.title = "Muted by a moderator"; muteIcon.appendChild(createIcon("mic-off", 14)); row.appendChild(muteIcon); } @@ -349,6 +480,7 @@ function renderVoiceChannelItem( e.clientX, e.clientY, signal, + buildVoiceModOptions(channel.id, user, onVoiceModerate), ); }, { signal }, @@ -419,14 +551,23 @@ function renderChannelItem( channels?: readonly Channel[], onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, onWatchStream?: (userId: number) => void, + onVoiceModerate?: VoiceModerationCallbacks, + onPurgeChannel?: (channel: Channel, count: number) => Promise, ): HTMLDivElement { let el: HTMLDivElement; if (channel.type === "voice") { - el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave, onWatchStream); + el = renderVoiceChannelItem( + channel, + signal, + onVoiceJoin, + onVoiceLeave, + onWatchStream, + onVoiceModerate, + ); } else { el = renderTextChannelItem(channel, isActive, signal); } - attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel); + attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel); if (containerEl !== undefined && channels !== undefined) { attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel); } @@ -445,6 +586,8 @@ function renderCategoryGroup( onDeleteChannel?: (channel: Channel) => void, onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void, onWatchStream?: (userId: number) => void, + onVoiceModerate?: VoiceModerationCallbacks, + onPurgeChannel?: (channel: Channel, count: number) => Promise, ): HTMLDivElement { const group = createElement("div", {}); @@ -462,11 +605,11 @@ function renderCategoryGroup( appendChildren(header, arrow, label); if (onCreateChannel !== undefined) { - const user = getCurrentUser(); - const role = user?.role?.toLowerCase() ?? ""; - const canManageChannels = role === "owner" || role === "admin"; - - if (canManageChannels) { + // MANAGE_CHANNELS is enforced server-side on /admin/api/channels*, so + // gate on the bit; the role-name check only stands in when the `ready` + // role list has no entry for this role. Same derivation as the channel + // context menu's Edit/Delete items. + if (canManageChannels()) { const addBtn = createElement( "span", { @@ -514,6 +657,8 @@ function renderCategoryGroup( channels, onReorderChannel, onWatchStream, + onVoiceModerate, + onPurgeChannel, ), ); } @@ -536,6 +681,8 @@ function renderCategoryGroup( channels, onReorderChannel, onWatchStream, + onVoiceModerate, + onPurgeChannel, ), ); } @@ -554,11 +701,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC onDeleteChannel, onReorderChannel, onWatchStream, + onVoiceModerate, + onPurgeChannel, } = options; const ac = new AbortController(); let root: HTMLDivElement | null = null; let channelList: HTMLDivElement | null = null; let serverNameEl: HTMLSpanElement | null = null; + let markAllBtn: HTMLButtonElement | null = null; const unsubscribers: Array<() => void> = []; @@ -576,7 +726,15 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC } } + /** Hide Mark All as Read while nothing is unread — a header button that can + * never do anything is worse than no button. */ + function updateMarkAllBtn(): void { + if (markAllBtn === null) return; + markAllBtn.classList.toggle("visible", unreadChannelIds().length > 0); + } + function renderChannels(): void { + updateMarkAllBtn(); if (channelList === null) { return; } @@ -613,6 +771,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC onDeleteChannel, onReorderChannel, onWatchStream, + onVoiceModerate, + onPurgeChannel, ), ); } @@ -620,8 +780,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC rebuildVoiceRowCache(); } + /** Redraw when a row's mute is toggled (see CHANNEL_MUTE_CHANGED). */ + function handleMuteChanged(): void { + renderChannels(); + } + function mount(container: Element): void { root = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" }); + root.addEventListener(CHANNEL_MUTE_CHANGED, handleMuteChanged, { signal: ac.signal }); // Header const header = createElement("div", { class: "channel-sidebar-header" }); @@ -629,6 +795,26 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC serverNameEl = createElement("h2", {}, authState.serverName ?? "Server Name"); header.appendChild(serverNameEl); + // Mark All as Read lives on the server header — it is a server-wide action, + // and it only appears while something is actually unread so the header does + // not carry a permanently dead button. + markAllBtn = createElement("button", { + class: "sidebar-mark-all-read", + title: "Mark All as Read", + "aria-label": "Mark All as Read", + "data-testid": "mark-all-read", + }); + markAllBtn.appendChild(createIcon("check", 16)); + markAllBtn.addEventListener( + "click", + (e: Event) => { + e.stopPropagation(); + markAllRead(); + }, + { signal: ac.signal }, + ); + header.appendChild(markAllBtn); + // Channel list channelList = createElement("div", { class: "channel-list" }); @@ -638,6 +824,10 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC // Initial render renderChannels(); + // DM badges live in dm.store, and Mark All as Read covers them too, so the + // header button's visibility has to track that store as well. + unsubscribers.push(dmStore.subscribeSelector((s) => s.channels, updateMarkAllBtn)); + // Subscribe to channels store changes (channels map OR active channel) const unsubChannelsMap = channelsStore.subscribeSelector( (s) => s.channels, @@ -692,7 +882,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC // Include the E2EE verification status so a verified↔unverified↔mismatch // flip re-renders the badge (it lives outside voiceUsers, in peerVerifications). const verif = state.peerVerifications?.get(uid); - structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`; + structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}` : ""}`; } } return structSig; @@ -730,6 +920,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC } channelList = null; serverNameEl = null; + markAllBtn = null; } return { mount, destroy }; diff --git a/Client/tauri-client/src/components/CreateChannelModal.ts b/Client/tauri-client/src/components/CreateChannelModal.ts index 4622835b..d2753e54 100644 --- a/Client/tauri-client/src/components/CreateChannelModal.ts +++ b/Client/tauri-client/src/components/CreateChannelModal.ts @@ -1,17 +1,24 @@ /** - * 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. + * CreateChannelModal — modal for creating a new channel. + * + * The category is an editable text field pre-filled with the group the "+" was + * clicked on, backed by a of the categories already in use. It used + * to be read-only, and the channel TYPE was inferred from the category name + * ("voice" anywhere in it meant voice-only), which made every other category + * name second-class: a voice channel could not live under "Gaming", and + * renaming a category silently changed what could be created there. Categories + * are free text and grouping is a display concern, so every type is offered + * under every category — the server agrees (it validates the type alone). */ import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import type { ChannelType } from "@lib/types"; +import { getKnownCategories, UNCATEGORIZED_VOICE_CATEGORY } from "@stores/channels.store"; export interface CreateChannelModalOptions { - /** The category this channel will be created under. */ + /** The category the create affordance was invoked from ("" = uncategorized). */ readonly category: string; /** Called when the user submits the form. */ readonly onCreate: (data: { name: string; type: ChannelType; category: string }) => Promise; @@ -19,17 +26,18 @@ export interface CreateChannelModalOptions { readonly onClose: () => void; } -/** Returns true if the category name indicates a voice section. */ -export function isVoiceCategory(category: string): boolean { - return category.toLowerCase().includes("voice"); -} +/** Every channel type is creatable under every category. */ +export const CHANNEL_TYPES: readonly ChannelType[] = ["text", "voice", "announcement"] as const; -/** 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; +/** + * The type pre-selected for a category. Only a hint for the dropdown's initial + * value — every type stays selectable. The one case worth guessing is the + * synthetic "Voice" fallback group the sidebar puts uncategorized voice + * channels in: creating from its "+" almost certainly means another voice + * channel. + */ +export function defaultTypeForCategory(category: string): ChannelType { + return category === UNCATEGORIZED_VOICE_CATEGORY ? "voice" : "text"; } export function createCreateChannelModal(options: CreateChannelModalOptions): MountableComponent { @@ -37,8 +45,6 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo 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", @@ -62,15 +68,23 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo // Body const body = createElement("div", { class: "modal-body" }); - // Category (read-only display) + // Category — free text, with the categories already in use as suggestions. const categoryGroup = createElement("div", { class: "form-group" }); const categoryLabel = createElement("label", { class: "form-label" }, "Category"); - const categoryDisplay = createElement("div", { + const categoryInput = createElement("input", { class: "form-input", - style: "opacity: 0.7; cursor: default;", + type: "text", + list: "create-channel-categories", + autocomplete: "off", + placeholder: "Leave blank for no category", + "data-testid": "channel-category-input", }); - setText(categoryDisplay, category); - appendChildren(categoryGroup, categoryLabel, categoryDisplay); + categoryInput.value = category; + const categoryList = createElement("datalist", { id: "create-channel-categories" }); + for (const known of getKnownCategories()) { + categoryList.appendChild(createElement("option", { value: known })); + } + appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList); // Channel name const nameGroup = createElement("div", { class: "form-group" }); @@ -78,7 +92,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo const nameInput = createElement("input", { class: "form-input", type: "text", - placeholder: isVoiceCategory(category) ? "lounge" : "general", + placeholder: defaultTypeForCategory(category) === "voice" ? "lounge" : "general", "data-testid": "channel-name-input", }); appendChildren(nameGroup, nameLabel, nameInput); @@ -91,10 +105,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo "data-testid": "channel-type-select", }); - for (const t of allowedTypes) { + for (const t of CHANNEL_TYPES) { const opt = createElement("option", { value: t }, t.charAt(0).toUpperCase() + t.slice(1)); typeSelect.appendChild(opt); } + typeSelect.value = defaultTypeForCategory(category); appendChildren(typeGroup, typeLabel, typeSelect); // Error display @@ -146,7 +161,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo await onCreate({ name, type: typeSelect.value as ChannelType, - category, + category: categoryInput.value.trim(), }); } catch (err) { errorEl.style.display = "block"; diff --git a/Client/tauri-client/src/components/DmProfileSidebar.ts b/Client/tauri-client/src/components/DmProfileSidebar.ts index 8db9fd18..13d490ce 100644 --- a/Client/tauri-client/src/components/DmProfileSidebar.ts +++ b/Client/tauri-client/src/components/DmProfileSidebar.ts @@ -49,6 +49,9 @@ const STATUS_COLORS: Readonly> = { online: "#3ba55d", idle: "#faa61a", dnd: "#ed4245", + // A DM partner is never invisible from here — the server maps it to offline + // for everyone but its owner — but the map has to be total over UserStatus. + invisible: "#747f8d", offline: "#747f8d", }; @@ -56,6 +59,7 @@ const STATUS_LABELS: Readonly> = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", + invisible: "Invisible", offline: "Offline", }; diff --git a/Client/tauri-client/src/components/DmSidebar.ts b/Client/tauri-client/src/components/DmSidebar.ts index 2c25d267..7d992af6 100644 --- a/Client/tauri-client/src/components/DmSidebar.ts +++ b/Client/tauri-client/src/components/DmSidebar.ts @@ -4,34 +4,65 @@ * * 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-section-label, dm-add, dm-item, dm-avatar, dm-status, * dm-name, dm-close, dm-unread. + * + * Rows are keyed on the DM *channel*, not on a recipient user: a group DM has + * no single recipient, and the same person can be in both a 1:1 and a group + * with you, so a user id no longer identifies a conversation. */ import { createElement, setText, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; +import { showContextMenu } from "@lib/context-menu"; import type { MountableComponent } from "@lib/safe-render"; import { isSafeUrl } from "./message-list/attachments"; +/** One member of a group DM, as far as the sidebar needs to draw them. */ +export interface DmParticipant { + readonly id: number; + readonly username: string; + readonly avatar: string | null; +} + export interface DmConversation { + /** The DM channel. The row's identity — see the module comment. */ + readonly channelId: number; + /** The other party of a 1:1 DM; for a group, the first participant. */ readonly userId: number; + /** What the row is labelled: a group's name or joined members, else a user. */ readonly username: string; readonly avatar: string | null; readonly avatarColor?: string; readonly status?: "online" | "idle" | "dnd" | "offline"; + /** True for a group DM: draws stacked avatars and a participant count. */ + readonly isGroup?: boolean; + /** Everyone but the current user. Drives the stack and the count. */ + readonly participants?: readonly DmParticipant[]; readonly lastMessage: string; readonly timestamp: string; readonly unread: boolean; + /** Unread message count. Drives the numeric badge; a conversation marked + * `unread` with no count still shows the plain dot (older payloads). */ + readonly unreadCount?: number; + /** Unread messages here that mention the current user. Outranks the unread + * badge, exactly as it does in the channel list. */ + readonly mentionCount?: number; + /** Muted: the unread badge renders dimmed. The mention badge does not — + * a mute silences chatter, never something addressed to you. */ + readonly muted?: boolean; readonly active?: boolean; } export interface DmSidebarOptions { readonly conversations: readonly DmConversation[]; - readonly onSelectConversation: (userId: number) => void; + readonly onSelectConversation: (channelId: number) => void; readonly onNewDm: () => void; - readonly onCloseDm?: (userId: number) => void; - readonly onFriendsClick?: () => void; - readonly friendsActive?: boolean; + /** Close a 1:1 DM / leave a group. The component does not distinguish — + * which one it is is the server's call, and the label says so. */ + readonly onCloseDm?: (channelId: number) => void; + readonly onToggleMute?: (channelId: number) => void; + readonly onRenameGroup?: (channelId: number) => void; readonly onBack?: () => void; readonly serverName?: string; } @@ -43,67 +74,137 @@ const STATUS_COLORS: Record = { offline: "var(--text-micro)", }; +/** Fill one avatar circle: the picture if it is safe to load, else the letter. */ +function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void { + if (avatar !== null && isSafeUrl(avatar)) { + const img = createElement("img", { src: avatar, alt: label }); + img.style.width = "100%"; + img.style.height = "100%"; + img.style.borderRadius = "50%"; + el.appendChild(img); + return; + } + setText(el, label.charAt(0).toUpperCase()); +} + +/** + * The avatar block for a row: one circle for a 1:1 DM with a presence dot, or + * two overlapping circles for a group. + * + * A group deliberately gets no presence dot — "is this group online" has no + * answer, and showing the first member's would be a fact about one person + * presented as a fact about the conversation. + */ +function buildAvatar(convo: DmConversation): HTMLDivElement { + const avatarBg = convo.avatarColor ?? "#5865F2"; + + if (convo.isGroup === true) { + const stack = createElement("div", { + class: "dm-avatar dm-avatar-stack", + "data-testid": `dm-avatar-stack-${convo.channelId}`, + }); + const shown = (convo.participants ?? []).slice(0, 2); + // An empty group (every other member has left) still needs a mark, so fall + // back to the row's own label rather than rendering an empty circle. + const faces = shown.length > 0 ? shown : [{ id: 0, username: convo.username, avatar: null }]; + faces.forEach((p, i) => { + const face = createElement("div", { class: `dm-avatar-face dm-avatar-face-${i}` }); + face.style.background = avatarBg; + paintAvatar(face, p.avatar, p.username); + stack.appendChild(face); + }); + return stack; + } + + const avatar = createElement("div", { class: "dm-avatar" }); + avatar.style.background = avatarBg; + paintAvatar(avatar, convo.avatar, convo.username); + + const statusKey = convo.status ?? "offline"; + const statusDot = createElement("span", { class: "dm-status" }); + statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)"; + avatar.appendChild(statusDot); + return avatar; +} + function renderDmItem( convo: DmConversation, - onSelect: (userId: number) => void, - onClose: ((userId: number) => void) | undefined, + options: DmSidebarOptions, signal: AbortSignal, ): HTMLDivElement { const item = createElement("div", { class: "dm-item" }); if (convo.active === true) { item.classList.add("active"); } + if (convo.muted === true) { + item.classList.add("muted"); + } + item.dataset.channelId = String(convo.channelId); 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; + const avatar = buildAvatar(convo); - if (convo.avatar !== null && isSafeUrl(convo.avatar)) { - 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) + appendChildren(item, avatar, name); + + // Participant count, groups only: the label may be a name that says nothing + // about size, and "who else is in here" is the first thing you want to know. + if (convo.isGroup === true) { + const count = (convo.participants ?? []).length + 1; + const countEl = createElement( + "span", + { class: "dm-member-count", "data-testid": `dm-members-${convo.channelId}` }, + String(count), + ); + countEl.title = `${count} members`; + item.appendChild(countEl); + } + + // Close / leave button (hidden by default, shown on hover via CSS) const closeBtn = createElement("button", { class: "dm-close", - title: "Close DM", + title: convo.isGroup === true ? "Leave group" : "Close DM", }); - closeBtn.textContent = ""; closeBtn.appendChild(createIcon("x", 14)); closeBtn.addEventListener( "click", (e: Event) => { e.stopPropagation(); - if (onClose !== undefined) { - onClose(convo.userId); - } + options.onCloseDm?.(convo.channelId); }, { signal }, ); + item.appendChild(closeBtn); - appendChildren(item, avatar, name, closeBtn); - - // Unread dot - if (convo.unread) { + // A mention badge outranks the unread badge, which in turn outranks the bare + // dot — the dot is only what is left when the payload carries no counts. + // + // A muted conversation dims the unread badge but NOT the mention badge: the + // whole point of Discord's mute is that things addressed to you still get + // through, so dimming both would make a mute unsafe to use. + const mentionCount = convo.mentionCount ?? 0; + const unreadCount = convo.unreadCount ?? 0; + if (mentionCount > 0) { + const badge = createElement( + "span", + { class: "dm-mention-badge", "data-testid": `dm-mentions-${convo.channelId}` }, + String(mentionCount), + ); + badge.title = `${mentionCount} mention${mentionCount === 1 ? "" : "s"}`; + item.appendChild(badge); + } else if (unreadCount > 0) { + const badge = createElement( + "span", + { + class: convo.muted === true ? "dm-unread-badge muted" : "dm-unread-badge", + "data-testid": `dm-unread-${convo.channelId}`, + }, + String(unreadCount), + ); + badge.title = `${unreadCount} unread message${unreadCount === 1 ? "" : "s"}`; + item.appendChild(badge); + } else if (convo.unread) { const unreadDot = createElement("span", { class: "dm-unread" }); item.appendChild(unreadDot); } @@ -118,7 +219,43 @@ function renderDmItem( } } item.classList.add("active"); - onSelect(convo.userId); + options.onSelectConversation(convo.channelId); + }, + { signal }, + ); + + item.addEventListener( + "contextmenu", + (e: MouseEvent) => { + e.preventDefault(); + const items = []; + if (options.onToggleMute !== undefined) { + const toggle = options.onToggleMute; + items.push({ + label: convo.muted === true ? "Unmute Conversation" : "Mute Conversation", + testId: `dm-mute-${convo.channelId}`, + onClick: () => toggle(convo.channelId), + }); + } + if (convo.isGroup === true && options.onRenameGroup !== undefined) { + const rename = options.onRenameGroup; + items.push({ + label: "Rename Group", + testId: `dm-rename-${convo.channelId}`, + onClick: () => rename(convo.channelId), + }); + } + if (options.onCloseDm !== undefined) { + const close = options.onCloseDm; + items.push({ + label: convo.isGroup === true ? "Leave Group" : "Close DM", + danger: true, + testId: `dm-close-${convo.channelId}`, + onClick: () => close(convo.channelId), + }); + } + if (items.length === 0) return; + showContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: "dm-context-menu" }); }, { signal }, ); @@ -141,7 +278,7 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent { class: "dm-back-header", "data-testid": "dm-back-header", }); - const arrow = createElement("span", { class: "dm-back-arrow" }, "\u2190"); + const arrow = createElement("span", { class: "dm-back-arrow" }, "←"); const backInfo = createElement("div", { class: "dm-back-info" }); const backTitle = createElement( "div", @@ -163,22 +300,6 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent { }); 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"); @@ -195,11 +316,9 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent { (a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0), ); - const items = sorted.map((convo) => - renderDmItem(convo, options.onSelectConversation, options.onCloseDm, ac.signal), - ); + const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal)); - appendChildren(root, header, friendsNav, sectionLabel, ...items); + appendChildren(root, header, sectionLabel, ...items); container.appendChild(root); } diff --git a/Client/tauri-client/src/components/EditChannelModal.ts b/Client/tauri-client/src/components/EditChannelModal.ts index 3a60ad06..6704858c 100644 --- a/Client/tauri-client/src/components/EditChannelModal.ts +++ b/Client/tauri-client/src/components/EditChannelModal.ts @@ -1,11 +1,101 @@ /** - * EditChannelModal — modal for editing an existing channel's name and topic. - * Only visible to admin/owner users. + * EditChannelModal — modal for editing an existing channel's name, topic, + * category, slow mode, NSFW flag and (for voice channels) its capacity limits. + * Mounted only for actors holding MANAGE_CHANNELS; the server enforces the same + * bit on the PATCH behind it. + * + * Category is free text with a of the categories already in use: + * moving a channel between groups is a rename, not a recreate, and no category + * name is special (a voice channel groups under whatever it carries). + * + * Slow mode is a preset ` is advisory — typing past it, or pasting, still + * produces the larger value — so the bound is applied here rather than trusting + * the attribute and letting the server 400 a form the user had no way to fix. + */ +export function clampVoiceLimit(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(MAX_VOICE_LIMIT, Math.max(0, Math.trunc(value))); +} + +/** The fields an edit submits. Mirrors the PATCH body. */ +export interface EditChannelData { + readonly name: string; + readonly topic: string; + readonly category: string; + readonly slow_mode: number; + readonly nsfw: boolean; + /** + * Only present for a voice channel. A text channel's PATCH omits them + * entirely rather than sending 0, so an edit here cannot wipe limits the + * channel carries. + */ + readonly voice_max_users?: number; + readonly voice_max_video?: number; +} export interface EditChannelModalOptions { /** Current channel ID. */ @@ -14,14 +104,59 @@ export interface EditChannelModalOptions { readonly channelName: string; /** Current channel type (displayed, not editable). */ readonly channelType: string; + /** Current channel topic ("" = none). */ + readonly channelTopic?: string; + /** Current channel category ("" = uncategorized). */ + readonly channelCategory?: string; + /** Current cooldown in seconds (0 = off). */ + readonly channelSlowMode?: number; + /** Whether the channel is currently flagged age-restricted. */ + readonly channelNsfw?: boolean; + /** Current voice capacity limits (0 = unlimited). Voice channels only. */ + readonly channelVoiceMaxUsers?: number; + readonly channelVoiceMaxVideo?: number; /** Called when the user saves changes. */ - readonly onSave: (data: { name: string }) => Promise; + readonly onSave: (data: EditChannelData) => Promise; /** Called when the modal is closed. */ readonly onClose: () => void; } +/** A labelled number input constrained to 0…MAX_VOICE_LIMIT. */ +function buildVoiceLimitField( + labelText: string, + hintText: string, + testId: string, + value: number, +): { group: HTMLDivElement; input: HTMLInputElement } { + const group = createElement("div", { class: "form-group" }); + const label = createElement("label", { class: "form-label" }, labelText); + const input = createElement("input", { + class: "form-input", + type: "number", + min: "0", + max: String(MAX_VOICE_LIMIT), + "data-testid": testId, + }); + input.value = String(clampVoiceLimit(value)); + const hint = createElement("div", { class: "form-hint" }, hintText); + appendChildren(group, label, input, hint); + return { group, input }; +} + export function createEditChannelModal(options: EditChannelModalOptions): MountableComponent { - const { channelName, channelType, onSave, onClose } = options; + const { + channelName, + channelType, + channelTopic, + channelCategory, + channelSlowMode, + channelNsfw, + channelVoiceMaxUsers, + channelVoiceMaxVideo, + onSave, + onClose, + } = options; + const isVoice = channelType === "voice"; const ac = new AbortController(); let overlay: HTMLDivElement | null = null; @@ -70,14 +205,119 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta nameInput.value = channelName; appendChildren(nameGroup, nameLabel, nameInput); + // Channel topic (optional, shown in the chat header) + const topicGroup = createElement("div", { class: "form-group" }); + const topicLabel = createElement("label", { class: "form-label" }, "Topic"); + const topicInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "What's this channel about? (optional)", + maxlength: "1024", + "data-testid": "edit-channel-topic-input", + }); + topicInput.value = channelTopic ?? ""; + appendChildren(topicGroup, topicLabel, topicInput); + + // Channel category (free text, suggestions from the categories in use) + const categoryGroup = createElement("div", { class: "form-group" }); + const categoryLabel = createElement("label", { class: "form-label" }, "Category"); + const categoryInput = createElement("input", { + class: "form-input", + type: "text", + list: "edit-channel-categories", + autocomplete: "off", + placeholder: "Leave blank for no category", + "data-testid": "edit-channel-category-input", + }); + categoryInput.value = channelCategory ?? ""; + const categoryList = createElement("datalist", { id: "edit-channel-categories" }); + for (const known of getKnownCategories()) { + categoryList.appendChild(createElement("option", { value: known })); + } + appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList); + + // Slow mode (presets; a stored off-preset value keeps its own option) + const currentSlowMode = clampSlowMode(channelSlowMode ?? 0); + const slowGroup = createElement("div", { class: "form-group" }); + const slowLabel = createElement("label", { class: "form-label" }, "Slow Mode"); + const slowSelect = createElement("select", { + class: "form-input", + "data-testid": "edit-channel-slowmode-select", + }); + const choices = SLOW_MODE_PRESETS.some((p) => p.seconds === currentSlowMode) + ? [...SLOW_MODE_PRESETS] + : [ + ...SLOW_MODE_PRESETS, + { seconds: currentSlowMode, label: formatSlowMode(currentSlowMode) }, + ]; + for (const choice of choices.toSorted((a, b) => a.seconds - b.seconds)) { + const opt = createElement("option", { value: String(choice.seconds) }, choice.label); + if (choice.seconds === currentSlowMode) opt.selected = true; + slowSelect.appendChild(opt); + } + const slowHint = createElement( + "div", + { class: "form-hint" }, + "Members must wait this long between messages. Holders of Manage Messages are exempt.", + ); + appendChildren(slowGroup, slowLabel, slowSelect, slowHint); + + // NSFW flag. The copy states the limit of the feature: the server does not + // filter anything, so promising otherwise here would be a lie. + const nsfwGroup = createElement("div", { class: "form-group" }); + const nsfwLabelRow = createElement("label", { class: "form-check" }); + const nsfwInput = createElement("input", { + type: "checkbox", + "data-testid": "edit-channel-nsfw-checkbox", + }); + nsfwInput.checked = channelNsfw === true; + const nsfwText = createElement("span", {}, "Age-restricted (NSFW)"); + appendChildren(nsfwLabelRow, nsfwInput, nsfwText); + const nsfwHint = createElement( + "div", + { class: "form-hint" }, + "Members see a one-time warning each session before opening the channel, and the channel is marked in the sidebar. Nothing is filtered.", + ); + appendChildren(nsfwGroup, nsfwLabelRow, nsfwHint); + + appendChildren(body, typeGroup, nameGroup, topicGroup, categoryGroup, slowGroup, nsfwGroup); + + // Voice-only section. Rendered for a voice channel alone: the columns exist + // on every row, but on a text channel they are values nothing reads, and + // offering them would imply an enforcement that does not happen. + let maxUsersInput: HTMLInputElement | null = null; + let maxVideoInput: HTMLInputElement | null = null; + if (isVoice) { + const voiceSection = createElement("div", { + class: "form-section", + "data-testid": "edit-channel-voice-section", + }); + const voiceHeading = createElement("div", { class: "form-section-title" }, "Voice Limits"); + const users = buildVoiceLimitField( + "User Limit", + "How many members may be connected at once. 0 = unlimited.", + "edit-channel-max-users-input", + channelVoiceMaxUsers ?? 0, + ); + const video = buildVoiceLimitField( + "Video Limit", + "How many may have a camera or screen share on at once. 0 = unlimited.", + "edit-channel-max-video-input", + channelVoiceMaxVideo ?? 0, + ); + maxUsersInput = users.input; + maxVideoInput = video.input; + appendChildren(voiceSection, voiceHeading, users.group, video.group); + body.appendChild(voiceSection); + } + // 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); + body.appendChild(errorEl); // Footer const footer = createElement("div", { class: "modal-footer" }); @@ -114,8 +354,22 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta saveBtn.setAttribute("disabled", "true"); setText(saveBtn, "Saving..."); + const data: EditChannelData = { + name, + topic: topicInput.value.trim(), + category: categoryInput.value.trim(), + slow_mode: clampSlowMode(Number.parseInt(slowSelect.value, 10)), + nsfw: nsfwInput.checked, + ...(maxUsersInput !== null + ? { voice_max_users: clampVoiceLimit(Number.parseInt(maxUsersInput.value, 10)) } + : {}), + ...(maxVideoInput !== null + ? { voice_max_video: clampVoiceLimit(Number.parseInt(maxVideoInput.value, 10)) } + : {}), + }; + try { - await onSave({ name }); + await onSave(data); } catch (err) { errorEl.style.display = "block"; setText(errorEl, err instanceof Error ? err.message : "Failed to update channel"); diff --git a/Client/tauri-client/src/components/EmojiAutocomplete.ts b/Client/tauri-client/src/components/EmojiAutocomplete.ts new file mode 100644 index 00000000..01eb336c --- /dev/null +++ b/Client/tauri-client/src/components/EmojiAutocomplete.ts @@ -0,0 +1,157 @@ +/** + * EmojiAutocomplete — inline emoji picker the composer opens on ":". + * + * Deliberately the same shape as MentionAutocomplete (setQuery / handleKeydown + * / destroy, mousedown-to-choose, arrow-key navigation): the composer drives + * both through one code path, and a user who has learned one has learned the + * other. + * + * Two sources in one list: the server's custom emoji, which insert their + * `:shortcode:` text, and the built-in unicode set, which inserts the character + * itself. Custom emoji come first — they are the ones a shortcode is really + * for, and there are far fewer of them. + * + * Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + */ + +import { createElement, setText } from "@lib/dom"; +import { EMOJI_NAMES } from "@components/EmojiPicker"; +import { buildCustomEmojiImage } from "@components/message-list/custom-emoji"; +import { listCustomEmoji, type CustomEmoji } from "@stores/emoji.store"; +import { + createInlineAutocomplete, + type InlineAutocompleteComponent, +} from "@components/inline-autocomplete"; + +/** Maximum rows shown at once — the popup is a shortcut, not the picker. */ +export const MAX_EMOJI_SUGGESTIONS = 10; + +/** + * The shortest query that opens the popup. One character after the colon would + * match most of the unicode set and fire on ordinary prose ("note: a thing"). + */ +export const MIN_EMOJI_QUERY = 2; + +export interface EmojiSuggestion { + /** Row label — the shortcode, or the unicode emoji's primary name. */ + readonly label: string; + /** Text inserted into the composer, replacing the `:query` under the caret. */ + readonly insert: string; + /** Secondary line: the remaining keywords, or the literal token for custom. */ + readonly detail: string; + readonly kind: "custom" | "unicode"; + /** The character to show as the preview, or null for a custom emoji image. */ + readonly char: string | null; + /** The custom emoji this row stands for, or null for a unicode one. */ + readonly emoji: CustomEmoji | null; +} + +export interface EmojiAutocompleteOptions { + /** Called with the text to insert (`:wave:` or a unicode character). */ + readonly onSelect: (insert: string) => void; + readonly onClose: () => void; +} + +/** Same shape as the shared inline-autocomplete widget. */ +export type EmojiAutocompleteComponent = InlineAutocompleteComponent; + +function byLabel(a: EmojiSuggestion, b: EmojiSuggestion): number { + return a.label.localeCompare(b.label); +} + +/** The preview cell for one row: the custom emoji's image, or the character. */ +function buildPreview(s: EmojiSuggestion): HTMLSpanElement { + const preview = createElement("span", { class: "ea-preview" }); + if (s.emoji !== null) preview.appendChild(buildCustomEmojiImage(s.emoji)); + else setText(preview, s.char ?? ""); + return preview; +} + +/** + * Suggestions for `query`, in the order the popup lists them: custom emoji + * first (prefix matches before substring), then unicode, alphabetical within + * each group. + * + * A query shorter than MIN_EMOJI_QUERY yields nothing at all, so the composer + * never opens a popup over a lone colon. + */ +export function filterEmojiSuggestions(query: string): EmojiSuggestion[] { + const q = query.toLowerCase(); + if (q.length < MIN_EMOJI_QUERY) return []; + + const customPrefix: EmojiSuggestion[] = []; + const customSubstring: EmojiSuggestion[] = []; + for (const emoji of listCustomEmoji()) { + const name = emoji.shortcode; + if (!name.includes(q)) continue; + const entry: EmojiSuggestion = { + label: name, + insert: `:${name}:`, + detail: "Server emoji", + kind: "custom", + char: null, + emoji, + }; + if (name.startsWith(q)) customPrefix.push(entry); + else customSubstring.push(entry); + } + + const unicodePrefix: EmojiSuggestion[] = []; + const unicodeSubstring: EmojiSuggestion[] = []; + for (const [char, keywords] of Object.entries(EMOJI_NAMES)) { + if (!keywords.includes(q)) continue; + const words = keywords.split(" "); + const primary = words[0] ?? keywords; + const entry: EmojiSuggestion = { + label: primary, + insert: char, + detail: words.slice(1).join(" "), + kind: "unicode", + char, + emoji: null, + }; + // "Prefix" means some whole keyword starts with the query, not just the + // primary one — typing ":fire" should rank 🔥 ("fire hot flame lit") above + // an emoji that merely contains "fire" mid-word. + if (words.some((w) => w.startsWith(q))) unicodePrefix.push(entry); + else unicodeSubstring.push(entry); + } + + customPrefix.sort(byLabel); + customSubstring.sort(byLabel); + unicodePrefix.sort(byLabel); + unicodeSubstring.sort(byLabel); + + return [...customPrefix, ...customSubstring, ...unicodePrefix, ...unicodeSubstring].slice( + 0, + MAX_EMOJI_SUGGESTIONS, + ); +} + +/** One emoji row: preview cell, `:label:`/name, and a keyword detail line. */ +function renderEmojiRow(s: EmojiSuggestion): HTMLElement[] { + const name = createElement("span", { class: "ma-name" }); + setText(name, s.kind === "custom" ? `:${s.label}:` : s.label); + const detail = createElement("span", { class: "ma-detail" }); + setText(detail, s.detail); + return [buildPreview(s), name, detail]; +} + +export function createEmojiAutocomplete( + options: EmojiAutocompleteOptions, +): EmojiAutocompleteComponent { + return createInlineAutocomplete({ + // Shares the base class deliberately (the composer test selects + // `.mention-autocomplete:not(.emoji-autocomplete)` to distinguish them). + rootClass: "mention-autocomplete emoji-autocomplete", + rootTestId: "emoji-autocomplete", + filter: filterEmojiSuggestions, + valueOf: (s) => s.insert, + rowTestId: (s) => `emoji-option-${s.label}`, + renderRow: renderEmojiRow, + // Unlike mentions, emoji stay empty until the composer types past + // MIN_EMOJI_QUERY, so there is nothing to prime on create. + onSelect: options.onSelect, + onClose: options.onClose, + }); +} diff --git a/Client/tauri-client/src/components/EmojiPicker.ts b/Client/tauri-client/src/components/EmojiPicker.ts index 25f85381..d4fbc343 100644 --- a/Client/tauri-client/src/components/EmojiPicker.ts +++ b/Client/tauri-client/src/components/EmojiPicker.ts @@ -2,6 +2,7 @@ // Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. import { createElement, setText, clearChildren } from "@lib/dom"; +import { buildCustomEmojiNode } from "@components/message-list/custom-emoji"; // --------------------------------------------------------------------------- // Types @@ -13,11 +14,19 @@ export interface CustomEmoji { } export interface EmojiPickerOptions { + /** + * The server's custom emoji, shown as a "Server" category above the unicode + * ones. Selecting one inserts its `:shortcode:` — the composer sends text, + * and the renderer turns that text back into the image. + */ readonly customEmoji?: readonly CustomEmoji[]; readonly onSelect: (emoji: string) => void; readonly onClose: () => void; } +/** The category label the server's own emoji appear under. */ +export const SERVER_CATEGORY = "Server"; + // --------------------------------------------------------------------------- // Built-in emoji data (common subset by category) // --------------------------------------------------------------------------- @@ -274,8 +283,14 @@ const CATEGORIES: readonly EmojiCategory[] = [ }, ]; -/** Emoji name lookup for search. Maps emoji character → searchable keywords. */ -const EMOJI_NAMES: Readonly> = { +/** + * Emoji name lookup for search. Maps emoji character → searchable keywords. + * + * Exported because the composer's `:` autocomplete searches the same list the + * picker does — two independently-maintained name tables would mean typing + * `:fire` and searching "fire" disagreeing about what exists. + */ +export const EMOJI_NAMES: Readonly> = { "😀": "grinning face happy smile", "😃": "smiley face happy smile", "😄": "smile happy grin", @@ -555,10 +570,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): { const recent = getRecentEmoji(); const cats: EmojiCategory[] = [{ name: "Recent", emoji: recent }]; - // Custom server emoji + // The server's own emoji, as the `:shortcode:` tokens a message carries. if (options.customEmoji && options.customEmoji.length > 0) { cats.push({ - name: "Custom", + name: SERVER_CATEGORY, emoji: options.customEmoji.map((e) => `:${e.shortcode}:`), }); } @@ -582,7 +597,16 @@ export function createEmojiPicker(options: EmojiPickerOptions): { class: "ep-emoji", title: emoji, }); - setText(span, emoji); + // A `:shortcode:` entry shows its image; everything else is the character + // itself. An unresolvable shortcode falls back to the text, which is what + // it would render as in a message anyway. + const image = buildCustomEmojiNode(emoji); + if (image !== null) { + span.classList.add("ep-emoji-custom"); + span.appendChild(image); + } else { + setText(span, emoji); + } span.addEventListener("click", () => handleEmojiClick(emoji), { signal }); return span; } diff --git a/Client/tauri-client/src/components/IncomingCallBanner.ts b/Client/tauri-client/src/components/IncomingCallBanner.ts new file mode 100644 index 00000000..650a5f60 --- /dev/null +++ b/Client/tauri-client/src/components/IncomingCallBanner.ts @@ -0,0 +1,97 @@ +/** + * IncomingCallBanner — the toast-like strip that appears when somebody rings a + * DM you are in. + * + * It is deliberately a banner and not a modal: a ring is an offer, not a + * demand, and a modal would block the app until the 30s timer expired. Accept + * joins the DM's voice channel; Decline tells the ringer to stop. + * + * All state lives in @lib/call-ring — this only draws whatever it is handed. + */ + +import { createElement, appendChildren, setText } from "@lib/dom"; +import { createIcon } from "@lib/icons"; +import type { MountableComponent } from "@lib/safe-render"; +import type { RingState } from "@lib/call-ring"; + +export interface IncomingCallBannerOptions { + readonly onAccept: () => void; + readonly onDecline: () => void; +} + +export interface IncomingCallBannerComponent extends MountableComponent { + /** Show the banner for a ring, or hide it with null. */ + readonly setRing: (state: RingState | null) => void; +} + +export function createIncomingCallBanner( + options: IncomingCallBannerOptions, +): IncomingCallBannerComponent { + const ac = new AbortController(); + + const root = createElement("div", { + class: "incoming-call-banner", + role: "alert", + "data-testid": "incoming-call-banner", + }); + root.style.display = "none"; + + const icon = createElement("div", { class: "incoming-call-icon" }); + icon.appendChild(createIcon("phone", 20)); + + const info = createElement("div", { class: "incoming-call-info" }); + const title = createElement("div", { + class: "incoming-call-title", + "data-testid": "incoming-call-title", + }); + const subtitle = createElement("div", { class: "incoming-call-subtitle" }, "Incoming call"); + appendChildren(info, title, subtitle); + + const acceptBtn = createElement( + "button", + { + class: "btn btn-primary incoming-call-accept", + type: "button", + "data-testid": "incoming-call-accept", + }, + "Accept", + ); + acceptBtn.addEventListener("click", () => options.onAccept(), { signal: ac.signal }); + + const declineBtn = createElement( + "button", + { + class: "btn btn-danger incoming-call-decline", + type: "button", + "data-testid": "incoming-call-decline", + }, + "Decline", + ); + declineBtn.addEventListener("click", () => options.onDecline(), { signal: ac.signal }); + + const actions = createElement("div", { class: "incoming-call-actions" }); + appendChildren(actions, acceptBtn, declineBtn); + appendChildren(root, icon, info, actions); + + function setRing(state: RingState | null): void { + if (state === null) { + root.style.display = "none"; + setText(title, ""); + return; + } + // setText, never innerHTML: the username is user-controlled. + setText(title, `${state.fromUsername} is calling`); + root.style.display = ""; + } + + return { + mount(container: Element): void { + container.appendChild(root); + }, + destroy(): void { + ac.abort(); + root.remove(); + }, + setRing, + }; +} diff --git a/Client/tauri-client/src/components/MemberList.ts b/Client/tauri-client/src/components/MemberList.ts index 61cef581..eedb2b0a 100644 --- a/Client/tauri-client/src/components/MemberList.ts +++ b/Client/tauri-client/src/components/MemberList.ts @@ -1,24 +1,47 @@ /** * MemberList component — shows server members grouped by role with online status. * Subscribes to membersStore for reactive updates. - * Right-click context menu for admin actions (kick, ban, role change). + * Right-click context menu for admin actions (force logout, ban, role change). */ import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import { Disposable } from "@lib/disposable"; -import { membersStore, type Member, type MembersState } from "@stores/members.store"; +import { + membersStore, + memberDisplayName, + type Member, + type MembersState, +} from "@stores/members.store"; import { authStore } from "@stores/auth.store"; -import { channelsStore } from "@stores/channels.store"; +import { blocksStore } from "@stores/blocks.store"; +import { channelsStore, type ChannelsState } from "@stores/channels.store"; import { createMemberContextMenu } from "@components/AdminActions"; -import type { UserStatus } from "@lib/types"; +import { + createUserProfilePopup, + type UserProfilePopupComponent, +} from "@components/UserProfilePopup"; +import { Permission, type ReadyRole, type UserStatus } from "@lib/types"; +import { roleHasPermission } from "@lib/permissions"; +import { createAvatarElement } from "@lib/avatar"; /** Options for configuring admin action callbacks on the member list. */ export interface MemberListOptions { + /** Role name of the signed-in user; resolved against the server's role list + * to get the permission mask that gates the moderation menu items. */ readonly currentUserRole: string; + /** Force logout: revokes the target's sessions (KICK_MEMBERS). */ readonly onKick: (userId: number, username: string) => Promise; - readonly onBan: (userId: number, username: string, reason: string) => Promise; + readonly onBan: ( + userId: number, + username: string, + reason: string, + durationHours: number, + ) => Promise; readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise; + readonly onToggleBlock: (userId: number, username: string, block: boolean) => Promise; + /** Start a DM with a user (wires the profile popup's Message button). */ + readonly onMessageUser?: (userId: number) => void; } /** Roles offered in the "Change Role" submenu when the server hasn't sent any. */ @@ -36,18 +59,69 @@ function assignableRoleNames(): readonly string[] { return roles.length > 0 ? roles : FALLBACK_ASSIGNABLE_ROLES; } -/** Ordered role groups with display names and CSS color variables. */ -const ROLE_GROUPS: readonly { +/** Which moderation menu items the signed-in user may see. */ +interface ModerationGates { + readonly canKick: boolean; + readonly canBan: boolean; + readonly canManageRoles: boolean; +} + +/** + * Menu items the signed-in user's role permits, from the permission mask the + * server ships in `ready`. Administrator implies all three. When the role name + * has no match in that list (pre-`ready`, or an older server that sent none) + * the legacy owner/admin name check stands in — a mask of 0 would otherwise + * hide moderation from every actual admin. + */ +function moderationGates(roleName: string): ModerationGates { + return { + canKick: roleHasPermission(roleName, Permission.KICK_MEMBERS), + canBan: roleHasPermission(roleName, Permission.BAN_MEMBERS), + canManageRoles: roleHasPermission(roleName, Permission.MANAGE_ROLES), + }; +} + +interface RoleGroup { 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)" }, +} + +/** Theme-variable fallbacks for the seeded roles (used when the server sends no color). */ +const FALLBACK_ROLE_COLORS: Record = { + owner: "var(--role-owner, #e74c3c)", + admin: "var(--role-admin, #f39c12)", + moderator: "var(--role-mod, #2ecc71)", +}; + +const MEMBER_COLOR = "var(--role-member, #949ba4)"; + +/** Ordered role groups used when the server hasn't sent a role list. */ +const FALLBACK_ROLE_GROUPS: readonly RoleGroup[] = [ + { role: "owner", label: "OWNER", colorVar: FALLBACK_ROLE_COLORS["owner"]! }, + { role: "admin", label: "ADMIN", colorVar: FALLBACK_ROLE_COLORS["admin"]! }, + { role: "moderator", label: "MODERATOR", colorVar: FALLBACK_ROLE_COLORS["moderator"]! }, + { role: "member", label: "MEMBER", colorVar: MEMBER_COLOR }, ] as const; +/** + * Role groups from the server's `ready` role list (already ordered by position, + * highest first), colored by the server's role color when set. A hardcoded + * list rendered custom roles nowhere and ignored `roles.color` entirely. + */ +function roleGroups(): readonly RoleGroup[] { + const roles = channelsStore.getState().roles; + if (roles.length === 0) return FALLBACK_ROLE_GROUPS; + return roles.map((r) => { + const key = r.name.toLowerCase(); + return { + role: key, + label: r.name.toUpperCase(), + colorVar: r.color ?? FALLBACK_ROLE_COLORS[key] ?? MEMBER_COLOR, + }; + }); +} + /** Status priority for sorting: lower = higher priority (shown first). */ function statusPriority(status: UserStatus): number { switch (status) { @@ -57,6 +131,10 @@ function statusPriority(status: UserStatus): number { return 1; case "dnd": return 2; + // "invisible" only ever describes the signed-in user (the server shows + // everyone else offline), and it sorts with offline because that is where + // they appear to everybody — including, in this list, to themselves. + case "invisible": case "offline": return 3; default: @@ -72,6 +150,7 @@ function statusColor(status: UserStatus): string { return "var(--yellow)"; case "dnd": return "var(--red)"; + case "invisible": case "offline": return "var(--text-micro)"; default: @@ -79,7 +158,13 @@ function statusColor(status: UserStatus): string { } } +/** True for the statuses that render a member as "not here". */ +function isAwayStatus(status: UserStatus): boolean { + return status === "offline" || status === "invisible"; +} + let activeMenu: { element: HTMLDivElement; destroy(): void } | null = null; +let activePopup: UserProfilePopupComponent | null = null; function closeActiveMenu(): void { if (activeMenu !== null) { @@ -88,6 +173,13 @@ function closeActiveMenu(): void { } } +function closeActivePopup(): void { + if (activePopup !== null) { + activePopup.destroy?.(); + activePopup = null; + } +} + function handleOutsideClick(e: MouseEvent): void { if (activeMenu !== null && !activeMenu.element.contains(e.target as Node)) { closeActiveMenu(); @@ -102,15 +194,13 @@ function createMemberItem( signal: AbortSignal, ): HTMLDivElement { const item = createElement("div", { - class: member.status === "offline" ? "member-item offline" : "member-item", + class: isAwayStatus(member.status) ? "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 avatar = createAvatarElement( + { username: member.username, displayName: member.displayName, avatar: member.avatar }, + { className: "mi-avatar", background: colorVar }, ); const statusDot = createElement("div", { @@ -121,10 +211,54 @@ function createMemberItem( }); avatar.appendChild(statusDot); + // Name + custom status stack. The custom status is only rendered when there + // is one, so a member without it keeps the single-line row it always had. + const nameWrap = createElement("div", { class: "mi-text" }); const name = createElement("span", { class: "mi-name", style: `color: ${colorVar}` }); - setText(name, member.username); + setText(name, memberDisplayName(member)); + nameWrap.appendChild(name); + const custom = member.customStatus; + if (typeof custom === "string" && custom.length > 0) { + const customEl = createElement("span", { + class: "mi-custom-status", + "data-testid": `member-custom-status-${member.id}`, + }); + setText(customEl, custom); + nameWrap.appendChild(customEl); + } - appendChildren(item, avatar, name); + appendChildren(item, avatar, nameWrap); + + // Left-click opens the profile popup (previously dead code — built and + // tested but never mounted from anywhere). + item.addEventListener( + "click", + (e) => { + closeActiveMenu(); + closeActivePopup(); + const currentUserId = authStore.getState().user?.id ?? 0; + const isSelf = member.id === currentUserId; + const onMessageUser = opts.onMessageUser; + activePopup = createUserProfilePopup({ + user: { + id: member.id, + username: member.username, + avatar: member.avatar, + role: member.role, + status: member.status, + displayName: member.displayName, + customStatus: member.customStatus, + }, + anchorX: e.clientX, + anchorY: e.clientY, + ...(isSelf || onMessageUser === undefined + ? {} + : { onMessage: (userId: number) => onMessageUser(userId) }), + }); + activePopup.mount(document.body); + }, + { signal }, + ); // Context menu for admin actions item.addEventListener( @@ -136,9 +270,10 @@ function createMemberItem( const currentUserId = authStore.getState().user?.id ?? 0; if (member.id === currentUserId) return; - // Only admins and owners can use admin actions - const role = opts.currentUserRole.toLowerCase(); - if (role !== "owner" && role !== "admin") return; + // Moderation actions are permission-gated per item (a role name told us + // nothing about what its bits allow); block/unblock is open to everyone. + const gates = moderationGates(opts.currentUserRole); + const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles; closeActiveMenu(); document.removeEventListener("mousedown", handleOutsideClick); @@ -147,14 +282,22 @@ function createMemberItem( // custom roles unreachable and, worse, unresolvable to a role id, so // picking one silently did nothing. const availableRoles = assignableRoleNames(); + const isBlocked = blocksStore.getState().blockedByMe.has(member.id); activeMenu = createMemberContextMenu({ userId: member.id, username: member.username, currentRole: member.role.toLowerCase(), availableRoles, + showAdminActions, + canKick: gates.canKick, + canBan: gates.canBan, + canManageRoles: gates.canManageRoles, + isBlocked, + onToggleBlock: () => opts.onToggleBlock(member.id, member.username, !isBlocked), onKick: () => opts.onKick(member.id, member.username), - onBan: (reason: string) => opts.onBan(member.id, member.username, reason), + onBan: (reason: string, durationHours: number) => + opts.onBan(member.id, member.username, reason, durationHours), onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole), }); @@ -208,25 +351,47 @@ function renderList( } } - for (const group of ROLE_GROUPS) { - const groupMembers = (buckets.get(group.role) ?? []).toSorted( - (a, b) => statusPriority(a.status) - statusPriority(b.status), - ); + const groups = roleGroups(); + const rendered = new Set(); + for (const group of groups) { + rendered.add(group.role); + appendGroup(root, group, buckets.get(group.role) ?? [], opts, signal, rowsByUserId); + } - if (groupMembers.length === 0) continue; + // Members whose role isn't in the server's role list (e.g. a role deleted + // mid-session) still render, in a gray group, instead of vanishing. + const leftovers = [...buckets.keys()].filter((role) => !rendered.has(role)).toSorted(); + for (const role of leftovers) { + const group: RoleGroup = { role, label: role.toUpperCase(), colorVar: MEMBER_COLOR }; + appendGroup(root, group, buckets.get(role) ?? [], opts, signal, rowsByUserId); + } +} - const header = createElement( - "div", - { class: "member-role-group" }, - `${group.label} \u2014 ${groupMembers.length}`, - ); - root.appendChild(header); +function appendGroup( + root: HTMLDivElement, + group: RoleGroup, + members: readonly Member[], + opts: MemberListOptions, + signal: AbortSignal, + rowsByUserId: Map, +): void { + const groupMembers = members.toSorted( + (a, b) => statusPriority(a.status) - statusPriority(b.status), + ); - for (const member of groupMembers) { - const item = createMemberItem(member, group.colorVar, opts, signal); - rowsByUserId.set(member.id, item); - root.appendChild(item); - } + if (groupMembers.length === 0) return; + + const header = createElement( + "div", + { class: "member-role-group" }, + `${group.label} \u2014 ${groupMembers.length}`, + ); + root.appendChild(header); + + for (const member of groupMembers) { + const item = createMemberItem(member, group.colorVar, opts, signal); + rowsByUserId.set(member.id, item); + root.appendChild(item); } } @@ -246,6 +411,10 @@ function isPresenceOnlyChange( before.username !== member.username || before.role !== member.role || before.avatar !== member.avatar || + before.displayName !== member.displayName || + // A custom status is rendered as its own line, so a change to it is a + // structural change, not a dot recolor. + before.customStatus !== member.customStatus || before.identityPublicKey !== member.identityPublicKey ) { return false; @@ -268,7 +437,7 @@ function patchPresence( if (before === undefined || before.status === member.status) continue; const row = rowsByUserId.get(id); if (row === undefined) continue; - row.classList.toggle("offline", member.status === "offline"); + row.classList.toggle("offline", isAwayStatus(member.status)); const dot = row.querySelector(".mi-status"); if (dot !== null) { dot.style.background = statusColor(member.status); @@ -305,11 +474,26 @@ export function createMemberList(opts: MemberListOptions): MountableComponent { }, ); + // Role groups, their labels and their colors all come from the server's + // role list, which role management makes mutable at runtime (a roles_update + // broadcast replaces it). Without this the list kept the old grouping until + // some unrelated member change happened to force a re-render. + disposable.onStoreChange( + channelsStore, + (s) => s.roles, + () => { + if (root !== null) { + renderList(root, opts, disposable.signal, rowsByUserId); + } + }, + ); + container.appendChild(root); } function destroy(): void { closeActiveMenu(); + closeActivePopup(); document.removeEventListener("mousedown", handleOutsideClick); disposable.destroy(); rowsByUserId.clear(); diff --git a/Client/tauri-client/src/components/MentionAutocomplete.ts b/Client/tauri-client/src/components/MentionAutocomplete.ts new file mode 100644 index 00000000..854668fd --- /dev/null +++ b/Client/tauri-client/src/components/MentionAutocomplete.ts @@ -0,0 +1,125 @@ +/** + * MentionAutocomplete — inline member picker the composer opens on "@". + * Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + */ + +import { createElement, setText } from "@lib/dom"; +import { membersStore } from "@stores/members.store"; +import { currentUserHasPermission } from "@lib/permissions"; +import { Permission } from "@lib/types"; +import { EVERYONE_TOKEN, HERE_TOKEN } from "@lib/mentions"; +import { + createInlineAutocomplete, + type InlineAutocompleteComponent, +} from "@components/inline-autocomplete"; + +/** Maximum rows shown at once — the popup is a shortcut, not the member list. */ +export const MAX_MENTION_SUGGESTIONS = 10; + +export interface MentionSuggestion { + /** Token inserted after the "@", e.g. "alice" or "everyone". */ + readonly token: string; + /** Row label. Equal to `token` for users. */ + readonly label: string; + /** Secondary line (role for users, meaning for @everyone/@here). */ + readonly detail: string; + readonly kind: "user" | "broadcast"; + /** User id, or null for @everyone/@here. */ + readonly userId: number | null; +} + +export interface MentionAutocompleteOptions { + /** Called with the token to insert (without the leading "@"). */ + readonly onSelect: (token: string) => void; + readonly onClose: () => void; +} + +/** Same shape as the shared inline-autocomplete widget. */ +export type MentionAutocompleteComponent = InlineAutocompleteComponent; + +function byLabel(a: MentionSuggestion, b: MentionSuggestion): number { + return a.label.localeCompare(b.label); +} + +/** + * Suggestions for `query`, in the order the popup lists them: prefix matches + * before substring matches, alphabetical within each group. + * + * @everyone / @here are offered only when the signed-in user's role holds + * MENTION_EVERYONE — offering a token the server will refuse to honour would + * be a lie. The server still enforces. + */ +export function filterMentionSuggestions(query: string): MentionSuggestion[] { + const q = query.toLowerCase(); + const prefix: MentionSuggestion[] = []; + const substring: MentionSuggestion[] = []; + + for (const member of membersStore.getState().members.values()) { + const lower = member.username.toLowerCase(); + if (q !== "" && !lower.includes(q)) continue; + const entry: MentionSuggestion = { + token: member.username, + label: member.username, + detail: member.role, + kind: "user", + userId: member.id, + }; + if (q === "" || lower.startsWith(q)) { + prefix.push(entry); + } else { + substring.push(entry); + } + } + + prefix.sort(byLabel); + substring.sort(byLabel); + + const broadcasts: MentionSuggestion[] = []; + if (currentUserHasPermission(Permission.MENTION_EVERYONE)) { + const all: MentionSuggestion[] = [ + { + token: EVERYONE_TOKEN, + label: EVERYONE_TOKEN, + detail: "Notify everyone in this channel", + kind: "broadcast", + userId: null, + }, + { + token: HERE_TOKEN, + label: HERE_TOKEN, + detail: "Notify everyone who is online", + kind: "broadcast", + userId: null, + }, + ]; + broadcasts.push(...all.filter((s) => q === "" || s.token.startsWith(q))); + } + + return [...broadcasts, ...prefix, ...substring].slice(0, MAX_MENTION_SUGGESTIONS); +} + +/** One mention row: `@label` plus a role / broadcast-meaning detail line. */ +function renderMentionRow(s: MentionSuggestion): HTMLElement[] { + const name = createElement("span", { class: "ma-name" }); + setText(name, `@${s.label}`); + const detail = createElement("span", { class: "ma-detail" }); + setText(detail, s.detail); + return [name, detail]; +} + +export function createMentionAutocomplete( + options: MentionAutocompleteOptions, +): MentionAutocompleteComponent { + return createInlineAutocomplete({ + rootClass: "mention-autocomplete", + rootTestId: "mention-autocomplete", + filter: filterMentionSuggestions, + valueOf: (s) => s.token, + rowTestId: (s) => `mention-option-${s.token}`, + renderRow: renderMentionRow, + // Open already populated with the full member list. + primeOnCreate: true, + onSelect: options.onSelect, + onClose: options.onClose, + }); +} diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index adaa515c..ae032d29 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -8,6 +8,16 @@ import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import { createEmojiPicker } from "@components/EmojiPicker"; import { createGifPicker } from "@components/GifPicker"; +import { + createMentionAutocomplete, + type MentionAutocompleteComponent, +} from "@components/MentionAutocomplete"; +import { + createEmojiAutocomplete, + MIN_EMOJI_QUERY, + type EmojiAutocompleteComponent, +} from "@components/EmojiAutocomplete"; +import { listCustomEmoji } from "@stores/emoji.store"; import type { GifApi } from "@lib/gifProvider"; export interface MessageInputOptions { @@ -50,6 +60,59 @@ export type MessageInputComponent = MountableComponent & { openFilePicker(): void; }; +/** Ctrl/Cmd shortcut → markdown marker it wraps the selection in. */ +const FORMAT_MARKERS: Readonly> = { + b: "**", + i: "*", + u: "__", +}; + +export interface WrapResult { + readonly value: string; + readonly selectionStart: number; + readonly selectionEnd: number; +} + +/** + * Wrap (or unwrap) `[start, end)` of `value` in `marker`, returning the new + * value and where the selection should land. With an empty selection the + * markers are inserted around the caret so typing continues inside them. + * + * Pure so the behaviour can be tested without a DOM selection. + */ +export function wrapWithMarker( + value: string, + start: number, + end: number, + marker: string, +): WrapResult { + const selected = value.slice(start, end); + const len = marker.length; + + // Already wrapped — pressing the shortcut again takes the markers back off. + if (selected.length > 2 * len && selected.startsWith(marker) && selected.endsWith(marker)) { + const inner = selected.slice(len, selected.length - len); + return { + value: value.slice(0, start) + inner + value.slice(end), + selectionStart: start, + selectionEnd: start + inner.length, + }; + } + if (value.slice(start - len, start) === marker && value.slice(end, end + len) === marker) { + return { + value: value.slice(0, start - len) + selected + value.slice(end + len), + selectionStart: start - len, + selectionEnd: start - len + selected.length, + }; + } + + return { + value: value.slice(0, start) + marker + selected + marker + value.slice(end), + selectionStart: start + len, + selectionEnd: start + len + selected.length, + }; +} + const TYPING_THROTTLE_MS = 3_000; const MAX_TEXTAREA_HEIGHT = 200; const SEND_DEBOUNCE_MS = 200; @@ -94,6 +157,12 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo let attachmentPreviewBar: HTMLDivElement | null = null; /** Set by mount() when file uploads are wired; backs openFilePicker(). */ let openPicker: (() => void) | null = null; + let mentionPopup: MentionAutocompleteComponent | null = null; + /** Index of the "@" the open popup is completing; -1 when closed. */ + let mentionStart = -1; + let emojiPopup: EmojiAutocompleteComponent | null = null; + /** Index of the ":" the open emoji popup is completing; -1 when closed. */ + let emojiStart = -1; /** Pending attachment IDs to send with the next message. */ const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = @@ -105,6 +174,161 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo /** Timer IDs for cleanup on destroy. */ const activeTimers: Set> = new Set(); + /** + * The @token immediately before the caret, or null. The leading boundary + * mirrors the server's mention rule, so the popup never offers a completion + * for text ("mail@dom") that a send would not resolve as a mention. + */ + function activeMentionToken(): { query: string; start: number } | null { + if (textarea === null) return null; + const caret = textarea.selectionStart; + const before = textarea.value.slice(0, caret); + const match = /(?:^|[^\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{0,64})$/u.exec(before); + if (match === null) return null; + const query = match[1] ?? ""; + return { query, start: caret - query.length - 1 }; + } + + function closeMentionPopup(): void { + if (mentionPopup === null) return; + mentionPopup.destroy(); + mentionPopup = null; + mentionStart = -1; + } + + /** Replace the token under the caret with "@token ". */ + function insertMention(token: string): void { + if (textarea === null || mentionStart < 0) { + closeMentionPopup(); + return; + } + const caret = textarea.selectionStart; + const before = textarea.value.slice(0, mentionStart); + const after = textarea.value.slice(caret); + const inserted = `@${token} `; + textarea.value = before + inserted + after; + const pos = before.length + inserted.length; + textarea.selectionStart = pos; + textarea.selectionEnd = pos; + closeMentionPopup(); + autoResize(); + textarea.focus(); + } + + /** + * The `:token` immediately before the caret, or null. The leading boundary + * keeps the popup out of ordinary prose: a colon that follows a word ("see + * this:thing", a "10:30" clock, an "http://" scheme) is punctuation, not the + * start of a shortcode. A completed `:token:` is skipped too — it is already + * an emoji, and re-offering completions over it would fight the user. + */ + function activeEmojiToken(): { query: string; start: number } | null { + if (textarea === null) return null; + const caret = textarea.selectionStart; + const before = textarea.value.slice(0, caret); + const match = /(?:^|\s):([A-Za-z0-9_]{0,32})$/.exec(before); + if (match === null) return null; + const query = match[1] ?? ""; + if (query.length < MIN_EMOJI_QUERY) return null; + return { query, start: caret - query.length - 1 }; + } + + function closeEmojiPopup(): void { + if (emojiPopup === null) return; + emojiPopup.destroy(); + emojiPopup = null; + emojiStart = -1; + } + + /** Replace the `:token` under the caret with the chosen emoji, plus a space. */ + function insertEmoji(insert: string): void { + if (textarea === null || emojiStart < 0) { + closeEmojiPopup(); + return; + } + const caret = textarea.selectionStart; + const before = textarea.value.slice(0, emojiStart); + const after = textarea.value.slice(caret); + const inserted = `${insert} `; + textarea.value = before + inserted + after; + const pos = before.length + inserted.length; + textarea.selectionStart = pos; + textarea.selectionEnd = pos; + closeEmojiPopup(); + autoResize(); + textarea.focus(); + } + + /** Open, refilter, or close the emoji popup for whatever is under the caret. */ + function syncEmojiPopup(): void { + const active = disabledReason === null ? activeEmojiToken() : null; + if (active === null) { + closeEmojiPopup(); + return; + } + if (emojiPopup === null) { + emojiPopup = createEmojiAutocomplete({ + onSelect: insertEmoji, + onClose: closeEmojiPopup, + }); + root?.appendChild(emojiPopup.element); + } + emojiStart = active.start; + if (!emojiPopup.setQuery(active.query)) { + closeEmojiPopup(); + } + } + + /** Apply a formatting marker to the current textarea selection. */ + function applyFormatting(marker: string): void { + if (textarea === null || disabledReason !== null) return; + const result = wrapWithMarker( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + marker, + ); + textarea.value = result.value; + textarea.selectionStart = result.selectionStart; + textarea.selectionEnd = result.selectionEnd; + autoResize(); + maybeEmitTyping(); + } + + /** Open, refilter, or close the popup for whatever is under the caret. */ + function syncMentionPopup(): void { + const active = disabledReason === null ? activeMentionToken() : null; + if (active === null) { + closeMentionPopup(); + return; + } + if (mentionPopup === null) { + mentionPopup = createMentionAutocomplete({ + onSelect: insertMention, + onClose: closeMentionPopup, + }); + root?.appendChild(mentionPopup.element); + } + mentionStart = active.start; + if (!mentionPopup.setQuery(active.query)) { + closeMentionPopup(); + } + } + + /** + * Drive both completion popups from one caret position. Only one can be open: + * the caret sits in exactly one token, and two stacked popups over the same + * textarea would race for the arrow keys. + */ + function syncAutocomplete(): void { + syncMentionPopup(); + if (mentionPopup !== null) { + closeEmojiPopup(); + return; + } + syncEmojiPopup(); + } + function showReplyBar(username: string): void { if (replyBar === null || replyText === null) return; setText(replyText, `Replying to @${username}`); @@ -479,12 +703,32 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo () => { autoResize(); maybeEmitTyping(); + syncAutocomplete(); }, { signal }, ); textarea.addEventListener( "keydown", (e: KeyboardEvent) => { + // Whichever popup is open owns navigation keys, so Enter completes the + // token instead of sending a half-typed message. + if (mentionPopup?.handleKeydown(e) === true) return; + if (emojiPopup?.handleKeydown(e) === true) return; + + // Ctrl+B / Ctrl+I / Ctrl+U wrap the selection in markdown markers. + // The composer owns Ctrl+U while it has focus, so the propagation stop + // is load-bearing: without it the global upload shortcut would fire on + // top of the underline. + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) { + const marker = FORMAT_MARKERS[e.key.toLowerCase()]; + if (marker !== undefined) { + e.preventDefault(); + e.stopPropagation(); + applyFormatting(marker); + return; + } + } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); @@ -520,6 +764,17 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo { signal }, ); + // Caret moves that aren't typing (click, blur) also decide the popup's fate. + textarea.addEventListener("click", syncAutocomplete, { signal }); + textarea.addEventListener( + "blur", + () => { + closeMentionPopup(); + closeEmojiPopup(); + }, + { signal }, + ); + sendBtn.addEventListener("click", handleSend, { signal }); // Picker state (declared together so both toggle functions can cross-close) @@ -558,6 +813,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo return; } emojiPicker = createEmojiPicker({ + // Read the set at open time, not at mount: an emoji_update while the + // composer is alive must be in the next picker the user opens. + customEmoji: listCustomEmoji(), onSelect: (emoji: string) => { if (textarea !== null) { const start = textarea.selectionStart; @@ -649,6 +907,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo cleanupPickers = () => { closeEmojiPicker(); closeGifPicker(); + closeMentionPopup(); + closeEmojiPopup(); }; appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn); diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 9cc69830..33aa3fe7 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -11,13 +11,22 @@ import { getChannelMessages, hasMoreMessages, getHistoryLoadState, + isWindowDetached, } from "@stores/messages.store"; import type { Message } from "@stores/messages.store"; import { membersStore } from "@stores/members.store"; import { unobserveMedia } from "@lib/media-visibility"; const log = createLogger("message-list"); -import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers"; +import { + shouldGroup, + isSameDay, + renderDayDivider, + renderNewDivider, + renderMessage, +} from "./message-list/renderers"; +import { getUnreadOnOpen } from "@stores/channels.store"; +import { isAudioMime, isVideoMime } from "./message-list/attachments"; import { FenwickTree } from "./message-list/fenwick"; // -- Options ------------------------------------------------------------------ @@ -39,6 +48,17 @@ export interface MessageListOptions { readonly onDeleteDraft?: (correlationId: string) => void; /** Retry a failed first-page history fetch. */ readonly onRetryLoad?: () => void; + /** + * Jump to another message in this channel — the reply bar above a reply, and + * any other in-row affordance. May target a message outside the loaded + * window; the handler is expected to fetch the around-window in that case. + */ + readonly onJumpToMessage?: (messageId: number) => void; + /** + * Leave a detached around-window and reload the live tail. Wired to the + * "Jump to Present" pill, which only appears while the window is detached. + */ + readonly onJumpToPresent?: () => void; } // -- Constants ---------------------------------------------------------------- @@ -68,20 +88,31 @@ interface VirtualItemDivider { readonly timestamp: string; } -type VirtualItem = VirtualItemMessage | VirtualItemDivider; +/** The "NEW" line marking where the reader's unread messages begin. At most + * one per list, and only for a visit that opened with unread messages. */ +interface VirtualItemNewDivider { + readonly kind: "new-divider"; +} + +type VirtualItem = VirtualItemMessage | VirtualItemDivider | VirtualItemNewDivider; // -- Smart height estimation -------------------------------------------------- function estimateItemHeight(item: VirtualItem): number { - if (item.kind === "divider") return 32; + if (item.kind === "divider" || item.kind === "new-divider") return 32; // Non-grouped: min-height 2.75rem (44px @16px root) + margin-top 17px = 61px // Grouped: min-height 1.375rem (22px @16px root) + margin-top 0px = 22px let height = item.isGrouped ? 22 : 61; - // Image attachments + // Media attachments. Video shares the image box, so it reserves the same + // space; the audio player is a chip-height row. for (const att of item.message.attachments) { - if (att.mime.startsWith("image/")) { + if (isVideoMime(att.mime)) { + height += 220; + } else if (isAudioMime(att.mime)) { + height += 96; + } else if (att.mime.startsWith("image/")) { height += 220; } } @@ -108,16 +139,24 @@ function buildVirtualItems( messages: readonly Message[], seedPrevMsg: Message | null = null, seedLastTimestamp: string | null = null, + newDividerAt = -1, ): readonly VirtualItem[] { const items: VirtualItem[] = []; let lastTimestamp: string | null = seedLastTimestamp; let prevMsg: Message | null = seedPrevMsg; - for (const msg of messages) { + for (const [i, msg] of messages.entries()) { if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) { items.push({ kind: "divider", timestamp: msg.timestamp }); } - const isGrouped = prevMsg !== null && shouldGroup(prevMsg, msg); + const isFirstUnread = i === newDividerAt; + if (isFirstUnread) { + items.push({ kind: "new-divider" }); + } + // A message directly under the NEW line starts a fresh block: rendering it + // as a grouped continuation of a message from before the line hides both + // its author and the fact that the line is there. + const isGrouped = !isFirstUnread && prevMsg !== null && shouldGroup(prevMsg, msg); items.push({ kind: "message", message: msg, isGrouped }); lastTimestamp = msg.timestamp; prevMsg = msg; @@ -125,6 +164,19 @@ function buildVirtualItems( return items; } +/** + * Index of the first unread message in `messages`, or -1 for none. + * + * Derived from the unread count the channel had when it was opened (the + * badge itself is cleared by the visit): the last N loaded messages are the + * unread ones. Clamped to 0 when the whole loaded window is unread, and + * suppressed at 0-length so an empty channel never renders a lone divider. + */ +function firstUnreadIndex(messages: readonly Message[], unreadOnOpen: number): number { + if (unreadOnOpen <= 0 || messages.length === 0) return -1; + return Math.max(0, messages.length - unreadOnOpen); +} + // -- Empty state -------------------------------------------------------------- function renderEmptyState(channelName: string, channelType?: string): HTMLDivElement { @@ -197,17 +249,39 @@ export function createMessageList(options: MessageListOptions): MessageListCompo let bottomSpacer: HTMLDivElement | null = null; let contentContainer: HTMLDivElement | null = null; let scrollToBottomBtn: HTMLButtonElement | null = null; + let jumpToPresentPill: HTMLButtonElement | null = null; let renderedStart = 0; let renderedEnd = 0; + /** + * Unread count this channel carried when the visit that created this list + * began. Read once here, not per render: the badge is cleared by the visit + * itself, and the divider must stay put for the whole visit rather than + * jumping as new messages arrive. Zero once the reader comes back, which is + * what makes the divider clear on the next visit. + * + * Suppressed while the window is detached (jumped to an old message): the + * loaded slice is then not the tail, so "the last N messages" would put the + * line somewhere arbitrary. + */ + const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId); + // --------------------------------------------------------------------------- // Height estimation (Fenwick tree backed) // --------------------------------------------------------------------------- + /** Render one virtual item — the single place the three item kinds map to DOM. */ + function renderVirtualItem(item: VirtualItem): HTMLElement { + if (item.kind === "divider") return renderDayDivider(item.timestamp); + if (item.kind === "new-divider") return renderNewDivider(); + return renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal); + } + function itemKey(index: number): string { const item = virtualItems[index]; if (item === undefined) return `idx-${index}`; if (item.kind === "divider") return `div-${item.timestamp}`; + if (item.kind === "new-divider") return "new-divider"; return `msg-${item.message.id}`; } @@ -271,6 +345,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } } + /** The pill is the only signal that the bottom of the list is not "now". */ + function updateJumpToPresentPill(): void { + if (jumpToPresentPill === null) return; + jumpToPresentPill.classList.toggle("visible", isWindowDetached(options.channelId)); + } + // --------------------------------------------------------------------------- // Render visible window // --------------------------------------------------------------------------- @@ -410,14 +490,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo 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), - ); - } + fragment.appendChild(renderVirtualItem(virtualItems[i]!)); } contentContainer.appendChild(fragment); @@ -439,7 +512,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo function rebuildItems(): void { allMessages = getChannelMessages(options.channelId); - virtualItems = buildVirtualItems(allMessages); + virtualItems = buildVirtualItems( + allMessages, + null, + null, + firstUnreadIndex(allMessages, unreadOnOpen), + ); // Build Fenwick tree initialized with smart estimates / cached heights tree = new FenwickTree(virtualItems.length); @@ -516,13 +594,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo // The rendered window includes the old tail — append the new rows. const fragment = document.createDocumentFragment(); for (const item of appendedItems) { - if (item.kind === "divider") { - fragment.appendChild(renderDayDivider(item.timestamp)); - } else { - fragment.appendChild( - renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal), - ); - } + fragment.appendChild(renderVirtualItem(item)); } contentContainer.appendChild(fragment); renderedEnd = virtualItems.length; @@ -676,11 +748,21 @@ export function createMessageList(options: MessageListOptions): MessageListCompo { signal: ac.signal }, ); + jumpToPresentPill = createElement("button", { + class: "jump-to-present-pill", + "data-testid": "jump-to-present", + }); + jumpToPresentPill.textContent = "Jump to Present ↓"; + jumpToPresentPill.addEventListener("click", () => options.onJumpToPresent?.(), { + signal: ac.signal, + }); + root.appendChild(topSpacer); root.appendChild(contentContainer); root.appendChild(bottomSpacer); root.appendChild(scrollAnchor); root.appendChild(scrollToBottomBtn); + root.appendChild(jumpToPresentPill); root.addEventListener("scroll", handleScroll, { signal: ac.signal, @@ -722,6 +804,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo parentContainer.appendChild(root); renderAll(); + updateJumpToPresentPill(); scrollToBottom(); const initialScrollRaf = requestAnimationFrame(() => scrollToBottom()); ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf)); @@ -750,6 +833,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo ), ); + // Show/hide the pill as the window detaches from (and reattaches to) the + // live tail. No re-render — only the pill's visibility changes. + unsubscribers.push( + messagesStore.subscribeSelector( + (s) => s.detachedChannels.has(options.channelId), + () => { + updateJumpToPresentPill(); + }, + ), + ); + // Only re-render when member roles change, not on presence/typing updates. // The store bumps roleRevision solely on membership/role mutations, so // selecting the counter avoids rebuilding a role map per notification. @@ -801,6 +895,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo topSpacer = null; bottomSpacer = null; scrollToBottomBtn = null; + jumpToPresentPill = null; } function scrollToMessage(messageId: number): boolean { @@ -811,6 +906,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo if (idx === -1) return false; root.scrollTop = offsetBefore(idx); + // Force the rebuild path: a scroll-driven renderWindow only moves spacers, + // so without this the target row can sit outside the rendered window and + // there is nothing to flash (and nothing to look at after the scroll). + renderedStart = -1; renderWindow(); // Briefly highlight the target message element @@ -819,9 +918,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo const el = contentContainer.children[localIdx] as HTMLElement | undefined; if (el !== undefined) { el.classList.add("highlight-flash"); - setTimeout(() => { + const timer = window.setTimeout(() => { el.classList.remove("highlight-flash"); }, 1500); + // Unmounting mid-flash must not leave a timer pointing at a dead node. + ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); } } diff --git a/Client/tauri-client/src/components/NsfwGate.ts b/Client/tauri-client/src/components/NsfwGate.ts new file mode 100644 index 00000000..41a37f81 --- /dev/null +++ b/Client/tauri-client/src/components/NsfwGate.ts @@ -0,0 +1,113 @@ +/** + * NsfwGate — the age-gate shown over a channel flagged NSFW. + * + * The server does nothing with the flag beyond storing and broadcasting it (see + * `@lib/nsfw-gate`), so this overlay is the whole of the feature on the reading + * side. It covers the message area rather than replacing it: the channel is + * mounted and live underneath, and accepting the warning reveals it without a + * refetch. + * + * Deliberately not a `.modal-overlay`: a modal is a decision about the app, + * while this is a property of the channel you just opened. It fills its + * container, so mounting it into the messages slot gates exactly the content it + * is warning about and leaves the sidebar and header usable. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import { createIcon } from "@lib/icons"; +import type { MountableComponent } from "@lib/safe-render"; +import { acknowledgeNsfw } from "@lib/nsfw-gate"; + +export interface NsfwGateOptions { + /** Channel being gated — its id keys the per-session acknowledgement. */ + readonly channelId: number; + /** Channel name, shown without the leading '#'. */ + readonly channelName: string; + /** Called after the acknowledgement is recorded. */ + readonly onContinue: () => void; + /** + * Called when the reader declines. Optional: without it the gate offers only + * "Continue", which is right for a container the reader can simply navigate + * away from. + */ + readonly onCancel?: () => void; +} + +export function createNsfwGate(options: NsfwGateOptions): MountableComponent { + const { channelId, channelName, onContinue, onCancel } = options; + const ac = new AbortController(); + let root: HTMLDivElement | null = null; + + function mount(container: Element): void { + root = createElement("div", { + class: "nsfw-gate", + "data-testid": "nsfw-gate", + role: "dialog", + "aria-modal": "false", + "aria-label": `Age restricted channel ${channelName}`, + }); + + const card = createElement("div", { class: "nsfw-gate-card" }); + + const iconWrap = createElement("div", { class: "nsfw-gate-icon" }); + iconWrap.appendChild(createIcon("shield-alert", 40)); + + const title = createElement("h2", { class: "nsfw-gate-title" }); + setText(title, `#${channelName}`); + + const body = createElement("p", { class: "nsfw-gate-body" }); + setText(body, "This channel may contain sensitive content — Continue?"); + + // Says plainly what the flag is and is not, so nobody reads the gate as a + // promise the server is filtering something. + const note = createElement("p", { class: "nsfw-gate-note" }); + setText( + note, + "The channel has been marked age-restricted by a moderator. Nothing is filtered — you are only being asked once per session.", + ); + + const actions = createElement("div", { class: "nsfw-gate-actions" }); + + if (onCancel !== undefined) { + const backBtn = createElement( + "button", + { class: "btn-modal-cancel", type: "button", "data-testid": "nsfw-gate-back" }, + "Go Back", + ); + backBtn.addEventListener("click", onCancel, { signal: ac.signal }); + actions.appendChild(backBtn); + } + + const continueBtn = createElement( + "button", + { class: "btn-modal-save", type: "button", "data-testid": "nsfw-gate-continue" }, + "Continue", + ); + continueBtn.addEventListener( + "click", + () => { + // Record first, then notify: the caller's handler tears this component + // down, and an acknowledgement written afterwards would race it. + acknowledgeNsfw(channelId); + onContinue(); + }, + { signal: ac.signal }, + ); + actions.appendChild(continueBtn); + + appendChildren(card, iconWrap, title, body, note, actions); + root.appendChild(card); + container.appendChild(root); + continueBtn.focus(); + } + + function destroy(): void { + ac.abort(); + if (root !== null) { + root.remove(); + root = null; + } + } + + return { mount, destroy }; +} diff --git a/Client/tauri-client/src/components/SearchOverlay.ts b/Client/tauri-client/src/components/SearchOverlay.ts index f647b0e6..8d48815e 100644 --- a/Client/tauri-client/src/components/SearchOverlay.ts +++ b/Client/tauri-client/src/components/SearchOverlay.ts @@ -113,7 +113,16 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom function doSearch(): void { const now = Date.now(); - if (now - lastSearchTime < MIN_SEARCH_INTERVAL_MS) return; + const sinceLast = now - lastSearchTime; + if (sinceLast < MIN_SEARCH_INTERVAL_MS) { + // Too soon after the previous search. Don't drop this query — that would + // leave the earlier query's results on screen for what the user is now + // typing. Reschedule for when the rate-limit window opens, reusing the + // debounce timer so destroy() still tears it down. + if (debounceTimer !== null) window.clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(doSearch, MIN_SEARCH_INTERVAL_MS - sinceLast); + return; + } lastSearchTime = now; const query = input.value.trim(); diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts index 4a924455..f5d3645f 100644 --- a/Client/tauri-client/src/components/SettingsOverlay.ts +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -28,7 +28,18 @@ import { createLogsTab } from "./settings/LogsTab"; export interface SettingsOverlayOptions { onClose(): void; onChangePassword(oldPassword: string, newPassword: string): Promise; - onUpdateProfile(username: string): Promise; + /** + * Patch the signed-in user's profile. Every field is optional and omitted + * means "leave unchanged"; an empty string clears the nullable ones, which + * is how the API itself distinguishes the two. + */ + onUpdateProfile(patch: { + username?: string; + display_name?: string; + about?: string; + }): Promise; + /** Upload an avatar image. Resolves with the URL the server stored. */ + onUploadAvatar(file: File): Promise; onLogout(): void; onDeleteAccount(password: string): Promise; onStatusChange(status: UserStatus): void; diff --git a/Client/tauri-client/src/components/StatusPicker.ts b/Client/tauri-client/src/components/StatusPicker.ts index 90638c86..780f0a50 100644 --- a/Client/tauri-client/src/components/StatusPicker.ts +++ b/Client/tauri-client/src/components/StatusPicker.ts @@ -8,6 +8,7 @@ import { createElement, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; +import { MAX_CUSTOM_STATUS_LEN } from "@lib/userStatus"; // --------------------------------------------------------------------------- // Types @@ -16,11 +17,18 @@ import type { UserStatus } from "@lib/types"; export interface StatusPickerOptions { readonly currentStatus: UserStatus; readonly onStatusChange: (status: UserStatus) => void; + /** The custom status line to pre-fill the input with. */ + readonly currentCustomStatus?: string; + /** Called when the user commits a custom status (Enter or blur). Passing an + * empty string means "clear it". Omitted = the input is not rendered. */ + readonly onCustomStatusChange?: (text: string) => void; } export type StatusPickerComponent = MountableComponent & { /** Update the displayed status without recreating the picker. */ setStatus(status: UserStatus): void; + /** Update the custom status input without recreating the picker. */ + setCustomStatus(text: string): void; }; // --------------------------------------------------------------------------- @@ -33,11 +41,17 @@ interface StatusDef { readonly color: string; } +/** + * "invisible" is its own value now, not "offline" wearing a different label. + * The server stores it as chosen and shows everyone else offline, so the + * picker can finally send what it means — and the status survives a reconnect + * instead of flashing back to online. + */ const STATUS_DEFS: readonly StatusDef[] = [ { value: "online", label: "Online", color: "#3ba55d" }, { value: "idle", label: "Idle", color: "#faa61a" }, { value: "dnd", label: "Do Not Disturb", color: "#ed4245" }, - { value: "offline", label: "Invisible", color: "#747f8d" }, + { value: "invisible", label: "Invisible", color: "#747f8d" }, ]; function colorForStatus(status: UserStatus): string { @@ -57,6 +71,11 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo let dotEl: HTMLDivElement | null = null; let dropdownEl: HTMLDivElement | null = null; let checkEls = new Map(); + let customInputEl: HTMLInputElement | null = null; + /** Last text handed to the callback. Guards the blur-after-Enter double + * send, which would otherwise cost a second presence_update against the + * server's one-per-ten-seconds limit. */ + let lastCommittedCustom = options.currentCustomStatus ?? ""; // ---- Dropdown visibility -------------------------------------------------- @@ -144,6 +163,58 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo return row; } + /** + * The "Set a custom status" row. Only built when a handler was supplied — + * an input whose value goes nowhere is worse than no input. + */ + function buildCustomStatusRow(onChange: (text: string) => void): HTMLDivElement { + const row = createElement("div", { class: "status-picker-custom" }); + const input = createElement("input", { + class: "status-picker-custom-input", + type: "text", + placeholder: "Set a custom status", + maxlength: String(MAX_CUSTOM_STATUS_LEN), + "aria-label": "Custom status", + "data-testid": "custom-status-input", + }); + input.value = options.currentCustomStatus ?? ""; + customInputEl = input; + + const commit = (): void => { + const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN); + if (text === lastCommittedCustom) return; + lastCommittedCustom = text; + input.value = text; + onChange(text); + }; + + input.addEventListener( + "keydown", + (e: KeyboardEvent) => { + // Keystrokes inside the input must not reach the dropdown's own + // Enter/Escape handling, which would close the menu mid-edit. + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + commit(); + closeDropdown(); + } else if (e.key === "Escape") { + e.preventDefault(); + input.value = lastCommittedCustom; + closeDropdown(); + } + }, + { signal }, + ); + input.addEventListener("blur", commit, { signal }); + // The row is inside the dropdown; clicking the input must not be treated + // as picking a status or as an outside click. + input.addEventListener("click", (e: MouseEvent) => e.stopPropagation(), { signal }); + + row.appendChild(input); + return row; + } + // ---- MountableComponent --------------------------------------------------- function mount(container: Element): void { @@ -186,6 +257,11 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo for (const def of STATUS_DEFS) { dropdownEl.appendChild(buildOption(def)); } + const onCustomStatusChange = options.onCustomStatusChange; + if (onCustomStatusChange !== undefined) { + dropdownEl.appendChild(createElement("div", { class: "status-picker-divider" })); + dropdownEl.appendChild(buildCustomStatusRow(onCustomStatusChange)); + } appendChildren(root, dotEl, dropdownEl); container.appendChild(root); @@ -221,11 +297,17 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo root = null; dotEl = null; dropdownEl = null; + customInputEl = null; } function setStatus(status: UserStatus): void { applyStatus(status); } - return { mount, destroy, setStatus }; + function setCustomStatus(text: string): void { + lastCommittedCustom = text; + if (customInputEl !== null) customInputEl.value = text; + } + + return { mount, destroy, setStatus, setCustomStatus }; } diff --git a/Client/tauri-client/src/components/UserBar.ts b/Client/tauri-client/src/components/UserBar.ts index b60f13a4..10142789 100644 --- a/Client/tauri-client/src/components/UserBar.ts +++ b/Client/tauri-client/src/components/UserBar.ts @@ -11,7 +11,15 @@ import { authStore } from "@stores/auth.store"; import { openSettings, uiStore } from "@stores/ui.store"; import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker"; import type { UserStatus } from "@lib/types"; -import { loadUserStatus, onUserStatusChange, saveUserStatus } from "@lib/userStatus"; +import { + loadCustomStatus, + loadUserStatus, + onUserStatusChange, + saveCustomStatus, + saveUserStatus, +} from "@lib/userStatus"; +import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar"; +import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments"; import type { WsClient } from "@lib/ws"; export interface UserBarOptions { @@ -19,6 +27,15 @@ export interface UserBarOptions { readonly ws?: WsClient | null; } +/** Status labels for the line under the username. */ +const STATUS_TEXT: Readonly> = { + online: "Online", + idle: "Idle", + dnd: "Do Not Disturb", + invisible: "Invisible", + offline: "Offline", +}; + export function createUserBar(options?: UserBarOptions): MountableComponent { const disposable = new Disposable(); let root: HTMLDivElement | null = null; @@ -26,24 +43,71 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { // Element references for targeted updates let avatarEl: HTMLDivElement | null = null; let avatarTextEl: HTMLSpanElement | null = null; + let avatarImgEl: HTMLImageElement | null = null; + /** Avatar URL currently rendered, so a re-render for an unrelated auth + * change doesn't re-fetch the same picture. */ + let renderedAvatarUrl: string | null = null; let nameEl: HTMLSpanElement | null = null; let statusEl: HTMLSpanElement | null = null; let statusPicker: StatusPickerComponent | null = null; + /** Swap the letter for the uploaded picture, or back again. */ + function renderAvatar(subject: { + username: string; + displayName: string | null; + avatar: string | null; + }): void { + if (avatarEl === null) return; + if (avatarTextEl !== null) setText(avatarTextEl, avatarInitial(subject)); + + const url = isRenderableAvatar(subject.avatar) ? resolveServerUrl(subject.avatar) : null; + if (url === renderedAvatarUrl) return; + renderedAvatarUrl = url; + + if (avatarImgEl !== null) { + avatarImgEl.remove(); + avatarImgEl = null; + } + if (url === null) { + if (avatarTextEl !== null) avatarTextEl.style.display = ""; + avatarEl.style.background = "var(--accent)"; + return; + } + void fetchImageAsDataUrl(url).then((dataUrl) => { + // The URL may have changed again (or the bar been torn down) while the + // bytes were in flight. + if (dataUrl === null || avatarEl === null || renderedAvatarUrl !== url) return; + const img = createElement("img", { + class: "avatar-img", + src: dataUrl, + alt: subject.username, + }); + avatarImgEl = img; + if (avatarTextEl !== null) avatarTextEl.style.display = "none"; + avatarEl.style.background = "transparent"; + avatarEl.insertBefore(img, avatarEl.firstChild); + }); + } + function updateFromState(): void { const state = authStore.getState(); const user = state.user; - const username = user?.username ?? "Unknown"; - const initial = username.charAt(0).toUpperCase() || "?"; + const subject = { + username: user?.username ?? "Unknown", + displayName: user?.display_name ?? null, + avatar: user?.avatar ?? null, + }; - if (avatarTextEl !== null) { - setText(avatarTextEl, initial); - } + renderAvatar(subject); if (nameEl !== null) { - setText(nameEl, username); + setText(nameEl, resolveDisplayName(subject)); } if (statusEl !== null) { - setText(statusEl, state.isAuthenticated ? "Online" : "Offline"); + // The bar shows the user's own chosen status, invisible included — + // everyone else is told offline, but lying to the owner about their own + // state is exactly the bug real invisible exists to fix. + const text = state.isAuthenticated ? (STATUS_TEXT[loadUserStatus()] ?? "Online") : "Offline"; + setText(statusEl, text); } } @@ -86,21 +150,39 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { // Start from the stored selection, not a hardcoded "online" — otherwise // this picker and the settings Account tab show different statuses. currentStatus: loadUserStatus(), + currentCustomStatus: loadCustomStatus(), onStatusChange: (status: UserStatus) => { saveUserStatus(status); + updateFromState(); const ws = options?.ws; if (ws !== null && ws !== undefined && canSetStatus()) { + // No custom_status field: a plain status change must leave whatever + // text the user set standing. ws.send({ type: "presence_update", payload: { status } } as never); } }, + onCustomStatusChange: (text: string) => { + saveCustomStatus(text); + const ws = options?.ws; + if (ws !== null && ws !== undefined && canSetStatus()) { + ws.send({ + type: "presence_update", + payload: { status: loadUserStatus(), custom_status: text }, + } as never); + } + }, }); statusPicker.mount(statusPickerWrap); // Reflect status changes made on the settings Account tab. disposable.addCleanup( - onUserStatusChange((status) => statusPicker?.setStatus(status), { - signal: disposable.signal, - }), + onUserStatusChange( + (status) => { + statusPicker?.setStatus(status); + updateFromState(); + }, + { signal: disposable.signal }, + ), ); // Disable picker (with a reason) when the connection is down @@ -172,6 +254,8 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { } avatarEl = null; avatarTextEl = null; + avatarImgEl = null; + renderedAvatarUrl = null; nameEl = null; statusEl = null; } diff --git a/Client/tauri-client/src/components/UserProfilePopup.ts b/Client/tauri-client/src/components/UserProfilePopup.ts index 6e635f2b..00423a34 100644 --- a/Client/tauri-client/src/components/UserProfilePopup.ts +++ b/Client/tauri-client/src/components/UserProfilePopup.ts @@ -9,11 +9,12 @@ * A11y: role="dialog", aria-label, focus trap, return focus on close. */ -import { createElement, appendChildren } from "@lib/dom"; +import { createElement, appendChildren, setText } from "@lib/dom"; import { createIcon } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import type { UserStatus } from "@lib/types"; -import { isSafeUrl } from "./message-list/attachments"; +import { createAvatarElement, resolveDisplayName } from "@lib/avatar"; +import { roleColorVar } from "./message-list/formatting"; // --------------------------------------------------------------------------- // Types @@ -25,7 +26,12 @@ export interface UserProfileData { readonly avatar: string | null; readonly role: string; readonly status: UserStatus; + /** Nickname. When set the popup shows it as the heading and the username + * underneath, because the username is still the handle you @mention. */ + readonly displayName?: string | null; readonly about?: string | null; + /** Free-text status line, shown under the name. */ + readonly customStatus?: string | null; readonly joinDate?: string | null; readonly isDeleted?: boolean; } @@ -58,6 +64,9 @@ const STATUS_COLORS: Record = { online: "#3ba55d", idle: "#faa61a", dnd: "#ed4245", + // Only ever reached for the signed-in user looking at their own profile — + // the server maps invisible to offline for everyone else. + invisible: "#747f8d", offline: "#747f8d", }; @@ -65,16 +74,10 @@ const STATUS_LABELS: Record = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", + invisible: "Invisible", offline: "Offline", }; -const ROLE_COLORS: Record = { - owner: "#e74c3c", - admin: "#f39c12", - moderator: "#2ecc71", - member: "#949ba4", -}; - // --------------------------------------------------------------------------- // Component factory // --------------------------------------------------------------------------- @@ -132,28 +135,22 @@ export function createUserProfilePopup( } function buildAvatar(user: UserProfileData): HTMLDivElement { - const wrapper = createElement("div", { class: "upp-avatar" }); - - if (user.isDeleted === true) { - wrapper.style.background = "#4e5058"; - const text = createElement("span", {}, "?"); - wrapper.appendChild(text); - } else if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) { - const img = createElement("img", { - src: user.avatar, - alt: user.username, - class: "upp-avatar-img", - }); - img.style.width = "64px"; - img.style.height = "64px"; - img.style.borderRadius = "50%"; - wrapper.appendChild(img); - } else { - wrapper.style.background = "var(--accent, #5865f2)"; - const initial = user.username.charAt(0).toUpperCase() || "?"; - const text = createElement("span", {}, initial); - wrapper.appendChild(text); - } + // The shared helper is what makes uploaded avatars work here and in the + // message rows and member list at the same time: it fetches the + // authenticated file through the cert-pinned path and falls back to the + // letter until (or unless) the bytes arrive. + const wrapper = createAvatarElement( + { + username: user.username, + displayName: user.displayName, + avatar: user.avatar, + isDeleted: user.isDeleted, + }, + { + className: "upp-avatar", + background: user.isDeleted === true ? "#4e5058" : "var(--accent, #5865f2)", + }, + ); // Status dot overlay const statusDot = createElement("div", { class: "upp-status-dot" }); @@ -167,7 +164,7 @@ export function createUserProfilePopup( function mount(container: Element): void { previousFocus = document.activeElement; const user = options.user; - const displayName = user.isDeleted === true ? "[deleted]" : user.username; + const displayName = user.isDeleted === true ? "[deleted]" : resolveDisplayName(user); // Overlay for outside-click detection overlay = createElement("div", { @@ -207,10 +204,24 @@ export function createUserProfilePopup( nameEl.style.color = "var(--text-faint, #80848e)"; } + // Username line, shown only when a display name is standing in for it. + // @mentions still resolve by username, so the popup has to keep telling + // you what to type. + const handleEl = createElement("div", { class: "upp-username-handle" }); + if (user.isDeleted !== true && displayName !== user.username) { + setText(handleEl, `@${user.username}`); + } + + // Custom status line — the user's own words, under the name. + const customStatusEl = createElement("div", { class: "upp-custom-status" }); + if (typeof user.customStatus === "string" && user.customStatus.length > 0) { + setText(customStatusEl, user.customStatus); + } + // Role badge const roleBadge = createElement("span", { class: "upp-role-badge" }); const roleDot = createElement("span", { class: "upp-role-dot" }); - roleDot.style.background = ROLE_COLORS[user.role] ?? ROLE_COLORS.member ?? ""; + roleDot.style.background = roleColorVar(user.role.toLowerCase()); const roleLabel = createElement( "span", {}, @@ -244,53 +255,64 @@ export function createUserProfilePopup( // Divider const divider = createElement("div", { class: "upp-divider" }); - // Actions + // Actions — only render buttons that are actually wired up, so the popup + // never shows a dead control (e.g. Call before DM calls exist, or Message + // on your own profile). const actions = createElement("div", { class: "upp-actions" }); - const messageBtn = createElement("button", { - class: "upp-action-btn", - "data-testid": "upp-message-btn", - }); - messageBtn.appendChild(createIcon("send", 16)); - messageBtn.appendChild(document.createTextNode(" Message")); - messageBtn.addEventListener( - "click", - () => { - options.onMessage?.(user.id); - close(); - }, - { signal }, - ); + if (options.onMessage !== undefined) { + const onMessage = options.onMessage; + const messageBtn = createElement("button", { + class: "upp-action-btn", + "data-testid": "upp-message-btn", + }); + messageBtn.appendChild(createIcon("send", 16)); + messageBtn.appendChild(document.createTextNode(" Message")); + messageBtn.addEventListener( + "click", + () => { + onMessage(user.id); + close(); + }, + { signal }, + ); + actions.appendChild(messageBtn); + } - const callBtn = createElement("button", { - class: "upp-action-btn", - "data-testid": "upp-call-btn", - }); - callBtn.appendChild(createIcon("phone", 16)); - callBtn.appendChild(document.createTextNode(" Call")); - callBtn.addEventListener( - "click", - () => { - options.onCall?.(user.id); - close(); - }, - { signal }, - ); - - appendChildren(actions, messageBtn, callBtn); + if (options.onCall !== undefined) { + const onCall = options.onCall; + const callBtn = createElement("button", { + class: "upp-action-btn", + "data-testid": "upp-call-btn", + }); + callBtn.appendChild(createIcon("phone", 16)); + callBtn.appendChild(document.createTextNode(" Call")); + callBtn.addEventListener( + "click", + () => { + onCall(user.id); + close(); + }, + { signal }, + ); + actions.appendChild(callBtn); + } // Assemble popup appendChildren( popup, avatar, nameEl, + handleEl, + customStatusEl, roleBadge, statusLine, aboutSection, joinSection, - divider, - actions, ); + if (actions.childElementCount > 0) { + appendChildren(popup, divider, actions); + } overlay.appendChild(popup); container.appendChild(overlay); diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts index 6abbcaf0..383efcc1 100644 --- a/Client/tauri-client/src/components/VoiceWidget.ts +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -11,6 +11,7 @@ import type { IconName } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; import { voiceStore, type VoiceStatus } from "@stores/voice.store"; import { channelsStore } from "@stores/channels.store"; +import { dmStore, dmDisplayName } from "@stores/dm.store"; import { uiStore } from "@stores/ui.store"; import { createConnectionStatsPoller, @@ -230,22 +231,44 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone updateStatus(voice.voiceStatus); updateFrozen(uiStore.getState().connectionStatus); - // Channel name + // Channel name. A DM call resolves through the DM store rather than the + // channels store: the channels-store row for a DM is synthesised when the + // conversation is opened, so accepting a call for a DM the user has not + // looked at yet would otherwise label the call "Voice Channel". const channel = channelsStore.getState().channels.get(channelId); - setText(channelNameEl, channel?.name ?? "Voice Channel"); + const dm = dmStore.getState().channels.find((c) => c.channelId === channelId); + setText( + channelNameEl, + dm !== undefined ? dmDisplayName(dm) : (channel?.name ?? "Voice Channel"), + ); // Toggle button active states, swap icons, and update aria-pressed muteBtn?.classList.toggle("active-ctrl", voice.localMuted); deafenBtn?.classList.toggle("active-ctrl", voice.localDeafened); cameraBtn?.classList.toggle("active-ctrl", voice.localCamera); + // A moderator-imposed mute/deafen is not ours to lift: the server refuses + // the unmute, so disable the control and say why instead of letting the + // click bounce off with an error toast. + const serverMuted = voice.localServerMuted === true; + const serverDeafened = voice.localServerDeafened === true; if (muteBtn) { swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic"); muteBtn.setAttribute("aria-pressed", String(voice.localMuted)); + // Only ever tighten: updateFrozen ran above and owns the socket-down + // disable, which must not be relaxed here. + if (serverMuted) { + muteBtn.disabled = true; + muteBtn.title = "You were muted by a moderator"; + } } if (deafenBtn) { swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones"); deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened)); + if (serverDeafened) { + deafenBtn.disabled = true; + deafenBtn.title = "You were deafened by a moderator"; + } } if (cameraBtn) { swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera"); @@ -435,6 +458,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone channelId: s.currentChannelId, muted: s.localMuted, deafened: s.localDeafened, + serverMuted: s.localServerMuted, + serverDeafened: s.localServerDeafened, camera: s.localCamera, screenshare: s.localScreenshare, listenOnly: s.listenOnly, @@ -445,6 +470,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone a.channelId === b.channelId && a.muted === b.muted && a.deafened === b.deafened && + a.serverMuted === b.serverMuted && + a.serverDeafened === b.serverDeafened && a.camera === b.camera && a.screenshare === b.screenshare && a.listenOnly === b.listenOnly && diff --git a/Client/tauri-client/src/components/channel-sidebar/context-menu.ts b/Client/tauri-client/src/components/channel-sidebar/context-menu.ts index db956847..1a94cd3e 100644 --- a/Client/tauri-client/src/components/channel-sidebar/context-menu.ts +++ b/Client/tauri-client/src/components/channel-sidebar/context-menu.ts @@ -1,26 +1,52 @@ /** - * Channel context menu — right-click on a channel for Edit/Delete actions. - * Only shown to admin/owner roles. + * Channel context menu — right-click on a channel for Mark as Read/Edit/Delete/ + * Purge. Mark as Read is offered to everyone (it only touches the caller's own + * read state); Edit and Delete follow the server's MANAGE_CHANNELS gate; Purge + * follows its MANAGE_MESSAGES gate. + * + * Both gates are permission bits, not role names: a custom role granted + * MANAGE_CHANNELS could edit a channel through the API while the client hid + * the menu item, because the old check asked whether the role was literally + * called "owner" or "admin". */ import { createElement } from "@lib/dom"; import type { Channel } from "@stores/channels.store"; -import { getCurrentUser } from "@stores/auth.store"; +import { hasPermission, currentUserPermissions, canManageChannels } from "@lib/permissions"; +import { Permission } from "@lib/types"; +import { markChannelRead, hasUnread } from "@lib/read-state"; +import { isChannelMuted, toggleChannelMute } from "@lib/channel-mutes"; +import { appendPurgeSection } from "@components/purge-prompt"; -/** Attach a right-click context menu to a channel element for edit/delete. */ +/** Bubbles from a channel row when its mute is toggled. */ +export const CHANNEL_MUTE_CHANGED = "owncord:channel-mute-changed"; + +/** Attach a right-click context menu to a channel element for edit/delete/purge. */ export function attachChannelContextMenu( el: HTMLElement, channel: Channel, signal: AbortSignal, onEdit?: (channel: Channel) => void, onDelete?: (channel: Channel) => void, + onPurge?: (channel: Channel, count: number) => Promise, ): void { - if (onEdit === undefined && onDelete === undefined) { - return; - } - const user = getCurrentUser(); - const role = user?.role?.toLowerCase() ?? ""; - if (role !== "owner" && role !== "admin") { + const canManage = canManageChannels(); + + // Voice channels hold no messages, and the server rejects a purge in a DM, + // so the section is offered only where it can succeed. + const canPurge = + onPurge !== undefined && + channel.type !== "voice" && + hasPermission(currentUserPermissions(), Permission.MANAGE_MESSAGES); + + const showEdit = canManage && onEdit !== undefined; + const showDelete = canManage && onDelete !== undefined; + // Mark as Read touches only the caller's own read state, so it needs no + // permission — but a voice channel holds no messages to read. + const showMarkRead = channel.type !== "voice"; + // Muting silences notifications, which a voice channel does not produce. + const showMute = channel.type !== "voice"; + if (!showMarkRead && !showMute && !showEdit && !showDelete && !canPurge) { return; } @@ -40,7 +66,67 @@ export function attachChannelContextMenu( menu.style.left = `${e.clientX}px`; menu.style.top = `${e.clientY}px`; - if (onEdit !== undefined) { + if (showMarkRead) { + // Disabled rather than hidden: a menu whose entries move between + // right-clicks is harder to use than one with a greyed-out row. + const unread = hasUnread(channel.id); + const markItem = createElement( + "div", + { + class: unread ? "context-menu-item" : "context-menu-item disabled", + "data-testid": "ctx-mark-read", + }, + "Mark as Read", + ); + if (unread) { + markItem.addEventListener( + "click", + () => { + closeMenu(); + markChannelRead(channel.id); + }, + { signal }, + ); + } + menu.appendChild(markItem); + } + + if (showMute) { + // "Until turned off": there is no timed mute, because a timed one needs + // a stored expiry the client would have to sweep, and the affordance it + // buys ("quiet for 8 hours") is one the user can reproduce by unmuting. + const muted = isChannelMuted(channel.id); + const muteItem = createElement( + "div", + { class: "context-menu-item", "data-testid": "ctx-mute-channel" }, + muted ? "Unmute Channel" : "Mute Channel", + ); + muteItem.addEventListener( + "click", + () => { + closeMenu(); + toggleChannelMute(channel.id); + // Mute state lives in localStorage, so there is no store change to + // subscribe to. A bubbling DOM event lets the sidebar redraw the + // row without threading a callback through four layers of + // positional render arguments. + el.dispatchEvent( + new CustomEvent(CHANNEL_MUTE_CHANGED, { + bubbles: true, + detail: { channelId: channel.id }, + }), + ); + }, + { signal }, + ); + menu.appendChild(muteItem); + } + + if ((showMarkRead || showMute) && (showEdit || showDelete || canPurge)) { + menu.appendChild(createElement("div", { class: "context-menu-sep" })); + } + + if (showEdit && onEdit !== undefined) { const editItem = createElement( "div", { class: "context-menu-item", "data-testid": "ctx-edit-channel" }, @@ -57,8 +143,8 @@ export function attachChannelContextMenu( menu.appendChild(editItem); } - if (onDelete !== undefined) { - if (onEdit !== undefined) { + if (showDelete && onDelete !== undefined) { + if (showEdit) { menu.appendChild(createElement("div", { class: "context-menu-sep" })); } const deleteItem = createElement( @@ -77,6 +163,17 @@ export function attachChannelContextMenu( menu.appendChild(deleteItem); } + if (canPurge && onPurge !== undefined) { + appendPurgeSection(menu, { + itemClass: "context-menu-item", + dangerItemClass: "context-menu-item danger", + separatorClass: showEdit || showDelete ? "context-menu-sep" : "", + onPurge: (count) => onPurge(channel, count), + signal, + onDone: () => closeMenu(), + }); + } + document.body.appendChild(menu); // Close menu on click elsewhere — use a per-menu AbortController diff --git a/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts b/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts index d6989c46..f799bf67 100644 --- a/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts +++ b/Client/tauri-client/src/components/channel-sidebar/volume-menu.ts @@ -1,17 +1,33 @@ /** - * Per-user volume context menu — right-click on a voice user row - * to adjust their playback volume locally. + * Per-user context menu on a voice participant row: local playback volume for + * everyone, plus a moderation section for users whose role holds MUTE_MEMBERS. */ import { createElement, setText, appendChildren } from "@lib/dom"; import { setUserVolume, getUserVolume } from "@lib/livekitSession"; +/** Moderation section wiring. Passed only when the local user may moderate + * voice; the menu renders the section iff this is present, so the permission + * decision stays with the caller (which knows the role list). */ +export interface VoiceModMenuOptions { + /** Current moderator-imposed state of the target, for the toggle labels. */ + readonly serverMuted: boolean; + readonly serverDeafened: boolean; + /** Voice channels the target can be moved to (the current one excluded). */ + readonly moveTargets: readonly { readonly id: number; readonly name: string }[]; + readonly onServerMute: (muted: boolean) => void; + readonly onServerDeafen: (deafened: boolean) => void; + readonly onMove: (toChannelId: number) => void; + readonly onDisconnect: () => void; +} + export function showUserVolumeMenu( userId: number, username: string, x: number, y: number, signal: AbortSignal, + mod?: VoiceModMenuOptions, ): void { // Remove any existing context menus and abort their dismiss controllers document.querySelectorAll(".user-vol-menu").forEach((el) => { @@ -85,6 +101,12 @@ export function showUserVolumeMenu( }); menu.appendChild(resetBtn); + if (mod !== undefined) { + appendModerationSection(menu, mod, () => { + menu.remove(); + }); + } + menu.style.left = `${x}px`; menu.style.top = `${y}px`; document.body.appendChild(menu); @@ -112,3 +134,78 @@ export function showUserVolumeMenu( dismissAc.abort(); }); } + +/** Builds the moderation rows. close() runs after any action so the menu does + * not linger showing stale labels while the server round-trip is in flight. */ +function appendModerationSection( + menu: HTMLElement, + mod: VoiceModMenuOptions, + close: () => void, +): void { + menu.appendChild(createElement("div", { class: "context-menu-sep" })); + + const muteItem = createElement( + "div", + { class: "context-menu-item", "data-action": "server-mute" }, + mod.serverMuted ? "Server Unmute" : "Server Mute", + ); + muteItem.addEventListener("click", () => { + mod.onServerMute(!mod.serverMuted); + close(); + }); + menu.appendChild(muteItem); + + const deafenItem = createElement( + "div", + { class: "context-menu-item", "data-action": "server-deafen" }, + mod.serverDeafened ? "Server Undeafen" : "Server Deafen", + ); + deafenItem.addEventListener("click", () => { + mod.onServerDeafen(!mod.serverDeafened); + close(); + }); + menu.appendChild(deafenItem); + + if (mod.moveTargets.length > 0) { + // Hover-revealed flyout, same shape as the AdminActions role submenu. + const moveWrap = createElement("div", { + class: "context-menu-item context-menu-item--submenu", + "data-action": "move-to", + }); + moveWrap.appendChild(createElement("span", {}, "Move to")); + const sub = createElement("div", { class: "context-menu__submenu" }); + sub.style.display = "none"; + moveWrap.addEventListener("mouseenter", () => { + sub.style.display = ""; + }); + moveWrap.addEventListener("mouseleave", () => { + sub.style.display = "none"; + }); + for (const ch of mod.moveTargets) { + const item = createElement( + "div", + { class: "context-menu-item", "data-move-channel": String(ch.id) }, + ch.name, + ); + item.addEventListener("click", (e) => { + e.stopPropagation(); + mod.onMove(ch.id); + close(); + }); + sub.appendChild(item); + } + moveWrap.appendChild(sub); + menu.appendChild(moveWrap); + } + + const kickItem = createElement( + "div", + { class: "context-menu-item danger", "data-action": "voice-disconnect" }, + "Disconnect", + ); + kickItem.addEventListener("click", () => { + mod.onDisconnect(); + close(); + }); + menu.appendChild(kickItem); +} diff --git a/Client/tauri-client/src/components/inline-autocomplete.ts b/Client/tauri-client/src/components/inline-autocomplete.ts new file mode 100644 index 00000000..f76748f5 --- /dev/null +++ b/Client/tauri-client/src/components/inline-autocomplete.ts @@ -0,0 +1,147 @@ +/** + * inline-autocomplete — the shared listbox the composer opens over the textarea + * for "@" mentions and ":" emoji. Both popups are the same widget: a filtered, + * keyboard-navigable list whose rows are chosen on mousedown (never click, so + * the textarea keeps focus). Only the suggestion source, the row contents, and + * a couple of flags differ, so those are injected and everything else — arrow + * navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here + * once instead of being duplicated in each popup. + * + * Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + */ + +import { createElement, clearChildren, appendChildren } from "@lib/dom"; + +export interface InlineAutocompleteConfig { + /** + * CSS class(es) on the root element. Mentions use `"mention-autocomplete"`; + * emoji use `"mention-autocomplete emoji-autocomplete"` (sharing the base + * class deliberately — a composer test selects + * `.mention-autocomplete:not(.emoji-autocomplete)` to tell them apart). + */ + readonly rootClass: string; + /** `data-testid` on the root element. */ + readonly rootTestId: string; + /** Suggestions for the text typed after the trigger, already ordered/capped. */ + readonly filter: (query: string) => T[]; + /** The value passed to onSelect when a row is chosen (token / insert text). */ + readonly valueOf: (item: T) => string; + /** `data-testid` for one row. */ + readonly rowTestId: (item: T) => string; + /** The children of one row (name/detail spans, an optional preview, …). */ + readonly renderRow: (item: T) => readonly HTMLElement[]; + /** + * When true, prime the list with `setQuery("")` on creation so the popup + * opens already populated (mentions list every member; emoji stay empty + * until the composer types past the minimum query). + */ + readonly primeOnCreate?: boolean; + /** Called with `valueOf(picked)` when a row is chosen. */ + readonly onSelect: (value: string) => void; + /** Called when the user dismisses the popup (Escape). */ + readonly onClose: () => void; +} + +export interface InlineAutocompleteComponent { + readonly element: HTMLDivElement; + /** + * Re-filter for `query`. Returns false when nothing matches, which the + * composer treats as "close the popup" rather than leaving an empty box. + */ + setQuery(query: string): boolean; + /** Handle a composer keydown. Returns true when the key was consumed. */ + handleKeydown(e: KeyboardEvent): boolean; + destroy(): void; +} + +export function createInlineAutocomplete( + cfg: InlineAutocompleteConfig, +): InlineAutocompleteComponent { + const ac = new AbortController(); + const signal = ac.signal; + + let suggestions: T[] = []; + let activeIndex = 0; + + const root = createElement("div", { + class: cfg.rootClass, + role: "listbox", + "data-testid": cfg.rootTestId, + }); + const list = createElement("div", { class: "ma-list" }); + root.appendChild(list); + + function choose(index: number): void { + const picked = suggestions[index]; + if (picked === undefined) return; + cfg.onSelect(cfg.valueOf(picked)); + } + + function render(): void { + clearChildren(list); + for (let i = 0; i < suggestions.length; i++) { + const s = suggestions[i]!; + const row = createElement("div", { + class: i === activeIndex ? "ma-item ma-item--active" : "ma-item", + role: "option", + "aria-selected": i === activeIndex ? "true" : "false", + "data-testid": cfg.rowTestId(s), + }); + appendChildren(row, ...cfg.renderRow(s)); + // mousedown, not click: the textarea must not lose focus before the + // insertion runs. + row.addEventListener( + "mousedown", + (e: MouseEvent) => { + e.preventDefault(); + choose(i); + }, + { signal }, + ); + list.appendChild(row); + } + } + + function setQuery(query: string): boolean { + suggestions = cfg.filter(query); + activeIndex = 0; + render(); + return suggestions.length > 0; + } + + function handleKeydown(e: KeyboardEvent): boolean { + if (suggestions.length === 0) return false; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + activeIndex = (activeIndex + 1) % suggestions.length; + render(); + return true; + case "ArrowUp": + e.preventDefault(); + activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length; + render(); + return true; + case "Enter": + case "Tab": + e.preventDefault(); + choose(activeIndex); + return true; + case "Escape": + e.preventDefault(); + cfg.onClose(); + return true; + default: + return false; + } + } + + function destroy(): void { + ac.abort(); + root.remove(); + } + + if (cfg.primeOnCreate === true) setQuery(""); + + return { element: root, setQuery, handleKeydown, destroy }; +} diff --git a/Client/tauri-client/src/components/message-list/attachments.ts b/Client/tauri-client/src/components/message-list/attachments.ts index c2e41a7b..fe92dfaf 100644 --- a/Client/tauri-client/src/components/message-list/attachments.ts +++ b/Client/tauri-client/src/components/message-list/attachments.ts @@ -10,6 +10,7 @@ import { loadPref } from "@components/settings/helpers"; import { createLogger } from "@lib/logger"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { ensureHttpProxy } from "@lib/httpProxy"; +import { getToken } from "@stores/auth.store"; import { save } from "@tauri-apps/plugin-dialog"; const log = createLogger("attachments"); @@ -55,8 +56,48 @@ export function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +/** Strip any `; codecs=…` parameters and normalise case before matching. */ +function baseMime(mime: string): string { + return (mime.split(";")[0] ?? "").trim().toLowerCase(); +} + +/** Whether the attachment should render as an inline . + * image/svg+xml is excluded: an SVG can carry script, and it is the one image + * type the data-URI allowlist already refuses — inlining it only ever produced + * a permanently-loading placeholder, so it belongs on the download chip. */ export function isImageMime(mime: string): boolean { - return mime.startsWith("image/"); + const base = baseMime(mime); + return base.startsWith("image/") && base !== "image/svg+xml"; +} + +/** Container MIME types we are willing to hand to a