100 Commits
Author SHA1 Message Date
jared 4656f08802 Revert "ci: re-enable npm/node_modules cache — runner cache network fixed"
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Trigger Desktop Build (push) Successful in 12s
This reverts commit a631e90ea2.
2026-08-02 23:23:52 -04:00
jaredandClaude Opus 4.8 a631e90ea2 ci: re-enable npm/node_modules cache — runner cache network fixed
CI / Build & Quality Checks (push) Canceled after 4m44s
CI / Trigger Desktop Build (push) Canceled after 0s
The act_runner cache server is now reachable from job containers: jobs were
landing on isolated per-job docker networks and couldn't reach the runner's
cache server on docker0 (getCacheEntry ETIMEDOUT, ~5 min wasted/build). Fixed
runner-side by putting the runner + all job containers on a shared dedicated
network (`act-cache-net`, runner at 172.30.0.2) and pointing cache.host at it —
verified a container on that network reaches the cache port.

Restores `cache: npm` on Setup Node and the actions/cache node_modules step
(restore + save-on-miss-and-success). Reverts 10270b75 now that the underlying
network issue is resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 23:18:00 -04:00
jaredandClaude Opus 4.8 10270b75ca ci: drop npm/node_modules cache — runner cache server is unreachable
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Trigger Desktop Build (push) Successful in 13s
The act_runner's internal cache server (172.17.0.2:46367) can't be reached
from job containers: `setup-node` with `cache: npm` spends ~4m42s on
`getCacheEntry failed: connect ETIMEDOUT` every build, then reports "npm cache
is not found" — ~5 min of pure cost for zero caching. The `actions/cache`
node_modules steps added in 79258668 would hit the same dead server and hang
too, so they're removed here as well.

Removing the cache usage reclaims ~5 min/build with no loss (nothing was being
cached). The fast-gates-before-build reorder is kept. Re-enable caching once
the runner's cache server is reachable from job containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:26:26 -04:00
jaredandClaude Opus 4.8 7925866868 ci: cache node_modules + run fast gates first; enable lint-staged hook
CI (.gitea/workflows/ci.yml):
- Cache node_modules keyed on package-lock + .node-version (actions/cache
  restore/save). An unchanged lockfile now skips `npm ci` (extraction +
  postinstall folds patch) and just restores the tree. Save runs only on a
  cache miss and only when install succeeded (`success()`), so a failed
  `npm ci` can't poison the cache. setup-node's existing `cache: npm` still
  warms the download cache on the miss path.
- Run prettier/eslint/typecheck/tests BEFORE the ~minutes-long build so a
  format/lint/type/test error fails in seconds instead of after the build.

DX (.husky/pre-commit):
- Enable the pre-commit hook (`npx lint-staged`). husky + lint-staged were
  already installed with a config (eslint + `prettier --write` on staged
  files), just commented out — so formatting kept reaching CI. It's now
  auto-applied on commit. (typecheck left out of the hook — too slow per commit.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:05:50 -04:00
jaredandClaude Opus 4.8 d5cfb663b9 chore(issues): point the issue chooser at this repo, not upstream Cinny
CI / Build & Quality Checks (push) Successful in 10m54s
CI / Trigger Desktop Build (push) Successful in 5s
The forked-in Cinny issue setup funneled reporters to the upstream project:
- config.yml disabled blank issues and linked to cinnyapp/cinny GitHub
  Discussions
- preapproved.md was Cinny's "do not open an issue, use Discussions"
  template (Gitea has no Discussions anyway)

Replace it with a Lotus setup: enable blank issues, drop the upstream
contact link, and add Bug Report + Feature Request templates. Now
/issues/new/choose offers Lotus templates and files against this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:59 -04:00
f2c356f288 refactor(assets): build logo/favicon URLs via withOriginBaseUrl helper
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Canceled after 0s
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>
2026-08-02 20:22:58 -04:00
jaredandClaude Opus 4.8 f12e05c510 fix(assets): reference logo/favicon PNGs by URL instead of importing from public/
CI / Build & Quality Checks (push) Successful in 12m7s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-08-02 15:52:04 -04:00
jaredandClaude Opus 4.8 d47032a14f fix(unread): clear rooms whose read receipt already covers the tail
CI / Build & Quality Checks (push) Successful in 10m54s
CI / Trigger Desktop Build (push) Successful in 8s
A room could show a permanent unread that survives every cold start even
though the server considers it fully read (notification_count 0, unthreaded
read receipt at the tail). matrix-js-sdk's fixNotificationCountOnDecryption
only ever INCREMENTS an encrypted room's Total, and addReceipt's auto-clear
fires only when the tail event is the user's own — so a count inflated in an
earlier state (before a receipt covered the tail, e.g. by a since-corrupted
undecryptable event) is never decremented and keeps a genuinely-read room lit.
This is aggravated by mixing threaded-receipt clients (Element X) with
unthreaded ones (Lotus/Cinny), which split the read marker.

Add readReceiptCoversTail(room, userId): walking the live timeline newest→
oldest, if we reach the user's read-receipt event without crossing any
notification-worthy event, the room is genuinely read and a lingering Total is
suppressed to {0,0} in getUnreadInfo / getUnreadInfos. Safe by construction —
a real unread sits AFTER the receipt and stops the walk at isNotificationEvent
— and guarded against unread threads (markAsRead clears threads unconditionally)
and off-window receipts (can't confirm → don't suppress). Self-correcting: a
new message becomes the tail and the walk stops suppressing.

Also recognize polls (m.poll.start / msc3381) as notification events so a
poll-only unread is never walked past (closes a pre-existing gap in the
tail scans), and factor the unread-thread guard into roomHasUnreadThread.

Reviewed by 3 agents (false-suppression safety, unread-system regression,
SDK behavior): no real unread is hidden for any standard content, no
regression to the atom/PUT-DELETE paths, and the fix produces {0,0} for the
target scenario and stays resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:42:32 -04:00
jaredandClaude Opus 4.8 b2678d5c6d fix(unread): stop DM device-verification requests re-lighting as unread
CI / Build & Quality Checks (push) Successful in 11m11s
CI / Trigger Desktop Build (push) Successful in 10s
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>
2026-07-29 01:18:31 -04:00
jaredandClaude Opus 4.8 1176bea0ee docs(todo): record composer autocomplete-insert crash fix (477df4ae)
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:13:34 -04:00
jaredandClaude Opus 4.8 477df4ae32 fix(composer): stop the intermittent autocomplete-insert crash
Picking an autocomplete item (mention/emoji/command — all inline voids)
occasionally tripped the composer error boundary, forcing a page refresh, even
though the element had already inserted. Root cause (traced through slate-react):
moveCursor deferred its cursor work to setTimeout(0), leaving the caret on the
just-inserted void's zero-width edge whose DOM (a U+FEFF node) isn't populated on
that tick. slate-react's commit-phase selection sync then calls
setBaseAndExtent(voidEdge, 1) and throws IndexSizeError mid-render → boundary.

Prevention: do the cursor work SYNCHRONOUSLY, in the same commit as the insert —
Transforms.move (escapes the void into the real trailing text node) then
insertText(' '). The caret is then always a resolvable text point when the
selection sync runs. (moveCursor's focus stays deferred+guarded, unchanged.)

Recovery (belt-and-suspenders): the composer error boundary is now recoverable —
a "Reload composer" button (resetErrorBoundary) + onReset Transforms.deselect
clears a transient bad selection so it remounts with the draft intact, no page
refresh. + role="alert" for screen readers.

Three review agents: two root-caused the exact slate-react throw and proved the
try/catch-only version merely recovered; a third reproduced the transforms
headlessly and caught that a first "sync insertText WITHOUT move" attempt hit
Slate's void guard (space dropped, caret trapped) — the move is required to
escape the void. Not unit-testable (needs the live DOM + the timing race).
Gate-green (tsc, eslint, prettier, 925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:12:52 -04:00
jaredandClaude Opus 4.8 15d85f52c4 docs(todo): desktop notification nav fix + rich-toast follow-ups (0ddf86c6)
CI / Build & Quality Checks (push) Successful in 10m51s
CI / Trigger Desktop Build (push) Successful in 8s
Root-caused the desktop notification-click-doesn't-navigate bug (SW shadowed the
Notification shim); web fix shipped. Documented the two desktop-Rust follow-ups
it activates (lost tag-coalescing, thread/invite quick-reply misroute) + a
Windows QA checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:17:39 -04:00
jaredandClaude Opus 4.8 0ddf86c678 fix(desktop): navigate to the message on notification click (route via rich toast)
On the Windows/Tauri build, clicking a message notification opened the app but
didn't navigate to the message. showOsNotification preferred the service worker
(registration.showNotification) and returned early; WebView2 has a service
worker, so the SW-owned toast always won and its click (SW notificationclick →
client.focus + postMessage → navigate) focused the app but the navigate didn't
complete in WebView2.

The desktop build injects a window.Notification shim that routes tagged message
toasts to the native rich WinRT toast, whose click emits lotus-notification-
activate with the path → useTauriToastActions → navigate. But the SW path
shadowed `new Notification()`, so that shim (and show_rich_toast) never ran on
desktop. Skipping the SW path under Tauri lets the shim take over and navigate.

Web browsers are unchanged (isDesktopApp() is false → SW path as before). Two
review agents verified the diagnosis + no web regression across both repos.

DESKTOP-QA REQUIRED — this activates a previously-dead code path. Known desktop
follow-ups it exposes (documented in LOTUS_TODO, both in cinny-desktop Rust):
- tag-coalescing is lost (rapid same-room messages stack toasts instead of
  collapsing) — show_rich_toast doesn't dedupe by room.
- thread/invite quick-reply misroutes: the reply target is the coalescing tag
  (roomId:threadId / 'lotus-invites'), not a real room id → sendMessage fails.
Navigation itself (body click) is correct for all cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:16:55 -04:00
jaredandClaude Opus 4.8 bd5f6a0855 docs(todo): mark quiet-hours empty-time feedback fixed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:59:50 -04:00
jaredandClaude Opus 4.8 5175c095b7 fix(notifications): flag inactive quiet-hours when a time field is empty
Clearing the quiet-hours start or end time silently deactivated the window
(isWithinTimeWindow → parseHHMM('') is null → returns false) while the toggle
still read "on", with no indication. Added an inline Critical-colored hint —
"Set both a start and end time — quiet hours stay inactive until both are filled
in" — shown when the toggle is on but either field is empty. Non-destructive:
it explains why rather than guessing a default time. Copy verified against
isWithinTimeWindow.

Last pure-client bug-hunt finding from LOTUS_TODO (the rest are live-call /
desktop-gated). Gate-green (tsc, eslint, prettier, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:59:50 -04:00
jaredandClaude Opus 4.8 99629edd9c docs(todo): mark MLocation permalink + PolicyListViewer doc findings fixed (8a461610)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:57:48 -04:00
jaredandClaude Opus 4.8 8a461610f4 fix(low-tail): MLocation permalink uses validated floats; PolicyListViewer doc
- MLocation "Open in OpenStreetMap" permalink built its URL from the raw
  parseGeoUri strings (location.latitude/longitude) while the embedded map iframe
  used the parseFloat + isFinite-validated lat/lon. Use lat/lon in the permalink
  too, so a malformed geo: substring can't reach the URL (they're already proven
  finite a few lines above and used identically in mapSrc).
- LOTUS_FEATURES claimed the Policy List Viewer has "Subscribe (join) /
  unsubscribe (leave) controls for each list" and lists subscribed lists.
  Verified against PolicyListViewer.tsx: it's a room-ID/alias input viewer that
  displays a joined policy room's rules read-only — no subscribe controls, no
  subscribed-lists listing. Corrected the doc to match.

Two low-tail bug-hunt findings from LOTUS_TODO. Gate-green (tsc, eslint,
prettier, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:57:34 -04:00
jaredandClaude Opus 4.8 53a2f738a9 docs(todo): mark pip auto-spotlight release finding fixed (08e19100)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:54:48 -04:00
jaredandClaude Opus 4.8 08e191008b fix(call): release auto-enabled pip spotlight when returning to the call room
In picture-in-picture with an active screenshare, spotlight is auto-enabled so
the share fills the pip window (tracked via pipAutoSpotlightRef). The release
branch sat behind `if (!pipMode) return`, so navigating BACK to the call room
(pipMode → false) early-returned and never released it — the spotlight stayed
stuck on with the ref latched true.

The effect now guards only on `!callEmbed`, computes wantSpotlight = pipMode &&
pipScreenshare, and releases whenever that's false (screenshare ends OR pip
ends). The ref still gates release so we only ever undo a spotlight we enabled,
never the user's. Two reviewer-prescribed hardenings folded in: reset the ref
when callEmbed is torn down (kills a stale cross-call latch), and a comment that
control.spotlight is deliberately not a dep (re-adding it would fight the user).

Bug-hunt finding from LOTUS_TODO. Two review agents verified against
CallControl.ts (ref-gating, deps, idempotency, cross-embed self-heal); [live] —
the code fix is unambiguous but confirming screenshare→pip→back wants a real
call. Gate-green (tsc, eslint, prettier, 925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:54:29 -04:00
jaredandClaude Opus 4.8 654466cf45 docs(todo): mark export-history E2EE pagination finding fixed (3ff8fb8e)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:47:53 -04:00
jaredandClaude Opus 4.8 3ff8fb8e55 fix(export): advance the raw pagination boundary on every event
Exporting a date range from an ENCRYPTED room over-paginated and mislabeled
"truncated": oldestRawTs (the how-far-back-have-we-paged boundary) was updated
only after the RoomMessage + decryption-failure filters, so undecryptable or
non-message old events never advanced it, the fromTs break never fired, and the
loop ran to MAX_EXPORT_PAGES. getTs() is unencrypted envelope metadata, so the
boundary update now runs for every event, above the filters.

Guarded with `ts > 0` so a bogus 0/negative origin_server_ts can't collapse the
boundary and cause the opposite failure — a silent early break / under-paginated
export (per review, silent omission in an export is worse than the loud
over-pagination this fixes). oldestTs (oldest collected in-range message) is
unchanged.

Two review agents (both confirmed getTs is decryption-independent, no
intra-page collection regression, oldestRawTs feeds only the fromTs break, no
plaintext regression); the second surfaced the 0-ts under-pagination edge, hence
the guard. Not unit-testable (embedded component + needs an E2EE room with
undecryptable history). Gate-green (tsc, eslint, prettier, 925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:47:40 -04:00
jaredandClaude Opus 4.8 02089cf60e docs(todo): mark encrypted-search cache size-cap finding fixed (fff811cb)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:41:40 -04:00
jaredandClaude Opus 4.8 fff811cb2d fix(search): cap the encrypted-search IDB cache per room (bound disk growth)
The on-device search index grew unbounded over a long session. putRows now
prunes each touched room to MAX_ROWS_PER_ROOM (5000) — deleting the oldest rows
by [roomId, ts] via a self-chaining IDB cursor within the same write tx (never
awaits a non-IDB promise mid-tx, so the transaction can't auto-commit and
truncate the prune). Exposed a pure, unit-tested evictCount() for the decision;
the cursor path itself is browser-only (node --test has no IndexedDB).

Deliberate tradeoff (documented in code): the coverage window keeps claiming the
evicted tail so the search doesn't re-fetch → re-evict it forever. Net effect —
in a room past 5000 cached rows, an evicted old message is silently unsearchable
rather than churning. Clear cached index / logout still wipe everything.

Two review agents verified the IndexedDB-spec correctness (cursor delete+continue
semantics, put-then-count ordering, roomRange bracketing with no prefix bleed,
tx liveness, abort→cache-miss) since CI can't. Gate-green (tsc, eslint, prettier,
925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:41:16 -04:00
jaredandClaude Opus 4.8 f54c386f36 docs(testing): add an automated-coverage map to the manual QA guide
Maps manual test items to the unit tests that now pin their LOGIC, so a human
tester can trust the deterministic parts and spend manual time on the
genuinely-human surface (visual rendering, live calls, desktop build, E2EE,
cross-device sync). Each row splits "logic pinned by a unit test" from "what
still needs you".

Every row verified against the real test assertions, then independently
audited by an agent for overclaims — the important failure mode being a tester
skipping manual QA of something not actually tested. Audit-driven corrections:
- O4 search cache: the IndexedDB round-trip test is skip'd under `npm test`
  (node has no IndexedDB), so only the pure merge/coverage helpers run in CI —
  said so explicitly rather than implying the round-trip is CI-covered.
- F2: relabeled — seasonSchedule.test.ts pins seasonal-theme *resolution*, NOT
  F2's background↔seasonal mutual exclusion (which is untested); flagged so no
  one skips the real F2 behavior.
- O5 + Q1/Q2: widened to reflect coverage that was understated.

No dangerous overclaim survived; the visual/live/device/E2EE carve-outs hold
for every row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:57:42 -04:00
jaredandClaude Opus 4.8 6dc0865965 docs(todo): mark soundboard-timer + permission-listener findings fixed (56561627)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:43:19 -04:00
jaredandClaude Opus 4.8 5656162720 fix(call): clear soundboard safety timer + detach permission onchange
- CallSoundboard: the 30s safety timeout (which unsticks the one-at-a-time
  playingKey guard if audio never signals end) was never cleared, so it fired
  ~30s after every clip. It's now stored in a per-play token that done() clears
  by identity — a natural 'ended' cancels it, and a stale done() from a prior
  clip can't disarm a newer clip's timer (which matters because a rejected
  audio.play() fires neither ended nor error, leaving the timer as the only
  guard-reset). The unmount effect also clears any pending timer, and the timer
  is armed only when there's an audio element.

- PrescreenControls: useMediaPermissions set PermissionStatus.onchange but never
  removed it → a permission change after unmount setState'd a dead component and
  retained the callback. Now guards all setState with a cancelled flag and
  detaches onchange in the effect cleanup.

Bug-hunt findings from LOTUS_TODO. Three review passes (the last prescribed the
per-play token to close a shared-ref cross-play edge). Gate-green (tsc, eslint,
prettier, 922 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:43:04 -04:00
jaredandClaude Opus 4.8 c6d558e5dd docs(todo): mark seasonal auto-ticker + mutual-exclusion findings fixed (d416c62b)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:35:07 -04:00
jaredandClaude Opus 4.8 d416c62b4c fix(seasonal): auto theme re-evaluates over time; auto clears chat background
- The "auto" seasonal theme was computed once at mount, so a long-lived session
  never crossed a season/holiday-window boundary. SeasonalEffect now re-evaluates
  on an hourly ticker (auto mode only) AND refreshes on entering auto — the
  interval only runs while auto, so a stale mount-time timestamp would otherwise
  resurface on a pinned/off → auto switch (the exact frozen-at-mount bug, caught
  in review). The decision is extracted to a pure resolveSeasonTheme(override,
  now) in seasonSchedule.ts (removing an unsafe cast) and unit-tested.

- Selecting seasonal "auto" while a chat background was set was a silent no-op:
  the seasonal picker only cleared the background for a *specific* theme, and the
  overlay is suppressed while a background is set. Now any active seasonal mode
  ("auto" included) clears the background; only "off" leaves it — symmetric with
  the background picker (which sets seasonal "off"). The overlay guard stays as a
  backstop for legacy persisted state.

Bug-hunt findings from LOTUS_TODO. Three review passes (the 2nd caught the
switch-into-auto staleness); +2 unit tests. Gate-green (tsc, eslint, prettier,
922 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:34:51 -04:00
jaredandClaude Opus 4.8 a24c98b199 docs: reconcile tab-title + collapsible-message threshold claims with code
Two bug-hunt findings that were doc inaccuracies, not code bugs:
- Tab title "(N)" is the mention/highlight count (+ "·" for other unread),
  mirroring the favicon — intentional. LOTUS_FEATURES said "N unread messages";
  corrected to describe the actual highlight-count behavior.
- Collapsible long messages use a fixed COLLAPSE_MAX_HEIGHT (320px ≈ 20 lines);
  the doc claimed a Settings → Appearance control that never existed. Corrected
  to describe the fixed threshold rather than build a marginal per-user setting.

Verified against ClientNonUIFeatures.tsx and MsgTypeRenderers.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:24:58 -04:00
jaredandClaude Opus 4.8 8fbde6df36 docs(todo): mark toast-cap + unread-sort findings fixed (1963222d)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:22:40 -04:00
jaredandClaude Opus 4.8 1963222d1e fix(ux): cap the in-app toast stack; stable "Unread First" room sort
- 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>
2026-07-24 17:22:23 -04:00
jaredandClaude Opus 4.8 d07f16586a docs(todo): mark push-rule + MSC1929-support findings fixed (2c0cd0d2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:06:19 -04:00
jaredandClaude Opus 4.8 2c0cd0d26c fix(settings): resync push-rule toggle from account-data; MSC1929 support host
- PushRuleEditor: the enable Switch initialized its state from pushRule.enabled
  once (useState initializer), so a rule toggled on another device left the
  Switch stale until remount. A useEffect now resyncs on pushRule.enabled
  change. pushRule flows from useAccountData(m.push_rules), which re-renders on
  sync, so the resync is genuinely reached; no optimistic-update conflict (the
  toggle sets state only after the PUT resolves).

- About: the "Homeserver Support" panel fetched /.well-known/matrix/support from
  the client-API URL (mx.getHomeserverUrl()). Per MSC1929 that file lives at the
  MXID server-name host (like /.well-known/matrix/client), which differs on
  delegated/split-domain servers. Now fetched from https://{mx.getDomain()};
  identical target for non-delegated servers (incl. Lotus), spec-correct for
  delegated ones, and degrades gracefully (catch → panel hidden) otherwise.

Bug-hunt findings from LOTUS_TODO. Two review agents; both confirmed effective
and non-regressing (full account-data re-render chain traced; CORS/host edge
weighed). Gate-green (tsc, eslint, prettier, 914 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:00:36 -04:00
jaredandClaude Opus 4.8 b0a3c81b15 docs(todo): mark 6 bug-hunt findings fixed (decorations, presence, denoise)
Closes the avatar-decoration cluster (live-update pub/sub, CDN-override
thumbnail, profile-404), the DND badge color, and the DenoiseTester
model-node leak + async mounted-guard — commits 29ff1654 and c9d9d914.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:44:44 -04:00
jaredandClaude Opus 4.8 c9d9d91415 fix(denoise-tester): dispose model nodes on playback stop; guard async lifecycle
Settings → Calls A/B denoise tester leaked audio resources:

- play() built a denoise model node (DeepFilterNet/DTLN worker/WASM) + optional
  gate but stopPlayback only closed the AudioContext, never disposing them —
  each A/B playback-through-a-model leaked a worker. stopPlayback now mirrors
  stopLive (gate.disconnect → model.dispose → node.disconnect).

- A generation token (playGenRef, bumped by stopPlayback) makes play() discard
  what it built if superseded during the async WASM/worklet load — closing the
  same leak in the rapid-Play-click race, the Stop-during-load case, and the
  unmount-during-load case, and stopping a superseded rejection from tearing
  down the winning playback.

- A mountedRef guards the getUserMedia paths (startLive/startRecord) so closing
  Settings during the mic permission prompt doesn't create untracked
  resources / setState-after-unmount; its effect sets true on mount (not only
  false on cleanup) so it survives a StrictMode/Activity same-fiber remount.

Bug-hunt findings from LOTUS_TODO. Three review passes: the first two confirmed
the base fixes and surfaced the concurrent-load leak + StrictMode fragility; a
third traced all six play() interleavings of the generation token. Gate-green
(tsc, eslint, prettier, 914 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:42:32 -04:00
jaredandClaude Opus 4.8 29ff16546a fix: avatar-decoration live-update + CDN override + profile 404; DND badge color
Avatar decorations (useAvatarDecoration.ts / ProfileDecoration.tsx):
- invalidateDecorationCache now notifies a per-user listener set (and clears the
  give-up counter), so changing your own decoration updates mounted avatars
  (timeline, member list) live instead of only after a remount. Concurrent
  re-fetches de-dupe via the existing `pending` map.
- Picker grid thumbnails use decorationUrl() instead of the raw DECORATION_CDN
  literal, so a VITE_DECORATION_CDN override no longer breaks the grid while
  real avatars work.
- Settings reads the full /profile/{userId} instead of the /{field}
  sub-resource, which 404s (console error) for anyone without a decoration set
  — matching the pattern already used by useAvatarDecoration.

Presence (Presence.tsx): PresenceBadge renders DND (unavailable + status 'dnd')
as red "Do Not Disturb" to match PresenceRingAvatar and the settings picker;
it was the lone outlier showing a yellow "Idle".

Bug-hunt findings from LOTUS_TODO. Two review agents (correctness +
upstream-behavior); gate-green (tsc, eslint, prettier, 914 tests, build). Both
flagged only pre-existing edge notes (in-flight piggyback staleness, 'dnd'
free-text collision shared with the ring avatar) — neither introduced here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:37:16 -04:00
jaredandClaude Opus 4.8 a5cc8a6d77 docs(todo): 5-agent feature bug hunt — open findings
Per-slice hunt over the LOTUS_FEATURES surface (theming / calls / messaging /
threads-presence-UX / rooms-mod-notif-infra-desktop), verified against current
code. Records ~20 residual findings (desktop-CSP missing Steam/Mixcloud/Deezer
frame-src hosts; DenoiseTester model-node leak; PiP auto-spotlight not released;
avatar-decoration no live update; DND badge shown as Idle; toast overflow;
Focus-Assist mount hydration; + Low tail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 03:12:05 -04:00
jaredandClaude Opus 4.8 59ec42564d docs(todo): record Steam detailed embed
CI / Build & Quality Checks (push) Successful in 11m8s
CI / Trigger Desktop Build (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:44:04 -04:00
jaredandClaude Opus 4.8 ef82650cf7 feat(embeds): detailed Steam store / news / app-widget embeds
Recognize store.steampowered.com content URLs and render each richly, within
the existing privacy-first facade. 2-agent reviewed (both SHIP).

- getSteamTarget / steamWidgetEmbedUrl (videoEmbed.ts, +tests): classify
  /app/{id}, /news/app/{id}/view/{gid}, /(bundle|sub|dlc)/{id}; non-content
  pages (home/search/wishlist) and other hosts fall through to the generic card.
- SteamCard now dispatches:
  - app → OG capsule header + click-to-play facade → Steam's OFFICIAL store
    widget iframe (store.steampowered.com/widget/{id}): live region-aware price,
    discount %, Buy on Steam. Nothing loads from Steam until "Show price &
    store" is pressed; gated by the inlineMediaEmbeds setting. App pages use the
    wide card so the ~646px widget has room.
  - news → rich announcement card (banner + headline + body preview + link) —
    your example URL previously fell through to the plain generic card.
  - bundle/sub/dlc → the OG store card.

Grounded in our CSP: the widget works via frame-src https: (no infra change),
images route through the homeserver (img-src excludes Steam), and there is NO
client-side Steam API call (connect-src + Steam CORS both block it) — which is
also the honest ceiling: no review scores/genres client-side, price/buy come
from the official widget.

Runtime QA still needed: the live widget iframe rendering (height/fit) can't be
verified headlessly.

Gates: tsc 0, eslint 0, prettier clean, 912 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:41:06 -04:00
jaredandClaude Opus 4.8 8e02cef658 docs(todo): prettier formatting (markdown emphasis _x_ not *x*)
CI / Build & Quality Checks (push) Successful in 11m4s
CI / Trigger Desktop Build (push) Successful in 7s
Fixes the prettier CI gate that failed on bc608b37 — check:prettier runs
`prettier --check .` over the whole repo (incl. markdown), and prettier's
markdown style uses `_italic_`. Prior doc commits slipped `*italic*` through
because I only ran prettier on changed src files, not the .md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 01:30:55 -04:00
jaredandClaude Opus 4.8 bc608b377a docs(todo): record inline-embed bug hunt — fixes + deferred items
CI / Build & Quality Checks (push) Failing after 6m27s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:36:31 -04:00
jaredandClaude Opus 4.8 f2673effe4 fix(embeds): parsing over/under-match + broken thumbnails + wide layout
Bugs found by a 3-agent audit of the inline-embed system (core posture —
sandbox, postMessage origin+source, XSS, noreferrer, oEmbed — verified sound);
fixes reviewed by 2 agents on the staged diff (both SHIP).

Parsing (videoEmbed.ts, + tests):
- Twitch/Kick/SoundCloud/Streamable reserved-path exclusion — their own utility
  pages (twitch.tv/directory, kick.com/browse, soundcloud.com/discover/…,
  streamable.com/login, bare /videos) no longer render as broken player embeds.
- SoundCloud: `/<artist>/<tab>` profile-tab listings excluded; `/<artist>/sets/<slug>`
  real sets still detected.
- Vimeo: unlisted-hash capture constrained to lowercase-hex, so a normal video's
  trailing segment (/likes, /settings, a slug) isn't captured as a bogus `h=`
  param that Vimeo then rejects.

Rendering (UrlPreviewCard.tsx, RenderMessageContent.tsx):
- Spotify/Steam/Discord/IMDb route og:image through mxcUrlToHttp like every other
  card — a raw og:image is an mxc:// URI (broken <img> on standard Synapse) or an
  off-homeserver request that defeats the click-to-play facade.
- `wide` card class now follows the RESOLVED embed (incl. the og:url short-link
  fallback), so an og:url-resolved player gets the wide layout, not a cramped one.
- Twitter host detection (isTwitter/isTwitterTweet) aligned with getTweetId —
  mobile.twitter.com and legacy /statuses/ now route to the Twitter card/embed.
- De-dupe preview URLs so a message repeating a link doesn't render sibling
  cards with identical React keys.

Gates: tsc 0, eslint 0, prettier clean, 910 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:35:59 -04:00
jaredandClaude Opus 4.8 f03c0ef960 test: cover cryptoDiagLog + closedLobbyCategories
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>
2026-07-19 22:38:02 -04:00
jaredandClaude Opus 4.8 8cc8dfd796 docs(todo): record CI hardening (concurrency + hard gates) + follow-ups
CI / Build & Quality Checks (push) Failing after 22m15s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:10:07 -04:00
jaredandClaude Opus 4.8 386a297997 ci: concurrency (cancel superseded) + promote typecheck/eslint/prettier gates
Reviewed by 2 agents + a focused deploy-script review (all SHIP).

- Add top-level `concurrency: cancel-in-progress`. A superseded lotus push
  cancels its in-flight run, freeing the shared act_runner (web CI otherwise
  queues behind long Tauri desktop builds); since `trigger-desktop` is
  `needs: build`, only the newest commit kicks a desktop build.
- Promote typecheck / eslint / prettier from `continue-on-error` to hard gates
  (tree held clean: tsc 0, eslint 0 errors, prettier formatted). eslint gates
  on errors only; existing no-explicit-any warnings stay informational.
- Mark the bundle-size report informational (audit already is).

Cancelling superseded runs is deploy-safe only because lotus_deploy.sh now
re-resolves origin/lotus each poll iteration (companion change in the matrix
repo); the comment documents the coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:09:10 -04:00
jaredandClaude Opus 4.8 36369926ca test: cover dom + emoji pure helpers; fix syntaxErrorPosition regex
Test-coverage expansion (2-agent reviewed, both SHIP). The named candidates
(roomToUnread, markedUnread, serverAcl, plaintextCaches, recent*) were already
tested, so this targets genuinely-untested pure logic.

- dom.test.ts: getThumbnailDimensions (scaling math incl. just-over-cap
  boundaries), tryDecodeURIComponent, syntaxErrorPosition, and the three
  scroll-view geometry helpers (via duck-typed element mocks — no jsdom).
- emoji.test.ts: getHexcodeForEmoji (astral codepoints, 4-digit zero-pad,
  FE0F/FE0E/200D stripping on and off, keycap sequences, degenerate inputs)
  and the pre-load `undefined` contract for getShortcode(s)For.

Fix (found while writing the tests): syntaxErrorPosition required whitespace
AFTER the digits (`/position\s(\d+)\s/`), but real V8/Node JSON.parse errors
put the number at end-of-string ("... at position N"), so it returned
undefined for every real error and the three dev-tools JSON editors silently
pointed their cursor at position 0. Dropped the trailing `\s`; tests now assert
extraction at end-of-string.

Gates: tsc 0, eslint 0, prettier clean, 891 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:49:51 -04:00
jaredandClaude Opus 4.8 015495c77d docs(todo): low-tail batch fixed (T5/T6/T7, C-L2/3/5, F5); remaining deferred
CI / Build & Quality Checks (push) Successful in 11m58s
CI / Trigger Desktop Build (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 02:47:56 -04:00
jaredandClaude Opus 4.8 a267e9e960 fix: low-tail correctness — thread notifs, call audio, OIDC expiry
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>
2026-07-19 02:47:27 -04:00
jaredandClaude Opus 4.8 291e14ab48 docs(todo): mobile r2 — embed-card stacking + secondary touch sweep done
CI / Build & Quality Checks (push) Successful in 10m42s
CI / Trigger Desktop Build (push) Successful in 10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:57:29 -04:00
jaredandClaude Opus 4.8 72e7447d28 fix(mobile): stack embed cards + secondary 44px touch targets (r2)
Mobile follow-ups round 2 (survey findings deferred from the mobile audit),
reviewed by 2 agents on the staged diff (both SHIP).

- URL-preview cards: the Twitch / Twitter / TikTok-fallback cards render
  their thumbnail/header BESIDE the content as direct children of the
  UrlPreview flex row, which squeezes both on a phone. Add `StackOnMobile`
  (@media max-width:750px -> flex-direction:column) scoped to those variants
  via cardClass. folds Box has no default `direction`, so the override wins
  uncontested; desktop (>750px) is unchanged. No-op for the single-column
  embed cards (MediaEmbedCard/TikTokEmbedCard).
- 44px touch targets (MobileTouchTarget, @media max-width:750px) on the
  otherwise ~28px controls: embed-player Close/Collapse/Fullscreen/View-post
  buttons; image-viewer close/zoom/download; the read-receipt "seen by" pill.

Deferred (rationale, not built): PiP resize handles + fullscreen button —
enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and
block "Return to call"; presence dot is a non-interactive status indicator.

Gates: tsc 0, eslint 0, prettier clean, 856/856 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:56:55 -04:00
jaredandClaude Opus 4.8 37d647d931 docs(todo): mobile follow-ups — P1 touch targets + P2 reduced-motion done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:30:31 -04:00
jaredandClaude Opus 4.8 c3e1fbfff5 fix(a11y): honor prefers-reduced-motion for avatar decorations (P2)
Avatar decorations are animated APNGs and were the only motion feature not
gated on prefers-reduced-motion (chat backgrounds / seasonal overlays all
suppress motion under it). Since there's no static-frame asset to freeze to,
render just the avatar (no decoration overlay) when the user prefers reduced
motion — the only motion-respecting option. Users without the preference are
unaffected; live OS-toggle is reactive via useReducedMotion. Also relieves the
mobile perf drain of dozens of live APNGs in scrolling lists.

Reviewed: correct a11y behavior, hooks-safe, no layout dependency on the overlay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:00:07 -04:00
jaredandClaude Opus 4.8 8a1168bc5f fix(mobile): 44px touch targets for primary call/thread/mod controls (P1)
Deep-audit follow-up. New shared MobileTouchTarget class (@media <=750px ->
minWidth/minHeight 44px) applied via className to the primary interactive
controls folds renders below 44px:
- in-call control bar (7 buttons) + persistent call-status bar (4 buttons)
- thread "N replies" chip
- knock Approve/Deny buttons
- server-ACL entry remove button

folds size variants set only padding (no width/height/min-*), so the class
raises the hit-area floor to 44px with the icon/label staying centered at its
normal size; desktop is untouched (@media-gated). Verified by two review passes
(no distortion, no layout overflow, counts exact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:57:55 -04:00
jaredandClaude Opus 4.8 1a3b1310b4 docs(todo): record mobile-audit code pass + deferred items
Mark the code-level mobile responsive audit done (M1-M6 + N1-N2 shipped) and
list what remains: runtime device QA, the Element Call fork in-call mobile UI,
M2/iOS touch discoverability, the sub-44px sweep, avatar-decoration reduced-
motion, and the Twitch/Twitter/TikTok card restructuring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:40:39 -04:00
jaredandClaude Opus 4.8 09f37f890f fix(mobile): image/video aspect-ratio so media doesn't crop/letterbox (N2)
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 6s
MImage/MVideo pinned AttachmentBox to a fixed height computed for a 400px-wide
layout. The box width is responsive (maxWidth:100%) but the height was frozen,
so on a phone the box narrows below 400px while keeping desktop height ->
images crop (object-fit:cover) and videos letterbox (object-fit:contain).

Drive the box by `aspect-ratio: w/h` when intrinsic dimensions are known, so the
height tracks the responsive width. On desktop the box stays 400px wide, so the
aspect-ratio yields the identical height (algebraically 400*h/w =
scaleYDimension(w,400,h)) — pixel-identical. Falls back to the fixed height when
dimensions are unknown; the 48px floor and 600px cap are preserved.

Uses the same pattern already shipped in this codebase (TwitchThumbnailWrapper,
GalleryTile). Two review passes, one empirically measuring the rendered image in
Chromium: desktop unchanged, narrow widths keep correct aspect, no collapse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:28:20 -04:00
jaredandClaude Opus 4.8 154e35ef9f fix(mobile): deep-audit structural fixes — dialogs, toasts, call bar (N1)
From the 6-agent deep per-feature audit. Mobile-gated / consistency fixes;
desktop unchanged except two intentional dialog-width normalizations noted below.

- In-call control bar: wrap="Wrap" on the SequenceCard so the compact two-group
  row wraps on the narrowest phones (<=390px) instead of pushing End off-screen
  (M1 fixed the 500-750px band; this covers narrower). Desktop stays one row.
- In-call soundboard popout: clamp maxWidth to the viewport (like M5's screenshare
  popover) so it can't overflow a narrow phone.
- Report-Message dialog + "Seen by" (EventReaders) modals (Message.tsx x2 +
  RoomViewFollowing): add useModalStyle so they go full-screen on mobile like
  their sibling report/receipt modals (they floated as fixed cards before).
- In-app toast container: full-width toasts inset from both edges on mobile
  (ScreenSize.Mobile); a fixed 280-340px card previously overflowed a narrow
  phone. Desktop byte-identical (bottom-right floating card).
- Policy-list tabs + audio-controls rows: wrap="Wrap" (inert on desktop).

Intentional desktop deltas (normalizing to existing sibling modals, verified by
two review passes as consistent, not regressions): Report dialog max-width
380->480px; EventReaders modals 460->360px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:22:59 -04:00
jaredandClaude Opus 4.8 36fdbdd399 fix(mobile): 44px touch targets for room rows + space rail (M6)
Mobile-audit batch 6. Bump the primary always-visible tap targets to a 44px
touch area on phones, via mobile-gated CSS only (desktop/tablet >750px keep the
denser sizing).

- NavItemBase (room/nav list row): minHeight 36 -> 44 at <=750px.
- SidebarItem (space-rail button): minWidth/minHeight -> 44 at <=750px (was 42).

The room lists are virtualized with ref=virtualizer.measureElement on every row,
so rows are measured to their actual height — the taller mobile rows can't
overlap/clip. Verified desktop-unchanged and virtualizer-safe by two review
passes. (A blanket app-wide size=300 button sweep was intentionally NOT done:
most such buttons are hover-gated and never appear on mobile; the primary
tap targets above are the high-value fixes.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:41:36 -04:00
jaredandClaude Opus 4.8 09415f95c0 fix(mobile): native settings controls + soundboard/gif/call polish (M5)
Mobile-audit batch 5. Desktop provably unchanged (two review passes).

- Translate-language control: raw <select> (crowded narrow tiles + broke under
  non-default themes) -> the folds-native SettingsSelect used by every other
  dropdown in the settings screen (native-cinny; keeps aria-label).
- Ringtone/Soundboard volume sliders: moved from the fixed-width tile `after`
  slot (which squeezed the title on phones) to a full-width slider in the tile
  body, matching the night-light slider pattern.
- Screenshare-confirm popover: clamp maxWidth to the viewport so it can't run
  past the screen edges on a phone (inert on desktop).
- In-call soundboard editor rows wrap on a narrow popout instead of crushing the
  clip-name field.
- GifPicker: feed the giphy Grid the measured container width (useElementSize
  Observer) instead of a fixed 296px, so it doesn't overflow a <312px phone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:38:01 -04:00
jaredandClaude Opus 4.8 4c298a36b4 fix(mobile): full-screen member profile + permissions row wrap (M4)
Mobile-audit batch 4. Desktop paths unchanged (verified by two review passes).

- UserRoomProfileRenderer: the member/room profile was always an anchored,
  fixed-width (340px), non-scrolling PopOut, so on a phone the moderation
  actions / device list / notes fell off the bottom, unreachable. On
  ScreenSize.Mobile it now renders a full-screen, internally-scrollable Modal
  with an explicit Close button (the full-screen sheet covers the backdrop and
  the profile has no self-close, so a tap-to-dismiss / X is required); desktop
  keeps the exact same anchored PopOut. Uses the provider-free useScreenSize().
- PowersEditor: the Color/Name/Power row wraps on narrow widths (wrap="Wrap")
  instead of squishing the name field; inert at desktop widths.

The mobile close button addresses a dismissal-trap both reviewers flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:30:50 -04:00
jaredandClaude Opus 4.8 836e4a6679 fix(mobile): full-screen media viewers + touch-pan for zoomed images (M3)
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.

- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
  of floating as an 85vw card. This also full-screens the file/PDF viewer and
  the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
  effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
  touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
  can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.

Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:24:26 -04:00
jaredandClaude Opus 4.8 d615999737 fix(mobile): overflow breaks — tables, composer, call bar, previews, cards (M1)
Mobile-audit batch 1. All fixes reuse cinny's own responsive primitives and are
mobile-gated so desktop is unchanged.

- Message tables: wrap <table> in an overflow-x container so a wide table scrolls
  instead of overflowing the message column / page body.
- Composer toolbar: let the before|editable|after row and the toolbar wrap on
  phones (@media <=750px) instead of squeezing the editable to zero and pushing
  Send off-screen.
- In-call control bar: collapse to the compact/stacked layout on a mobile
  viewport (ScreenSize.Mobile) too, not just when the bar's own container is
  <500px — fixes the 500-750px band where the control row overflowed.
- URL-preview card: base width toRem(400) -> min(25rem, 92vw) so a single card
  fits a narrow phone (still exactly 400px on desktop).
- Explore card grid: drop to one column at <=750px (was a fixed 3-col grid).

Two review passes: desktop behavior provably unchanged (all gated by @media /
ScreenSize.Mobile; the table wrapper only contains previously-overflowing tables).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:14:51 -04:00
jaredandClaude Opus 4.8 dcfee9f1df docs(todo): mark Discovery Pass 2 (PERF/SEC/COR) done
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
Replace the open Discovery-pass-2 list with a completion summary + commit refs
(PERF-1..5, SEC-1..4, COR-1..6 shipped this session, gate-green, each reviewed
by two agents). Record PERF-6 / SEC-5 as deferred-informational and note KE-1's
storage.persist() preventive is already implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:29:53 -04:00
jaredandClaude Opus 4.8 ab01d27aa7 fix(correctness): call-invite clock skew, forceState, upload cancel (COR-3/5/6)
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>
2026-07-18 21:28:31 -04:00
jaredandClaude Opus 4.8 3e1106b2d9 fix(security): tab-nabbing hardening + /acl self-lockout guard (SEC-3/4)
SEC-3: add `noopener,noreferrer` to the 5 `window.open(_blank)` sites that
don't use the returned handle (UserChips, OidcManageAccount, OtherDevices x2,
Verification), closing reverse tab-nabbing. SSOStage is intentionally excluded —
it needs the window handle + intact opener for its origin-checked SSO
postMessage handshake.

SEC-4: guard the `/acl` slash command against bricking the room.
- Extract the ACL glob helpers (isValidServerPattern/globToRegExp/matchesAnyGlob)
  from RoomServerACL into a shared utils/serverAcl.ts (+ unit test) so the
  command and the settings editor validate identically.
- Default a MISSING allow list to `*` only when the room has NO existing ACL
  (a first `/acl -d x` otherwise sent `allow: []`, which bricks the room); an
  existing ACL's absent/empty allow is preserved, not silently widened.
- Reject invalid globs; fail CLOSED on the universally-catastrophic cases
  (empty allow, or a `*` deny) even when the local domain is unknown; and reject
  any change that would ban this homeserver (self-lockout).

Guard hardened per two review passes (fail-closed on unknown domain; no silent
federation widening).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:23:35 -04:00
jaredandClaude Opus 4.8 1b8f554584 perf(receipts): shared member-change store instead of per-row listeners (PERF-3)
Every ReadReceiptAvatars row and every useMemberAvatar registered its own global
RoomStateEvent.Members listener — ~6 per receipt row — each firing on any
membership / display-name / avatar change in ANY room.

Add a module-level MemberChangeStore (mirroring the PERF-1 presence store) that
registers exactly ONE global Members listener and fans out to subscribers keyed
by roomId|userId. Two hooks: useRoomMemberChange (single) and
useRoomMembersChange (multi, one effect). useMemberAvatar and ReadReceiptAvatars
use them; behavior (re-render triggers) is byte-for-byte equivalent. Unsubscribe
is idempotent via a set-identity guard; the multi-hook key is order-independent.
Unit-tested (key-scoped fan-out, single shared listener, idempotent unsubscribe).

Reviewed by two passes (lifecycle/closure + behavioral equivalence) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:10:33 -04:00
jaredandClaude Opus 4.8 4708a17961 perf: memoize room-list sorts + gate DM-preview listener (PERF-2/4/5)
PERF-2 (RoomMentionAutocomplete): the #-mention list did
`useAtomValue(allRoomsAtom).sort(...)` inline — `.sort()` MUTATED the shared
allRoomsAtom array in place (reordering it for ~27 other consumers) and re-ran
the O(N log N) getRoom compare every keystroke. Copy then memoize:
`useMemo(() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)), ...)`.

PERF-4 (SearchFilters): the room-filter A-Z sort ran every render; wrap in
useMemo keyed on [searchResult, roomList, mx].

PERF-5 (useRoomLatestRenderedEvent + RoomNavItem): the hook registered a GLOBAL
client `Decrypted` listener for every nav item, but its result is only used for
DM rows. Add an `enabled` param (default true) that skips all work + listeners
when false; RoomNavItem passes `!!direct`. The only other caller keeps the
default.

Verified behavior-preserving by two review passes (PERF-2 also fixes a real
shared-atom mutation bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:05:39 -04:00
jaredandClaude Opus 4.8 8a15405189 perf(presence): shared presence store instead of per-avatar listeners (PERF-1)
CI / Build & Quality Checks (push) Successful in 10m50s
CI / Trigger Desktop Build (push) Successful in 7s
useUserPresence registered 3 client listeners (Presence / CurrentlyActive /
LastPresenceTs) PER hook instance. On a large room that meant 100-250 global
listeners, every presence event fanning out across all of them, with add/remove
churn on every fast scroll.

Replace with a module-level PresenceStore singleton that registers exactly 3
listeners total (lazily, on first subscriber) and fans out to per-user
subscribers itself. The hook keeps the same public API (useState + a subscribe
effect); consumers are unchanged. Cache + subscriber sets stay bounded to
currently-mounted users; the mx-swap branch re-homes listeners on re-login.

Reviewed by two passes (SDK mutate-before-emit ordering and handler signatures
independently verified). Includes their recommended hardening: the unsubscribe
is made idempotent via a set-identity check so a double-invoke / re-subscribe
can't evict a newer subscriber.

Note: a User object that appears silently with no presence event no longer
re-seeds (deps are [mx, userId] not [mx, user]); the common presence-EDU case
is handled (and better than before). Reviewers rated this narrow case Low.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:09:21 -04:00
jaredandClaude Opus 4.8 fd3b8b421e fix(spaces): unlink one space-child edge instead of over-deleting (COR-1)
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>
2026-07-18 17:04:14 -04:00
jaredandClaude Opus 4.8 1f80d1d129 fix(correctness): call-join reset on embed swap + per-path notify dedupe
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>
2026-07-18 17:00:52 -04:00
jaredandClaude Opus 4.8 726cefb5ab fix(privacy): wipe plaintext/PII localStorage caches on logout (SEC-1/2)
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>
2026-07-18 16:56:08 -04:00
jaredandClaude Opus 4.8 7c28ba58b2 docs(translation): document on-device message translation
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 7s
Add the feature to README (Messaging) and LOTUS_FEATURES (new
On-Device Message Translation section under Messaging Enhancements),
matching the existing style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:18:34 -04:00
jaredandClaude Opus 4.8 c77ab346d3 fix(translation): address review findings
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>
2026-07-18 16:18:27 -04:00
jaredandClaude Opus 4.8 ecb7b1a7fb feat(translation): on-device message translation
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>
2026-07-18 16:08:40 -04:00
jaredandClaude Opus 4.8 539901ec64 fix(status): unicode-only emoji picker (custom emojis silently did nothing)
CI / Build & Quality Checks (push) Successful in 11m25s
CI / Trigger Desktop Build (push) Successful in 6s
The status-message emoji picker listed the guild's custom/image-pack emojis, but
clicking one did nothing — the field only wires onEmojiSelect (unicode), not
onCustomEmojiSelect, so custom picks were silently dropped (the room composer
works because it wires both). A custom emoji is an mxc image and a status is
plain-text presence status_msg, so it can't render there anyway.

Add an EmojiBoard hideCustomEmojis (unicode-only) mode that zeroes the image
packs (removing pack groups, sidebar icons, and search results) and filters
custom entries out of Recent, and enable it on the status field. Now every emoji
shown actually inserts. Additive prop, default off — no change to other pickers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:16:55 -04:00
jaredandClaude Opus 4.8 0ce5e763ad fix(desktop): focus the native window when a notification is clicked
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-07-15 23:35:18 -04:00
jaredandClaude Opus 4.8 a8c99f2a45 a11y(polls): drop redundant aria-label on max-selections input
CI / Build & Quality Checks (push) Successful in 11m12s
CI / Trigger Desktop Build (push) Successful in 6s
Review noted the number input had both an htmlFor-associated visible label
("Voters can pick up to") and an aria-label, so the aria-label won and the
visible label was not announced. Remove the aria-label so the accessible name
matches the visible label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:09:01 -04:00
jaredandClaude Opus 4.8 a4660a8163 feat(polls): let creators set max selections for multiple-choice
The poll creator only offered single (max_selections 1) or multiple = pick ALL
options — no way to run a "pick your top 2" poll, even though the display side
already enforces an arbitrary max_selections ("Select up to N"). Add a "Voters
can pick up to N of M options" control shown for multiple-choice polls. Defaults
to the option count (preserving the old select-all behavior) until lowered;
clamped to [2, filled option count] on submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:06:32 -04:00
jaredandClaude Opus 4.8 85ac8de5d9 style: apply prettier across fork files
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>
2026-07-11 13:52:36 -04:00
jaredandClaude Opus 4.8 d727e7a7ab refactor(schedule): dedup formatSendAt into shared formatFriendlyDateTime
ScheduleMessageModal had a local formatSendAt(Date) byte-equivalent to the
tested formatFriendlyDateTime (utils/datetimeInput). Reuse the shared, unit-
tested helper instead of a second copy — identical output. (Also prettier-clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:29 -04:00
jaredandClaude Opus 4.8 3a1c626bc8 feat(stickers): "recently used" row in the sticker picker
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>
2026-07-10 22:43:17 -04:00
jaredandClaude Opus 4.8 fb8e0c6e14 fix(threads): enable slash commands in the thread composer
The thread composer already showed the /command autocomplete (RoomInput.tsx:954
was never gated), but the interpreter was disabled (:523), so /me, /shrug,
/invite, etc. sent literally in threads - a confusing inconsistency and the
other half of the threads "v1" limitation.

Remove the thread gate: content-transform commands (/me, /notice, /shrug,
/tableflip, /unflip) flow into the normal send path, which already routes to the
thread via threadRootId; the rest are room-level actions. No command sends a
mis-routed timeline message (verified against useCommands). Scheduling stays
disabled in threads for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:34:24 -04:00
jaredandClaude Opus 4.8 360e72f73c a11y(polls): associate voter list with its answer for screen readers
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 6s
From review: the per-answer voter line sat inside the radiogroup with no
association, so a screen-reader user on the radio didn't hear who voted. Add
aria-describedby from each answer to its voter line (id poll-voters-<eventId>-
<answerId>) and prefix the line with "Voted by" for a clearer announcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:00:27 -04:00
jaredandClaude Opus 4.8 1aaea09fc9 feat(polls): "See who voted" — per-answer voter list
The poll card showed vote counts but never who voted, even though
computePollState already parses a sender for every response. Surface it:

- tallyResponses now also returns voters: Map<answerId, senderId[]>, built in the
  same latest-response-per-sender loop as the counts, so voters can never disagree
  with the numbers (voters.get(id).length === counts.get(id)). +5 unit tests.
- PollContent adds a "Show who voted" toggle, shown only when results are visible
  (disclosed live, or undisclosed after end — so a secret ballot stays secret).
  When on, each answer lists its voters' display names (getMemberName), rendered
  as a sibling of the answer button so the radiogroup keyboard model is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:56:50 -04:00
jaredandClaude Opus 4.8 5cce94edba refactor(notifications): quiet-hours uses shared tested time-window helper
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>
2026-07-10 20:43:08 -04:00
jaredandClaude Opus 4.8 f155a4dc22 feat(threads): up-arrow edits your last thread reply (+ fix cross-fire)
The thread composer reused RoomInput's hardcoded editableName="RoomInput", so
the main timeline's global up-arrow "edit last message" handler fired while
focused in a thread composer and targeted the MAIN timeline's last message
(wrong), and there was no up-arrow edit for the thread itself.

- Make editableName a RoomInput prop (default "RoomInput"); the thread composer
  passes "ThreadInput", so the two up-arrow handlers never cross-fire.
- Add an up-arrow-edit handler to ThreadTimeline (parity with RoomTimeline):
  empty thread composer + Up -> edit the latest editable reply in that thread,
  using thread.liveTimeline + canEditEvent + setEditId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:28:26 -04:00
jaredandClaude Opus 4.8 61f1733f50 feat(notifications): cross-platform "Pause Notifications" / snooze
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>
2026-07-10 19:08:02 -04:00
jaredandClaude Opus 4.8 e7e6d44a31 feat(image-viewer): scroll wheel to zoom
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 18s
The timeline image viewer only zoomed via the -/+ buttons and the % toggle. Add
scroll-to-zoom (wheel up = in, down = out) for parity with the media-gallery
lightbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:58:21 -04:00
jaredandClaude Opus 4.8 829525506c fix(image-viewer): pan tracks the cursor 1:1 when zoomed
The timeline image viewer's transform applied translate inside scale(), so
dragging a zoomed image moved it by zoom x the cursor distance (panning outran
the pointer). Divide the pan offset by zoom so it tracks 1:1 - matching the
media-gallery lightbox fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:57:33 -04:00
jaredandClaude Opus 4.8 dcad282749 fix(jump-to-time): friendly message when homeserver lacks MSC3030
Jump to Time uses timestampToEvent (MSC3030). On a homeserver that doesn't
support it, the dialog showed the raw "M_UNRECOGNIZED: Unrecognized request"
error. Show a clear explanation instead when errcode is M_UNRECOGNIZED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:22:43 -04:00
jaredandClaude Opus 4.8 4d78d427d2 fix(message): gate "Copy Text" to textual message types
Review noted media without a caption has a filename body, so Copy Text showed
and copied the filename. Gate to m.text/m.emote/m.notice so it only appears for
actual text messages (matching the intended behavior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:20:58 -04:00
jaredandClaude Opus 4.8 12aa49f054 feat(message): add "Copy Text" context-menu action
The message menu had Copy Link (permalink) but no way to copy the message text
itself. Add a Copy Text item that copies the plain-text body with the reply
fallback stripped (trimReplyFromBody). It renders nothing when there is no
usable text body (e.g. media without a caption), so the caller can list it
unconditionally next to Copy Link. Uses Icons.Text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:17:52 -04:00
jaredandClaude Opus 4.8 df8afe4410 fix(location): wrap long MSC3488 descriptions
Constrain the rendered location description width and break long tokens so an
oversized description from another client can't overflow the message bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:14:09 -04:00
jaredandClaude Opus 4.8 ea6e89ae56 feat(location): render MSC3488-only locations (uri + description)
The m.location renderer only read the legacy top-level geo_uri, so a location
from a client that sends only the MSC3488 shape (uri under
org.matrix.msc3488.location / m.location) showed as broken. Fall back to that
uri, and display an MSC3488 description above the coordinates when present.
Closes the consume-side gap noted in review of the send-side MSC3488 change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:12:01 -04:00
jaredandClaude Opus 4.8 75b94bd39f fix(night-light): warn when schedule start equals end
From review: equal start/end silently disables the tint (zero-length window).
Show an inline hint so the user isn't left wondering why nothing happens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:09:33 -04:00
jaredandClaude Opus 4.8 2d8bc487e5 feat(location): send MSC3488-compliant shared-location events
Location sharing sent only a legacy geo_uri with a "Location: geo:..." body, so
other clients often rendered it as plain text. Include the MSC3488 blocks
(org.matrix.msc3488.location/asset/ts + m.ts) alongside geo_uri and a readable
body, so Element and others render a proper location pin. Local rendering is
unchanged (still reads geo_uri).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:08:32 -04:00
jaredandClaude Opus 4.8 cf4ee6618c feat(night-light): optional auto-on schedule (time window)
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>
2026-07-10 16:05:56 -04:00
jaredandClaude Opus 4.8 23950a6797 fix(invite): robust QR filename + encode-failure feedback
From review: sanitizeFilename now denylists only path-hostile + control chars
(preserving Unicode room names instead of collapsing CJK/emoji names to "room"),
converts whitespace to dashes, trims separator runs, and caps length. Also
surface an inline error when canvas.toBlob returns null so a failed PNG encode
isn't a silent no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:03:04 -04:00
jaredandClaude Opus 4.8 a1107015fd feat(invite): download the room QR code as a PNG
The invite QR was display-only. Add a "Download QR" button that exports it as a
PNG via an offscreen high-resolution (1024px, spec 4-module quiet zone)
QRCodeCanvas + canvas.toBlob, saved through useSaveFile (filename from the room
name) with the standard download toast. The visible code stays an SVG so it
renders crisply at any size/theme.

Also corrects the stale LOTUS_FEATURES note (the QR is generated locally via
qrcode.react, not api.qrserver.com).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:57:19 -04:00
jaredandClaude Opus 4.8 4a3a80df9f fix(reminders): make cancel optimistic, no contradictory error
The shared account-data store removes optimistically with no rollback, so the
reminder row vanished the instant Cancel was clicked. Showing a "could not
cancel" error beside the already-gone row (and it reappearing on next sync) was
self-contradictory. Match the removeBookmark convention: fire-and-forget
optimistic removal, no inline error. Drops the now-moot cancelling busy-guard
and uses a collision-safe React key for same-minute custom reminders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:56:22 -04:00
jaredandClaude Opus 4.8 7d67cdeef0 feat(reminders): view and cancel a message's existing reminders
There was no way to see or cancel a reminder once set (removeReminder was only
called by the fire-and-forget monitor), and addReminder didn't dedupe, so a
message could silently accumulate duplicate reminders. The Remind Me dialog now
lists the reminders already set on that message (soonest first) each with a
cancel button.

- New shared, tested formatFriendlyDateTime(ts, now?) in utils/datetimeInput.ts
  (Today/Tomorrow/date + time).
- Per-row cancel busy-guard; inline "Could not cancel" on failure.

Also applies two nits from the custom-time review: focus the date input when the
custom picker is revealed, and clear the error when editing date/time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:51:11 -04:00