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`).
|
||||
|
||||
| # | Feature | Enable via | EC module |
|
||||
| :-- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
||||
| :--- | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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
|
||||
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.
|
||||
|
||||
| ID | Item | File / area | Test |
|
||||
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- |
|
||||
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| #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 |
|
||||
| #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-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 |
|
||||
| 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.
|
||||
|
||||
@@ -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
|
||||
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
||||
**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).**
|
||||
`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
|
||||
`developerTools` flag), right after the "Access Token" card. It pulls `mx`
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
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`).
|
||||
|
||||
### 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)
|
||||
|
||||
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
|
||||
|
||||
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
||||
|
||||
@@ -3,4 +3,15 @@ cinny.domain.tld {
|
||||
root * /path/to/cinny/dist
|
||||
try_files {path} /index.html
|
||||
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;
|
||||
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 / {
|
||||
root /opt/cinny/dist/;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||
import { useTauriDnd } from '../hooks/useTauriDnd';
|
||||
|
||||
/**
|
||||
* 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
|
||||
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
||||
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 {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
import classNames from 'classnames';
|
||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
|
||||
bookmark: Bookmark;
|
||||
onJump: (roomId: string, 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 useAuthentication = useMediaAuthentication();
|
||||
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 }}
|
||||
>
|
||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||
{bookmark.previewText || '(no preview)'}
|
||||
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||
</Text>
|
||||
</Button>
|
||||
</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 = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { bookmarks, removeBookmark } = useBookmarks();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
</Box>
|
||||
) : (
|
||||
<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
|
||||
key={bk.eventId}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
|
||||
@@ -3,6 +3,8 @@ import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
@@ -29,16 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
dm: boolean;
|
||||
useAuthentication: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
sending: boolean;
|
||||
};
|
||||
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
|
||||
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -49,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={onClick}
|
||||
onClick={onToggle}
|
||||
disabled={sending}
|
||||
after={
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
readOnly
|
||||
variant="Primary"
|
||||
disabled={sending}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
@@ -93,6 +108,21 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentTo, setSentTo] = 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(
|
||||
() =>
|
||||
@@ -109,64 +139,53 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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();
|
||||
const sendToSelected = useCallback(async () => {
|
||||
if (sending || selectedRoomIds.size === 0) return;
|
||||
const fwdContent = buildForwardContent(mx, mEvent);
|
||||
if (!fwdContent) {
|
||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
const ids = [...selectedRoomIds];
|
||||
const results = await Promise.allSettled(
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendEvent(room.roomId, 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],
|
||||
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||
);
|
||||
|
||||
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 (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -237,9 +256,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
gap="300"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Text size="T300">✓ Forwarded to {sentTo}</Text>
|
||||
<Text size="T300">✓ {sentTo}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
@@ -249,7 +269,8 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
room={room}
|
||||
dm={directs.has(room.roomId)}
|
||||
useAuthentication={useAuthentication}
|
||||
onClick={() => forward(room)}
|
||||
selected={selectedRoomIds.has(room.roomId)}
|
||||
onToggle={() => toggleRoom(room.roomId)}
|
||||
sending={sending}
|
||||
/>
|
||||
))}
|
||||
@@ -281,6 +302,26 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
</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>
|
||||
</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 { SequenceCardStyle } from '../styles.css';
|
||||
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 { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
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 = {
|
||||
themeNames: Record<string, string>;
|
||||
themes: Theme[];
|
||||
@@ -443,6 +475,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
|
||||
<DesktopChromeSetting />
|
||||
<AutostartSetting />
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<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,
|
||||
} from 'matrix-js-sdk';
|
||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||
import { manualDndAtom } from '../../state/manualDnd';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/lotus.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||
@@ -128,6 +129,7 @@ function InviteNotifications() {
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const manualDnd = useAtomValue(manualDndAtom);
|
||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
|
||||
@@ -187,7 +189,9 @@ function InviteNotifications() {
|
||||
useEffect(() => {
|
||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||
const quietActive =
|
||||
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||
focusAssistActive ||
|
||||
manualDnd ||
|
||||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||
if (!quietActive) {
|
||||
if (showNotifications && notificationPermission('granted')) {
|
||||
notify(invites.length - perviousInviteLen);
|
||||
@@ -210,6 +214,7 @@ function InviteNotifications() {
|
||||
quietHoursStart,
|
||||
quietHoursEnd,
|
||||
focusAssistActive,
|
||||
manualDnd,
|
||||
inviteSoundId,
|
||||
]);
|
||||
|
||||
@@ -236,6 +241,7 @@ function MessageNotifications() {
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const manualDnd = useAtomValue(manualDndAtom);
|
||||
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
|
||||
@@ -374,7 +380,9 @@ function MessageNotifications() {
|
||||
}
|
||||
|
||||
const quietActive =
|
||||
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||
focusAssistActive ||
|
||||
manualDnd ||
|
||||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
|
||||
if (quietActive) return;
|
||||
|
||||
if (showNotifications && notificationPermission('granted')) {
|
||||
@@ -409,6 +417,7 @@ function MessageNotifications() {
|
||||
quietHoursStart,
|
||||
quietHoursEnd,
|
||||
focusAssistActive,
|
||||
manualDnd,
|
||||
messageSoundId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -31,6 +31,12 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
|
||||
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 {
|
||||
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
||||
}
|
||||
@@ -141,6 +147,12 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.spotlight,
|
||||
);
|
||||
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() {
|
||||
@@ -209,6 +221,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
el.muted = !sound || (isScreenshareAudio && this.screenshareAudioMuted);
|
||||
});
|
||||
}
|
||||
this.sendDeafenState();
|
||||
}
|
||||
|
||||
private applyScreenshareAudioMuted(): void {
|
||||
@@ -221,6 +234,20 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
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>) {
|
||||
@@ -286,10 +313,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
public toggleSound() {
|
||||
const sound = !this.sound;
|
||||
|
||||
this.setSound(sound);
|
||||
// After un-deafening, re-apply screenshare audio mute if active
|
||||
if (sound) this.applyScreenshareAudioMuted();
|
||||
|
||||
// P6-2: commit state before setSound()/applyScreenshareAudioMuted() so
|
||||
// sendDeafenState() (which reads this.sound) reports the new value.
|
||||
const state = new CallControlState(
|
||||
this.microphone,
|
||||
this.video,
|
||||
@@ -299,6 +324,11 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.screenshareAudioMuted,
|
||||
);
|
||||
this.state = state;
|
||||
|
||||
this.setSound(sound);
|
||||
// After un-deafening, re-apply screenshare audio mute if active
|
||||
if (sound) this.applyScreenshareAudioMuted();
|
||||
|
||||
this.emitStateUpdate();
|
||||
|
||||
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