Compare commits
11 Commits
7c06b27c73
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| c1efa7b94e | |||
| e31b84c08e | |||
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 | |||
| a0fcdf74da | |||
| ebc782b16c | |||
| 7939dc92d4 |
@@ -548,8 +548,19 @@ roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
|
|||||||
|
|
||||||
### 12.1 cinny host integration checklist (REQUIRED to light these up)
|
### 12.1 cinny host integration checklist (REQUIRED to light these up)
|
||||||
|
|
||||||
The EC side is additive and dormant until cinny opts in. Host work needed (in
|
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
|
||||||
`src/app/plugins/call/CallEmbed.ts` unless noted):
|
> focus_participant, decorations, and transparent background are active; the
|
||||||
|
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
|
||||||
|
> models in-source); and the two formerly-dormant capabilities now have cinny
|
||||||
|
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
|
||||||
|
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
|
||||||
|
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
|
||||||
|
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
|
||||||
|
> of what was wired. (One open denoise item tracked separately: the "Series
|
||||||
|
> Suppression" native-NS toggle is not wired to the real call path.)
|
||||||
|
|
||||||
|
The EC side is additive and dormant until cinny opts in. Host work (in
|
||||||
|
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
|
||||||
|
|
||||||
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
|
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
|
||||||
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
|
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
|
||||||
@@ -559,16 +570,16 @@ The EC side is additive and dormant until cinny opts in. Host work needed (in
|
|||||||
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
|
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
|
||||||
> flush on join, or no-op before join.
|
> flush on join, or no-op before join.
|
||||||
>
|
>
|
||||||
> Also: **F3** — the fork implements only `rnnoise`/`speex`; cinny's `dtln`/
|
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
|
||||||
> `deepfilternet` selections silently fall back to rnnoise (now logged). Restrict
|
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
|
||||||
> the embedded-call model picker to rnnoise/speex, or implement the others in
|
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
|
||||||
> `lotusDenoiseProcessor.ts`. **F4** — cinny sends `lotusNativeNS`, which the
|
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
|
||||||
> fork ignores; drop it or wire it in. **F7** — no widget _capability_ changes
|
> real calls — open item). **F7** — no widget _capability_ changes needed;
|
||||||
> needed; custom actions bypass capability checks.
|
> custom actions bypass capability checks.
|
||||||
|
|
||||||
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
|
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
|
||||||
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
|
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
|
||||||
`lotusAudioInject=1` as desired. (Denoise already sets `lotusDenoise=ml` etc.)
|
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
|
||||||
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
|
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
|
||||||
without a reply the fork's sends time out every 250ms. Feed the payload into
|
without a reply the fork's sends time out every 250ms. Feed the payload into
|
||||||
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
|
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
|
||||||
|
|||||||
+84
-8
@@ -25,7 +25,8 @@ Last updated: June 2026.
|
|||||||
16. [Notifications](#notifications)
|
16. [Notifications](#notifications)
|
||||||
17. [Server Integration](#server-integration)
|
17. [Server Integration](#server-integration)
|
||||||
18. [Infrastructure](#infrastructure)
|
18. [Infrastructure](#infrastructure)
|
||||||
19. [Key Custom Files](#key-custom-files)
|
19. [Desktop App Features](#desktop-app-features)
|
||||||
|
20. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -512,7 +513,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
|
|
||||||
**Advanced Features & Test Options:**
|
**Advanced Features & Test Options:**
|
||||||
|
|
||||||
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
|
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
|
||||||
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
||||||
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
||||||
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
||||||
@@ -524,17 +525,35 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
**Open-Source Models (all now in-source in the EC fork):**
|
**Open-Source Models (all now in-source in the EC fork):**
|
||||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **RNNoise** (default) | Poor | Moderate | < 5% | 48 kHz |
|
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
||||||
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
|
||||||
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
||||||
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
|
||||||
|
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
||||||
|
|
||||||
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
||||||
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
||||||
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
||||||
> rather than ever going silent). The model picker selects between them. Real-call
|
> rather than ever going silent). The model picker selects between them.
|
||||||
> **audio-quality** comparison across models is still the open verification item
|
|
||||||
> (RNNoise output is known to be weak) — see `LOTUS_TESTING.md` §D2-1.
|
> **Update (2026-07) — quality, reliability & AEC/AGC:**
|
||||||
|
>
|
||||||
|
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
|
||||||
|
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
|
||||||
|
> under the denoised signal so suppression can't fully collapse the noise
|
||||||
|
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
|
||||||
|
> would comb-filter, so they rely on their own level. The **noise gate now runs
|
||||||
|
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
|
||||||
|
> `lotusDenoiseFloor` param.
|
||||||
|
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
|
||||||
|
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
|
||||||
|
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
|
||||||
|
> playback stays on standard elements — no AEC-defeat vector.)
|
||||||
|
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
|
||||||
|
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
|
||||||
|
> (transient failures recover), activation off the local participant (works
|
||||||
|
> solo), and init/build-failure leak fixes.
|
||||||
|
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
|
||||||
|
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
@@ -1143,6 +1162,63 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Desktop App Features
|
||||||
|
|
||||||
|
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
|
||||||
|
|
||||||
|
### Call Continuity — No-Sleep (P5-46)
|
||||||
|
|
||||||
|
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower` ↔ `native/power.rs`.
|
||||||
|
|
||||||
|
### Windows Jump List (P5-36)
|
||||||
|
|
||||||
|
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList` ↔ `native/jumplist.rs` (`ICustomDestinationList`).
|
||||||
|
|
||||||
|
### Taskbar Thumbnail Toolbar (P5-44)
|
||||||
|
|
||||||
|
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar` ↔ `native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
|
||||||
|
|
||||||
|
### System Media Transport Controls — SMTC (P5-43)
|
||||||
|
|
||||||
|
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc` ↔ `native/smtc.rs`. _Experimental — may require an active audio session to surface._
|
||||||
|
|
||||||
|
### Network Awareness (P5-49)
|
||||||
|
|
||||||
|
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork` ↔ `native/network.rs`.
|
||||||
|
|
||||||
|
### Instant Background Sync (P5-42)
|
||||||
|
|
||||||
|
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
|
||||||
|
|
||||||
|
### Native Rich Notifications (P5-41 / P5-35)
|
||||||
|
|
||||||
|
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions` ↔ `native/toast.rs`; the desktop notification bridge routes room notifications to it.
|
||||||
|
|
||||||
|
### Focus Assist Sync (P5-56)
|
||||||
|
|
||||||
|
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom` ↔ `native/focus_assist.rs` (`SHQueryUserNotificationState`).
|
||||||
|
|
||||||
|
### Custom Window Chrome (P5-47)
|
||||||
|
|
||||||
|
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
||||||
|
|
||||||
|
### Proactive Update Toast (P5-40)
|
||||||
|
|
||||||
|
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
|
||||||
|
|
||||||
|
### Cross-platform composer niceties
|
||||||
|
|
||||||
|
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
|
||||||
|
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
|
||||||
|
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
|
||||||
|
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|||||||
+42
-30
@@ -301,8 +301,12 @@ Features:
|
|||||||
|
|
||||||
**Models — all in-source in the fork:**
|
**Models — all in-source in the fork:**
|
||||||
|
|
||||||
- [x] **RNNoise** (48 kHz, default) · **Speex** (48 kHz) · **DTLN** (16 kHz) · **DeepFilterNet 3** (48 kHz) — all four wired and selectable.
|
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
|
||||||
- [ ] **Open verification:** real-call **audio-quality** comparison across the four models (RNNoise output is known-weak). Track under the denoise quality project, `LOTUS_TESTING.md` §D2-1 / J2.
|
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
|
||||||
|
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams`→`ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
|
||||||
|
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
|
||||||
|
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
|
||||||
|
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~3–4 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
|
||||||
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
||||||
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
||||||
|
|
||||||
@@ -330,16 +334,16 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
|
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
||||||
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
|
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
|
||||||
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
||||||
**Complexity:** High (platform-specific native code required).
|
**Complexity:** High (platform-specific native code required).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
|
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
||||||
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
||||||
@@ -348,78 +352,86 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
|
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
|
||||||
|
|
||||||
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
||||||
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
||||||
|
|
||||||
### [ ] P5-42 · Desktop — Persistent Background Sync
|
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Maintain light connection to homeserver when WebView2 is suspended.
|
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
|
||||||
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
|
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
|
||||||
|
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
|
||||||
|
|
||||||
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
|
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
|
||||||
|
|
||||||
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
||||||
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
||||||
|
|
||||||
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
|
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Add persistent call controls to the taskbar preview.
|
**What:** Add persistent call controls to the taskbar preview.
|
||||||
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
||||||
|
|
||||||
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
|
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
|
||||||
|
|
||||||
**What:** Prevent system sleep/hibernate during active calls.
|
**What:** Prevent system sleep/hibernate during active calls.
|
||||||
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
||||||
|
|
||||||
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
|
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
|
||||||
|
|
||||||
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
||||||
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
||||||
|
|
||||||
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
|
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
|
||||||
|
|
||||||
**What:** Enhance drag-and-drop support for Windows.
|
**What:** Enhance drag-and-drop support for Windows.
|
||||||
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
||||||
|
|
||||||
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
|
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Proactively detect Windows network connectivity changes.
|
**What:** Proactively detect Windows network connectivity changes.
|
||||||
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
||||||
|
|
||||||
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
||||||
|
|
||||||
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
||||||
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
|
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
|
||||||
|
|
||||||
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
||||||
|
|
||||||
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
|
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
|
||||||
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
|
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
|
||||||
|
|
||||||
|
**Future-work spec (why it's big):** the app is currently **single-session**.
|
||||||
|
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
|
||||||
|
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
|
||||||
|
|
||||||
|
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) *without* the hard isolation boundary — much less risky, reuses most of the login flow.
|
||||||
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
||||||
|
|
||||||
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
|
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
||||||
|
|
||||||
**What:** Granular sync tuning for individual rooms.
|
**What:** Granular per-room sync tuning (frequency, event-type filtering).
|
||||||
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
|
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
|
||||||
|
|
||||||
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
||||||
|
|
||||||
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
||||||
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
|
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
|
||||||
|
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
|
||||||
|
|
||||||
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
|
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
|
||||||
|
|
||||||
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
||||||
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
||||||
|
|
||||||
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
|
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
||||||
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
||||||
|
|
||||||
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
|
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -560,7 +572,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
|
|
||||||
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||||
>
|
>
|
||||||
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked; only the cinny-side UI remains (see P5-15 above). The capability ships dormant today.
|
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -631,7 +643,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
|
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
|
||||||
|
|
||||||
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||||
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||||
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||||
|
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
|
||||||
|
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
|
||||||
|
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
|
||||||
|
|
||||||
### Customization & Appearance
|
### Customization & Appearance
|
||||||
|
|
||||||
@@ -136,6 +139,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
|
|||||||
|
|
||||||
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||||
|
|
||||||
|
### Desktop-Specific Features
|
||||||
|
|
||||||
|
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
|
||||||
|
|
||||||
|
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
|
||||||
|
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
|
||||||
|
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
|
||||||
|
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
|
||||||
|
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
|
||||||
|
- **Network awareness** — reconnects promptly when Windows connectivity changes.
|
||||||
|
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
|
||||||
|
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
|
||||||
|
- **Automatic background updates** with a one-click update toast.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## For Developers
|
## For Developers
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
||||||
|
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
||||||
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
|
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
|
||||||
|
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
|
||||||
|
* desktop features (window chrome) live in `App.tsx` instead, so they work
|
||||||
|
* before login.
|
||||||
|
*/
|
||||||
|
export function TauriDesktopFeatures(): null {
|
||||||
|
useTauriCallPower(); // P5-46 no-sleep during calls
|
||||||
|
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
||||||
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const BAR_HEIGHT = toRem(32);
|
||||||
|
const CONTROL_WIDTH = toRem(46);
|
||||||
|
|
||||||
|
export const TitleBar = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: BAR_HEIGHT,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
// Sit above app content but never intercept scroll etc. below the bar.
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
|
||||||
|
// the free space so most of the bar is grabbable.
|
||||||
|
export const DragRegion = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
paddingInline: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Brand = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
// Children shouldn't swallow the drag; the region itself owns the attribute.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Controls = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ControlButton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: CONTROL_WIDTH,
|
||||||
|
height: '100%',
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'background-color 100ms ease',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ControlButtonClose = style({
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Critical.Main,
|
||||||
|
color: color.Critical.OnMain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Text } from 'folds';
|
||||||
|
import { customWindowChromeAtom } from '../../state/customWindowChrome';
|
||||||
|
import { invokeTauri, isTauri } from '../../hooks/useTauri';
|
||||||
|
import * as css from './TitleBar.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
|
||||||
|
* a coarse "is this a Mac" signal to decide which side the window controls sit
|
||||||
|
* on, so the UA/platform sniff is sufficient and stays cross-platform.
|
||||||
|
*/
|
||||||
|
const isMacOS = (): boolean => {
|
||||||
|
const platform =
|
||||||
|
(
|
||||||
|
navigator as unknown as {
|
||||||
|
userAgentData?: { platform?: string };
|
||||||
|
}
|
||||||
|
).userAgentData?.platform ??
|
||||||
|
navigator.platform ??
|
||||||
|
navigator.userAgent;
|
||||||
|
return /mac/i.test(platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLOSE_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ControlButtonProps = {
|
||||||
|
label: string;
|
||||||
|
glyph: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
close?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — TDS Custom Window Chrome titlebar.
|
||||||
|
*
|
||||||
|
* Renders `null` unless we're inside Tauri **and** the user opted into custom
|
||||||
|
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
|
||||||
|
* draggable region (explicit `window_start_drag` on mousedown, double-press to
|
||||||
|
* maximize) with the app brand, plus minimize / maximize / close controls that
|
||||||
|
* call the native window commands.
|
||||||
|
*
|
||||||
|
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
|
||||||
|
* the left (the native traffic-light position) since decorations — and thus the
|
||||||
|
* real traffic lights — are stripped while custom chrome is on.
|
||||||
|
*/
|
||||||
|
export function TitleBar() {
|
||||||
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
|
if (!isTauri() || !enabled) return null;
|
||||||
|
|
||||||
|
const mac = isMacOS();
|
||||||
|
|
||||||
|
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
|
||||||
|
// OS window drag; a double press (detail === 2) toggles maximize instead. An
|
||||||
|
// explicit `window_start_drag` invoke is used rather than
|
||||||
|
// `data-tauri-drag-region` because the attribute only fires when the exact
|
||||||
|
// element is the event target (children like the brand text wouldn't drag).
|
||||||
|
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
if (evt.detail === 2) {
|
||||||
|
invokeTauri('window_toggle_maximize');
|
||||||
|
} else {
|
||||||
|
invokeTauri('window_start_drag');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<div className={css.Controls}>
|
||||||
|
<ControlButton
|
||||||
|
label="Minimize"
|
||||||
|
glyph={MIN_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_minimize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Maximize"
|
||||||
|
glyph={MAX_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_toggle_maximize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Close"
|
||||||
|
glyph={CLOSE_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_close')}
|
||||||
|
close
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dragRegion = (
|
||||||
|
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
|
||||||
|
<span className={css.Brand}>
|
||||||
|
<Text as="span" size="T200" truncate>
|
||||||
|
Lotus Chat
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={css.TitleBar}>
|
||||||
|
{mac ? (
|
||||||
|
<>
|
||||||
|
{controls}
|
||||||
|
{dragRegion}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dragRegion}
|
||||||
|
{controls}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
|
||||||
|
// A brief, gentle acknowledgement when a draft first becomes persisted.
|
||||||
|
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
|
||||||
|
const savedPulse = keyframes({
|
||||||
|
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
|
||||||
|
'45%': { opacity: 1, transform: 'scale(1.15)' },
|
||||||
|
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftIndicatorBase = style({
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftDot = style({
|
||||||
|
width: toRem(6),
|
||||||
|
height: toRem(6),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color.Success.Main,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DraftDotPulse = style({
|
||||||
|
'@media': {
|
||||||
|
'(prefers-reduced-motion: no-preference)': {
|
||||||
|
animation: `${savedPulse} 600ms ease-out`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Box, Text, config } from 'folds';
|
||||||
|
|
||||||
|
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
|
import { toPlainText } from '../../components/editor';
|
||||||
|
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
|
||||||
|
|
||||||
|
const PULSE_DURATION = 600;
|
||||||
|
|
||||||
|
type DraftIndicatorProps = {
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtle, non-distracting status shown near the composer when the current room
|
||||||
|
* has a persisted (unsent) message draft. It reacts to the shared draft atom
|
||||||
|
* (`roomIdToMsgDraftAtomFamily`) — the same source that backs the
|
||||||
|
* `draft-msg-${roomId}` localStorage persistence — so it never introduces a
|
||||||
|
* parallel persistence path.
|
||||||
|
*
|
||||||
|
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
|
||||||
|
* indicator settles into a quiet, muted resting state. The pulse is gated behind
|
||||||
|
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
|
||||||
|
* static label.
|
||||||
|
*/
|
||||||
|
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
|
||||||
|
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
|
// Real content, not just an empty paragraph.
|
||||||
|
const hasDraft = toPlainText(draft, false).trim().length > 0;
|
||||||
|
|
||||||
|
const [pulse, setPulse] = useState(false);
|
||||||
|
const hadDraft = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasDraft && !hadDraft.current) {
|
||||||
|
hadDraft.current = true;
|
||||||
|
setPulse(true);
|
||||||
|
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
hadDraft.current = hasDraft;
|
||||||
|
return undefined;
|
||||||
|
}, [hasDraft]);
|
||||||
|
|
||||||
|
if (!hasDraft) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={DraftIndicatorBase}
|
||||||
|
as="span"
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: `0 ${config.space.S100}` }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
|
||||||
|
<Text as="span" size="T200" priority="300">
|
||||||
|
Draft saved
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
+260
-181
@@ -1,9 +1,11 @@
|
|||||||
import React, {
|
import React, {
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
|
|||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import {
|
||||||
|
ComposerToolbarButtonKey,
|
||||||
|
normalizeComposerToolbarOrder,
|
||||||
|
settingsAtom,
|
||||||
|
} from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
getAudioMsgContent,
|
getAudioMsgContent,
|
||||||
getFileMsgContent,
|
getFileMsgContent,
|
||||||
@@ -128,6 +134,7 @@ import { PollCreator } from './PollCreator';
|
|||||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||||
|
import { DraftIndicator } from './DraftIndicator';
|
||||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
@@ -219,6 +226,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||||
|
const composerButtonOrder = useMemo(
|
||||||
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||||
|
[composerToolbarButtons?.order],
|
||||||
|
);
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = useCallback(() => {
|
const handleShareLocation = useCallback(() => {
|
||||||
@@ -358,13 +369,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const nodes = JSON.parse(stored);
|
const nodes = JSON.parse(stored);
|
||||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||||
Transforms.insertFragment(editor, nodes);
|
Transforms.insertFragment(editor, nodes);
|
||||||
|
// Mirror the restored draft into the atom so the draft indicator
|
||||||
|
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||||
|
// after a page reload — not only on same-session room re-entry.
|
||||||
|
setMsgDraft(nodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed stored draft
|
// Ignore malformed stored draft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, msgDraft, roomId]);
|
}, [editor, msgDraft, roomId, setMsgDraft]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -954,59 +969,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Icon src={Icons.PlusCircle} />
|
<Icon src={Icons.PlusCircle} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
after={
|
after={(() => {
|
||||||
<>
|
const formatButton = showFormat ? (
|
||||||
{showFormat && (
|
<IconButton
|
||||||
<IconButton
|
key="showFormat"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
style={touchTarget}
|
style={touchTarget}
|
||||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||||
aria-pressed={toolbar}
|
aria-pressed={toolbar}
|
||||||
onClick={() => setToolbar(!toolbar)}
|
onClick={() => setToolbar(!toolbar)}
|
||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
) : null;
|
||||||
{(showEmoji || showSticker) && (
|
|
||||||
<UseStateProvider initial={undefined}>
|
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
// emoji button, so they are rendered together as one unit. Their
|
||||||
<PopOut
|
// relative order still follows the saved order.
|
||||||
offset={16}
|
const emojiStickerBlock =
|
||||||
alignOffset={-44}
|
showEmoji || showSticker ? (
|
||||||
position="Top"
|
<UseStateProvider key="showEmojiSticker" initial={undefined}>
|
||||||
align="End"
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
|
||||||
anchor={
|
const stickerBtn =
|
||||||
emojiBoardTab === undefined
|
showSticker && !hideStickerBtn ? (
|
||||||
? undefined
|
|
||||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<React.Suspense fallback={null}>
|
|
||||||
<EmojiBoard
|
|
||||||
tab={emojiBoardTab}
|
|
||||||
onTabChange={setEmojiBoardTab}
|
|
||||||
imagePackRooms={imagePackRooms}
|
|
||||||
returnFocusOnDeactivate={false}
|
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
|
||||||
onStickerSelect={handleStickerSelect}
|
|
||||||
requestClose={() => {
|
|
||||||
setEmojiBoardTab((t) => {
|
|
||||||
if (t) {
|
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showSticker && !hideStickerBtn && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
|
key="showSticker"
|
||||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
aria-label="Insert sticker"
|
aria-label="Insert sticker"
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||||
@@ -1020,36 +1009,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
) : null;
|
||||||
{showEmoji && (
|
const emojiBtn = showEmoji ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={emojiBtnRef}
|
key="showEmoji"
|
||||||
aria-label="Insert emoji"
|
ref={emojiBtnRef}
|
||||||
aria-pressed={
|
aria-label="Insert emoji"
|
||||||
|
aria-pressed={
|
||||||
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
|
}
|
||||||
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
src={Icons.Smile}
|
||||||
|
filled={
|
||||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
}
|
}
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
/>
|
||||||
variant="SurfaceVariant"
|
</IconButton>
|
||||||
size="300"
|
) : null;
|
||||||
radii="300"
|
const emojiFirst =
|
||||||
style={touchTarget}
|
composerButtonOrder.indexOf('showEmoji') <
|
||||||
>
|
composerButtonOrder.indexOf('showSticker');
|
||||||
<Icon
|
return (
|
||||||
src={Icons.Smile}
|
<PopOut
|
||||||
filled={
|
offset={16}
|
||||||
hideStickerBtn
|
alignOffset={-44}
|
||||||
? !!emojiBoardTab
|
position="Top"
|
||||||
: emojiBoardTab === EmojiBoardTab.Emoji
|
align="End"
|
||||||
}
|
anchor={
|
||||||
/>
|
emojiBoardTab === undefined
|
||||||
</IconButton>
|
? undefined
|
||||||
)}
|
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||||
</PopOut>
|
}
|
||||||
)}
|
content={
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<EmojiBoard
|
||||||
|
tab={emojiBoardTab}
|
||||||
|
onTabChange={setEmojiBoardTab}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
|
onStickerSelect={handleStickerSelect}
|
||||||
|
requestClose={() => {
|
||||||
|
setEmojiBoardTab((t) => {
|
||||||
|
if (t) {
|
||||||
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
)}
|
) : null;
|
||||||
{!!gifApiKey && showGif && (
|
|
||||||
<UseStateProvider initial={false}>
|
const gifButton =
|
||||||
|
!!gifApiKey && showGif ? (
|
||||||
|
<UseStateProvider key="showGif" initial={false}>
|
||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
offset={16}
|
offset={16}
|
||||||
@@ -1101,113 +1130,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
)}
|
) : null;
|
||||||
{gifError && (
|
|
||||||
<Text
|
const locationButton = showLocation ? (
|
||||||
size="T200"
|
|
||||||
style={{
|
|
||||||
color: color.Critical.Main,
|
|
||||||
padding: '2px 6px',
|
|
||||||
alignSelf: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{gifError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{locationError && (
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
style={{
|
|
||||||
color: color.Critical.Main,
|
|
||||||
padding: '2px 6px',
|
|
||||||
alignSelf: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locationError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{showLocation && (
|
|
||||||
<IconButton
|
|
||||||
onClick={handleShareLocation}
|
|
||||||
disabled={locating}
|
|
||||||
aria-label="Share location"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
title="Share location"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
{locating ? (
|
|
||||||
<Spinner variant="Secondary" size="100" />
|
|
||||||
) : (
|
|
||||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showPoll && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setPollOpen(true)}
|
|
||||||
aria-label="Create poll"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
title="Create poll"
|
|
||||||
style={touchTarget}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.OrderList} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{showVoice && (
|
|
||||||
<VoiceMessageRecorder
|
|
||||||
onSend={handleVoiceSend}
|
|
||||||
onError={(err) => {
|
|
||||||
setLocationError(err);
|
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{charCount > 0 && (
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
priority="300"
|
|
||||||
style={{
|
|
||||||
padding: `0 ${config.space.S100}`,
|
|
||||||
alignSelf: 'center',
|
|
||||||
userSelect: 'none',
|
|
||||||
minWidth: '2rem',
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{charCount}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{showSchedule && (
|
|
||||||
<IconButton
|
|
||||||
onClick={handleScheduleClick}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
style={touchTarget}
|
|
||||||
aria-label="Schedule message"
|
|
||||||
title="Schedule message"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Clock} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={submit}
|
key="showLocation"
|
||||||
|
onClick={handleShareLocation}
|
||||||
|
disabled={locating}
|
||||||
|
aria-label="Share location"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title="Share location"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
{locating ? (
|
||||||
|
<Spinner variant="Secondary" size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const pollButton = showPoll ? (
|
||||||
|
<IconButton
|
||||||
|
key="showPoll"
|
||||||
|
onClick={() => setPollOpen(true)}
|
||||||
|
aria-label="Create poll"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title="Create poll"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.OrderList} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const voiceButton = showVoice ? (
|
||||||
|
<VoiceMessageRecorder
|
||||||
|
key="showVoice"
|
||||||
|
onSend={handleVoiceSend}
|
||||||
|
onError={(err) => {
|
||||||
|
setLocationError(err);
|
||||||
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const scheduleButton = showSchedule ? (
|
||||||
|
<IconButton
|
||||||
|
key="showSchedule"
|
||||||
|
onClick={handleScheduleClick}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
style={touchTarget}
|
style={touchTarget}
|
||||||
aria-label="Send message"
|
aria-label="Schedule message"
|
||||||
|
title="Schedule message"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Clock} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
) : null;
|
||||||
}
|
|
||||||
|
const orderedButtons: ReactNode[] = [];
|
||||||
|
let emojiStickerRendered = false;
|
||||||
|
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'showFormat':
|
||||||
|
if (formatButton) orderedButtons.push(formatButton);
|
||||||
|
break;
|
||||||
|
case 'showEmoji':
|
||||||
|
case 'showSticker':
|
||||||
|
// Rendered once as a combined unit at whichever of the two
|
||||||
|
// keys comes first in the order.
|
||||||
|
if (!emojiStickerRendered) {
|
||||||
|
emojiStickerRendered = true;
|
||||||
|
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'showGif':
|
||||||
|
if (gifButton) orderedButtons.push(gifButton);
|
||||||
|
break;
|
||||||
|
case 'showLocation':
|
||||||
|
if (locationButton) orderedButtons.push(locationButton);
|
||||||
|
break;
|
||||||
|
case 'showPoll':
|
||||||
|
if (pollButton) orderedButtons.push(pollButton);
|
||||||
|
break;
|
||||||
|
case 'showVoice':
|
||||||
|
if (voiceButton) orderedButtons.push(voiceButton);
|
||||||
|
break;
|
||||||
|
case 'showSchedule':
|
||||||
|
if (scheduleButton) orderedButtons.push(scheduleButton);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{orderedButtons}
|
||||||
|
{gifError && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
color: color.Critical.Main,
|
||||||
|
padding: '2px 6px',
|
||||||
|
alignSelf: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gifError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{locationError && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
color: color.Critical.Main,
|
||||||
|
padding: '2px 6px',
|
||||||
|
alignSelf: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<DraftIndicator roomId={roomId} />
|
||||||
|
{charCount > 0 && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
alignSelf: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
minWidth: '2rem',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{charCount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={submit}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Send} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
bottom={
|
bottom={
|
||||||
toolbar && (
|
toolbar && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, {
|
|||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -34,6 +35,19 @@ import {
|
|||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
monitorForElements,
|
||||||
|
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
|
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||||
|
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
|
||||||
|
import {
|
||||||
|
attachClosestEdge,
|
||||||
|
extractClosestEdge,
|
||||||
|
Edge,
|
||||||
|
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||||
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
@@ -47,12 +61,14 @@ import { useSetting } from '../../../state/hooks/settings';
|
|||||||
import {
|
import {
|
||||||
CallAudioBitrate,
|
CallAudioBitrate,
|
||||||
ChatBackground,
|
ChatBackground,
|
||||||
|
ComposerToolbarButtonKey,
|
||||||
ComposerToolbarSettings,
|
ComposerToolbarSettings,
|
||||||
DateFormat,
|
DateFormat,
|
||||||
DenoiseModelId,
|
DenoiseModelId,
|
||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
NoiseSuppressionMode,
|
NoiseSuppressionMode,
|
||||||
|
normalizeComposerToolbarOrder,
|
||||||
RingtoneId,
|
RingtoneId,
|
||||||
ScreenshareBitrate,
|
ScreenshareBitrate,
|
||||||
ScreenshareFramerate,
|
ScreenshareFramerate,
|
||||||
@@ -86,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
|||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
|
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
|
||||||
|
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||||
|
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
|
||||||
|
* (mounted in App.tsx) applies `set_decorations` when this flips.
|
||||||
|
*/
|
||||||
|
function DesktopChromeSetting() {
|
||||||
|
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
|
||||||
|
if (!isTauriEnv()) return null;
|
||||||
|
return (
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Custom Window Chrome (Beta)"
|
||||||
|
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
|
||||||
|
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
themeNames: Record<string, string>;
|
themeNames: Record<string, string>;
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
@@ -405,6 +442,8 @@ function Appearance() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
|
<DesktopChromeSetting />
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Twitter Emoji"
|
title="Twitter Emoji"
|
||||||
@@ -1025,6 +1064,165 @@ function DateAndTime() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
|
||||||
|
showFormat: 'Format',
|
||||||
|
showEmoji: 'Emoji',
|
||||||
|
showSticker: 'Sticker',
|
||||||
|
showGif: 'GIF',
|
||||||
|
showLocation: 'Location',
|
||||||
|
showPoll: 'Poll',
|
||||||
|
showVoice: 'Voice',
|
||||||
|
showSchedule: 'Schedule',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
|
||||||
|
|
||||||
|
type ComposerToolbarButtonRowProps = {
|
||||||
|
buttonKey: ComposerToolbarButtonKey;
|
||||||
|
index: number;
|
||||||
|
active: boolean;
|
||||||
|
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ComposerToolbarButtonRow({
|
||||||
|
buttonKey,
|
||||||
|
index,
|
||||||
|
active,
|
||||||
|
onToggle,
|
||||||
|
}: ComposerToolbarButtonRowProps) {
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = rowRef.current;
|
||||||
|
const dragHandle = handleRef.current;
|
||||||
|
if (!element || !dragHandle) return undefined;
|
||||||
|
|
||||||
|
return combine(
|
||||||
|
draggable({
|
||||||
|
element,
|
||||||
|
dragHandle,
|
||||||
|
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
|
||||||
|
onDragStart: () => setDragging(true),
|
||||||
|
onDrop: () => setDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element,
|
||||||
|
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||||||
|
getData: ({ input }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
|
||||||
|
{ element, input, allowedEdges: ['top', 'bottom'] },
|
||||||
|
),
|
||||||
|
getIsSticky: () => true,
|
||||||
|
onDrag: ({ self, source }) => {
|
||||||
|
if (source.data.buttonKey === buttonKey) {
|
||||||
|
setClosestEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClosestEdge(extractClosestEdge(self.data));
|
||||||
|
},
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: () => setClosestEdge(null),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [buttonKey, index]);
|
||||||
|
|
||||||
|
let boxShadow: string | undefined;
|
||||||
|
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
|
||||||
|
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={rowRef}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
opacity: dragging ? 0.5 : undefined,
|
||||||
|
boxShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
ref={handleRef}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{ cursor: 'grab' }}
|
||||||
|
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.VerticalDots} />
|
||||||
|
</IconButton>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="T300">
|
||||||
|
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
|
||||||
|
</Text>
|
||||||
|
<Chip
|
||||||
|
variant={active ? 'Primary' : 'Secondary'}
|
||||||
|
outlined={active}
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => onToggle(buttonKey)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComposerToolbarReorderProps = {
|
||||||
|
order: ComposerToolbarButtonKey[];
|
||||||
|
buttons: ComposerToolbarSettings;
|
||||||
|
onReorder: (startIndex: number, finishIndex: number) => void;
|
||||||
|
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ComposerToolbarReorder({
|
||||||
|
order,
|
||||||
|
buttons,
|
||||||
|
onReorder,
|
||||||
|
onToggle,
|
||||||
|
}: ComposerToolbarReorderProps) {
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
monitorForElements({
|
||||||
|
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||||||
|
onDrop: ({ location, source }) => {
|
||||||
|
const target = location.current.dropTargets[0];
|
||||||
|
if (!target) return;
|
||||||
|
const startIndex = source.data.index;
|
||||||
|
const indexOfTarget = target.data.index;
|
||||||
|
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
|
||||||
|
const closestEdgeOfTarget = extractClosestEdge(target.data);
|
||||||
|
|
||||||
|
// Insert relative to the target row, then compensate for the source
|
||||||
|
// row being removed from its original position.
|
||||||
|
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
|
||||||
|
if (startIndex < finishIndex) finishIndex -= 1;
|
||||||
|
|
||||||
|
if (finishIndex === startIndex) return;
|
||||||
|
onReorder(startIndex, finishIndex);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[onReorder],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column">
|
||||||
|
{order.map((key, index) => (
|
||||||
|
<ComposerToolbarButtonRow
|
||||||
|
key={key}
|
||||||
|
buttonKey={key}
|
||||||
|
index={index}
|
||||||
|
active={buttons[key]}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
@@ -1034,20 +1232,31 @@ function Editor() {
|
|||||||
'composerToolbarButtons',
|
'composerToolbarButtons',
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
const composerToolbarOrder = useMemo(
|
||||||
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||||
};
|
[composerToolbarButtons?.order],
|
||||||
|
);
|
||||||
|
|
||||||
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
|
const toggleToolbarButton = useCallback(
|
||||||
{ key: 'showFormat', label: 'Format' },
|
(key: ComposerToolbarButtonKey) => {
|
||||||
{ key: 'showEmoji', label: 'Emoji' },
|
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
|
||||||
{ key: 'showSticker', label: 'Sticker' },
|
},
|
||||||
{ key: 'showGif', label: 'GIF' },
|
[setComposerToolbarButtons],
|
||||||
{ key: 'showLocation', label: 'Location' },
|
);
|
||||||
{ key: 'showPoll', label: 'Poll' },
|
|
||||||
{ key: 'showVoice', label: 'Voice' },
|
const reorderToolbarButtons = useCallback(
|
||||||
{ key: 'showSchedule', label: 'Schedule' },
|
(startIndex: number, finishIndex: number) => {
|
||||||
];
|
setComposerToolbarButtons((current) => ({
|
||||||
|
...current,
|
||||||
|
order: reorder({
|
||||||
|
list: normalizeComposerToolbarOrder(current.order),
|
||||||
|
startIndex,
|
||||||
|
finishIndex,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setComposerToolbarButtons],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -1082,28 +1291,15 @@ function Editor() {
|
|||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Composer Toolbar"
|
title="Composer Toolbar"
|
||||||
description="Tap a button to show or hide it in the message composer."
|
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||||||
wrap="Wrap"
|
<ComposerToolbarReorder
|
||||||
gap="200"
|
order={composerToolbarOrder}
|
||||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
buttons={composerToolbarButtons}
|
||||||
>
|
onReorder={reorderToolbarButtons}
|
||||||
{TOOLBAR_CHIPS.map(({ key, label }) => {
|
onToggle={toggleToolbarButton}
|
||||||
const active = composerToolbarButtons?.[key] ?? true;
|
/>
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
key={key}
|
|
||||||
variant={active ? 'Primary' : 'Secondary'}
|
|
||||||
outlined={active}
|
|
||||||
radii="Pill"
|
|
||||||
onClick={() => toggleToolbarButton(key)}
|
|
||||||
aria-pressed={active}
|
|
||||||
>
|
|
||||||
<Text size="T300">{label}</Text>
|
|
||||||
</Chip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
||||||
import { getDataTransferFiles } from '../utils/dom';
|
import { collectDroppedFiles } from '../utils/fileEntries';
|
||||||
|
|
||||||
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.dataTransfer);
|
// `collectDroppedFiles` synchronously captures the entry list from the
|
||||||
if (files) onDrop(files);
|
// DataTransfer before traversing folders asynchronously.
|
||||||
|
collectDroppedFiles(evt.dataTransfer)
|
||||||
|
.then((files) => {
|
||||||
|
if (files) onDrop(files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
},
|
},
|
||||||
[onDrop],
|
[onDrop],
|
||||||
);
|
);
|
||||||
@@ -24,8 +29,14 @@ export const useFileDropZone = (
|
|||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setActive(false);
|
setActive(false);
|
||||||
if (!evt.dataTransfer) return;
|
if (!evt.dataTransfer) return;
|
||||||
const files = getDataTransferFiles(evt.dataTransfer);
|
// Capture entries synchronously (inside the event) then traverse any
|
||||||
if (files) onDrop(files);
|
// dropped folders asynchronously — the DataTransferItemList is emptied
|
||||||
|
// once this handler returns.
|
||||||
|
collectDroppedFiles(evt.dataTransfer)
|
||||||
|
.then((files) => {
|
||||||
|
if (files) onDrop(files);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
target?.addEventListener('drop', handleDrop);
|
target?.addEventListener('drop', handleDrop);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview at runtime; we use it
|
||||||
|
// directly so cinny doesn't need `@tauri-apps/api` as a dependency. Native Rust
|
||||||
|
// modules push data back to the web by dispatching DOM CustomEvents (see
|
||||||
|
// `emit_to_web` in cinny-desktop's `native` module), which `useTauriEvent`
|
||||||
|
// subscribes to. This module is the single source for the desktop bridge that
|
||||||
|
// every `useTauri*` feature hook builds on.
|
||||||
|
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
|
export const tauriInvoke = (): Invoke | undefined =>
|
||||||
|
(window as unknown as { __TAURI_INTERNALS__?: { invoke: Invoke } }).__TAURI_INTERNALS__?.invoke;
|
||||||
|
|
||||||
|
export const isTauri = (): boolean => tauriInvoke() !== undefined;
|
||||||
|
|
||||||
|
/** Fire-and-forget invoke that no-ops (and never throws) outside Tauri. */
|
||||||
|
export const invokeTauri = (cmd: string, args?: Record<string, unknown>): void => {
|
||||||
|
tauriInvoke()?.(cmd, args).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a CustomEvent dispatched from the Rust side via `emit_to_web`.
|
||||||
|
* The handler is kept in a ref so callers don't need to memoize it to avoid
|
||||||
|
* re-subscribing. No-op outside Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriEvent<T = unknown>(name: string, handler: (detail: T) => void): void {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
handlerRef.current = handler;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return undefined;
|
||||||
|
const listener = (e: Event): void => handlerRef.current((e as CustomEvent<T>).detail);
|
||||||
|
window.addEventListener(name, listener);
|
||||||
|
return () => window.removeEventListener(name, listener);
|
||||||
|
}, [name]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { invokeTauri } from './useTauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-46 — keep the system awake during calls (call continuity). Mirrors the
|
||||||
|
* call-embed atom (undefined = no active call) onto the native `set_call_active`
|
||||||
|
* command, which holds a `SetThreadExecutionState` request on Windows while a
|
||||||
|
* voice/video call is active and releases it when the call ends. No-op in the
|
||||||
|
* browser.
|
||||||
|
*/
|
||||||
|
export function useTauriCallPower(): void {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
useEffect(() => {
|
||||||
|
invokeTauri('set_call_active', { active: callEmbed !== undefined });
|
||||||
|
}, [callEmbed]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
|
||||||
|
type FocusAssistChangedDetail = {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (desktop). Subscribes to
|
||||||
|
* the native `focus-assist-changed` event (Windows `SHQueryUserNotificationState`
|
||||||
|
* poll, `{ active }`) and mirrors it into `focusAssistActiveAtom`, which the
|
||||||
|
* notification gate reads to suppress notifications while the shell is in Focus
|
||||||
|
* Assist / Quiet Hours, presenting, gaming full-screen, or busy. Inert in the
|
||||||
|
* browser, since `useTauriEvent` only listens under Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriFocusAssist(): void {
|
||||||
|
const setFocusAssist = useSetAtom(focusAssistActiveAtom);
|
||||||
|
|
||||||
|
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
|
||||||
|
setFocusAssist(active),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { isTauri, invokeTauri } from './useTauri';
|
||||||
|
|
||||||
|
/** Cap the Jump List to a small, glanceable set of rooms. */
|
||||||
|
const MAX_ITEMS = 8;
|
||||||
|
|
||||||
|
/** Wait for room activity to settle before re-publishing the (native) list. */
|
||||||
|
const DEBOUNCE_MS = 1500;
|
||||||
|
|
||||||
|
type JumpItem = { title: string; uri: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `matrix:` deep link the desktop deep-link handler understands (see
|
||||||
|
* `useDeepLinkNavigate`): `matrix:r/<alias>` for a canonical alias, otherwise
|
||||||
|
* `matrix:roomid/<id>`. The sigil is dropped and the remainder is percent-encoded
|
||||||
|
* because the handler decodes each segment with `decodeURIComponent`.
|
||||||
|
*/
|
||||||
|
const roomToUri = (room: Room): string => {
|
||||||
|
const alias = room.getCanonicalAlias();
|
||||||
|
if (alias && alias.startsWith('#')) {
|
||||||
|
return `matrix:r/${encodeURIComponent(alias.slice(1))}`;
|
||||||
|
}
|
||||||
|
return `matrix:roomid/${encodeURIComponent(room.roomId.slice(1))}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-36 — publish a Windows taskbar Jump List of the most recently-active rooms.
|
||||||
|
* Rooms come from `allRoomsAtom` (the joined-room list), sorted by
|
||||||
|
* `getLastActiveTimestamp` (mirroring the sort used elsewhere, e.g. the forward
|
||||||
|
* dialog), with spaces excluded. The list is pushed to the native
|
||||||
|
* `set_jump_list` command, debounced so bursts of activity don't thrash the
|
||||||
|
* shell. No-op outside Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriJumpList(): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return undefined;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const items: JumpItem[] = allRooms
|
||||||
|
.map((roomId) => mx.getRoom(roomId))
|
||||||
|
.filter((room): room is Room => room !== null && !room.isSpaceRoom())
|
||||||
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0))
|
||||||
|
.slice(0, MAX_ITEMS)
|
||||||
|
.map((room) => ({ title: room.name || room.roomId, uri: roomToUri(room) }));
|
||||||
|
|
||||||
|
invokeTauri('set_jump_list', { items });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [mx, allRooms]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Detail shape of the `network-changed` event emitted by the native side. */
|
||||||
|
type NetworkChangedDetail = {
|
||||||
|
online: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-49 — Network awareness (desktop). Subscribes to the native
|
||||||
|
* `network-changed` event (Windows Network List Manager poll, `{ online }`) and,
|
||||||
|
* on a transition back to online, calls `mx.retryImmediately()` so the sync loop
|
||||||
|
* retries its backed-off `/sync` at once instead of waiting out the backoff
|
||||||
|
* timer. Returns the last known connectivity (`undefined` until the first
|
||||||
|
* event). Inert in the browser, since `useTauriEvent` only listens under Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriNetwork(): boolean | undefined {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [online, setOnline] = useState<boolean | undefined>(undefined);
|
||||||
|
// Track the previous value in a ref so we can detect an offline -> online
|
||||||
|
// transition without adding it to a dependency list.
|
||||||
|
const onlineRef = useRef<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
useTauriEvent<NetworkChangedDetail>('network-changed', ({ online: next }) => {
|
||||||
|
const previous = onlineRef.current;
|
||||||
|
onlineRef.current = next;
|
||||||
|
setOnline(next);
|
||||||
|
// Only nudge the client when connectivity is (re)gained. The initial event
|
||||||
|
// (previous === undefined) also triggers a retry, which is safe: it's a
|
||||||
|
// no-op if nothing is backed off.
|
||||||
|
if (next && previous !== true) {
|
||||||
|
mx.retryImmediately();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return online;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { useCallControlState } from '../plugins/call';
|
||||||
|
import { invokeTauri, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-43 — expose the active call to the Windows System Media Transport Controls
|
||||||
|
* (the volume-flyout / media overlay). Mirrors the call-embed atom (undefined =
|
||||||
|
* no active call) and the current mic state onto the native
|
||||||
|
* `set_smtc_call_state` command, and translates SMTC button presses back into
|
||||||
|
* call actions:
|
||||||
|
* - Play/Pause (`smtc-action` → `mute`) toggles the microphone.
|
||||||
|
* - Stop (`smtc-action` → `end`) hangs up the call.
|
||||||
|
* No-op in the browser (the native command and events only fire under Tauri).
|
||||||
|
*/
|
||||||
|
type SmtcAction = { action: 'mute' | 'end' };
|
||||||
|
|
||||||
|
export function useTauriSmtc(): void {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
// `microphone` reflects mic-enabled; muted is its inverse while in a call.
|
||||||
|
const { microphone } = useCallControlState(callEmbed?.control);
|
||||||
|
const active = callEmbed !== undefined;
|
||||||
|
const muted = active && !microphone;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invokeTauri('set_smtc_call_state', { active, muted });
|
||||||
|
}, [active, muted]);
|
||||||
|
|
||||||
|
useTauriEvent<SmtcAction>('smtc-action', ({ action }) => {
|
||||||
|
if (!callEmbed) return;
|
||||||
|
if (action === 'mute') {
|
||||||
|
callEmbed.control.toggleMicrophone().catch(() => undefined);
|
||||||
|
} else if (action === 'end') {
|
||||||
|
callEmbed.hangup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { useCallControlState } from '../plugins/call';
|
||||||
|
import { invokeTauri, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
type ThumbbarAction = { action: 'mute' | 'deafen' | 'end' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-44 — Taskbar thumbnail toolbar (call controls). While a call is active,
|
||||||
|
* mirrors the mic/sound state onto the native `set_thumbbar` command (three
|
||||||
|
* Mute / Deafen / End-Call buttons on the Windows taskbar thumbnail toolbar) and
|
||||||
|
* hides them when the call ends. Thumb-button clicks come back as the
|
||||||
|
* `thumbbar-action` event and drive the real call controls. No-op in the browser.
|
||||||
|
*/
|
||||||
|
export function useTauriThumbbar(): void {
|
||||||
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
|
const { microphone, sound } = useCallControlState(callEmbed?.control);
|
||||||
|
|
||||||
|
const active = callEmbed !== undefined;
|
||||||
|
// Muted / deafened only make sense while a call is active; report false
|
||||||
|
// otherwise so the buttons render in a sane (hidden) state.
|
||||||
|
const muted = active && !microphone;
|
||||||
|
const deafened = active && !sound;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invokeTauri('set_thumbbar', { active, muted, deafened });
|
||||||
|
}, [active, muted, deafened]);
|
||||||
|
|
||||||
|
useTauriEvent<ThumbbarAction>('thumbbar-action', ({ action }) => {
|
||||||
|
if (!callEmbed) return;
|
||||||
|
if (action === 'mute') {
|
||||||
|
// toggleMicrophone flips the mic; `microphone === false` means muted.
|
||||||
|
// Async transport send — swallow rejection (widget mid-teardown), as SMTC does.
|
||||||
|
callEmbed.control.toggleMicrophone().catch(() => undefined);
|
||||||
|
} else if (action === 'deafen') {
|
||||||
|
// toggleSound flips local audio; `sound === false` means deafened. It also
|
||||||
|
// mutes the mic while deafened, matching the in-app Deafen control.
|
||||||
|
callEmbed.control.toggleSound();
|
||||||
|
} else if (action === 'end') {
|
||||||
|
callEmbed.hangup().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { MsgType } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Payload of the `lotus-notification-activate` event (a plain body click). */
|
||||||
|
interface ActivateDetail {
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
|
||||||
|
interface ReplyDetail {
|
||||||
|
roomId?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-41 / P5-35 — wire the native WinRT toast's click + quick-reply back into the
|
||||||
|
* client. The Rust side (`show_rich_toast`) dispatches DOM CustomEvents via
|
||||||
|
* `emit_to_web`:
|
||||||
|
* - `lotus-notification-activate` → route to the room the toast was for, reusing
|
||||||
|
* the same `useNavigate(path)` mechanism the web `notificationclick` path uses
|
||||||
|
* (see ClientNonUIFeatures).
|
||||||
|
* - `lotus-notification-reply` → send the typed reply straight to the room.
|
||||||
|
* No-op outside Tauri (the events never fire).
|
||||||
|
*/
|
||||||
|
export function useTauriToastActions(): void {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
|
||||||
|
if (path) navigate(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
|
||||||
|
if (!roomId || !text) return;
|
||||||
|
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||||
|
import { invokeTauri, isTauri } from './useTauri';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
|
||||||
|
*
|
||||||
|
* On mount and whenever the atom changes, pushes the value onto the native
|
||||||
|
* `set_custom_chrome` command: `enabled = true` strips the OS decorations so the
|
||||||
|
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
|
||||||
|
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
|
||||||
|
* from the app shell.
|
||||||
|
*/
|
||||||
|
export function useTauriWindowChrome(): void {
|
||||||
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
invokeTauri('set_custom_chrome', { enabled });
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
+38
-2
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,10 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
|||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
|
import { isTauri } from '../hooks/useTauri';
|
||||||
|
import { TitleBar } from '../features/desktop/TitleBar';
|
||||||
|
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||||
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||||
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
@@ -88,6 +92,36 @@ function TauriEffects() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
|
||||||
|
// window decorations in sync with the setting; when a desktop user enables
|
||||||
|
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
|
||||||
|
// default, and always in the browser) this returns children unchanged, so there
|
||||||
|
// is zero layout impact for everyone else.
|
||||||
|
function DesktopChrome({ children }: { children: ReactNode }) {
|
||||||
|
const customChrome = useAtomValue(customWindowChromeAtom);
|
||||||
|
useTauriWindowChrome();
|
||||||
|
const useChrome = isTauri() && customChrome;
|
||||||
|
// Keep the wrapper element structure STABLE across the toggle so flipping the
|
||||||
|
// setting never changes the element type in `children`'s ancestry — otherwise
|
||||||
|
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
|
||||||
|
// menus, unsaved composer state). When off, both wrappers use `display:contents`
|
||||||
|
// so they generate no box → zero layout impact (also the browser default path).
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
useChrome
|
||||||
|
? { display: 'flex', flexDirection: 'column', height: '100vh' }
|
||||||
|
: { display: 'contents' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{useChrome && <TitleBar />}
|
||||||
|
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NightLightOverlay() {
|
function NightLightOverlay() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
if (!settings.nightLightEnabled) return null;
|
if (!settings.nightLightEnabled) return null;
|
||||||
@@ -160,7 +194,9 @@ function App() {
|
|||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<AppearanceEffects />
|
<AppearanceEffects />
|
||||||
<TauriEffects />
|
<TauriEffects />
|
||||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
<DesktopChrome>
|
||||||
|
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||||
|
</DesktopChrome>
|
||||||
<SeasonalEffect />
|
<SeasonalEffect />
|
||||||
<NightLightOverlay />
|
<NightLightOverlay />
|
||||||
<LotusToastContainer />
|
<LotusToastContainer />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
|||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||||
@@ -33,6 +34,7 @@ import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
|||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
|
|
||||||
function isInQuietHours(start: string, end: string): boolean {
|
function isInQuietHours(start: string, end: string): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -109,6 +111,7 @@ function InviteNotifications() {
|
|||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
||||||
@@ -167,7 +170,8 @@ function InviteNotifications() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
const quietActive =
|
||||||
|
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (!quietActive) {
|
if (!quietActive) {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
notify(invites.length - perviousInviteLen);
|
notify(invites.length - perviousInviteLen);
|
||||||
@@ -189,6 +193,7 @@ function InviteNotifications() {
|
|||||||
quietHoursEnabled,
|
quietHoursEnabled,
|
||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
|
focusAssistActive,
|
||||||
inviteSoundId,
|
inviteSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -212,6 +217,7 @@ function MessageNotifications() {
|
|||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
||||||
@@ -355,7 +361,8 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
const quietActive =
|
||||||
|
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (!quietActive) {
|
if (!quietActive) {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
@@ -394,6 +401,7 @@ function MessageNotifications() {
|
|||||||
quietHoursEnabled,
|
quietHoursEnabled,
|
||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
|
focusAssistActive,
|
||||||
messageSoundId,
|
messageSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -555,6 +563,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
|
<TauriDesktopFeatures />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ export class CallEmbed {
|
|||||||
themeKind: ElementCallThemeKind,
|
themeKind: ElementCallThemeKind,
|
||||||
denoiseMode: NoiseSuppressionMode = 'browser',
|
denoiseMode: NoiseSuppressionMode = 'browser',
|
||||||
denoiseModel: string = 'rnnoise',
|
denoiseModel: string = 'rnnoise',
|
||||||
// [lotus] no longer used by the in-source denoise path; kept positionally
|
// [lotus] "Series suppression": also run EC's built-in WebRTC NS before the
|
||||||
// for callers. Prefixed with _ to satisfy no-unused-vars.
|
// in-source ML model (opt-in test aid for stacking browser NS + ML).
|
||||||
_denoiseNativeNS: boolean = true,
|
denoiseNativeNS: boolean = false,
|
||||||
denoiseGate: boolean = false,
|
denoiseGate: boolean = false,
|
||||||
denoiseGateThreshold: number = -45,
|
denoiseGateThreshold: number = -45,
|
||||||
initialAudio = true,
|
initialAudio = true,
|
||||||
@@ -166,10 +166,18 @@ export class CallEmbed {
|
|||||||
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
||||||
lang: 'en-EN',
|
lang: 'en-EN',
|
||||||
theme: themeKind,
|
theme: themeKind,
|
||||||
// EC's built-in WebRTC suppressor: on only for 'browser' tier. For 'ml'
|
// EC's built-in WebRTC suppressor: on for the 'browser' tier, and for the
|
||||||
// we disable it so EC captures a raw mic and the fork's in-source denoise
|
// 'ml' tier only when "series suppression" is opted into (stack browser NS
|
||||||
// TrackProcessor (lotusDenoiseSource) handles the pipeline.
|
// before the fork's in-source ML model). Plain 'ml' keeps it OFF so the
|
||||||
noiseSuppression: (denoiseMode === 'browser').toString(),
|
// fork's TrackProcessor (lotusDenoiseSource) gets a raw mic.
|
||||||
|
noiseSuppression: (
|
||||||
|
denoiseMode === 'browser' ||
|
||||||
|
(denoiseMode === 'ml' && denoiseNativeNS)
|
||||||
|
).toString(),
|
||||||
|
// Turn the browser's auto gain control OFF for the ML tier only: its
|
||||||
|
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
|
||||||
|
// tiers keep the browser's normal capture pipeline (AGC on).
|
||||||
|
autoGainControl: (denoiseMode !== 'ml').toString(),
|
||||||
audio: initialAudio.toString(),
|
audio: initialAudio.toString(),
|
||||||
video: initialVideo.toString(),
|
video: initialVideo.toString(),
|
||||||
header: 'none',
|
header: 'none',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
atomWithLocalStorage,
|
||||||
|
getLocalStorageItem,
|
||||||
|
setLocalStorageItem,
|
||||||
|
} from './utils/atomWithLocalStorage';
|
||||||
|
|
||||||
|
const CUSTOM_WINDOW_CHROME = 'customWindowChrome';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — TDS Custom Window Chrome opt-in flag (default `false`).
|
||||||
|
*
|
||||||
|
* Standalone, `localStorage`-backed boolean atom kept separate from
|
||||||
|
* `state/settings.ts` on purpose. When `true` (and running inside Tauri) the app
|
||||||
|
* strips the native window frame and renders its own `<TitleBar/>`; when `false`
|
||||||
|
* the native OS frame is used. The feature is runtime-reversible, so flipping
|
||||||
|
* this atom is all it takes to switch back and forth.
|
||||||
|
*/
|
||||||
|
export const customWindowChromeAtom = atomWithLocalStorage<boolean>(
|
||||||
|
CUSTOM_WINDOW_CHROME,
|
||||||
|
(key) => getLocalStorageItem<boolean>(key, false),
|
||||||
|
(key, value) => setLocalStorageItem(key, value),
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync (live OS state).
|
||||||
|
*
|
||||||
|
* Standalone, non-persisted boolean atom reflecting whether the shell is
|
||||||
|
* currently suppressing notifications (Focus Assist / Quiet Hours, presentation
|
||||||
|
* mode, full-screen D3D, or "busy"). It is driven at runtime by
|
||||||
|
* `useTauriFocusAssist` from the native `focus-assist-changed` event and read by
|
||||||
|
* the notification gate. Because it mirrors transient OS state — not a user
|
||||||
|
* preference — it is a plain in-memory atom that defaults to `false` and is
|
||||||
|
* intentionally NOT written to `localStorage`.
|
||||||
|
*/
|
||||||
|
export const focusAssistActiveAtom = atom(false);
|
||||||
@@ -60,6 +60,39 @@ export enum MessageLayout {
|
|||||||
Bubble = 2,
|
Bubble = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of the toggleable composer toolbar buttons. Also used as the identity
|
||||||
|
* of each button when persisting/restoring a custom drag-and-drop order.
|
||||||
|
*/
|
||||||
|
export const COMPOSER_TOOLBAR_BUTTON_KEYS = [
|
||||||
|
'showFormat',
|
||||||
|
'showEmoji',
|
||||||
|
'showSticker',
|
||||||
|
'showGif',
|
||||||
|
'showLocation',
|
||||||
|
'showPoll',
|
||||||
|
'showVoice',
|
||||||
|
'showSchedule',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ComposerToolbarButtonKey = (typeof COMPOSER_TOOLBAR_BUTTON_KEYS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed order the composer toolbar rendered before reordering existed.
|
||||||
|
* Used as the fallback for users without a saved order, and to append any
|
||||||
|
* new/unknown button keys, so existing users see no change.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_COMPOSER_TOOLBAR_ORDER: ComposerToolbarButtonKey[] = [
|
||||||
|
'showFormat',
|
||||||
|
'showSticker',
|
||||||
|
'showEmoji',
|
||||||
|
'showGif',
|
||||||
|
'showLocation',
|
||||||
|
'showPoll',
|
||||||
|
'showVoice',
|
||||||
|
'showSchedule',
|
||||||
|
];
|
||||||
|
|
||||||
export interface ComposerToolbarSettings {
|
export interface ComposerToolbarSettings {
|
||||||
showFormat: boolean;
|
showFormat: boolean;
|
||||||
showEmoji: boolean;
|
showEmoji: boolean;
|
||||||
@@ -69,6 +102,7 @@ export interface ComposerToolbarSettings {
|
|||||||
showPoll: boolean;
|
showPoll: boolean;
|
||||||
showVoice: boolean;
|
showVoice: boolean;
|
||||||
showSchedule: boolean;
|
showSchedule: boolean;
|
||||||
|
order: ComposerToolbarButtonKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
||||||
@@ -80,6 +114,47 @@ export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
|||||||
showPoll: true,
|
showPoll: true,
|
||||||
showVoice: true,
|
showVoice: true,
|
||||||
showSchedule: true,
|
showSchedule: true,
|
||||||
|
order: DEFAULT_COMPOSER_TOOLBAR_ORDER,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a complete, de-duplicated composer toolbar order:
|
||||||
|
* - drops unknown/duplicate keys from the saved order
|
||||||
|
* - appends any missing keys (new buttons or existing users with no saved
|
||||||
|
* order) at the end in their canonical default position
|
||||||
|
* so a button can never disappear from the toolbar.
|
||||||
|
*/
|
||||||
|
export const normalizeComposerToolbarOrder = (
|
||||||
|
order: ComposerToolbarButtonKey[] | undefined,
|
||||||
|
): ComposerToolbarButtonKey[] => {
|
||||||
|
const known = new Set<ComposerToolbarButtonKey>(COMPOSER_TOOLBAR_BUTTON_KEYS);
|
||||||
|
const seen = new Set<ComposerToolbarButtonKey>();
|
||||||
|
const result: ComposerToolbarButtonKey[] = [];
|
||||||
|
|
||||||
|
(order ?? []).forEach((key) => {
|
||||||
|
if (known.has(key) && !seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
result.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Append missing keys in their canonical default position…
|
||||||
|
DEFAULT_COMPOSER_TOOLBAR_ORDER.forEach((key) => {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
result.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// …then any known key not covered by the default order (safety net so a new
|
||||||
|
// button added to COMPOSER_TOOLBAR_BUTTON_KEYS but forgotten in the default
|
||||||
|
// order can still render/reorder rather than being permanently dropped).
|
||||||
|
COMPOSER_TOOLBAR_BUTTON_KEYS.forEach((key) => {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
result.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
@@ -236,9 +311,13 @@ const defaultSettings: Settings = {
|
|||||||
perMessageProfiles: false,
|
perMessageProfiles: false,
|
||||||
|
|
||||||
cameraOnJoin: false,
|
cameraOnJoin: false,
|
||||||
|
// Tier default stays browser-native (known-good; best-perceived in testing so
|
||||||
|
// far). If a user opts into the ML tier, default to the highest-quality model.
|
||||||
callNoiseSuppression: 'browser',
|
callNoiseSuppression: 'browser',
|
||||||
callDenoiseModel: 'rnnoise',
|
callDenoiseModel: 'deepfilternet',
|
||||||
callDenoiseNativeNS: true,
|
// "Series suppression" (stack the browser's native NS before the ML model) is
|
||||||
|
// off by default — best practice is a single NS stage; it's an opt-in test aid.
|
||||||
|
callDenoiseNativeNS: false,
|
||||||
callDenoiseGate: false,
|
callDenoiseGate: false,
|
||||||
callDenoiseGateThreshold: -45,
|
callDenoiseGateThreshold: -45,
|
||||||
pttMode: false,
|
pttMode: false,
|
||||||
@@ -314,6 +393,7 @@ export const getSettings = (): Settings => {
|
|||||||
composerToolbarButtons: {
|
composerToolbarButtons: {
|
||||||
...DEFAULT_COMPOSER_TOOLBAR,
|
...DEFAULT_COMPOSER_TOOLBAR,
|
||||||
...(saved.composerToolbarButtons ?? {}),
|
...(saved.composerToolbarButtons ?? {}),
|
||||||
|
order: normalizeComposerToolbarOrder(saved.composerToolbarButtons?.order),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
contrastingText,
|
contrastingText,
|
||||||
varNameFromToken,
|
varNameFromToken,
|
||||||
derivePrimaryPalette,
|
derivePrimaryPalette,
|
||||||
|
deriveAccentExtras,
|
||||||
|
buildAccentCss,
|
||||||
} from './accentColor';
|
} from './accentColor';
|
||||||
|
|
||||||
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
|
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
|
||||||
@@ -66,3 +68,22 @@ test('derivePrimaryPalette produces the full Primary token set', () => {
|
|||||||
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
|
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
|
||||||
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
|
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deriveAccentExtras derives focus ring, link and selection from one base', () => {
|
||||||
|
const base = { r: 255, g: 136, b: 0 };
|
||||||
|
const extras = deriveAccentExtras(base);
|
||||||
|
// focus ring keeps the translucent character in the accent hue
|
||||||
|
assert.equal(extras.focusRing, 'rgba(255, 136, 0, 0.5)');
|
||||||
|
// link + selection background are the solid base hex
|
||||||
|
assert.equal(extras.link, '#ff8800');
|
||||||
|
assert.equal(extras.selectionBg, '#ff8800');
|
||||||
|
// selection text is WCAG-aware contrasting text over the base
|
||||||
|
assert.equal(extras.selectionText, contrastingText(base));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAccentCss emits selection rules using the derived palette', () => {
|
||||||
|
const base = { r: 0, g: 0, b: 0 };
|
||||||
|
const css = buildAccentCss(base);
|
||||||
|
assert.match(css, /::selection\{background:#000000;color:#fff;\}/);
|
||||||
|
assert.match(css, /::-moz-selection\{background:#000000;color:#fff;\}/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record<string, string> = {
|
|||||||
OnContainer: color.Primary.OnContainer,
|
OnContainer: color.Primary.OnContainer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The neutral focus-ring token folds uses for the outline on inputs, buttons,
|
||||||
|
// switches, checkboxes and radios. Its default is a semi-transparent grey/black,
|
||||||
|
// so tinting it in the accent hue themes every focus ring without touching the
|
||||||
|
// neutral Secondary family (see below). We keep the same translucent character
|
||||||
|
// so it reads as a ring rather than a fill.
|
||||||
|
const FOCUS_RING_TOKEN = color.Other.FocusRing;
|
||||||
|
|
||||||
|
// `--tc-link` is the global anchor color (index.css `a { color: var(--tc-link) }`);
|
||||||
|
// overriding it themes plain links inside messages, room topics and URL previews.
|
||||||
|
const LINK_VAR = '--tc-link';
|
||||||
|
|
||||||
|
// Injected stylesheet id — carries rules that cannot be expressed as a single
|
||||||
|
// CSS variable (currently text ::selection).
|
||||||
|
const ACCENT_STYLE_ID = 'lotus-accent-style';
|
||||||
|
|
||||||
|
export type AccentExtras = {
|
||||||
|
focusRing: string;
|
||||||
|
link: string;
|
||||||
|
selectionBg: string;
|
||||||
|
selectionText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive the extra (non-Primary) accent values from the single base color, using
|
||||||
|
// the same helpers as the Primary palette so everything stays in one hue.
|
||||||
|
export const deriveAccentExtras = (base: Rgb): AccentExtras => ({
|
||||||
|
focusRing: rgba(base, 0.5),
|
||||||
|
link: rgbToHex(base),
|
||||||
|
selectionBg: rgbToHex(base),
|
||||||
|
selectionText: contrastingText(base),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the injected stylesheet body. Selection uses a solid accent fill with
|
||||||
|
// WCAG-aware contrasting text so highlighted text stays readable.
|
||||||
|
export const buildAccentCss = (base: Rgb): string => {
|
||||||
|
const { selectionBg, selectionText } = deriveAccentExtras(base);
|
||||||
|
const selection = `background:${selectionBg};color:${selectionText};`;
|
||||||
|
return `::selection{${selection}}::-moz-selection{${selection}}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Derive the 10 Primary sub-token values from a single chosen base color.
|
// Derive the 10 Primary sub-token values from a single chosen base color.
|
||||||
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
||||||
const baseHex = rgbToHex(base);
|
const baseHex = rgbToHex(base);
|
||||||
@@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Apply a custom accent color by overriding the folds Primary CSS variables on
|
// Apply a custom accent color by overriding the folds Primary CSS variables on
|
||||||
// `document.body`. Returns true when applied, false when the input is invalid.
|
// `document.body`, tinting the focus-ring and link vars, and injecting a small
|
||||||
|
// stylesheet for text selection. Returns true when applied, false when the input
|
||||||
|
// is invalid.
|
||||||
export const applyCustomAccent = (hex: string): boolean => {
|
export const applyCustomAccent = (hex: string): boolean => {
|
||||||
const base = hexToRgb(hex);
|
const base = hexToRgb(hex);
|
||||||
if (!base) return false;
|
if (!base) return false;
|
||||||
|
|
||||||
const palette = derivePrimaryPalette(base);
|
const palette = derivePrimaryPalette(base);
|
||||||
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
||||||
const varName = varNameFromToken(token);
|
const varName = varNameFromToken(token);
|
||||||
if (varName) document.body.style.setProperty(varName, palette[key]);
|
if (varName) document.body.style.setProperty(varName, palette[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extras = deriveAccentExtras(base);
|
||||||
|
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
||||||
|
if (focusRingVar) document.body.style.setProperty(focusRingVar, extras.focusRing);
|
||||||
|
document.body.style.setProperty(LINK_VAR, extras.link);
|
||||||
|
|
||||||
|
let styleEl = document.getElementById(ACCENT_STYLE_ID) as HTMLStyleElement | null;
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = ACCENT_STYLE_ID;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = buildAccentCss(base);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
||||||
|
// Idempotent — safe to call even when nothing was applied.
|
||||||
export const removeCustomAccent = (): void => {
|
export const removeCustomAccent = (): void => {
|
||||||
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
||||||
const varName = varNameFromToken(token);
|
const varName = varNameFromToken(token);
|
||||||
if (varName) document.body.style.removeProperty(varName);
|
if (varName) document.body.style.removeProperty(varName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
||||||
|
if (focusRingVar) document.body.style.removeProperty(focusRingVar);
|
||||||
|
document.body.style.removeProperty(LINK_VAR);
|
||||||
|
|
||||||
|
document.getElementById(ACCENT_STYLE_ID)?.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { filesFromEntries } from './fileEntries';
|
||||||
|
|
||||||
|
const fileEntry = (name: string): FileSystemFileEntry =>
|
||||||
|
({
|
||||||
|
isFile: true,
|
||||||
|
isDirectory: false,
|
||||||
|
name,
|
||||||
|
file: (success: (file: File) => void) => {
|
||||||
|
success(new File(['x'], name, { type: 'text/plain' }));
|
||||||
|
},
|
||||||
|
}) as unknown as FileSystemFileEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A directory whose reader yields its children in several batches (mirroring
|
||||||
|
* Chromium's `readEntries`, which caps each call) and finally an empty batch.
|
||||||
|
*/
|
||||||
|
const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry => {
|
||||||
|
const batches = [children.slice(0, 1), children.slice(1), [] as FileSystemEntry[]];
|
||||||
|
return {
|
||||||
|
isFile: false,
|
||||||
|
isDirectory: true,
|
||||||
|
name,
|
||||||
|
createReader: () => {
|
||||||
|
let call = 0;
|
||||||
|
return {
|
||||||
|
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
|
||||||
|
const batch = batches[call] ?? [];
|
||||||
|
call += 1;
|
||||||
|
success(batch);
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryReader;
|
||||||
|
},
|
||||||
|
} as unknown as FileSystemDirectoryEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('filesFromEntries flattens nested folders and prefixes relative paths', async () => {
|
||||||
|
const entries: FileSystemEntry[] = [
|
||||||
|
fileEntry('top.txt'),
|
||||||
|
dirEntry('photos', [
|
||||||
|
fileEntry('a.jpg'),
|
||||||
|
dirEntry('2024', [fileEntry('b.jpg'), fileEntry('c.jpg')]),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await filesFromEntries(entries);
|
||||||
|
const names = files.map((f) => f.name).sort();
|
||||||
|
|
||||||
|
assert.deepEqual(names, ['photos/2024/b.jpg', 'photos/2024/c.jpg', 'photos/a.jpg', 'top.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filesFromEntries reads directory entries in batches until empty', async () => {
|
||||||
|
const entries: FileSystemEntry[] = [
|
||||||
|
dirEntry('docs', [fileEntry('one.txt'), fileEntry('two.txt')]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await filesFromEntries(entries);
|
||||||
|
assert.equal(files.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filesFromEntries respects the maxFiles cap', async () => {
|
||||||
|
const entries: FileSystemEntry[] = [
|
||||||
|
dirEntry('many', [fileEntry('a.txt'), fileEntry('b.txt')]),
|
||||||
|
fileEntry('c.txt'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await filesFromEntries(entries, 2);
|
||||||
|
assert.equal(files.length, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { getDataTransferFiles, renameFile } from './dom';
|
||||||
|
|
||||||
|
// Guard against pathological drops (deeply nested / huge trees) that could
|
||||||
|
// otherwise queue thousands of uploads and freeze the composer.
|
||||||
|
export const MAX_DROPPED_FILES = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously collect the `FileSystemEntry` objects for every item in a
|
||||||
|
* drop's `DataTransfer`.
|
||||||
|
*
|
||||||
|
* This MUST be called synchronously inside the drop event handler: the
|
||||||
|
* `DataTransferItemList` is emptied once the handler returns, so calling
|
||||||
|
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
|
||||||
|
* first, then traverse them asynchronously with {@link filesFromEntries}.
|
||||||
|
*
|
||||||
|
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
|
||||||
|
* browsers), signalling the caller to fall back to the flat file list.
|
||||||
|
*/
|
||||||
|
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
|
||||||
|
const entries: FileSystemEntry[] = [];
|
||||||
|
const { items } = dataTransfer;
|
||||||
|
if (!items) return entries;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
|
||||||
|
const entry = item.webkitGetAsEntry();
|
||||||
|
if (entry) entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every entry from a directory reader.
|
||||||
|
*
|
||||||
|
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
|
||||||
|
* call), so it must be called repeatedly until it resolves with an empty array.
|
||||||
|
*/
|
||||||
|
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const all: FileSystemEntry[] = [];
|
||||||
|
const readBatch = () => {
|
||||||
|
reader.readEntries((batch) => {
|
||||||
|
if (batch.length === 0) {
|
||||||
|
resolve(all);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
all.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
}, reject);
|
||||||
|
};
|
||||||
|
readBatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk `FileSystemEntry` objects (as produced by
|
||||||
|
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
|
||||||
|
* descending into every nested directory.
|
||||||
|
*
|
||||||
|
* Nested files keep their relative folder path as a name prefix (e.g.
|
||||||
|
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
|
||||||
|
* once `maxFiles` files have been collected.
|
||||||
|
*/
|
||||||
|
export const filesFromEntries = async (
|
||||||
|
entries: FileSystemEntry[],
|
||||||
|
maxFiles: number = MAX_DROPPED_FILES,
|
||||||
|
): Promise<File[]> => {
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||||||
|
if (files.length >= maxFiles) return;
|
||||||
|
|
||||||
|
// A single unreadable file/directory (moved between drop and read, a
|
||||||
|
// permissions/lock error, an OS special file) must NOT abort the whole
|
||||||
|
// traversal — skip it and keep collecting the rest.
|
||||||
|
if (entry.isFile) {
|
||||||
|
try {
|
||||||
|
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
|
||||||
|
if (files.length >= maxFiles) return;
|
||||||
|
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
|
||||||
|
} catch {
|
||||||
|
/* skip unreadable file */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
let childEntries: FileSystemEntry[] = [];
|
||||||
|
try {
|
||||||
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
childEntries = await readAllDirectoryEntries(reader);
|
||||||
|
} catch {
|
||||||
|
return; /* skip unreadable directory */
|
||||||
|
}
|
||||||
|
const childPrefix = `${prefix}${entry.name}/`;
|
||||||
|
for (const child of childEntries) {
|
||||||
|
if (files.length >= maxFiles) break;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await walk(child, childPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (files.length >= maxFiles) break;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await walk(entry, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract dropped files, descending into any dropped folders.
|
||||||
|
*
|
||||||
|
* Captures the `FileSystemEntry` list synchronously (required — see
|
||||||
|
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
|
||||||
|
* to the flat `dataTransfer.files` list when the directory API is unavailable
|
||||||
|
* (non-Chromium) or when no entries are exposed.
|
||||||
|
*/
|
||||||
|
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
|
||||||
|
const entries = entriesFromDataTransfer(dataTransfer);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return Promise.resolve(getDataTransferFiles(dataTransfer));
|
||||||
|
}
|
||||||
|
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
|
||||||
|
};
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
import { test, beforeEach, afterEach } from 'node:test';
|
import { test, beforeEach, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import {
|
import { DENOISE_MODELS, ML_DENOISE_REQUIREMENTS, isMLDenoiseSupported } from './lotusDenoiseUtils';
|
||||||
DENOISE_MODELS,
|
|
||||||
ML_DENOISE_REQUIREMENTS,
|
|
||||||
isMLDenoiseSupported,
|
|
||||||
} from './lotusDenoiseUtils';
|
|
||||||
|
|
||||||
// ── Model catalog (data integrity) ──────────────────────────────────────────
|
// ── Model catalog (data integrity) ──────────────────────────────────────────
|
||||||
|
|
||||||
test('DENOISE_MODELS lists the four expected models in order', () => {
|
test('DENOISE_MODELS lists the four models ordered best-quality (highest CPU) first', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
DENOISE_MODELS.map((m) => m.id),
|
DENOISE_MODELS.map((m) => m.id),
|
||||||
['rnnoise', 'speex', 'dtln', 'deepfilternet'],
|
['deepfilternet', 'dtln', 'rnnoise', 'speex'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Detection utilities for Lotus ML noise suppression (RNNoise).
|
* Detection utilities + model catalog for Lotus ML noise suppression
|
||||||
|
* (DeepFilterNet 3 / DTLN / RNNoise / Speex). The catalog is ordered by
|
||||||
|
* quality (and, correspondingly, CPU cost) — highest first — and drives the
|
||||||
|
* order of the model dropdown in settings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DenoiseModelId } from '../state/settings';
|
import { DenoiseModelId } from '../state/settings';
|
||||||
@@ -14,42 +17,47 @@ export type DenoiseModel = {
|
|||||||
voiceQuality: 'Moderate' | 'High' | 'Very High';
|
voiceQuality: 'Moderate' | 'High' | 'Very High';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ordered best-quality (highest CPU) first — this is the dropdown order.
|
||||||
export const DENOISE_MODELS: DenoiseModel[] = [
|
export const DENOISE_MODELS: DenoiseModel[] = [
|
||||||
{
|
{
|
||||||
id: 'rnnoise',
|
id: 'deepfilternet',
|
||||||
name: 'RNNoise',
|
name: 'DeepFilterNet 3 (beta)',
|
||||||
description: 'Lightweight hybrid model. Best for consistent noise like fans.',
|
description:
|
||||||
cpuUsage: '< 5%',
|
'Studio-grade deep-learning model (48 kHz fullband, ONNX). Best quality; highest CPU and a larger one-time download.',
|
||||||
binarySize: '< 1 MB',
|
cpuUsage: '25-50%',
|
||||||
transients: 'Good',
|
binarySize: '~18 MB',
|
||||||
voiceQuality: 'High',
|
transients: 'Excellent',
|
||||||
},
|
voiceQuality: 'Very High',
|
||||||
{
|
|
||||||
id: 'speex',
|
|
||||||
name: 'Speex (Legacy)',
|
|
||||||
description: 'Classic DSP noise suppressor. Minimal CPU, gentler on voice.',
|
|
||||||
cpuUsage: '< 2%',
|
|
||||||
binarySize: '< 1 MB',
|
|
||||||
transients: 'Poor',
|
|
||||||
voiceQuality: 'Moderate',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dtln',
|
id: 'dtln',
|
||||||
name: 'DTLN (beta)',
|
name: 'DTLN (beta)',
|
||||||
description: 'Deep-learning model (TFLite). Stronger on transient noise; higher CPU.',
|
description:
|
||||||
|
'Dual-signal deep-learning model (16 kHz). Strong on transient noise; moderate CPU.',
|
||||||
cpuUsage: '10-20%',
|
cpuUsage: '10-20%',
|
||||||
binarySize: '~4 MB',
|
binarySize: '~4 MB',
|
||||||
transients: 'Excellent',
|
transients: 'Excellent',
|
||||||
voiceQuality: 'High',
|
voiceQuality: 'High',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'deepfilternet',
|
id: 'rnnoise',
|
||||||
name: 'DeepFilterNet 3 (beta)',
|
name: 'RNNoise',
|
||||||
description: 'Studio-grade deep-learning model (48 kHz, ONNX). Best quality; highest CPU.',
|
description:
|
||||||
cpuUsage: '25-50%',
|
'Lightweight hybrid model (48 kHz). Very low CPU; good for steady noise like fans, but can sound processed at full strength.',
|
||||||
binarySize: '~18 MB',
|
cpuUsage: '< 5%',
|
||||||
transients: 'Excellent',
|
binarySize: '< 1 MB',
|
||||||
voiceQuality: 'Very High',
|
transients: 'Good',
|
||||||
|
voiceQuality: 'Moderate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speex',
|
||||||
|
name: 'Speex (Legacy)',
|
||||||
|
description:
|
||||||
|
'Classic DSP noise suppressor. Minimal CPU, gentlest on voice; weakest suppression.',
|
||||||
|
cpuUsage: '< 2%',
|
||||||
|
binarySize: '< 1 MB',
|
||||||
|
transients: 'Poor',
|
||||||
|
voiceQuality: 'Moderate',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -67,8 +75,14 @@ export const isMLDenoiseSupported = (): boolean => {
|
|||||||
// instead of returning false.
|
// instead of returning false.
|
||||||
const hasAudioWorklet = hasAudioContext && typeof AudioWorkletNode !== 'undefined';
|
const hasAudioWorklet = hasAudioContext && typeof AudioWorkletNode !== 'undefined';
|
||||||
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
|
// Every ML model compiles WebAssembly (and DFN/DTLN load worklets via blob
|
||||||
|
// URLs). Under a strict CSP without `wasm-unsafe-eval` (e.g. some desktop/Tauri
|
||||||
|
// shells) WASM is unavailable, so gate on it — otherwise we'd offer ML and then
|
||||||
|
// silently fall back to the raw mic in-call.
|
||||||
|
const hasWasm =
|
||||||
|
typeof WebAssembly !== 'undefined' && typeof WebAssembly.instantiate === 'function';
|
||||||
|
|
||||||
return hasAudioWorklet && hasGetUserMedia;
|
return hasAudioWorklet && hasGetUserMedia && hasWasm;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +91,6 @@ export const isMLDenoiseSupported = (): boolean => {
|
|||||||
export const ML_DENOISE_REQUIREMENTS = [
|
export const ML_DENOISE_REQUIREMENTS = [
|
||||||
'Modern browser with Web Audio API support',
|
'Modern browser with Web Audio API support',
|
||||||
'AudioWorklet support (Chrome 66+, Firefox 76+, Safari 14.1+)',
|
'AudioWorklet support (Chrome 66+, Firefox 76+, Safari 14.1+)',
|
||||||
|
'WebAssembly (WASM) support',
|
||||||
'Microphone access',
|
'Microphone access',
|
||||||
'48kHz AudioContext capability',
|
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user