While an update is available the web client asks the native side to show
"Restart to update (vX)" in the tray menu and an "update ready" tooltip;
it's cleared once a check reports we're current, and kept while
installing or after a failed attempt. Clicking the tray item runs the
same install flow as the toast, so progress and failures show in-app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.
- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
error prefix) in plain language, with Try again and a Download
installer button (Windows: the setup .exe; else the release page), and
the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
toast, and on failure a sticky "Update didn't install" toast that
retries on click and points at Settings → General → App Updates.
Before, the toast vanished and the failure was only visible in Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Notification sounds called `audio.play()` without handling the promise,
so every message that arrived before the user interacted with the page
(e.g. right after launch) logged an uncaught NotAllowedError — 15 in a
short test run. Same pattern in the video thumbnail loader, the voice
preview (which now also resets its Play button) and useMediaPlay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
public/res/ carried two DIFFERENT files whose names differ only in case —
Lotus.png (19 897 B, the 256px logo used by the welcome page, auth layout,
OIDC config and About) and lotus.png (2 073 B, the notification icon).
Windows and macOS filesystems are case-insensitive, so a checkout there
collapses them into one path: one of the two references then resolves to
the wrong bytes or to nothing, which is why the desktop client's home
logo rendered as alt text from
http://localhost:44548/public/res/Lotus.png.
Renamed the logo to lotus-logo.png and updated its four references;
verified no case-only filename collisions remain anywhere in the repo,
and that both logos still load (256x256 on the welcome page and About).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.
The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.
Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.
Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.
Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".
Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Event handlers typed as React.MouseEvent, join errors as Error, the
service-worker session setter as strings, the UIA policy map and the
webkitAudioContext probe as narrow shapes. No behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
public/manifest.json declares share_target (POST multipart to
/share-target: title/text/url + image/video/audio/pdf/text files). The
service worker answers that POST itself: it stashes the form in a Cache
API bucket and 303s to the in-app /share page, which lists what arrived,
offers a room search, and on pick writes the files into that room's
upload-board atom (encrypting first for E2EE rooms via the composer's
shared filesToUploadItems) and the title/text/url into its draft, then
opens the room — the user still presses Send. The stash is cleared once
placed; reopening /share afterwards says so.
nginx/caddy examples and the prod image config gain a 303 for
/share-target so a POST that reaches the origin before the worker
controls the page lands on /share instead of a 405. iOS has no share
target support and ignores the manifest entry.
Verified headless against the built preview: SW-controlled page → POST
/share-target (two PNGs + title + text) → /share lists both files and the
text → pick the DM → composer shows both files on the upload board and
the text in the draft → /share reports nothing pending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A small red dot on a space tab when any room in that space (recursively)
has an active MatrixRTC session, so a live call is visible even when you
are looking at a different space or your DMs. Hidden while the space is
selected — the room list already shows the Live badge there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Message notifications shown through the service worker now carry a text-input
'Reply' action (Chrome desktop/Android). On notificationclick with
action==='reply' the SW sends the typed text itself — it already holds the
newest session's access token for authenticated media — as m.room.message
(threaded when the notification was for a thread), so it works with the tab in
the background or closed; a failed send shows a 'Reply not sent' notification
that opens the room. Not offered for encrypted rooms (the SW cannot encrypt).
The sender lives in swReply.ts so it is unit-tested; verified headless that
the SW notification carries actions + {roomId, threadId}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
'Unable to decrypt message' now carries one sentence per matrix-js-sdk
DecryptionFailureCode (describeDecryptionFailure, unit-tested against every
code so no raw code can leak into the copy) and, where something fixes it,
one button: no key backup → 'Set up key backup'; backup exists but this
session can't open it / key withheld for an unverified session → 'Unlock key
backup' / 'Verify this session' (both open Settings → Devices via a new
settingsRequestAtom that SettingsTab consumes); backup working or unknown
session (rust-crypto re-requests keys itself) → 'Retry', which re-runs
decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits
in the placeholder's tooltip for support.
Verified headless on a fresh session in the encrypted seed room: each event
shows 'Sent before you signed in here, and no key backup exists…' with
tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.
Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.
UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.
Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
axe-core flagged 18 'button-name' criticals on the room view: the space
tabs, Home/Direct/Inbox/Search/Saved/Explore/Add Space/User Settings/
Unverified sidebar buttons, and each message's avatar button had no text
for screen readers. Labels mirror the existing tooltips; message avatars
read '<name>, open profile'. Room view now has zero button-name findings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix.to cannot target this deployment (Cinny adapter hard-codes
app.cinny.in; web-instance[] is Element-only, allowlisted), so add a
"Copy Lotus Link" next to every Copy Link (message menu, space header
menu, sidebar space tab) producing https://<this origin>/home/<room>/<event>
?viaServers=… via plugins/lotus-permalink.ts. Lotus links in messages are
rewritten to their matrix.to form inside the HTML parser so they render as
room/event mentions and navigate in place.
/home/<room> for a joined room that belongs to a space or Direct now
redirects to its own route (was a preview card with a View button; also
the form matrix.to → Cinny links use). ?via= is accepted as an alias of
?viaServers= (what the matrix.to Cinny adapter emits). A deep link
visited while logged out is now honoured after an OIDC login: the OIDC
callback reloads at the app root, which discarded the stored path — the
index loader consumes it via the shared takeAfterLoginPath().
Verified end-to-end with Playwright on a local Synapse: logged-out cold
link → login → lands on the event under the space route; menu copies the
expected link; a pasted Lotus link renders as a mention and jumps in
place; both space menus copy the space link.
Closes#130
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Same number as the tab title (leaf-room highlights), cleared at zero.
Skipped under Tauri where the native set_badge_count owns the badge, and
silently absent where the Badging API is not available.
Closes#154
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The client never handled beforeinstallprompt and showed no install hint, so
phone users only got the PWA if they knew to dig through Share → Add to
Home Screen (iOS never prompts; Chromium's mini-infobar is easy to miss).
- utils/pwaInstall.ts (pure, 4 tests): show from the second visit, never in
Tauri or an installed PWA (display-mode standalone / navigator.standalone),
30-day snooze after a dismissal; kind = real prompt when the browser
handed us a deferred beforeinstallprompt, Share-sheet instructions on iOS
Safari, nothing elsewhere (Firefox desktop has no install path).
- hooks/usePwaInstallPrompt.ts: captures beforeinstallprompt/appinstalled,
counts one visit per browser session, waits 6s for the prompt event before
deciding, then enqueues a sticky toast; tap → prompt(), X → snooze.
- ToastNotif gains onDismiss (fired by the X button only) so the snooze is
recorded however the toast is closed. Mounted from ClientNonUIFeatures for
signed-in users only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every Lotus setting was localStorage-only, so a user on web + desktop + phone
configured theme, composer toolbar, quiet hours, call keys… three times.
- utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom,
media auto-load, animation pause, glassmorphism, denoise tier/model,
bitrates, volumes, notification permission, developer tools, PTT mode,
camera-on-join, drawer state, and the sync toggle itself), pickSyncable,
mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped
values are skipped), buildSyncedContent, shouldApplyRemote (LWW on
updatedAt; equal stamp = our own echo).
- hooks/useSettingsSync.ts: on start applies a newer remote snapshot or
pushes local if it differs; debounced push on any settingsAtom write,
skipped when the syncable subset equals the last pushed/applied snapshot
so a remote apply never echoes back; AccountData listener for live
updates; stamps forced monotonic per device; per-account lastSyncedAt
marker so another user on the same device can't inherit it; failed pushes
roll the marker back so the next change retries. Remote values are re-read
through getSettings() so enum coercion applies.
- Settings → General → Sync: toggle (device-local), "Push now", "Clear
synced copy". AccountDataEvent.LotusSettings registered.
- ClientNonUIFeatures: the #103 tracking-param subscriber moves out of
PageZoomFeature into its own TrackingParamsFeature next to
SettingsSyncFeature.
- Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new
Features 2026-Q4 milestone and #108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid,
YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every
recipient's click back to the person who shared the link. New
src/app/utils/urlTracking.ts is a pure, local stripper: a global list +
utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only
removed on youtube/spotify. matrix.to and non-http(s) schemes are never
rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc`
variant selectors are deliberately kept. 13 unit tests.
Wired at three points, all behind a new Settings → Privacy toggle
(`stripTrackingParams`, default on):
- paste: plain-text pastes are cleaned and re-inserted through Slate's own
insertData so multi-line pastes still split into paragraphs;
- send: RoomInput submit + schedule paths and MessageEditor saves clean both
`body` and `formatted_body` (the HTML variant unescapes `&` around each
URL and re-escapes it so the markup is untouched);
- render: linkify `formatHref`/`format` and explicit `<a href>` in
formatted_body are cleaned, so links sent from other clients are safe to
click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so
the toggle is a module flag kept current by ClientNonUIFeatures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The "should we make noise" predicate used for message sounds is extracted
into useNotificationsQuiet() (unit-tested) and applied to the ringtone in
both the full-screen incoming-call overlay and the compact in-call banner.
The overlay/banner still show so the call can be answered; only the audio
is skipped. Join/media paths untouched.
Fixes#28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Home's categorisation memo keyed only on the room list, and nothing
observed m.tag changes. The SDK emits RoomEvent.Tags on the room and
re-emits it on the client (room.js addTags, sync.js reEmit); a small
client-level hook bumps a version that the memo depends on.
Fixes#20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The redirect back with ?error=invalid_client is the only place a stale
dynamic client id is ever rejected; the callback now resolves the issuer
from the SDK's stored mx_oidc_<state> entry and invalidates the cache so
the next attempt re-registers. Degrades to a no-op if the state entry is
gone. Unit-tested.
Fixes#102
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A transient network/discovery failure invalidated the cached dynamic
client and registered a fresh one on every retry. Invalidate only on
invalid_client / unauthorized_client or a 400/401 from the provider.
Unit-tested.
Fixes#67
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
useSessionSync reloaded on any out-of-tab session change, so a routine
refresh in one tab hard-reloaded the others mid-call. Classify the
change: removed → reload, user/device changed → reload, same device with
a new token → swap it into the running client (setAccessToken + the
shared refresh token) in place. The refresher takes a Web Lock and adopts
tokens another tab already rotated instead of racing the issuer.
Unit-tested classifier.
Fixes#16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
unmuteRoom unconditionally reset the room to Unset when a timed mute expired
(in-session timer and boot-time restore alike), silently reverting a mode the
user had changed by hand during the window. Mute-timer helpers move to
muteTimers.ts; unmuteRoom now reads the live push-rule mode and only resets
when it is still Mute, always dropping the persisted timer. Unit-tested.
Fixes#21
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Swap the logo/favicon URL constants from the inline
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...` form to the
repo's existing `withOriginBaseUrl(getOriginBaseUrl(), '/public/res/...')`
helper (already used here for the OIDC callback URL). Functionally equivalent —
same /public/res/ target, resolves in dev and the static-copied prod build — and
it keeps the logo URL absolute and consistent with clientUri for the OIDC
logoUri. No build-config change (publicDir stays false).
Co-authored-by: Nathan Vititoe <nathanvititoe@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Importing images from public/ (`import X from '../../public/res/*.png'`) is
fragile under Vite with `publicDir: false` and can white-screen the dev app —
Vite tries to resolve the public/ path as a module. Switch the five logo/
favicon call sites to the repo's existing BASE_URL URL pattern
(`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...`), matching how
config.json, locales, and the Element Call widget are referenced. Resolves
identically in dev and the static-copied prod build (public/res -> dist/public/res).
Sounds under public/sound/ stay ESM-imported: that folder is not copied to
dist/ by vite-plugin-static-copy, so a URL reference would 404.
Also add a Local Development section to the README (no-backend model, npm ci /
npm start on :8080, which homeserver to log in against, OIDC-on-localhost note).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>