Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81904372bc | |||
| c82ab5c7f5 | |||
| ebcd8ec926 | |||
| 4ff07ea2bd | |||
| 804caa5130 | |||
| 625f0c2386 |
@@ -532,7 +532,7 @@ Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
|
|||||||
(toWidget ones allow-listed in `src/widget.ts`).
|
(toWidget ones allow-listed in `src/widget.ts`).
|
||||||
|
|
||||||
| # | Feature | Enable via | EC module |
|
| # | Feature | Enable via | EC module |
|
||||||
| :-- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
| :--- | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
||||||
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
|
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
|
||||||
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
|
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
|
||||||
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
|
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
|
||||||
@@ -540,6 +540,26 @@ Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
|
|||||||
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
|
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
|
||||||
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
|
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
|
||||||
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
|
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
|
||||||
|
| P6-2 | Deafen / screenshare-audio-mute at the LiveKit source | action `io.lotus.set_deafen {deafened,screenshareAudioMuted}` — sets remote `RemoteParticipant.setVolume(0/1)` per source (Microphone + ScreenShareAudio), persists to late joiners via `RoomEvent.ParticipantConnected` | `lotusDeafen.ts` |
|
||||||
|
|
||||||
|
### 12.4 P6-2 — deafen action (retires cinny's iframe-DOM `.muted` hack)
|
||||||
|
|
||||||
|
`io.lotus.set_deafen` (fork commit, folded into unpublished **`0.20.1-lotus.2`**) replaces
|
||||||
|
cinny's `CallControl.setSound`/`applyScreenshareAudioMuted` DOM `<audio>.muted` poking —
|
||||||
|
which broke silently on EC re-render / late tracks. **Two-phase rollout:**
|
||||||
|
|
||||||
|
1. **DONE (this batch):** fork action implemented; cinny's `CallControl` now ALSO sends
|
||||||
|
`io.lotus.set_deafen` (gated on join via `forceState`) alongside the retained DOM hack.
|
||||||
|
Against the current pinned bundle (`lotus.1`, no handler) the action is immediately
|
||||||
|
error-replied and swallowed by `.catch` — inert, no timeout.
|
||||||
|
2. **TODO — needs YOU to publish, then me:** publish the fork (`0.20.1-lotus.2`) to npm →
|
||||||
|
I bump cinny's pin `0.20.1-lotus.1` → `lotus.2`, `npm install`, then DELETE the DOM
|
||||||
|
`.muted` code from `CallControl.ts` (the hack is fully retired only here).
|
||||||
|
|
||||||
|
**Known divergence to confirm:** deafen silences remote Microphone + ScreenShareAudio, but
|
||||||
|
NOT injected/soundboard audio (`Track.Source.Unknown` — livekit-client's `setVolume` type
|
||||||
|
only accepts Microphone|ScreenShareAudio). So a deafened user still hears host-triggered
|
||||||
|
soundboard clips. Defensible (short, host-gated); confirm it's the desired UX.
|
||||||
|
|
||||||
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
|
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
|
||||||
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
|
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
|
||||||
|
|||||||
+18
-1
@@ -16,7 +16,7 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
|||||||
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
| ID | Item | File / area | Test |
|
| ID | Item | File / area | Test |
|
||||||
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- |
|
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||||
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
@@ -42,6 +42,10 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||||
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||||
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||||
|
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
||||||
|
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
||||||
|
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
||||||
|
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
|
||||||
|
|
||||||
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||||
|
|
||||||
@@ -108,6 +112,19 @@ signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}` —
|
|||||||
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
|
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
|
||||||
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
||||||
**Extreme — planning session.**
|
**Extreme — planning session.**
|
||||||
|
**Update 2026-07 (investigation §6):** upstream `matrix-rust-sdk#5200` (still
|
||||||
|
OPEN) confirms the mechanism — on the 400, `mark_request_as_sent()` never fires
|
||||||
|
so the SDK re-issues the identical upload forever. **`41.7.0` does NOT fix it**
|
||||||
|
(crypto-wasm 17→18.3.1 has no OTK/upload change; 18.3.x was to-device security
|
||||||
|
only) — the SDK-pin lever is closed. Root cause = **store↔server OTK
|
||||||
|
divergence**; the leading **web-specific** trigger is that cinny never calls
|
||||||
|
**`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable
|
||||||
|
while the `localStorage` session/device-id survives → device resurrects with a
|
||||||
|
blank store → re-uploads OTKs the server still holds. **Actionable preventive
|
||||||
|
fix (buildable now, no call needed):** request persistent storage on login
|
||||||
|
(+ optional multi-tab guard + 400-loop→recovery-prompt). Healing an already-
|
||||||
|
diverged device still needs a clean **logout+login** (not just "clear
|
||||||
|
storage"). See `LOTUS_E2EE_INVESTIGATION.md` §6.
|
||||||
|
|
||||||
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
|
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
|
||||||
`MissingKey: missing key at index N for participant @user`, `skipping decryption
|
`MissingKey: missing key at index N for participant @user`, `skipping decryption
|
||||||
|
|||||||
@@ -405,3 +405,100 @@ signature, message }`, most-recent-last).
|
|||||||
to the `Box direction="Column" gap="700"` list (guarded by the existing
|
to the `Box direction="Column" gap="700"` list (guarded by the existing
|
||||||
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
|
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
|
||||||
from `useMatrixClient()` itself, so it just needs to be placed in the tree.
|
from `useMatrixClient()` itself, so it just needs to be placed in the tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 2026-07 investigation update — 41.7.0 delta + web-specific root cause
|
||||||
|
|
||||||
|
New findings this session (code-read + upstream issue triage). These **sharpen
|
||||||
|
KE-1's root cause and close the "just upgrade the SDK" lever**.
|
||||||
|
|
||||||
|
### 6.1 The 41.7.0 upgrade does NOT fix KE-1 (lever closed)
|
||||||
|
|
||||||
|
We are now on **`matrix-js-sdk@41.7.0`** → **`@matrix-org/matrix-sdk-crypto-wasm@18.3.1`**
|
||||||
|
(was `41.6.0-rc.0` when KE-1/2 were observed). Checked both changelogs:
|
||||||
|
|
||||||
|
- 41.7.0's only crypto line is the **security bump to crypto-wasm 18.3.1**. No
|
||||||
|
OTK / keys-upload / Olm-session change.
|
||||||
|
- crypto-wasm 17.0 → 18.3.1: **no entry** for one-time-keys, keys/upload,
|
||||||
|
"already exists", or upload conflicts. The 18.3.x work was **to-device
|
||||||
|
security hardening** (vodozemac 0.10; sender-spoofing check via
|
||||||
|
`sender_device_keys`; MSC4147 validation) — unrelated to the OTK loop.
|
||||||
|
- Upstream **`matrix-rust-sdk#5200`** ("OlmMachine constantly tries to upload
|
||||||
|
keys when restoring session") is **still OPEN** (as of mid-2025). The loop
|
||||||
|
mechanism is confirmed there: on the 400, `mark_request_as_sent()` never
|
||||||
|
fires, so the keys stay "unshared" and the SDK re-issues the identical failing
|
||||||
|
upload every cycle → the storm.
|
||||||
|
|
||||||
|
⇒ **Remediation option 3 (SDK pin) is exhausted for KE-1.** Do not expect a
|
||||||
|
version bump to help; the fix is store-hygiene, below.
|
||||||
|
|
||||||
|
### 6.2 Confirmed root cause + the web-specific trigger we can act on
|
||||||
|
|
||||||
|
Upstream `#5200` + `#1415` pin the root condition to **rust-crypto store ↔
|
||||||
|
server OTK divergence**, from one of:
|
||||||
|
|
||||||
|
1. **Crypto store reset/restore without deregistering the device server-side**
|
||||||
|
— the store forgets OTKs it already published; the server still holds them.
|
||||||
|
2. **Unsafe concurrent access to the crypto store** — e.g. the **same session
|
||||||
|
open in multiple browser tabs**, each running its own OlmMachine against the
|
||||||
|
one IndexedDB crypto store.
|
||||||
|
3. A store that isn't durably persisted, so a restore can't track what was sent.
|
||||||
|
|
||||||
|
**Cinny is a web client and hits two of these by construction (verified in code):**
|
||||||
|
|
||||||
|
- **No `navigator.storage.persist()` anywhere** (`grep` clean). The rust-crypto
|
||||||
|
IndexedDB store is therefore **evictable under storage pressure** — while the
|
||||||
|
**access token + device id live in `localStorage`** (N97), which browsers evict
|
||||||
|
_less_ aggressively. Partial eviction ⇒ the device **resurrects with a blank
|
||||||
|
crypto store but the SAME device id** ⇒ it re-uploads OTKs the server still
|
||||||
|
holds ⇒ the **exact KE-1 "already exists" divergence**, with **no user action**
|
||||||
|
and no visible cause. This is the leading hypothesis for a self-hosted web
|
||||||
|
deployment.
|
||||||
|
- **No multi-tab crypto guard** (no `navigator.locks` / `BroadcastChannel`
|
||||||
|
leader election in `src/`). `initMatrix.ts` calls `mx.initRustCrypto()` with no
|
||||||
|
single-writer coordination, so 2+ tabs = concurrent store access = trigger #2.
|
||||||
|
|
||||||
|
### 6.3 Concrete PREVENTIVE client mitigations (new — buildable, don't need a call)
|
||||||
|
|
||||||
|
Ordered by value/effort. These reduce the _recurrence_ of KE-1; they don't heal
|
||||||
|
an already-diverged device (that still needs remediation option 1: clean
|
||||||
|
logout+login).
|
||||||
|
|
||||||
|
1. **Request persistent storage on login — `navigator.storage.persist()`**
|
||||||
|
_(cheapest, highest value)_. Idempotent, side-effect only, no behavior change
|
||||||
|
if the browser denies it. Directly prevents the eviction-induced divergence in
|
||||||
|
6.2. Best placed at app entry alongside the other module-scope calls (NOT in
|
||||||
|
`initMatrix.ts`, which is off-limits) — e.g. a one-liner in `ClientRoot`/app
|
||||||
|
bootstrap: `if (navigator.storage?.persist) navigator.storage.persist();`
|
||||||
|
Optionally surface `navigator.storage.persisted()` in the Crypto Diagnostics
|
||||||
|
card so a capture records whether the store was evictable.
|
||||||
|
2. **Multi-tab guard** _(medium)_. Detect a second tab of the same session
|
||||||
|
(BroadcastChannel or the Web Locks API) and either (a) warn "Lotus is open in
|
||||||
|
another tab — encryption may misbehave", or (b) make secondary tabs read-only
|
||||||
|
for crypto. Prevents trigger #2.
|
||||||
|
3. **Loop detection → recovery prompt** _(medium)_. Watch for repeated
|
||||||
|
`keys/upload` 400 `M_UNKNOWN … already exists` (the client sees the rejection);
|
||||||
|
after N in a window, stop hammering and surface a "Reset encryption on this
|
||||||
|
device (log out & back in)" prompt instead of looping silently.
|
||||||
|
|
||||||
|
### 6.4 Secondary KE-2 hypothesis to test in the capture
|
||||||
|
|
||||||
|
crypto-wasm **18.3.0 tightened Olm to-device validation** (sender-spoof check +
|
||||||
|
MSC4147). It's therefore possible KE-2's `WARN … unexpected encrypted to-device
|
||||||
|
event … io.element.call.encryption_keys` is **partly** the new validation
|
||||||
|
rejecting EC's media-key events, not _only_ the missing-Olm-session downstream of
|
||||||
|
KE-1. **Capture discriminator:** if KE-2 still occurs in a call where OTK counts
|
||||||
|
are healthy and no KE-1 storm is present (Q1 = NO), suspect the to-device
|
||||||
|
validation path (EC ↔ rust-crypto 18.3.x), not KE-1. If KE-2 only ever co-occurs
|
||||||
|
with the KE-1 storm, the original KE-1⇒KE-2 chain stands.
|
||||||
|
|
||||||
|
### 6.5 What to do now vs. at capture
|
||||||
|
|
||||||
|
- **Now (no call needed):** ship 6.3.1 (`persist()`) — it's safe and preventive.
|
||||||
|
Consider 6.3.3 (loop detection) as a follow-up.
|
||||||
|
- **At the next glitchy call:** run the §4 capture; answer Q1 (divergence?) and
|
||||||
|
6.4's discriminator. For any _currently_ stuck device, remediation option 1
|
||||||
|
(clean **logout + login**, not just "clear storage" — clearing storage without
|
||||||
|
`mx.logout()` leaves the server device + its OTKs and can re-trigger the
|
||||||
|
divergence).
|
||||||
|
|||||||
@@ -905,6 +905,14 @@ Hook: `src/app/hooks/useUserNotes.ts`
|
|||||||
|
|
||||||
## UX & Composer
|
## UX & Composer
|
||||||
|
|
||||||
|
### Forward to Multiple Rooms (P6-3)
|
||||||
|
|
||||||
|
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
|
||||||
|
|
||||||
|
### Live Bookmark Previews (P6-3)
|
||||||
|
|
||||||
|
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
|
||||||
|
|
||||||
### Message Length Counter
|
### Message Length Counter
|
||||||
|
|
||||||
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
||||||
@@ -1267,6 +1275,15 @@ Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `To
|
|||||||
|
|
||||||
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`).
|
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`).
|
||||||
|
|
||||||
|
### Linux parity + cross-platform extras (P6-1)
|
||||||
|
|
||||||
|
Rounds out the native app beyond Windows (macOS out of scope):
|
||||||
|
|
||||||
|
- **No-sleep during calls on Linux** — a D-Bus `org.freedesktop.ScreenSaver` inhibit (zbus) keeps the display awake mid-call, matching the Windows behavior. `native/power.rs`.
|
||||||
|
- **Launcher unread badge on Linux** — best-effort Unity `LauncherEntry` D-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge.
|
||||||
|
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
||||||
|
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
||||||
|
|
||||||
### Custom Window Chrome (P5-47)
|
### 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`.
|
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`.
|
||||||
|
|||||||
@@ -509,6 +509,66 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Priority 6 — Post-audit batches (2026-07)
|
||||||
|
|
||||||
|
Buildable follow-ups surfaced by the deep-audit wave. Web Push (N107) deliberately deferred. **macOS is out of scope for all of these — Linux is the parity target (Windows already has most native features).**
|
||||||
|
|
||||||
|
### [~] P6-1 · Desktop — cross-platform parity (Linux + Windows; NO macOS) — IMPLEMENTED (2026-07); native CI-compile-pending, runtime-verify on Linux
|
||||||
|
|
||||||
|
From the desktop audit. Round out the native app now that the full Rust stack compiles:
|
||||||
|
|
||||||
|
- **No-sleep during calls on Linux** — `power.rs` is Windows-only (`SetThreadExecutionState`); add a Linux inhibitor (`org.freedesktop.login1.Manager.Inhibit` / ScreenSaver inhibit via zbus/D-Bus) so the display/system doesn't sleep mid-call.
|
||||||
|
- **Taskbar/launcher unread badge on Linux** — `set_badge_count` is Windows-only; add Unity/`com.canonical.Unity.LauncherEntry` (D-Bus) count where supported.
|
||||||
|
- **Launch-on-login** — add `tauri-plugin-autostart` (cross-platform) + a Settings/tray toggle.
|
||||||
|
- **Tray "Do Not Disturb" toggle** — the tray menu is Open/Quit only; add a DND item (reuses the Focus-Assist suppression atom path) so users can silence notifications from the tray.
|
||||||
|
CI-compile-verified (Windows + Linux runners); no local Rust.
|
||||||
|
|
||||||
|
### [~] P6-2 · Element Call fork — retire the remaining DOM hacks — DEAFEN DONE (2026-07), Phase-2 pending publish
|
||||||
|
|
||||||
|
**Shipped (Phase 1):** new `io.lotus.set_deafen` action in the fork (`lotusDeafen.ts`) sets remote `RemoteParticipant.setVolume` per source (mic + screenshare-audio), persisting to late joiners — replaces the brittle `CallControl.setSound`/`applyScreenshareAudioMuted` `<audio>.muted` iframe-DOM hack. cinny now sends it (join-gated) alongside the retained DOM hack (transitional). Folded into unpublished fork `0.20.1-lotus.2`.
|
||||||
|
**Phase 2 (needs user publish):** publish `0.20.1-lotus.2` to npm → bump cinny pin `lotus.1`→`lotus.2` → delete the DOM `.muted` code. See HANDOFF §12.4.
|
||||||
|
**DEFERRED (rationale):** the `useCallSpeakers` DOM-scrape is a dormant _fallback_ behind `io.lotus.call_state` (deleting only removes the safety net); the `.click()`-by-`data-testid` UI toggles (screenshare/grid/spotlight/reactions/settings) are low-value and would balloon fork surface for buttons that just trigger EC's own UI.
|
||||||
|
**Divergence:** deafen doesn\'t silence soundboard/`Unknown`-source audio (setVolume type limit) — confirm UX.
|
||||||
|
|
||||||
|
_Original scope below._
|
||||||
|
|
||||||
|
### [ ] P6-2b · Element Call fork — remaining DOM hacks (deferred pieces)
|
||||||
|
|
||||||
|
Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*` widget actions in the fork (`LotusGuild/element-call`), which break on EC re-renders/version bumps:
|
||||||
|
|
||||||
|
- **Deafen / screenshare-audio-mute** → an `io.lotus` action that mutes/attenuates `RemoteAudioTrack`s at the LiveKit source (replaces `CallControl.ts` `setSound`/`applyScreenshareAudioMuted` DOM `.muted` poking).
|
||||||
|
- **UI-toggle actions** (screenshare/spotlight/reactions/settings) → replace the `.click()`-by-`data-testid` calls.
|
||||||
|
- Retire the `useCallSpeakers` DOM-scrape fallback once `io.lotus.call_state` is verified.
|
||||||
|
Fork commits are local (coordinator); publishing needs the user's npm token.
|
||||||
|
|
||||||
|
### [~] P6-3 · Web UX wins - DONE (2026-07): forward multi-select + live bookmark previews
|
||||||
|
|
||||||
|
**Shipped:** Forward Message multi-select (checkbox rooms + "Send to N", batch `Promise.allSettled` with partial-failure summary; content builder extracted to tested `forwardContent.ts`). Live bookmark previews (`BookmarksPanel` renders the live event via `useRoomEvent` - edits + redactions - snapshot as fallback / left-room). Both `lotus`, gate-green (665 tests).
|
||||||
|
|
||||||
|
_Original scope:_
|
||||||
|
|
||||||
|
### [ ] P6-3-orig · Web UX wins (from the audit ADD list)
|
||||||
|
|
||||||
|
- **Forward to multiple rooms** — multi-select (checkbox + "Send to N") in `ForwardMessageDialog` (currently one room per open, capped at 60).
|
||||||
|
- **Live bookmark previews** — `BookmarksPanel` shows a stale snapshot captured at save time; resolve live from the event when cached (edits/redactions), fall back to the snapshot.
|
||||||
|
- Other small paper-cuts as scoped.
|
||||||
|
|
||||||
|
### [~] P6-4 · Hygiene sweep - TRIMMED (2026-07): security headers only
|
||||||
|
|
||||||
|
**Shipped:** HSTS + Permissions-Policy on the real prod nginx (`matrix/cinny/nginx.conf`, already had X-Frame/CSP/Referrer) + synced the `contrib/nginx` + `contrib/caddy` examples (also fixed the caddy `try_files` SPA fallback). Permissions-Policy allows `self` for the features the app uses (camera/mic/display-capture/geolocation/autoplay/fullscreen), denies unused. **User must `nginx -s reload` on the LXC + verify calls/location still work.**
|
||||||
|
**WON'T-DO (rationale):** patch-package migration - the current `patch-folds.mjs` is already robust (fails hard on drift) and patch-package would be more brittle to folds restructuring; `types/matrix` drift - risky spot-fixes with no concrete bug; build-config streamlining - build is already ~5s. Known follow-up: nginx `add_header` isn't inherited by the cache `location` blocks (pre-existing; the SPA entry `/` still gets all headers, so HSTS is delivered).
|
||||||
|
|
||||||
|
_Original scope:_
|
||||||
|
|
||||||
|
### [ ] P6-4-orig · Hygiene sweep
|
||||||
|
|
||||||
|
- `patch-folds.mjs` (edits `node_modules` directly) → `patch-package`.
|
||||||
|
- `contrib/nginx` + `contrib/caddy`: security headers (HSTS/CSP), `try_files` over rewrites, fix the caddy placeholder path.
|
||||||
|
- `types/matrix/` drift (mirrors SDK types) — spot-fix the highest-risk.
|
||||||
|
- Build-config: streamline `lotusDenoise` sequential `fs` work + redundant `viteStaticCopy` renames.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📚 Implementation Reference
|
## 📚 Implementation Reference
|
||||||
|
|
||||||
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,17 @@
|
|||||||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
cinny.domain.tld {
|
cinny.domain.tld {
|
||||||
root * /path/to/cinny/dist
|
root * /path/to/cinny/dist
|
||||||
try_files {path} / index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). Caddy serves HTTPS automatically, so
|
||||||
|
# HSTS is delivered over TLS.
|
||||||
|
header {
|
||||||
|
X-Frame-Options SAMEORIGIN
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||||
|
Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ server {
|
|||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name cinny.domain.tld;
|
server_name cinny.domain.tld;
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). NOTE: nginx does not inherit
|
||||||
|
# server-level add_header into a location that sets its own add_header.
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
|||||||
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
import { useTauriDnd } from '../hooks/useTauriDnd';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
@@ -21,5 +22,6 @@ export function TauriDesktopFeatures(): null {
|
|||||||
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
|
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
|
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { RoomAvatar } from '../../components/room-avatar';
|
import { RoomAvatar } from '../../components/room-avatar';
|
||||||
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
|
|||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
onJump: (roomId: string, eventId: string) => void;
|
onJump: (roomId: string, eventId: string) => void;
|
||||||
onRemove: (eventId: string) => void;
|
onRemove: (eventId: string) => void;
|
||||||
|
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
|
||||||
|
preview?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||||
@@ -104,18 +109,50 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
|||||||
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
||||||
>
|
>
|
||||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||||
{bookmark.previewText || '(no preview)'}
|
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
|
||||||
|
|
||||||
|
// Renders the same layout as BookmarkItem, but resolves the message body live so
|
||||||
|
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
|
||||||
|
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
|
||||||
|
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
||||||
|
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
||||||
|
room.findEventById(bookmark.eventId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = bookmark.previewText || '(no preview)';
|
||||||
|
let preview: ReactNode = snapshot;
|
||||||
|
|
||||||
|
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
||||||
|
if (liveEvent) {
|
||||||
|
if (liveEvent.isRedacted()) {
|
||||||
|
preview = (
|
||||||
|
<MessageDeletedContent
|
||||||
|
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// body is already the edited text since useRoomEvent applied m.replace.
|
||||||
|
const { body } = liveEvent.getContent();
|
||||||
|
preview = typeof body === 'string' && body ? body : snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
||||||
|
}
|
||||||
|
|
||||||
type BookmarksPanelProps = {
|
type BookmarksPanelProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
const { bookmarks, removeBookmark } = useBookmarks();
|
const { bookmarks, removeBookmark } = useBookmarks();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||||
{filtered.map((bk) => (
|
{filtered.map((bk) => {
|
||||||
|
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||||
|
// otherwise fall back to the stored snapshot for rooms we've left.
|
||||||
|
const room = mx.getRoom(bk.roomId);
|
||||||
|
return room ? (
|
||||||
|
<LiveBookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
room={room}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<BookmarkItem
|
<BookmarkItem
|
||||||
key={bk.eventId}
|
key={bk.eventId}
|
||||||
bookmark={bk}
|
bookmark={bk}
|
||||||
onJump={handleJump}
|
onJump={handleJump}
|
||||||
onRemove={removeBookmark}
|
onRemove={removeBookmark}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
Header,
|
Header,
|
||||||
@@ -29,16 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
import { buildForwardContent } from './forwardContent';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
dm: boolean;
|
dm: boolean;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
onClick: () => void;
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
};
|
};
|
||||||
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
|
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const avatarMxc = room.getMxcAvatarUrl();
|
const avatarMxc = room.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -49,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={onClick}
|
onClick={onToggle}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
|
after={
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
readOnly
|
||||||
|
variant="Primary"
|
||||||
|
disabled={sending}
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200" radii="300">
|
<Avatar size="200" radii="300">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
@@ -93,6 +108,21 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Selection persists across query changes: a room selected then filtered out
|
||||||
|
// of the rendered slice stays selected.
|
||||||
|
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleRoom = useCallback((roomId: string) => {
|
||||||
|
setSelectedRoomIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(roomId)) {
|
||||||
|
next.delete(roomId);
|
||||||
|
} else {
|
||||||
|
next.add(roomId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const allRooms = useMemo(
|
const allRooms = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -109,64 +139,53 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
}, [allRooms, query]);
|
}, [allRooms, query]);
|
||||||
|
|
||||||
/**
|
const sendToSelected = useCallback(async () => {
|
||||||
* Build the content to forward:
|
if (sending || selectedRoomIds.size === 0) return;
|
||||||
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
const fwdContent = buildForwardContent(mx, mEvent);
|
||||||
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
|
||||||
* original pre-edit body
|
|
||||||
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
|
||||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
|
||||||
* message stands alone in the target room
|
|
||||||
*/
|
|
||||||
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
|
||||||
if (mEvent.isDecryptionFailure()) return undefined;
|
|
||||||
|
|
||||||
let content = { ...mEvent.getContent() };
|
|
||||||
|
|
||||||
const eventId = mEvent.getId();
|
|
||||||
const room = mx.getRoom(mEvent.getRoomId());
|
|
||||||
if (eventId && room) {
|
|
||||||
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
|
||||||
const newContent = editedEvent?.getContent()['m.new_content'];
|
|
||||||
if (newContent && typeof newContent === 'object') {
|
|
||||||
content = { ...(newContent as Record<string, unknown>) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delete content['m.relates_to'];
|
|
||||||
if (typeof content.body === 'string') {
|
|
||||||
content.body = trimReplyFromBody(content.body);
|
|
||||||
}
|
|
||||||
if (typeof content.formatted_body === 'string') {
|
|
||||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}, [mx, mEvent]);
|
|
||||||
|
|
||||||
const forward = useCallback(
|
|
||||||
async (room: Room) => {
|
|
||||||
if (sending) return;
|
|
||||||
const fwdContent = buildForwardContent();
|
|
||||||
if (!fwdContent) {
|
if (!fwdContent) {
|
||||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
|
||||||
|
const ids = [...selectedRoomIds];
|
||||||
|
const results = await Promise.allSettled(
|
||||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
|
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||||
setSentTo(room.name);
|
|
||||||
setTimeout(onClose, 1400);
|
|
||||||
} catch {
|
|
||||||
setSending(false);
|
|
||||||
setError(`Failed to forward to ${room.name}. Try again.`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mx, mEvent, onClose, sending, buildForwardContent],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const failedIds: string[] = [];
|
||||||
|
const failedNames: string[] = [];
|
||||||
|
results.forEach((result, i) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
failedIds.push(ids[i]);
|
||||||
|
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = ids.length;
|
||||||
|
const failed = failedNames.length;
|
||||||
|
const succeeded = total - failed;
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
setSentTo(`Forwarded to ${total} ${total === 1 ? 'room' : 'rooms'}`);
|
||||||
|
setTimeout(onClose, 1400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(false);
|
||||||
|
// Prune to only the failures so a retry doesn't re-send to rooms that
|
||||||
|
// already succeeded (duplicate messages).
|
||||||
|
setSelectedRoomIds(new Set(failedIds));
|
||||||
|
if (succeeded === 0) {
|
||||||
|
setError('Failed to forward. Try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||||
|
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -237,9 +256,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
gap="300"
|
gap="300"
|
||||||
style={{ padding: config.space.S400 }}
|
style={{ padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
<Text size="T300">✓ Forwarded to {sentTo}</Text>
|
<Text size="T300">✓ {sentTo}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||||
<Scroll size="300" hideTrack visibility="Hover">
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
@@ -249,7 +269,8 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
room={room}
|
room={room}
|
||||||
dm={directs.has(room.roomId)}
|
dm={directs.has(room.roomId)}
|
||||||
useAuthentication={useAuthentication}
|
useAuthentication={useAuthentication}
|
||||||
onClick={() => forward(room)}
|
selected={selectedRoomIds.has(room.roomId)}
|
||||||
|
onToggle={() => toggleRoom(room.roomId)}
|
||||||
sending={sending}
|
sending={sending}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -281,6 +302,26 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
<Line size="300" />
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
disabled={selectedRoomIds.size === 0 || sending}
|
||||||
|
before={sending && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||||
|
onClick={sendToSelected}
|
||||||
|
>
|
||||||
|
<Text size="B400">
|
||||||
|
Send to {selectedRoomIds.size} {selectedRoomIds.size === 1 ? 'room' : 'rooms'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { buildForwardContent } from './forwardContent';
|
||||||
|
|
||||||
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||||
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||||
|
// MatrixClient / MatrixEvent are mocked minimally. getEditedEvent reads edits off
|
||||||
|
// `timelineSet.relations.getChildEventsForEvent(...).getRelations()`, so the base
|
||||||
|
// client returns no child edits and the edit test injects one.
|
||||||
|
|
||||||
|
const SENDER = '@me:example.org';
|
||||||
|
|
||||||
|
type EventOptions = {
|
||||||
|
content?: Record<string, unknown>;
|
||||||
|
type?: string;
|
||||||
|
id?: string;
|
||||||
|
roomId?: string;
|
||||||
|
decryptionFailure?: boolean;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeEvent = (options: EventOptions = {}): MatrixEvent => {
|
||||||
|
const {
|
||||||
|
content = {},
|
||||||
|
type = 'm.room.message',
|
||||||
|
id = '$evt:example.org',
|
||||||
|
roomId = '!room:example.org',
|
||||||
|
decryptionFailure = false,
|
||||||
|
ts = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getContent: () => content,
|
||||||
|
getType: () => type,
|
||||||
|
getId: () => id,
|
||||||
|
getRoomId: () => roomId,
|
||||||
|
getSender: () => SENDER,
|
||||||
|
getTs: () => ts,
|
||||||
|
isDecryptionFailure: () => decryptionFailure,
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base client: the timeline reports no `m.replace` edits, so the original content
|
||||||
|
// is forwarded unchanged.
|
||||||
|
const makeClient = (): MatrixClient =>
|
||||||
|
({
|
||||||
|
getRoom: () => ({
|
||||||
|
getUnfilteredTimelineSet: () => ({
|
||||||
|
relations: {
|
||||||
|
getChildEventsForEvent: () => null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}) as unknown as MatrixClient;
|
||||||
|
|
||||||
|
test('plain text forwards the body and strips m.relates_to', () => {
|
||||||
|
const mx = makeClient();
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: 'hello world',
|
||||||
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = buildForwardContent(mx, mEvent);
|
||||||
|
|
||||||
|
assert.ok(content);
|
||||||
|
assert.equal(content.body, 'hello world');
|
||||||
|
assert.equal(content.msgtype, 'm.text');
|
||||||
|
assert.equal(content['m.relates_to'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reply quote is stripped from body and formatted_body', () => {
|
||||||
|
const mx = makeClient();
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: '> <@alice:example.org> original\n\nmy reply',
|
||||||
|
format: 'org.matrix.custom.html',
|
||||||
|
formatted_body: '<mx-reply><blockquote>original</blockquote></mx-reply>my reply',
|
||||||
|
'm.relates_to': { 'm.in_reply_to': { event_id: '$root:example.org' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = buildForwardContent(mx, mEvent);
|
||||||
|
|
||||||
|
assert.ok(content);
|
||||||
|
assert.equal(content.body, 'my reply');
|
||||||
|
assert.equal(content.formatted_body, 'my reply');
|
||||||
|
assert.equal(content['m.relates_to'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decryption failure returns undefined', () => {
|
||||||
|
const mx = makeClient();
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: { msgtype: 'm.bad.encrypted' },
|
||||||
|
decryptionFailure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(buildForwardContent(mx, mEvent), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edited message forwards m.new_content', () => {
|
||||||
|
const mEvent = makeEvent({
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: 'original body',
|
||||||
|
'm.relates_to': { rel_type: 'm.thread', event_id: '$root:example.org' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The latest `m.replace` edit carries the new content under `m.new_content`.
|
||||||
|
const editEvent = makeEvent({
|
||||||
|
content: { 'm.new_content': { msgtype: 'm.text', body: 'edited body' } },
|
||||||
|
ts: 100,
|
||||||
|
});
|
||||||
|
const mx = {
|
||||||
|
getRoom: () => ({
|
||||||
|
getUnfilteredTimelineSet: () => ({
|
||||||
|
relations: {
|
||||||
|
getChildEventsForEvent: () => ({
|
||||||
|
getRelations: () => [editEvent],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
|
const content = buildForwardContent(mx, mEvent);
|
||||||
|
|
||||||
|
assert.ok(content);
|
||||||
|
assert.equal(content.body, 'edited body');
|
||||||
|
assert.equal(content.msgtype, 'm.text');
|
||||||
|
assert.equal(content['m.new_content'], undefined);
|
||||||
|
assert.equal(content['m.relates_to'], undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the content to forward:
|
||||||
|
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||||
|
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||||
|
* original pre-edit body
|
||||||
|
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||||
|
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||||
|
* message stands alone in the target room
|
||||||
|
*/
|
||||||
|
export function buildForwardContent(
|
||||||
|
mx: MatrixClient,
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (mEvent.isDecryptionFailure()) return undefined;
|
||||||
|
|
||||||
|
let content = { ...mEvent.getContent() };
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const room = mx.getRoom(mEvent.getRoomId());
|
||||||
|
if (eventId && room) {
|
||||||
|
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||||
|
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||||
|
if (newContent && typeof newContent === 'object') {
|
||||||
|
content = { ...(newContent as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete content['m.relates_to'];
|
||||||
|
if (typeof content.body === 'string') {
|
||||||
|
content.body = trimReplyFromBody(content.body);
|
||||||
|
}
|
||||||
|
if (typeof content.formatted_body === 'string') {
|
||||||
|
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ 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 { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
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';
|
||||||
@@ -129,6 +129,38 @@ function DesktopChromeSetting() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P6-1 — "Launch on login" toggle (desktop only). Renders nothing in the
|
||||||
|
* browser. Reads the current state from the `autostart` plugin on mount and
|
||||||
|
* enables/disables it via the plugin commands when flipped. Not backed by an
|
||||||
|
* atom — the OS registration is the source of truth, mirrored into local state.
|
||||||
|
*/
|
||||||
|
function AutostartSetting() {
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tauriInvoke()?.('plugin:autostart|is_enabled')
|
||||||
|
.then((value) => setEnabled(value === true))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (value: boolean) => {
|
||||||
|
invokeTauri(value ? 'plugin:autostart|enable' : 'plugin:autostart|disable');
|
||||||
|
setEnabled(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isTauriEnv()) return null;
|
||||||
|
return (
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Launch on login"
|
||||||
|
description="Start Lotus Chat automatically when you sign in to your computer."
|
||||||
|
after={<Switch variant="Primary" value={enabled} onChange={handleChange} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
themeNames: Record<string, string>;
|
themeNames: Record<string, string>;
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
@@ -443,6 +475,7 @@ function Appearance() {
|
|||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
<DesktopChromeSetting />
|
<DesktopChromeSetting />
|
||||||
|
<AutostartSetting />
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { manualDndAtom } from '../state/manualDnd';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
|
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
||||||
|
type DndChangedDetail = {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P6-1 — Tray "Do Not Disturb" → notification suppression (desktop). Subscribes
|
||||||
|
* to the native `lotus-dnd-changed` event (emitted when the user toggles the
|
||||||
|
* tray "Do Not Disturb" item, `{ active }`) and mirrors it into `manualDndAtom`,
|
||||||
|
* which the notification gate reads to suppress notifications while DND is on.
|
||||||
|
* Inert in the browser, since `useTauriEvent` only listens under Tauri.
|
||||||
|
*/
|
||||||
|
export function useTauriDnd(): void {
|
||||||
|
const setDnd = useSetAtom(manualDndAtom);
|
||||||
|
|
||||||
|
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
|
import { manualDndAtom } from '../../state/manualDnd';
|
||||||
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';
|
||||||
@@ -128,6 +129,7 @@ function InviteNotifications() {
|
|||||||
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 focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
|
const manualDnd = useAtomValue(manualDndAtom);
|
||||||
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');
|
||||||
@@ -187,7 +189,9 @@ function InviteNotifications() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||||
const quietActive =
|
const quietActive =
|
||||||
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
focusAssistActive ||
|
||||||
|
manualDnd ||
|
||||||
|
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (!quietActive) {
|
if (!quietActive) {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
notify(invites.length - perviousInviteLen);
|
notify(invites.length - perviousInviteLen);
|
||||||
@@ -210,6 +214,7 @@ function InviteNotifications() {
|
|||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
focusAssistActive,
|
focusAssistActive,
|
||||||
|
manualDnd,
|
||||||
inviteSoundId,
|
inviteSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -236,6 +241,7 @@ function MessageNotifications() {
|
|||||||
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 focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
|
const manualDnd = useAtomValue(manualDndAtom);
|
||||||
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');
|
||||||
@@ -374,7 +380,9 @@ function MessageNotifications() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quietActive =
|
const quietActive =
|
||||||
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
focusAssistActive ||
|
||||||
|
manualDnd ||
|
||||||
|
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
@@ -409,6 +417,7 @@ function MessageNotifications() {
|
|||||||
quietHoursStart,
|
quietHoursStart,
|
||||||
quietHoursEnd,
|
quietHoursEnd,
|
||||||
focusAssistActive,
|
focusAssistActive,
|
||||||
|
manualDnd,
|
||||||
messageSoundId,
|
messageSoundId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private _pipMode = false;
|
private _pipMode = false;
|
||||||
|
|
||||||
|
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
|
||||||
|
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
|
||||||
|
// before the fork's widget handler mounts (pre-join sends pend to a 10s
|
||||||
|
// timeout — HANDOFF_ELEMENT_CALL_FORK.md §12.1 F1).
|
||||||
|
private joined = false;
|
||||||
|
|
||||||
private get document(): Document | undefined {
|
private get document(): Document | undefined {
|
||||||
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||||||
}
|
}
|
||||||
@@ -141,6 +147,12 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.spotlight,
|
this.spotlight,
|
||||||
);
|
);
|
||||||
await this.applyState();
|
await this.applyState();
|
||||||
|
// P6-2: CallEmbed calls forceState() only from onCallJoined(), so this is
|
||||||
|
// the join transition. Flip the gate open, then push the current deafen
|
||||||
|
// state to the fork's freshly-mounted handler. (setSound() above ran while
|
||||||
|
// this.joined was still false, so it was gated — this is the first send.)
|
||||||
|
this.joined = true;
|
||||||
|
this.sendDeafenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public startObserving() {
|
public startObserving() {
|
||||||
@@ -209,6 +221,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
|
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.sendDeafenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyScreenshareAudioMuted(): void {
|
private applyScreenshareAudioMuted(): void {
|
||||||
@@ -221,6 +234,20 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
el.muted = this.screenshareAudioMuted;
|
el.muted = this.screenshareAudioMuted;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.sendDeafenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// P6-2: send deafen state to the fork (io.lotus.set_deafen). The DOM .muted
|
||||||
|
// code above is a transitional fallback — remove once the fork ships & the
|
||||||
|
// pin is bumped.
|
||||||
|
private sendDeafenState(): void {
|
||||||
|
if (!this.joined) return;
|
||||||
|
this.call.transport
|
||||||
|
.send('io.lotus.set_deafen', {
|
||||||
|
deafened: !this.sound,
|
||||||
|
screenshareAudioMuted: this.screenshareAudioMuted,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
|
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
|
||||||
@@ -286,10 +313,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
public toggleSound() {
|
public toggleSound() {
|
||||||
const sound = !this.sound;
|
const sound = !this.sound;
|
||||||
|
|
||||||
this.setSound(sound);
|
// P6-2: commit state before setSound()/applyScreenshareAudioMuted() so
|
||||||
// After un-deafening, re-apply screenshare audio mute if active
|
// sendDeafenState() (which reads this.sound) reports the new value.
|
||||||
if (sound) this.applyScreenshareAudioMuted();
|
|
||||||
|
|
||||||
const state = new CallControlState(
|
const state = new CallControlState(
|
||||||
this.microphone,
|
this.microphone,
|
||||||
this.video,
|
this.video,
|
||||||
@@ -299,6 +324,11 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.screenshareAudioMuted,
|
this.screenshareAudioMuted,
|
||||||
);
|
);
|
||||||
this.state = state;
|
this.state = state;
|
||||||
|
|
||||||
|
this.setSound(sound);
|
||||||
|
// After un-deafening, re-apply screenshare audio mute if active
|
||||||
|
if (sound) this.applyScreenshareAudioMuted();
|
||||||
|
|
||||||
this.emitStateUpdate();
|
this.emitStateUpdate();
|
||||||
|
|
||||||
if (!this.sound && this.microphone) {
|
if (!this.sound && this.microphone) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P6-1 — Tray "Do Not Disturb" ↔ notification suppression (manual toggle).
|
||||||
|
*
|
||||||
|
* Standalone, non-persisted boolean atom reflecting whether the user has flipped
|
||||||
|
* the native tray "Do Not Disturb" item. It is driven at runtime by
|
||||||
|
* `useTauriDnd` from the native `lotus-dnd-changed` event and read by the
|
||||||
|
* notification gate to suppress notifications while DND is on. Because it mirrors
|
||||||
|
* a transient session toggle — not a persisted user preference — it is a plain
|
||||||
|
* in-memory atom that defaults to `false` and is intentionally NOT written to
|
||||||
|
* `localStorage`.
|
||||||
|
*/
|
||||||
|
export const manualDndAtom = atom(false);
|
||||||
Reference in New Issue
Block a user