Compare commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:59 -04:00
f2c356f288 refactor(assets): build logo/favicon URLs via withOriginBaseUrl helper
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Canceled after 0s
Swap the logo/favicon URL constants from the inline
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...` form to the
repo's existing `withOriginBaseUrl(getOriginBaseUrl(), '/public/res/...')`
helper (already used here for the OIDC callback URL). Functionally equivalent —
same /public/res/ target, resolves in dev and the static-copied prod build — and
it keeps the logo URL absolute and consistent with clientUri for the OIDC
logoUri. No build-config change (publicDir stays false).

Co-authored-by: Nathan Vititoe <nathanvititoe@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:22:58 -04:00
jaredandClaude Opus 4.8 f12e05c510 fix(assets): reference logo/favicon PNGs by URL instead of importing from public/
CI / Build & Quality Checks (push) Successful in 12m7s
CI / Trigger Desktop Build (push) Successful in 7s
Importing images from public/ (`import X from '../../public/res/*.png'`) is
fragile under Vite with `publicDir: false` and can white-screen the dev app —
Vite tries to resolve the public/ path as a module. Switch the five logo/
favicon call sites to the repo's existing BASE_URL URL pattern
(`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...`), matching how
config.json, locales, and the Element Call widget are referenced. Resolves
identically in dev and the static-copied prod build (public/res -> dist/public/res).

Sounds under public/sound/ stay ESM-imported: that folder is not copied to
dist/ by vite-plugin-static-copy, so a URL reference would 404.

Also add a Local Development section to the README (no-backend model, npm ci /
npm start on :8080, which homeserver to log in against, OIDC-on-localhost note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:52:04 -04:00
jaredandClaude Opus 4.8 d47032a14f fix(unread): clear rooms whose read receipt already covers the tail
CI / Build & Quality Checks (push) Successful in 10m54s
CI / Trigger Desktop Build (push) Successful in 8s
A room could show a permanent unread that survives every cold start even
though the server considers it fully read (notification_count 0, unthreaded
read receipt at the tail). matrix-js-sdk's fixNotificationCountOnDecryption
only ever INCREMENTS an encrypted room's Total, and addReceipt's auto-clear
fires only when the tail event is the user's own — so a count inflated in an
earlier state (before a receipt covered the tail, e.g. by a since-corrupted
undecryptable event) is never decremented and keeps a genuinely-read room lit.
This is aggravated by mixing threaded-receipt clients (Element X) with
unthreaded ones (Lotus/Cinny), which split the read marker.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:42:32 -04:00
jaredandClaude Opus 4.8 b2678d5c6d fix(unread): stop DM device-verification requests re-lighting as unread
CI / Build & Quality Checks (push) Successful in 11m11s
CI / Trigger Desktop Build (push) Successful in 10s
A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.

Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
  whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
  via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
  Conservative: never suppresses when the read marker is off-window, the tail
  is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
  the request (the only SDK-durable lever), once per room per session, gated on
  the same verification-only predicate so it can never ack a real message.

unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.

Reviewed by 5 agents; the thread-scope guard closes the one bug they found.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified against ClientNonUIFeatures.tsx and MsgTypeRenderers.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:24:58 -04:00
jaredandClaude Opus 4.8 8fbde6df36 docs(todo): mark toast-cap + unread-sort findings fixed (1963222d)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:22:40 -04:00
jaredandClaude Opus 4.8 1963222d1e fix(ux): cap the in-app toast stack; stable "Unread First" room sort
- Toast queue: a burst of notifications appended unboundedly and could cover the
  viewport. Cap at 5 in the atom writer, dropping the OLDEST non-sticky toast
  (sticky = action toasts requiring a click, never dropped). The drop scan
  excludes the just-appended newest (`length - 1` bound) so a fresh toast is
  never the one eaten when the cap is full of stickies — it stretches instead.
  Container gains a maxHeight + overflowY safety net and scrolls the newest
  (bottom) toast into view if the stack ever overflows. +4 unit tests incl. the
  cap-full-of-stickies boundary.

- "Unread First" room sort left the entire read tail (all counts tie at 0) in
  arbitrary Map order. factoryRoomIdByUnread now breaks ties by recent activity.
  Relocated from Home.tsx (module-private) to utils/sort.ts (exported, pure) and
  unit-tested (equal-count and read-tail cases fall back to activity).

Bug-hunt findings from LOTUS_TODO. Three review passes: the second caught that
the cap could silently drop the newest notification when full of stickies (real
bug, untested boundary) — fixed and covered; a third traced the corrected loop.
Gate-green (tsc, eslint, prettier, 920 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:22:23 -04:00
jaredandClaude Opus 4.8 d07f16586a docs(todo): mark push-rule + MSC1929-support findings fixed (2c0cd0d2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:06:19 -04:00
jaredandClaude Opus 4.8 2c0cd0d26c fix(settings): resync push-rule toggle from account-data; MSC1929 support host
- PushRuleEditor: the enable Switch initialized its state from pushRule.enabled
  once (useState initializer), so a rule toggled on another device left the
  Switch stale until remount. A useEffect now resyncs on pushRule.enabled
  change. pushRule flows from useAccountData(m.push_rules), which re-renders on
  sync, so the resync is genuinely reached; no optimistic-update conflict (the
  toggle sets state only after the PUT resolves).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:35:59 -04:00
jaredandClaude Opus 4.8 f03c0ef960 test: cover cryptoDiagLog + closedLobbyCategories
Test-coverage batch 2 (2-agent reviewed, both SHIP; isolation verified — Node
runs each test file in its own process, so the console patch can't leak).

- cryptoDiagLog.test.ts: the E2EE KE-cluster diagnostics tool — KE-signature
  capture vs ignore, most-specific-first match order, KE-3/KE-4 rows, Error /
  object / circular-arg serialization (String() fallback never throws), the
  200-entry ring-buffer eviction, getCryptoDiagEntries copy semantics,
  install idempotency, and buildCryptoDiagReport's client metadata + LOCKED
  PII-safe key set (no field can silently leak) + no-client/throwing-getter
  fallbacks. Silences console pass-through so the ring-buffer test stays quiet.
- closedLobbyCategories.test.ts: mirrors closedNavCategories — id join,
  hydrate, PUT/DELETE, idempotent PUT, no-op DELETE, array persistence,
  per-user key namespacing.

Also: mark the EC in-call mobile UI audit done in LOTUS_TODO (stale entry;
shipped as element-call:lotus e36aef8a).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:49:51 -04:00
jaredandClaude Opus 4.8 015495c77d docs(todo): low-tail batch fixed (T5/T6/T7, C-L2/3/5, F5); remaining deferred
CI / Build & Quality Checks (push) Successful in 11m58s
CI / Trigger Desktop Build (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 02:47:56 -04:00
jaredandClaude Opus 4.8 a267e9e960 fix: low-tail correctness — thread notifs, call audio, OIDC expiry
Verify-then-fix batch of minor bugs; each staged diff reviewed by 2 agents
(both SHIP). Two listed items (N6 receipt-avatar refresh, H10 room-name
length reject) were already handled and left unchanged.

Threads:
- T5: a just-sent reply no longer under-notifies — `participated` also checks
  the local thread timeline for our own events, since the server-bundle
  `hasCurrentUserParticipated` lags.
- T6: a room set to "Mentions & Keywords only" no longer over-notifies Default
  thread replies — new `roomMentionsOnly` gate (behavior-identical when false;
  +4 unit tests).
- T7: thread-mode account-data writes are serialized with content carried
  forward (setAccountData is a bare PUT whose result lags the /sync echo, so
  plain serialization wouldn't stop the lost update); carry only on success.

Calls / audio:
- C-L2: a real incoming ring cancels a lingering Settings ringtone preview.
- C-L3: the ringtone AudioContext is primed on the first page gesture (via the
  always-mounted CallEmbedProvider) so the first ring after a cold load isn't
  silent.
- C-L5: useCallSpeakers depends on a stable boolean, so the tile MutationObserver
  + io.lotus.call_state subscription aren't rebuilt on every membership change.

Crypto:
- F5: the OIDC refresher forwards the freshly-refreshed token expiry
  (passed on the tokens object at runtime) as expiresInMs, so the persisted
  expiresAt no longer goes stale across reloads.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 02:47:27 -04:00
jaredandClaude Opus 4.8 291e14ab48 docs(todo): mobile r2 — embed-card stacking + secondary touch sweep done
CI / Build & Quality Checks (push) Successful in 10m42s
CI / Trigger Desktop Build (push) Successful in 10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:57:29 -04:00
jaredandClaude Opus 4.8 72e7447d28 fix(mobile): stack embed cards + secondary 44px touch targets (r2)
Mobile follow-ups round 2 (survey findings deferred from the mobile audit),
reviewed by 2 agents on the staged diff (both SHIP).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:14:51 -04:00
91 changed files with 3100 additions and 370 deletions
+45 -23
View File
@@ -6,6 +6,18 @@ on:
pull_request:
branches: [lotus]
# Only the newest commit per ref needs to build: a superseded push cancels its
# in-flight run. This keeps the shared act_runner free (web CI otherwise queues
# behind long Tauri desktop builds) and — since `trigger-desktop` is `needs:
# build` — means only the latest lotus commit ever kicks a desktop build,
# instead of one per rapid push. Cancelling a superseded run is deploy-safe
# ONLY because lotus_deploy.sh re-resolves origin/lotus each poll iteration and
# retargets its CI gate to HEAD — otherwise a run cancelled mid-poll would
# strand the newest commit undeployed. Keep those two in sync.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build & Quality Checks
@@ -18,8 +30,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
cache: npm
# No npm / node_modules cache: the act_runner's internal cache server is
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
# node_modules step) once the runner's cache server is reachable from jobs.
- name: Install dependencies
# Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
@@ -40,39 +57,44 @@ jobs:
sleep $((attempt * 15))
done
# ── Critical gate — if this fails, nothing deploys ──────────────────
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
# error fails in seconds instead of after the ~minutes-long build. All are
# hard gates — any failure fails the job and blocks the deploy. The tree is
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
# deploy without CI; a later normal push surfaces any introduced issue here
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
# eslint gates on errors only (existing no-explicit-any warnings stay
# informational — check:eslint has no --max-warnings).
- name: Prettier
run: npm run check:prettier
- name: ESLint
run: npm run check:eslint
- name: TypeScript
run: npm run typecheck
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }}
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
# A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Quality checks (informational — pre-existing issues exist) ───────
- name: TypeScript
run: npm run typecheck
continue-on-error: true
- name: ESLint
run: npm run check:eslint
continue-on-error: true
- name: Prettier
run: npm run check:prettier
continue-on-error: true
# ── Security ─────────────────────────────────────────────────────────
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
continue-on-error: true
# ── Bundle size report ───────────────────────────────────────────────
# ── Bundle size report (informational — never blocks a deploy) ───────
- name: Report bundle sizes
continue-on-error: true
run: |
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
+27
View File
@@ -0,0 +1,27 @@
---
name: Bug Report
about: Report something that isn't working in Lotus Chat
title: ''
labels: bug
---
**Describe the bug**
A clear and concise description of what went wrong.
**Steps to reproduce**
1. Go to '...'
2. Click on '...'
3. See error
**Expected behavior**
What you expected to happen instead.
**Client info**
- Lotus Chat version (Settings → Help & About):
- Platform: Web / Desktop (Windows / macOS / Linux)
- Browser + version (if web):
**Screenshots / logs**
If applicable, add screenshots or the browser devtools console output.
+1 -5
View File
@@ -1,5 +1 @@
blank_issues_enabled: false
contact_links:
- name: Features, Bug Reports, Questions
url: https://github.com/cinnyapp/cinny/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
blank_issues_enabled: true
+15
View File
@@ -0,0 +1,15 @@
---
name: Feature Request
about: Suggest an idea or improvement for Lotus Chat
title: ''
labels: enhancement
---
**What would you like?**
A clear and concise description of the feature or change.
**Why / use case**
What problem does it solve, or what does it make better?
**Alternatives considered**
Any workarounds or other approaches you've thought about.
-9
View File
@@ -1,9 +0,0 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
+1 -3
View File
@@ -1,3 +1 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
npx lint-staged
+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
+130 -12
View File
@@ -32,10 +32,9 @@ A three-wave feature bug-hunt (~15 parallel agents, each batch independently rev
**Still open (low tail — all 🟡 minor):**
- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first ring after cold load can be silent (ctx not unlocked); C-L5 speaker-observer churn on membership change; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
- **Threads:** T5 `participating` detection is server-bundle-only (`thread.hasCurrentUserParticipated`) → can under-notify a thread you just replied to; T6 room "Mentions & Keywords" not honored for participated/Default thread replies (over-notify); T7 account-data thread-mute write is a lost-update race.
- **Crypto/session:** F5 OIDC refresh drops `expiresAt` on persist (`persistTokens` can't reach the expiry without SDK-internal plumbing; refresh is reactive on 401).
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage. H10 room-name setter fire-and-forget/silent length reject (trivial). N6 per-message read-receipt avatars may not refresh on membership change (emitter uncertain, low impact).
- **Low-tail batch FIXED** (`a267e9e9`, 2-agent-reviewed, gate-green): **T5** (`participated` now also scans the local thread timeline, not just the server bundle → no under-notify), **T6** (room "Mentions & Keywords" honored for Default thread replies via a new `roomMentionsOnly` gate → no over-notify; +4 tests), **T7** (thread-mode account-data writes serialized with content carried forward → no lost update), **C-L2** (a real incoming ring cancels a lingering Settings preview), **C-L3** (ringtone AudioContext primed on first page gesture → first ring after cold load not silent), **C-L5** (`useCallSpeakers` depends on a stable boolean → no observer churn on membership change), **F5** (OIDC refresher forwards the refreshed token `expiry` as `expiresInMs``expiresAt` no longer stale across reloads). **Verified already-handled, no change:** **N6** (`useMemberAvatar` already subscribes via `useRoomMemberChange`), **H10** (`RoomProfile` already has `maxLength={255}` + surfaces the submit error).
- **Calls host (still open):** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size. **All four are EC-DOM/echo-behavior or visual-jitter items — need a real call + the EC iframe to verify; deferred.**
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
- **EC fork (EC1EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
---
@@ -92,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).
@@ -150,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**:
@@ -204,13 +284,25 @@ Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex;
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1``lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
### [ ] Mobile audit
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1M6 + N1N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
**NOT done — needs a real device / product decisions (open):**
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@media (max-width:750px){ flex-direction:column }`) scoped to those variants via `cardClass`. folds `Box` has no default `direction` so the override wins uncontested; desktop unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
### [ ] 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.)
@@ -218,6 +310,19 @@ The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were *
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
**✅ 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):**
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
### Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
@@ -226,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.
@@ -239,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`.
@@ -287,8 +393,20 @@ Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-sou
```
edit → commit → git push origin lotus
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
→ lotus_deploy.sh on LXC 106 polls CI → npm ci && npm run build → rsync → live (~11 min)
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
```
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
**CI hardening (2026-07, reviewed):**
- [x] **Concurrency**`cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
**CI follow-ups (open):**
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
- [ ] **Debounce the desktop trigger**`trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
+17
View File
@@ -167,6 +167,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**
+36 -3
View File
@@ -42,7 +42,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone } from '../utils/ringtones';
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallQuality } from '../hooks/useCallQuality';
@@ -703,6 +703,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
// C-L3 — prime the ringtone AudioContext on the first user gesture of the
// session so the first incoming-call ring isn't silent (a fresh context stays
// suspended until a gesture, and an incoming ring has none of its own).
useEffect(() => {
const prime = () => {
unlockRingtoneAudio();
window.removeEventListener('pointerdown', prime);
window.removeEventListener('keydown', prime);
};
window.addEventListener('pointerdown', prime, { once: true, passive: true });
window.addEventListener('keydown', prime, { once: true, passive: true });
return () => {
window.removeEventListener('pointerdown', prime);
window.removeEventListener('keydown', prime);
};
}, []);
// Sync pip mode into CallControl so it can adjust behavior accordingly
useEffect(() => {
if (!callEmbed) return;
@@ -714,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;
+14 -3
View File
@@ -1,9 +1,10 @@
import React, { useCallback } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { useAtom } from 'jotai';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box, color, config } from 'folds';
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
@@ -146,8 +147,18 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
const showRecents = recents.length > 0 && !(term ?? '').trim();
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
// narrower than 312px with a fixed 296px grid.
const containerRef = useRef<HTMLDivElement>(null);
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
useCallback((w) => setGridWidth(Math.max(1, Math.floor(w) - 16)), []),
);
return (
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
{lotusTerminal && (
<div
style={{
@@ -178,7 +189,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
<Grid
key={searchKey}
fetchGifs={fetchGifs}
width={PICKER_WIDTH - 16}
width={gridWidth}
columns={2}
gutter={4}
onGifClick={handleClick}
+3 -2
View File
@@ -88,8 +88,9 @@ export function RenderMessageContent({
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview
// fetches + iframes at once.
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
// fetches + iframes at once. De-dupe first: a message linking the same URL
// twice would otherwise render sibling cards with identical React keys.
const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6);
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>
+73 -16
View File
@@ -1,5 +1,19 @@
import React from 'react';
import { Menu, PopOut, toRem } from 'folds';
import {
Box,
Header,
Icon,
IconButton,
Icons,
Menu,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile';
@@ -8,6 +22,20 @@ import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom';
import { ScreenSize, useScreenSize } from '../hooks/useScreenSize';
// Matches useModalStyle's mobile branch: fill the phone screen with internal
// scroll so tall profiles (moderation actions, device list, notes) are fully
// reachable — the anchored 340px popout below can't scroll and clipped them.
const MOBILE_FULLSCREEN = {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
} as const;
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state;
@@ -15,32 +43,61 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined;
const screenSize = useScreenSize();
const close = useCloseUserRoomProfile();
if (!room) return null;
const profile = (
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
);
const focusTrapOptions = {
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
};
// On phones, render as a full-screen scrollable modal instead of an anchored,
// fixed-width, unscrollable popout.
if (screenSize === ScreenSize.Mobile) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Modal size="500" style={MOBILE_FULLSCREEN}>
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
profile has no self-close, so provide an explicit close. */}
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
<Box grow="Yes" />
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes" style={{ overflow: 'hidden auto' }}>
{profile}
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
return (
<PopOut
anchor={cords}
position={position ?? 'Top'}
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ width: toRem(340) }}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
</Menu>
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Menu style={{ width: toRem(340) }}>{profile}</Menu>
</FocusTrap>
}
/>
@@ -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"
@@ -1,5 +1,6 @@
import React from 'react';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { decorationUrl } from '../../features/lotus/avatarDecorations';
const DEFAULT_INSET = 8;
@@ -16,8 +17,14 @@ export function AvatarDecoration({
inset = DEFAULT_INSET,
}: AvatarDecorationProps) {
const slug = useAvatarDecoration(userId);
const reducedMotion = useReducedMotion();
if (!slug) {
// Decorations are animated APNGs with no static asset to freeze to, so honor
// prefers-reduced-motion by not rendering the animation at all (consistent
// with the rest of the theming stack — chat backgrounds / seasonal overlays —
// which all suppress motion under this preference; also avoids dozens of live
// APNGs animating in scrolling mobile lists).
if (!slug || reducedMotion) {
return <>{children}</>;
}
+14
View File
@@ -16,9 +16,23 @@ export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
'@media': {
// On phones the toolbar can hold many 44px buttons; let them wrap to a
// second line instead of overflowing horizontally.
'(max-width: 750px)': { flexWrap: 'wrap' },
},
},
]);
// The composer's before | editable | after row. On phones, allow the toolbar
// (`after`) to wrap below the input instead of squeezing the editable to zero
// and pushing the Send button off-screen.
export const EditorInputRow = style({
'@media': {
'(max-width: 750px)': { flexWrap: 'wrap' },
},
});
export const EditorTextareaScroll = style({});
export const EditorTextarea = style([
+1 -1
View File
@@ -124,7 +124,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
<Box className={css.EditorInputRow} alignItems="Start">
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
+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);
};
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { MobileTouchTarget } from '../../styles/mobile.css';
export type ImageViewerProps = {
alt: string;
@@ -19,7 +20,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
@@ -35,7 +36,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
<IconButton
size="300"
radii="300"
className={MobileTouchTarget}
onClick={requestClose}
aria-label="Close"
>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
@@ -48,12 +55,18 @@ export const ImageViewer = as<'div', ImageViewerProps>(
outlined={zoom < 1}
size="300"
radii="Pill"
className={MobileTouchTarget}
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Chip
variant="SurfaceVariant"
radii="Pill"
className={MobileTouchTarget}
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
@@ -61,6 +74,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
outlined={zoom > 1}
size="300"
radii="Pill"
className={MobileTouchTarget}
onClick={zoomIn}
aria-label="Zoom In"
>
@@ -70,6 +84,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
variant="Primary"
onClick={handleDownload}
radii="300"
className={MobileTouchTarget}
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
@@ -100,6 +115,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
src={src}
alt={alt}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/>
</Box>
</Box>
+1 -1
View File
@@ -11,7 +11,7 @@ export const MediaControl = as<'div', MediaControlProps>(
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
{before && <Box direction="Column">{before}</Box>}
<Box alignItems="Center" gap="200">
<Box alignItems="Center" gap="200" wrap="Wrap">
<Box alignItems="Center" grow="Yes" gap="Inherit">
{leftControl}
</Box>
+19 -11
View File
@@ -417,6 +417,22 @@ type RenderImageContentProps = {
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
// Media frame sizing. When intrinsic width/height are known, drive the box by
// aspect-ratio so its height tracks the responsive (maxWidth:100%) width — a
// fixed pixel height computed for a 400px-wide layout otherwise crops (images,
// object-fit:cover) or letterboxes (videos, object-fit:contain) on phones where
// the box narrows below 400px. On desktop the box stays 400px wide, so the
// aspect-ratio yields the identical height. Falls back to the fixed height when
// dimensions are unknown.
const attachmentMediaStyle = (
w: number | undefined,
h: number | undefined,
fallbackHeight: number,
): CSSProperties =>
w && h
? { aspectRatio: `${w} / ${h}`, minHeight: toRem(48) }
: { height: toRem(fallbackHeight < 48 ? 48 : fallbackHeight) };
type MImageProps = {
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
@@ -432,11 +448,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
return (
<Attachment outlined={outlined}>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
<AttachmentBox style={attachmentMediaStyle(imgInfo?.w, imgInfo?.h, height)}>
{renderImageContent({
body: content.body || 'Image',
info: imgInfo,
@@ -498,11 +510,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
<AttachmentBox style={attachmentMediaStyle(videoInfo.w, videoInfo.h, height)}>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,
@@ -677,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"
+5
View File
@@ -53,6 +53,11 @@ const NavItemBase = style({
color: OnContainer,
outline: 'none',
minHeight: toRem(36),
'@media': {
// The room/nav row is the app's primary tap target; give it a 44px touch
// area on phones (desktop stays the denser 36px).
'(max-width: 750px)': { minHeight: toRem(44) },
},
selectors: {
'&:hover, &:focus-visible': {
+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"
/>
@@ -22,6 +22,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
import { MobileTouchTarget } from '../../styles/mobile.css';
import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5;
@@ -92,7 +93,7 @@ export function ReadReceiptAvatars({
onClick={() => setOpen(true)}
title={tooltipNames}
aria-label={tooltipNames}
className={css.ReceiptTrigger}
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
>
{/* Pill wrapper ensures visibility on any wallpaper/background */}
<span
@@ -6,6 +6,11 @@ export const CardGrid = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S400,
'@media': {
// Cards squish/overflow below ~360px each; drop to a single column on phones
// (mirrors the 750px breakpoint the nav uses).
'(max-width: 750px)': { gridTemplateColumns: '1fr' },
},
});
export const RoomCardBase = style([
+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;
}
@@ -81,6 +81,10 @@ export const SidebarItem = recipe({
justifyContent: 'center',
position: 'relative',
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
'@media': {
// Space-rail buttons to a 44px touch target on phones (2px larger).
'(max-width: 750px)': { minWidth: toRem(44), minHeight: toRem(44) },
},
selectors: {
'&:hover': {
@@ -275,6 +275,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
key={key}
alignItems="Center"
gap="200"
wrap="Wrap"
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
@@ -4,7 +4,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
export const UrlPreview = style([
DefaultReset,
{
width: toRem(400),
// 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
width: 'min(25rem, 92vw)',
minHeight: toRem(102),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
@@ -21,6 +23,19 @@ export const UrlPreviewWide = style({
width: 'min(38rem, 94vw)',
});
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
// the content as direct children of the UrlPreview flex row; on a phone that
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
// Box has no explicit direction (browser default row), so this override wins
// with nothing to compete against, and desktop (>750px) is unchanged.
export const StackOnMobile = style({
'@media': {
'(max-width: 750px)': {
flexDirection: 'column',
},
},
});
export const UrlPreviewImg = style([
DefaultReset,
{
@@ -404,6 +419,56 @@ export const BadgeSteam = style({
color: '#c7d5e0',
});
// ---------------------------------------------------------------------------
// Steam card — full-width header/banner image + official store widget iframe
// ---------------------------------------------------------------------------
export const SteamBannerWrapper = style([
DefaultReset,
{
position: 'relative',
display: 'block',
width: '100%',
// Steam header capsules are 460×215 (~2.14:1); news banners vary but crop
// fine to the same ratio.
aspectRatio: '460 / 215',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: '#0e1520',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.9)',
},
},
]);
export const SteamBannerImg = style([
DefaultReset,
{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
},
]);
// The official Steam store widget is a compact banner (~646×190). Full-width,
// fixed height so the iframe doesn't collapse to its intrinsic size.
export const SteamWidget = style([
DefaultReset,
{
width: '100%',
height: toRem(190),
border: 0,
display: 'block',
borderRadius: config.radii.R300,
backgroundColor: '#1b2838',
marginTop: config.space.S100,
},
]);
export const BadgeWikipedia = style({
backgroundColor: color.SurfaceVariant.ContainerLine,
color: color.SurfaceVariant.OnContainer,
@@ -484,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
// ---------------------------------------------------------------------------
+290 -49
View File
@@ -15,6 +15,7 @@ import {
config,
} from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
@@ -33,11 +34,13 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
extractEmbedHeight,
getSteamTarget,
getTikTokVideoId,
getTweetId,
isTikTokLink,
MediaEmbed,
parseMediaEmbed,
steamWidgetEmbedUrl,
tiktokIdFromOembed,
tiktokOembedUrl,
tiktokPlayerEmbedUrl,
@@ -115,11 +118,15 @@ function isGitHubRepo(url: string): boolean {
}
}
// Keep these hosts + the /status(es) pattern in sync with getTweetId
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
// an extractable id but never routes to the Twitter card or "View post" embed.
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);
function isTwitter(url: string): boolean {
try {
const { hostname } = new URL(url);
const h = hostname.replace(/^www\./, '');
return h === 'twitter.com' || h === 'x.com';
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
} catch {
return false;
}
@@ -128,9 +135,8 @@ function isTwitter(url: string): boolean {
function isTwitterTweet(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'twitter.com' && h !== 'x.com') return false;
return /\/status\/\d+/.test(pathname);
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
return /\/status(?:es)?\/\d+/.test(pathname);
} catch {
return false;
}
@@ -219,17 +225,6 @@ function isTwitch(url: string): boolean {
}
}
function isSteamApp(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h !== 'store.steampowered.com') return false;
return pathname.startsWith('/app/');
} catch {
return false;
}
}
function isWikipedia(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
@@ -310,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
@@ -320,7 +341,7 @@ function getCardVariant(url: string): CardVariant {
if (getRedditSubreddit(url) !== null) return 'reddit';
if (getSpotifyType(url) !== null) return 'spotify';
if (isTwitch(url)) return 'twitch';
if (isSteamApp(url)) return 'steam';
if (getSteamTarget(url)) return 'steam';
if (isWikipedia(url)) return 'wikipedia';
if (isDiscordInvite(url)) return 'discord';
if (isNpm(url)) return 'npm';
@@ -726,6 +747,7 @@ function TwitterCard({
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
@@ -780,6 +802,7 @@ function TwitterCard({
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
@@ -1080,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
@@ -1249,6 +1274,7 @@ function MediaEmbedCard({
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
@@ -1259,6 +1285,7 @@ function MediaEmbedCard({
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
@@ -1407,6 +1434,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
@@ -1416,6 +1444,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
@@ -1492,9 +1521,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
}
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// Route through the homeserver like every other card — a raw og:image would
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
// defeats the click-to-play facade on a nonstandard one.
const artworkUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
: null;
const spotifyType = getSpotifyType(url) ?? 'track';
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
@@ -1544,10 +1581,16 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
);
}
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
// Steam bundle/sub/dlc or other store page — OG card (no per-app widget).
function SteamStoreCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const thumbnailUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
: null;
return (
<>
@@ -1608,6 +1651,167 @@ function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
);
}
// Steam news / announcement post — rich OG card (Steam has no official embed
// widget for announcements): banner + headline + body preview.
function SteamNewsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const bannerUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
: null;
return (
<Box direction="Column" style={{ width: '100%' }}>
{bannerUrl && (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.SteamBannerWrapper}
aria-label={`View on Steam: ${title}`}
>
<img className={previewCss.SteamBannerImg} src={bannerUrl} alt={title} loading="lazy" />
</a>
)}
<UrlPreviewContent>
<Box alignItems="Center" gap="100" wrap="Wrap">
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
Announcement
</Text>
</Box>
{title && (
<Text
priority="400"
style={{
fontWeight: 700,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
<Text
style={linkStyles}
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
View on Steam
</Text>
</UrlPreviewContent>
</Box>
);
}
// Steam store app page — OG header + a click-to-play facade that loads Steam's
// official store-widget iframe (live, region-aware price / discount / Buy on
// Steam). Nothing loads from Steam until the user presses "Show price & store".
function SteamAppCard({
url,
prev,
appId,
}: {
url: string;
prev: IPreviewUrlResponse;
appId: string;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [showWidget, setShowWidget] = useState(false);
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const capsuleUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
: null;
return (
<Box direction="Column" style={{ width: '100%' }}>
{capsuleUrl && (
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.SteamBannerWrapper}
aria-label={`View on Steam: ${title}`}
>
<img className={previewCss.SteamBannerImg} src={capsuleUrl} alt={title} loading="lazy" />
</a>
)}
<UrlPreviewContent>
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
{description && (
<Text size="T200" priority="300">
<UrlPreviewDescription>{description}</UrlPreviewDescription>
</Text>
)}
{showWidget ? (
<iframe
className={previewCss.SteamWidget}
src={steamWidgetEmbedUrl(appId)}
title={title ? `Steam store: ${title}` : 'Steam store widget'}
sandbox={EMBED_SANDBOX}
loading="lazy"
/>
) : (
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
store.steampowered.com
</Text>
{inlineMediaEmbeds && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setShowWidget(true)}
before={<Icon size="50" src={Icons.Setting} />}
>
<Text size="T200">Show price &amp; store</Text>
</Chip>
)}
</Box>
)}
</UrlPreviewContent>
</Box>
);
}
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const target = getSteamTarget(url);
if (target?.kind === 'news') return <SteamNewsCard url={url} prev={prev} />;
if (target?.kind === 'app') return <SteamAppCard url={url} prev={prev} appId={target.appId} />;
return <SteamStoreCard url={url} prev={prev} />;
}
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const title = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
@@ -1664,9 +1868,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const iconUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
: null;
return (
<>
@@ -1827,9 +2036,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
}
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const posterUrl = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
: null;
return (
<>
@@ -1895,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
@@ -1994,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}
@@ -2068,21 +2288,37 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// Interactive embeds (players, tweets) render in a wider, responsive card so
// player chrome / tweet content isn't cramped or clipped.
const embed = parseMediaEmbed(url, window.location.hostname);
const wide = !!embed || isTwitterTweet(url);
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
// redirects) don't carry the id, so fall back to the canonical og:url that
// the homeserver already resolved when fetching the preview.
// Short "copy-link" links carry no id, so the embed is only resolvable from
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
// the wide layout, not the cramped narrow one.
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
if (embed) return embed;
const ogUrl = prev['og:url'];
const resolvedEmbed =
embed ??
(typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null);
return typeof ogUrl === 'string' && ogUrl !== url
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
// A Steam app page renders the official ~646px store-widget iframe, so it
// needs the wide card too.
const steamAppWide = getSteamTarget(url)?.kind === 'app';
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
// in the card flex row; stack them on phones (no-op for the single-column
// embed cards). Desktop keeps the row layout.
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
const buildCardClass = (wide: boolean): string | undefined =>
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
.filter(Boolean)
.join(' ') || undefined;
const renderContent = (
prev: IPreviewUrlResponse,
resolvedEmbed: MediaEmbed | null,
): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
// folds in the og:url fallback for short "copy-link" share URLs.
if (resolvedEmbed) {
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
}
@@ -2147,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
@@ -2175,17 +2406,27 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// Don't render the card wrapper when content is empty (loaded but nothing to show)
if (previewStatus.status === AsyncStatus.Success) {
const content = renderContent(previewStatus.data);
const prev = previewStatus.data;
const resolvedEmbed = resolveEmbed(prev);
const content = renderContent(prev, resolvedEmbed);
if (content === null) return null;
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
// link that resolves to a player still gets the wide layout.
const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
return (
<UrlPreview {...props} ref={ref} className={cardClass}>
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
{content}
</UrlPreview>
);
}
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
return (
<UrlPreview {...props} ref={ref} className={cardClass}>
<UrlPreview
{...props}
ref={ref}
className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
>
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
+6 -1
View File
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
import { ModalMobileFull } from '../../styles/Modal.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
<Modal
size="500"
className={ModalMobileFull}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
<ImageViewer
src={viewAvatar}
alt={userId}
@@ -5,6 +5,7 @@ import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -31,6 +32,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
fill="Soft"
radii="300"
size="300"
className={MobileTouchTarget}
onClick={toggleMic}
outlined
disabled={disabled || loading}
@@ -66,6 +68,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
fill="Soft"
radii="300"
size="300"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
@@ -108,6 +111,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
fill="Soft"
radii="300"
size="300"
className={MobileTouchTarget}
onClick={toggleVideo}
aria-label={enabled ? 'Stop Video' : 'Start Video'}
aria-pressed={enabled}
@@ -147,6 +151,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
fill="Soft"
radii="300"
size="300"
className={MobileTouchTarget}
onClick={onToggle}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
aria-pressed={enabled}
+14 -2
View File
@@ -35,6 +35,7 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
@@ -51,18 +52,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null);
const callEmbedRef = useCallEmbedRef();
const setCallEmbed = useSetAtom(callEmbedAtom);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
const screenSize = useScreenSize();
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
const [isFullscreen, setIsFullscreen] = useState(false);
useResizeObserver(
useCallback(() => {
const element = controlRef.current;
if (!element) return;
setCompact(element.clientWidth < 500);
setNarrowContainer(element.clientWidth < 500);
}, []),
useCallback(() => controlRef.current, []),
);
// Collapse to the stacked/compact layout whenever the bar's own container is
// narrow (a small desktop call window) OR the viewport is a phone. The old
// element-only `< 500` check left the ~11-control row overflowing off-screen
// in the 500750px band (landscape phones / small tablets).
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullscreenChange);
@@ -330,6 +338,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
// Don't run past the screen edges on a narrow phone (centered via
// translateX(-50%)); clamp to the viewport minus a small margin.
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
@@ -376,6 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
+21 -3
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();
@@ -135,7 +148,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
<Menu
style={{
maxWidth: `min(${manage ? toRem(420) : toRem(340)}, calc(100vw - 2 * ${config.space.S400}))`,
maxHeight: '70vh',
}}
>
<Box direction="Column" style={{ maxHeight: '70vh' }}>
<Box
shrink="No"
+8
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
import { useAtom } from 'jotai';
import * as css from './styles.css';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { callChatAtom } from '../../state/callEmbed';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -36,6 +37,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={toggleMic}
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
outlined
@@ -70,6 +72,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Undeafen' : 'Deafen'}
outlined
@@ -113,6 +116,7 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={toggleVideo}
outlined
disabled={disabled || loading}
@@ -155,6 +159,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
outlined
@@ -200,6 +205,7 @@ export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonPro
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={onToggle}
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
aria-pressed={isFullscreen}
@@ -234,6 +240,7 @@ export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButt
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={onToggle}
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
aria-pressed={muted}
@@ -266,6 +273,7 @@ export function ChatButton() {
fill="Soft"
radii="400"
size="400"
className={MobileTouchTarget}
onClick={() => setChat(!chat)}
aria-label={chat ? 'Close Chat' : 'Open Chat'}
aria-pressed={chat}
+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;
@@ -119,7 +119,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
return (
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
<Box direction="Column" gap="300">
<Box gap="200">
<Box gap="200" wrap="Wrap">
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Color</Text>
<Box gap="200">
@@ -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();
@@ -307,7 +307,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
gap="300"
>
{/* Tabs */}
<Box gap="200">
<Box gap="200" wrap="Wrap">
<TabButton
label="Users"
count={userEntries.length}
@@ -33,6 +33,7 @@ import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -148,6 +149,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
size="300"
variant="Background"
radii="300"
className={MobileTouchTarget}
aria-label={`Remove ${entry}`}
onClick={() => onRemove(i)}
style={{ flexShrink: 0 }}
+5 -1
View File
@@ -190,6 +190,7 @@ function LightboxMedia({
pan,
cursor,
onMouseDown,
onTouchStart,
onImageDoubleClick,
}: {
item: LightboxItem;
@@ -198,6 +199,7 @@ function LightboxMedia({
pan: Pan;
cursor: string;
onMouseDown: React.MouseEventHandler<HTMLElement>;
onTouchStart: React.TouchEventHandler<HTMLElement>;
onImageDoubleClick: () => void;
}) {
const mx = useMatrixClient();
@@ -253,6 +255,7 @@ function LightboxMedia({
alt={item.body}
draggable={false}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onDoubleClick={onImageDoubleClick}
style={{
maxWidth: '100%',
@@ -297,7 +300,7 @@ function Lightbox({
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
// Pan is only active for a zoomed-in image; usePan resets its offset when this
// flips false (i.e. back to 1x, on navigation, or on a video).
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1);
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
@@ -512,6 +515,7 @@ function Lightbox({
pan={pan}
cursor={cursor}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onImageDoubleClick={toggleZoom}
/>
</Box>
+3
View File
@@ -30,6 +30,7 @@ import {
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { Membership } from '../../../types/matrix/room';
import * as css from './MembersDrawer.css';
@@ -460,6 +461,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
variant="Success"
radii="300"
fill="Soft"
className={MobileTouchTarget}
onClick={() => mx.invite(room.roomId, knockMember.userId)}
>
<Text size="B300">Approve</Text>
@@ -469,6 +471,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
variant="Critical"
radii="300"
fill="Soft"
className={MobileTouchTarget}
onClick={() => mx.kick(room.roomId, knockMember.userId)}
>
<Text size="B300">Deny</Text>
+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}
+3 -1
View File
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { EventReaders } from '../../components/event-readers';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard';
export function RoomViewFollowingPlaceholder() {
@@ -34,6 +35,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
({ className, room, ...props }, ref) => {
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(360);
const latestEvent = useRoomLatestRenderedEvent(room);
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
const names = latestEventReaders
@@ -55,7 +57,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
+5 -2
View File
@@ -65,6 +65,7 @@ import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
@@ -260,6 +261,7 @@ export const MessageReadReceiptItem = as<
}
>(({ room, eventId, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(360);
const handleClose = () => {
setOpen(false);
@@ -278,7 +280,7 @@ export const MessageReadReceiptItem = as<
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal>
</FocusTrap>
@@ -674,6 +676,7 @@ export const MessageReportItem = as<
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(480);
const [reportState, reportMessage] = useAsyncCallback(
useCallback(
(eventId: string, score: number, reason: string) =>
@@ -715,7 +718,7 @@ export const MessageReportItem = as<
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -1,6 +1,7 @@
import React from 'react';
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { MobileTouchTarget } from '../../../styles/mobile.css';
import { useThreadSummary } from '../../../hooks/useThreadSummary';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
@@ -31,6 +32,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
<Chip
variant="SurfaceVariant"
radii="300"
className={MobileTouchTarget}
before={<Icon size="50" src={Icons.Thread} />}
after={
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
+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();
+54 -54
View File
@@ -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>
@@ -1898,27 +1902,27 @@ function Calls() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Ringtone Volume"
description="Volume of the incoming call ringtone."
after={
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
<input
type="range"
min="0"
max="100"
step="5"
value={ringtoneVolume}
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
aria-label="Ringtone volume"
style={{ flex: 1, accentColor: color.Primary.Main }}
/>
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
{ringtoneVolume}%
</Text>
</Box>
}
/>
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
<Box
direction="Row"
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<input
type="range"
min="0"
max="100"
step="5"
value={ringtoneVolume}
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
aria-label="Ringtone volume"
style={{ flex: 1, accentColor: color.Primary.Main }}
/>
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
{ringtoneVolume}%
</Text>
</Box>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
@@ -1990,26 +1994,28 @@ function Calls() {
}
/>
{soundboardEnabled && (
<SettingTile
title="Soundboard Volume"
after={
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
<input
type="range"
min={0}
max={100}
step={5}
value={soundboardVolume}
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
style={{ flexGrow: 1 }}
aria-label="Soundboard volume"
/>
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
{soundboardVolume}%
</Text>
</Box>
}
/>
<>
<SettingTile title="Soundboard Volume" />
<Box
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<input
type="range"
min={0}
max={100}
step={5}
value={soundboardVolume}
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
style={{ flexGrow: 1 }}
aria-label="Soundboard volume"
/>
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
{soundboardVolume}%
</Text>
</Box>
</>
)}
</SequenceCard>
</Box>
@@ -2454,19 +2460,13 @@ function Messages() {
: 'On-device translation isnt available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
}
after={
<select
aria-label="Translate messages into"
disabled={!translationSupported}
<SettingsSelect
value={selectedTargetLang}
onChange={(e) => setTranslateTargetLang(e.target.value)}
style={pickerInputStyle(color, config)}
>
{TRANSLATE_TARGET_LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.name}
</option>
))}
</select>
onChange={(v) => setTranslateTargetLang(v)}
disabled={!translationSupported}
aria-label="Translate messages into"
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
/>
}
/>
{translationSupported && (
@@ -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>
+27 -5
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -37,6 +38,7 @@ function ToastCard({ toast }: ToastCardProps) {
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
// vars only exist while Terminal mode is active).
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const isMobile = useScreenSize() === ScreenSize.Mobile;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -80,8 +82,11 @@ function ToastCard({ toast }: ToastCardProps) {
}`,
borderRadius: config.radii.R400,
padding: `${config.space.S300} ${config.space.S400}`,
minWidth: '280px',
maxWidth: '340px',
// Full-width on phones (the container spans the viewport there); a fixed
// 280-340px card would otherwise overflow a narrow screen.
minWidth: isMobile ? 0 : '280px',
maxWidth: isMobile ? 'none' : '340px',
width: isMobile ? '100%' : undefined,
boxShadow: lotusTerminal
? toast.sticky
? 'var(--lt-box-glow-cyan)'
@@ -216,22 +221,39 @@ 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;
const containerStyle: CSSProperties = {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
// Span the width just inside the screen edges on a phone (so full-width
// cards fit); float bottom-right on desktop.
bottom: isMobile ? config.space.S200 : '1.5rem',
right: isMobile ? config.space.S200 : '1.5rem',
left: isMobile ? config.space.S200 : undefined,
zIndex: zIndices.toast,
display: 'flex',
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]);
+8 -2
View File
@@ -25,8 +25,14 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
const callMembers = useCallMembers(callSession);
const joined = useCallJoined(callEmbed);
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
// changes on every membership change). The MutationObserver + io.lotus.call_state
// subscription below already track tiles joining/leaving live, so rebuilding
// them on each membership change is pure churn.
const hasCallMembers = callMembers.length > 0;
useEffect(() => {
if (!callMembers || !joined) {
if (!hasCallMembers || !joined) {
setSpeakers(new Set<string>());
return undefined;
}
@@ -126,7 +132,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
bodyWatcher?.disconnect();
unsubLotus();
};
}, [callEmbed, callMembers, joined]);
}, [callEmbed, hasCallMembers, joined]);
return speakers;
};
+46 -1
View File
@@ -1,4 +1,4 @@
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
export type Pan = {
translateX: number;
@@ -21,6 +21,10 @@ export const usePan = (active: boolean) => {
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
const touchAttachedRef = useRef<{
move: (e: TouchEvent) => void;
end: (e: TouchEvent) => void;
} | null>(null);
useEffect(() => {
setCursor(active ? 'grab' : 'initial');
@@ -53,6 +57,40 @@ export const usePan = (active: boolean) => {
document.addEventListener('mouseup', handleMouseUp);
};
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
// derive the delta from the previous touch position.
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
if (!active || evt.touches.length !== 1) return;
setCursor('grabbing');
let lastX = evt.touches[0].clientX;
let lastY = evt.touches[0].clientY;
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
e.preventDefault();
const t = e.touches[0];
const dx = t.clientX - lastX;
const dy = t.clientY - lastY;
lastX = t.clientX;
lastY = t.clientY;
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
};
const handleTouchEnd = () => {
setCursor('grab');
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
touchAttachedRef.current = null;
};
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
useEffect(() => {
if (!active) setPan(INITIAL_PAN);
}, [active]);
@@ -65,6 +103,12 @@ export const usePan = (active: boolean) => {
document.removeEventListener('mouseup', attachedRef.current.up);
attachedRef.current = null;
}
if (touchAttachedRef.current) {
document.removeEventListener('touchmove', touchAttachedRef.current.move);
document.removeEventListener('touchend', touchAttachedRef.current.end);
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
touchAttachedRef.current = null;
}
},
[],
);
@@ -73,5 +117,6 @@ export const usePan = (active: boolean) => {
pan,
cursor,
onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
};
};
+47 -13
View File
@@ -36,21 +36,19 @@ const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
return joined;
};
const writeThreadNotificationMode = async (
mx: MatrixClient,
// Apply a single mode change to a base content object, returning a fresh clone
// (never mutates the input).
const applyThreadMode = (
base: ThreadNotificationsContent,
roomId: string,
threadRootId: string,
mode: ThreadNotificationMode,
): Promise<void> => {
const current = readContent(mx);
const now = Date.now();
// Work on a mutable clone; prune produces a fresh object so the mutations
// below never touch the atom's/account-data's current content.
now: number,
): ThreadNotificationsContent => {
const next: ThreadNotificationsContent = {
...current,
...base,
rooms: Object.fromEntries(
Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
Object.entries(base.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
),
};
@@ -70,10 +68,46 @@ const writeThreadNotificationMode = async (
rooms[roomId][threadRootId] = { mode, ts: now };
}
// ALWAYS prune before persisting to keep account data bounded.
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
return next;
};
await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent);
// T7 — serialize writes so rapid, overlapping mode changes don't lost-update
// each other. `setAccountData` is a bare PUT whose result doesn't reach the
// local store until the /sync echo, so back-to-back writes would otherwise all
// read the same stale base and clobber one another. Each queued write instead
// bases its mutation on the previous write's RESULT; once the queue drains the
// carried base is dropped so the next independent write re-reads fresh (possibly
// externally-changed) server state.
let writeChain: Promise<unknown> = Promise.resolve();
let pendingWrites = 0;
let carriedContent: ThreadNotificationsContent | null = null;
const writeThreadNotificationMode = (
mx: MatrixClient,
roomId: string,
threadRootId: string,
mode: ThreadNotificationMode,
): Promise<void> => {
pendingWrites += 1;
const run = writeChain.then(async () => {
const now = Date.now();
const base = carriedContent ?? readContent(mx);
const next = applyThreadMode(base, roomId, threadRootId, mode, now);
// ALWAYS prune before persisting to keep account data bounded.
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent);
// Carry the result forward only on success, so a queued follow-up write bases
// on persisted content — never on a shape the server just rejected.
carriedContent = finalContent;
});
// Keep the chain alive on error; drop the carried base once the queue drains.
writeChain = run
.catch(() => {})
.finally(() => {
pendingWrites -= 1;
if (pendingWrites === 0) carriedContent = null;
});
return run;
};
export function useSetThreadNotificationMode(
+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.
*
+25 -7
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,
@@ -69,6 +72,10 @@ import {
// 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 +538,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);
@@ -644,13 +651,24 @@ function MessageNotifications() {
const content = threadPrefs;
const mode = getThreadNotificationMode(content, room.roomId, thread.id);
const actions = mx.getPushActionsForEvent(mEvent);
// `hasCurrentUserParticipated` is derived from the server thread bundle,
// which lags a reply we just sent — so also treat any of our own events
// already in the thread timeline as participation (T5: avoid under-notify).
const myUserId = mx.getUserId();
const participated =
thread.hasCurrentUserParticipated ||
thread.timeline.some((e) => e.getSender() === myUserId);
const roomNotifType = getNotificationType(mx, room.roomId);
const decision = shouldNotifyThreadReply({
mode,
defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
participated: thread.hasCurrentUserParticipated,
participated,
highlight: !!actions?.tweaks?.highlight,
notify: !!actions?.notify,
roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute,
roomMuted: roomNotifType === NotificationType.Mute,
// T6: honor a room-level "Mentions & Keywords only" setting for Default
// threads instead of over-notifying every participated reply.
roomMentionsOnly: roomNotifType === NotificationType.MentionsAndKeywords,
});
if (decision === 'none') return;
+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);
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getHexcodeForEmoji, getShortcodeFor, getShortcodesFor } from './emoji';
describe('getHexcodeForEmoji', () => {
it('converts a single astral codepoint to an uppercase hexcode', () => {
// 😀 = U+1F600
assert.equal(getHexcodeForEmoji('😀'), '1F600');
});
it('zero-pads BMP codepoints to at least four hex digits', () => {
// ☺ = U+263A ; # = U+0023 (must pad "23" -> "0023")
assert.equal(getHexcodeForEmoji('☺'), '263A');
assert.equal(getHexcodeForEmoji('#'), '0023');
});
it('strips the FE0F variation selector by default', () => {
// ❤️ = U+2764 U+FE0F
assert.equal(getHexcodeForEmoji('❤️'), '2764');
});
it('keeps the variation selector when strip is false', () => {
assert.equal(getHexcodeForEmoji('❤️', false), '2764-FE0F');
});
it('strips ZWJ (200D) joiners from a sequence by default', () => {
// 👨‍👩‍👧 = 1F468 200D 1F469 200D 1F467
assert.equal(getHexcodeForEmoji('👨‍👩‍👧'), '1F468-1F469-1F467');
});
it('keeps ZWJ joiners when strip is false', () => {
assert.equal(getHexcodeForEmoji('👨‍👩‍👧', false), '1F468-200D-1F469-200D-1F467');
});
it('strips the FE0E text-presentation selector too', () => {
// ▶ = U+25B6 ; ▶︎ = U+25B6 U+FE0E (text presentation)
assert.equal(getHexcodeForEmoji('▶︎'), '25B6');
assert.equal(getHexcodeForEmoji('▶︎', false), '25B6-FE0E');
});
it('handles a keycap sequence (padding + selector strip together)', () => {
// #️⃣ = U+0023 U+FE0F U+20E3 -> "0023" + (FE0F stripped) + "20E3"
assert.equal(getHexcodeForEmoji('#️⃣'), '0023-20E3');
});
it('handles degenerate inputs (empty string, plain ASCII per codepoint)', () => {
assert.equal(getHexcodeForEmoji(''), '');
assert.equal(getHexcodeForEmoji('ab'), '0061-0062');
});
});
describe('getShortcodesFor / getShortcodeFor before emoji data is loaded', () => {
// These gracefully degrade to `undefined` until loadEmojiData() has populated
// the shortcode maps — the contract that lets tooltips/aria-labels render
// eagerly without pulling the emojibase runtime into the eager graph.
it('returns undefined for getShortcodesFor', () => {
assert.equal(getShortcodesFor('1F600'), undefined);
});
it('returns undefined for getShortcodeFor', () => {
assert.equal(getShortcodeFor('1F600'), undefined);
});
});
@@ -455,6 +455,17 @@ export const getReactCustomHtmlParser = (
return <CodeBlock opts={opts}>{children}</CodeBlock>;
}
if (name === 'table') {
// Sanitize allows tables, but a wide one would otherwise overflow the
// message column and the page body on narrow screens. Wrap it in a
// horizontally-scrollable container (same idea as CodeBlock's Scroll).
return (
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
<table {...props}>{domToReact(children as unknown as DOMNode[], opts)}</table>
</div>
);
}
if (name === 'blockquote') {
return (
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
+102
View File
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { enableMapSet } from 'immer';
import { makeClosedLobbyCategoriesAtom, makeLobbyCategoryId } from './closedLobbyCategories';
// makeClosedLobbyCategoriesAtom(userId) builds a Set<string> atom whose reducer uses
// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage
// key `closedLobbyCategories<userId>`. The reducers produce over a Set, so enable
// immer's Map/Set plugin (the app does this once at startup).
// makeLobbyCategoryId joins args with '|'. (Mirrors closedNavCategories.test.ts.)
enableMapSet();
type Store = Record<string, string>;
const installLocalStorage = (): Store => {
const data: Store = {};
const ls = {
getItem: (k: string) => (k in data ? data[k] : null),
setItem: (k: string, v: string) => {
data[k] = String(v);
},
removeItem: (k: string) => {
delete data[k];
},
};
(globalThis as { localStorage?: unknown }).localStorage = ls;
(globalThis as { window?: unknown }).window = {
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
return data;
};
test('makeLobbyCategoryId joins args with "|"', () => {
assert.equal(makeLobbyCategoryId('space', 'catA'), 'space|catA');
assert.equal(makeLobbyCategoryId('only'), 'only');
assert.equal(makeLobbyCategoryId(), '');
});
test('starts empty when nothing is stored', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
assert.equal(store.get(lobbyAtom).size, 0);
});
test('hydrates the Set from the per-user localStorage key', () => {
const data = installLocalStorage();
data['closedLobbyCategories@u:server'] = JSON.stringify(['x', 'y']);
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
assert.deepEqual(Array.from(store.get(lobbyAtom)).sort(), ['x', 'y']);
});
test('PUT adds a category and DELETE removes it', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
assert.deepEqual(Array.from(store.get(lobbyAtom)), ['cat1']);
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'cat1' });
assert.equal(store.get(lobbyAtom).has('cat1'), false);
});
test('PUT of an existing category is idempotent', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
assert.equal(store.get(lobbyAtom).size, 1);
});
test('DELETE of an absent category is a no-op', () => {
installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'missing' });
assert.equal(store.get(lobbyAtom).size, 0);
});
test('writes persist to localStorage as an array', () => {
const data = installLocalStorage();
const store = createStore();
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
assert.deepEqual(JSON.parse(data['closedLobbyCategories@u:server']), ['cat1']);
});
test('the storage key is namespaced per user', () => {
const data = installLocalStorage();
const store = createStore();
const aAtom = makeClosedLobbyCategoriesAtom('@a:server');
const bAtom = makeClosedLobbyCategoriesAtom('@b:server');
store.set(aAtom, { type: 'PUT', categoryId: 'only-a' });
assert.deepEqual(JSON.parse(data['closedLobbyCategories@a:server']), ['only-a']);
assert.equal(data['closedLobbyCategories@b:server'], undefined);
assert.equal(store.get(bAtom).size, 0);
});
@@ -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);
},
);
+21
View File
@@ -1,6 +1,27 @@
import { style } from '@vanilla-extract/css';
const mobileFullscreen = {
minWidth: '100vw',
minHeight: '100vh',
width: '100vw',
height: '100vh',
borderRadius: 0,
} as const;
export const ModalWide = style({
minWidth: '85vw',
minHeight: '90vh',
'@media': {
// Fill the phone screen instead of floating as an 85vw card with margins.
'(max-width: 750px)': mobileFullscreen,
},
});
// Mobile-only full-screen: no desktop effect (keeps the modal's normal size),
// but fills the viewport on phones. For dialogs that should stay a small card on
// desktop but go edge-to-edge on mobile (e.g. the avatar viewer).
export const ModalMobileFull = style({
'@media': {
'(max-width: 750px)': mobileFullscreen,
},
});
+18
View File
@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
import { toRem } from 'folds';
/**
* A 44px minimum touch target on phones for otherwise-small interactive controls
* (folds `IconButton`/`Chip` at `size="300"`/`"400"` render ~2440px, below the
* 44px guideline). Apply via `className`; the icon/label stays its normal visual
* size only the hit area grows and desktop is unchanged (the rule is gated to
* `@media (max-width: 750px)`, matching MOBILE_BREAKPOINT).
*/
export const MobileTouchTarget = style({
'@media': {
'(max-width: 750px)': {
minWidth: toRem(44),
minHeight: toRem(44),
},
},
});
+180
View File
@@ -0,0 +1,180 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import { buildCryptoDiagReport, getCryptoDiagEntries, installCryptoDiagLog } from './cryptoDiagLog';
// installCryptoDiagLog() replaces console.warn/error with capturing wrappers that
// ALWAYS pass through to the original. Silence the originals here (before install
// captures them) so the ring-buffer test doesn't spam hundreds of lines into the
// test output — the capture logic itself still runs. The module's `entries` buffer
// is module-global and accumulates across tests in this file, so assertions use
// deltas or the absolute cap rather than assuming an empty buffer.
console.warn = () => undefined;
console.error = () => undefined;
installCryptoDiagLog();
const mockClient = (partial: Partial<Record<string, unknown>>): MatrixClient =>
partial as unknown as MatrixClient;
test('captures a KE-signature line and ignores non-matching output', () => {
const before = getCryptoDiagEntries().length;
console.error('POST /keys/upload 400 M_UNKNOWN: One time key already exists');
console.warn('just a normal warning with nothing to capture');
const after = getCryptoDiagEntries();
assert.equal(after.length, before + 1, 'only the matching line is captured');
const last = after[after.length - 1];
assert.equal(last.ke, 'KE-1');
assert.equal(last.signature, 'already exists');
assert.equal(last.level, 'error');
assert.match(last.message, /already exists/);
assert.match(last.ts, /^\d{4}-\d\d-\d\dT/); // ISO-8601 UTC
});
test('matches the first (tightest) signature when several apply', () => {
const before = getCryptoDiagEntries().length;
// Matches both KE-1 'already exists' (index 0) and KE-2 'MissingKey' (index 3);
// find() returns the first, so the tightest/most-specific label is recorded.
console.error('MissingKey: the session key already exists somehow');
const e = getCryptoDiagEntries();
assert.equal(e.length, before + 1);
assert.equal(e[e.length - 1].ke, 'KE-1');
assert.equal(e[e.length - 1].signature, 'already exists');
});
test('serializes Error and object args into the captured message', () => {
const b1 = getCryptoDiagEntries().length;
console.error(new Error('boom: io.element.call.encryption_keys arrived encrypted'));
const afterErr = getCryptoDiagEntries();
assert.equal(afterErr.length, b1 + 1);
assert.match(afterErr[afterErr.length - 1].message, /^Error: boom/);
assert.equal(afterErr[afterErr.length - 1].ke, 'KE-2');
const b2 = getCryptoDiagEntries().length;
console.warn('missing key at index', { index: 7 });
const afterObj = getCryptoDiagEntries();
assert.equal(afterObj.length, b2 + 1);
assert.match(afterObj[afterObj.length - 1].message, /\{"index":7\}/);
assert.equal(afterObj[afterObj.length - 1].ke, 'KE-2');
});
test('ring-buffers to at most 200 entries, evicting the oldest', () => {
// Push well past the cap with uniquely-tagged KE-4 lines (matches /delayed event/i).
for (let i = 0; i < 250; i += 1) {
console.warn(`delayed event tag=${i};`);
}
const e = getCryptoDiagEntries();
assert.equal(e.length, 200, 'buffer is capped at MAX_ENTRIES');
assert.match(e[e.length - 1].message, /tag=249;/, 'newest is retained');
assert.ok(!e.some((x) => x.message.includes('tag=0;')), 'the oldest pushes were evicted');
});
test('getCryptoDiagEntries returns a copy, not the live buffer', () => {
const a = getCryptoDiagEntries();
const b = getCryptoDiagEntries();
assert.notEqual(a, b, 'each call returns a fresh array');
const len = a.length;
a.push({ ts: 'x', level: 'warn', ke: 'X', signature: 'x', message: 'x' });
assert.equal(getCryptoDiagEntries().length, len, 'mutating the copy does not affect the buffer');
});
// NOTE: these run after the ring-buffer test has filled the buffer to its cap,
// so a capture evicts the oldest and length stays at 200 — assert on the NEWEST
// entry (always the line just pushed) rather than a length delta.
test('captures the KE-3 and KE-4 signatures', () => {
console.error('DecryptionError: unable to decrypt event');
const e1 = getCryptoDiagEntries();
assert.equal(e1[e1.length - 1].ke, 'KE-3');
assert.equal(e1[e1.length - 1].signature, 'DecryptionError');
// underscore, not a space, so it matches the tighter 'update_delayed_event'
// row (index 5) rather than the looser 'delayed event' row.
console.warn('msc4157.update_delayed_event timed out');
const e2 = getCryptoDiagEntries();
assert.equal(e2[e2.length - 1].ke, 'KE-4');
assert.equal(e2[e2.length - 1].signature, 'update_delayed_event');
});
test('serializes an unserializable (circular) arg via the String() fallback without throwing', () => {
const circular: Record<string, unknown> = {};
circular.self = circular; // JSON.stringify throws → stringifyArg falls back to String()
// Pair with a matching string so the line is captured; the object exercises
// the catch branch. This must not throw.
console.error('DecryptionError from', circular);
const e = getCryptoDiagEntries();
assert.equal(e[e.length - 1].ke, 'KE-3');
assert.match(e[e.length - 1].message, /\[object Object\]/);
});
test('installCryptoDiagLog is idempotent — a second call does not re-wrap console', () => {
const wrappedWarn = console.warn;
installCryptoDiagLog(); // guarded no-op
// Reference unchanged ⇒ not re-wrapped ⇒ a line is captured once, not doubled.
assert.equal(console.warn, wrappedWarn, 'console.warn is not re-wrapped');
console.error('DecryptionError single-capture check');
const e = getCryptoDiagEntries();
assert.match(e[e.length - 1].message, /single-capture check/, 'still captured');
});
test('buildCryptoDiagReport captures client metadata in a fixed, PII-safe shape', () => {
const mx = mockClient({
getDeviceId: () => 'DEVICE123',
getUserId: () => '@alice:example.org',
getSyncState: () => 'SYNCING',
getCrypto: () => ({}),
getSdkVersion: () => '99.9.9',
});
const report = JSON.parse(buildCryptoDiagReport(mx));
assert.equal(report.kind, 'lotus-crypto-diag');
assert.equal(report.deviceId, 'DEVICE123');
assert.equal(report.userId, '@alice:example.org');
assert.equal(report.syncState, 'SYNCING');
assert.equal(report.cryptoReady, true);
assert.equal(report.sdkVersion, '99.9.9');
assert.equal(report.maxEntries, 200);
assert.equal(report.entryCount, getCryptoDiagEntries().length);
const sum = Object.values<number>(report.countsByKe).reduce((a, b) => a + b, 0);
assert.equal(sum, report.entryCount, 'countsByKe sums to entryCount');
// Locked field set: nothing beyond these documented keys ever leaks.
assert.deepEqual(Object.keys(report).sort(), [
'countsByKe',
'cryptoReady',
'deviceId',
'entries',
'entryCount',
'generatedAt',
'kind',
'maxEntries',
'sdkVersion',
'syncState',
'userId',
]);
});
test('buildCryptoDiagReport degrades gracefully with no client', () => {
const report = JSON.parse(buildCryptoDiagReport());
assert.equal(report.deviceId, null);
assert.equal(report.userId, null);
assert.equal(report.syncState, null);
assert.equal(report.cryptoReady, false);
// sdkVersion falls back to the declared package.json pin — a version string.
assert.equal(typeof report.sdkVersion, 'string');
assert.ok(report.sdkVersion.length > 0);
});
test('sdkVersion falls back to the declared pin when the client getter throws', () => {
const mx = mockClient({
getDeviceId: () => null,
getUserId: () => null,
getSyncState: () => null,
getCrypto: () => undefined,
getSdkVersion: () => {
throw new Error('not ready');
},
});
const report = JSON.parse(buildCryptoDiagReport(mx));
assert.equal(typeof report.sdkVersion, 'string');
assert.ok(report.sdkVersion.length > 0);
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
canFitInScrollView,
getThumbnailDimensions,
isInScrollView,
isIntersectingScrollView,
syntaxErrorPosition,
tryDecodeURIComponent,
} from './dom';
// The scroll-view helpers only read numeric layout properties off their
// elements, so a plain duck-typed object stands in for an HTMLElement.
type ElLike = {
offsetTop?: number;
scrollTop?: number;
offsetHeight?: number;
clientHeight?: number;
};
const el = (props: ElLike): HTMLElement => props as unknown as HTMLElement;
describe('getThumbnailDimensions', () => {
it('leaves dimensions within the 400x300 cap untouched', () => {
assert.deepEqual(getThumbnailDimensions(200, 150), [200, 150]);
assert.deepEqual(getThumbnailDimensions(400, 300), [400, 300]);
assert.deepEqual(getThumbnailDimensions(100, 100), [100, 100]);
});
it('scales down by height when taller than 300', () => {
// 200x600 -> width * (300/600) = 100, height clamped to 300
assert.deepEqual(getThumbnailDimensions(200, 600), [100, 300]);
});
it('scales down by width when wider than 400', () => {
// 800x200 -> height * (400/800) = 100, width clamped to 400
assert.deepEqual(getThumbnailDimensions(800, 200), [400, 100]);
});
it('applies the height clamp first, then the width clamp', () => {
// 800x600 -> height clamp: 400x300 (width already at cap, no further change)
assert.deepEqual(getThumbnailDimensions(800, 600), [400, 300]);
// 1200x600 -> height clamp: 600x300 -> width clamp: 400x200
assert.deepEqual(getThumbnailDimensions(1200, 600), [400, 200]);
});
it('floors fractional results', () => {
// 300x700 -> width * (300/700) = 128.57 -> floored to 128
assert.deepEqual(getThumbnailDimensions(300, 700), [128, 300]);
});
it('scales on a just-over-boundary input (strict > comparisons)', () => {
// one over the height cap -> scales; one over the width cap -> scales
assert.deepEqual(getThumbnailDimensions(400, 301), [398, 300]);
assert.deepEqual(getThumbnailDimensions(401, 300), [400, 299]);
});
});
describe('tryDecodeURIComponent', () => {
it('decodes a valid encoded component', () => {
assert.equal(tryDecodeURIComponent('a%20b'), 'a b');
assert.equal(tryDecodeURIComponent('%C3%A9'), 'é');
});
it('returns the input unchanged when it has no escapes', () => {
assert.equal(tryDecodeURIComponent('hello'), 'hello');
});
it('returns the raw input on a malformed sequence instead of throwing', () => {
assert.equal(tryDecodeURIComponent('%'), '%');
assert.equal(tryDecodeURIComponent('%E0%A4%A'), '%E0%A4%A');
});
});
describe('syntaxErrorPosition', () => {
it('extracts the position when the number ends the message (real V8/Node shape)', () => {
// Real JSON.parse errors read "... at position N" with N at end-of-string.
assert.equal(
syntaxErrorPosition(new SyntaxError('Unexpected end of JSON input at position 10')),
10,
);
});
it('extracts the position when it is followed by more text', () => {
// Newer V8 appends "(line N column M)" after the number.
assert.equal(
syntaxErrorPosition(new SyntaxError('bad token in JSON at position 6 (line 1 column 7)')),
6,
);
assert.equal(syntaxErrorPosition(new SyntaxError('bad at position 42 more')), 42);
});
it('returns undefined when the message has no position', () => {
assert.equal(syntaxErrorPosition(new SyntaxError('Unexpected end of input')), undefined);
});
});
describe('isIntersectingScrollView', () => {
// Viewport spans 0..100 (offsetTop 0 + scrollTop 0, height 100).
const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 });
it('is true for a child fully inside the view', () => {
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 20, clientHeight: 30 })), true);
});
it('is true for a child straddling the top edge', () => {
// -10..20 -> bottom (20) is within 0..100
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 30 })), true);
});
it('is true for a child taller than and spanning the whole view', () => {
// -20..180 -> top above, bottom below
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -20, clientHeight: 200 })), true);
});
it('is false for a child entirely above or below the view', () => {
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -50, clientHeight: 20 })), false);
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 200, clientHeight: 20 })), false);
});
it('respects the strict pixel boundaries (> vs >=)', () => {
// child bottom sits exactly on scrollTop (0) -> not intersecting (childBottom > scrollTop is strict)
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 10 })), false);
// child top sits exactly on scrollBottom (100) -> not intersecting (childTop < scrollBottom is strict)
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 100, clientHeight: 20 })), false);
});
it('accounts for the view scrollTop offset', () => {
// View 0..100 in layout, scrolled by 100 -> logical window 100..200.
const scrolled = el({ offsetTop: 0, scrollTop: 100, offsetHeight: 100 });
assert.equal(
isIntersectingScrollView(scrolled, el({ offsetTop: 120, clientHeight: 10 })),
true,
);
assert.equal(
isIntersectingScrollView(scrolled, el({ offsetTop: 20, clientHeight: 10 })),
false,
);
});
});
describe('isInScrollView', () => {
const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 });
it('is true only when the child is fully within the view', () => {
assert.equal(isInScrollView(view, el({ offsetTop: 10, offsetHeight: 50 })), true);
// straddles the bottom edge -> not fully in
assert.equal(isInScrollView(view, el({ offsetTop: 80, offsetHeight: 50 })), false);
});
});
describe('canFitInScrollView', () => {
it('is true when the child is shorter than the view', () => {
const view = el({ offsetHeight: 100 });
assert.equal(canFitInScrollView(view, el({ offsetHeight: 60 })), true);
assert.equal(canFitInScrollView(view, el({ offsetHeight: 100 })), false);
assert.equal(canFitInScrollView(view, el({ offsetHeight: 140 })), false);
});
});
+19 -2
View File
@@ -230,7 +230,11 @@ export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
};
export const syntaxErrorPosition = (error: SyntaxError): number | undefined => {
const match = error.message.match(/position\s(\d+)\s/);
// The number may sit at the very end of the message — real V8/Node JSON
// errors read "... at position 7" with no trailing character — so do NOT
// require whitespace after the digits (that made this return undefined for
// every real error, silently pointing the editors' cursor at position 0).
const match = error.message.match(/position\s(\d+)/);
if (!match) return undefined;
const posStr = match[1];
@@ -264,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);
+16
View File
@@ -29,6 +29,18 @@ const getCtx = (): AudioContext | undefined => {
}
};
/**
* C-L3 prime (create + resume) the shared ringtone AudioContext from within a
* user gesture. Browsers keep a fresh context suspended until a gesture, and an
* incoming-call ring fires with no gesture of its own, so the first ring after a
* cold page load could be silent (resume() is async and may not finish before
* the notes are scheduled). Call this on any early app gesture so a later ring
* plays through an already-running context. Mirrors `unlockCallSounds`.
*/
export const unlockRingtoneAudio = (): void => {
getCtx();
};
type Note = {
freq: number;
/** Offset from phrase start, in seconds */
@@ -173,6 +185,10 @@ const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => vo
* silent. This matches the pre-existing behaviour of the classic ringtone.
*/
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
// C-L2 — a real incoming ring supersedes any lingering Settings preview so the
// two don't overlap (the preview is otherwise only cleared by its own timer).
activePreviewStop?.();
activePreviewStop = null;
if (id === 'none') return () => undefined;
if (id === 'classic') return startClassic(volume, true);
return startSynth(id, volume, true);
+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) => {
+20
View File
@@ -23,6 +23,7 @@ const decide = (
highlight: false,
notify: false,
roomMuted: false,
roomMentionsOnly: false,
...overrides,
});
@@ -53,6 +54,25 @@ describe('shouldNotifyThreadReply', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify');
});
it('roomMentionsOnly: Default + participating + participated but no highlight => none', () => {
assert.equal(decide({ roomMentionsOnly: true, participated: true }), 'none');
});
it('roomMentionsOnly: Default + highlight still notifies loudly', () => {
assert.equal(decide({ roomMentionsOnly: true, highlight: true }), 'loud');
});
it('roomMentionsOnly does NOT suppress an explicit All override', () => {
assert.equal(
decide({ roomMentionsOnly: true, mode: ThreadNotificationMode.All, highlight: false }),
'notify',
);
});
it('roomMentionsOnly: Default + defaultBehavior all + no highlight => none', () => {
assert.equal(decide({ roomMentionsOnly: true, defaultBehavior: 'all' }), 'none');
});
it('mode MentionsOnly + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud');
});
+15 -7
View File
@@ -106,8 +106,16 @@ export function shouldNotifyThreadReply(input: {
highlight: boolean;
notify: boolean;
roomMuted: boolean;
/**
* The room is set to "Mentions & Keywords only" (room push rule, not the
* global default). When the thread mode is Default, this makes only
* highlights notify honoring the room preference instead of the
* all/participating default (which otherwise over-notifies). An explicit
* per-thread All/MentionsOnly/Mute override still wins.
*/
roomMentionsOnly: boolean;
}): ThreadNotifyDecision {
const { mode, defaultBehavior, participated, highlight, roomMuted } = input;
const { mode, defaultBehavior, participated, highlight, roomMuted, roomMentionsOnly } = input;
if (roomMuted) return 'none';
if (mode === ThreadNotificationMode.Mute) return 'none';
@@ -120,13 +128,13 @@ export function shouldNotifyThreadReply(input: {
return highlight ? 'loud' : 'none';
}
// ThreadNotificationMode.Default
if (defaultBehavior === 'all') {
return highlight ? 'loud' : 'notify';
}
// defaultBehavior === 'participating'
// ThreadNotificationMode.Default — highlights always notify loudly.
if (highlight) return 'loud';
// Room is "Mentions & Keywords only": a Default thread inherits that, so a
// non-highlight reply does not notify.
if (roomMentionsOnly) return 'none';
if (defaultBehavior === 'all') return 'notify';
// defaultBehavior === 'participating'
return participated ? 'notify' : 'none';
}
+101
View File
@@ -24,6 +24,11 @@ import {
getBlueskyEmbed,
getLoomId,
getKickChannel,
getMixcloudFeed,
getDeezerEmbed,
deezerEmbedHeight,
getSteamTarget,
steamWidgetEmbedUrl,
buildVideoEmbedUrl,
spotifyEmbedHeight,
parseMediaEmbed,
@@ -57,6 +62,10 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
// a normal video with a trailing sub-path segment must NOT capture it as a hash
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.hash, undefined);
assert.equal(getVimeoParts('https://vimeo.com/123456789/settings')?.hash, undefined);
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.id, '123456789');
});
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
@@ -113,6 +122,7 @@ test('Dailymotion + Streamable', () => {
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page
});
test('Twitch: channel / video / clip', () => {
@@ -132,6 +142,10 @@ test('Twitch: channel / video / clip', () => {
type: 'clip',
value: 'CoolSlug',
});
// reserved utility pages are not channels
assert.equal(getTwitchTarget('https://twitch.tv/directory'), null);
assert.equal(getTwitchTarget('https://twitch.tv/settings'), null);
assert.equal(getTwitchTarget('https://twitch.tv/videos'), null); // bare /videos, not a channel
});
test('Spotify target + height', () => {
@@ -153,6 +167,9 @@ test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
assert.equal(isSoundCloudTrack('https://soundcloud.com/discover/xyz'), false); // site section
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets'), false); // profile-tab listing
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets/my-set'), true); // a real set
});
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
@@ -196,6 +213,88 @@ 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',
appId: '739630',
});
assert.deepEqual(
getSteamTarget(
'https://store.steampowered.com/news/app/739630/view/668371152183232404?l=english',
),
{ kind: 'news', appId: '739630', gid: '668371152183232404' },
);
assert.deepEqual(getSteamTarget('https://store.steampowered.com/bundle/232/'), {
kind: 'store',
label: 'bundle',
});
assert.deepEqual(getSteamTarget('https://store.steampowered.com/sub/12345/'), {
kind: 'store',
label: 'sub',
});
// non-content store pages and other hosts are not embedded
assert.equal(getSteamTarget('https://store.steampowered.com/'), null);
assert.equal(getSteamTarget('https://store.steampowered.com/search/?term=horror'), null);
assert.equal(getSteamTarget('https://steamcommunity.com/app/739630'), null);
assert.equal(getSteamTarget('not a url'), null);
});
test('steamWidgetEmbedUrl', () => {
assert.equal(steamWidgetEmbedUrl('739630'), 'https://store.steampowered.com/widget/739630/');
});
test('getTweetId', () => {
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
@@ -287,6 +386,8 @@ test('Bluesky / Loom / Kick', () => {
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
assert.equal(getKickChannel('https://kick.com/browse'), null); // nav page, not a channel
assert.equal(getKickChannel('https://kick.com/following'), null);
assert.ok(
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
'player.kick.com/streamer?autoplay=true',
+246 -6
View File
@@ -66,8 +66,10 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
// Canonical /{id} or unlisted /{id}/{hash}
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
// Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token
// (constrain it so a normal video's trailing segment — /likes, /settings, a
// review slug — isn't captured as a bogus `h=` param that Vimeo then rejects).
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-f]{6,}))?/);
if (m) return { id: m[1], hash: m[2] };
// channels/groups/album share a trailing numeric video id
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
@@ -192,17 +194,67 @@ export function getDailymotionId(url: string): string | null {
// --- Streamable -----------------------------------------------------------
// Streamable's own utility/first-path pages that are not video ids.
const STREAMABLE_RESERVED = new Set([
'e',
'login',
'signup',
'settings',
'account',
'dashboard',
'help',
'terms',
'privacy',
'about',
]);
export function getStreamableId(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
return m && m[1] !== 'e' ? m[1] : null;
return m && !STREAMABLE_RESERVED.has(m[1].toLowerCase()) ? m[1] : null;
} catch {
return null;
}
}
// --- Steam ----------------------------------------------------------------
export type SteamTarget =
// a game/app store page → gets the official click-to-play store widget
| { kind: 'app'; appId: string }
// a news/announcement post → a rich card (no official widget for these)
| { kind: 'news'; appId: string; gid: string }
// bundle / sub / dlc pages → an OG store card (no per-app widget)
| { kind: 'store'; label: string };
/**
* Classify a store.steampowered.com content URL. Only content pages (app / news /
* bundle / sub / dlc) match; the homepage, search, wishlist, cart etc. return
* null and fall through to the generic preview card.
*/
export function getSteamTarget(url: string): SteamTarget | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname.replace(/^www\./, '') !== 'store.steampowered.com') return null;
let m = pathname.match(/^\/news\/app\/(\d+)\/view\/(\d+)/);
if (m) return { kind: 'news', appId: m[1], gid: m[2] };
m = pathname.match(/^\/app\/(\d+)/);
if (m) return { kind: 'app', appId: m[1] };
m = pathname.match(/^\/(bundle|sub|dlc)\/\d+/);
if (m) return { kind: 'store', label: m[1] };
return null;
} catch {
return null;
}
}
/** Steam's official embeddable store widget (live price / discount / Buy). */
export function steamWidgetEmbedUrl(appId: string): string {
return `https://store.steampowered.com/widget/${encodeURIComponent(appId)}/`;
}
// --- Twitch ---------------------------------------------------------------
export type TwitchTarget =
@@ -210,6 +262,32 @@ export type TwitchTarget =
| { type: 'video'; value: string }
| { type: 'clip'; value: string };
// Twitch's own reserved first-path segments — single-segment paths that are
// utility pages, not channels, and must NOT be embedded as `channel=<x>`.
const TWITCH_RESERVED = new Set([
'directory',
'videos',
'settings',
'subscriptions',
'following',
'followers',
'friends',
'inventory',
'wallet',
'drops',
'prime',
'turbo',
'downloads',
'jobs',
'store',
'search',
'dashboard',
'popout',
'p',
'u',
'team',
]);
export function getTwitchTarget(url: string): TwitchTarget | null {
try {
const { hostname, pathname } = new URL(url);
@@ -219,7 +297,9 @@ export function getTwitchTarget(url: string): TwitchTarget | null {
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
if (parts.length === 1 && parts[0] && !TWITCH_RESERVED.has(parts[0].toLowerCase())) {
return { type: 'channel', value: parts[0] };
}
}
} catch {
/* ignore */
@@ -248,6 +328,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
// --- SoundCloud -----------------------------------------------------------
// SoundCloud's own site sections (first segment) that are never `<artist>`.
const SOUNDCLOUD_RESERVED = new Set([
'discover',
'you',
'stream',
'search',
'upload',
'settings',
'notifications',
'messages',
'tags',
'charts',
'people',
'pages',
'terms',
'pro',
]);
// Profile tabs — `/<artist>/<tab>` is a listing, not a single track (a real set
// is the deeper `/<artist>/sets/<slug>`, which has length >= 3 and is allowed).
const SOUNDCLOUD_PROFILE_TABS = new Set([
'tracks',
'sets',
'albums',
'reposts',
'likes',
'following',
'followers',
'comments',
'popular-tracks',
'toptracks',
]);
export function isSoundCloudTrack(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
@@ -260,7 +372,11 @@ export function isSoundCloudTrack(url: string): boolean {
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length >= 2;
if (parts.length < 2) return false;
if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return false;
// `/<artist>/<tab>` profile-tab listing (not a playable single track/set).
if (parts.length === 2 && SOUNDCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return false;
return true;
} catch {
return false;
}
@@ -391,6 +507,22 @@ export function getLoomId(url: string): string | null {
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
// Kick's own reserved first-path segments (nav pages, not channels).
const KICK_RESERVED = new Set([
'browse',
'following',
'category',
'categories',
'search',
'messages',
'subscriptions',
'settings',
'wallet',
'help',
'clips',
'dashboard',
]);
export function getKickChannel(url: string): string | null {
try {
const { hostname, pathname } = new URL(url);
@@ -399,7 +531,11 @@ export function getKickChannel(url: string): string | null {
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean);
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
return parts.length === 1 &&
/^[A-Za-z0-9_]+$/.test(parts[0]) &&
!KICK_RESERVED.has(parts[0].toLowerCase())
? parts[0]
: null;
} catch {
return null;
}
@@ -419,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;
@@ -536,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 };
+10
View File
@@ -30,13 +30,23 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher {
this.oidcRef = oidc;
}
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
// reloads instead of going stale. The SDK invokes persistTokens synchronously
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
// tokens object at runtime, even though its published type omits it — so read
// it here directly (a doRefreshAccessToken override would run too late, since
// persistTokens is called before that method returns).
protected async persistTokens(tokens: {
accessToken: string;
refreshToken?: string;
expiry?: Date;
}): Promise<void> {
const expiresInMs =
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
refreshToken: tokens.refreshToken,
oidc: this.oidcRef,
expiresInMs,
});
}
}