- 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>
Verify-then-fix batch of minor bugs; each staged diff reviewed by 2 agents
(both SHIP). Two listed items (N6 receipt-avatar refresh, H10 room-name
length reject) were already handled and left unchanged.
Threads:
- T5: a just-sent reply no longer under-notifies — `participated` also checks
the local thread timeline for our own events, since the server-bundle
`hasCurrentUserParticipated` lags.
- T6: a room set to "Mentions & Keywords only" no longer over-notifies Default
thread replies — new `roomMentionsOnly` gate (behavior-identical when false;
+4 unit tests).
- T7: thread-mode account-data writes are serialized with content carried
forward (setAccountData is a bare PUT whose result lags the /sync echo, so
plain serialization wouldn't stop the lost update); carry only on success.
Calls / audio:
- C-L2: a real incoming ring cancels a lingering Settings ringtone preview.
- C-L3: the ringtone AudioContext is primed on the first page gesture (via the
always-mounted CallEmbedProvider) so the first ring after a cold load isn't
silent.
- C-L5: useCallSpeakers depends on a stable boolean, so the tile MutationObserver
+ io.lotus.call_state subscription aren't rebuilt on every membership change.
Crypto:
- F5: the OIDC refresher forwards the freshly-refreshed token expiry
(passed on the tokens object at runtime) as expiresInMs, so the persisted
expiresAt no longer goes stale across reloads.
Gates: tsc 0, eslint 0, prettier clean, 860/860 tests, build ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
COR-2 (useCallEmbed): useCallJoined only reset `joined` when the embed became
undefined. Answering a 2nd call swaps the embed A->B directly (embed stays
truthy), so `joined` stayed true and call B rendered as already-joined,
skipping the loading/watchdog UI. Re-seed from `embed?.joined ?? false` on
every embed identity change.
COR-4 (ClientNonUIFeatures): the notify-dedupe used one Map<roomId,eventId>
slot shared by the main-timeline and per-thread paths, so a thread reply
overwrote the room's slot and a re-fired main message (decrypt/edit re-emit,
common in E2EE) then mismatched and double-notified. Key the slot by
`${roomId}|${threadId ?? 'main'}` so each path dedupes independently.
Both verified correct by two review passes (no missed-notification or
missed-join regressions).
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>
On cinny-desktop (Tauri/WebView2) a clicked notification navigated the web
content but never raised the OS window (a service-worker/WebView2 client.focus()
only focuses the document). The service-worker notificationClick path now calls
a new focus_main_window Tauri command via invokeTauri (no-op outside Tauri) in
addition to navigating; the rich-toast path is focused natively on the Rust side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isInQuietHours was a hand-rolled, untested duplicate of the overnight-window
logic. Replace it with the shared, unit-tested isWithinTimeWindow (utils/
timeWindow.ts) - identical behavior for valid HH:MM inputs, more robust on
malformed ones (returns false rather than doing NaN math), and now covered by
timeWindow.test.ts. One implementation instead of two.
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>
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.
- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
re-renders only when its draft flag flips (the draft atom is written on
room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
per-room draft atoms from draft-msg-* localStorage on startup, so
indicators are correct after a page reload, not only after revisiting a
room. Thread drafts (key contains ::) are skipped; room-level only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- DP2: the earlier seed fix was ineffective (allInvitesAtom populates post-mount,
so first render is still empty). Rewrite to track invite room ids and stay
'unarmed' until the initial sync settles (+3s grace), notifying only for ids that
first appear after arming — robust to the async population race.
- DP4: batch the mutually-exclusive tag writes via Promise.all so a failure surfaces
a single toast instead of one per operation.
- DP15: route the two remaining StateEvent writes (RoomSoundboardPack / RoomImagePack,
which used an 'as unknown as keyof StateEvents' idiom the sweep missed) through the
typed sendStateEvent helper.
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>
Add a pure `getMemberName(room, userId): string` helper in utils/room.ts
(= getMemberDisplayName ?? getMxIdLocalPart ?? userId) and replace the
inline `getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id`
fallback across the codebase. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Invites: seed the notify baseline with the invite count at mount instead
of 0, so a warm reload (allInvitesAtom populates synchronously from cache
while SYNCING) no longer re-fires the toast+sound for pre-existing invites.
Only a genuine increase notifies.
- Status: the cross-device sync effect gated on a truthy presence.status, so
a remote CLEAR never reset the input or localStorage[STATUS_MSG_KEY] and
usePresenceUpdater.readStatus() re-sent the stale status. Now mirror an
empty status as a clear (skipping offline/invisible, which carries an empty
status_msg by design).
- Soundboard: useState initializers ran once and never recomputed when the
room/rooms arg changed. Add a resync effect keyed on the arg while keeping
the live state-event update path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the room-preview work so you can actually reach a preview from the
room lists, not just via a matrix.to link:
- Space lobby (RoomItem): un-joined child rooms now show an Eye 'Preview' chip
next to Join. It routes through the existing onOpen (data-room-id -> space/room
path), which renders the full JoinBeforeNavigate preview card because the room
isn't joined.
- Explore grids (Server + Featured): pass join_rule (and encryption, where the
summary provides it) to RoomCard, so the directory/featured cards — which already
are full preview cards — now show the join-rule chip and a Request-to-join button
for knock rooms instead of a Join that fails.
738 tests pass, build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declining a remote invite could show a raw 'MatrixError: [500] Internal server
error' and appear to do nothing. Root causes were client-side: decline called
mx.leave unconditionally, so re-clicking after a slow federated leave hit an
already-left remote room that Synapse 500s on; the room was never forgotten so a
'leave' ghost lingered and re-invited a click; and the raw error string was shown.
Add a shared declineInvite(mx, roomId) helper that only leaves when still in the
room (invite/join/knock) and then forgets it (best-effort, first use of forget in
the app). Route the InviteCard decline and both 'Decline All' paths through it,
and replace the raw error with a friendly message (real error kept in console).
Tests: declineInvite covered (6 cases); typecheck + full suite + build clean.
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>
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>
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>
- 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>
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>
- 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>
- 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>
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>
- 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>
- oidc/OidcCallback.tsx: standalone page that exchanges code+state via
completeAuthorizationCodeGrant (SDK validates state = CSRF), derives
user_id/device_id from the new access token via whoami(), persists the OIDC
session (refresh token + expiry + issuer/clientId/redirectUri/idTokenClaims),
then full-page-reloads at the app root. Minimal UI (no Overlay/portal) so it
needs no app providers.
- App.tsx: short-circuit — render OidcCallback before the RouterProvider when the
path is the OIDC callback (redirect_uris can't contain a fragment, so it must
live outside the hash router). The nginx SPA catch-all already serves index.html
for it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- oidc/oidcState.ts (pure, +3 tests): dynamic-registration cache (by issuer +
redirectUri, corrupt-tolerant) and parseOidcCallbackParams (success/error/invalid).
- oidc/oidcLoginUtil.ts: getOrRegisterClientId (cache + registerOidcClient) and
startOidcLogin (discoverAndValidateOIDCIssuerWellKnown -> generateOidcAuthorization
Url -> redirect; invalidates the cache on failure). redirectUri is the
deterministic getOidcCallbackUrl(), and the SDK returns clientId/issuer on
callback, so no hand-rolled transient state is needed.
- login/OidcLogin.tsx: native-OIDC button mirroring SSOLogin + TokenLogin async/error.
- login/Login.tsx: issuer-gated — when discovery advertises an issuer, render
OidcLogin and suppress password/legacy-SSO; non-OIDC servers unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Toward MSC3861/MSC2965 next-gen-auth login (P4-6), client-only.
- cs-api.ts: type the stable `m.authentication` well-known key + getOidcIssuer()
(stable preferred over the unstable msc2965 key; {} for non-OIDC servers).
- useParsedLoginFlows.ts: getOidcCompatibilityFlag() (MSC3824 oauth_aware_preferred
/ delegated_oidc_compatibility) as a secondary OIDC hint.
- New pages/auth/oidc/oidcConfig.ts: dynamic-registration client metadata + the
non-hash callback URL (redirect_uris can't contain a fragment).
- paths.ts: OIDC_CALLBACK_PATH.
- 8 unit tests for the pure helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OS notifications were shown via page-level `new Notification()` whose onclick
only works while the originating tab is alive — clicking a notification after
closing the tab did nothing.
- New `showOsNotification()` (utils/dom) prefers `registration.showNotification()`
so the notification is service-worker-owned and persists; falls back to
`new Notification()` (with the previous onclick) when no SW is available, so
worst case is unchanged behaviour.
- sw.ts gains a `notificationclick` handler: focuses an existing app window and
forwards the target path, or opens the app if none is open.
- ClientNonUIFeatures forwards the SW `notificationClick` message to react-router
`navigate()` (works for both hash and browser router configs), and uses a
per-room `tag` to coalesce notifications (replacing the old notifRef.close()
dedup a SW notification can't hold).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Centralized the global floating-UI stacking values into styles/zIndex.ts
(inCallBanner 9990 < seasonalEffect 9997 < nightLight 9998 < toast 10001;
folds modals sit at 9999 between). Same values, no behavior change — just
removes the magic numbers and documents the layering so future overlays don't
collide. Component-internal small z-index stays local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- RouteError: raw <div>/<h2>/<p>/<button> (sans-serif, raw px) -> folds
Box/Text/Button with config tokens.
- CallEmbedProvider PiP fullscreen control: raw <button> with ⊡/⛶ glyphs ->
folds IconButton reusing the exported FullscreenIcon/ExitFullscreenIcon SVGs
from Controls (consistent with the main fullscreen button). The intentional
dark over-video scrim is kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We no longer use Sentry. Removed:
- @sentry/react + @sentry/vite-plugin (package.json + lockfile)
- Sentry.init in index.tsx and the VITE_SENTRY_DSN env (.env.production)
- @sentry/vite-plugin + the SENTRY_AUTH_TOKEN sourcemap-upload path in
vite.config.js (sourcemap now always false) and the CI env var
- Sentry.ErrorBoundary in App.tsx -> react-error-boundary's ErrorBoundary with a
folds-native fallback (Box/Text/Button + config tokens), which also resolves
the native-cinny audit's raw-#hex/#5865f2 fallback finding.
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>
- N100: restrict <pre> classes to language-* in sanitize-html allowedClasses;
previously `class` was allowed on <pre> with no allowedClasses entry, so a
remote sender could inject arbitrary class names that activate site CSS.
- N106: OS notifications for E2EE rooms no longer carry decrypted plaintext
(which persists in the OS notification center / lock screen). Encrypted rooms
show only the sender; the in-page toast still previews while focused.
- N109: OS notification icon/badge use the static app logo instead of an
authenticated-media avatar URL the OS can't fetch (was 401 / no icon). The
in-app toast keeps the real room avatar (it can fetch via the SW).
- N119: syncDecorations.mjs distinguishes a confirmed 404 (remove) from a
network/5xx failure (abort) so a transient CDN outage can't silently wipe the
whole decoration catalog from source control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N98: logoutClient and handleLogout now call removeFallbackSession() (removes
only the 4 session credential keys) instead of window.localStorage.clear(),
so settings, unsent drafts, PiP position, and status are preserved across a
normal logout. localStorage.clear() stays reserved for clearLoginData() (the
explicit factory-reset path).
- N99: the useSyncState callback now handles ERROR/STOPPED. A sync failure
before the first PREPARED (offline at startup, homeserver unreachable) shows
a dedicated error splash with a Retry button (startMatrix) instead of an
endless "Heating up" spinner alongside a contradictory "Connection Lost!"
banner. Guarded by a hasPreparedRef so post-PREPARED transient errors still
go through <SyncStatus>; PREPARED self-heals the splash on recovery, and the
redundant banner is suppressed while the splash is shown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- N113: mutations compute from a local ref kept in sync with server echoes, and
writes serialize through a promise queue, so rapid add/remove no longer reads
a stale baseline and clobbers a prior write.
- N114: ReminderMonitor shows each toast once (firedRef) but retries the
account-data removal on later ticks if it fails (removingRef released on
error) — a failed removal no longer permanently swallows the reminder.
- N115: the 30s poll interval reads reminders/mDirects via refs and drops them
from the effect deps, so it's created once instead of resetting its countdown
on every reminder sync (which could indefinitely defer a near-due reminder).
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>
- N69: @mention highlight color now uses HexColorPickerPopOut + react-colorful
HexColorPicker behind a folds Button (color swatch); built-in onRemove
replaces the separate Reset, dropping the OS-native <input type="color">
- N10: mentionPulseKeyframes animates only box-shadow (dropped the imperceptible
scale(1.003)) so it no longer fights MsgAppearClass over `transform` on
self-sent @mention messages
- N22: Direct.tsx virtualizer estimateSize 38 -> 52 (two-line DM row height) to
avoid the initial-render jump before measureElement corrects each row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure formatting reflows (multi-line wrapping of long lines/imports/tables);
no behavior change. Clears the working tree of pending prettier diffs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar decorations: useAvatarDecoration cached ALL profile-field fetch
failures as "no decoration" permanently for the session. The member list
and timeline mount many avatars at once, so one rate-limited (429) burst
would wipe everyone's decoration until a full reload. Now only a genuine
404 (field unset) is cached; transient errors retry on the next mount.
Saved Messages panel — full redesign to match the canonical MembersDrawer:
- co-located BookmarksPanel.css.ts: toRem(266) + max-width:750px full-screen
media query, replacing the old position:absolute/zIndex:100 mobile "modal"
that had no backdrop or escape
- variant="Background" header; room avatars on each item (was a generic hash)
- priority tokens replace all raw opacity hacks; 3px borderLeft accent removed
- Escape-to-close; multi-line preview is now a proper folds Button (N38)
Media Gallery (N12): moved fixed positioning + width into MediaGallery.css.ts
using toRem(320) + a full-screen media query; border/header use config tokens;
added Escape-to-close on the panel (previously only the lightbox handled it).
Presence (SettingsTab / useUserPresence):
- N16: wrap presence-dot trigger in TooltipProvider; replace undefined
--bg-surface with color.Background.Container
- N17: add escapeDeactivates + isKeyForward/isKeyBackward to the FocusTrap
- N19: align reader labels (usePresenceLabel) to the setter vocabulary
(Online/Idle/Offline) so a chosen status matches the tooltip others see
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add SelectSenderButton: clickable people picker for the From filter
replacing the text-only from:@user syntax
- Add date preset shortcuts in DateRangeButton (Today, Last week,
Last month, Last year) for one-click range selection
- Add Has link chip backed by Matrix contains_url API filter; toggle
removes cleanly with X badge
- Wire containsUrl through URL params, useMessageSearch hook, and
SearchFilters props
UNTESTED — verify at chat.lotusguild.org post-deploy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OS notifications now show the real message body ("user: text" instead
of "New inbox notification from user"), clicking jumps directly to the
room at the triggering event, and window.focus() brings the tab to
front. Reminder toasts also link to the specific event via eventId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TauriUpdateFeature component in ClientNonUIFeatures checks for updates
on mount and every 12h (skips if checked within the window). On update
available, fires a Lotus toast: "Lotus Chat vX.Y.Z is ready to install."
Clicking the toast calls install(). No-op on web (isTauri guard).
Also adds optional onClick to ToastNotif type and wires it in
LotusToastContainer so custom click handlers can skip hash navigation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>