The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved
files silently — no visual or audio confirmation. Users re-clicked because
nothing said it worked (one report: 5 copies of the same file). Add a small
useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and
route every download call site through it (file attachments, image viewer, PDF
viewer, plus the recovery-key / key-backup exports). The file-message download
button also shows a green check on success.
Toast system extended with an optional iconSrc so system toasts render an icon
instead of an avatar/initials, and an empty roomName is no longer rendered.
Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.
Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).
Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.
Tests: 700/700 pass; typecheck + prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase C.1 of the protocol-gaps roadmap, gate-green (693 tests). Generalizes the
Element Call widget host into a general room-widget feature:
- StateEvent.Widget + widgetsPanelAtom + useRoomWidgets (WidgetParser).
- RoomWidgetView: sandboxed-iframe host via ClientWidgetApi with a conservative
GeneralWidgetDriver (approves only benign display caps — no room-event
send/read/to-device). Blocks same-origin widget URLs (sandbox breakout guard).
- WidgetsPanel: list / open / add / remove, PL-gated on im.vector.modular.widgets,
https + non-same-origin URL validation. Mounted like the media gallery (header
toggle + 3-way content-panel exclusivity + mobile full-screen overlay).
- Tested URL/capability/id helpers.
Requires the prod CSP frame-src widening (matrix repo) for external widgets.
v1 cuts (capability consent prompt, Jitsi/sticker types, user widgets) noted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
- 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>
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>
- 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>
- 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>
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>
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>
Toggling custom chrome expanded the screen and sent the message feed
auto-scrolling into the past. Two causes:
- DesktopChrome used height:100vh while html/#root use 100dvh; in the Tauri
webview 100vh can exceed the visible height after decorations are stripped,
making the timeline's scroll container taller than the viewport → the virtual
paginator runs away paginating backwards. Switched to 100dvh.
- Toggling live reflowed the whole app while the timeline was mounted. The
setting now persists + reloads so the layout is rebuilt cleanly (description
updated: "reloads to apply").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- index.tsx: request navigator.storage.persist() for logged-in sessions so the
browser can't evict the IndexedDB rust-crypto store (eviction while the
localStorage session survives resurrects the device with a blank store → the
KE-1 "one time key already exists" upload storm). Guarded, checks persisted()
first, best-effort.
- Docs: remove HANDOFF_ELEMENT_CALL_FORK.md, LOTUS_E2EE_INVESTIGATION.md, and
LOTUS_BUGS.md. Port their live content into the three kept docs — verification
backlog → LOTUS_TESTING; open bugs + E2EE (KE-1..4) + an Element Call fork
operational reference (publish steps + io.lotus action catalog) → LOTUS_TODO.
Fix all dangling references (README, code comments, cross-doc links). Full
history of the removed docs remains in git.
Gates: tsc/eslint/prettier clean, build OK, 665 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forward: checkbox multi-select room picker + "Send to N rooms" batch send
(Promise.allSettled). Full success auto-closes; partial failure keeps the dialog
open with a "Forwarded to X/N — failed: …" summary and prunes the selection to
only the failures (retry won't duplicate to already-sent rooms). Content builder
extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip,
undecryptable-refused; 4 tests).
Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent)
so previews reflect edits and show a deleted indicator for redactions; the stored
snapshot stays as the fallback while loading, on fetch failure, or after leaving
the room. Stored bookmark shape unchanged.
Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on
retry + Checkbox readOnly fixed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CallControl now sends the new io.lotus.set_deafen action (join-gated via
forceState) on every deafen / screenshare-audio-mute toggle + on join, ALONGSIDE
the retained iframe-DOM .muted hack (transitional). Against the current pinned
bundle the action is immediately error-replied + swallowed by .catch — inert, no
timeout. Reordered toggleSound() to commit state before setSound() so the sent
deafen value isn't inverted.
Phase 2 (after the fork is published): bump the pin lotus.1 -> lotus.2 and delete
the DOM hack. Docs: HANDOFF §12.4, LOTUS_TODO P6-2, LOTUS_BUGS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- `?` shortcut now stopImmediatePropagation so RoomView's type-to-focus handler
doesn't steal focus into the composer behind the dialog (and swallow Escape) —
CONFIRMED review finding.
- Typing live region stays mounted (empty when idle) so the FIRST "X is typing"
is reliably announced (a status region added with its text isn't always read).
- Removed a stray empty `{}` JSX expression in MediaGallery (leftover from an
auto-fix).
Reviewer verified the rest: collapsed-message labels, focus-return
classification (4 dialogs fixed, popouts correctly left), and all aria fixes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Focus returns to the trigger when closing 4 genuine dialogs (room-topic
viewer, reaction viewer, header topic, Search) — 20 inline popouts/menus
correctly left as-is (returning focus to a hover target would be wrong).
- Typing indicator announced via a visually-hidden role="status" region;
the visual text is aria-hidden to avoid double announcement.
- New keyboard-shortcuts help dialog (press ?, ignored while typing),
mounted in ClientNonUIFeatures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accessible names for ~15 controls that lacked them: invite/join/create-room/
account-data/image-pack/private-note/power-level inputs (visible <label htmlFor>
where a label exists, else aria-label); the two range sliders (night-light
intensity, noise-gate threshold); the soundboard file input; media <video>
elements; and the Media Gallery (region) + Search (dialog) overlays. Hidden
notification/preview <audio> marked aria-hidden.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Each message is role="article"; collapsed messages (consecutive from one
sender) now carry an aria-label with sender + time — previously a screen
reader heard only the body with no attribution (the biggest a11y gap).
Pure messageAriaLabel() reuses the existing time utils (+3 tests).
- Editing a message announces "Editing message from <sender>" (ariaLabel
threaded MessageEditor → CustomEditor; the main composer is unaffected).
- System emoji get role="img" + aria-label from the shortcode; custom
emoticons always have an accessible name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HEAD-checks the copied denoise worklet/wasm/model assets for the selected model
and console.warns a single line listing anything missing — a silent asset skew
between the EC fork's expectations and vite's copied files would otherwise
disable noise suppression with no signal. Fire-and-forget; never blocks call
setup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- emojibase (~965 KB) is now fully lazy: plugins/emoji.ts loads compact data +
shortcode maps via a memoized dynamic import (rejections reset the memo so a
mid-deploy chunk 404 can retry); reaction labels degrade to the raw glyph
until loaded. Consumers get FRESH array references on load (the module arrays
populate in place — same-ref state updates would skip re-render and leave
emoji search empty; reviewer-caught). Verified out of the eager graph.
- Service worker precaches hashed assets (workbox precacheAndRoute, 82 entries
~10.8 MB incl. the crypto wasm): repeat visits stop re-downloading the app.
index.html is NOT precached — navigations stay network-first so deploys are
picked up immediately; the media-auth fetch handler is untouched.
- ReactPrism: curated 21-language set — chunk 574 KB → 71 KB.
- Timeline inline images get loading="lazy".
- Removed dead dompurify (+types); sanitize-html is the real sanitizer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ScheduledMessagesTray: cancel prunes local state ONLY on confirmed server
cancel; failures keep the item + show an inline error (was: a failed cancel
looked cancelled but still sent at the scheduled time).
- Escape semantics: the composer consumes Escape (preventDefault+stopPropagation)
iff autocomplete is open or a reply draft is set; the thread panel and Room's
markAsRead act only on unconsumed Escape, and markAsRead defers entirely while
a thread panel is open (listener order made it fire before the panel closed).
- Room: thread panel / media gallery are mutually exclusive (most-recently-
opened wins); on mobile at most one right panel renders (thread > gallery >
members) instead of stacked fullscreen overlays.
- RemindMeDialog: busy-disabled presets (no more double-click duplicates),
try/catch with inline error, close only on success.
- ThreadTimeline: "Jump to Latest" floating chip when scrolled up (RoomTimeline
idiom).
From the 4-auditor deep-audit wave; reviewer-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deletions from the git-mv in 992d2b83 were unstaged by a concurrent
worktree operation before commit, so the pushed tree contained BOTH
threadSummary.ts and threadSummaryData.ts (and the Windows case-collision
persisted). This commit removes the stale originals; caseCollision.test.ts
would have failed CI on the incomplete state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
threadSummary.ts (pure helpers) and ThreadSummary.tsx (chip component) lived in
the same directory differing only by case. On the case-insensitive Windows
release runner, RoomTimeline's extensionless import of ./thread/ThreadSummary
resolved .ts BEFORE .tsx and matched the helper module → rolldown
MISSING_EXPORT "ThreadSummary" — invisible on every Linux/macOS build (and the
cause of the earlier masked pdf.worker failure). Helper module renamed to
threadSummaryData.ts (+ test), 3 importers updated.
Prevention: new caseCollision.test.ts walks src/ and fails on any same-directory
names differing only by case (extensionless compare, so Foo.tsx vs foo.ts is
caught) — verified it fails on the pre-rename tree. Runs in the hard CI gate.
Gates: tsc clean, eslint/prettier clean, build OK, 658/659 tests (1 IDB skip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Soundboard v2 — a near-parallel of the custom-emoji image-pack system for
in-call audio clips.
- Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event
io.lotus.soundboard, inherited by child rooms via parent-space aggregation),
global refs (io.lotus.soundboard_rooms), and the personal pack
(io.lotus.soundboard account data; the v1 flat-list content is migrated to the
pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) +
hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room,
deduped). Unit-tested (migration + slug).
- Management: reusable SoundboardPackEditor (name + emoji + per-clip volume +
delete + upload + batched save), power-level-gated for room packs like emoji
packs; a Soundboard page wired into Room + Space settings.
- In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack
(emoji + name tiles), sourcing room+parent-space U personal clips; a Manage
toggle embeds the editors; per-clip volume x master volume on playback.
- Spam guard: host gates on a playing key (fork enforces one clip at a time).
- Control bar: Mute-Screenshare moved next to the Screenshare button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Audit of ForwardMessageDialog, fixes:
- Search input was intrinsic-width (sat in a default Row Box with no grow) —
now a Column Box stretches it full-width, matching every other search input.
- Search field is auto-focused on open (FocusTrap initialFocus; was nothing).
- Edited messages now forward the LATEST edit (m.new_content via
getEditedEvent) instead of the stale pre-edit body.
- Reply fallbacks stripped (trimReplyFromBody + <mx-reply> block) along with
m.relates_to, so forwards stand alone instead of quoting the old room.
- Undecryptable events are refused with an inline error (previously forwarded
m.bad.encrypted junk); send failures now show an error instead of silently
resetting.
- sendEvent uses the typed threadId-aware overload (explicit null) instead of
an untyped (mx as any) call relying on the SDK's legacy arg-sniffing.
- Room list + filter memoized (was re-sorting all rooms every keystroke).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- useRoomsListener now PREPENDS the emitting Room (was appended): the SDK emits
RoomEvent.UnreadNotifications with VARIABLE arity (0/1/2 args), so a trailing
extra arg landed in the wrong positional slot on the most common room-count
sync path — room.isSpaceRoom() threw inside the SDK emit loop and the badge
PUT never ran. Both consumers updated (CONFIRMED HIGH review finding).
- roomToUnread: SpaceChild RESET now passes the thread prefs so muted-thread
subtraction survives space-child state changes.
Reviewer also verified: badge subtraction math exact (no double-subtraction),
encrypted thread replies caught by the timeline guard (m.relates_to is
cleartext), fresh prefs flow to handlers, single-owner wiring load-bearing.
Documented-acceptable: hasCurrentUserParticipated can lag until the server
bundle refreshes after your first reply; dedupe maps grow per-session only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default = Participating: thread replies notify only when you've posted in the
thread or are @mentioned; per-thread override to All / Mentions-only / Mute via
a bell menu in the thread panel header. Modes sync across devices in
io.lotus.thread_notifications account data (pruned on write: left rooms, >180d,
cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed
(+BellMute glyph), and their counts are subtracted from the room's sidebar
badge (client-side; clamped ≥0).
Also fixes the thread notification path itself: thread replies are now owned by
exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener
hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS
tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and
the unread binder are explicitly thread-guarded, eliminating the previously
un-gated/double path. Room badges now also refresh live on
RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag).
Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread
subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may
under-notify pre-decryption (same class as the existing path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two-reviewer audit of the thread stack; confirmed findings fixed:
- ThreadTimeline: wrap encrypted events in EncryptedContent so a live-arriving
E2EE reply re-renders when its key decrypts (decryption emits neither
RoomEvent.Timeline nor ThreadEvent.Update — previously stuck at "Unable to
decrypt").
- ThreadPanel: mark-read deduped on the latest event id (RoomEvent.Timeline
re-emits per backfilled event/edit/reaction; previously up to N receipt POSTs
per panel open) + rejection handled with retry.
- RoomTimeline: ThreadSummary chips now mount only for events carrying thread
data (each chip holds a room-level listener; one per rendered message would
blow the SDK's 100-listener emitter cap) with a single room-level
ThreadEvent.New tick for new-thread liveness.
- useThreadPendingEvents: keep a sent reply visible through the /send-response→
/sync window (was flashing out of the pending strip before landing).
- ThreadTimeline: reseed the window on RoomEvent.TimelineReset (gappy sync left
a detached timeline).
Documented-acceptable (reviewer-noted): thread typing shows as room typing (no
per-thread typing in the spec; Element matches), thread panel + members drawer
can be open together, scheduled-send is thread-unaware but unreachable there.
Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-side thread drawer (MembersDrawer pattern; mobile fullscreen):
- ThreadPanel: header + close/Escape, ThreadTimeline, its own RoomInput
(threadRootId prop; drafts/replies/uploads isolated per roomId::threadId;
schedule + slash-commands off in threads v1) and threaded mark-as-read.
- ThreadTimeline: lean reimplementation over thread.liveTimeline — copied
useTimelinePagination pattern (/relations back-pagination + decryption),
virtualized, root event emphasized + "N replies" divider, reactions/edits/
redactions, and a pending strip (chronological local echo never enters the
thread timelineSet — rendered from LocalEchoUpdated instead).
- ThreadSummary chips on root messages (server-aggregated bundle or live
Thread; unread badge via getThreadUnreadNotificationCount) keep threads
discoverable now that replies leave the main timeline.
- Reply-in-Thread menu + thread indicators open the panel; deep links to
thread events redirect into it.
- State: roomIdToActiveThreadIdAtomFamily + getThreadDraftKey (+18 tests).
Gates: tsc clean, eslint 0 errors, build OK, 616/617 tests (1 IDB skip).
Awaiting live QA; release note: threaded replies no longer render inline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
threadSupport:true makes matrix-js-sdk partition m.thread relations into Thread
objects (replies leave the main timeline; roots stay). markAsRead now sends
UNTHREADED receipts so one receipt still clears room + thread notification
counts — without this, badges would stick unread. The thread panel + summary
chips land in the same push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LOTUS_E2EE_INVESTIGATION.md: per-KE capture runbook (console signatures, synapse
log greps + SQL against the documented LXC deployment, the KE-1⇒KE-2 causality
decision tree, ranked remediations incl. what a crypto-store reset wipes; SDK
finding: stable 41.6.0 has no OTK fix over our RC pin). Client: capture-only
console ring buffer (cryptoDiagLog, KE-signature-matched, max 200) + a Crypto
Diagnostics card in Developer Tools with a download-report button. ClientRoot
installs the capture hook at module load and mounts useSessionSync (cross-tab
sessions, prior commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session now persists as ONE atomic cinny_session_v1 JSON write (blob-wins read,
transparent migration from the ~10 legacy keys, dual-write kept one release for
rollback). subscribeSessionChanges + useSessionSync reload a tab whose session
was changed/removed by another tab (logout/login/token rotation). OIDC refresher
already routes through setFallbackSession, so rotations stay atomic. Tests 7→22.
Full token-protection redesign remains tracked in LOTUS_BUGS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Raw-IndexedDB cache (lotus-search-cache: messages keyed [roomId,eventId] +
per-room coverage) merged into local search with in-memory-wins dedupe. OPT-IN
(default off) via a standalone atom — stores decrypted text at rest, so it ships
with a privacy note, a Clear button, and an unconditional wipe on logout
(initMatrix). All IDB errors degrade to cache-miss. +8 tests (1 IDB skip in node).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renders LaTeX via spec data-mx-maths spans/divs (KaTeX render of the attr,
children as fallback) and conservative $…$ / $$…$$ text detection (escape-aware,
currency-guarded, never inside code/pre). KaTeX + CSS load lazily on first math
(ReactPrism pattern) — verified absent from the eager bundle. Sanitizer
unchanged by design (we render post-sanitize from attr/text; no incoming MathML
accepted). +14 unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The accent previously only overrode the folds Primary.* family; links kept the
hardcoded --tc-link blue, ::selection was browser-default, and focus rings were
neutral grey (Other.FocusRing). Now all three derive from the chosen base color:
- --tc-link → accent hex (messages, topics, URL previews)
- ::selection via an injected <style id="lotus-accent-style"> (accent bg +
WCAG-contrasting text)
- Other.FocusRing → rgba(accent, 0.5)
Deliberately NOT recolored: Secondary.* (doubles as the neutral text/button/
badge palette), Success.* + mention pills (semantic mention/notification green),
scrollbar thumbs (folds styles them per-component; a global rule would only
half-apply). removeCustomAccent() clears everything — no residue when switching
off or to the TDS theme. +2 unit tests (561 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
data-tauri-drag-region only fires when the exact element is the event target
and was never runtime-verified; replace it with the official Tauri custom-
titlebar recipe — primary-button mousedown starts an OS drag, detail===2
toggles maximize. Works across the whole region (brand text included, which
already passes pointer events through).
Pairs with cinny-desktop set_custom_chrome Mica fix (clear backdrop before
undecorating; window-state no longer restores the decorated flag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- fileEntries: a single unreadable file/dir in a dropped folder no longer aborts
the whole traversal (try/catch per entry, skip failures) — was discarding ALL
dropped files (incl. the flat-file path) + an unhandled rejection; also add
.catch in both useFileDrop consumers.
- RoomInput: mirror a localStorage-restored draft into the draft atom so the
P5-57 indicator reflects a persisted draft after a page reload, not only on
same-session room re-entry.
- useTauriThumbbar: swallow toggleMicrophone()/hangup() rejections (parity with
SMTC) — avoids an unhandled rejection when clicked mid-teardown.
- App/DesktopChrome: keep wrapper element types stable across the chrome toggle
(display:contents when off) so flipping it no longer remounts RouterProvider.
- settings: normalizeComposerToolbarOrder also appends missing keys from the
canonical key set (safety net if a new button is absent from the default order).
Gates: tsc/eslint/prettier clean, build OK, 559 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Web half of the desktop feature wave. A shared bridge (`hooks/useTauri.ts`:
invokeTauri/isTauri/useTauriEvent) backs per-feature hooks that no-op in the
browser and drive the native Tauri commands (compiled in cinny-desktop):
- P5-46 useTauriCallPower — hold system awake while a call is active.
- P5-36 useTauriJumpList — Windows jump list of recent rooms → matrix: deep links.
- P5-44 useTauriThumbbar — taskbar Mute/Deafen/End; events toggle mic/sound/hangup.
- P5-43 useTauriSmtc — SMTC call state + button events.
- P5-49 useTauriNetwork — react to native network-change → mx.retryImmediately().
- P5-47 window chrome — opt-in `customWindowChromeAtom` + TDS `TitleBar`; DesktopChrome
wrapper in App.tsx (zero layout impact when off) + a desktop-only settings toggle.
- P5-55 composer toolbar drag-reorder (settings order[] + pragmatic-drag-and-drop).
- P5-57 DraftIndicator — subtle "draft saved" cue in the composer.
Client-scoped hooks mount via TauriDesktopFeatures in ClientNonUIFeatures; window
chrome mounts at App level. Gates: tsc/eslint/prettier clean, build OK, 556 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CallEmbed sets `autoGainControl=false` for the ML noise-suppression tier so
the browser's auto gain control doesn't fight the in-source ML model; the
browser/off tiers keep AGC on.
- Docs: refresh the LOTUS_FEATURES noise-suppression section (browser-native
default, quality-ordered dropdown, DFN3 ML default, attenuation floor,
gate-after-ML, DFN level 60, AGC-off, the reliability fixes) and LOTUS_TODO
P5-30 (mark tuning/reliability/AGC done; record GTCRN as researched-and-deferred).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Model dropdown is now ordered by quality/CPU, best first (DeepFilterNet 3 →
DTLN → RNNoise → Speex); fix RNNoise's inaccurate "High" voice-quality label.
- When a user opts into the ML tier, default to the highest-quality model
(DeepFilterNet 3). The tier default stays browser-native (known-good, best
perceived in testing so far).
- Wire the "Series Suppression" (native-NS-before-ML) toggle into the real call
path — it was applied only in the settings tester, so the tester could sound
better than the actual call. Default it OFF (a single NS stage is best
practice; it's an opt-in test aid).
- isMLDenoiseSupported now also requires WebAssembly, so ML isn't offered on
strict-CSP shells where it would silently fall back to the raw mic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Element Call is now consumed as our self-built fork
(@lotusguild/element-call-embedded); wire up its previously-dormant
capabilities and document the fork as live.
Soundboard (P5-15): a call-bar button plays user-uploaded audio clips into the
call as a real published track (io.lotus.inject_audio) plus local playback.
Clips are uploadable like emoji/sticker packs, stored in io.lotus.soundboard
account data (synced across devices). Gated by a Settings toggle + volume.
Quality controls (P5-31): per-user mic/screenshare bitrate + screenshare
framerate (Settings -> Calls), applied via io.lotus.set_quality clamped to any
room cap. Room admins set caps and hard call-permissions (allow_screenshare /
allow_camera) in Room Settings -> Voice; the call bar hides blocked buttons.
- New: CallSoundboard, useSoundboard, soundboardClips; RoomQuality,
useCallQuality, callQuality (+ unit tests).
- Optimistic-write RoomQuality admin UI (no stale-state clobber).
- Docs: mark EC fork live across README/FEATURES/TODO/BUGS/TESTING; add D2
manual-test steps.
Numeric quality caps are client-cooperative; screenshare/camera permissions are
hard-enforced server-side (see LotusGuild/matrix voice-limit-guard).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>