A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.
Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
Conservative: never suppresses when the read marker is off-window, the tail
is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
the request (the only SDK-durable lever), once per room per session, gated on
the same verification-only predicate so it can never ack a real message.
unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.
Reviewed by 5 agents; the thread-scope guard closes the one bug they found.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Toast queue: a burst of notifications appended unboundedly and could cover the
viewport. Cap at 5 in the atom writer, dropping the OLDEST non-sticky toast
(sticky = action toasts requiring a click, never dropped). The drop scan
excludes the just-appended newest (`length - 1` bound) so a fresh toast is
never the one eaten when the cap is full of stickies — it stretches instead.
Container gains a maxHeight + overflowY safety net and scrolls the newest
(bottom) toast into view if the stack ever overflows. +4 unit tests incl. the
cap-full-of-stickies boundary.
- "Unread First" room sort left the entire read tail (all counts tie at 0) in
arbitrary Map order. factoryRoomIdByUnread now breaks ties by recent activity.
Relocated from Home.tsx (module-private) to utils/sort.ts (exported, pure) and
unit-tested (equal-count and read-tail cases fall back to activity).
Bug-hunt findings from LOTUS_TODO. Three review passes: the second caught that
the cap could silently drop the newest notification when full of stickies (real
bug, untested boundary) — fixed and covered; a third traced the corrected loop.
Gate-green (tsc, eslint, prettier, 920 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node
runs each test file in its own process, so the console patch can't leak).
- cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature
capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error /
object / circular-arg serialization (String() fallback never throws), the
200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics,
install idempotency, and buildCryptoDiagReport's client metadata + LOCKED
PII-safe key set (no field can silently leak) + no-client/throwing-getter
fallbacks. Silences console pass-through so the ring-buffer test stays quiet.
- closedLobbyCategories.test.ts: mirrors closedNavCategories — id join,
hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence,
per-user key namespacing.
Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry;
shipped as element-call:lotus e36aef8a).
Gates: tsc 0, eslint 0, prettier clean, 911 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
COR-3 (CallEmbedProvider): the incoming-call lifetime guard distrusted a
caller's sender_ts only when it was >20s AHEAD of the server ts. A caller clock
that ran SLOW left sender_ts in the past, so the ring auto-dismissed/never
showed for a fresh invite. Trust sender_ts only within ±20s of the server ts,
else fall back to it (also fixes a NaN path when sender_ts is missing).
COR-6 (CallControl): forceState rebuilt CallControlState with 5 args, silently
defaulting screenshareAudioMuted to false; pass this.screenshareAudioMuted.
COR-5 (uploadContent + useBindUploadAtom): cancelling during the retry back-off
was a no-op (mx.cancelUpload only aborts an in-flight request), so the upload
resurrected on the next attempt. Thread an AbortSignal: the back-off sleep
resolves early on abort and the loop stops with an abort error; the hook aborts
a per-upload AbortController on cancel (alongside mx.cancelUpload for the
in-flight case).
All verified by two review passes (no double-settle / no resurrection); includes
their suggested abort-listener cleanup on normal sleep resolution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a single m.space.child was removed (unlinking child C from space P), the
roomToParents reducer fired the whole-room DELETE action, which wiped C's
entire parent set, stripped C as a parent from every other room, and orphaned
C's own descendants until a full resync. So removing C from space A also
dropped C's other parent B, and C's children lost C.
Add a targeted UNLINK {parent, child} action that removes only that one
parent->child edge and prunes the child entry only when its parent set
empties (matching the map's build-time invariant that zero-parent rooms have
no entry). Point the invalid-child branch of handleStateChange at it; DELETE
is unchanged for genuine room leave/delete. Unit-tested (keeps other parents,
prunes on last parent, does NOT orphan descendants, unknown pair no-op).
Verified correct + consumer-safe by two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several localStorage caches held decrypted message content or user PII and
survived a normal logout, leaving residue on a shared device (the search
index was already wiped; these were not):
- cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends
- cinny_recent_searches_v1 - search query text
- cinny_recent_forward_targets_v1 - recent forward contact/room graph
- cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent
- navToActivePath<userId> - per-space last-visited room paths
- (plus the translation cache added earlier)
Add a clear function per module and a single auditable clearPlaintextCaches()
aggregator, called from both logout paths (logoutClient + the server-forced
SessionLoggedOut handler) alongside the existing session/search-index wipes.
Unit-tested.
Deliberately NOT cleared (documented in the aggregator): unsent composer
drafts and the presence status message (preserved by product decision N98);
SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes),
already wiped by mx.clearStores(); low-sensitivity UI/metadata residue.
The forward-targets/gifs/stickers/nav-path additions and the accurate
"not covered" documentation address findings from two review passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up hardening from two review passes on the on-device translation
feature:
- Privacy (HIGH): the translation cache is decrypted message plaintext,
but logout did not clear it (unlike the search index), leaving up to
300 cleartext bodies in localStorage on shared devices. Add
clearTranslationCache() and call it from both logout paths
(logoutClient and the server-forced SessionLoggedOut handler).
- Edited messages (MEDIUM): the cache key was eventId:target with no
content dependence, so an edit reused the pre-edit translation. Fold a
content fingerprint into the key, and re-arm the auto-translate
one-shot when the text changes.
- Settings (LOW): coerce a persisted translateTargetLang to a supported
curated code so the hook never targets a language the engine can't
produce (previously only the UI clamped it).
- Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at
the Translator API boundary, since normalizeLang lowercases the script
subtag for internal keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.
- Engine abstraction (utils/translation): TranslationEngine interface
plus a chromeTranslationEngine implementation (feature-detected,
caches translator/detector instances, download-progress monitor).
Pure lang-code helpers (normalize/sameLanguage/curated targets) with
unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
(opt-in), with a Messages settings tile — a target-language select
and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
"Translated from <lang> - Show original" chip, and a body swap in
m.text/m.emote/m.notice that renders the translated text through the
plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
downloaded; first-time downloads keep the manual chip (user gesture).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the
shared EmojiBoard did not — you had to hunt through packs to re-send a sticker.
Add recent stickers, mirroring recentGifs:
- New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by
url, capped 16) + pure addRecentSticker with 4 unit tests.
- EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar
icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into
minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they
render + re-send like pack stickers.
- Recorded on select in the shared delegated click handler, covering both the
grouped and search paths.
Blast radius is the sticker tab only (reactions/status use the emoji tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual DND only existed via the desktop tray (manualDndAtom), so web/mobile
users had no way to pause notifications, and there was no snooze-for-a-duration
anywhere. Add a "Pause Notifications" control in Settings > Notifications:
- Presets: 30 min / 1 hour / 4 hours / Until 8 AM / Until I resume, plus Resume;
live "Paused until ..." status that flips back on when the snooze lapses.
- Persisted snooze instant (cinny_notification_snooze_until_v1) so it survives a
reload; 0 = off, SNOOZE_INDEFINITE = until resumed.
- Feeds the existing notification gate (ClientNonUIFeatures, both the message and
invite monitors) alongside Focus Assist / manual DND / Quiet Hours, suppressing
notify() and playSound().
- Pure helpers isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITE in utils/snooze.ts
(+5 unit tests); persisted atom in state/notificationSnooze.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles itself on/off
automatically (the overlay re-checks every minute; no reload). Overnight windows
that wrap midnight (e.g. 21:00 -> 07:00) are handled.
Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the recent-GIFs row:
- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
once (autoplaying). Capture a small still image at pick time
(fixed_width_small_still / *_still) into RecentGif.previewUrl and render
that for the thumbnail, so recents no longer autoplay. Pre-existing
recents without a preview fall back to the animated url. Re-send still
uses the animated url, so the sent m.image is unchanged.
- a11y: the recent buttons all had the identical label "Send recent GIF".
Give them positional labels ("Send recent GIF N of M") and wrap the grid
in a role="group" labelled by the "Recent" section heading, so the row
is a distinguishable, announced group.
Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GIF picker was a bare Giphy search grid with no memory of what you've
sent, so re-sending a go-to reaction GIF meant re-typing the search every
time. Add a "Recent" row at the top of the picker (default view; hidden
while searching) for one-click re-sending.
- New persisted state state/recentGifs.ts: recentGifsAtom (localStorage,
cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url
move-to-front, cap 16, ignore empty url), with 5 unit tests.
- GifPicker records every sent GIF (from search or the Recent row) to the
front, and renders a 3-up thumbnail grid of recents above the search
grid when there are recents and no active search term. Section label
matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal)
or a muted label otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).
- Lists every thread with a rich row: root sender + snippet, unread dot,
"N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
Oldest by last-reply time), both persisted in localStorage
(cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
and is virtualized (@tanstack/react-virtual) for busy rooms.
Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Forward dialog forwarded blind. Add three things (design-system
cleanup included):
- Preview: a compact read-only preview at the top of the dialog shows the
sender + body, with a thumbnail for image/video (reuses ThumbnailContent
and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We
already hold mEvent, so nothing is fetched.
- Comment: an optional "Add a comment" field sends a short m.text note to
each target room, sequenced BEFORE the forwarded message per room so the
note reads above the quoted content. The existing per-room failure /
retry logic is preserved (a room fails if either send rejects).
- Recent targets: a "Recent" chip row (hidden while searching) offers
one-tap selection of rooms you last forwarded to. Successful targets are
recorded most-recent-first, deduped, capped at 8, in localStorage via the
pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts).
Rooms you've since left are filtered out of the row.
Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a
token-free opacity dim of the list (design-system rule: no hardcoded
colors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add centralized typed helpers in src/app/utils/accountData.ts:
- getAccountData<T>(mx, eventType): T | undefined (returns content)
- setAccountData<T>(mx, eventType, content): Promise<void>
These wrap the single `as any` cast needed because matrix-js-sdk's typed
overloads reject the fork's custom account-data event names. Every call site
now stays fully typed on its content shape.
Route all account-data reads/writes that previously used
`(mx as any).getAccountData/setAccountData` or `mx.getAccountData(... as any)`
through the helpers (or, where a MatrixEvent is needed, through the existing
utils/room.ts getAccountData whose param is widened to accept string keys).
No behavior change: same event types, same content shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several fire-and-forget promises failed silently:
- RoomInput slash-command exe() rejections reset the editor as if they
succeeded; now caught and shown via an error toast.
- Favourite / low-priority room-tag toggles (setRoomTag/deleteRoomTag)
swallowed rejections; now surfaced via the same error toast.
- Call-decline sendEvent(RTCDecline) was uncaught; now logged best-effort
while the local UI still dismisses.
Adds a shared createErrorToast builder mirroring createDownloadToast.
/kick and /ban route through rateLimitedActions, whose to() helper
swallows non-429 errors, so those two can still resolve on failure — noted
in code; the top-level exe() catch covers everything that does reject.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only
opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a
privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail
+ a play button, and only on click does it swap in the cookie-less
youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until
the user presses play. Gated by a new 'Inline Media Players' setting (default on);
when off it falls back to a link that opens the video in a new tab.
Also sources YouTube thumbnails from the homeserver og:image instead of
img.youtube.com, which fixes the existing broken YouTube thumbnails on the web
build (nginx img-src has no YouTube host) and removes the pre-click Google request.
Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested).
Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate
commit in cinny-desktop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
- 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>
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>
- 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>
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>
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>
- 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>
- 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>
setFallbackSession gains an optional `extra` arg (password call sites unchanged)
persisting cinny_refresh_token, cinny_expires_at (absolute), and
cinny_oidc_{issuer,client_id,redirect_uri,id_token_claims}. getFallbackSession
reads them back (expiry as remaining lifetime); removeFallbackSession + re-save
clear stale OIDC keys. Session type gains `oidc?: OidcSessionMeta`. +2 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Via subagent, verified against real behavior (all use jotai store + enableMapSet):
- state/list (11): createListAtom PUT/DELETE/REPLACE (single + array, identity).
- state/room/roomToParents (10): INITIALIZE/PUT/DELETE incl. cycle-skip and
orphan-cleanup pruning of zero-parent children.
- state/room/roomToUnread (22): unreadInfoToUnread, unreadEqual, and the
roomToUnreadAtom reducer — leaf/overwrite/equal-guard, multi-level parent
roll-up with `from` recording, RESET rebuild, DELETE subtract/prune.
No bugs (noted a latent never-hit string-spread in deleteUnreadInfo's `from ??
roomId` fallback; left as-is). Suite growing toward full pure-logic coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Via subagent, all verified against real behavior:
- state/sessions (5): fallback-session round-trip across the four cinny_* keys,
missing-key → undefined for each required key, removeFallbackSession clears all.
- state/recentSearches (6): addRecentSearch prepend, case-sensitive dedupe +
move-to-front, trim, ignore empty/whitespace, cap at 10.
- state/upload (6): the createUploadAtom reducer driven through a real jotai
store — idle→loading→progress(gated)→success/error, file ref preserved.
No bugs found.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prevention work found a real bug: getSettings() runs at module load, and its
catch block called localStorage.removeItem() — but we often reach that catch
*because* localStorage access threw (blocked storage / private mode / sandboxed
context). The removeItem then re-threw, producing an uncaught error that crashed
the whole app at startup. Guarded the cleanup in its own try/catch.
New state/settings suite (6) covers the legacy-boolean callNoiseSuppression
migration, denoise-model/ringtone-id coercion of unknown values, default merge,
malformed JSON, and the blocked-storage regression.
Full suite now 129 tests, all passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a customAccentColor setting + a HexColorPickerPopOut in Settings →
Appearance. When set (and Lotus Terminal/TDS is OFF), it derives a full folds
Primary palette (Main/hover/active/line, contrasting OnMain, alpha-tiered
Container set, OnContainer) from the chosen color and overrides the folds
Primary CSS variables on document.body — resolving each var name from the
imported folds color.Primary.* token strings (e.g. "var(--oq6d07f)"), the
same body-level injection pattern used for mentionHighlightColor. The theme
class is on document.body, so an inline override on body wins over it.
Reverts to theme defaults when unset or when Lotus Terminal is enabled (TDS
keeps its fixed palette); the picker is disabled with a note in TDS mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add three msgtype toggle chips (Images/Files/Video) to the search filter
bar, mirroring the existing "Has link" chip. The Matrix search API can't
filter by msgtype server-side, so results are post-filtered client-side
(union match on event.content.msgtype, dropping now-empty groups); the
server request is unchanged. Visible count may be lower than the server
total — inherent to client-side filtering.
- Recent searches: last 10 distinct terms persisted via a new
state/recentSearches.ts (atomWithStorage, error-safe, mirrors
scheduledMessages). Shown as clickable chips when the search input is
focused + empty, with a Clear affordance; clicking re-runs the search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add sticky?: boolean to ToastNotif — sticky toasts skip the 4s auto-dismiss
timer entirely, staying until the user clicks or manually dismisses. Sticky
toasts also use cyan accent/glow (vs orange for messages) and allow the body
to wrap rather than truncate, so longer action-oriented copy is fully readable.
Update the Tauri update toast to: sticky: true, ⬆ prefix on the title,
"Click to install and restart" as explicit call to action.
Fixes: auto-dismiss before user noticed it, no visual distinction from
a regular message notification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the
incoming-call ring is no longer hardcoded to call.ogg. The three synth
styles are generated in-browser via a new utils/ringtones.ts module
(mirroring the existing callSounds.ts WebAudio pattern), so no new binary
assets are bundled; 'classic' keeps the existing call.ogg clip and 'none'
is a silent, visual-only incoming-call UI.
- ringtones.ts: startRingtone() loops until stopped; previewRingtone()
plays a single non-looping preview and auto-cancels the prior preview.
- IncomingCall: ring driven by the setting; <audio> element removed.
- Settings > Calls: Ringtone selector with on-select preview, beside the
existing Ringtone Volume slider.
- settings.ts: persisted value whitelisted back to a known id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The real reason the gallery didn't look or function like the Members drawer
or Saved Messages: it was a position:fixed overlay floating over the timeline,
mounted from RoomViewHeader. Now it docks into the room layout row exactly like
MembersDrawer.
- new mediaGalleryAtom (mirrors bookmarksPanelAtom) holds the open state
- RoomViewHeader toggles the atom instead of local useState and no longer
renders the panel
- Room.tsx renders <MediaGallery> as a flex sibling of the timeline with a
vertical Line separator on desktop and key={room.roomId} to reset per room
- MediaGallery.css: static width on desktop, position:fixed inset:0 full-screen
only on mobile (identical strategy to MembersDrawer.css); root Box shrink="No"
The panel now shares the row with the timeline instead of overlapping it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>