Compare commits

...

18 Commits

Author SHA1 Message Date
jared 17bd50cc4e feat(crypto): QR-code device verification (alongside emoji SAS)
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 7s
B2 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- Enable QR verification methods (show/scan/reciprocate) in initMatrix.
- Extend DeviceVerification: the Ready step offers your own QR (byte-mode encode
  via qrcode), a camera 'Scan their QR code' flow, and an emoji fallback; the
  Started step routes reciprocate → a confirm step (useVerifierShowReciprocateQr)
  or SAS as before.
- New QrScanner component: getUserMedia + jsQR, handing the raw binaryData bytes
  to request.scanQRCode (BarcodeDetector is string-only, so can't be used).
- Adds qrcode + jsqr (small, pure-JS, client-only); build-verified under rolldown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:30:23 -04:00
jared 82e52e1bc7 feat(rooms): Disappearing Messages (MSC1763 m.room.retention)
B1 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- StateEvent.RoomRetention + a shared utils/retention.ts (presets, isExpired,
  getRoomRetentionMs) with tests.
- RoomRetention settings control (PL-gated preset buttons Off/1d/1w/1m) in Room
  Settings → General → Message Retention.
- Timeline hides events past the room's max_lifetime (gated behind Show Hidden
  Events, like redactions) — messages visually disappear, losslessly.
- Opt-in setting enforceRetentionLocally (default OFF) + a headless
  RetentionSweeper that permanently redacts the user's OWN expired messages
  (own-only, loaded-timeline scope, dedupe + retry). Nothing auto-deletes unless
  the user opts in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:23:14 -04:00
jared d46b91b1b8 feat(rooms): Mark as Unread (MSC2867) + Low Priority rooms
Two Matrix protocol gaps (Phase A), gate-green (683 tests):
- Mark as Unread: m.marked_unread room account data (+ com.famedly.marked_unread
  fallback), a new markedUnreadAtom binder that seeds from account data and
  clears on our own read receipt (MSC2867). RoomNavItem gains Mark as Unread /
  Read menu items and lights the row dot for a marked room. Tested.
- Low Priority: m.lowpriority room tag mirroring favourites — a context-menu
  toggle (mutually exclusive with Favorite) and a collapsed Low Priority
  category sorted to the bottom of the Home room list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:04:47 -04:00
jared 5b94a44eb3 docs: add Matrix Protocol Gaps backlog (audited spec/MSC gaps)
Six confirmed client-buildable gaps + server-gated items from a spec/MSC audit:
Mark as Unread (MSC2867), Low Priority rooms (m.lowpriority), Disappearing
Messages (MSC1763), QR Device Verification, Room Widgets (MSC1236), Sliding Sync
(MSC3575/4186). Phased build order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:53:33 -04:00
jared ca9abb5363 docs: condense LOTUS_TODO to open work only (1063→~230 lines)
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Successful in 7s
Removed resolved audit-wave finding tables and shipped-feature narratives (now
in LOTUS_FEATURES.md + git history); kept every open/blocked/deferred item, the
E2EE + Web Push backlog, and the reference tables (server caps, key files, EC
fork ops, CI/CD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:23:03 -04:00
jared 21276a47fc fix(audit): low-tail cleanup — session/logout/unread/presence/forward
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 14s
Clears the clean 🟡 remainders from the feature audit (gate-green, 677 tests):
- F3: getFallbackSession prefers the session-blob/legacy source with the later
  expiresAt (a downgrade→upgrade could boot on a stale blob's dead token).
- F6: server-forced logout (SessionLoggedOut) now mirrors logoutClient —
  pushSessionToSW() + best-effort revokeOidcTokens for OIDC sessions (the search
  plaintext wipe was already added).
- N5: deleteUnreadInfo parent fallback `?? roomId` → `?? []` (latently spread the
  roomId string into chars).
- P10: useUserPresence re-seeds when the User object appears after first render.
- forward: strip m.mentions so forwarding doesn't re-ping the original mentions.

Left open: F5 (OIDC expiry not reachable in persistTokens), N6/H10/D7 (minor /
runtime-verify). See LOTUS_TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:57:09 -04:00
jared b7788cc79c docs: mark D6 Windows rich-toast AUMID fixed + add runtime test
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:32:31 -04:00
jared 13d08c3fd7 docs: mark H5 invite-QR fixed (local generation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:42 -04:00
jared a899d7d3a8 fix(privacy): generate invite QR locally instead of api.qrserver.com (H5)
The Share Room QR was fetched from the third-party api.qrserver.com, leaking
which rooms a user shares (and failing offline / under strict CSP). Now rendered
locally via qrcode.react (QRCodeSVG) — no network request, works offline. Added a
white quiet-zone container so the code scans on any theme; dropped the qrError
fallback (local generation can't fail the same way). Removed api.qrserver.com
from the prod CSP img-src (matrix repo). Build verified (rolldown interop OK).
Verification steps added to LOTUS_TESTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:19:22 -04:00
jared dcd8201e16 fix(wave-3): audit fixes — ACL guards, presence, moderation, theming perf
Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green:
- 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click
  federation brick), warn on self-ban (case-insensitive glob match of
  mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and
  gate Save behind a confirm dialog.
- 🔴 [P1] room context menu no longer acts on the wrong room after a live
  reorder (key by roomId, not list index). 🔴 [P2] status writes no longer
  force presence to online over Invisible/DND (shared presenceStateFromSetting).
- 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires
  (always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz
  profile field so it's visible to others; [H6] RoomInsights single-pass
  min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels.
- 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview
  refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant
  always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key.
- NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons.

DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak);
local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean,
build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:40:07 -04:00
jared 41149db685 fix(ui): NATIVE-CINNY LAW — replace emoji with folds icons in settings
- Notification profile presets (P5-27) used literal emoji (🎮/💼/🌙) instead of
  folds Icons → Gaming=Ball, Work=Monitor, Sleep=BellMute.
- Permissions "Powers" list used / text emoji for has/no-power → folds
  Icons.Check / Icons.Cross (colored via the row).

Reviewed the rest of the UI: seasonal-theme picker emoji kept (folds has no
holiday-icon equivalents; a distinctly-Lotus visual feature), soundboard clip
emoji kept (user-chosen clip identity), URL-preview brand glyphs + upstream
device-verification emoji + keyboard key-symbols left as-is.

(Also records the F2 URL-preview decision: keep default-on.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:21:00 -04:00
jared 668bdaad7d fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO):
- F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED
  logout too (token expiry / remote sign-out) — only manual logout did before.
  F4: the delete no longer reports success while onblocked (waits, 3s cap).
- M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now
  serialized at MODULE scope (single queue + latestRef per client, echo-driven),
  fixing the cross-instance lost-update clobber (useBookmarks mounts per message
  row, so a per-instance queue was insufficient — caught in review).
- M6: room-history export gets a 200-page cap + Cancel + unmount-abort +
  correct date-range early-break (raw paginated ts). M4: image compression
  skips PNG (was flattening transparency to black), bakes EXIF orientation via
  createImageBitmap, .jpg-renames, and falls back to the original on decode
  failure instead of dropping the file. M5: MediaGallery lightbox opens the
  right item (shared thumb guard). M8: audio speed survives async decrypt.
- Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like
  the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5
  updater has a terminal state.

Reviewed; M7 reverted (past-time clamp is an intentional, tested contract).
tsc/eslint/prettier clean, build OK, 678 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:56:27 -04:00
jared ee6bdd8241 fix(call): Wave-1 audit fixes (calls host side)
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers
  (resendForkState — deafen+quality only) instead of clobbering live mic/video/
  deafen back to the join-time snapshot.
- C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL
  published track instead of getUserMedia on the browser DEFAULT mic (which could
  measure silence while the user spoke on another device → auto-mute an active
  speaker). Fails safe (never mutes) when call_state is null OR empty.
- C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms
  debounce) with an early-return so unchanged state doesn't re-render.
- C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent);
  C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on
  stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards.

Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier
clean, build OK, 677 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:20:07 -04:00
jared 0bbdd7ce94 fix(notifications/threads): Wave-1 audit fixes (🔴 + web 🟠)
- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance
  of the read-marker-corruption regression — opening a thread whose root is old
  re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression
  tests.
- N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double-
  counting every ancestor-space aggregate in roomToUnread).
- N2 (🔴): notifications/sounds dedupe on the event id, not the unread count —
  fixes "read a DM, next message never notifies again".
- T4 (🟠): the thread notification path no longer re-gates on the room count, so
  an explicit per-thread "All replies" override in a Mentions-only room fires.
- N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no
  longer light the nav row / pollute unread filters).
- N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a
  threaded receipt can't wipe a room's valid main-timeline badge.
- T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on
  a stale mid/old event).

Gates: tsc/eslint/prettier clean, build OK, 678 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:10:32 -04:00
jared 7c85ad177f docs(audit): Wave-1 bug-hunt findings (notifications/threads/calls/EC fork)
4 parallel deep-audit agents over the Tier-1 high-risk areas. Findings only (no
source changes). Top 🔴: markThreadAsRead corrupts the main read marker via a
thread-root receipt (a SECOND instance of the P6 read-receipt regression, likely
a live cause of "unread won't clear"); favicon/title count double-counts space
aggregates; deliverNotification dedupe cache never cleared on read → missed
notifications/sounds. Plus 🟠 (thread "All" override defeated, phantom
muted-thread dot, receipt-DELETE badge race, thread jump-to-latest, call
forceState-on-reconnect clobber, AFK wrong-mic auto-mute, stale control observer)
and a long 🟡 tail. Recorded in LOTUS_TODO for prioritized fix passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:25:57 -04:00
jared bbf0800c19 fix(ci): disable lines-between-class-members + prefer-arrow-callback for test files
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 12s
CI check:eslint failed with 28 errors in two test files: callSounds.test.ts
(lines-between-class-members on mock classes) and lotusDenoiseUtils.test.ts
(prefer-arrow-callback on `function AudioWorkletNode(){}` constructor mocks —
arrows aren't constructable, so auto-fixing would break the test). Both are
stylistic false-positives for test code; relax them in the existing test-file
override next to max-classes-per-file. `npm run check:eslint` now exits 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:27:01 -04:00
jared abd0753148 fix(notifications): safe thread receipts on mark-read (fixes read-receipt regression)
CI / Build & Quality Checks (push) Failing after 35m56s
CI / Trigger Desktop Build (push) Has been skipped
The prior thread-receipt change (8192da5a) broke read receipts globally. Exact
cause: markAsRead used `thread.lastReply() ?? thread.rootEvent`. When a thread's
replies weren't loaded (lastReply() null — common on room open), it sent a
receipt for the thread ROOT. Since roots are "in the main timeline",
threadIdForReceipt() makes that a MAIN receipt at an old event; when the root
isn't in the loaded timeline the SDK's backward-guard falls back to timestamp
and applies it, moving the main read receipt onto an event we don't have, so
getEventReadUpTo() returns null and roomHaveUnread() reports the room unread —
re-broken on every mark-read, amplified by the bulk mark-all-orphan-rooms-read
callers.

Fix: main unthreaded receipt unchanged; the thread loop now sends a threaded
receipt ONLY for a genuine loaded thread reply (thread.lastReply()), never the
root — if replies aren't loaded, skip. New notifications.test.ts locks the
regression (null lastReply → no root receipt) + the main/threaded/no-op cases.

Gates: tsc/eslint/prettier clean, build OK, 672 tests (7 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:09:28 -04:00
jared 8192da5a12 fix(notifications): clear thread receipts on mark-read; cap avatar-decoration refetch
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 29s
Two federated-room bugs surfaced by the desktop build:

1. markAsRead only sent one unthreaded receipt at the main-timeline tail. With
   threadSupport enabled, thread replies leave the main timeline, so a reply
   newer than that tail was never covered — its per-thread notification count
   (which the room dot sums) lingered, so the unread dot never cleared even
   after reading. It also early-returned when the main timeline was already
   read. Now also send a threaded receipt at each unread thread's latest reply.

2. useAvatarDecoration never cached non-404 failures, so every avatar mount
   re-requested io.lotus.avatar_decoration for federated users whose homeserver
   403s/502s the field — a refetch storm that spammed the console and hammered
   our homeserver's federation. Now cache definitive rejections (400/403/404)
   and give up after ~2 transient (429/5xx) attempts per session.

Gates: tsc/eslint/prettier clean, build OK, 665 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:31:10 -04:00
74 changed files with 2807 additions and 1433 deletions
+16
View File
@@ -675,6 +675,22 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
## Outstanding verification backlog ## Outstanding verification backlog
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame. **Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._ _Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
+171 -920
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -144,10 +144,16 @@ export default [
}, },
}, },
{ {
// Test files commonly define several small mock/fake classes. // Test files commonly define several small mock/fake classes and named
// function expressions used as constructor mocks (e.g.
// `setGlobal('AudioWorkletNode', function AudioWorkletNode(){})`), which must
// NOT be rewritten to arrows (arrows aren't constructable). Relax the
// stylistic class/callback rules here.
files: ['**/*.test.ts', '**/*.test.tsx'], files: ['**/*.test.ts', '**/*.test.tsx'],
rules: { rules: {
'max-classes-per-file': 'off', 'max-classes-per-file': 'off',
'lines-between-class-members': 'off',
'prefer-arrow-callback': 'off',
}, },
}, },
]; ];
+224 -1
View File
@@ -49,6 +49,7 @@
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
@@ -57,6 +58,8 @@
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.6",
"react-aria": "3.48.0", "react-aria": "3.48.0",
"react-blurhash": "0.3.0", "react-blurhash": "0.3.0",
@@ -86,6 +89,7 @@
"@types/katex": "0.16.8", "@types/katex": "0.16.8",
"@types/node": "25.9.1", "@types/node": "25.9.1",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9", "@types/react-google-recaptcha": "2.1.9",
@@ -3989,6 +3993,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.15", "version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -5170,6 +5184,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelize": { "node_modules/camelize": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
@@ -5964,6 +5987,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6107,6 +6139,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/direction": { "node_modules/direction": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
@@ -9056,6 +9094,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -10499,6 +10543,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10536,7 +10589,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -10651,6 +10703,15 @@
"pathe": "^2.0.1" "pathe": "^2.0.1"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -10758,6 +10819,150 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/raf-schd": { "node_modules/raf-schd": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -11178,6 +11383,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resize-observer-polyfill": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -11508,6 +11719,12 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -12973,6 +13190,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+4
View File
@@ -74,6 +74,7 @@
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.11", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
@@ -82,6 +83,8 @@
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "5.7.284", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
"react": "19.2.6", "react": "19.2.6",
"react-aria": "3.48.0", "react-aria": "3.48.0",
"react-blurhash": "0.3.0", "react-blurhash": "0.3.0",
@@ -111,6 +114,7 @@
"@types/katex": "0.16.8", "@types/katex": "0.16.8",
"@types/node": "25.9.1", "@types/node": "25.9.1",
"@types/prismjs": "1.26.6", "@types/prismjs": "1.26.6",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.15", "@types/react": "19.2.15",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9", "@types/react-google-recaptcha": "2.1.9",
+17 -2
View File
@@ -56,6 +56,7 @@ import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getChatBg } from '../features/lotus/chatBackground'; import { getChatBg } from '../features/lotus/chatBackground';
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls'; import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme'; import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room'; import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
@@ -413,6 +414,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm); const startCall = useCallStart(dm);
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await.
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => { async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events. // only process rtc notification reference events.
@@ -427,6 +438,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
await event.getDecryptionPromise(); await event.getDecryptionPromise();
} }
// C-L6: bail if we unmounted while awaiting decryption above.
if (!mountedRef.current) return;
// Caller-side: a participant declined a call we're hosting in this room. // Caller-side: a participant declined a call we're hosting in this room.
// Without this the caller's UI keeps "ringing" until the notification // Without this the caller's UI keeps "ringing" until the notification
// lifetime expires, with no indication the callee said no. // lifetime expires, with no indication the callee said no.
@@ -706,9 +720,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const [chatBackground] = useSetting(settingsAtom, 'chatBackground'); const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const reduced = useReducedMotion();
const wallpaperStyle = React.useMemo( const wallpaperStyle = React.useMemo(
() => getChatBg(chatBackground, isDark), () => getChatBg(chatBackground, isDark, reduced),
[chatBackground, isDark], [chatBackground, isDark, reduced],
); );
const [pipIsFullscreen, setPipIsFullscreen] = useState(false); const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
+141 -33
View File
@@ -1,12 +1,14 @@
import { import {
ShowQrCodeCallbacks,
ShowSasCallbacks, ShowSasCallbacks,
VerificationPhase, VerificationPhase,
VerificationRequest, VerificationRequest,
Verifier, Verifier,
} from 'matrix-js-sdk/lib/crypto-api'; } from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types'; import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import { import {
Box, Box,
Button, Button,
@@ -27,11 +29,13 @@ import {
useVerificationRequestPhase, useVerificationRequestPhase,
useVerificationRequestReceived, useVerificationRequestReceived,
useVerifierCancel, useVerifierCancel,
useVerifierShowReciprocateQr,
useVerifierShowSas, useVerifierShowSas,
} from '../hooks/useVerificationRequest'; } from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { QrScanner } from './QrScanner';
const DialogHeaderStyles: CSSProperties = { const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`, padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
); );
} }
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
</Box>
);
}
type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
); );
} }
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Byte-mode so the raw verification bytes round-trip (a string value would
// mangle high bytes via UTF-8).
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}).catch(() => undefined);
}, [data]);
return (
<Box justifyContent="Center">
<canvas ref={canvasRef} style={{ borderRadius: config.radii.R300 }} />
</Box>
);
}
type VerificationReadyProps = {
request: VerificationRequest;
onStartSas: () => void;
onScanned: (bytes: Uint8ClampedArray) => void;
};
function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) {
const [myQr, setMyQr] = useState<Uint8ClampedArray>();
const [scanning, setScanning] = useState(false);
const canShowMine = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
const canScanTheirs = request.otherPartySupportsMethod(VerificationMethod.ShowQrCode);
useEffect(() => {
if (!canShowMine) return;
request
.generateQRCode()
.then((bytes) => {
if (bytes) setMyQr(bytes);
})
.catch(() => undefined);
}, [request, canShowMine]);
if (scanning) {
return <QrScanner onScan={onScanned} onCancel={() => setScanning(false)} />;
}
return (
<Box direction="Column" gap="400">
{myQr && (
<Box direction="Column" gap="200">
<Text size="T300">Scan this code with your other device to verify.</Text>
<QrCodeImage data={myQr} />
</Box>
)}
<Box direction="Column" gap="200">
{canScanTheirs && (
<Button variant="Primary" fill="Solid" onClick={() => setScanning(true)}>
<Text size="B400">Scan their QR code</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={onStartSas}>
<Text size="B400">Verify with emoji instead</Text>
</Button>
</Box>
</Box>
);
}
type ReciprocateVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) {
const [qrCallbacks, setQrCallbacks] = useState<ShowQrCodeCallbacks>();
const [confirmState, confirm] = useAsyncCallback(
useCallback(async () => qrCallbacks?.confirm(), [qrCallbacks]),
);
useVerifierShowReciprocateQr(verifier, setQrCallbacks);
useVerifierCancel(verifier, onCancel);
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
// The showing side gets ShowReciprocateQr callbacks after the other device
// scans; the scanning side never does (it already called verify()) and just
// waits for completion.
if (!qrCallbacks) {
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Verifying…" />
</Box>
);
}
return (
<Box direction="Column" gap="400">
<Text>The other device scanned this code. Confirm it now shows as verified.</Text>
<Box direction="Column" gap="200">
<Button variant="Primary" fill="Soft" onClick={confirm} disabled={confirming}>
<Text size="B400">Confirm</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => qrCallbacks.cancel()}
disabled={confirming}
>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
);
}
type DeviceVerificationProps = { type DeviceVerificationProps = {
request: VerificationRequest; request: VerificationRequest;
onExit: () => void; onExit: () => void;
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
const handleStart = useCallback(async () => { const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas); await request.startVerification(VerificationMethod.Sas);
}, [request]); }, [request]);
const handleScanned = useCallback(
async (bytes: Uint8ClampedArray) => {
try {
const verifier = await request.scanQRCode(bytes);
await verifier.verify();
} catch {
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
}
},
[request],
);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
) : ( ) : (
<VerificationAccept onAccept={handleAccept} /> <VerificationAccept onAccept={handleAccept} />
))} ))}
{phase === VerificationPhase.Ready && {phase === VerificationPhase.Ready && (
(request.initiatedByMe ? ( <VerificationReady
<AutoVerificationStart onStart={handleStart} /> request={request}
) : ( onStartSas={handleStart}
<VerificationWaitStart /> onScanned={handleScanned}
))} />
)}
{phase === VerificationPhase.Started && {phase === VerificationPhase.Started &&
(request.verifier ? ( (request.verifier ? (
request.chosenMethod === VerificationMethod.Reciprocate ? (
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<SasVerification verifier={request.verifier} onCancel={handleCancel} /> <SasVerification verifier={request.verifier} onCancel={handleCancel} />
)
) : ( ) : (
<VerificationUnexpected <VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing." message="Unexpected Error! Verification is started but verifier is missing."
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Button, color, config, Text } from 'folds';
import jsQR from 'jsqr';
type QrScannerProps = {
onScan: (bytes: Uint8ClampedArray) => void;
onCancel: () => void;
};
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [error, setError] = useState<string>();
const doneRef = useRef(false);
useEffect(() => {
let stream: MediaStream | undefined;
let raf = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const tick = () => {
const video = videoRef.current;
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = jsQR(image.data, image.width, image.height);
if (result && result.binaryData.length > 0) {
doneRef.current = true;
onScan(new Uint8ClampedArray(result.binaryData));
return;
}
}
raf = requestAnimationFrame(tick);
};
(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
raf = requestAnimationFrame(tick);
} catch {
setError(
'Could not access the camera. Grant camera permission, or verify with emojis instead.',
);
}
})();
return () => {
doneRef.current = true;
cancelAnimationFrame(raf);
stream?.getTracks().forEach((track) => track.stop());
};
}, [onScan]);
if (error) {
return (
<Box direction="Column" gap="400">
<Text style={{ color: color.Critical.Main }} size="T300">
{error}
</Text>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B400">Back</Text>
</Button>
</Box>
);
}
return (
<Box direction="Column" gap="400" alignItems="Center">
<Text size="T300" align="Center">
Point your camera at the QR code shown on your other device.
</Text>
<video
ref={videoRef}
muted
playsInline
style={{
width: '100%',
maxWidth: 280,
borderRadius: config.radii.R400,
background: '#000',
}}
>
<track kind="captions" />
</video>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
);
}
@@ -31,6 +31,10 @@ export function AvatarDecoration({
> >
{children} {children}
<img <img
// Force a fresh element per slug so a recycled node whose previous slug
// 404'd (and was hidden in onError) can't leak `display:none` onto a
// valid decoration.
key={slug}
src={decorationUrl(slug)} src={decorationUrl(slug)}
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -48,6 +52,9 @@ export function AvatarDecoration({
aria-hidden="true" aria-hidden="true"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={(e) => {
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
}}
onError={(e) => { onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget as HTMLImageElement).style.display = 'none';
}} }}
@@ -99,9 +99,21 @@ export function AudioContent({
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1); const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
useEffect(() => { useEffect(() => {
if (audioRef.current) { const audio = audioRef.current;
audioRef.current.playbackRate = playbackSpeed; if (!audio) return undefined;
} const applyRate = () => {
audio.playbackRate = playbackSpeed;
};
// Apply immediately, and re-apply whenever the media element (re)loads a new
// source — e.g. after async decrypt swaps in the blob URL — since the browser
// resets playbackRate to 1 on load, discarding the user's speed choice.
applyRate();
audio.addEventListener('loadedmetadata', applyRate);
audio.addEventListener('play', applyRate);
return () => {
audio.removeEventListener('loadedmetadata', applyRate);
audio.removeEventListener('play', applyRate);
};
}, [playbackSpeed]); }, [playbackSpeed]);
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2]; const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex'; import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types'; import { SeasonTheme } from './types';
import { getActiveSeason } from './seasonSchedule'; import { getActiveSeason } from './seasonSchedule';
@@ -94,8 +95,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
export function SeasonalEffect() { export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const reduced = const reduced = useReducedMotion();
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const theme = useMemo<SeasonTheme | null>(() => { const theme = useMemo<SeasonTheme | null>(() => {
const override = settings.seasonalThemeOverride ?? 'auto'; const override = settings.seasonalThemeOverride ?? 'auto';
+31 -1
View File
@@ -1,4 +1,5 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import { import {
Box, Box,
Button, Button,
@@ -32,6 +33,7 @@ import {
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -48,6 +50,7 @@ type CallControlsProps = {
export function CallControls({ callEmbed }: CallControlsProps) { export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null); const controlRef = useRef<HTMLDivElement>(null);
const callEmbedRef = useCallEmbedRef(); const callEmbedRef = useCallEmbedRef();
const setCallEmbed = useSetAtom(callEmbedAtom);
const [compact, setCompact] = useState(document.body.clientWidth < 500); const [compact, setCompact] = useState(document.body.clientWidth < 500);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
@@ -175,22 +178,28 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}; };
if (isEditable(target)) return; if (isEditable(target)) return;
e.preventDefault(); e.preventDefault();
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
// doesn't treat this transient unmute as a user-initiated undeafen.
callEmbed.control.pttActive = true;
if (!microphoneRef.current) callEmbed.control.setMicrophone(true); if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
pttActiveRef.current = true; pttActiveRef.current = true;
setPttActive(true); setPttActive(true);
}; };
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return; if (e.code !== pttKey) return;
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
}; };
const onBlur = () => { const onBlur = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
}; };
const onFocus = () => { const onFocus = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
@@ -215,6 +224,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
iframeWindow?.removeEventListener('focus', onFocus); iframeWindow?.removeEventListener('focus', onFocus);
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup // BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) { if (pttActiveRef.current) {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false); callEmbed.control.setMicrophone(false);
pttActiveRef.current = false; pttActiveRef.current = false;
setPttActive(false); setPttActive(false);
@@ -242,8 +252,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
e.preventDefault(); e.preventDefault();
callEmbed.control.toggleSound(); callEmbed.control.toggleSound();
}; };
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const iframeWindow = callEmbed.iframe.contentWindow;
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); iframeWindow?.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keydown', onKeyDown);
};
}, [callEmbed, deafenKey]); }, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback( const [hangupState, hangup] = useAsyncCallback(
@@ -252,6 +269,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const exiting = const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
// C-M4: the normal teardown relies on EC echoing a Close/Hangup action after
// it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose).
// If EC ACKs but never echoes, the End button would spin forever. Fall back to
// disposing the embed a few seconds after a successful hangup send, unless it
// was already torn down by the normal path.
useEffect(() => {
if (hangupState.status !== AsyncStatus.Success) return undefined;
const id = setTimeout(() => {
if (!callEmbed.disposed) setCallEmbed(undefined);
}, 4000);
return () => clearTimeout(id);
}, [hangupState.status, callEmbed, setCallEmbed]);
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', ''); const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return ( return (
+15 -2
View File
@@ -1,4 +1,4 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Box, Box,
Icon, Icon,
@@ -64,6 +64,16 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
// guard those setState calls against the component unmounting first.
const mountedRef = useRef(true);
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const groups = useMemo( const groups = useMemo(
() => () =>
packs packs
@@ -86,7 +96,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
if (playingKey) return; // one at a time (fork also enforces this) if (playingKey) return; // one at a time (fork also enforces this)
setPlayingKey(flat.key); setPlayingKey(flat.key);
setError(undefined); setError(undefined);
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k)); const done = () => {
if (!mountedRef.current) return;
setPlayingKey((k) => (k === flat.key ? undefined : k));
};
try { try {
const url = await resolveClipObjectUrl(mx, flat.clip.url); const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master; const vol = (flat.clip.volume / 100) * master;
@@ -0,0 +1,80 @@
import React, { useCallback } from 'react';
import { Box, Button, color, Spinner, Text } from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { RetentionContent, RETENTION_PRESETS } from '../../../utils/retention';
type RoomRetentionProps = {
permissions: RoomPermissionsAPI;
};
export function RoomRetention({ permissions }: RoomRetentionProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.RoomRetention, mx.getSafeUserId());
const event = useStateEvent(room, StateEvent.RoomRetention);
const currentMs = event?.getContent<RetentionContent>().max_lifetime ?? 0;
const [submitState, submit] = useAsyncCallback(
useCallback(
async (ms: number) => {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Message Retention"
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
>
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
{RETENTION_PRESETS.map((preset) => {
const active = currentMs === preset.ms;
return (
<Button
key={preset.label}
type="button"
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
disabled={!canEdit || submitting}
onClick={() => submit(preset.ms)}
>
<Text size="B300">{preset.label}</Text>
</Button>
);
})}
{submitting && <Spinner size="100" variant="Secondary" />}
</Box>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Box, Button, color, config, Icon, Icons, Text } from 'folds'; import { Box, Button, config, Icon, Icons, Text } from 'folds';
import { QRCodeSVG } from 'qrcode.react';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@@ -12,11 +13,9 @@ export function RoomShareInvite() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [qrError, setQrError] = useState(false);
const domain = mx.getDomain() ?? undefined; const domain = mx.getDomain() ?? undefined;
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined); const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(inviteUrl)}`;
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard.writeText(inviteUrl).then(() => { navigator.clipboard.writeText(inviteUrl).then(() => {
@@ -64,35 +63,19 @@ export function RoomShareInvite() {
</Box> </Box>
</Box> </Box>
<Box justifyContent="Center"> <Box justifyContent="Center">
{qrError ? ( {/* Generated locally (qrcode.react) — no third-party service, works
offline + under strict CSP. White padded quiet-zone so the
default black-on-white code scans on any theme. */}
<Box <Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{ style={{
width: 160, padding: config.space.S200,
height: 160, background: '#ffffff',
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container, lineHeight: 0,
}} }}
> >
<Icon size="400" src={Icons.Warning} /> <QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
<Text size="T200" priority="300" align="Center">
QR code unavailable
</Text>
</Box> </Box>
) : (
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
loading="lazy"
onError={() => setQrError(true)}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
)}
</Box> </Box>
</Box> </Box>
</CutoutCard> </CutoutCard>
@@ -5,6 +5,7 @@ export * from './RoomJoinRules';
export * from './RoomProfile'; export * from './RoomProfile';
export * from './RoomPublish'; export * from './RoomPublish';
export * from './RoomQuality'; export * from './RoomQuality';
export * from './RoomRetention';
export * from './RoomShareInvite'; export * from './RoomShareInvite';
export * from './RoomUpgrade'; export * from './RoomUpgrade';
export * from './RoomVoiceLimit'; export * from './RoomVoiceLimit';
@@ -5,6 +5,8 @@ import {
Button, Button,
Chip, Chip,
Text, Text,
Icon,
Icons,
RectCords, RectCords,
PopOut, PopOut,
Menu, Menu,
@@ -75,15 +77,16 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
const hasPower = requiredPower <= power; const hasPower = requiredPower <= power;
return ( return (
<Text <Box
key={itemIndex} key={itemIndex}
size="T200" as="span"
style={{ alignItems="Center"
color: hasPower ? undefined : color.Critical.Main, gap="100"
}} style={{ color: hasPower ? undefined : color.Critical.Main }}
> >
{hasPower ? '✅' : '❌'} {item.name} <Icon size="50" src={hasPower ? Icons.Check : Icons.Cross} />
</Text> <Text size="T200">{item.name}</Text>
</Box>
); );
})} })}
</div> </div>
+5 -4
View File
@@ -137,12 +137,13 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
export const getChatBg = ( export const getChatBg = (
bg: ChatBackground, bg: ChatBackground,
isDark: boolean, isDark: boolean,
pauseAnimations?: boolean, // Whether to strip animation (user "pause animations" setting OR OS
// prefers-reduced-motion). Supplied by the caller — e.g. via useReducedMotion —
// so this function stays pure and SSR-safe (no matchMedia read at call time).
suppressAnimation?: boolean,
): CSSProperties => { ): CSSProperties => {
const style = isDark ? DARK[bg] : LIGHT[bg]; const style = isDark ? DARK[bg] : LIGHT[bg];
const reducedMotion = if (suppressAnimation && style.animation) {
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if ((pauseAnimations || reducedMotion) && style.animation) {
const { animation: _anim, ...rest } = style; const { animation: _anim, ...rest } = style;
return rest; return rest;
} }
+85 -19
View File
@@ -1,5 +1,5 @@
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk'; import { MatrixClient, Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Box, Box,
@@ -38,6 +38,7 @@ import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels'; import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications'; import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
@@ -263,27 +264,46 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
} }
// localStorage key for timed mute timers // localStorage key for timed mute timers
const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
type MuteTimerEntry = { roomId: string; unmuteAt: number }; // setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
function loadMuteTimers(): MuteTimerEntry[] { export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try { try {
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch { } catch {
return []; return [];
} }
} }
function saveMuteTimers(timers: MuteTimerEntry[]): void { export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
} }
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs; const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]); saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, durationMs); setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
} }
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
@@ -310,18 +330,39 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const isServerNotice = room.getType() === 'm.server_notice'; const isServerNotice = room.getType() === 'm.server_notice';
const isFavorite = !!room.tags?.['m.favourite']; const isFavorite = !!room.tags?.['m.favourite'];
const isLowPriority = !!room.tags?.['m.lowpriority'];
const handleToggleFavorite = () => { const handleToggleFavorite = () => {
if (isFavorite) { if (isFavorite) {
mx.deleteRoomTag(room.roomId, 'm.favourite'); mx.deleteRoomTag(room.roomId, 'm.favourite');
} else { } else {
// Favourite and low-priority are mutually exclusive.
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }); mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
} }
requestClose(); requestClose();
}; };
const handleToggleLowPriority = () => {
if (isLowPriority) {
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
} else {
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
}
requestClose();
};
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
if (markedUnread) setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
requestClose();
};
const handleMarkAsUnread = () => {
setMarkedUnread(mx, room.roomId, true).catch(() => undefined);
requestClose(); requestClose();
}; };
@@ -338,13 +379,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
).catch(() => {}); ).catch(() => {});
if (durationMs !== null) { if (durationMs !== null) {
scheduleMuteTimer(room.roomId, durationMs, () => { scheduleMuteTimer(room.roomId, durationMs, () => {
setRoomNotificationPreference( unmuteRoom(mx, room.roomId);
mx,
room.roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
}); });
} }
requestClose(); requestClose();
@@ -380,12 +415,23 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
size="300" size="300"
after={<Icon size="100" src={Icons.CheckTwice} />} after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300" radii="300"
disabled={!unread} disabled={!unread && !markedUnread}
> >
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate> <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read Mark as Read
</Text> </Text>
</MenuItem> </MenuItem>
<MenuItem
onClick={handleMarkAsUnread}
size="300"
after={<Icon size="100" src={Icons.MessageUnread} />}
radii="300"
disabled={!!unread || markedUnread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Unread
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}> <RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => ( {(handleOpen, opened, changing) => (
<MenuItem <MenuItem
@@ -480,6 +526,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
</Text> </Text>
</MenuItem> </MenuItem>
<MenuItem
onClick={handleToggleLowPriority}
size="300"
after={<Icon size="100" src={Icons.ChevronBottom} />}
radii="300"
aria-pressed={isLowPriority}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
</Text>
</MenuItem>
<MenuItem <MenuItem
onClick={handleInvite} onClick={handleInvite}
variant="Primary" variant="Primary"
@@ -597,6 +654,10 @@ function RoomNavItem_({
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [renameDialog, setRenameDialog] = useState(false); const [renameDialog, setRenameDialog] = useState(false);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
// MSC2867: an explicit "mark as unread" lights the row even with no unread
// count. `hasUnread` drives the bold name / icon emphasis below.
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const hasUnread = !!unread || markedUnread;
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId(), (receipt) => receipt.userId !== mx.getUserId(),
); );
@@ -679,7 +740,7 @@ function RoomNavItem_({
<NavItem <NavItem
variant="Background" variant="Background"
radii="400" radii="400"
highlight={unread !== undefined} highlight={hasUnread}
aria-selected={selected} aria-selected={selected}
data-hover={!!menuAnchor} data-hover={!!menuAnchor}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
@@ -708,7 +769,7 @@ function RoomNavItem_({
) : ( ) : (
<RoomIcon <RoomIcon
style={{ style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300, opacity: hasUnread ? config.opacity.P500 : config.opacity.P300,
}} }}
filled={selected} filled={selected}
size="100" size="100"
@@ -719,7 +780,7 @@ function RoomNavItem_({
</Avatar> </Avatar>
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}> <Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" alignItems="Center" gap="100"> <Box as="span" grow="Yes" alignItems="Center" gap="100">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate> <Text priority={hasUnread ? '500' : '300'} as="span" size="Inherit" truncate>
{roomName} {roomName}
</Text> </Text>
{hasLocalName && ( {hasLocalName && (
@@ -760,7 +821,7 @@ function RoomNavItem_({
</Box> </Box>
)} )}
</Box> </Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && ( {!optionsVisible && !hasUnread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined> <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation /> <TypingIndicator size="300" disableAnimation />
</Badge> </Badge>
@@ -770,6 +831,11 @@ function RoomNavItem_({
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} /> <UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter> </UnreadBadgeCenter>
)} )}
{!optionsVisible && !unread && markedUnread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={false} count={0} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon <Icon
size="50" size="50"
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds'; import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page'; import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
html: 'HTML', html: 'HTML',
}; };
const PAGE_LIMIT = 100;
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
// otherwise decrypt and hold every message in the room, hammering the server and
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
const MAX_EXPORT_PAGES = 200;
type ExportRoomHistoryProps = { type ExportRoomHistoryProps = {
requestClose: () => void; requestClose: () => void;
}; };
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const [toDate, setToDate] = useState(''); const [toDate, setToDate] = useState('');
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportCount, setExportCount] = useState(0); const [exportCount, setExportCount] = useState(0);
const [notice, setNotice] = useState('');
const cancelledRef = useRef(false);
const handleCancel = useCallback(() => {
cancelledRef.current = true;
}, []);
// Stop an in-flight export if the panel unmounts (closing settings mid-export
// would otherwise keep paginating + decrypting in the background).
useEffect(
() => () => {
cancelledRef.current = true;
},
[],
);
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
if (exporting) return; if (exporting) return;
cancelledRef.current = false;
setExporting(true); setExporting(true);
setExportCount(0); setExportCount(0);
setNotice('');
try { try {
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null; const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const seen = new Set<string>(); const seen = new Set<string>();
const timeline = room.getLiveTimeline(); const timeline = room.getLiveTimeline();
let canLoadMore = true; let canLoadMore = true;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
let oldestTs = Number.POSITIVE_INFINITY;
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
// date-range early-break must use this — oldestTs only ever holds collected
// events (all >= fromTs), so it can never fall below fromTs and the export
// would over-paginate to the page cap and show a misleading "truncated".
let oldestRawTs = Number.POSITIVE_INFINITY;
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => { const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
for (const ev of events) { for (const ev of events) {
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (ev.getType() !== EventType.RoomMessage) continue; if (ev.getType() !== EventType.RoomMessage) continue;
if (ev.isDecryptionFailure()) continue; if (ev.isDecryptionFailure()) continue;
const ts = ev.getTs(); const ts = ev.getTs();
if (ts < oldestRawTs) oldestRawTs = ts;
if (fromTs !== null && ts < fromTs) continue; if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue; if (toTs !== null && ts > toTs) continue;
const content = ev.getContent(); const content = ev.getContent();
const body: string = content.body ?? ''; const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? ''; const msgtype: string = content.msgtype ?? '';
if (!body) continue; if (!body) continue;
if (ts < oldestTs) oldestTs = ts;
collected.push({ collected.push({
ts, ts,
sender: ev.getSender() ?? '', sender: ev.getSender() ?? '',
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
await addEvents(timeline.getEvents()); await addEvents(timeline.getEvents());
// Paginate backwards until start or date range exceeded // Paginate backwards until start, date range exceeded, cap hit, or cancel
let pageCount = 0;
let truncated = false;
let cancelled = false;
while (canLoadMore) { while (canLoadMore) {
// If we have a fromTs, check whether the oldest collected event is already if (cancelledRef.current) {
// before it — if so we don't need to paginate further. cancelled = true;
if (fromTs !== null && collected.length > 0) { break;
const oldestTs = Math.min(...collected.map((r) => r.ts));
if (oldestTs < fromTs) break;
} }
// If we've paginated back past the fromTs boundary, there's nothing more
// in range to fetch (use the raw paginated ts, not the collected one).
if (fromTs !== null && oldestRawTs < fromTs) break;
// Hard cap so "export all" can't run away and OOM the tab.
if (pageCount >= MAX_EXPORT_PAGES) {
truncated = true;
break;
}
pageCount += 1;
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
canLoadMore = await mx.paginateEventTimeline(timeline, { canLoadMore = await mx.paginateEventTimeline(timeline, {
backwards: true, backwards: true,
limit: 100, limit: PAGE_LIMIT,
}); });
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await addEvents(timeline.getEvents()); await addEvents(timeline.getEvents());
} }
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
}
// Sort chronologically (oldest first) // Sort chronologically (oldest first)
collected.sort((a, b) => a.ts - b.ts); collected.sort((a, b) => a.ts - b.ts);
@@ -191,6 +239,12 @@ ${msgRows}
a.download = `export-${safeRoomName}-${dateStr}.${ext}`; a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
if (truncated) {
setNotice(
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
);
}
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -297,24 +351,35 @@ ${msgRows}
? `Exporting… ${exportCount} messages` ? `Exporting… ${exportCount} messages`
: 'Export will download automatically.'} : 'Export will download automatically.'}
</Text> </Text>
{exporting ? (
<Button
size="400"
variant="Critical"
fill="Soft"
radii="300"
onClick={handleCancel}
before={<Icon src={Icons.Cross} size="100" />}
>
<Text size="B400">Cancel</Text>
</Button>
) : (
<Button <Button
size="400" size="400"
variant="Primary" variant="Primary"
fill="Solid" fill="Solid"
radii="300" radii="300"
disabled={exporting}
onClick={handleExport} onClick={handleExport}
before={ before={<Icon src={Icons.Download} size="100" />}
exporting ? (
<Spinner size="200" />
) : (
<Icon src={Icons.Download} size="100" />
)
}
> >
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text> <Text size="B400">Export</Text>
</Button> </Button>
)}
</Box> </Box>
{notice && (
<Text size="T200" priority="400">
{notice}
</Text>
)}
</SequenceCard> </SequenceCard>
</Box> </Box>
</Box> </Box>
@@ -46,7 +46,9 @@ function isGlob(entity: string): boolean {
} }
function recommendationLabel(rec: string): string { function recommendationLabel(rec: string): string {
if (rec === 'm.ban') return 'Ban'; // `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
// (pre-stabilization) recommendation still emitted by older bots.
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
return rec; return rec;
} }
@@ -103,9 +105,11 @@ function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
<Text size="T200">glob</Text> <Text size="T200">glob</Text>
</Badge> </Badge>
)} )}
{entry.recommendation && (
<Badge variant="Critical" fill="Soft" radii="Pill"> <Badge variant="Critical" fill="Soft" radii="Pill">
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text> <Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
</Badge> </Badge>
)}
</Box> </Box>
{entry.reason && ( {entry.reason && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}> <Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
@@ -67,6 +67,7 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
if (membership === 'join') { if (membership === 'join') {
if ( if (
prevMembership === 'invite' || prevMembership === 'invite' ||
prevMembership === 'knock' ||
prevMembership === undefined || prevMembership === undefined ||
prevMembership === null prevMembership === null
) { ) {
@@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
filter: 'members', filter: 'members',
}; };
} }
// sender !== stateKey and the target was only invited → the inviter (or a
// moderator) retracted the invite; this is not a kick.
if (prevMembership === 'invite') {
return {
text: (
<>
<strong>{senderName}</strong> withdrew the invite to <strong>{targetName}</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
return { return {
text: ( text: (
<> <>
@@ -115,10 +115,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0); const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
const uniqueParticipants = msgCounts.size; const uniqueParticipants = msgCounts.size;
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage); // Single-pass min/max — `Math.min(...allTs)` spreads one arg per message and
const allTs = msgEvents.map((ev) => ev.getTs()); // overflows the call stack (RangeError) on a large paginated timeline.
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null; let oldestTs: number | null = null;
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null; let newestTs: number | null = null;
for (const ev of events) {
if (ev.getType() !== EventType.RoomMessage) continue;
const ts = ev.getTs();
if (oldestTs === null || ts < oldestTs) oldestTs = ts;
if (newestTs === null || ts > newestTs) newestTs = ts;
}
return { return {
top5, top5,
+170 -15
View File
@@ -3,16 +3,22 @@ import {
Box, Box,
Button, Button,
Checkbox, Checkbox,
Dialog,
Header,
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
Input, Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
color, color,
config, config,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../components/page'; import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
@@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css'; import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = {
// ── Validation ──────────────────────────────────────────────────────────────── // ── Validation ────────────────────────────────────────────────────────────────
/** /**
* Validate a server name or wildcard pattern. * Validate a server-name glob for an ACL entry.
* Allowed forms: *
* - plain hostname / IP: letters, digits, hyphens, dots * Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
* - wildcard prefix: *.example.com (asterisk only at the very start) * `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
* The Matrix spec allows `*` on its own (match-all wildcard). * `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
* rather than a concrete hostname:
* - reject empty / whitespace-only
* - allow only hostname/IP chars plus the wildcards `*` and `?`
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
*/ */
function isValidServerPattern(value: string): boolean { function isValidServerPattern(value: string): boolean {
if (value === '*') return true; const v = value.trim();
// Strip leading wildcard if (!v) return false;
const rest = value.startsWith('*.') ? value.slice(2) : value; // Only hostname/IP glob chars — wildcards may appear at any position.
// Must not be empty after stripping wildcard if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
if (!rest) return false; // Structural rules for the dotted parts.
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports) if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
return /^[A-Za-z0-9.:_-]+$/.test(rest); // Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
if (!/[A-Za-z0-9*?]/.test(v)) return false;
return true;
}
/**
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
* escaping every other regex metacharacter. Used only for local self-ban
* detection — never sent to the server.
*/
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
return new RegExp(`^${pattern}$`, 'i');
}
function matchesAnyGlob(domain: string, globs: string[]): boolean {
return globs.some((glob) => {
try {
return globToRegExp(glob).test(domain);
} catch {
return false;
}
});
} }
// ── Server list sub-component ───────────────────────────────────────────────── // ── Server list sub-component ─────────────────────────────────────────────────
@@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
if (!value) return; if (!value) return;
if (!isValidServerPattern(value)) { if (!isValidServerPattern(value)) {
setError('Invalid server pattern. Use a hostname or *.example.com'); setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)');
return; return;
} }
setError(undefined); setError(undefined);
@@ -181,6 +221,7 @@ type RoomServerACLProps = {
export function RoomServerACL({ requestClose }: RoomServerACLProps) { export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const modalStyle = useModalStyle(480);
// Power level checks // Power level checks
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
@@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
const saveError = const saveError =
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined; saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
// ── Save guards ───────────────────────────────────────────────────────────
// #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and
// partitions the room from all federation irreversibly — block the save.
const emptyAllow = allowList.length === 0;
// #2 Self-ban: the local homeserver must match at least one allow glob and no
// deny glob, otherwise applying this ACL removes our own server from the room.
const localDomain = mx.getDomain() ?? '';
const selfBanned =
localDomain.length > 0 &&
(!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList));
// #4 Gate the destructive write behind a confirmation dialog.
const [prompt, setPrompt] = useState(false);
const handleConfirmSave = () => {
setPrompt(false);
save();
};
// Required power level for this state event // Required power level for this state event
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl); const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
const myPL = readPowerLevel.user(powerLevels, myUserId); const myPL = readPowerLevel.user(powerLevels, myUserId);
@@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary" variant="Primary"
fill="Solid" fill="Solid"
radii="300" radii="300"
disabled={saving || !isDirty} disabled={saving || !isDirty || emptyAllow}
onClick={() => save()} onClick={() => setPrompt(true)}
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />} before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
> >
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text> <Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
</Text> </Text>
)} )}
{/* #1 Empty allow list guard — blocks save */}
{canEdit && emptyAllow && (
<Text size="T300" style={{ color: color.Critical.Main }}>
The allow list is empty. An empty allow list denies every server and partitions
this room from all federation permanently. Add at least one entry (use
&quot;*&quot; to allow all servers).
</Text>
)}
{/* #2 Self-ban warning — save allowed but confirmation required */}
{canEdit && !emptyAllow && selfBanned && (
<Text size="T300" style={{ color: color.Warning.Main }}>
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
Applying it will remove your server from the room and you may lose the ability to
moderate it.
</Text>
)}
{/* Allow IP literals toggle */} {/* Allow IP literals toggle */}
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">IP Address Access</Text> <Text size="L400">IP Address Access</Text>
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
</PageContent> </PageContent>
</Scroll> </Scroll>
</Box> </Box>
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
warnings and keeps a safe save one extra click. */}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
aria-labelledby="server-acl-confirm-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="server-acl-confirm-title">
Apply Server ACL
</Text>
</Box>
<IconButton
size="300"
onClick={() => setPrompt(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">
Server ACL changes take effect immediately and control which servers can
participate in this room. This cannot be undone by other servers once they are
removed.
</Text>
{emptyAllow && (
<Text size="T300" style={{ color: color.Critical.Main }}>
The allow list is empty this would deny every server and partition the
room from all federation permanently.
</Text>
)}
{!emptyAllow && selfBanned && (
<Text size="T300" style={{ color: color.Warning.Main }}>
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
Applying it will remove your server from the room and you may lose the
ability to moderate it.
</Text>
)}
</Box>
<Button
type="submit"
variant={selfBanned ? 'Critical' : 'Primary'}
onClick={handleConfirmSave}
disabled={emptyAllow}
>
<Text size="B400">Apply ACL</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Page> </Page>
); );
} }
@@ -12,6 +12,7 @@ import {
RoomPublishedAddresses, RoomPublishedAddresses,
RoomPublish, RoomPublish,
RoomQuality, RoomQuality,
RoomRetention,
RoomShareInvite, RoomShareInvite,
RoomUpgrade, RoomUpgrade,
RoomVoiceLimit, RoomVoiceLimit,
@@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) {
<RoomEncryption permissions={permissions} /> <RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} /> <RoomPublish permissions={permissions} />
</Box> </Box>
<Box direction="Column" gap="100">
<Text size="L400">Message Retention</Text>
<RoomRetention permissions={permissions} />
</Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Voice</Text> <Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} /> <RoomVoiceLimit permissions={permissions} />
+17 -4
View File
@@ -133,6 +133,18 @@ function getSenderName(room: Room, userId: string): string {
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId; return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
} }
// Resolve the thumbnail/display MXC for an image/video event, mirroring the
// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
}
// ── Lightbox ────────────────────────────────────────────────────────────────── // ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = { type LightboxItem = {
@@ -585,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const lightboxItems: LightboxItem[] = events const lightboxItems: LightboxItem[] = events
.filter((ev) => { .filter((ev) => {
const c = ev.getContent(); const c = ev.getContent();
return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video; if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
// Match the grid's guard exactly: tiles without a thumb are not rendered,
// so they must not occupy a lightbox slot either.
return !!getThumbMxc(ev);
}) })
.map((ev) => { .map((ev) => {
const c = ev.getContent(); const c = ev.getContent();
@@ -712,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const info: (IImageInfo & IThumbnailContent) | undefined = c.info; const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url // Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
const thumbMxc: string | undefined = isEnc const thumbMxc: string | undefined = getThumbMxc(mEvent);
? (info?.thumbnail_file?.url ?? c.file?.url)
: (info?.thumbnail_url ?? c.url);
const thumbEnc: IEncryptedFile | undefined = isEnc const thumbEnc: IEncryptedFile | undefined = isEnc
? (info?.thumbnail_file ?? c.file) ? (info?.thumbnail_file ?? c.file)
: undefined; : undefined;
+10 -4
View File
@@ -456,12 +456,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (compressionResult) { if (compressionResult) {
const originalFile = fileItem.originalFile as File; const originalFile = fileItem.originalFile as File;
const compressedFile = new File([compressionResult.blob], originalFile.name, { // compressImage re-encodes as JPEG; swap the extension so the file
type: 'image/jpeg', // name and MIME type agree (avoids e.g. a JPEG named "photo.png").
const compressedType = compressionResult.type;
const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType,
}); });
const uploadRes = await mx.uploadContent(compressedFile, { const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name, name: compressedName,
type: 'image/jpeg', type: compressedType,
}); });
const compressedMxc = (uploadRes as { content_uri: string }).content_uri; const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) { if (compressedMxc) {
@@ -538,6 +542,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCharCount(0);
sendTypingStatus(false); sendTypingStatus(false);
return; return;
} }
@@ -579,6 +584,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
mx.sendMessage(roomId, threadRootId ?? null, content as any); mx.sendMessage(roomId, threadRootId ?? null, content as any);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCharCount(0);
localStorage.removeItem(`draft-msg-${draftKey}`); localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
+12
View File
@@ -109,6 +109,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { ThreadSummary } from './thread/ThreadSummary'; import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -468,6 +470,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
// MSC1763 retention: messages older than this window are hidden from the
// timeline (unless "show hidden events" is on). Reactive so a policy change
// re-renders. `undefined` = no policy.
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
@@ -2043,6 +2050,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventSender && ignoredUsersSet.has(eventSender)) { if (eventSender && ignoredUsersSet.has(eventSender)) {
return null; return null;
} }
// MSC1763: hide messages past the room's retention window (disappearing
// messages). Power users can still inspect via "show hidden events".
if (retentionMs && !showHiddenEvents && isExpired(mEvent.getTs(), retentionMs, Date.now())) {
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) { if (mEvent.isRedacted() && !showHiddenEvents) {
// eslint-disable-next-line @typescript-eslint/no-shadow // eslint-disable-next-line @typescript-eslint/no-shadow
const t = mEvent.getType(); const t = mEvent.getType();
+6 -3
View File
@@ -19,6 +19,7 @@ import { Page } from '../../components/page';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useTheme, ThemeKind } from '../../hooks/useTheme'; import { useTheme, ThemeKind } from '../../hooks/useTheme';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { getChatBg } from '../lotus/chatBackground'; import { getChatBg } from '../lotus/chatBackground';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
@@ -65,6 +66,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const reduced = useReducedMotion();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -102,10 +104,11 @@ export function RoomView({ eventId }: { eventId?: string }) {
// Background.Container color. SidebarNav mirrors it onto document.body separately // Background.Container color. SidebarNav mirrors it onto document.body separately
// so the glassmorphism sidebar can blur through it. // so the glassmorphism sidebar can blur through it.
const chatBgStyle = useMemo(() => { const chatBgStyle = useMemo(() => {
if (chatBackground !== 'none') return getChatBg(chatBackground, isDark, pauseAnimations); if (chatBackground !== 'none')
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations); return getChatBg(chatBackground, isDark, pauseAnimations || reduced);
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations || reduced);
return {}; return {};
}, [chatBackground, lotusTerminal, isDark, pauseAnimations]); }, [chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
return ( return (
<Page ref={roomViewRef} style={chatBgStyle}> <Page ref={roomViewRef} style={chatBgStyle}>
@@ -29,6 +29,9 @@ export function buildForwardContent(
} }
delete content['m.relates_to']; delete content['m.relates_to'];
// Drop intentional mentions so forwarding a message doesn't re-ping the
// originally-mentioned users (they're not in the destination room's context).
delete content['m.mentions'];
if (typeof content.body === 'string') { if (typeof content.body === 'string') {
content.body = trimReplyFromBody(content.body); content.body = trimReplyFromBody(content.body);
} }
@@ -460,12 +460,17 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
}, [scrollToBottomCount]); }, [scrollToBottomCount]);
const handleJumpToBottom = useCallback(() => { const handleJumpToBottom = useCallback(() => {
// Re-anchor the virtual window at the thread tail first. While scrolled up,
// live replies deliberately don't extend the window, so without this the chip
// would scroll to the bottom of the STALE window (a mid/old event) instead of
// the newest reply. Mirrors the main timeline's handleJumpToLatest.
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true; scrollToBottomRef.current.smooth = true;
// Flip atBottom so the layout effect re-runs (count re-read) and live // Flip atBottom so the layout effect re-runs (count re-read) and live
// events resume sticking to the bottom. // events resume sticking to the bottom.
setAtBottom(true); setAtBottom(true);
}, []); }, [thread]);
// Scroll in-place editor into view. // Scroll in-place editor into view.
useEffect(() => { useEffect(() => {
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ReceiptType } from 'matrix-js-sdk';
import { markThreadAsRead } from './threadReceipt';
// The regression this guards: sending a receipt for the thread ROOT (when
// replies aren't loaded, lastReply() is null / equals the root) becomes a MAIN
// receipt at an old event and drags the room's read marker backwards. It must
// only ever receipt a genuine loaded reply.
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
const setup = (lastReply: any) => {
const calls: Array<{ eventId: string; type: ReceiptType }> = [];
const thread = { id: '$root', lastReply: () => lastReply } as any;
const mx = {
sendReadReceipt: async (e: any, type: ReceiptType) => {
calls.push({ eventId: e.getId(), type });
return {};
},
} as any;
return { mx, thread, calls };
};
test('REGRESSION: no loaded reply (lastReply null) → NO receipt (never the root)', async () => {
const { mx, thread, calls } = setup(null);
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 0);
});
test('REGRESSION: lastReply IS the root → NO receipt', async () => {
const { mx, thread, calls } = setup(evt('$root'));
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 0);
});
test('genuine loaded reply → threaded receipt at that reply', async () => {
const { mx, thread, calls } = setup(evt('$reply'));
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, '$reply');
assert.equal(calls[0].type, ReceiptType.Read);
});
test('sending reply is skipped', async () => {
const { mx, thread, calls } = setup(evt('$reply', true));
await markThreadAsRead(mx, thread, false);
assert.equal(calls.length, 0);
});
test('private flag uses ReadPrivate', async () => {
const { mx, thread, calls } = setup(evt('$reply'));
await markThreadAsRead(mx, thread, true);
assert.equal(calls[0].type, ReceiptType.ReadPrivate);
});
@@ -0,0 +1,28 @@
import { MatrixClient, ReceiptType, Thread } from 'matrix-js-sdk';
/**
* Send a threaded read receipt for a thread, clearing its per-thread unread
* count.
*
* CRITICAL: never receipt the thread ROOT. A thread's liveTimeline is
* `[root, reply1, …]`, so the latest event IS the root when replies aren't
* loaded yet (common the thread panel fires this on mount before replies
* fetch). The root is "in the main timeline", so a receipt for it is written by
* the SDK with `thread_id:"main"` at the old root, dragging the room's MAIN read
* marker backwards (`getEventReadUpTo` an old/unloaded event) and re-lighting
* the whole room. We only receipt a genuine loaded reply (`thread.lastReply()`);
* if none is loaded we bail (the per-thread count clears when the reply loads
* and this runs again). Mirrors the root guard in `utils/notifications.ts`.
*
* Pure (no React/CSS) so it can be unit-tested see `threadReceipt.test.ts`.
*/
export const markThreadAsRead = async (
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean,
): Promise<void> => {
const lastReply = thread.lastReply();
if (!lastReply || lastReply.isSending() || lastReply.getId() === thread.id) return;
await mx.sendReadReceipt(lastReply, privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read);
};
+3 -30
View File
@@ -4,7 +4,6 @@ import {
EventTimeline, EventTimeline,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
ReceiptType,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
@@ -146,32 +145,6 @@ export const useThreadPendingEvents = (
return pending; return pending;
}; };
/** // markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
* Send a threaded read receipt up to the latest confirmed event in the thread. // here for existing import sites.
* export { markThreadAsRead } from './threadReceipt';
* The receipt is threaded by default (scoped to this thread), which clears the
* per-thread unread count. Mirrors the latest-valid-event scan in
* `utils/notifications.ts`.
*/
export const markThreadAsRead = async (
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean,
): Promise<void> => {
const events = thread.liveTimeline.getEvents();
let latestEvent: MatrixEvent | undefined;
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
latestEvent = evt;
break;
}
}
if (!latestEvent) return;
await mx.sendReadReceipt(
latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
);
};
+32 -34
View File
@@ -35,6 +35,9 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
@@ -319,8 +322,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
); );
} }
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`; export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
const CLEAR_AFTER_OPTIONS = [ const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' }, { label: 'Never', value: '0' },
@@ -347,6 +350,8 @@ function ProfileStatus() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const [statusMsg, setStatusMsg] = useState<string>( const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '', presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
@@ -357,12 +362,6 @@ function ProfileStatus() {
const [clearAfter, setClearAfter] = useState('0'); const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>(); const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
// Initialise expiry from localStorage so timer survives page reload
const [expiryTs, setExpiryTs] = useState<number>(() => {
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
return stored ? parseInt(stored, 10) : 0;
});
// Sync input when another device changes the status. // Sync input when another device changes the status.
// Skipped while the user has unsaved local edits to avoid clobbering // Skipped while the user has unsaved local edits to avoid clobbering
// mid-flight input (e.g. an emoji being inserted). // mid-flight input (e.g. an emoji being inserted).
@@ -373,32 +372,16 @@ function ProfileStatus() {
} }
}, [presence?.status, userId]); }, [presence?.status, userId]);
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
useEffect(() => {
if (!expiryTs) return undefined;
const remaining = expiryTs - Date.now();
const clearStatus = () => {
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
};
if (remaining <= 0) {
clearStatus();
return undefined;
}
const timer = window.setTimeout(clearStatus, remaining);
return () => clearTimeout(timer);
}, [expiryTs, userId, mx]);
const [saveState, saveStatus] = useAsyncCallback( const [saveState, saveStatus] = useAsyncCallback(
useCallback( useCallback(
(msg: string) => (msg: string) =>
mx.setPresence({ mx.setPresence({
presence: 'online', // Derive presence from the user's chosen setting so writing a status
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: msg, status_msg: msg,
}), }),
[mx], [mx, presenceStatus, hidePresence],
), ),
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
@@ -429,12 +412,12 @@ function ProfileStatus() {
const delayMs = getMsFromOption(clearAfter); const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) { if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
const ts = Date.now() + delayMs; const ts = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts)); localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
setExpiryTs(ts);
} else { } else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
} }
}; };
@@ -443,8 +426,11 @@ function ProfileStatus() {
setStatusMsg(''); setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0); // Preserve the user's chosen presence when clearing the status message.
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined); mx.setPresence({
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: '',
}).catch(() => undefined);
}; };
const hasChanges = statusMsg !== (presence?.status ?? ''); const hasChanges = statusMsg !== (presence?.status ?? '');
@@ -751,10 +737,22 @@ function ProfileTimezone() {
const [saveState, saveTimezone] = useAsyncCallback( const [saveState, saveTimezone] = useAsyncCallback(
useCallback( useCallback(
(value: string) => (value: string) =>
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => { Promise.all([
// Self-fallback: account data is readable by useExtendedProfile for the
// own user even on servers without extended-profile (m.tz) support.
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }),
// Mirror the pronouns write path so OTHER users can read the timezone
// via the m.tz profile field. Best-effort: standard Synapse rejects
// unknown profile fields, so a failure here must not fail the save.
mx.http
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
'm.tz': value,
})
.catch(() => undefined),
]).then(() => {
setSavedTimezone(value); setSavedTimezone(value);
}), }),
[mx], [mx, userId],
), ),
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
@@ -50,7 +50,7 @@ function DecorationPreviewCell({
<img <img
src={`${DECORATION_CDN}/${slug}.png`} src={`${DECORATION_CDN}/${slug}.png`}
alt={name} alt={name}
loading="eager" loading="lazy"
decoding="async" decoding="async"
style={{ style={{
position: 'absolute', position: 'absolute',
+20 -1
View File
@@ -105,6 +105,7 @@ import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } 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 { useReducedMotion } from '../../../hooks/useReducedMotion';
import { playCallJoinSound } from '../../../utils/callSounds'; import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones'; import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester'; import { DenoiseTester } from './DenoiseTester';
@@ -2054,6 +2055,7 @@ function ChatBgGrid() {
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const reduced = useReducedMotion();
return ( return (
<Box <Box
@@ -2079,7 +2081,7 @@ function ChatBgGrid() {
style={{ style={{
width: toRem(76), width: toRem(76),
height: toRem(50), height: toRem(50),
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations), ...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
}} }}
/> />
<Text <Text
@@ -2249,6 +2251,10 @@ function Messages() {
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
settingsAtom,
'enforceRetentionLocally',
);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
@@ -2346,6 +2352,19 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Enforce Message Retention"
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
after={
<Switch
variant="Primary"
value={enforceRetentionLocally}
onChange={setEnforceRetentionLocally}
/>
}
/>
</SequenceCard>
</Box> </Box>
); );
} }
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Button, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds'; import { Box, Button, Text, IconButton, Icon, Icons, IconSrc, Scroll, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SystemNotification } from './SystemNotification'; import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages'; import { AllMessagesNotifications } from './AllMessages';
@@ -14,13 +14,13 @@ import { settingsAtom, Settings } from '../../../state/settings';
const PRESETS: Array<{ const PRESETS: Array<{
label: string; label: string;
emoji: string; icon: IconSrc;
description: string; description: string;
patch: Partial<Settings>; patch: Partial<Settings>;
}> = [ }> = [
{ {
label: 'Gaming', label: 'Gaming',
emoji: '🎮', icon: Icons.Ball,
description: 'Notifications on, sounds off', description: 'Notifications on, sounds off',
patch: { patch: {
showNotifications: true, showNotifications: true,
@@ -32,7 +32,7 @@ const PRESETS: Array<{
}, },
{ {
label: 'Work', label: 'Work',
emoji: '💼', icon: Icons.Monitor,
description: 'All notifications and sounds on', description: 'All notifications and sounds on',
patch: { patch: {
showNotifications: true, showNotifications: true,
@@ -44,7 +44,7 @@ const PRESETS: Array<{
}, },
{ {
label: 'Sleep', label: 'Sleep',
emoji: '🌙', icon: Icons.BellMute,
description: 'All notifications off', description: 'All notifications off',
patch: { patch: {
showNotifications: false, showNotifications: false,
@@ -83,7 +83,7 @@ function NotificationPresets() {
}} }}
> >
<Box direction="Column" alignItems="Center" gap="100"> <Box direction="Column" alignItems="Center" gap="100">
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span> <Icon size="400" src={preset.icon} />
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}> <Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
{preset.label} {preset.label}
</Text> </Text>
+41 -39
View File
@@ -4,60 +4,66 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast'; import { toastQueueAtom } from '../state/toast';
import { useMatrixClient } from './useMatrixClient';
const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500; const CHECK_INTERVAL_MS = 500;
/** /**
* Monitors microphone audio while in a call. If the mic stays unmuted but * Monitors microphone activity while in a call. If the mic stays unmuted but
* silent for longer than the configured timeout, the mic is muted and a toast * the user is not speaking for longer than the configured timeout, the mic is
* is shown. * muted and a toast is shown.
* *
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is * [C-H2] Activity is read from the EC fork's `io.lotus.call_state` stream
* unmuted there is nothing to auto-mute once you are already muted, so * (getLotusParticipants) i.e. the VAD state of the user's ACTUAL published
* holding the capture would keep the OS recording indicator lit even though the * track on their SELECTED input device. The previous implementation opened its
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting * own `getUserMedia({ audio: true })`, which captured the browser DEFAULT mic
* re-acquires it. The AudioContext + stream are also torn down on unmount. * (not necessarily the device EC publishes from): it could measure silence
* while the user spoke on a different device (auto-muting an active speaker) and
* lit a second OS microphone indicator. Sourcing from the fork removes both
* problems and needs no extra capture.
*
* If the fork hasn't reported call-state yet (getLotusParticipants() === null
* e.g. plain EC, or immediately after join), we cannot tell whether the user is
* publishing, so we fail SAFE and never auto-mute during that window.
*/ */
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void { export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const mx = useMatrixClient();
const [enabled] = useSetting(settingsAtom, 'afkAutoMute'); const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const { microphone } = useCallControlState(callEmbed?.control); const { microphone } = useCallControlState(callEmbed?.control);
useEffect(() => { useEffect(() => {
// Only capture while in a call, enabled, AND unmuted (see N95 note above). // Only monitor while in a call, enabled, AND unmuted — there is nothing to
// auto-mute once you are already muted.
if (!callEmbed || !enabled || !microphone) return undefined; if (!callEmbed || !enabled || !microphone) return undefined;
let stream: MediaStream | undefined; const localUserId = mx.getSafeUserId();
let audioCtx: AudioContext | undefined; const timeoutMs = timeoutMinutes * 60 * 1000;
let intervalId: ReturnType<typeof setInterval> | undefined;
let silenceStart: number | null = null; let silenceStart: number | null = null;
let active = true; let active = true;
const timeoutMs = timeoutMinutes * 60 * 1000;
navigator.mediaDevices // undefined = fork hasn't reported call-state yet (can't tell — fail safe).
.getUserMedia({ audio: true, video: false }) const isLocalSpeaking = (): boolean | undefined => {
.then((s) => { const participants = callEmbed.getLotusParticipants();
if (!active) { // null = fork not reported; [] = malformed/spurious payload (CallEmbed
s.getTracks().forEach((t) => t.stop()); // stores [] for a non-array). You are ALWAYS present in your own joined
return; // call, so an empty list means "no usable data", NOT "silent" — matching
} // useCallSpeakers / useRemoteAllMuted. Treating [] as silent would let the
stream = s; // timer mute an active speaker. Fail safe on both.
audioCtx = new AudioContext(); if (participants === null || participants.length === 0) return undefined;
const source = audioCtx.createMediaStreamSource(stream); return participants.some((p) => p.userId === localUserId && p.audioEnabled && p.speaking);
const analyser = audioCtx.createAnalyser(); };
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Float32Array(analyser.fftSize);
intervalId = setInterval(() => { const intervalId = setInterval(() => {
if (!active) return; if (!active) return;
analyser.getFloatTimeDomainData(buffer); const speaking = isLocalSpeaking();
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
if (rms > SILENCE_RMS_THRESHOLD) { if (speaking === undefined) {
// Audio detected — reset the silence timer. // No usable signal — don't risk muting an active speaker.
silenceStart = null;
} else if (speaking) {
// Voice detected on the published track — reset the silence timer.
silenceStart = null; silenceStart = null;
} else if (silenceStart === null) { } else if (silenceStart === null) {
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer. // Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
@@ -74,14 +80,10 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
silenceStart = null; silenceStart = null;
} }
}, CHECK_INTERVAL_MS); }, CHECK_INTERVAL_MS);
})
.catch(() => undefined);
return () => { return () => {
active = false; active = false;
if (intervalId !== undefined) clearInterval(intervalId); clearInterval(intervalId);
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined);
}; };
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]); }, [callEmbed, enabled, timeoutMinutes, setToast, microphone, mx]);
} }
+18 -8
View File
@@ -9,6 +9,9 @@ const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const cache = new Map<string, string | null>(); const cache = new Map<string, string | null>();
// Callbacks waiting for a userId's result // Callbacks waiting for a userId's result
const pending = new Map<string, Array<(val: string | null) => void>>(); const pending = new Map<string, Array<(val: string | null) => void>>();
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
// can retry a couple of times, then gives up for the session.
const failures = new Map<string, number>();
function fetchDecoration( function fetchDecoration(
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>, authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
@@ -33,16 +36,23 @@ function fetchDecoration(
return val; return val;
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
// decoration". A transient failure (429 rate-limit, 5xx, network) must
// NOT be cached: doing so permanently hides the user's decoration for the
// whole session. This matters most for the member list and timeline, which
// mount many avatars at once and can trip homeserver rate limits — a
// single 429 in that burst would otherwise wipe the decoration until a
// full reload. Leaving the cache unset lets the next mount retry.
const status = err instanceof MatrixError ? err.httpStatus : undefined; const status = err instanceof MatrixError ? err.httpStatus : undefined;
if (status === 404) { // Definitive rejections — the field is unset (404) or the server won't
// serve it (400/403). This is the common case for FEDERATED users whose
// homeserver doesn't support extended profiles / rejects the field. Cache
// "no decoration" so we never refetch: otherwise every avatar mount
// re-requests and floods our homeserver with failing federated profile
// lookups (the 403/502 console storm + real HS load).
if (status === 404 || status === 403 || status === 400) {
cache.set(userId, null); cache.set(userId, null);
} else {
// Transient (429 rate-limit / 5xx / network). Allow a couple of retries
// — a single 429 in a member-list burst shouldn't permanently hide a
// decoration — then give up for the session so a persistently-failing
// federated link (e.g. a 502'ing remote server) can't loop forever.
const attempts = (failures.get(userId) ?? 0) + 1;
failures.set(userId, attempts);
if (attempts >= 2) cache.set(userId, null);
} }
return null; return null;
}) })
+86 -26
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Bookmark = { export type Bookmark = {
roomId: string; roomId: string;
@@ -25,6 +24,75 @@ function readBookmarks(mx: MatrixClient): Bookmark[] {
); );
} }
// Module-scoped serialization state.
//
// useBookmarks() is mounted once per message row (dozens of live instances), so
// a per-instance latest/queue would only serialize writes within a single row —
// bookmarking message A then message B from different rows (before the server
// echo lands) would let B compute from a stale snapshot and clobber A
// (setAccountData replaces the whole content, no server merge). We therefore
// keep a single shared latest ref + write queue, keyed off the active client.
type BookmarksModuleState = {
mx: MatrixClient;
latest: Bookmark[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Bookmark[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: BookmarksModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: BookmarksModuleState = {
mx,
latest: readBookmarks(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === BOOKMARKS_KEY) {
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueBookmarkWrite(
mx: MatrixClient,
compute: (current: Bookmark[]) => Bookmark[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useBookmarks(): { export function useBookmarks(): {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
addBookmark: (b: Bookmark) => Promise<void>; addBookmark: (b: Bookmark) => Promise<void>;
@@ -32,45 +100,37 @@ export function useBookmarks(): {
isBookmarked: (eventId: string) => boolean; isBookmarked: (eventId: string) => boolean;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx)); const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
useAccountDataCallback( // Subscribe to the shared module state. A single AccountData listener is
mx, // installed per client (in ensureModuleState); each hook instance only
useCallback( // registers a local setter and unregisters it on unmount / client change.
(evt) => {
if (evt.getType() === BOOKMARKS_KEY) {
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
}
},
[setBookmarks],
),
);
// Re-read on mx change
useEffect(() => { useEffect(() => {
setBookmarks(readBookmarks(mx)); const state = ensureModuleState(mx);
setBookmarks(state.latest);
state.listeners.add(setBookmarks);
return () => {
state.listeners.delete(setBookmarks);
};
}, [mx]); }, [mx]);
const addBookmark = useCallback( const addBookmark = useCallback(
async (b: Bookmark) => { (b: Bookmark) =>
const current = readBookmarks(mx); enqueueBookmarkWrite(mx, (current) => {
// Avoid duplicates // Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== b.eventId); const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [b, ...filtered]; let next = [b, ...filtered];
if (next.length > MAX_BOOKMARKS) { if (next.length > MAX_BOOKMARKS) {
next = next.slice(0, MAX_BOOKMARKS); next = next.slice(0, MAX_BOOKMARKS);
} }
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next }); return next;
}, }),
[mx], [mx],
); );
const removeBookmark = useCallback( const removeBookmark = useCallback(
async (eventId: string) => { (eventId: string) =>
const current = readBookmarks(mx); enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
const next = current.filter((bk) => bk.eventId !== eventId);
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
},
[mx], [mx],
); );
+19
View File
@@ -6,6 +6,25 @@ import { settingsAtom } from '../state/settings';
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const ACTIVITY_THROTTLE_MS = 1000; const ACTIVITY_THROTTLE_MS = 1000;
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
export type PresenceState = 'online' | 'unavailable' | 'offline';
/**
* Single source of truth for mapping the user's presence preference to the
* Matrix presence value: auto/online 'online', idle/dnd 'unavailable',
* invisible (or the hidePresence override) 'offline'. Shared with the Profile
* status writer so setting/clearing a status message never overrides the user's
* chosen presence (e.g. outing an Invisible user as online).
*/
export function presenceStateFromSetting(
presenceStatus: PresenceSetting,
hidePresence: boolean,
): PresenceState {
if (hidePresence || presenceStatus === 'invisible') return 'offline';
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
return 'online';
}
export function usePresenceUpdater() { export function usePresenceUpdater() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
const readReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia(REDUCED_MOTION_QUERY).matches;
/**
* Reactively tracks the OS `prefers-reduced-motion: reduce` setting.
*
* Unlike a one-off `window.matchMedia(...).matches` read, this subscribes to the
* media query's `change` event, so toggling the OS setting mid-session updates
* the returned value (and any animation gated on it) without a page reload.
* SSR/undefined-safe: returns `false` when `window`/`matchMedia` is unavailable.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(readReducedMotion);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
// Re-sync in case the setting changed between the initial render and this effect.
setReduced(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
+87 -54
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Reminder = { export type Reminder = {
roomId: string; roomId: string;
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
); );
} }
// Module-scoped serialization state.
//
// The latest snapshot and the write queue must be shared across every hook
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
// (adds reminders) mount separate hooks, and a per-instance queue would let a
// remove and an add race across instances and clobber each other (setAccountData
// replaces the whole content, no server merge). We therefore keep a single
// shared queue + latest ref, keyed off the active MatrixClient.
type ReminderModuleState = {
mx: MatrixClient;
latest: Reminder[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Reminder[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ReminderModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ReminderModuleState = {
mx,
latest: readReminders(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === REMINDERS_KEY) {
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueReminderWrite(
mx: MatrixClient,
compute: (current: Reminder[]) => Reminder[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useReminders(): { export function useReminders(): {
reminders: Reminder[]; reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>; addReminder: (r: Reminder) => Promise<void>;
@@ -30,69 +98,34 @@ export function useReminders(): {
getReminders: () => Reminder[]; getReminders: () => Reminder[];
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx)); const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
// Authoritative local snapshot used to compute mutations. Reading // Subscribe to the shared module state. A single AccountData listener is
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both // installed per client (in ensureModuleState); each hook instance only
// read the same stale baseline and the second write clobbers the first // registers a local setter and unregisters it on unmount / client change.
// (N113). We instead mutate from this ref, kept in sync with server echoes.
const latestRef = useRef<Reminder[]>(reminders);
// Serialize writes so overlapping setAccountData calls can't land out of
// order on the server (last-write-wins would otherwise drop data).
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const applyServerState = useCallback((list: Reminder[]) => {
latestRef.current = list;
setReminders(list);
}, []);
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[applyServerState],
),
);
// Re-read on mx change
useEffect(() => { useEffect(() => {
applyServerState(readReminders(mx)); const state = ensureModuleState(mx);
}, [mx, applyServerState]); setReminders(state.latest);
state.listeners.add(setReminders);
const enqueueWrite = useCallback( return () => {
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => { state.listeners.delete(setReminders);
const run = writeQueueRef.current.then(async () => { };
const next = compute(latestRef.current); }, [mx]);
latestRef.current = next;
setReminders(next);
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
writeQueueRef.current = run.catch(() => undefined);
return run;
},
[mx],
);
const addReminder = useCallback( const addReminder = useCallback(
(r: Reminder) => enqueueWrite((current) => [...current, r]), (r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
[enqueueWrite], [mx],
); );
const removeReminder = useCallback( const removeReminder = useCallback(
(eventId: string, timestamp: number) => (eventId: string, timestamp: number) =>
enqueueWrite((current) => enqueueReminderWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)), current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
), ),
[enqueueWrite], [mx],
); );
const getReminders = useCallback(() => reminders, [reminders]); const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
return { reminders, addReminder, removeReminder, getReminders }; return { reminders, addReminder, removeReminder, getReminders };
} }
+17 -1
View File
@@ -1,4 +1,11 @@
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import {
MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
@@ -45,11 +52,20 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => { const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
setLatestEvent(getLatestEvent()); setLatestEvent(getLatestEvent());
}; };
// An E2EE message often arrives as an undecrypted placeholder and is decrypted
// shortly after — decryption does NOT re-fire RoomEvent.Timeline, so without this
// the DM preview stays stale ("Encrypted message") until the next timeline event.
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
if (event.getRoomId() !== room.roomId) return;
setLatestEvent(getLatestEvent());
};
setLatestEvent(getLatestEvent()); setLatestEvent(getLatestEvent());
room.on(RoomEvent.Timeline, handleTimelineEvent); room.on(RoomEvent.Timeline, handleTimelineEvent);
room.client.on(MatrixEventEvent.Decrypted, handleDecrypted);
return () => { return () => {
room.removeListener(RoomEvent.Timeline, handleTimelineEvent); room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
}; };
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]); }, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
+15 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { manualDndAtom } from '../state/manualDnd'; import { manualDndAtom } from '../state/manualDnd';
import { useTauriEvent } from './useTauri'; import { tauriInvoke, useTauriEvent } from './useTauri';
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */ /** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
type DndChangedDetail = { type DndChangedDetail = {
@@ -18,4 +19,17 @@ export function useTauriDnd(): void {
const setDnd = useSetAtom(manualDndAtom); const setDnd = useSetAtom(manualDndAtom);
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active)); useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
// Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but
// `manualDndAtom` is in-memory and resets to false on every reload (the
// custom-chrome toggle, logout). Without this the tray could show DND ON while
// notifications resume firing. Query the native tray state (`get_tray_dnd`) so
// they stay in sync. No-op in the browser.
useEffect(() => {
tauriInvoke()?.('get_tray_dnd')
.then((active) => {
if (typeof active === 'boolean') setDnd(active);
})
.catch(() => undefined);
}, [setDnd]);
} }
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
let totalHighlights = 0; let totalHighlights = 0;
roomToUnread.forEach((unread) => { roomToUnread.forEach((unread) => {
// Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor
// space aggregates (from = Set), so counting all entries double-counts a
// space-nested room. Mirrors the favicon fix in ClientNonUIFeatures.
if (unread.from !== null) return;
totalHighlights += unread.highlight; totalHighlights += unread.highlight;
}); });
+4
View File
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
setStatus({ state: 'installing' }); setStatus({ state: 'installing' });
try { try {
await invoke('install_update'); await invoke('install_update');
// On a successful install the native side calls app.restart(), so this
// resolve is only reached when nothing was installed (no update found) —
// don't leave the UI stuck on "installing".
setStatus({ state: 'up-to-date' });
} catch (e) { } catch (e) {
setStatus({ state: 'error', message: String(e) }); setStatus({ state: 'error', message: String(e) });
} }
+85 -17
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
const NOTES_KEY = 'io.lotus.user_notes'; const NOTES_KEY = 'io.lotus.user_notes';
export const USER_NOTE_MAX_LENGTH = 500; export const USER_NOTE_MAX_LENGTH = 500;
@@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent {
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {}; return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
} }
// Module-scoped serialization state.
//
// useUserNotes() can be mounted by many components at once, so a per-instance
// latest/queue would only serialize writes within one instance. Notes for
// different users saved from different instances (before the server echo lands)
// would each compute from a stale snapshot and clobber each other, since
// setAccountData replaces the whole record with no server merge. We therefore
// keep a single shared latest record + write queue, keyed off the active client.
type UserNotesModuleState = {
mx: MatrixClient;
latest: UserNotesContent;
writeQueue: Promise<unknown>;
listeners: Set<(record: UserNotesContent) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: UserNotesModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: UserNotesModuleState = {
mx,
latest: readNotes(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === NOTES_KEY) {
const record = evt.getContent<UserNotesContent>() ?? {};
state.latest = record;
state.listeners.forEach((listener) => listener(record));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueNotesWrite(
mx: MatrixClient,
compute: (current: UserNotesContent) => UserNotesContent,
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(NOTES_KEY, next);
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
export function useUserNotes(): { export function useUserNotes(): {
getNote: (userId: string) => string; getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>; setNote: (userId: string, note: string) => Promise<void>;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx)); const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
useAccountDataCallback(
mx,
useCallback((evt) => {
if (evt.getType() === NOTES_KEY) {
setNotes(evt.getContent<UserNotesContent>() ?? {});
}
}, []),
);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => { useEffect(() => {
setNotes(readNotes(mx)); const state = ensureModuleState(mx);
setNotes(state.latest);
state.listeners.add(setNotes);
return () => {
state.listeners.delete(setNotes);
};
}, [mx]); }, [mx]);
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]); const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback( const setNote = useCallback(
async (userId: string, note: string) => { (userId: string, note: string) => {
const current = readNotes(mx);
const updated = { ...current };
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH); const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
return enqueueNotesWrite(mx, (current) => {
const updated = { ...current };
if (trimmed) { if (trimmed) {
updated[userId] = trimmed; updated[userId] = trimmed;
} else { } else {
delete updated[userId]; delete updated[userId];
} }
await (mx as any).setAccountData(NOTES_KEY, updated); return updated;
});
}, },
[mx], [mx],
); );
+4
View File
@@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => { useEffect(() => {
// Re-seed when the User object appears/changes after first render — the
// useState initializer only ran if `user` already existed at mount, so a
// late-arriving user would otherwise show no presence until the next event.
if (user) setPresence(getUserPresence(user));
// Subscribe on mx (MatrixClient) rather than on individual User objects. // Subscribe on mx (MatrixClient) rather than on individual User objects.
// User objects have a default 10-listener limit; the same user can appear // User objects have a default 10-listener limit; the same user can appear
// in many components simultaneously (avatars, member list, etc.) and // in many components simultaneously (avatars, member list, etc.) and
+176 -14
View File
@@ -11,7 +11,7 @@ import {
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist'; import { focusAssistActiveAtom } from '../../state/focusAssist';
import { manualDndAtom } from '../../state/manualDnd'; import { manualDndAtom } from '../../state/manualDnd';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } 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';
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png'; import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
@@ -32,15 +32,23 @@ import {
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { NotificationType } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
MAX_MUTE_TIMEOUT_MS,
MuteTimerEntry,
loadMuteTimers,
unmuteRoom,
} from '../../features/room-nav/RoomNavItem';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast'; import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts'; import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
@@ -96,6 +104,11 @@ function FaviconUpdater() {
let totalNotif = 0; let totalNotif = 0;
let totalHighlight = 0; let totalHighlight = 0;
roomToUnread.forEach((unread) => { roomToUnread.forEach((unread) => {
// roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates
// (leaves have `from === null`, aggregates a Set). Sum only leaves —
// otherwise a space-nested room is counted once as the leaf and again in
// every ancestor space, inflating the tab title / favicon count.
if (unread.from !== null) return;
totalNotif += unread.total; totalNotif += unread.total;
totalHighlight += unread.highlight; totalHighlight += unread.highlight;
}); });
@@ -230,9 +243,95 @@ function PresenceUpdater() {
return null; return null;
} }
// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
// lost and the room stays muted forever. On boot: unmute anything already
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
function MuteTimerRestore() {
const mx = useMatrixClient();
useEffect(() => {
const timers = loadMuteTimers();
if (timers.length === 0) return undefined;
const now = Date.now();
const pastDue: MuteTimerEntry[] = [];
const future: MuteTimerEntry[] = [];
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
pastDue.forEach((entry) => {
unmuteRoom(mx, entry.roomId);
});
const handles = future.map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
),
);
return () => {
handles.forEach(clearTimeout);
};
}, [mx]);
return null;
}
// Fires the custom-status auto-clear even when Settings→Profile is closed. The
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
// the status never cleared. This always-mounted watcher polls the persisted
// expiry key and clears (preserving the user's chosen presence) when due.
function StatusExpiryMonitor() {
const mx = useMatrixClient();
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
// Read latest settings via refs so the poll interval isn't torn down/restarted
// (resetting its countdown) whenever the presence setting changes.
const presenceStatusRef = useRef(presenceStatus);
presenceStatusRef.current = presenceStatus;
const hidePresenceRef = useRef(hidePresence);
hidePresenceRef.current = hidePresence;
useEffect(() => {
const userId = mx.getUserId();
if (!userId) return undefined;
const expiryKey = STATUS_EXPIRY_KEY(userId);
const msgKey = STATUS_MSG_KEY(userId);
const check = () => {
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
mx.setPresence({
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
status_msg: '',
}).catch(() => undefined);
};
check();
const interval = setInterval(check, 30_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx]);
return null;
}
function MessageNotifications() { function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map()); const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
// Per-thread dedupe: threadId -> last notified eventId. // Per-thread dedupe: threadId -> last notified eventId.
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map()); const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -367,17 +466,21 @@ function MessageNotifications() {
const eventId = mEvent.getId(); const eventId = mEvent.getId();
if (!sender || !eventId) return; if (!sender || !eventId) return;
const unreadInfo = getUnreadInfo(room); // Dedupe on the event id (per room): the same event can re-fire (decryption,
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId); // edit, thread repopulation). This replaces the old unread-COUNT dedupe,
unreadCacheRef.current.set(room.roomId, unreadInfo); // which suppressed a genuinely-new message whenever its post-read count
// matched the previously-notified count — i.e. "read a DM, next message
// never notifies/sounds" (the common one-at-a-time cadence).
if (lastNotifiedEventRef.current.get(room.roomId) === eventId) return;
if (unreadInfo.total === 0) return; // Main-timeline path respects push rules: don't notify when the room has no
if ( // notification count (e.g. a non-mention in a Mentions-only room). The
cachedUnreadInfo && // thread path is already gated by shouldNotifyThreadReply, so it must NOT
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo)) // re-gate on the room count — otherwise an explicit per-thread "All replies"
) { // override in a Mentions-only room is silently dropped.
return; if (!threadId && getUnreadInfo(room).total === 0) return;
}
lastNotifiedEventRef.current.set(room.roomId, eventId);
const quietActive = const quietActive =
focusAssistActive || focusAssistActive ||
@@ -585,6 +688,62 @@ function ReminderMonitor() {
return null; return null;
} }
// MSC1763: opt-in local enforcement of room retention. When enabled, permanently
// redacts the user's OWN messages once a room's retention window passes. Own-only
// (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight
// redactions and retries on the next tick. Default-off, so nothing auto-deletes
// unless the user turns it on.
function RetentionSweeper() {
const mx = useMatrixClient();
const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally');
const enabledRef = useRef(enforceRetentionLocally);
enabledRef.current = enforceRetentionLocally;
const redactingRef = useRef<Set<string>>(new Set());
useEffect(() => {
const check = () => {
if (!enabledRef.current) return;
const myId = mx.getUserId();
if (!myId) return;
const now = Date.now();
mx.getRooms().forEach((room) => {
const maxLifetime = getRoomRetentionMs(room);
if (!maxLifetime) return;
room
.getLiveTimeline()
.getEvents()
.forEach((ev) => {
const evId = ev.getId();
if (!evId || ev.getSender() !== myId) return;
if (ev.isState() || ev.isRedacted() || ev.isSending()) return;
const t = ev.getType();
// Only actual messages — never our membership/topic/reactions.
if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return;
if (!isExpired(ev.getTs(), maxLifetime, now)) return;
if (redactingRef.current.has(evId)) return;
redactingRef.current.add(evId);
mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => {
redactingRef.current.delete(evId);
});
});
});
};
check();
const interval = setInterval(check, 30_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx]);
return null;
}
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck'; const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
@@ -666,9 +825,12 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PageZoomFeature /> <PageZoomFeature />
<FaviconUpdater /> <FaviconUpdater />
<PresenceUpdater /> <PresenceUpdater />
<MuteTimerRestore />
<StatusExpiryMonitor />
<InviteNotifications /> <InviteNotifications />
<MessageNotifications /> <MessageNotifications />
<ReminderMonitor /> <ReminderMonitor />
<RetentionSweeper />
<TauriUpdateFeature /> <TauriUpdateFeature />
<TauriDesktopFeatures /> <TauriDesktopFeatures />
<LotusDenoiseFeature /> <LotusDenoiseFeature />
+18
View File
@@ -31,6 +31,7 @@ import {
logoutClient, logoutClient,
startClient, startClient,
} from '../../../client/initMatrix'; } from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
import { SplashScreen } from '../../components/splash-screen'; import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@@ -43,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
import { revokeOidcTokens } from '../../../client/oidcLogout';
import { useSessionSync } from '../../hooks/useSessionSync'; import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog'; import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery'; import { AutoDiscovery } from './AutoDiscovery';
@@ -142,8 +145,23 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const useLogoutListener = (mx?: MatrixClient) => { const useLogoutListener = (mx?: MatrixClient) => {
useEffect(() => { useEffect(() => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
// Clear the SW's cached bearer token so it stops attaching the now-revoked
// token to media fetches (mirrors the manual logoutClient path).
pushSessionToSW();
mx?.stopClient(); mx?.stopClient();
// Best-effort issuer revocation for OIDC sessions (the token is already
// server-revoked here, but revoke the refresh token too). Before we drop
// the stored session below.
const loggedOutSession = getFallbackSession();
if (loggedOutSession?.oidc) {
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
}
await mx?.clearStores(); await mx?.clearStores();
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
// on server-forced logout too (token expiry / remote sign-out / password
// change) — the manual logout path already does, but this path didn't, so
// the plaintext survived on disk (and persist() makes it non-evictable).
await deleteSearchCacheDatabase();
// Remove only the session credential keys — NOT settings, drafts, and // Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above; // other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path. // window.localStorage.clear() is reserved for the explicit reset path.
+15 -4
View File
@@ -24,6 +24,7 @@ import { CreateTab } from './sidebar/CreateTab';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useTheme, ThemeKind } from '../../hooks/useTheme'; import { useTheme, ThemeKind } from '../../hooks/useTheme';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { getChatBg } from '../../features/lotus/chatBackground'; import { getChatBg } from '../../features/lotus/chatBackground';
export function SidebarNav() { export function SidebarNav() {
@@ -34,6 +35,7 @@ export function SidebarNav() {
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme(); const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark; const isDark = theme.kind === ThemeKind.Dark;
const reduced = useReducedMotion();
// backdrop-filter only blurs content directly behind the element in the z-axis. // backdrop-filter only blurs content directly behind the element in the z-axis.
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default. // The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
@@ -53,19 +55,28 @@ export function SidebarNav() {
} }
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical'; const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations); const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations || reduced);
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? ''; style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? ''; style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? ''; style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? ''; style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
// The animated body mirror (animation + will-change) exists solely so the
// glassmorphism sidebar can blur through document.body. When glass is OFF nothing
// samples this layer, yet SidebarNav is always mounted, so writing an animated bg +
// will-change here would leave a permanent invisible animated compositor layer
// app-wide. Only mirror the animation when glass is on; the static background above
// (needed by lotusTerminal / non-animated cases) is still written regardless.
if (glassmorphismSidebar) {
style.animation = (bgStyle.animation as string | undefined) ?? ''; style.animation = (bgStyle.animation as string | undefined) ?? '';
// Promote animated backgrounds to their own compositor layer so the browser
// doesn't repaint the overlaid text/UI content on every animation frame.
if (bgStyle.animation) { if (bgStyle.animation) {
style.willChange = 'background-position, background-size'; style.willChange = 'background-position, background-size';
} else { } else {
style.removeProperty('will-change'); style.removeProperty('will-change');
} }
} else {
style.removeProperty('animation');
style.removeProperty('will-change');
}
return () => { return () => {
style.removeProperty('background-image'); style.removeProperty('background-image');
@@ -75,7 +86,7 @@ export function SidebarNav() {
style.removeProperty('animation'); style.removeProperty('animation');
style.removeProperty('will-change'); style.removeProperty('will-change');
}; };
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]); }, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
return ( return (
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}> <Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
+1 -5
View File
@@ -321,11 +321,7 @@ export function Direct() {
const selected = selectedRoomId === roomId; const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile <VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem <RoomNavItem
room={room} room={room}
selected={selected} selected={selected}
+97 -19
View File
@@ -223,6 +223,7 @@ const factoryRoomIdByUnread =
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite'); const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
export function Home() { export function Home() {
const mx = useMatrixClient(); const mx = useMatrixClient();
useNavToActivePathMapper('home'); useNavToActivePathMapper('home');
@@ -261,29 +262,66 @@ export function Home() {
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomToUnread = useAtomValue(roomToUnreadAtom);
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>(); const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
const { favoriteRooms, otherRooms } = useMemo(() => { const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const favs: string[] = []; const favs: string[] = [];
const low: string[] = [];
const others: string[] = []; const others: string[] = [];
rooms.forEach((rId) => { rooms.forEach((rId) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) { if (room?.tags?.['m.favourite']) {
favs.push(rId); favs.push(rId);
} else if (room?.tags?.['m.lowpriority']) {
low.push(rId);
} else { } else {
others.push(rId); others.push(rId);
} }
}); });
return { favoriteRooms: favs, otherRooms: others }; return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
}, [mx, rooms]); }, [mx, rooms]);
const sortedFavoriteRooms = useMemo( const sortedFavoriteRooms = useMemo(() => {
() => const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
Array.from(favoriteRooms).sort( const items = Array.from(favoriteRooms).sort(
closedCategories.has(FAVORITES_CATEGORY_ID) isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx),
),
[mx, favoriteRooms, closedCategories],
); );
if (isClosed) {
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
const filteredFavoriteRooms = useMemo(() => {
if (!filterQuery.trim()) return sortedFavoriteRooms;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return sortedFavoriteRooms.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, sortedFavoriteRooms, filterQuery]);
const sortedLowPriorityRooms = useMemo(() => {
const isClosed = closedCategories.has(LOW_PRIORITY_CATEGORY_ID);
const items = Array.from(lowPriorityRooms).sort(
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
);
if (isClosed) {
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, lowPriorityRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
const filteredLowPriorityRooms = useMemo(() => {
if (!filterQuery.trim()) return sortedLowPriorityRooms;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return sortedLowPriorityRooms.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, sortedLowPriorityRooms, filterQuery]);
const sortedRooms = useMemo(() => { const sortedRooms = useMemo(() => {
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID); const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
@@ -324,7 +362,7 @@ export function Home() {
}, [mx, sortedRooms, filterQuery]); }, [mx, sortedRooms, filterQuery]);
const favVirtualizer = useVirtualizer({ const favVirtualizer = useVirtualizer({
count: sortedFavoriteRooms.length, count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 38, estimateSize: () => 38,
overscan: 10, overscan: 10,
@@ -337,6 +375,13 @@ export function Home() {
overscan: 10, overscan: 10,
}); });
const lowVirtualizer = useVirtualizer({
count: filteredLowPriorityRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId), closedCategories.has(categoryId),
); );
@@ -453,7 +498,7 @@ export function Home() {
/> />
</Box> </Box>
</NavCategory> </NavCategory>
{sortedFavoriteRooms.length > 0 && ( {favoriteRooms.length > 0 && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
@@ -466,13 +511,13 @@ export function Home() {
</NavCategoryHeader> </NavCategoryHeader>
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}> <div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
{favVirtualizer.getVirtualItems().map((vItem) => { {favVirtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedFavoriteRooms[vItem.index]; const roomId = filteredFavoriteRooms[vItem.index];
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return null; if (!room) return null;
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
key={vItem.index} key={roomId}
ref={favVirtualizer.measureElement} ref={favVirtualizer.measureElement}
> >
<RoomNavItem <RoomNavItem
@@ -611,11 +656,7 @@ export function Home() {
const selected = selectedRoomId === roomId; const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile <VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem <RoomNavItem
room={room} room={room}
selected={selected} selected={selected}
@@ -630,6 +671,43 @@ export function Home() {
})} })}
</div> </div>
</NavCategory> </NavCategory>
{lowPriorityRooms.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(LOW_PRIORITY_CATEGORY_ID)}
data-category-id={LOW_PRIORITY_CATEGORY_ID}
onClick={handleCategoryClick}
>
Low Priority
</RoomNavCategoryButton>
</NavCategoryHeader>
<div style={{ position: 'relative', height: lowVirtualizer.getTotalSize() }}>
{lowVirtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredLowPriorityRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={lowVirtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
</Box> </Box>
</PageNavContent> </PageNavContent>
)} )}
+79 -5
View File
@@ -29,8 +29,22 @@ export class CallControl extends EventEmitter implements CallControlState {
private controlMutationObserver: MutationObserver; private controlMutationObserver: MutationObserver;
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
private _pipMode = false; private _pipMode = false;
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
// it once joined (io.lotus.set_quality must not be sent before call-join — a
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
private lastQuality: LotusQualityPayload | null = null;
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
// user-initiated unmute that auto-undeafens the user.
public pttActive = false;
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed // P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send // 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 // before the fork's widget handler mounts (pre-join sends pend to a 10s
@@ -153,19 +167,43 @@ export class CallControl extends EventEmitter implements CallControlState {
// this.joined was still false, so it was gated — this is the first send.) // this.joined was still false, so it was gated — this is the first send.)
this.joined = true; this.joined = true;
this.sendDeafenState(); this.sendDeafenState();
this.sendQuality();
}
/**
* C-H1 / C-M3: re-push the sticky fork-side state (deafen + quality) after an
* EC reconnect. Unlike forceState() this does NOT touch mic/video, so a
* reconnect can't clobber the user's live media state it only re-arms the
* fork handlers that remount on reconnect.
*/
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
} }
public startObserving() { public startObserving() {
if (!this.document) return; if (!this.document) return;
// C-H3: watch the whole body subtree (not just direct children) so we
// re-bind the control observer when EC re-renders its controls deeper in the
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
this.bodyMutationObserver.observe(this.document.body, { this.bodyMutationObserver.observe(this.document.body, {
childList: true, childList: true,
subtree: false, // only direct children of body subtree: true,
}); });
this.onBodyMutation(); this.applyBodyMutation();
} }
private onBodyMutation() { private onBodyMutation() {
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
if (this.bodyMutationTimer !== undefined) return;
this.bodyMutationTimer = setTimeout(() => {
this.bodyMutationTimer = undefined;
this.applyBodyMutation();
}, 100);
}
private applyBodyMutation() {
if (!this.document) return; if (!this.document) return;
this.document.body.style.setProperty('background', 'none', 'important'); this.document.body.style.setProperty('background', 'none', 'important');
@@ -266,22 +304,43 @@ export class CallControl extends EventEmitter implements CallControlState {
this.state = state; this.state = state;
this.emitStateUpdate(); this.emitStateUpdate();
if (this.microphone && !this.sound) { // C-M5: auto-undeafen when the mic turns on, but NOT for a transient
// push-to-talk unmute — a PTT tap while deafened must not silently
// un-deafen the user.
if (this.microphone && !this.sound && !this.pttActive) {
this.toggleSound(); this.toggleSound();
} }
} }
private onControlMutation() { private onControlMutation() {
const wasScreensharing = this.screenshare;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false; const spotlight: boolean = this.spotlightButton?.checked ?? false;
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
// later screenshare doesn't start pre-muted.
const screenshareAudioMuted =
wasScreensharing && !screenshare ? false : this.screenshareAudioMuted;
// C-H3: the body observer now watches subtree:true, so this fires on any DOM
// churn in EC's controls. Only re-emit (→ re-render every consumer) when one
// of the values this method derives actually changed — microphone/video/sound
// are copied unchanged from the current state here.
if (
this.state.screenshare === screenshare &&
this.state.spotlight === spotlight &&
this.state.screenshareAudioMuted === screenshareAudioMuted
) {
return;
}
this.state = new CallControlState( this.state = new CallControlState(
this.microphone, this.microphone,
this.video, this.video,
this.sound, this.sound,
screenshare, screenshare,
spotlight, spotlight,
this.screenshareAudioMuted, screenshareAudioMuted,
); );
this.emitStateUpdate(); this.emitStateUpdate();
} }
@@ -423,10 +482,25 @@ export class CallControl extends EventEmitter implements CallControlState {
* clamped fork-side, so out-of-range input can't brick the encoder. * clamped fork-side, so out-of-range input can't brick the encoder.
*/ */
public setQuality(settings: LotusQualityPayload): void { public setQuality(settings: LotusQualityPayload): void {
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined); // C-M3: remember the request and only send once joined; sendQuality() gates
// on this.joined so a pre-join call is a no-op that we replay on join.
this.lastQuality = settings;
this.sendQuality();
}
// C-M3: push the last-requested quality to the fork. Gated on this.joined so
// we never send io.lotus.set_quality before the fork's handler mounts (a
// pre-join send would pend to a 10s widget timeout).
private sendQuality(): void {
if (!this.joined || !this.lastQuality) return;
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
} }
public dispose() { public dispose() {
if (this.bodyMutationTimer !== undefined) {
clearTimeout(this.bodyMutationTimer);
this.bodyMutationTimer = undefined;
}
this.bodyMutationObserver.disconnect(); this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect(); this.controlMutationObserver.disconnect();
} }
+17 -1
View File
@@ -57,6 +57,10 @@ export class CallEmbed {
public joined = false; public joined = false;
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null // [lotus #2] Latest per-participant state from io.lotus.call_state, or null
// until the fork sends the first one. When non-null, the speaker/mute hooks // until the fork sends the first one. When non-null, the speaker/mute hooks
// read it instead of scraping the EC iframe DOM. // read it instead of scraping the EC iframe DOM.
@@ -403,6 +407,8 @@ export class CallEmbed {
* @param opts * @param opts
*/ */
public dispose(): void { public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.disposables.forEach((disposable) => { this.disposables.forEach((disposable) => {
disposable(); disposable();
}); });
@@ -501,9 +507,19 @@ export class CallEmbed {
private onCallJoined(): void { private onCallJoined(): void {
this.settleLoad(); this.settleLoad();
this.joined = true;
this.applyStyles(); this.applyStyles();
this.control.startObserving(); this.control.startObserving();
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
// once-guard). forceState() would reset live mic/video/deafen back to the
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
// re-apply styles/observers (above) and re-push the sticky fork state
// (deafen/quality), leaving the user's live media state untouched.
if (this.joined) {
this.control.resendForkState();
return;
}
this.joined = true;
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live // EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState); this.control.forceState(this.initialState);
} }
+2
View File
@@ -3,6 +3,7 @@ import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList'; import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList'; import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread'; import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications'; import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
@@ -14,6 +15,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
useBindRoomToParentsAtom(mx, roomToParentsAtom); useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindThreadNotificationsAtom(mx, threadNotificationsAtom); useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom); useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom); useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
}; };
+65
View File
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixEvent } from 'matrix-js-sdk';
import { receiptIsMine, setMarkedUnread } from './markedUnread';
// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so
// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write
// must land on BOTH the stable `m.marked_unread` and the unstable
// `com.famedly.marked_unread` key so it round-trips across servers/clients.
const ME = '@me:server';
const OTHER = '@friend:server';
const receiptEvent = (content: object): MatrixEvent =>
({ getContent: () => content }) as MatrixEvent;
test('receiptIsMine: true when the receipt content carries our user id', () => {
const event = receiptEvent({
$abc: { 'm.read': { [ME]: { ts: 1 } } },
});
assert.equal(receiptIsMine(event, ME), true);
});
test('receiptIsMine: false when only another user has a receipt', () => {
const event = receiptEvent({
$abc: { 'm.read': { [OTHER]: { ts: 1 } } },
});
assert.equal(receiptIsMine(event, ME), false);
});
test('receiptIsMine: tolerates empty / malformed content', () => {
assert.equal(receiptIsMine(receiptEvent({}), ME), false);
assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false);
});
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
const calls: Array<{ type: string; content: unknown }> = [];
const mx = {
setRoomAccountData: (_roomId: string, type: string, content: unknown) => {
calls.push({ type, content });
return Promise.resolve();
},
} as any;
await setMarkedUnread(mx, '!room:server', true);
const types = calls.map((c) => c.type).sort();
assert.deepEqual(types, ['com.famedly.marked_unread', 'm.marked_unread']);
assert.ok(calls.every((c) => (c.content as { unread: boolean }).unread === true));
});
test('setMarkedUnread(false) clears both keys and does not reject if the unstable write fails', async () => {
const seen: string[] = [];
const mx = {
setRoomAccountData: (_roomId: string, type: string) => {
seen.push(type);
// Simulate an older server rejecting the unstable key — must not reject.
if (type === 'com.famedly.marked_unread') return Promise.reject(new Error('unknown type'));
return Promise.resolve();
},
} as any;
await assert.doesNotReject(() => setMarkedUnread(mx, '!room:server', false));
assert.ok(seen.includes('m.marked_unread'));
});
+97
View File
@@ -0,0 +1,97 @@
import { atom, useSetAtom } from 'jotai';
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../../types/matrix/accountData';
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
// the unstable `com.famedly.marked_unread`. We read either and write both so the
// flag round-trips across the ecosystem.
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
const readMarkedUnread = (room: Room): boolean => {
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
if (typeof stable === 'boolean') return stable;
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
};
/** Set of room ids the user has explicitly marked as unread. */
export const markedUnreadAtom = atom<Set<string>>(new Set<string>());
/** Write (or clear) the marked-unread flag on both the stable + unstable keys. */
export const setMarkedUnread = (
mx: MatrixClient,
roomId: string,
unread: boolean,
): Promise<unknown> =>
Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
// Best-effort mirror for older servers; never fail the primary write on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
]);
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
const content = event.getContent();
return Object.keys(content).some((eventId) =>
Object.keys(content[eventId] ?? {}).some(
(receiptType) => content[eventId][receiptType]?.[userId],
),
);
};
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
const setAtom = useSetAtom(anAtom);
useEffect(() => {
const seed = new Set<string>();
mx.getRooms().forEach((room) => {
if (readMarkedUnread(room)) seed.add(room.roomId);
});
setAtom(seed);
const syncRoom = (room: Room) => {
const marked = readMarkedUnread(room);
setAtom((prev) => {
if (marked === prev.has(room.roomId)) return prev;
const next = new Set(prev);
if (marked) next.add(room.roomId);
else next.delete(room.roomId);
return next;
});
};
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
syncRoom(room);
};
// Reading a room clears its marked-unread flag (MSC2867): when our own read
// receipt lands for a room that's currently marked, clear it.
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
const myId = mx.getUserId();
if (!myId || !readMarkedUnread(room)) return;
if (receiptIsMine(event, myId)) {
setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
}
};
const onMembership: RoomEventHandlerMap[RoomEvent.MyMembership] = (room) => {
if (room.getMyMembership() !== 'join') {
setAtom((prev) => {
if (!prev.has(room.roomId)) return prev;
const next = new Set(prev);
next.delete(room.roomId);
return next;
});
}
};
mx.on(RoomEvent.AccountData, onAccountData);
mx.on(RoomEvent.Receipt, onReceipt);
mx.on(RoomEvent.MyMembership, onMembership);
return () => {
mx.removeListener(RoomEvent.AccountData, onAccountData);
mx.removeListener(RoomEvent.Receipt, onReceipt);
mx.removeListener(RoomEvent.MyMembership, onMembership);
};
}, [mx, setAtom]);
};
+17 -1
View File
@@ -24,6 +24,7 @@ import {
getUnreadInfo, getUnreadInfo,
getUnreadInfos, getUnreadInfos,
isNotificationEvent, isNotificationEvent,
roomHaveUnread,
} from '../../utils/room'; } from '../../utils/room';
import { roomToParentsAtom } from './roomToParents'; import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -82,7 +83,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
allParents.forEach((parentId) => { allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId); const oldParentUnread = roomToUnread.get(parentId);
if (!oldParentUnread) return; if (!oldParentUnread) return;
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]); // `from` is always a Set for parent aggregates; the fallback must be an
// iterable of ids, NOT the roomId string (which would spread into chars).
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
newFrom.delete(roomId); newFrom.delete(roomId);
if (newFrom.size === 0) { if (newFrom.size === 0) {
roomToUnread.delete(parentId); roomToUnread.delete(parentId);
@@ -253,7 +256,20 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
), ),
); );
if (isMyReceipt) { if (isMyReceipt) {
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
// receipt (reading one thread) would wipe the room's still-valid
// main-timeline badge, and if the room was already read no
// UnreadNotifications PUT follows to restore it. Recompute instead —
// DELETE only when the room is genuinely fully read.
const info = getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
);
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} else {
setUnreadAtom({ type: 'PUT', unreadInfo: info });
}
} }
}; };
mx.on(RoomEvent.Receipt, handleReceipt); mx.on(RoomEvent.Receipt, handleReceipt);
+15 -1
View File
@@ -264,7 +264,21 @@ export const removeFallbackSession = () => {
// the next setFallbackSession then persists the blob. When both exist the blob // the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction. // wins by construction.
export const getFallbackSession = (): Session | undefined => { export const getFallbackSession = (): Session | undefined => {
const persisted = readSessionBlob() ?? readLegacyKeys(); const blob = readSessionBlob();
const legacy = readLegacyKeys();
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
// pre-blob build's token refresh writes only the legacy keys, so a
// downgrade→upgrade can leave a stale blob newer than fresh legacy keys →
// booting on a dead token. Whichever has the later expiresAt wins.
let persisted = blob ?? legacy;
if (
blob &&
legacy &&
typeof legacy.expiresAt === 'number' &&
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
) {
persisted = legacy;
}
if (!persisted) return undefined; if (!persisted) return undefined;
return sessionFromPersisted(persisted); return sessionFromPersisted(persisted);
}; };
+4
View File
@@ -183,6 +183,9 @@ export interface Settings {
urlPreview: boolean; urlPreview: boolean;
encUrlPreview: boolean; encUrlPreview: boolean;
showHiddenEvents: boolean; showHiddenEvents: boolean;
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
// retention window passes (default off — nothing auto-deletes by surprise).
enforceRetentionLocally: boolean;
legacyUsernameColor: boolean; legacyUsernameColor: boolean;
showNotifications: boolean; showNotifications: boolean;
@@ -288,6 +291,7 @@ const defaultSettings: Settings = {
urlPreview: true, urlPreview: true,
encUrlPreview: true, encUrlPreview: true,
showHiddenEvents: false, showHiddenEvents: false,
enforceRetentionLocally: false,
legacyUsernameColor: false, legacyUsernameColor: false,
showNotifications: true, showNotifications: true,
+37 -22
View File
@@ -1,5 +1,7 @@
export type CompressionResult = { export type CompressionResult = {
blob: Blob; blob: Blob;
/** MIME type of the produced blob (currently always image/jpeg). */
type: string;
originalSize: number; originalSize: number;
compressedSize: number; compressedSize: number;
width: number; width: number;
@@ -17,22 +19,47 @@ export function isCompressible(file: File | Blob): boolean {
return isCompressibleType(file.type); return isCompressibleType(file.type);
} }
const JPEG_OUTPUT_TYPE = 'image/jpeg';
/** /**
* Compress an image file via canvas.toBlob JPEG at the given quality. * Compress an image file via canvas.toBlob JPEG at the given quality.
* Returns null if the browser cannot render the image (e.g. unsupported codec). * Returns null if the browser cannot render the image (e.g. unsupported codec)
* or if the source is left untouched to avoid data loss (see below).
*
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
* JPEG composites transparency onto an opaque (black) background, corrupting the
* image. Returning null makes callers fall back to uploading the lossless
* original. The image is decoded with `imageOrientation: 'from-image'` so any
* EXIF orientation is baked into the pixels instead of being silently dropped.
*/ */
export async function compressImage( export async function compressImage(
file: File | Blob, file: File | Blob,
quality = 0.82, quality = 0.82,
): Promise<CompressionResult | null> { ): Promise<CompressionResult | null> {
if (!isCompressibleType(file.type)) return null; if (!isCompressibleType(file.type)) return null;
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
if (file.type === 'image/png') return null;
const img = await loadImage(file); let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
} catch {
// Corrupt/unsupported source: fall back to uploading the lossless original
// (the caller uses the original file on a null result) rather than rejecting,
// which would drop the file entirely from the Promise.allSettled upload.
return null;
}
const { width, height } = bitmap;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth; canvas.width = width;
canvas.height = img.naturalHeight; canvas.height = height;
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0); if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return new Promise((resolve) => { return new Promise((resolve) => {
canvas.toBlob( canvas.toBlob(
@@ -43,31 +70,19 @@ export async function compressImage(
} }
resolve({ resolve({
blob, blob,
type: JPEG_OUTPUT_TYPE,
originalSize: file.size, originalSize: file.size,
compressedSize: blob.size, compressedSize: blob.size,
width: img.naturalWidth, width,
height: img.naturalHeight, height,
}); });
}, },
'image/jpeg', JPEG_OUTPUT_TYPE,
quality, quality,
); );
}); });
} }
function loadImage(file: File | Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = reject;
img.src = url;
});
}
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+126
View File
@@ -0,0 +1,126 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { markAsRead } from './notifications';
// markAsRead sends an unthreaded read receipt at the latest main-timeline event,
// plus a THREADED receipt at each unread thread's latest loaded reply. The
// regression these tests guard against: a thread whose replies aren't loaded
// (lastReply() === null) must NOT produce a receipt for the thread root — that
// resolves to a MAIN receipt at an old event and permanently unreads the room.
type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boolean };
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
const thread = (id: string, lastReply: any) => ({ id, lastReply: () => lastReply }) as any;
type RoomOpts = {
timeline?: any[];
readUpTo?: string | null;
threads?: any[];
threadUnread?: Record<string, number>;
};
const setup = (opts: RoomOpts) => {
const calls: ReceiptCall[] = [];
const room = {
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
getEventReadUpTo: () => opts.readUpTo ?? null,
getThreads: () => opts.threads ?? [],
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
opts.threadUnread?.[threadId] ?? 0,
};
const mx = {
getRoom: () => room,
getUserId: () => '@me:server',
sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => {
calls.push({ eventId: event.getId(), receiptType, unthreaded });
return {};
},
} as any;
return { mx, calls };
};
test('main timeline: unthreaded receipt at the latest event', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b'), evt('c')], readUpTo: 'a' });
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], { eventId: 'c', receiptType: ReceiptType.Read, unthreaded: true });
});
test('REGRESSION: an unread thread with unloaded replies (lastReply null) sends NO root receipt', async () => {
const t = thread('$root', null); // replies not loaded
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [t],
threadUnread: { $root: 3 },
});
await markAsRead(mx, '!r:server', false);
// Only the main unthreaded receipt — never a receipt for the thread root.
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, 'b');
assert.equal(calls[0].unthreaded, true);
assert.ok(!calls.some((c) => c.eventId === '$root'));
});
test('unread thread with a loaded reply sends a threaded receipt at that reply', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
threads: [t],
threadUnread: { $root: 1 },
});
await markAsRead(mx, '!r:server', false);
const main = calls.find((c) => c.eventId === 'b');
const threaded = calls.find((c) => c.eventId === '$reply');
assert.ok(main && main.unthreaded === true);
assert.ok(threaded && threaded.unthreaded === false);
assert.equal(calls.length, 2);
});
test('main already read but a thread is unread: no main receipt, threaded receipt only', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b', // latest main event already read → getLatestValidEvent() null
threads: [t],
threadUnread: { $root: 2 },
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 1);
assert.equal(calls[0].eventId, '$reply');
assert.equal(calls[0].unthreaded, false);
});
test('everything read: no receipts sent', async () => {
const t = thread('$root', evt('$reply'));
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b',
threads: [t],
threadUnread: { $root: 0 }, // thread read too
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0);
});
test('sending thread reply is skipped', async () => {
const t = thread('$root', evt('$reply', true)); // isSending → skip
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b',
threads: [t],
threadUnread: { $root: 1 },
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0);
});
test('private receipt flag uses ReadPrivate', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a' });
await markAsRead(mx, '!r:server', true);
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
});
+36 -11
View File
@@ -1,4 +1,4 @@
import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { getSettings } from '../state/settings'; import { getSettings } from '../state/settings';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
@@ -6,6 +6,9 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return; if (!room) return;
const receiptType =
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
const timeline = room.getLiveTimeline().getEvents(); const timeline = room.getLiveTimeline().getEvents();
const readEventId = room.getEventReadUpTo(mx.getUserId()!); const readEventId = room.getEventReadUpTo(mx.getUserId()!);
@@ -17,17 +20,39 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
} }
return null; return null;
}; };
if (timeline.length === 0) return;
const latestEvent = getLatestValidEvent();
if (latestEvent === null) return;
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
if (latestEvent) {
// Unthreaded receipt: with client threadSupport enabled the SDK would // Unthreaded receipt: with client threadSupport enabled the SDK would
// otherwise scope this to the main timeline (thread_id: "main"), leaving // otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
// per-thread notification counts permanently unread. Unthreaded preserves // clears the main timeline + every event up to this one.
// the pre-threads wire behavior — one receipt clears everything. await mx.sendReadReceipt(latestEvent, receiptType, true);
await mx.sendReadReceipt( }
latestEvent,
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read, // Clear per-thread notification counts too — the room's unread dot sums them,
true, // so an unread thread reply keeps the dot lit even after the main timeline is
// read (threadSupport moves thread replies out of the main timeline, so the
// unthreaded receipt above doesn't necessarily cover them).
//
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
// NEVER fall back to the thread root: a root event is "in the main timeline",
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
// receipt at that old root event. If the root isn't in the loaded timeline it
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
// returns null -> the room is reported unread on every mark-read call (this was
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
// If a thread's replies aren't loaded (lastReply() null), just skip it.
const threads = room.getThreads();
await Promise.all(
threads.map((thread) => {
const unread =
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
if (unread <= 0) return undefined;
const lastReply = thread.lastReply();
if (!lastReply || lastReply.isSending()) return undefined;
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
// via the reply's real threadRootId; it never touches the main marker).
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
}),
); );
} }
+42
View File
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
// MSC1763 retention: `isExpired` decides whether a message is past the room's
// retention window. It must be strict (> window, not >=) and a disabled policy
// (0) must never expire anything.
const HOUR = 60 * 60 * 1000;
test('isExpired: an event older than the window is expired', () => {
const now = 10 * HOUR;
assert.equal(isExpired(now - 2 * HOUR, HOUR, now), true);
});
test('isExpired: an event within the window is NOT expired', () => {
const now = 10 * HOUR;
assert.equal(isExpired(now - HOUR / 2, HOUR, now), false);
});
test('isExpired: exactly at the boundary is NOT expired (strict >)', () => {
const now = 10 * HOUR;
assert.equal(isExpired(now - HOUR, HOUR, now), false);
});
test('isExpired: a disabled policy (0 / negative) never expires', () => {
const now = 10 * HOUR;
assert.equal(isExpired(now - 100 * HOUR, 0, now), false);
assert.equal(isExpired(0, -1, now), false);
});
test('presets: Off is 0 and the rest are strictly increasing, all >= the floor', () => {
assert.equal(RETENTION_PRESETS[0].ms, 0);
const nonZero = RETENTION_PRESETS.slice(1).map((p) => p.ms);
for (let i = 1; i < nonZero.length; i += 1) {
assert.ok(nonZero[i] > nonZero[i - 1], 'presets increase');
}
assert.ok(
nonZero.every((ms) => ms >= RETENTION_MIN_MS),
'all presets above the floor',
);
});
+32
View File
@@ -0,0 +1,32 @@
import { Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
// MSC1763 — per-room message retention (`m.room.retention`). `max_lifetime` is a
// duration in milliseconds after which a message is considered expired.
export type RetentionContent = {
max_lifetime?: number;
};
const DAY_MS = 24 * 60 * 60 * 1000;
// Floor to avoid foot-guns (an admin fat-fingering a tiny value nuking a room).
export const RETENTION_MIN_MS = 10 * 60 * 1000;
export type RetentionPreset = { label: string; ms: number };
export const RETENTION_PRESETS: RetentionPreset[] = [
{ label: 'Off', ms: 0 },
{ label: '1 Day', ms: DAY_MS },
{ label: '1 Week', ms: 7 * DAY_MS },
{ label: '1 Month', ms: 30 * DAY_MS },
];
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
export const getRoomRetentionMs = (room: Room): number | undefined => {
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
return typeof ms === 'number' && ms > 0 ? ms : undefined;
};
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
+9 -1
View File
@@ -269,7 +269,15 @@ export const getUnreadInfos = (
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) { if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined; const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
unread.push(getUnreadInfo(room, mutedThreads)); const info = getUnreadInfo(room, mutedThreads);
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
// roomHaveNotification true (the server room total includes the muted
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
// would still light the nav row + pollute "unread only" filters. Keep it
// only if there's real unread (count > 0) or a genuine unread marker.
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
unread.push(info);
}
} }
return unread; return unread;
+2
View File
@@ -12,6 +12,8 @@ export async function scheduleMessage(
content: IContent, content: IContent,
sendAtMs: number, sendAtMs: number,
): Promise<string> { ): Promise<string> {
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now())); const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`; const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`; const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
+17 -3
View File
@@ -298,9 +298,23 @@ export const deleteSearchCacheDatabase = async (): Promise<void> => {
return; return;
} }
const req = indexedDB.deleteDatabase(DB_NAME); const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => resolve(); let settled = false;
req.onerror = () => resolve(); const done = () => {
req.onblocked = () => resolve(); if (!settled) {
settled = true;
resolve();
}
};
req.onsuccess = done;
req.onerror = done;
req.onblocked = () => {
// Another tab still holds the DB open, so the delete is QUEUED, not done —
// resolving now would report a wipe that hasn't happened (plaintext still
// on disk). Wait for the real onsuccess (fires once the other tab closes;
// cross-tab logout reloads it shortly), but cap the wait so logout can't
// hang forever if a tab never releases.
setTimeout(done, 3000);
};
} catch { } catch {
resolve(); resolve();
} }
+2 -1
View File
@@ -38,7 +38,8 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
deviceId: session.deviceId, deviceId: session.deviceId,
timelineSupport: true, timelineSupport: true,
cryptoCallbacks: cryptoCallbacks as any, cryptoCallbacks: cryptoCallbacks as any,
verificationMethods: ['m.sas.v1'], // SAS (emoji) + QR-code verification (show/scan/reciprocate).
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
tokenRefreshFunction: oidcRefresher tokenRefreshFunction: oidcRefresher
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken) ? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
: undefined, : undefined,
+2
View File
@@ -2,6 +2,8 @@ export enum AccountDataEvent {
PushRules = 'm.push_rules', PushRules = 'm.push_rules',
Direct = 'm.direct', Direct = 'm.direct',
IgnoredUserList = 'm.ignored_user_list', IgnoredUserList = 'm.ignored_user_list',
// [MSC2867] Per-room "mark as unread" flag (room account data).
MarkedUnread = 'm.marked_unread',
CinnySpaces = 'in.cinny.spaces', CinnySpaces = 'in.cinny.spaces',
+2
View File
@@ -29,6 +29,8 @@ export enum StateEvent {
RoomPinnedEvents = 'm.room.pinned_events', RoomPinnedEvents = 'm.room.pinned_events',
RoomEncryption = 'm.room.encryption', RoomEncryption = 'm.room.encryption',
RoomHistoryVisibility = 'm.room.history_visibility', RoomHistoryVisibility = 'm.room.history_visibility',
// [MSC1763] Per-room message retention policy (disappearing messages).
RoomRetention = 'm.room.retention',
RoomGuestAccess = 'm.room.guest_access', RoomGuestAccess = 'm.room.guest_access',
RoomServerAcl = 'm.room.server_acl', RoomServerAcl = 'm.room.server_acl',
RoomTombstone = 'm.room.tombstone', RoomTombstone = 'm.room.tombstone',