Compare commits

...
42 Commits
Author SHA1 Message Date
nathan 6d9ee10926 Merge remote-tracking branch 'origin/lotus' into read-me-update
CI / Build & Quality Checks (pull_request) Failing after 4m59s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 20:29:04 -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
nathan f136082181 Merge remote-tracking branch 'origin/image-path-changes' into read-me-update
CI / Build & Quality Checks (pull_request) Successful in 10m48s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 20:04:59 -04:00
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan 6a0037ecec read me updates with instructions for running a dev server
CI / Build & Quality Checks (pull_request) Failing after 5m54s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:04:25 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -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
Claude e1bb8301f0 fix(composer): collapse mobile action buttons behind a "+" overflow menu
On phones the composer's 7-8 secondary action buttons wrapped into a tall
multi-row stack ("massive height"). Mobile now shows a single compact row —
[ + | input | emoji | send ] — where "+" toggles a collapsible row (above the
formatting toolbar) holding attach, GIF, location, poll, voice, formatting and
schedule. Desktop is unchanged (isMobile === false; the mobile branches are
never entered and composerOverflow stays null).

The after-builder stashes the collapsed buttons in a render-local `let` that
the bottom slot reads; safe because JSX props evaluate in source order within
one render (verified by review). Emoji/Send stay inline; the emoji and GIF
PopOut anchors still resolve wherever their button renders.

Review fixes folded in: the "+" toggle uses aria-expanded + aria-controls
(dropped the redundant aria-pressed) pointing at the labelled role="group"
overflow row; the voice recorder's idle mic button gets the @media-gated
MobileTouchTarget 44px target so the overflow row is uniformly tappable.

Two review agents (correctness + UX/a11y); gate-green (tsc, eslint, prettier,
914 tests, build). Visual confirmation still wants a real device per
LOTUS_TESTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:44:13 -04:00
Claude 098e3c900f docs(todo): park Matrix 2.0 / MSC4354 sticky-events call rollout
Records the 3-agent + live-infra investigation into moving MatrixRTC call
membership to sticky events. Conclusion: enabling msc4354_enabled on Synapse
is low-risk/reversible but a no-op by itself (EC stays in Legacy mode behind a
per-device dev toggle; fleet is single-hosted so upgrades atomically). The one
unverified risk is media-layer interop (lk-jwt /get_token vs /sfu/get resolving
to the same LiveKit room) — needs a two-account cross-mode test before any
default change. Parked as a scoped future rollout, not a flag flip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:33:56 -04:00
Claude 6cbd7337f4 docs: record GIF/embed fixes, Synapse 1.157.1 caps, MSC4354 decision
- Mark the desktop Tauri CSP frame-src finding FIXED (cinny-desktop daba59b).
- Add the GIF-animation fix + Mixcloud/Deezer embeds write-up (4154cae5),
  incl. the Deezer /show vs /podcast correction.
- New section: Matrix 2.0 call membership (MSC4354 sticky events) —
  investigated across 3 agents, deliberately NOT enabled. Records why the
  server flag is a no-op alone (EC gates it behind a per-device developer
  setting defaulting to Legacy), what was verified safe, and the one open
  risk (lk-jwt-service LiveKit-alias mapping across the two JWT endpoints)
  that a two-account test call must settle before any rollout.
- Refresh Server Capabilities: Synapse 1.157.1, MSC list re-dumped live from
  /_matrix/client/versions, note that msc4143 is not a real gap (LiveKit is
  discovered via .well-known) and that msc3861 client code is now dead.
- Note the blocked-feature re-check found no change on 1.157.1.
- Drop a verbatim-duplicated "remaining providers" heading; provider count
  16 -> 18.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:32:44 -04:00
Claude 4154cae55a fix(embeds): animate GIF previews; add Mixcloud/Deezer; misc embed fixes
CI / Build & Quality Checks (push) Successful in 11m21s
CI / Trigger Desktop Build (push) Successful in 34s
GIF previews rendered but never played: Synapse's /thumbnail endpoint
flattens animated GIFs to a still first frame. GifCard and the generic OG
card now request the original via /download (no width/height) for GIFs, so
they animate. Guarded with shouldServeGifOriginal(): a matrix:image:size cap
(10 MB) keeps a huge self-hosted GIF on the frozen thumbnail, and the generic
card's eager <img> gains loading="lazy" (it was the one preview image missing
it) so originals stay off the wire until near the viewport.

Also adds Mixcloud + Deezer inline media embeds (iframe widgets via
parseMediaEmbed/MediaEmbedCard, matching the existing click-to-play pattern),
and fixes Deezer podcast links: they live at /show/<id>, not /podcast/<id>
(the latter 404s on Deezer's own oEmbed) — verified against the live API.

Reviewed by two agents; both findings (Deezer /show, GIF eager-load) fixed
and covered by tests. Desktop Tauri frame-src CSP updated separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 00:21:10 -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
47 changed files with 1459 additions and 150 deletions
+9 -8
View File
@@ -1019,10 +1019,12 @@ Fixed by replacing the single read with a `readStatus()` function called inside
The browser tab title updates to reflect unread state:
- `(N) Lotus Chat` — N unread messages
- `· Lotus Chat` — unread activity without a specific count
- `(N) Lotus Chat` — N mentions / keyword highlights (the count is highlights, not total unread)
- `· Lotus Chat` — unread messages without a mention (activity, no specific count)
- `Lotus Chat` — no unread items
The favicon mirrors this (highlight badge / unread dot / default).
### Extended Profile Fields
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
@@ -1094,10 +1096,9 @@ OS-level notifications are unchanged and still fire when the window is not focus
### Collapsible Long Messages
Messages exceeding a configurable line threshold are truncated with a "Show more" toggle.
Messages exceeding a fixed height threshold are truncated with a "Show more" toggle.
- Default threshold: 20 lines
- Threshold is configurable in **Settings → Appearance**
- Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
- Transition is disabled when `prefers-reduced-motion: reduce` is active
@@ -1288,9 +1289,9 @@ Features:
Accessible via **Room/Space Settings → Policy Lists** (admin only).
- Displays the room's subscribed policy lists in read-only format
- Subscribe (join) and unsubscribe (leave) controls for each list
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
- Enter a policy-list room's **ID or alias** (one you have already joined) to view its `m.policy.rule.user` / `.room` / `.server` rules in read-only format
- Viewer only — there are **no** subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
- Enforcement is delegated to Draupnir or equivalent tooling
---
+25 -1
View File
@@ -1,6 +1,6 @@
# Lotus Chat — Manual Testing Guide
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
@@ -28,6 +28,30 @@
---
## Automated coverage map — what the unit tests already pin (2026-07)
**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
| Guide item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
| **B2/B3** poll voting | `poll.test.ts` (18) — vote tally, latest-per-sender, multi-select, cleared/re-vote, winners, results-visible, single-vs-multi validation | **visual** only: borders, radio-vs-checkbox, progress-bar fill, on each theme |
| **O2/P4-1** thread notifications | `threadNotifications.test.ts` (32) — the **entire** notify decision (participating default, All/Mentions/Mute, @mention+highlight override, room-mute trumps), mode-map + muted-badge hygiene | live **2-person** delivery + sound + **cross-device** account-data sync |
| **O3** math / LaTeX | `mathParse.test.ts` (14) — inline `$…$`, block `$$…$$`, **currency guard** (`$5 and $10`), escaped/unbalanced stay text, adjacency rules | KaTeX **renders** visually + lazy-chunk load; code-block-literal is the markdown pipeline |
| **O4/P4-8** encrypted search cache | `searchCache.test.ts` — the pure helpers `mergeSearchResults` (merge/dedupe/sort) + `computeCoverage` (window widening) + resilient-when-IDB-absent. **The IDB round-trip test is `skip`ped under `npm test`** (node has no IndexedDB), so it runs only in a browser-like env, not CI | the actual IndexedDB persist-across-**reload**, Clear button, **logout wipe** (integration — and the round-trip itself) |
| **M1** `has:image/file/video` | `useMessageSearch.test.ts``filterGroupsByMsgType` union filter, drops empty groups, ignores non-string msgtype | the chips render + compose with room/sender/date filters |
| **M4** pinned-only filter | `useMessageSearch.test.ts``filterGroupsByPinned` keeps pinned, drops empty | chip renders; needs a room with actual pins |
| **M2** recent searches | `recentSearches.test.ts` (6) — prepend, dedupe+move-to-front, trim, ignore-empty, cap-at-10 | chips render/click-re-run; persistence across refresh |
| **Retention** (disappearing msgs) | `retention.test.ts``isExpired` window math (strict boundary), disabled = never, preset monotonicity | the timeline **hide** + self-**redact** integration; Synapse-side purge |
| **O5/N97a** session hardening | `sessions.test.ts` (22) — blob migration, legacy-key coercion, dual-write blob↔legacy sync, corrupt/partial-blob fallback, token-refresh, AND the `subscribeSessionChanges` storage-event logic (fires on session/null, ignores unrelated keys) | the real **cross-tab** logout _behavior_ end-to-end (two live tabs) |
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts``resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").
---
## A. Calls — new ringtone + notification work (highest priority)
### A1. Ringtone selection — preview in Settings
+88 -8
View File
@@ -91,6 +91,62 @@ Agent-surveyed findings, each **verified against the code before fixing**, then
- [DEFERRED] **SEC-5 — embeds' `allow-popups-to-escape-sandbox`** — informational; main-app hijack already prevented (no `allow-top-navigation`), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped.
- **KE-1 preventive (`navigator.storage.persist()`)** is **already implemented** (`initClient``requestPersistentStorage()` + `src/index.tsx` boot). The rest of the KE cluster stays under **Encryption / E2EE** below (needs live capture).
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
**Embeds / URL previews**
- [x] **[Med] Desktop (Tauri) CSP `frame-src` was missing `store.steampowered.com`, `www.mixcloud.com`, `widget.deezer.com`** → the Steam widget (shipped) + new Mixcloud/Deezer embeds were silently blocked (blank iframe) **in the desktop app**. **FIXED** (`cinny-desktop` `daba59b`): all three added to `frame-src` (no `connect-src` — these don't do a client oEmbed fetch). Web was always fine (`frame-src 'self' https:`). Needs desktop-app QA to confirm the widgets render.
- [x] **[Low]** `searchCache.ts` encrypted-search index has no size/count cap — unbounded on-disk growth (mitigated by the manual "Clear cached index" + logout wipe). **FIXED** (`fff811cb`): per-room cap of 5000 rows, oldest-by-ts evicted on write via a self-chaining IDB cursor + pure unit-tested `evictCount`. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB.
- [x] **[Low]** `MsgTypeRenderers.tsx` `MLocation` OSM permalink uses raw `geo:` lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). **FIXED** (`8a461610`): permalink uses the `parseFloat`+`isFinite` validated `lat`/`lon` (as the map iframe already did).
**Voice / video calls**
- [x] **[Med]** `DenoiseTester.play()` (Settings → Calls A/B model test) leaks the denoise model node — calls `ctx.close()` but never `denoise.dispose()` (inconsistent with `stopLive`, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. **FIXED** (`c9d9d914`): `stopPlayback` now mirrors `stopLive` (dispose model + gate), and a generation token also closes the rapid-click / stop-during-load / unmount-during-load leak windows (3 review passes, all 6 interleavings traced).
- [x] **[Med] [live]** PiP auto-spotlight never released on return to the call room — the release branch sits inside the `if (!pipMode) return` guard, so screenshare→PiP→back leaves spotlight forced on and `pipAutoSpotlightRef` stuck `true`. `CallEmbedProvider.tsx:733-744`. **FIXED** (`08e19100`, code-level; still wants live QA): effect guards only on `!callEmbed`, releases whenever `pipMode && pipScreenshare` is false; + ref-reset on embed teardown + deps comment (2-agent reviewed).
- [x] **[Low]** DenoiseTester async paths (`getUserMedia`) have no mounted-guard → ctx/stream leak + setState-after-unmount if Settings closes during the mic prompt. **FIXED** (`c9d9d914`): a `mountedRef` guards `startLive`/`startRecord` after the `getUserMedia` await (and `play()` after its model load); the ref is set on mount, not only cleared on unmount, so it survives a StrictMode/Activity remount.
- [x] **[Low]** Soundboard 30s safety timeout never cleared on natural clip end (`CallSoundboard.tsx:115`); `PrescreenControls` `PermissionStatus.onchange` not removed on unmount (`PrescreenControls.tsx:22-28`). **FIXED** (`56561627`): per-play timer token cleared on end/unmount (identity-guarded so a stale clip can't disarm a newer one); permission `onchange` detached + `cancelled`-guarded setState.
- [ ] **[Low] [live]** Call-to-call switch disposes the embed without an explicit `HangupCall` → possible transient ghost RTC membership until EC's unload-leave fires.
**Theming / visuals**
- [x] **[Med]** `invalidateDecorationCache` clears the module cache but has no pub/sub → changing **your own** avatar decoration doesn't update live in already-mounted avatars (timeline/members) until remount. Add a listener set / bump counter. `useAvatarDecoration.ts:67`. **FIXED** (`29ff1654`): per-user listener set notified on invalidation (+ clears the give-up counter); concurrent re-fetches de-dupe via the existing `pending` map.
- [x] **[Med/Low]** Decoration picker grid thumbnails use the raw `DECORATION_CDN` constant instead of `decorationUrl()`, ignoring the `VITE_DECORATION_CDN` override → broken thumbnails if decorations are repointed. `ProfileDecoration.tsx:51`. **FIXED** (`29ff1654`): grid uses `decorationUrl(slug)`.
- [x] **[Low]** Seasonal "Auto" is computed once at mount (no ticker, unlike NightLight) → won't flip across a holiday-window boundary in a long-lived session. `SeasonalEffect.tsx:100`. **FIXED** (`d416c62b`): hourly re-eval ticker (auto only) + refresh on entering auto; decision extracted to pure `resolveSeasonTheme` + tested.
- [x] **[Low]** Selecting seasonal "Auto" while a chat background is set is a silent no-op (asymmetric mutual exclusion — SeasonalEffect early-returns when `chatBackground !== 'none'`). `General.tsx:550`. **FIXED** (`d416c62b`): any active seasonal mode (incl. auto) now clears the chat background; only "off" leaves it (symmetric with the bg picker).
- [x] **[Low]** Decoration settings fetch the `/{field}` sub-resource → console 404 for users with no decoration set. `ProfileDecoration.tsx:79`. **FIXED** (`29ff1654`): reads the full `/profile/{userId}` (matching `useAvatarDecoration`); PUT/save path unchanged.
**Threads / presence / UX**
- [x] **[Med]** `PresenceBadge` renders DND (`unavailable` + `status_msg:'dnd'`) as a **yellow "Idle"** badge + label, while `PresenceRingAvatar` correctly shows **red** — inconsistent. Give the badge the same `status === 'dnd' → Critical` + "Do Not Disturb" branch. `Presence.tsx:17-59`. **FIXED** (`29ff1654`): badge now matches the ring + settings picker (Critical / "Do Not Disturb", `'dnd'` sentinel line suppressed).
- [x] **[Med]** Collapsible-message threshold is hardcoded (`COLLAPSE_MAX_HEIGHT = 320`), but the docs claim it's "configurable in Settings → Appearance (default 20 lines)" — unimplemented. Add the setting + control, or fix the doc. `MsgTypeRenderers.tsx:38`. **FIXED** (doc): LOTUS_FEATURES now describes the fixed 320px (≈20-line) threshold; the full 320px is sensible and a per-user setting wasn't worth the surface — reconciled the doc rather than build a marginal setting.
- [x] **[Med/Low]** In-app toast container has no visible cap / scroll — a burst of messages across rooms while focused stacks toasts unbounded and can cover the viewport. Cap visible N or `overflow-y:auto` + max-height. `LotusToastContainer.tsx:223-247`. **FIXED** (`1963222d`): queue capped at 5 in the atom writer (drops oldest non-sticky, never the newest or a sticky action toast) + container maxHeight/overflow + scroll-to-newest; +4 tests. (3 review passes — the 2nd caught a newest-dropped edge when the cap is full of stickies.)
- [x] **[Low]** "Unread First" room sort leaves the (larger) read portion unordered — no activity fallback for the equal-unread case. `Home.tsx:213-222`. **FIXED** (`1963222d`): `factoryRoomIdByUnread` breaks ties by recent activity; relocated to `utils/sort.ts` (pure) + unit-tested.
- [x] **[Low]** Tab title "(N)" counts mentions, not unread messages (doc says unread) — reconcile doc vs. code. `ClientNonUIFeatures.tsx:120-123`. **FIXED** (doc): the mention-count + unread-dot behavior is intentional (mirrors the favicon); LOTUS_FEATURES now describes it accurately (N = highlights, `·` = other unread).
**Rooms / moderation / notifications / infra / desktop**
- [ ] **[Med] [desktop]** `useTauriFocusAssist` never queries the initial OS Focus-Assist state on mount (unlike `useTauriDnd`, which rehydrates via `get_tray_dnd`) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add a `get_focus_assist` mount query (confirm whether the native poll emits an initial reading). `useTauriFocusAssist.ts:18-24`.
- [x] **[Low]** Push-rule enable toggle holds stale local `useState` after an external rule change (toggled on another device) — sync from the `pushRule.enabled` prop. `PushRuleEditor.tsx:55-79`. **FIXED** (`2c0cd0d2`): `useEffect` resyncs on `pushRule.enabled` change (prop flows from live `useAccountData(m.push_rules)`; no optimistic conflict).
- [x] **[Low]** Server-support `.well-known/matrix/support` is fetched from `mx.getHomeserverUrl()` (client-API host) instead of the MXID **server-name** host → silently missing on delegated/split-domain servers. `About.tsx:45-47`. **FIXED** (`2c0cd0d2`): fetched from `https://{mx.getDomain()}` (MSC1929-correct); identical for non-delegated, graceful catch otherwise.
- [x] **[Low]** Cleared/partial quiet-hours `time` input (`''` → window inactive) silently disables the window while the toggle still reads "on" — no feedback. `SystemNotification.tsx:364-382`. **FIXED** (`5175c095`): inline Critical hint when the toggle is on but a time field is empty.
- [~] **[Low] [desktop]** Native quick-reply swallows send errors (`.catch(() => undefined)`); the `show_rich_toast` trigger has no verified web-side caller. `useTauriToastActions.ts:35-38`. **ROOT CAUSE FOUND + web fix shipped** (`0ddf86c6`): `show_rich_toast` was dead because `showOsNotification` preferred the service worker (WebView2 has one), shadowing the injected `window.Notification` shim. Now skips the SW path under Tauri → notifications route to the rich toast, whose click navigates to the message.
### 🖥️ Desktop notification rich-toast — follow-ups (activated by `0ddf86c6`, need a Windows build)
The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the first time. It fixes click→navigate, but exposes latent behaviors in the **cinny-desktop Rust** that need a Windows build to fix + verify:
- [ ] **[Med] [desktop]** **Tag-coalescing lost.** The web SW notification used `tag` to _replace_ prior notifications for the same room; `show_rich_toast` (`cinny-desktop/src-tauri/src/native/toast.rs`) ignores `tag` and shows a new WinRT toast every time → rapid same-room messages stack instead of collapsing. Fix: dedupe/replace by room in the toast store (`toast.rs:226-230`).
- [ ] **[Med] [desktop]** **Thread / invite quick-reply misroutes.** The reply target is the coalescing `tag``${roomId}:${threadId}` for thread replies, `'lotus-invites'` for invites (`ClientNonUIFeatures.tsx:471,192`) — not a real room id, so `mx.sendMessage(tag, …)` fails silently (`useTauriToastActions.ts:37`). Body-click navigation is correct (uses `path`). Fix: pass the real `roomId` separately (e.g. `data.roomId`) and have the shim (`lib.rs` `NOTIFICATION_BRIDGE`) + `toast.rs` use it for the reply target; keep `tag` for coalescing. Invite toasts should also drop the reply box (nothing to reply to).
- [ ] **[desktop QA] Windows notification checklist** (verify `0ddf86c6` + the above): (1) confirm the pre-fix symptom was focus-without-navigate; (2) message toast → click navigates to the message, quick-reply sends to the room; (3) thread toast → navigates, reply currently misroutes (until fixed above); (4) invite toast → navigates to invites; (5) rapid same-room messages → stacking until coalescing restored; (6) AUMID-missing/dev build → plain-notification fallback still shows; (7) web PWA unaffected.
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
@@ -149,6 +205,31 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
Findings:
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy``Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
### Remaining spec/MSC gaps (2026-07 full-surface survey)
After Phases AC the client spec is ~complete. What's left, flagged by **what unblocks it**:
@@ -221,11 +302,7 @@ Intentional desktop deltas (disclosed, non-regressive): volume sliders below lab
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
@@ -235,6 +312,8 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
**✅ Steam detailed embed (2026-07, 2-agent review) — `ef82650c`.** `store.steampowered.com` content URLs get rich cards: **app** pages → OG capsule header + click-to-play facade → Steam's official `/widget/{id}` store iframe (live region-aware price / discount % / Buy on Steam, gated by `inlineMediaEmbeds`); **news/announcement** → banner + headline + body-preview card; **bundle/sub/dlc** → OG store card. `getSteamTarget`/`steamWidgetEmbedUrl` in `videoEmbed.ts` (+tests). Grounded in prod CSP (`frame-src https:` allows the widget with no infra change; images via homeserver `mxc`; NO client-side Steam API — `connect-src` + Steam CORS both block it, which is the honest ceiling: no review scores/genres/screenshots client-side). **Needs on-device QA:** the live widget iframe height/fit (can't render headlessly) — verify the price/Buy stay visible on desktop-wide and phone.
**✅ GIF previews now animate + Mixcloud/Deezer embeds (2026-07, 2-agent review) — `4154cae5`.** Reported live: a `media.giphy.com` link "shows the gif's image but doesn't play it." Root cause: **Synapse's `/thumbnail` endpoint flattens animated GIFs to a still first frame**, and every preview image went through it. `GifCard` (Giphy/Tenor) + the generic OG card now request the **original** via `/download` (`mxcUrlToHttp` with no width/height) when the preview is a GIF (`og:image:type === 'image/gif'` or a `.gif` pathname). Guarded: `shouldServeGifOriginal()` keeps the frozen thumbnail past a **10 MB** `matrix:image:size` cap, and the generic card's eager `<img>` gained the `loading="lazy"` it was the only preview image missing. Also added **Mixcloud + Deezer** audio embeds, and fixed Deezer podcasts (they live at `/show/<id>`, **not** `/podcast/<id>` — the latter 404s on Deezer's own oEmbed; verified against the live API). **Needs on-device QA:** confirm a large GIF still animates and doesn't stall the timeline.
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
@@ -252,7 +331,7 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade.
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
@@ -265,8 +344,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
### Server Capabilities (as of 2026-07)
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.156.0+trixie1` (upgraded 2026-07-07 from 1.155; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
+49 -1
View File
@@ -13,7 +13,38 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
---
## Development Environment Setup
#### Getting correct Node version
- Ensure you have the correct version of node installed, specified in `.node-version`
- Use this command from the terminal to install nvm
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
```
- Reload your terminal shell using (for Ubuntu):
```bash
source ~/.bashrc
```
- Install the specified Node version
```bash
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
nvm install "$NODE_VERSION"
nvm use "$NODE_VERSION"
```
- verify the correct version was installed by running
```bash
node --version
```
and comparing the output to what is listed in `.node-version`
#### Install npm packages
- To install the npm packages listed in `package.json` run:
```bash
npm i
```
### Start Development Server
```bash
npm run start
```
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
## Features
### Messaging
@@ -167,6 +198,23 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### Local Development
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
```bash
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
npm start # Vite dev server → http://localhost:8080
```
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
**Which homeserver / logging in:** `config.json` sets `defaultHomeserver: 0``matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
+18 -2
View File
@@ -731,8 +731,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
// When screenshare ends, release the spotlight we auto-enabled.
const pipAutoSpotlightRef = React.useRef(false);
useEffect(() => {
if (!pipMode || !callEmbed) return;
if (pipScreenshare) {
if (!callEmbed) {
// The embed (and its spotlight) is torn down with the call; drop the latch
// so a stale ref can't act on the next call's fresh embed.
pipAutoSpotlightRef.current = false;
return;
}
// Spotlight is wanted only while in pip with an active screenshare. Release
// it when EITHER ends — including leaving pip (returning to the call room).
// The release must not sit behind a `!pipMode` early-return, or a
// screenshare→pip→back sequence leaves the auto-enabled spotlight stuck on
// with pipAutoSpotlightRef latched true. The ref gates release so we only
// ever undo a spotlight we turned on (never one the user set).
// NB: `control.spotlight` is read below but deliberately NOT a dependency —
// this effect reacts to pip/screenshare *intent*, not to spotlight changes.
// Adding it as a dep would re-run on every manual spotlight toggle and fight
// the user.
const wantSpotlight = pipMode && pipScreenshare;
if (wantSpotlight) {
if (!callEmbed.control.spotlight) {
callEmbed.control.toggleSpotlight();
pipAutoSpotlightRef.current = true;
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { MobileTouchTarget } from '../styles/mobile.css';
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
@@ -239,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
if (state === 'idle') {
return (
<IconButton
className={MobileTouchTarget}
onClick={startRecording}
aria-label="Record voice message"
variant="SurfaceVariant"
+33 -12
View File
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
});
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
Transforms.select(editor, selectRange);
Transforms.insertNodes(editor, element);
Transforms.collapse(editor, {
edge: 'end',
});
// Wrap the whole sequence: on a stale autocomplete range (the document changed
// between the menu opening and the pick) `insertNodes` — not `select`, which is
// lazy in this Slate version — can throw. This runs inside the pick's event
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
try {
Transforms.select(editor, selectRange);
Transforms.insertNodes(editor, element);
Transforms.collapse(editor, { edge: 'end' });
} catch {
/* stale range — the pick is a no-op rather than an uncaught error */
}
};
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
// Defer to the next tick so React can flush any pending void-element DOM
// updates (e.g. after inserting a mention) before Slate resolves cursor
// positions via ReactEditor.toDOMNode — otherwise Slate throws
// "Cannot resolve a DOM node from slate node".
// Move the caret out of the just-inserted inline void and land it in a real
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
// Slate's void guard). The space then lands in a real text node.
// Doing this in the same commit (vs the old deferred setTimeout) means the
// caret never sits on the void's zero-width edge on a racy tick — that edge's
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
// the composer error boundary. Both ops are pure model transforms (no DOM
// resolution), so running them synchronously is safe.
Transforms.move(editor);
if (withSpace) editor.insertText(' ');
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
// node this is safe, but stay defensive against a mid-flight editor.
setTimeout(() => {
ReactEditor.focus(editor);
Transforms.move(editor);
if (withSpace) editor.insertText(' ');
try {
ReactEditor.focus(editor);
} catch {
// The editor DOM can be mid-flight (autocomplete just closed / re-render
// landed). The element is already inserted, so skip the focus nudge.
}
}, 0);
};
@@ -685,7 +685,7 @@ export function MLocation({ content }: MLocationProps) {
<Button
as="a"
size="400"
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`}
target="_blank"
rel="noreferrer noopener"
variant="Secondary"
+12 -5
View File
@@ -27,7 +27,14 @@ type PresenceBadgeProps = {
};
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
const label = usePresenceLabel();
const ariaLabel = status ? `${label[presence]}${status}` : label[presence];
// DND is encoded as unavailable + status_msg 'dnd'; render it red/"Do Not
// Disturb" to match PresenceRingAvatar and the settings picker (which both
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
const isDnd = presence === Presence.Unavailable && status === 'dnd';
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
const displayStatus = isDnd ? undefined : status;
const ariaLabel = displayStatus ? `${displayLabel}${displayStatus}` : displayLabel;
return (
<TooltipProvider
@@ -38,9 +45,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
tooltip={
<Tooltip>
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
<Text size="L400">{label[presence]}</Text>
{status && <Text size="T200"></Text>}
{status && <Text size="T200">{status}</Text>}
<Text size="L400">{displayLabel}</Text>
{displayStatus && <Text size="T200"></Text>}
{displayStatus && <Text size="T200">{displayStatus}</Text>}
</Box>
</Tooltip>
}
@@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
aria-label={ariaLabel}
ref={triggerRef}
size={size}
variant={PresenceToColor[presence]}
variant={isDnd ? 'Critical' : PresenceToColor[presence]}
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
radii="Pill"
/>
+20 -8
View File
@@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { getActiveSeason } from './seasonSchedule';
import { resolveSeasonTheme } from './seasonSchedule';
import { HalloweenOverlay } from './themes/Halloween';
import { ChristmasOverlay } from './themes/Christmas';
import { NewYearOverlay } from './themes/NewYear';
@@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom);
const reduced = useReducedMotion();
const override = settings.seasonalThemeOverride ?? 'auto';
const theme = useMemo<SeasonTheme | null>(() => {
const override = settings.seasonalThemeOverride ?? 'auto';
if (override === 'off') return null;
if (override === 'auto') return getActiveSeason(new Date());
return override as SeasonTheme;
}, [settings.seasonalThemeOverride]);
// In auto mode, re-evaluate hourly so a long-lived session crosses a
// season/holiday-window boundary (e.g. into a new day) without a reload —
// otherwise the active season is frozen at the value it had on mount.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (override !== 'auto') return undefined;
// Refresh on entering auto too: `now` may be a stale mount-time value if we
// were previously in a pinned/off mode (the interval only runs while auto).
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
return () => window.clearInterval(id);
}, [override]);
const theme = useMemo<SeasonTheme | null>(
() => resolveSeasonTheme(override, now),
[override, now],
);
if (!theme) return null;
// Suppress seasonal overlay when a chat background is active — both running simultaneously
@@ -1,7 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
import {
getActiveSeason,
resolveSeasonTheme,
SEASON_SCHEDULE,
SEASON_DATE_RANGES,
} from './seasonSchedule';
import { SeasonTheme } from './types';
// Date(year, monthIndex0, day)
@@ -52,6 +57,27 @@ test('window boundaries are inclusive at both ends', () => {
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
});
test('resolveSeasonTheme: off → null, pinned → that theme, auto → active season', () => {
const halloweenTs = on(9, 20).getTime(); // Oct 20 → halloween season
const offSeasonTs = on(5, 15).getTime(); // Jun 15 → no season
// 'off' never renders, regardless of date.
assert.equal(resolveSeasonTheme('off', halloweenTs), null);
// A pinned theme renders regardless of date (even off-season).
assert.equal(resolveSeasonTheme('christmas', offSeasonTs), 'christmas');
// 'auto' tracks the active season for the given instant.
assert.equal(resolveSeasonTheme('auto', halloweenTs), 'halloween');
assert.equal(resolveSeasonTheme('auto', offSeasonTs), null);
});
test('resolveSeasonTheme: auto re-evaluates as `now` advances across a boundary', () => {
// The same 'auto' override yields different themes at different instants — this
// is what the SeasonalEffect ticker relies on (incl. the switch-into-auto case
// where `now` must be current, not a stale mount value).
assert.equal(resolveSeasonTheme('auto', on(9, 20).getTime()), 'halloween'); // Oct 20
assert.equal(resolveSeasonTheme('auto', on(11, 15).getTime()), 'christmas'); // Dec 15
assert.equal(resolveSeasonTheme('auto', on(6, 4).getTime()), null); // Jul 4
});
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
assert.equal(SEASON_SCHEDULE.length, 11);
const themes = SEASON_SCHEDULE.map((e) => e.theme);
@@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null {
const day = now.getDate();
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
}
/** A seasonal-theme setting value: the active season, a pinned theme, or off. */
export type SeasonalOverride = SeasonTheme | 'auto' | 'off';
/**
* The theme to render for a `seasonalThemeOverride` at time `now` (epoch ms):
* 'off' → none, 'auto' → the active season for that instant, else the pinned
* theme. Kept pure (and unit-tested) so the decision is verifiable without
* mounting the React overlay.
*/
export function resolveSeasonTheme(override: SeasonalOverride, now: number): SeasonTheme | null {
if (override === 'off') return null;
if (override === 'auto') return getActiveSeason(new Date(now));
return override;
}
@@ -549,6 +549,16 @@ export const BadgeTidal = style({
color: '#ffffff',
});
export const BadgeMixcloud = style({
backgroundColor: '#52aad8',
color: '#ffffff',
});
export const BadgeDeezer = style({
backgroundColor: '#a238ff',
color: '#ffffff',
});
// ---------------------------------------------------------------------------
// Twitch LIVE badge
// ---------------------------------------------------------------------------
@@ -305,6 +305,32 @@ function isTenor(url: string): boolean {
}
}
// Synapse's thumbnailer flattens animated images to a single still frame, so a
// GIF served from /thumbnail renders but never plays. Detect GIF previews so the
// card can point at /download (the original) instead.
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
if (prev['og:image:type'] === 'image/gif') return true;
try {
return new URL(url).pathname.toLowerCase().endsWith('.gif');
} catch {
return false;
}
}
// Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of
// MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the
// (frozen) thumbnail — the card still links out, so the GIF is one click away.
const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024;
// Should this preview's image be fetched whole (so it animates) rather than
// thumbnailed? Size is advisory: Synapse usually reports it, and when it's
// absent we prefer a working animation over a hypothetical huge file.
function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean {
if (!isGifPreview(url, prev)) return false;
const size = prev['matrix:image:size'];
return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES;
}
function getCardVariant(url: string): CardVariant {
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
@@ -1077,6 +1103,8 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
loom: { label: 'Loom', class: previewCss.BadgeLoom },
kick: { label: 'Kick', class: previewCss.BadgeKick },
mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
};
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
@@ -2081,8 +2109,13 @@ function GifCard({
const title = (prev['og:title'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// A GIF card exists to show a moving GIF, so request the original rather than
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
// `loading="lazy"` below keeps it off the wire until it's near the viewport.
const thumbSrc = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
? shouldServeGifOriginal(url, prev)
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
: null;
// If there's no image, fall back to a generic-style layout
@@ -2180,6 +2213,7 @@ function GenericCard({
src={displayThumb}
alt={prev['og:title']}
title={prev['og:title']}
loading="lazy"
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
onClick={onOpenViewer}
@@ -2349,16 +2383,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// Generic fallback — skip empty cards
if (!prev['og:title'] && !prev['og:description']) return null;
const thumbUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
256,
256,
'scale',
false,
);
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
// Show the original for GIFs so they animate; thumbnailing freezes them.
const thumbUrl = shouldServeGifOriginal(url, prev)
? imgUrl
: mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
return (
<GenericCard
+15 -2
View File
@@ -67,9 +67,11 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
// guard those setState calls against the component unmounting first.
const mountedRef = useRef(true);
const safetyTimerRef = useRef<number | undefined>(undefined);
useEffect(
() => () => {
mountedRef.current = false;
if (safetyTimerRef.current !== undefined) window.clearTimeout(safetyTimerRef.current);
},
[],
);
@@ -96,7 +98,17 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
if (playingKey) return; // one at a time (fork also enforces this)
setPlayingKey(flat.key);
setError(undefined);
// Per-play timer token: `done` clears its OWN timer by identity, so a
// stale done() from a prior clip can't disarm a newer clip's safety timer
// (which — since a rejected audio.play() fires neither ended nor error —
// is sometimes the only thing that unsticks the playingKey guard).
let myTimer: number | undefined;
const done = () => {
if (myTimer !== undefined) {
window.clearTimeout(myTimer);
if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined;
myTimer = undefined;
}
if (!mountedRef.current) return;
setPlayingKey((k) => (k === flat.key ? undefined : k));
};
@@ -108,11 +120,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
if (audio) {
audio.addEventListener('ended', done, { once: true });
audio.addEventListener('error', done, { once: true });
// Safety: clear the guard even if the audio never signals end.
myTimer = window.setTimeout(done, 30_000);
safetyTimerRef.current = myTimer;
} else {
done();
}
// Safety: clear the guard even if the audio never signals end.
window.setTimeout(done, 30_000);
} catch {
setError('Could not play that clip.');
done();
+17 -3
View File
@@ -17,15 +17,29 @@ function useMediaPermissions(): MediaPermState {
useEffect(() => {
if (!navigator.permissions) {
setState('unknown');
return;
return undefined;
}
let cancelled = false;
let permStatus: PermissionStatus | undefined;
navigator.permissions
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
.then((result) => {
if (cancelled) return;
permStatus = result;
setState(result.state as MediaPermState);
result.onchange = () => setState(result.state as MediaPermState);
result.onchange = () => {
if (!cancelled) setState(result.state as MediaPermState);
};
})
.catch(() => setState('unknown'));
.catch(() => {
if (!cancelled) setState('unknown');
});
// Detach the onchange handler on unmount so it can't setState afterward (and
// so the PermissionStatus doesn't retain the callback).
return () => {
cancelled = true;
if (permStatus) permStatus.onchange = null;
};
}, []);
return state;
@@ -92,6 +92,16 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const evId = ev.getId();
if (!evId || seen.has(evId)) continue;
seen.add(evId);
// Advance the raw pagination boundary for EVERY event (any type,
// decrypted or not) — getTs() is unencrypted metadata. Gating this on
// a decrypted m.room.message let undecryptable/non-message old events
// stall oldestRawTs, so the fromTs break never fired → over-paginate
// and a false "truncated".
const ts = ev.getTs();
// Require a positive ts: an event with a bogus 0/negative
// origin_server_ts must not collapse the boundary and trigger an early
// break (silent under-pagination in the export).
if (ts > 0 && ts < oldestRawTs) oldestRawTs = ts;
// Attempt decryption for events that haven't been decrypted yet
// (paginateEventTimeline may fetch events before the SDK decrypts them)
if (ev.isEncrypted() && !ev.getClearContent()) {
@@ -100,8 +110,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
}
if (ev.getType() !== EventType.RoomMessage) continue;
if (ev.isDecryptionFailure()) continue;
const ts = ev.getTs();
if (ts < oldestRawTs) oldestRawTs = ts;
if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue;
const content = ev.getContent();
+91 -18
View File
@@ -230,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
const touchTarget = mobileOrTablet() ? { minWidth: '44px', minHeight: '44px' } : undefined;
const isMobile = mobileOrTablet();
// On phones the composer's secondary action buttons (attach, GIF, poll,
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
// input stays one compact row instead of wrapping into a tall stack. Emoji +
// Send remain inline. Desktop keeps everything inline (isMobile === false).
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
const showFormat = composerToolbarButtons?.showFormat ?? true;
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
const showSticker = composerToolbarButtons?.showSticker ?? true;
@@ -876,6 +882,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
}
// Mobile "+" overflow: the `after` builder stashes the collapsed secondary
// buttons here and the `bottom` slot renders them when the toggle is open.
// React evaluates JSX props in source order (before → after → bottom), so
// `after` assigns this before `bottom` reads it within the same render.
let composerOverflow: ReactNode = null;
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
@@ -1035,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
)
}
before={
<IconButton
onClick={() => pickFile('*')}
aria-label="Attach file"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
isMobile ? (
<IconButton
onClick={() => setMobileToolsOpen((open) => !open)}
aria-label="More actions"
aria-expanded={mobileToolsOpen}
aria-controls="composer-more-actions"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
</IconButton>
) : (
<IconButton
onClick={() => pickFile('*')}
aria-label="Attach file"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
)
}
after={(() => {
const formatButton = showFormat ? (
@@ -1306,9 +1333,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
});
// Mobile: keep only emoji/sticker inline beside Send; the rest move
// into the "+" overflow row (rendered via `bottom`), led by the attach
// button that `before` gives up on mobile. Desktop renders all inline.
const emojiInline = orderedButtons.filter(
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
);
const overflowButtons = orderedButtons.filter(
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
);
if (isMobile) {
composerOverflow = (
<>
<IconButton
key="showAttach"
onClick={() => pickFile('*')}
aria-label="Attach file"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
{overflowButtons}
</>
);
}
return (
<>
{orderedButtons}
{isMobile ? emojiInline : orderedButtons}
{gifError && (
<Text
size="T200"
@@ -1365,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
})()}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
<>
{isMobile && mobileToolsOpen && composerOverflow && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Box
id="composer-more-actions"
role="group"
aria-label="More actions"
alignItems="Center"
gap="100"
wrap="Wrap"
style={{ padding: config.space.S200 }}
>
{composerOverflow}
</Box>
</div>
)}
{toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)}
</>
}
/>
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
+28 -6
View File
@@ -1,8 +1,9 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Box, Text, config } from 'folds';
import { Box, Button, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms } from 'slate';
import { isKeyHotkey } from 'is-hotkey';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
<>
{canMessage && (
<ErrorBoundary
fallback={
onReset={() => {
// The composer crash is a transient bad-selection render
// (e.g. after an autocomplete insert); the draft content is
// intact. Clear the selection so the remounted composer can
// render — the user clicks in to continue, no page refresh.
try {
Transforms.deselect(editor);
} catch {
/* editor already in a safe state */
}
}}
fallbackRender={({ resetErrorBoundary }) => (
<RoomInputPlaceholder
role="alert"
style={{ padding: config.space.S200 }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
<Text align="Center">
Message composer encountered an error. Try refreshing.
</Text>
<Text align="Center">The message composer hit a snag.</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={resetErrorBoundary}
>
<Text size="B300">Reload composer</Text>
</Button>
</RoomInputPlaceholder>
}
)}
>
<RoomInput
room={room}
+12 -3
View File
@@ -4,11 +4,13 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import LotusLogo from '../../../../../public/res/Lotus.png';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
type MSC1929Contact = {
matrix_id?: string;
email_address?: string;
@@ -42,9 +44,16 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
useEffect(() => {
const controller = new AbortController();
const baseUrl = mx.getHomeserverUrl();
// MSC1929 support info is served from the MXID server-name host (like
// /.well-known/matrix/client), which on delegated/split-domain servers is
// NOT the client-API URL. Derive it from the user's domain.
const serverName = mx.getDomain();
if (!serverName) {
setLoading(false);
return undefined;
}
setLoading(true);
fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal })
fetch(`https://${serverName}/.well-known/matrix/support`, { signal: controller.signal })
.then((res) => {
if (!res.ok) return null;
return res.json();
@@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import {
DECORATION_CATEGORIES,
DECORATION_CDN,
decorationUrl,
} from '../../lotus/avatarDecorations';
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -48,7 +44,7 @@ function DecorationPreviewCell({
}}
>
<img
src={`${DECORATION_CDN}/${slug}.png`}
src={decorationUrl(slug)}
alt={name}
loading="lazy"
decoding="async"
@@ -73,11 +69,11 @@ export function ProfileDecoration() {
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
// 404s (a console error for anyone without a decoration). The full profile
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
mx.http
.authedRequest<Record<string, string>>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
)
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
@@ -129,6 +129,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
try {
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
if (!mountedRef.current) {
stream.getTracks().forEach((t) => t.stop());
ctx.close().catch(() => undefined);
return;
}
const source = ctx.createMediaStreamSource(stream);
const inAnalyser = ctx.createAnalyser();
inAnalyser.fftSize = 1024;
@@ -182,7 +187,12 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
timer: number;
} | null>(null);
const clipRef = useRef<AudioBuffer | null>(null);
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
const playRef = useRef<{
ctx: AudioContext;
source: AudioBufferSourceNode;
model: DenoiseNode | null;
gate: AudioWorkletNode | null;
} | null>(null);
const [recording, setRecording] = useState(false);
const [recDb, setRecDb] = useState(-100);
const [hasClip, setHasClip] = useState(false);
@@ -208,6 +218,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
const startRecord = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
if (!mountedRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
@@ -249,7 +263,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
}
};
// Bumped whenever a playback starts or stops. An in-flight `play()` compares
// the generation it claimed against this after its awaits; if it no longer
// matches (a newer play, a Stop, or unmount happened during model load) it
// discards what it built instead of orphaning it — closes the rapid-click /
// unmount-during-load leak.
const playGenRef = useRef(0);
const stopPlayback = useCallback(() => {
playGenRef.current += 1;
const p = playRef.current;
playRef.current = null;
if (p) {
@@ -259,6 +281,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
} catch {
/* noop */
}
try {
// Mirror stopLive: dispose the model node (worker/WASM) + gate, else each
// A/B playback through a model leaks a DeepFilterNet/DTLN worker.
p.gate?.disconnect();
p.model?.dispose();
p.model?.node.disconnect();
} catch {
/* noop */
}
p.ctx.close().catch(() => undefined);
}
setPlaying(null);
@@ -268,37 +299,73 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
stopPlayback();
const clip = clipRef.current;
if (!clip) return;
// Claim this generation AFTER stopPlayback's bump; a later play/stop/unmount
// moves it past `gen`, signalling us to discard what we built below.
const gen = playGenRef.current;
try {
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
const source = ctx.createBufferSource();
source.buffer = clip;
let playGate: AudioWorkletNode | null = null;
let playModelNode: DenoiseNode | null = null;
if (playModel) {
let head: AudioNode = source;
if (useGate) {
const gate = await buildGateNode(ctx, gateThreshold);
head.connect(gate);
head = gate;
playGate = await buildGateNode(ctx, gateThreshold);
head.connect(playGate);
head = playGate;
}
const denoise = await buildModelNode(ctx, playModel);
head.connect(denoise.node);
denoise.node.connect(ctx.destination);
playModelNode = await buildModelNode(ctx, playModel);
head.connect(playModelNode.node);
playModelNode.node.connect(ctx.destination);
} else {
source.connect(ctx.destination);
}
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
// panel unmounted)? Tear down this now-orphaned graph instead of storing
// it — otherwise its worker/WASM + ctx would leak and its audio would play
// over the winner.
if (playGenRef.current !== gen || !mountedRef.current) {
try {
playGate?.disconnect();
playModelNode?.dispose();
playModelNode?.node.disconnect();
} catch {
/* noop */
}
ctx.close().catch(() => undefined);
return;
}
source.onended = () => {
if (playRef.current?.ctx === ctx) stopPlayback();
};
playRef.current = { ctx, source };
playRef.current = { ctx, source, model: playModelNode, gate: playGate };
source.start();
setPlaying(label);
} catch (e) {
console.error('[denoise-tester] playback failed', e);
stopPlayback();
// Only tear down if we're still the current playback — a superseded
// invocation must not stop the winner that replaced it.
if (playGenRef.current === gen) stopPlayback();
}
};
// Guards the async getUserMedia paths: if Settings closes while the mic
// permission prompt is open, the resolved stream/ctx would otherwise be
// created after the unmount cleanup already ran, leaking + setState-after-
// unmount. Own [] effect so it only flips on real unmount.
const mountedRef = useRef(true);
useEffect(() => {
// Set on mount (not just cleared on unmount) so a setup→cleanup→setup
// remount of the same fiber (StrictMode/Activity) leaves it true.
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(
() => () => {
stopLive();
@@ -549,7 +549,11 @@ function Appearance() {
value={seasonalThemeOverride ?? 'auto'}
onChange={(v) => {
setSeasonalThemeOverride(v);
if (v !== 'auto' && v !== 'off') setChatBackground('none');
// Any active seasonal mode (incl. "auto") is mutually exclusive
// with a chat background — else picking it is a silent no-op, since
// SeasonalEffect suppresses the overlay while a background is set.
// Only "off" leaves the background alone.
if (v !== 'off') setChatBackground('none');
}}
/>
</Box>
@@ -1,4 +1,11 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
@@ -56,6 +63,13 @@ function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
const mx = useMatrixClient();
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
// Re-sync when the rule changes externally (e.g. toggled on another device →
// account-data sync). The useState initializer only runs once, so without
// this the Switch would show a stale value.
useEffect(() => {
setEnabled(pushRule.enabled !== false);
}, [pushRule.enabled]);
const [toggleState, toggle] = useAsyncCallback(
useCallback(
async (value: boolean) => {
@@ -381,6 +381,11 @@ export function SystemNotification() {
style={selectStyle}
/>
</Box>
{(!quietHoursStart || !quietHoursEnd) && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Set both a start and end time quiet hours stay inactive until both are filled in.
</Text>
)}
</Box>
)}
</SequenceCard>
+14 -1
View File
@@ -222,6 +222,15 @@ export function LotusToastContainer() {
const toasts = useAtomValue(toastQueueAtom);
const isMobile = useScreenSize() === ScreenSize.Mobile;
const listRef = useRef<HTMLDivElement>(null);
// The newest toast is the last (bottom) child; if the stack ever overflows its
// max-height (many sticky action toasts), keep that newest one in view instead
// of leaving it scrolled below the fold.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [toasts.length]);
if (toasts.length === 0) return null;
@@ -237,10 +246,14 @@ export function LotusToastContainer() {
flexDirection: 'column',
gap: config.space.S200,
pointerEvents: 'auto',
// Safety net beyond the queue cap: if many sticky action toasts pile up they
// scroll within a bounded height instead of covering the whole screen.
maxHeight: isMobile ? '70vh' : '80vh',
overflowY: 'auto',
};
return (
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} />
))}
+34 -6
View File
@@ -12,6 +12,22 @@ const pending = new Map<string, Array<(val: string | null) => void>>();
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
// can retry a couple of times, then gives up for the session.
const failures = new Map<string, number>();
// Mounted hooks per userId, so an invalidation (e.g. you change your own
// decoration) re-fetches live instead of waiting for a remount.
const listeners = new Map<string, Set<() => void>>();
function subscribeDecoration(userId: string, cb: () => void): () => void {
let set = listeners.get(userId);
if (!set) {
set = new Set();
listeners.set(userId, set);
}
set.add(cb);
return () => {
set.delete(cb);
if (set.size === 0) listeners.delete(userId);
};
}
function fetchDecoration(
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
@@ -66,6 +82,10 @@ function fetchDecoration(
export function invalidateDecorationCache(userId: string): void {
cache.delete(userId);
// Also clear the give-up counter so the next fetch starts fresh.
failures.delete(userId);
// Notify mounted avatars for this user so they re-fetch immediately.
listeners.get(userId)?.forEach((cb) => cb());
}
export function useAvatarDecoration(userId: string): string | null {
@@ -74,14 +94,22 @@ export function useAvatarDecoration(userId: string): string | null {
useEffect(() => {
let cancelled = false;
fetchDecoration(
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
userId,
).then((val) => {
if (!cancelled) setSlug(val);
});
const load = () => {
fetchDecoration(
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
userId,
).then((val) => {
if (!cancelled) setSlug(val);
});
};
load();
// Re-run on invalidation (fetchDecoration re-fetches since the cache entry
// was cleared; concurrent mounts for the same user still de-dupe via
// `pending`). Keeps live avatars in sync when the decoration changes.
const unsubscribe = subscribeDecoration(userId, load);
return () => {
cancelled = true;
unsubscribe();
};
}, [mx, userId]);
+3 -1
View File
@@ -20,7 +20,7 @@ import {
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import LotusLogo from '../../../../public/res/Lotus.png';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -31,6 +31,8 @@ import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
return LOGIN_PATH;
+2 -1
View File
@@ -1,8 +1,9 @@
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import LotusLogo from '../../../../../public/res/Lotus.png';
import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
/**
* Absolute URL the OIDC provider redirects back to after authorization.
*
+16 -5
View File
@@ -17,9 +17,6 @@ import { manualDndAtom } from '../../state/manualDnd';
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
import { isWithinTimeWindow } from '../../utils/timeWindow';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png';
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
@@ -29,7 +26,13 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import {
getDirectRoomPath,
getHomeRoomPath,
getInboxInvitesPath,
getOriginBaseUrl,
withOriginBaseUrl,
} from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberName,
@@ -67,8 +70,16 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-highlight.png');
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
function SystemEmojiFeature() {
@@ -531,7 +542,7 @@ function MessageNotifications() {
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
// re-gate on the room count — otherwise an explicit per-thread "All replies"
// override in a Mentions-only room is silently dropped.
if (!threadId && getUnreadInfo(room).total === 0) return;
if (!threadId && getUnreadInfo(room, undefined, mx).total === 0) return;
lastNotifiedEventRef.current.set(dedupeKey, eventId);
+3 -1
View File
@@ -1,9 +1,11 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import LotusLogo from '../../../../public/res/Lotus.png';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import pkg from '../../../../package.json';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
export function WelcomePage() {
return (
<Page>
+6 -14
View File
@@ -27,8 +27,11 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import FocusTrap from 'focus-trap-react';
import { Unread } from '../../../../types/matrix/room';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import {
factoryRoomIdByActivity,
factoryRoomIdByAtoZ,
factoryRoomIdByUnread,
} from '../../../utils/sort';
import {
NavButton,
NavCategory,
@@ -210,17 +213,6 @@ function HomeEmpty() {
);
}
const factoryRoomIdByUnread =
(roomToUnread: Map<string, Unread>) =>
(aId: string, bId: string): number => {
const aUnread = roomToUnread.get(aId);
const bUnread = roomToUnread.get(bId);
const aHas = (aUnread?.total ?? 0) > 0;
const bHas = (bUnread?.total ?? 0) > 0;
if (aHas !== bHas) return aHas ? -1 : 1;
return (bUnread?.total ?? 0) - (aUnread?.total ?? 0);
};
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
@@ -331,7 +323,7 @@ export function Home() {
} else if (homeRoomSort === 'alpha') {
comparator = factoryRoomIdByAtoZ(mx);
} else if (homeRoomSort === 'unread') {
comparator = factoryRoomIdByUnread(roomToUnread);
comparator = factoryRoomIdByUnread(roomToUnread, mx);
} else {
comparator = factoryRoomIdByActivity(mx);
}
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { MatrixClient, MatrixEvent, MatrixEventEvent, Room } from 'matrix-js-sdk';
import { roomHaveNotification, unreadIsOnlyVerification } from '../../utils/room';
import { markAsRead } from '../../utils/notifications';
/**
* A COMPLETED in-room device-verification request is a plain `m.room.message`
* that permanently keeps a DM's server/SDK notification count > 0 (it matches the
* default DM push rule and there's no recency gate), so the DM re-lights as unread
* on every fresh sync. `getUnreadInfo`'s suppression hides the dot, but the raw
* SDK count stays "dirty" (desktop badge, other consumers) and the SDK re-inflates
* it on every decrypt. The only durable, SDK-supported fix is a read receipt that
* covers the request event.
*
* This hook sends that receipt but ONLY when a room's ENTIRE unread span is
* verification-flow events (`unreadIsOnlyVerification`), so it can never mark a
* real unread message read. It fires at most once per room per session, after the
* tail decrypts (the count is only attributable to the request post-decryption).
* `markAsRead` honours the user's private-read-receipt setting.
*/
export const useAutoMarkVerificationRead = (mx: MatrixClient): void => {
const doneRef = useRef<Set<string>>(new Set());
useEffect(() => {
const done = doneRef.current;
const maybeMark = (room: Room) => {
const { roomId } = room;
if (done.has(roomId)) return;
if (room.getMyMembership() !== 'join') return;
// Only touch rooms the SDK actually counts as notifying...
if (!roomHaveNotification(room)) return;
// ...and only when the whole unread span is a completed verification.
if (!unreadIsOnlyVerification(room, mx.getUserId())) return;
done.add(roomId);
markAsRead(mx, roomId, false).catch(() => {
// Let a later decrypt/sweep retry on transient failure.
done.delete(roomId);
});
};
// Sweep once on mount (after initial sync some verification tails are already
// decrypted), then re-check whenever an event decrypts — the count only
// becomes attributable to the verification request once it's decrypted.
mx.getRooms().forEach(maybeMark);
const onDecrypted = (event: MatrixEvent) => {
const roomId = event.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room) maybeMark(room);
};
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
return () => {
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [mx]);
};
+2
View File
@@ -7,6 +7,7 @@ import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread'
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
import { useAutoMarkVerificationRead } from './useAutoMarkVerificationRead';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
@@ -16,6 +17,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
useAutoMarkVerificationRead(mx);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
};
+2
View File
@@ -254,6 +254,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
mx,
),
});
};
@@ -332,6 +333,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
mx,
),
});
},
+42 -1
View File
@@ -7,12 +7,13 @@ import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } fro
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
// through a jotai store and read back via toastQueueAtom's getter.
const makeToast = (id: string): ToastNotif => ({
const makeToast = (id: string, sticky?: boolean): ToastNotif => ({
id,
displayName: `name-${id}`,
body: `body-${id}`,
roomName: `room-${id}`,
roomId: `!${id}:server`,
...(sticky ? { sticky: true } : {}),
});
test('starts empty', () => {
@@ -86,6 +87,46 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
);
});
test('toastQueueAtom caps at 5, dropping the oldest non-sticky', () => {
const store = createStore();
// Append 7 transient toasts; the queue should keep only the newest 5.
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['t2', 't3', 't4', 't5', 't6'],
);
});
test('toastQueueAtom never drops a sticky toast, even over cap', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('sticky-old', true));
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
const ids = store.get(toastQueueAtom).map((t) => t.id);
// The sticky action toast survives; the oldest non-sticky ones are dropped.
assert.ok(ids.includes('sticky-old'));
assert.ok(ids.includes('t6')); // newest kept
assert.ok(!ids.includes('t0')); // oldest non-sticky dropped
assert.equal(ids.length, 5);
});
test('toastQueueAtom queue of all-sticky toasts is allowed to exceed the cap', () => {
const store = createStore();
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
// Nothing droppable → all 7 retained rather than silently losing action toasts.
assert.equal(store.get(toastQueueAtom).length, 7);
});
test('toastQueueAtom keeps a new transient toast even when the cap is full of stickies', () => {
const store = createStore();
// Fill the cap with sticky action toasts, then a normal message toast arrives.
for (let i = 0; i < 5; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
store.set(toastQueueAtom, makeToast('fresh'));
const ids = store.get(toastQueueAtom).map((t) => t.id);
// The newest is never the one dropped — the queue stretches instead of eating it.
assert.ok(ids.includes('fresh'));
assert.equal(ids.length, 6);
});
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
const a = createDownloadToast('photo.jpg');
assert.equal(a.displayName, 'Downloaded');
+17 -1
View File
@@ -46,12 +46,28 @@ export const createErrorToast = (
const baseAtom = atom<ToastNotif[]>([]);
// Cap concurrent toasts so a burst (e.g. many rooms lighting up while focused)
// can't stack unbounded and cover the viewport.
const MAX_TOASTS = 5;
// Write-only setter used in ClientNonUIFeatures
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
(get) => get(baseAtom),
(get, set, notif) => {
if (notif === null) return; // no-op guard
set(baseAtom, [...get(baseAtom), notif]);
const next = [...get(baseAtom), notif];
// Over cap: drop the oldest NON-sticky toasts (transient message/error
// toasts auto-dismiss anyway); never drop a sticky action toast, which
// requires a click. The `length - 1` bound excludes the just-appended
// newest, so a fresh toast is never the one dropped — if everything older
// is sticky the cap simply stretches rather than eating the new notice.
for (let i = 0; i < next.length - 1 && next.length > MAX_TOASTS; i += 1) {
if (!next[i].sticky) {
next.splice(i, 1);
i -= 1;
}
}
set(baseAtom, next);
},
);
+14 -1
View File
@@ -268,13 +268,26 @@ export const notificationPermission = (permission: NotificationPermission) => {
* (with the provided `onClick`) when no service worker is available, preserving
* the previous behaviour.
*/
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview. On the desktop build,
// an injected `window.Notification` shim routes `tag`-bearing message toasts to
// the native rich WinRT toast, whose click focuses the app AND navigates to the
// message (via the `lotus-notification-activate` event → useTauriToastActions).
const isDesktopApp = (): boolean =>
(window as unknown as { __TAURI_INTERNALS__?: { invoke?: unknown } }).__TAURI_INTERNALS__
?.invoke !== undefined;
export const showOsNotification = async (
title: string,
options: NotificationOptions & { data?: { path?: string } },
onClick?: () => void,
): Promise<void> => {
try {
if ('serviceWorker' in navigator) {
// On desktop, skip the service-worker notification: WebView2 exposes a
// service worker, so the SW-owned toast would win here and bypass the
// Notification shim above — its click focuses the app but never navigates to
// the message. Falling through to `new Notification()` lets the shim route
// to the native rich toast, which does navigate.
if (!isDesktopApp() && 'serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
if (registration && typeof registration.showNotification === 'function') {
await registration.showNotification(title, options);
+231
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
EventTimeline,
JoinRule,
MatrixClient,
MatrixEvent,
NotificationCountType,
Room,
@@ -24,6 +25,10 @@ import {
isMutedRule,
findMutedRule,
isNotificationEvent,
isVerificationFlowEvent,
unreadIsOnlyVerification,
readReceiptCoversTail,
roomHasUnreadThread,
roomHaveNotification,
getUnreadInfo,
getRoomIconSrc,
@@ -410,6 +415,232 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
// --- verification-flow unread suppression --------------------------------
test('isVerificationFlowEvent', () => {
// the in-room request (m.room.message + verification msgtype)
assert.equal(
isVerificationFlowEvent(
mockEvent({
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
}),
),
true,
);
// the handshake events (their own m.key.verification.* types)
['ready', 'start', 'accept', 'key', 'mac', 'done', 'cancel'].forEach((phase) => {
assert.equal(
isVerificationFlowEvent(mockEvent({ getType: () => `m.key.verification.${phase}` })),
true,
);
});
// a normal message is not verification flow
assert.equal(
isVerificationFlowEvent(
mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text' }) }),
),
false,
);
// a still-encrypted event can't be classified → false (conservative)
assert.equal(isVerificationFlowEvent(mockEvent({ getType: () => 'm.room.encrypted' })), false);
});
const mockUnreadRoom = (
events: MatrixEvent[],
readUpToId: string | null,
counts: { total: number; highlight: number } = { total: 0, highlight: 0 },
threadCounts: Record<string, { total: number; highlight: number }> = {},
): Room =>
({
roomId: '!r:x',
getEventReadUpTo: () => readUpToId,
getLiveTimeline: () => ({ getEvents: () => events }),
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? counts.total : counts.highlight,
getThreads: () => Object.keys(threadCounts).map((id) => ({ id })),
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
type === NotificationCountType.Total
? (threadCounts[threadId]?.total ?? 0)
: (threadCounts[threadId]?.highlight ?? 0),
}) as unknown as Room;
const mx = { getUserId: () => '@me:x' } as unknown as MatrixClient;
const verifRequest = (id: string) =>
mockEvent({
getId: () => id,
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
});
const verifPhase = (id: string, phase: string) =>
mockEvent({ getId: () => id, getType: () => `m.key.verification.${phase}` });
const textMsg = (id: string) =>
mockEvent({
getId: () => id,
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
});
const reactionEv = (id: string) =>
mockEvent({ getId: () => id, getType: () => 'm.reaction', getContent: () => ({}) });
const encryptedEv = (id: string) =>
mockEvent({ getId: () => id, getType: () => 'm.room.encrypted', getContent: () => ({}) });
const pollEv = (id: string) =>
mockEvent({ getId: () => id, getType: () => 'm.poll.start', getContent: () => ({}) });
test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
// timeline oldest→newest: [read msg] then the verification handshake at the tail
const events = [textMsg('$read'), verifPhase('$done', 'done'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), true);
});
test('unreadIsOnlyVerification: a real unread message in the span → false', () => {
// an unread text message sits between the read marker and the verification tail
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('unreadIsOnlyVerification: read marker off-window → false (conservative)', () => {
// the read marker isn't in the loaded timeline
const events = [verifPhase('$done', 'done'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$offwindow'), '@me:x'), false);
});
test('unreadIsOnlyVerification: still-encrypted tail → false (conservative)', () => {
const encryptedTail = mockEvent({ getId: () => '$enc', getType: () => 'm.room.encrypted' });
const events = [textMsg('$read'), encryptedTail];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('unreadIsOnlyVerification: no userId → false', () => {
const events = [textMsg('$read'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), null), false);
});
test('unreadIsOnlyVerification: verification-only main tail but a real unread THREAD → false', () => {
// markAsRead clears every thread, so a verification-only main timeline must NOT
// count as "only verification" when a thread still has a genuine unread reply.
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(
events,
'$read',
{ total: 2, highlight: 0 },
{
$thread: { total: 1, highlight: 0 },
},
);
assert.equal(unreadIsOnlyVerification(room, '@me:x'), false);
});
test('getUnreadInfo suppresses a verification-only room to {0,0} when mx is passed', () => {
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
// Without mx, the raw count is trusted (backward compatible).
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
// With mx, the verification-only count is suppressed.
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});
test('getUnreadInfo does NOT suppress a highlight (real mention) even if the tail is a verification', () => {
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 2, highlight: 1 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 1, total: 2 });
});
test('getUnreadInfo does NOT suppress when a real message is unread alongside a verification', () => {
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
});
// --- readReceiptCoversTail (UTD / spurious-count suppression) --------------
test('readReceiptCoversTail: receipt on the tail (a reaction) → true', () => {
// The Cool Kids case: a corrupt/undecryptable event sits BEFORE the read
// receipt, and the receipt itself landed on the trailing reaction.
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), '@me:x'), true);
});
test('readReceiptCoversTail: only non-notifiable events after the receipt → true', () => {
const events = [textMsg('$read'), reactionEv('$r1'), verifRequest('$v')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), true);
});
test('readReceiptCoversTail: a real unread message after the receipt → false', () => {
const events = [textMsg('$read'), reactionEv('$r'), textMsg('$new')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('readReceiptCoversTail: an unread poll after the receipt → false (polls are content)', () => {
const events = [textMsg('$read'), pollEv('$poll')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('isNotificationEvent recognizes polls (MSC3381)', () => {
assert.equal(isNotificationEvent(pollEv('$p')), true);
});
test('readReceiptCoversTail: a still-encrypted message after the receipt → false (conservative)', () => {
const events = [textMsg('$read'), encryptedEv('$enc')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('readReceiptCoversTail: receipt off-window → false', () => {
const events = [reactionEv('$r'), textMsg('$new')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$gone'), '@me:x'), false);
});
test('readReceiptCoversTail: no receipt / null user → false', () => {
const events = [textMsg('$read'), reactionEv('$tail')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, null), '@me:x'), false);
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), null), false);
});
test('readReceiptCoversTail: a genuine unread THREAD blocks suppression → false', () => {
const events = [textMsg('$read'), reactionEv('$tail')];
const room = mockUnreadRoom(
events,
'$tail',
{ total: 2, highlight: 0 },
{
$thread: { total: 1, highlight: 0 },
},
);
assert.equal(readReceiptCoversTail(room, '@me:x'), false);
});
test('roomHasUnreadThread reflects per-thread counts', () => {
const events = [textMsg('$read')];
assert.equal(roomHasUnreadThread(mockUnreadRoom(events, '$read')), false);
assert.equal(
roomHasUnreadThread(
mockUnreadRoom(
events,
'$read',
{ total: 1, highlight: 0 },
{ $t: { total: 1, highlight: 0 } },
),
),
true,
);
});
test('getUnreadInfo suppresses a spurious count when the read receipt covers the tail', () => {
// Inflated Total=1 (undecryptable event) but the receipt is on the trailing
// reaction → the room is genuinely read; suppress to {0,0}. Without mx the raw
// count is trusted (backward compatible).
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
const room = mockUnreadRoom(events, '$tail', { total: 1, highlight: 0 });
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});
test('getUnreadInfo does NOT suppress a spurious count when a real message is unread past the receipt', () => {
const events = [textMsg('$read'), textMsg('$new')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
});
const mockRoomWithThreadCounts = (
total: number,
highlight: number,
+121 -5
View File
@@ -214,6 +214,11 @@ const NOTIFICATION_EVENT_TYPES = [
'm.room.encrypted',
'm.room.member',
'm.sticker',
// Polls (MSC3381) are real content the server can count toward a room's total.
// Recognizing them keeps a poll-only unread from being walked past by the
// read-receipt/tail scans (roomHaveUnread, readReceiptCoversTail).
'm.poll.start',
'org.matrix.msc3381.poll.start',
];
// In-room device-verification requests are sent as m.room.message with this
// msgtype (the rest of the flow — start/accept/key/mac/done/cancel — uses its own
@@ -237,6 +242,84 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
return true;
};
// In-room device verification is a small burst at the tail of a DM: the
// `m.key.verification.request` message plus the `m.key.verification.*` flow
// (ready/start/key/mac/done/cancel). A COMPLETED request keeps the server/SDK
// Total notification count > 0 forever — it's a plain `m.room.message`, so it
// matches the default DM push rule and there's no recency gate — so the DM
// re-lights as unread on every fresh sync.
export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
// getType() returns the CLEAR type once decrypted; while still encrypted we
// can't tell, so this returns false and callers treat that as "not confirmed".
const eType = mEvent.getType();
if (eType.startsWith('m.key.verification.')) return true;
if (eType === 'm.room.message') {
return mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE;
}
return false;
};
// True iff the room has any thread carrying a real unread notification. A room's
// server/SDK Total INCLUDES its threads, and `markAsRead` clears every thread
// unconditionally, so any tail-based suppression must bail when a thread is
// genuinely unread — otherwise it would hide (or wrongly ack) a real thread reply.
export const roomHasUnreadThread = (room: Room): boolean =>
room
.getThreads()
.some(
(thread) => room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
);
// True iff a room's ENTIRE unread span (tail → the user's read receipt) is
// nothing but verification-flow events — i.e. the only "unread" is a completed
// device verification, not a real message. Conservative: returns false when the
// read marker isn't in the loaded timeline (can't confirm the span) or while the
// tail is still encrypted (undecryptable → unknown), so it never suppresses or
// auto-reads a genuine unread message.
export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => {
if (!userId) return false;
if (roomHasUnreadThread(room)) return false;
const readUpToId = room.getEventReadUpTo(userId);
const liveEvents = room.getLiveTimeline().getEvents();
let sawVerification = false;
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const event = liveEvents[i];
if (!event) return false;
if (event.getId() === readUpToId) return sawVerification;
if (isNotificationEvent(event) && !isVerificationFlowEvent(event)) return false;
if (isVerificationFlowEvent(event)) sawVerification = true;
}
return false;
};
// True iff the user's read receipt already covers the room's entire notifiable
// tail: walking from the newest live event, we reach the receipt's event without
// crossing any notification-worthy event. In that case there is demonstrably
// nothing real left to read, so a lingering Total > 0 is a spurious SDK count.
// matrix-js-sdk's `fixNotificationCountOnDecryption` only ever INCREMENTS an
// encrypted room's Total, and `addReceipt`'s auto-clear-to-zero only fires 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 across cold
// starts. Anchoring on the read receipt is safe: a genuine unread would sit AFTER
// the receipt and stop the walk at `isNotificationEvent`. Conservative: returns
// false when the receipt isn't in the loaded window (can't confirm) or a thread
// is genuinely unread.
export const readReceiptCoversTail = (room: Room, userId: string | null): boolean => {
if (!userId) return false;
if (roomHasUnreadThread(room)) return false;
const readUpToId = room.getEventReadUpTo(userId);
if (!readUpToId) return false;
const liveEvents = room.getLiveTimeline().getEvents();
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const event = liveEvents[i];
if (!event) return false;
if (event.getId() === readUpToId) return true;
if (isNotificationEvent(event)) return false;
}
return false;
};
export const roomHaveNotification = (room: Room): boolean => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -263,7 +346,11 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
export const getUnreadInfo = (
room: Room,
mutedThreads?: Set<string>,
mx?: MatrixClient,
): UnreadInfo => {
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -280,10 +367,28 @@ export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInf
if (highlight < 0) highlight = 0;
}
const resolvedTotal = highlight > total ? highlight : total;
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
// both requiring `mx` (backward-compatible for callers/tests without it) and a
// highlight-free count (a real mention must never be hidden):
// 1. the entire unread span is a completed device verification, or
// 2. the user's read receipt already covers the whole notifiable tail (the
// SDK re-inflated an encrypted-room count past a receipt that genuinely
// covers everything — e.g. a permanently-undecryptable event).
if (
mx &&
resolvedTotal > 0 &&
highlight === 0 &&
(unreadIsOnlyVerification(room, mx.getUserId()) || readReceiptCoversTail(room, mx.getUserId()))
) {
return { roomId: room.roomId, highlight: 0, total: 0 };
}
return {
roomId: room.roomId,
highlight,
total: highlight > total ? highlight : total,
total: resolvedTotal,
};
};
@@ -298,13 +403,24 @@ export const getUnreadInfos = (
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
const info = getUnreadInfo(room, mutedThreads);
const info = getUnreadInfo(room, mutedThreads, mx);
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
// roomHaveNotification true (the server room total includes the muted
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
// would still light the nav row + pollute "unread only" filters. Keep it
// only if there's real unread (count > 0) or a genuine unread marker.
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
// only if there's real unread (count > 0) or a genuine unread marker. The
// unreadIsOnlyVerification/readReceiptCoversTail guards below mirror the
// getUnreadInfo suppression: roomHaveUnread returning `true` here already
// implies both are false (they only report `true` once the receipt covers
// the tail, exactly where roomHaveUnread returns `false`), so the guards are
// defensive insurance against divergence, not load-bearing.
if (
info.total > 0 ||
info.highlight > 0 ||
(roomHaveUnread(mx, room) &&
!unreadIsOnlyVerification(room, mx.getUserId()) &&
!readReceiptCoversTail(room, mx.getUserId()))
) {
unread.push(info);
}
}
+13
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
computeCoverage,
evictCount,
mergeSearchResults,
putRows,
queryRoom,
@@ -57,6 +58,18 @@ test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
test('evictCount: 0 when under or at the cap, else the excess', () => {
assert.equal(evictCount(0, 100), 0);
assert.equal(evictCount(100, 100), 0); // exactly at cap → nothing evicted
assert.equal(evictCount(101, 100), 1);
assert.equal(evictCount(250, 100), 150);
});
test('evictCount: uses the default per-room cap (5000)', () => {
assert.equal(evictCount(5000), 0);
assert.equal(evictCount(5001), 1);
});
test('computeCoverage: derives oldest/newest from rows', () => {
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
+36
View File
@@ -16,6 +16,17 @@ const DB_NAME = 'lotus-search-cache';
const DB_VERSION = 1;
const MESSAGES_STORE = 'messages';
const COVERAGE_STORE = 'coverage';
// Cap cached rows per room so the on-disk index can't grow unbounded over a
// long-lived session. When a room exceeds this, the oldest rows (by ts) are
// evicted on write. ~5k small rows/room is generous search history; the coverage
// window is intentionally left claiming the evicted tail so we don't re-fetch +
// re-evict it forever (Clear cached index / logout still wipe everything).
const MAX_ROWS_PER_ROOM = 5000;
/** How many of a room's rows to evict to bring it back to the cap (0 if under). */
export const evictCount = (currentCount: number, max = MAX_ROWS_PER_ROOM): number =>
Math.max(0, currentCount - max);
const ROOM_TS_INDEX = 'roomTs';
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
@@ -90,6 +101,29 @@ const awaitTx = (tx: IDBTransaction): Promise<void> =>
tx.onabort = () => reject(tx.error);
});
/**
* Within an open readwrite tx, delete the oldest rows of `roomId` (ascending
* `[roomId, ts]` index) until it's back under the cap. Self-chains IDB requests
* so the transaction stays alive never awaits a non-IDB promise mid-tx (which
* would let the transaction auto-commit early).
*/
const pruneRoom = (store: IDBObjectStore, roomId: string): void => {
const index = store.index(ROOM_TS_INDEX);
const countReq = index.count(roomRange(roomId));
countReq.onsuccess = () => {
let remaining = evictCount(countReq.result);
if (remaining <= 0) return;
const cursorReq = index.openCursor(roomRange(roomId), 'next'); // oldest first
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor || remaining <= 0) return;
cursor.delete();
remaining -= 1;
cursor.continue();
};
};
};
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
if (rows.length === 0) return;
@@ -99,6 +133,8 @@ export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
const store = tx.objectStore(MESSAGES_STORE);
rows.forEach((row) => store.put(row));
// Bound growth: prune each room this batch touched back to the cap.
new Set(rows.map((row) => row.roomId)).forEach((roomId) => pruneRoom(store, roomId));
await awaitTx(tx);
} catch {
// Cache write failures must never surface to the UI.
+36
View File
@@ -7,7 +7,9 @@ import {
factoryRoomIdByUnreadCount,
factoryRoomIdByActivity,
factoryRoomIdByAtoZ,
factoryRoomIdByUnread,
} from './sort';
import type { Unread } from '../../types/matrix/room';
test('byTsOldToNew sorts ascending by timestamp', () => {
assert.ok(byTsOldToNew(1, 2) < 0);
@@ -45,6 +47,40 @@ test('factoryRoomIdByActivity sorts most-recently-active first', () => {
assert.deepEqual(['missing', 'new'].sort(cmp), ['new', 'missing']);
});
test('factoryRoomIdByUnread: unread first, by count, then activity for ties', () => {
const ts: Record<string, number> = { a: 100, b: 300, c: 200, d: 400 };
const mx = {
getRoom: (id: string) => (id in ts ? { getLastActiveTimestamp: () => ts[id] } : null),
} as unknown as MatrixClient;
const mkUnread = (total: number): Unread => ({ total, highlight: 0, from: null });
// a: 2 unread, b: 5 unread, c: read, d: read
const roomToUnread = new Map<string, Unread>([
['a', mkUnread(2)],
['b', mkUnread(5)],
['c', mkUnread(0)],
['d', mkUnread(0)],
]);
const cmp = factoryRoomIdByUnread(roomToUnread, mx);
// b (5) and a (2) lead by unread; then the read tail c/d breaks by activity
// (d @400 more recent than c @200) rather than arbitrary order.
assert.deepEqual(['a', 'b', 'c', 'd'].sort(cmp), ['b', 'a', 'd', 'c']);
});
test('factoryRoomIdByUnread: equal unread counts break by activity', () => {
const ts: Record<string, number> = { x: 100, y: 500 };
const mx = {
getRoom: (id: string) => ({ getLastActiveTimestamp: () => ts[id] ?? 0 }),
} as unknown as MatrixClient;
const mkUnread = (total: number): Unread => ({ total, highlight: 0, from: null });
const roomToUnread = new Map<string, Unread>([
['x', mkUnread(3)],
['y', mkUnread(3)],
]);
const cmp = factoryRoomIdByUnread(roomToUnread, mx);
// Same unread count → y (more recent) before x.
assert.deepEqual(['x', 'y'].sort(cmp), ['y', 'x']);
});
test('factoryRoomIdByAtoZ sorts case-insensitively and ignores leading #', () => {
const names: Record<string, string> = { a: 'Banana', b: 'apple', c: '#Cherry' };
const mx = {
+21
View File
@@ -1,4 +1,5 @@
import { MatrixClient } from 'matrix-js-sdk';
import { Unread } from '../../types/matrix/room';
export type SortFunc<T> = (a: T, b: T) => number;
@@ -42,6 +43,26 @@ export const factoryRoomIdByUnreadCount =
return bT - aT;
};
// "Unread First": rooms with unread sort before those without, then by unread
// count desc, then — crucially for the large all-read tail where counts tie —
// by recent activity, so it isn't left in arbitrary order.
export const factoryRoomIdByUnread = (
roomToUnread: Map<string, Unread>,
mx: MatrixClient,
): SortFunc<string> => {
const byActivity = factoryRoomIdByActivity(mx);
return (a, b) => {
const aUnread = roomToUnread.get(a);
const bUnread = roomToUnread.get(b);
const aHas = (aUnread?.total ?? 0) > 0;
const bHas = (bUnread?.total ?? 0) > 0;
if (aHas !== bHas) return aHas ? -1 : 1;
const byCount = (bUnread?.total ?? 0) - (aUnread?.total ?? 0);
if (byCount !== 0) return byCount;
return byActivity(a, b);
};
};
export const byTsOldToNew: SortFunc<number> = (a, b) => a - b;
export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
+55
View File
@@ -24,6 +24,9 @@ import {
getBlueskyEmbed,
getLoomId,
getKickChannel,
getMixcloudFeed,
getDeezerEmbed,
deezerEmbedHeight,
getSteamTarget,
steamWidgetEmbedUrl,
buildVideoEmbedUrl,
@@ -210,6 +213,58 @@ test('Apple Music: album vs single song height, embed host swap', () => {
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
});
test('Mixcloud: cloudcast feed vs profile/section', () => {
assert.equal(
getMixcloudFeed('https://www.mixcloud.com/NTSRadio/some-show-2024/'),
'https://www.mixcloud.com/NTSRadio/some-show-2024/',
);
assert.equal(getMixcloudFeed('https://www.mixcloud.com/NTSRadio/'), null); // bare profile
assert.equal(getMixcloudFeed('https://www.mixcloud.com/NTSRadio/uploads/'), null); // profile tab
assert.equal(getMixcloudFeed('https://www.mixcloud.com/discover/house/'), null); // site section
assert.equal(getMixcloudFeed('https://example.com/a/b/'), null);
assert.ok(
parseMediaEmbed('https://www.mixcloud.com/NTSRadio/some-show/', HOST)?.embedUrl.startsWith(
'https://www.mixcloud.com/widget/iframe/?feed=',
),
);
});
test('Deezer: track / album / playlist (+ locale prefix)', () => {
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/track/3135556'), {
type: 'track',
id: '3135556',
});
assert.deepEqual(getDeezerEmbed('https://deezer.com/en/album/302127'), {
type: 'album',
id: '302127',
});
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/playlist/1479458365?utm=x'), {
type: 'playlist',
id: '1479458365',
});
// Podcasts live at /show/<id>; /podcast/<id> is not a real Deezer path.
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/show/1002330852'), {
type: 'show',
id: '1002330852',
});
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/episode/897651701'), {
type: 'episode',
id: '897651701',
});
assert.equal(getDeezerEmbed('https://www.deezer.com/us/podcast/1002330852'), null);
assert.equal(getDeezerEmbed('https://www.deezer.com/'), null);
assert.equal(getDeezerEmbed('https://www.deezer.com/track/notanid'), null);
assert.equal(deezerEmbedHeight('track'), 152);
assert.equal(deezerEmbedHeight('episode'), 152);
assert.equal(deezerEmbedHeight('show'), 352);
assert.equal(deezerEmbedHeight('album'), 352);
assert.ok(
parseMediaEmbed('https://www.deezer.com/track/3135556', HOST)?.embedUrl.startsWith(
'https://widget.deezer.com/widget/dark/track/3135556',
),
);
});
test('getSteamTarget: app / news / bundle / non-content', () => {
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
kind: 'app',
+104
View File
@@ -555,6 +555,90 @@ export function getBlueskyEmbed(url: string): string | null {
}
}
// --- Mixcloud -------------------------------------------------------------
// Mixcloud's own site sections (first segment) that are never a `<user>`.
const MIXCLOUD_RESERVED = new Set([
'discover',
'categories',
'upload',
'live',
'settings',
'notifications',
'search',
'tag',
'select',
'browse',
]);
// Profile tabs — `/<user>/<tab>` is a listing, not a single cloudcast.
const MIXCLOUD_PROFILE_TABS = new Set([
'uploads',
'favorites',
'listens',
'following',
'followers',
'playlists',
'stream',
'reposts',
]);
/**
* Canonical Mixcloud cloudcast feed URL (`/<user>/<slug>/`) for the widget's
* `feed=` param, or null. Bare profiles / profile-tab listings / site sections
* are excluded (they aren't a single playable cloudcast).
*/
export function getMixcloudFeed(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname.replace(/^www\./, '') !== 'mixcloud.com') return null;
const parts = u.pathname
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
if (parts.length < 2) return null;
if (MIXCLOUD_RESERVED.has(parts[0].toLowerCase())) return null;
if (MIXCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return null;
return `https://www.mixcloud.com/${parts[0]}/${parts[1]}/`;
} catch {
return null;
}
}
// --- Deezer ---------------------------------------------------------------
// NB: Deezer podcast pages are `/show/<id>`, not `/podcast/<id>` — the latter
// 404s on their own oEmbed API, and `widget.deezer.com/widget/dark/show/<id>`
// is the matching widget path.
const DEEZER_TYPES = ['track', 'album', 'playlist', 'artist', 'show', 'episode'] as const;
export type DeezerType = (typeof DEEZER_TYPES)[number];
/** deezer.com[/<locale>]/<type>/<id> → widget target, or null. */
export function getDeezerEmbed(url: string): { type: DeezerType; id: string } | null {
try {
const u = new URL(url);
if (u.hostname.replace(/^www\./, '') !== 'deezer.com') return null;
const parts = u.pathname.replace(/^\/+/, '').split('/').filter(Boolean);
// optional locale prefix (/en/, /us/, /fr/…) then <type>/<id>
const idx = parts.findIndex((p) => (DEEZER_TYPES as readonly string[]).includes(p));
if (idx === -1 || !parts[idx + 1]) return null;
const id = parts[idx + 1].split('?')[0];
if (!/^\d+$/.test(id)) return null;
return { type: parts[idx] as DeezerType, id };
} catch {
return null;
}
}
/**
* Deezer single track/episode players are compact; collections show a scrollable
* tracklist and need room. Mirrors the Spotify sizing (152 / 352) the widget
* requests `tracklist=true`, so 352 keeps the list from being clipped the way a
* shorter box would.
*/
export function deezerEmbedHeight(type: DeezerType): number {
return type === 'track' || type === 'episode' ? 152 : 352;
}
// --- Embed-URL builders ---------------------------------------------------
const enc = encodeURIComponent;
@@ -672,6 +756,26 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
if (tidal)
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
const mixFeed = getMixcloudFeed(url);
if (mixFeed)
return {
provider: 'mixcloud',
kind: 'audio',
embedUrl: `https://www.mixcloud.com/widget/iframe/?feed=${enc(mixFeed)}&light=0`,
height: 120,
};
const deezer = getDeezerEmbed(url);
if (deezer)
return {
provider: 'deezer',
kind: 'audio',
embedUrl: `https://widget.deezer.com/widget/dark/${deezer.type}/${enc(
deezer.id,
)}?app_id=457142&autoplay=false&radius=true&tracklist=true`,
height: deezerEmbedHeight(deezer.type),
};
const insta = getInstagramEmbed(url);
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
+1 -1
View File
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
export default defineConfig({
appType: 'spa',
publicDir: false,
publicDir: './public/res',
base: buildConfig.base,
server: {
port: 8080,