Compare commits

..
Author SHA1 Message Date
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -04:00
jaredandClaude Opus 4.8 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
jaredandClaude Opus 4.8 dcfee9f1df docs(todo): mark Discovery Pass 2 (PERF/SEC/COR) done
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
Replace the open Discovery-pass-2 list with a completion summary + commit refs
(PERF-1..5, SEC-1..4, COR-1..6 shipped this session, gate-green, each reviewed
by two agents). Record PERF-6 / SEC-5 as deferred-informational and note KE-1's
storage.persist() preventive is already implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:29:53 -04:00
jaredandClaude Opus 4.8 ab01d27aa7 fix(correctness): call-invite clock skew, forceState, upload cancel (COR-3/5/6)
COR-3 (CallEmbedProvider): the incoming-call lifetime guard distrusted a
caller's sender_ts only when it was >20s AHEAD of the server ts. A caller clock
that ran SLOW left sender_ts in the past, so the ring auto-dismissed/never
showed for a fresh invite. Trust sender_ts only within ±20s of the server ts,
else fall back to it (also fixes a NaN path when sender_ts is missing).

COR-6 (CallControl): forceState rebuilt CallControlState with 5 args, silently
defaulting screenshareAudioMuted to false; pass this.screenshareAudioMuted.

COR-5 (uploadContent + useBindUploadAtom): cancelling during the retry back-off
was a no-op (mx.cancelUpload only aborts an in-flight request), so the upload
resurrected on the next attempt. Thread an AbortSignal: the back-off sleep
resolves early on abort and the loop stops with an abort error; the hook aborts
a per-upload AbortController on cancel (alongside mx.cancelUpload for the
in-flight case).

All verified by two review passes (no double-settle / no resurrection); includes
their suggested abort-listener cleanup on normal sleep resolution.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:09:21 -04:00
jaredandClaude Opus 4.8 fd3b8b421e fix(spaces): unlink one space-child edge instead of over-deleting (COR-1)
When a single m.space.child was removed (unlinking child C from space P), the
roomToParents reducer fired the whole-room DELETE action, which wiped C's
entire parent set, stripped C as a parent from every other room, and orphaned
C's own descendants until a full resync. So removing C from space A also
dropped C's other parent B, and C's children lost C.

Add a targeted UNLINK {parent, child} action that removes only that one
parent->child edge and prunes the child entry only when its parent set
empties (matching the map's build-time invariant that zero-parent rooms have
no entry). Point the invalid-child branch of handleStateChange at it; DELETE
is unchanged for genuine room leave/delete. Unit-tested (keeps other parents,
prunes on last parent, does NOT orphan descendants, unknown pair no-op).

Verified correct + consumer-safe by two review passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:04:14 -04:00
jaredandClaude Opus 4.8 1f80d1d129 fix(correctness): call-join reset on embed swap + per-path notify dedupe
COR-2 (useCallEmbed): useCallJoined only reset `joined` when the embed became
undefined. Answering a 2nd call swaps the embed A->B directly (embed stays
truthy), so `joined` stayed true and call B rendered as already-joined,
skipping the loading/watchdog UI. Re-seed from `embed?.joined ?? false` on
every embed identity change.

COR-4 (ClientNonUIFeatures): the notify-dedupe used one Map<roomId,eventId>
slot shared by the main-timeline and per-thread paths, so a thread reply
overwrote the room's slot and a re-fired main message (decrypt/edit re-emit,
common in E2EE) then mismatched and double-notified. Key the slot by
`${roomId}|${threadId ?? 'main'}` so each path dedupes independently.

Both verified correct by two review passes (no missed-notification or
missed-join regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:00:52 -04:00
jaredandClaude Opus 4.8 726cefb5ab fix(privacy): wipe plaintext/PII localStorage caches on logout (SEC-1/2)
Several localStorage caches held decrypted message content or user PII and
survived a normal logout, leaving residue on a shared device (the search
index was already wiped; these were not):

- cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends
- cinny_recent_searches_v1     - search query text
- cinny_recent_forward_targets_v1 - recent forward contact/room graph
- cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent
- navToActivePath<userId>       - per-space last-visited room paths
- (plus the translation cache added earlier)

Add a clear function per module and a single auditable clearPlaintextCaches()
aggregator, called from both logout paths (logoutClient + the server-forced
SessionLoggedOut handler) alongside the existing session/search-index wipes.
Unit-tested.

Deliberately NOT cleared (documented in the aggregator): unsent composer
drafts and the presence status message (preserved by product decision N98);
SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes),
already wiped by mx.clearStores(); low-sensitivity UI/metadata residue.

The forward-targets/gifs/stickers/nav-path additions and the accurate
"not covered" documentation address findings from two review passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:56:08 -04:00
jaredandClaude Opus 4.8 7c28ba58b2 docs(translation): document on-device message translation
CI / Build & Quality Checks (push) Successful in 10m45s
CI / Trigger Desktop Build (push) Successful in 7s
Add the feature to README (Messaging) and LOTUS_FEATURES (new
On-Device Message Translation section under Messaging Enhancements),
matching the existing style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:18:34 -04:00
jaredandClaude Opus 4.8 c77ab346d3 fix(translation): address review findings
Follow-up hardening from two review passes on the on-device translation
feature:

- Privacy (HIGH): the translation cache is decrypted message plaintext,
  but logout did not clear it (unlike the search index), leaving up to
  300 cleartext bodies in localStorage on shared devices. Add
  clearTranslationCache() and call it from both logout paths
  (logoutClient and the server-forced SessionLoggedOut handler).
- Edited messages (MEDIUM): the cache key was eventId:target with no
  content dependence, so an edit reused the pre-edit translation. Fold a
  content fingerprint into the key, and re-arm the auto-translate
  one-shot when the text changes.
- Settings (LOW): coerce a persisted translateTargetLang to a supported
  curated code so the hook never targets a language the engine can't
  produce (previously only the UI clamped it).
- Chinese (LOW): restore canonical BCP-47 case (zh-Hant / zh-Hans) at
  the Translator API boundary, since normalizeLang lowercases the script
  subtag for internal keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:18:27 -04:00
jaredandClaude Opus 4.8 ecb7b1a7fb feat(translation): on-device message translation
Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.

- Engine abstraction (utils/translation): TranslationEngine interface
  plus a chromeTranslationEngine implementation (feature-detected,
  caches translator/detector instances, download-progress monitor).
  Pure lang-code helpers (normalize/sameLanguage/curated targets) with
  unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
  (opt-in), with a Messages settings tile — a target-language select
  and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
  persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
  "Translated from <lang> - Show original" chip, and a body swap in
  m.text/m.emote/m.notice that renders the translated text through the
  plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
  downloaded; first-time downloads keep the manual chip (user gesture).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:16:55 -04:00
jaredandClaude Opus 4.8 0ce5e763ad fix(desktop): focus the native window when a notification is clicked
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
On cinny-desktop (Tauri/WebView2) a clicked notification navigated the web
content but never raised the OS window (a service-worker/WebView2 client.focus()
only focuses the document). The service-worker notificationClick path now calls
a new focus_main_window Tauri command via invokeTauri (no-op outside Tauri) in
addition to navigating; the rich-toast path is focused natively on the Rust side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:35:18 -04:00
jaredandClaude Opus 4.8 a8c99f2a45 a11y(polls): drop redundant aria-label on max-selections input
CI / Build & Quality Checks (push) Successful in 11m12s
CI / Trigger Desktop Build (push) Successful in 6s
Review noted the number input had both an htmlFor-associated visible label
("Voters can pick up to") and an aria-label, so the aria-label won and the
visible label was not announced. Remove the aria-label so the accessible name
matches the visible label.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:06:32 -04:00
jaredandClaude Opus 4.8 85ac8de5d9 style: apply prettier across fork files
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:29 -04:00
jaredandClaude Opus 4.8 3a1c626bc8 feat(stickers): "recently used" row in the sticker picker
The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the
shared EmojiBoard did not — you had to hunt through packs to re-send a sticker.
Add recent stickers, mirroring recentGifs:

- New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by
  url, capped 16) + pure addRecentSticker with 4 unit tests.
- EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar
  icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into
  minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they
  render + re-send like pack stickers.
- Recorded on select in the shared delegated click handler, covering both the
  grouped and search paths.

Blast radius is the sticker tab only (reactions/status use the emoji tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:43:17 -04:00
jaredandClaude Opus 4.8 fb8e0c6e14 fix(threads): enable slash commands in the thread composer
The thread composer already showed the /command autocomplete (RoomInput.tsx:954
was never gated), but the interpreter was disabled (:523), so /me, /shrug,
/invite, etc. sent literally in threads - a confusing inconsistency and the
other half of the threads "v1" limitation.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:56:50 -04:00
jaredandClaude Opus 4.8 5cce94edba refactor(notifications): quiet-hours uses shared tested time-window helper
isInQuietHours was a hand-rolled, untested duplicate of the overnight-window
logic. Replace it with the shared, unit-tested isWithinTimeWindow (utils/
timeWindow.ts) - identical behavior for valid HH:MM inputs, more robust on
malformed ones (returns false rather than doing NaN math), and now covered by
timeWindow.test.ts. One implementation instead of two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:43:08 -04:00
jaredandClaude Opus 4.8 f155a4dc22 feat(threads): up-arrow edits your last thread reply (+ fix cross-fire)
The thread composer reused RoomInput's hardcoded editableName="RoomInput", so
the main timeline's global up-arrow "edit last message" handler fired while
focused in a thread composer and targeted the MAIN timeline's last message
(wrong), and there was no up-arrow edit for the thread itself.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:28:26 -04:00
jaredandClaude Opus 4.8 61f1733f50 feat(notifications): cross-platform "Pause Notifications" / snooze
Manual DND only existed via the desktop tray (manualDndAtom), so web/mobile
users had no way to pause notifications, and there was no snooze-for-a-duration
anywhere. Add a "Pause Notifications" control in Settings > Notifications:

- Presets: 30 min / 1 hour / 4 hours / Until 8 AM / Until I resume, plus Resume;
  live "Paused until ..." status that flips back on when the snooze lapses.
- Persisted snooze instant (cinny_notification_snooze_until_v1) so it survives a
  reload; 0 = off, SNOOZE_INDEFINITE = until resumed.
- Feeds the existing notification gate (ClientNonUIFeatures, both the message and
  invite monitors) alongside Focus Assist / manual DND / Quiet Hours, suppressing
  notify() and playSound().
- Pure helpers isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITE in utils/snooze.ts
  (+5 unit tests); persisted atom in state/notificationSnooze.ts.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:08:32 -04:00
jaredandClaude Opus 4.8 cf4ee6618c feat(night-light): optional auto-on schedule (time window)
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles itself on/off
automatically (the overlay re-checks every minute; no reload). Overnight windows
that wrap midnight (e.g. 21:00 -> 07:00) are handled.

Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:51:11 -04:00
jaredandClaude Opus 4.8 b6725a6ee3 feat(reminders): custom date/time option in Remind Me
The Remind Me dialog only offered four fixed presets, so you couldn't set a
reminder for an arbitrary time. Add a "Custom time…" option that reveals date +
time pickers (validated >= 1 minute in the future) and sets the reminder at that
absolute timestamp.

Also extract the local date/time <input> helpers (toLocalDate, toLocalTime,
parseLocalDateTime, pickerInputStyle) into a shared, unit-tested
utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an
inline copy) — identical output, now covered by tests.

Documents the previously-undocumented Message Reminders feature in
LOTUS_FEATURES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:45:43 -04:00
jaredandClaude Opus 4.8 23e264d179 fix(bookmarks): drop unused senderId from stored bookmark
Review noted the 500-entry io.lotus.bookmarks blob approaches the ~65KB event
limit. The senderId I stored was never read back (the panel re-resolves the
live sender via the event and uses the senderName snapshot otherwise), so it
was dead payload. Keep only senderName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:40:39 -04:00
jaredandClaude Opus 4.8 e01b87b214 feat(bookmarks): show who wrote each saved message
Saved-message cards showed the room and time but not the author, so in a busy
room you couldn't tell who said it without jumping. Now each card shows
"{sender} - {time ago}":

- Bookmark gains optional senderId/senderName (snapshotted at save time in
  Message.tsx from the already-computed sender display name); optional so
  existing stored bookmarks stay valid.
- The panel re-resolves the author's current display name live from the event
  when the room is joined, falling back to the stored snapshot for left rooms.
- Search now also matches the author name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:37:12 -04:00
jaredandClaude Opus 4.8 f9af363dd6 fix(media-gallery): harden download filename + zoom/pan from review
- mediaFilename: only skip appending an extension when the body already ends in
  a plausible short alphanumeric extension, so "Screenshot 2024.01.05" still
  gets a real extension appended for the saved file.
- Lightbox pan: divide the translate by zoom (it runs nested inside scale), so
  dragging tracks the cursor 1:1 instead of moving `zoom`x too far.
- Wheel: ignore deltaY === 0 (pure horizontal scroll no longer zooms out).
- Zoom-out button disables at the real reachable minimum (0.2, not dead 0.1).
- Zoom-controls group gets role="group" so its aria-label is announced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:28:27 -04:00
jaredandClaude Opus 4.8 8a92821a48 feat(media-gallery): zoom & pan images in the lightbox
The timeline image viewer supports zoom/pan, but the gallery's own lightbox
rendered a plain object-fit:contain image. Add the same affordances for images
(videos keep their native controls):

- scroll wheel or header -/+ buttons to zoom; +/-/0 keys; double-click or the
  % chip toggles 1x<->2x
- drag to pan when zoomed; zoom/pan reset when navigating to another item

Reuses the shared useZoom/usePan hooks (usePan already cleans up drag listeners
on unmount and resets pan when zoom returns to 1x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:23:14 -04:00
jaredandClaude Opus 4.8 68a88e84b8 feat(media-gallery): download images/videos from the viewer and grid tiles
The gallery's File and Audio tabs already had download buttons, but images and
videos could only be saved by jumping to the source message. Add:

- a Download button in the lightbox header (full-resolution source), and
- a hover/focus download button on each image/video grid tile

Both reuse the shared FileDownloadButton (decrypts E2EE media client-side, saves
via useSaveFile, spinner/check/retry states). The tile download control is a
sibling of the tile button (not nested — avoids interactive-in-interactive) and
stays visible on touch (hover:none) devices. Download always targets the
full-res file/url, not the thumbnail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:18:55 -04:00
jaredandClaude Opus 4.8 82eb65b822 fix(scheduled): clear stale send-now error when a row is edited
CI / Build & Quality Checks (push) Successful in 10m50s
CI / Trigger Desktop Build (push) Successful in 9s
handleEdit already clears cancelErrors for the old row; also clear sendErrors
so a prior failed "Send now" doesn't leave a stale inline error after editing.
Cosmetic hygiene, matching the existing cancelErrors handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:51:15 -04:00
jaredandClaude Opus 4.8 708a15d196 fix(soundboard): let the clip-name field fill its available width
The folds Input container is display:flex with no width, so inside the row's
grow="Yes" wrapper the wrapper grew but the input collapsed to content width.
Set width:100% on the Input so the name field uses the space the row gives it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:48:00 -04:00
jaredandClaude Opus 4.8 120ad2d1b5 feat(scheduled): add "Send now" action to the scheduled-messages tray
Fire a pending scheduled message immediately via MSC4140 action:'send'
(the server dispatches the stored delayed event now, as a normal timeline
event) instead of having to cancel and retype.

- sendScheduledMessageNow(mx, delayId) mirrors cancel/restart with action:'send'
- handleSendNow reuses the per-row busy guard; prunes local state only once the
  server confirms; a failed send shows an inline "Could not send now" error with
  the message still sendable/editable/cancellable
- Send-now IconButton (Icons.Send) added before Edit/Cancel in each row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:47:52 -04:00
jaredandClaude Opus 4.8 b38df58b68 fix(voice): release mic on cancel/unmount + recorder polish
Address findings from 2 review agents (pause/resume duration model +
meter lifecycle were verified correct):

- Mic-stream leak (HIGH, pre-existing but in this change's blast radius):
  the mic tracks were only stopped inside mr.onstop, which cancel() nulls
  and the unmount effect never triggered — so cancelling or unmounting
  mid-recording/pause left the mic live (OS indicator on). Hold the stream
  in a ref and release its tracks explicitly (stopStream) on cancel and on
  unmount, independent of onstop. Normal stop still releases via onstop.

- Defensive: startMeters now cancels any existing rAF/interval before
  starting, so it can never spawn a second loop.

- a11y/UX: the finish button (checkmark, advances to the preview step) is
  relabeled "Finish recording"/"Finish" so the label matches the check
  glyph (was "Stop recording" with the old pause icon). The three
  recording-control buttons get flexShrink:0 so they don't squish the
  waveform at narrow composer widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:04:31 -04:00
jaredandClaude Opus 4.8 13304f1571 feat(voice): pause / resume while recording
The voice recorder was a single continuous take — an interruption meant
stopping early or starting over (and the Stop button confusingly used a
Pause icon). Add real pause/resume.

- MediaRecorder.pause()/resume() with a new 'paused' state.
- Duration now accumulates only active-recording time: an accumulate-on-
  pause model (accumulatedMsRef + segmentStartRef) replaces the wall-clock
  startTime, so paused time is excluded from both the live timer and the
  finalized preview duration.
- Extracted startMeters/stopMeters so the waveform rAF + timer interval are
  reused across start/resume; stopMeters keeps the audio graph alive for
  resume while stopAll tears it down.
- Recording view now also renders the 'paused' state: a Pause/Resume toggle
  (Pause vs Play icon), the record dot stops pulsing (dimmed), and the
  waveform/timer freeze. Stop/Cancel/unmount all handle a paused recorder.
- Fixed the mislabeled finish button: it now shows a checkmark (it advances
  to the preview step) instead of a pause icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:58:01 -04:00
jaredandClaude Opus 4.8 87b1c1a702 fix(captions): harden caption editing after review
Address findings from 2 review agents (core edit path was verified
correct — media preservation incl. encrypted, threading, no-op guard):

- Require a filename (utils/room.ts): canEditCaption now also checks the
  MSC2530 `filename` exists. Fixes media from clients that omit filename,
  where the editor prefilled the filename as a caption and clearing it
  wrote an empty body. Such media simply isn't caption-editable (matches
  renderCaption never showing a caption for it).

- Carry m.mentions (MessageEditor): a caption edit now unions typed
  @-mentions with prior mentions like the text-edit path, so mentioning
  someone in a caption edit notifies them.

- Double caption: revert to the editor replacing the content while editing
  (as text edits do) instead of rendering the media + its caption above an
  editor prefilled with the same caption — removes the confusing duplicate.

- Removed-caption "(edited)" marker (RenderMessageContent): when a media
  message is edited but has no caption (e.g. the caption was removed),
  render the standalone "(edited)" affordance so Edit History stays
  reachable (previously it lived only inside the caption and vanished).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:01:02 -04:00
jaredandClaude Opus 4.8 125af8446f feat(captions): edit image/video captions after sending
Captions could be attached at upload but never changed — canEditEvent
only allowed m.text/emote/notice, so a typo in an image caption meant
delete + re-upload. Add caption editing for image/video messages.

- utils/room.ts: canEditCaption (own image/video RoomMessage, no
  non-thread relation) + canEditEventOrCaption. canEditEvent unchanged.
- Message.tsx: gate the Edit affordance (quick-actions + menu) on
  canEditEventOrCaption; label it "Edit caption" for media; keep the media
  rendered above the editor while editing.
- MessageEditor.tsx: for a media message, seed the editor from the caption
  (not the filename), allow an empty caption (removes it), and build the
  m.replace so m.new_content spreads the original media content
  (url/info/encrypted file/filename/msgtype) and only sets body +
  format/formatted_body. Outer content is the full media (not a "* text"
  fallback) so non-edit-aware clients still render the media. No-op guard
  when the caption is unchanged. Placeholder "Add a caption…".

Rendering + Edit History need no changes: getEditedEvent's m.new_content
flows to renderCaption, and the word-diff already diffs body (the
caption). Encrypted media keeps its file/key (no re-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:50:42 -04:00
jaredandClaude Opus 4.8 9a4796c167 fix(gif): still previews + a11y for recent GIFs
Address findings from 2 review agents on the recent-GIFs row:

- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
  once (autoplaying). Capture a small still image at pick time
  (fixed_width_small_still / *_still) into RecentGif.previewUrl and render
  that for the thumbnail, so recents no longer autoplay. Pre-existing
  recents without a preview fall back to the animated url. Re-send still
  uses the animated url, so the sent m.image is unchanged.

- a11y: the recent buttons all had the identical label "Send recent GIF".
  Give them positional labels ("Send recent GIF N of M") and wrap the grid
  in a role="group" labelled by the "Recent" section heading, so the row
  is a distinguishable, announced group.

Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:34:51 -04:00
jaredandClaude Opus 4.8 5b58e5fe43 feat(gif): recently-used GIFs in the picker
The GIF picker was a bare Giphy search grid with no memory of what you've
sent, so re-sending a go-to reaction GIF meant re-typing the search every
time. Add a "Recent" row at the top of the picker (default view; hidden
while searching) for one-click re-sending.

- New persisted state state/recentGifs.ts: recentGifsAtom (localStorage,
  cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url
  move-to-front, cap 16, ignore empty url), with 5 unit tests.
- GifPicker records every sent GIF (from search or the Recent row) to the
  front, and renders a 3-up thumbnail grid of recents above the search
  grid when there are recents and no active search term. Section label
  matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal)
  or a muted label otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:27:18 -04:00
jaredandClaude Opus 4.8 eaf6853910 fix(nav): harden draft indicator after review
Address findings from 2 review agents on the room-nav draft indicator:

- Icon semantics (a11y): Icons.Message read as message activity and
  collided with two existing bubble uses in the same row (call-chat
  toggle, mark-unread), especially next to the unread badge. Replace it
  with the composer's shared DraftDot (a small color.Success.Main dot),
  so both draft surfaces share one visual language. Rendered as a
  role="img" span with aria-label "Unsent draft" (reliably announced,
  unlike a bare aria-labelled svg).

- Precise thread-key filter: hydration skipped any draftKey containing
  '::', which would also skip an IPv6-literal server name in a roomId.
  Match '::$' (thread root is an event id) so only real thread drafts are
  skipped.

- Defensive hasMsgDraft: guard toPlainText so a corrupted/foreign draft
  value can't throw during a nav render.

- Clear the draft atom on send: the send / scheduled-send handlers reset
  the editor and localStorage but left the jotai draft atom set, so the
  composer DraftIndicator could show a stale dot after sending a restored
  draft. Add setMsgDraft([]) to both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:48:35 -04:00
jaredandClaude Opus 4.8 c44ef8d795 feat(nav): draft indicator on room-nav items
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.

- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
  the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
  re-renders only when its draft flag flips (the draft atom is written on
  room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
  for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
  per-room draft atoms from draft-msg-* localStorage on startup, so
  indicators are correct after a page reload, not only after revisiting a
  room. Thread drafts (key contains ::) are skipped; room-level only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:37:21 -04:00
jaredandClaude Opus 4.8 90e6901a60 fix(edit-history): harden diff after review
Address findings from 2 review agents on the edit-history diff:

- Perf: diffWords is O(n*m); cap at 2000 tokens/side and fall back to a
  coarse whole-block replaced diff above that, so a very large multi-edit
  message can't freeze the main thread. Memoize the per-row diff in
  DiffText. (Added a unit test for the coarse fallback.)

- Perceivability (a11y/design): the added-word <ins> highlight was
  color-fill only, which is faint against the modal surface in the lotus
  themes. Add a Success.ContainerLine border + horizontal padding (so the
  rounded corners read as a chip) + box-decoration-break: clone for clean
  wrapping, so the "added" cue survives low fill contrast.

- Consistency: a media/no-body edit now renders "(no text)" in diff mode
  too (matched the toggle-off view; was blank).

- Softened the code comment's screen-reader claim (bare <ins>/<del> aren't
  announced by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:11:16 -04:00
jaredandClaude Opus 4.8 961789fd71 feat(edit-history): word-level diff view
The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.

- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
  utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
  preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
  semantic <ins>/<del> (screen-reader-meaningful) styled with folds
  Success/Critical tokens. A "Highlight changes" header toggle (default
  on) switches back to full text, which keeps the rich formatted render;
  the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
  (the toggle restores the rich view), and media/no-body edits diff as
  empty strings gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:02:59 -04:00
jaredandClaude Opus 4.8 b6413d763d fix(threads): harden threads list after review
CI / Build & Quality Checks (push) Successful in 11m23s
CI / Trigger Desktop Build (push) Successful in 9s
Address findings from 3 review agents on the Threads list panel:

- Last-activity accuracy (SDK): sort key and the "last reply <time>"
  label now use thread.replyToEvent.getTs() (server bundle latest_event)
  instead of lastReply(), which returns the ROOT time until each thread's
  replies lazily paginate (or permanently on fetch error). Applied to the
  hook signature too.

- Live-refresh completeness (correctness): the useRoomThreads signature
  now includes thread.length and the root event's replacingEventId, so a
  mid-thread redaction (reply count) and a root-message edit (row snippet)
  refresh the row live instead of going stale.

- a11y: the row's aria-label was the button's whole accessible name,
  hiding the snippet/count/unread from screen readers. It now describes
  the thread ("Open thread by <name>, unread, N replies, last reply ..").

- Unread badge: replaced the bare green dot (Success = the mention color)
  with the app-wide UnreadBadge, using the Highlight count so mentions
  render red and ordinary unread renders secondary, matching room-nav.

- Hover/focus affordance: the clickable row moved its inline styles to a
  css class with token-based :hover / :active backgrounds.

- Participant pile now also includes the last replier from the bundle.

- Stabilized the panel's onClose/onOpenThread with useCallback so its
  Escape listener isn't re-subscribed every Room render. Added
  filter->sort pipeline + all/participating immutability tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:21:01 -04:00
jaredandClaude Opus 4.8 d6d1f5a233 feat(threads): room-level Threads list panel
Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).

- Lists every thread with a rich row: root sender + snippet, unread dot,
  "N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
  Oldest by last-reply time), both persisted in localStorage
  (cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
  type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
  setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
  RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
  and is virtualized (@tanstack/react-virtual) for busy rooms.

Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:08:34 -04:00
jaredandClaude Opus 4.8 57f21e5cac fix(forward): harden comment retry + a11y after review
CI / Build & Quality Checks (push) Successful in 10m47s
CI / Trigger Desktop Build (push) Successful in 9s
Address findings from 2 review agents on the forward upgrades:

- Duplicate comment on retry (correctness): if a room's comment message
  sent but the forward then failed, retrying re-posted the comment. Track
  rooms whose comment already delivered (commentSentRef) and skip it on
  retry, sending only the missing forward. An already-commented room won't
  get the comment again even if the text is later edited (no-duplicate
  choice).

- a11y: give the message-preview box role="group" + aria-label
  ("Message to forward"), add aria-label to the comment and search inputs
  (placeholder is not a label), and match the RecentChip's RoomIcon
  fallback size (100) to the room-row convention for a size-200 avatar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:43:32 -04:00
jaredandClaude Opus 4.8 629db9724f feat(forward): message preview, optional comment, recent targets
The Forward dialog forwarded blind. Add three things (design-system
cleanup included):

- Preview: a compact read-only preview at the top of the dialog shows the
  sender + body, with a thumbnail for image/video (reuses ThumbnailContent
  and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We
  already hold mEvent, so nothing is fetched.

- Comment: an optional "Add a comment" field sends a short m.text note to
  each target room, sequenced BEFORE the forwarded message per room so the
  note reads above the quoted content. The existing per-room failure /
  retry logic is preserved (a room fails if either send rejects).

- Recent targets: a "Recent" chip row (hidden while searching) offers
  one-tap selection of rooms you last forwarded to. Successful targets are
  recorded most-recent-first, deduped, capped at 8, in localStorage via the
  pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts).
  Rooms you've since left are filtered out of the row.

Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a
token-free opacity dim of the list (design-system rule: no hardcoded
colors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:37:49 -04:00
jaredandClaude Opus 4.8 a739c25f10 fix(status): stop presence heartbeats from clobbering status edits
Selecting an emoji (or any unsaved edit) in the profile Status Message
field could vanish because the presence-sync effect re-applied the remote
status_msg on every presence heartbeat (which fire every few seconds).
The dirty-edit guard alone left a window where a heartbeat carrying the
previous status overwrote the just-inserted emoji, so there was no way to
add emoji to a status.

Track the last remote status we synced and only react when the remote
value actually changes, instead of on every heartbeat. Repeated
heartbeats with an unchanged status are now ignored, so an unsaved local
edit is preserved regardless of the dirty flag's timing. Cross-device
status changes (a genuinely new remote value) and clears still sync, and
the pending-applied stale-echo guard is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:13:44 -04:00
jaredandClaude Opus 4.8 53ce2e40a4 fix(url-preview): thumbnail fallback + quiet timeline console spam
Two console-noise / glitch fixes surfaced while a room with link
previews was open:

- URL preview og:image thumbnails 400 when Synapse can't thumbnail a
  cached preview image (SVG/animated), leaving a broken image that the
  browser keeps re-requesting. GenericCard now falls back to the full
  image on error, then hides the image (and shows the link icon) if that
  also fails, so no broken image and no repeated failing requests.

- Extend the existing console.warn filter to drop matrix-js-sdk's
  high-volume, benign timeline bookkeeping warnings ("EventTimelineSet…"
  and "Decrypted event … is not in room …"), which fire constantly in
  E2EE rooms with threads. Real warnings still log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:47:19 -04:00
jaredandClaude Opus 4.8 398e743cdd fix(status): harden presets after review
Address findings from 2 review agents on the status-presets feature:

- Presence-echo race: a heartbeat can echo the previous status just after
  a new one is saved, reverting the input. Track the last-applied value
  and ignore non-matching echoes until our own echo lands or a 15s window
  elapses (bounded so a dropped echo can't block real cross-device
  updates). Applies to Save, preset apply, and Clear.

- Duplicate chips: a saved custom preset that matches a built-in is now
  hidden from "Your presets" (it already shows under Quick statuses).

- a11y: the two preset rows use aria-labelledby tied to their visible
  headings instead of mismatched hardcoded aria-labels.

- Visual grouping: a custom preset's chip and its delete X now sit with
  gap=0 as one unit while the row separates presets with gap=200, so a
  chip and its delete no longer read as two separate presets. Delete/Plus
  icons bumped to size=100 to match the folds chip-icon convention.

- Parity: addPreset/removePreset promises are now caught like the other
  account-data call sites.

Docs updated to the exact 11-preset built-in list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:37:05 -04:00
jaredandClaude Opus 4.8 d0614710b0 feat(status): built-in and custom status presets
The Status Message field required typing every status from scratch. Add
a one-click preset row:

- Built-in "Quick statuses" spanning gaming, social, life and work
  (Gaming, In a party, Ranked grind, AFK, Watching, In a meeting,
  Working remotely, Lunch, On vacation, Out sick...), each carrying a
  suggested auto-clear so a click sets the message and the timer at once.
- Custom presets: save the current status as a reusable preset, stored
  in io.lotus.status_presets account data (synced across devices via the
  shared account-data list store), de-duped by normalized label, capped
  at 20, deletable inline.

The existing save path is factored into a shared applyStatus() used by
the Save button and by preset apply, so server writes, the status
localStorage keys, and the auto-clear expiry bookkeeping stay identical.
Ordering/de-dupe logic is pure in utils/statusPresets.ts (upsertPreset,
normalizeLabel) with unit tests; no change to the presence wire format,
expiry monitor, or presence-mode selector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:30:08 -04:00
jaredandClaude Opus 4.8 39e75f4eea fix(bookmarks): harden sort/group after review
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s
Address findings from 2 review agents on the bookmark sort/group feature:

- Flash on open: the persisted-sort atom now uses getOnInit so the saved
  sort applies on the first render instead of briefly showing Newest and
  reordering after mount.

- Stale collapse state: prune collapsed roomIds that no longer have any
  bookmark, so a room re-saved later doesn't reappear pre-collapsed and
  the Set can't grow unbounded across a session.

- Corrupt persisted value: validate the stored sort with a new
  isBookmarkSort type guard, normalizing anything unexpected to Newest so
  exactly one sort button is always active.

- a11y: room group headers now expose an explicit aria-label
  ("<room>, N saved messages") instead of announcing the avatar alt and
  the visible name twice with a bare count, plus aria-controls linking the
  header to its collapsible content region.

- Layout: move the sort control to its own toolbar row so the three
  buttons don't crowd the count text in the narrow (266px) panel.

- Memoize filtered/sortedItems/groups for consistency with renderItem.

Adds unit tests for isBookmarkSort, group-order tie-break, and
groupBookmarksByRoom immutability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:09:42 -04:00
jaredandClaude Opus 4.8 cb3cd30ab5 feat(bookmarks): sort & group saved messages by room
The Saved Messages panel showed bookmarks in one fixed order (newest
save first) with no way to reorganize. Add a Newest / Oldest / By-room
segmented sort control to the panel toolbar. In "By room" mode the list
renders collapsible per-room sections, with groups ordered by their most
recently saved message so active rooms float to the top. The chosen sort
persists across panel opens via a localStorage-backed atom.

Ordering and grouping are pure functions in utils/bookmarks.ts
(sortBookmarks, groupBookmarksByRoom) with deterministic eventId
tie-breaks, covered by bookmarks.test.ts (9 tests). No change to the
bookmark data model, account-data schema, useBookmarks, or how bookmarks
are created; search still feeds the sorter/grouper unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:03:28 -04:00
jaredandClaude Opus 4.8 cacefb1f30 fix(scheduling): harden edit/reschedule after review
Address findings from 3 review agents on the edit/reschedule feature:

- Race (correctness): handleEdit now marks the old message as cancelling
  while its cancel is in flight, so its Edit/Cancel buttons are disabled.
  Previously the old row stayed live during the fire-and-forget cancel, so
  a fast second edit could orphan a still-scheduled event and send twice.

- Durability (correctness): on a failed cancel-old, re-insert the old
  message if auto-prune removed its row while the modal was open, so the
  still-live delayed event stays visible and retriable instead of failing
  silently. Also clear any stale cancel error when starting an edit.

- a11y: per-row Edit/Cancel buttons now carry distinct aria-labels that
  include the message preview and send-time, so screen-reader users can
  tell which of several scheduled messages each button targets.

- UX: modal gains a submitLabel prop; the edit flow shows "Reschedule"
  instead of "Schedule". Modal now focuses the message body on open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:54:57 -04:00
jaredandClaude Opus 4.8 5d5ae0ee70 feat(scheduling): edit / reschedule a scheduled message
CI / Build & Quality Checks (push) Successful in 11m43s
CI / Trigger Desktop Build (push) Successful in 8s
The scheduled-messages tray was cancel-only. Add an inline edit button
that re-opens ScheduleMessageModal seeded with the existing body and
send-time, letting the user change the text and/or when it sends.

MSC4140 has no in-place edit, so an edit is schedule-new + cancel-old.
Order matters: the modal schedules the new delayed event first, then we
cancel the old one and only prune it from local state once the server
confirms. A failed cancel therefore leaves a visible, retriable copy in
the tray instead of silently letting the stale message fire or losing
the edit. Edits go through the plain-text composer, so rich content
collapses to m.text (acceptable for v1).

ScheduleMessageModal gains optional initialSendAt (seed the pickers) and
title props so it is reusable for both scheduling and editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:47:43 -04:00
jaredandClaude Opus 4.8 7d02f4e538 fix(voice): apply waveform review findings
Three review agents (no regressions found). Applied:
- Keyboard arrow-seek now reads the live media currentTime, not the throttled
  ~500ms state, so rapid presses accumulate instead of dropping steps.
- Scrubbing the waveform (or the fallback seek bar) BEFORE first play now loads
  the media and plays from the clicked position (was a silent no-op).
- Unplayed bars use a dimmed accent (color-mix 32%) instead of a faint surface
  token, for consistent contrast across TDS-dark/light + normal themes.
- Fixed first-bar always-lit off-by-one ((i+1)/len), and added overflow:hidden so
  the strip clips rather than overflows in a very narrow drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:01:41 -04:00
jaredandClaude Opus 4.8 56863d2649 feat(voice): render the waveform on playback with click/drag/keyboard scrubbing
Voice messages carry an MSC1767 waveform (org.matrix.msc1767.audio.waveform) and the
recorder draws a live one, but AudioContent playback only showed a plain seek bar.
Now the player renders the waveform as bars that fill with the accent (TDS green under
Lotus Terminal) as the clip plays, and the waveform itself is the seek control —
click, drag, or keyboard (arrows +/-5s, Home/End) with role=slider + ARIA value text.

- AudioContent: new optional "waveform" prop + a WaveformSeek sub-component
  (downsamples to 48 bars, mirrors the recorder's bar styling); falls back to the
  plain Range seek bar when there's no waveform.
- Threaded through MAudio (RenderAudioContentProps) so timeline voice messages get it
  automatically; the Media Gallery Audio tab passes it directly.

Improves both the timeline and the new gallery Audio tab at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:50:08 -04:00
jaredandClaude Opus 4.8 2d0c804abc fix(media-gallery): address review — proper decrypt-download + mimetype sanitize
Review of 28cb004e (3 agents) surfaced two real issues:
- Downloads used a raw mxc→http anchor, so encrypted media (voice messages are
  almost always in E2EE DMs) downloaded ciphertext. Reuse FileDownloadButton for
  both the audio AND file rows — it decrypts before saving, adds loading/success
  state + a toast, and derives a filename extension.
- Audio playback bypassed MAudio's mimetype sanitization; pass the value through
  getBlobSafeMimeType (e.g. application/ogg → audio/ogg) so encrypted/odd-mimetype
  audio actually plays. Give voice messages a real filename+extension.

Also corrected the docs: AudioContent renders a seek bar, not an MSC3245 waveform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:35:35 -04:00
jaredandClaude Opus 4.8 28cb004e80 feat(media-gallery): add Audio/Voice tab + jump-to-message
- New Audio tab (m.audio) listing voice messages + audio files with an inline player
  (reuses AudioContent + MediaControl: play/seek/speed, MSC3245 voice waveform,
  decrypt-on-play), sender/date, and download. Added to the tab counts.
- Jump-to-message: a 'Go to message' action on file rows, audio rows, and in the
  lightbox navigates the timeline to the source event (useRoomNavigate) and closes
  the drawer; threaded eventId into LightboxItem.
- Update the (stale) LOTUS_FEATURES.md Media Gallery entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:26:02 -04:00
jaredandClaude Opus 4.8 0b06158477 feat(polls): complete the poll lifecycle — voting, undisclosed, end, spec-correct
Upgrade the MSC3381 poll feature from a leaky half-implementation to a complete,
cross-client-correct one:

- End/close a poll (creator or redact-PL mod) via inline confirm → m.poll.end;
  locks voting, reveals results, marks winner(s); only pre-end responses count.
- Honor poll kind: undisclosed polls hide counts/percent/bars/total until ended
  (creator gets a Show-live-results vs Hidden-until-ended toggle; default live).
  Previously every poll was created undisclosed yet the UI leaked live results.
- Enforce max_selections for multi-choice; radiogroup/checkbox a11y with arrow-key
  roving and an AT-announced winner.
- Robust, dual-namespace wire handling: parse BOTH stable (m.poll/m.id/m.selections)
  and unstable (org.matrix.msc3381.poll.*) by hand — matrix-js-sdk 41.7.0's
  PollStart/Response parsers only understand the unstable bodies, so delegating to
  them broke every stable poll (caught in agent review). Use the SDK Poll model only
  for end validation + before-end filtering.
- Pure tally/visibility/winner/parse logic extracted to utils/poll.ts with 14 tests
  incl. a stable/unstable wire-format round-trip.

Reviewed by 3 agents (spec/cross-client, logic, a11y/UI); findings applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:48:58 -04:00
186 changed files with 10306 additions and 1297 deletions
+24 -6
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
@@ -53,26 +65,32 @@ jobs:
- name: Unit tests
run: npm test
# ── Quality checks (informational — pre-existing issues exist) ───────
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
# formatted), so these gate real regressions instead of relying on local
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
# introduce upstream type/lint/format issues; that path deploys without
# CI, but a subsequent normal push would surface the failure here — fix
# forward (or briefly re-soften a gate) rather than let it deploy broken.
# eslint gates on errors only (existing `no-explicit-any` warnings stay
# informational — `check:eslint` has no --max-warnings).
- 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
+140 -31
View File
@@ -1,7 +1,7 @@
# Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: June 2026.
Last updated: July 2026.
---
@@ -294,6 +294,7 @@ A warm orange overlay rendered over the entire UI to reduce blue light emission.
- CSS: `position: fixed; inset: 0; pointer-events: none; z-index: 9998`
- Orange tint color with configurable opacity
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
- **Schedule (auto on at night):** an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested `isWithinTimeWindow`/`parseHHMM` in `src/app/utils/timeWindow.ts` (`timeWindow.test.ts`). Defaults: 21:0007:00.
- Settings persisted via the standard Lotus settings store
---
@@ -660,6 +661,7 @@ The indicator is hidden once the server confirms the event (when the internal st
- API: `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace`
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
- **Word-level diff**: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic `<ins>`/`<del>`. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-tested `diffWords` (LCS) in `src/app/utils/textDiff.ts`.
- Accessible from the message context menu
### Inline GIF Preview
@@ -673,18 +675,70 @@ The indicator is hidden once the server confirms the event (when the internal st
- Giphy-powered picker accessible from the composer toolbar
- The button is only shown when `gifApiKey` is set in `config.json`
- Selected GIFs are sent as `m.image` events
- **Recently used**: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (`cinny_recent_gifs_v1`), deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentGif` (`src/app/state/recentGifs.ts`).
- Picker UI is styled with TDS variables when the TDS theme is active
- Located at `src/app/components/GifPicker.tsx`
### Sticker Picker — Recently used
The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar
`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`),
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker`
(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s
(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack
stickers. Recorded on select for both the grouped and search paths (shared delegated click).
### Message Forwarding
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
### Copy Message Text
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
### On-Device Message Translation
Translate chat messages written in other languages into a language you choose,
inline in the timeline — running **entirely on your device** so message text
never leaves it.
- **Per-message translate** — a foreign-language message shows a **Translate**
action in its message menu; once translated, the message displays an inline
**"Translated from &lt;language&gt; · Show original"** toggle that swaps between
the translation and the original text.
- **Fully on-device / E2EE-preserving** — translation and language detection run
through the browser's built-in **Translator** and **Language Detector** APIs
(the Chromium on-device AI translation models). Message text is **never** sent
to any cloud translation service — no Google / DeepL / Microsoft, not even a
self-hosted server — and there is **no network fallback**, so end-to-end
encryption is preserved. That privacy guarantee is the whole point of the
feature.
- **Automatic detection** — the language of each message is detected
automatically; messages already in your target language are skipped (no
Translate action is shown).
- **Settings (Settings → General → Messages):**
- **Translate Messages Into** — your target language (default **English**;
~26 common languages).
- **Auto-translate Incoming Messages** (default **off**) — automatically
translates foreign-language messages whose on-device language model is
already downloaded.
- **One-time model download** — the first time you translate from a given
language, a small on-device model (a few MB) downloads once. Because the
browser requires a user gesture for that first download, the initial
translation needs a click.
- **Availability** — Chromium desktop browsers (**Chrome / Edge 138+**) and the
**Lotus desktop app** (WebView2 / Chromium). Not available in Firefox, Safari,
or mobile browsers; where the APIs are unavailable the feature hides itself and
the settings tile shows a note.
### Draft Persistence
- Composer drafts are stored in `localStorage` keyed by `roomId`
- Draft is cleared on successful send
- The Jotai atom is the primary source of truth; `localStorage` is only read on room mount
- **Room-nav draft indicator**: a subtle green dot (the composer's shared `DraftDot`, `color.Success.Main`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (`role="img"`, aria-label "Unsent draft"). It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`.
### Message Search Date Range
@@ -712,11 +766,15 @@ KaTeX-rendered math in messages, two paths:
### Image / Video Captions
Images and videos can be sent with a caption. The caption and media are sent as a single event.
Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event `body` when it differs from `filename`).
- **Edit caption**: an image/video you sent shows an **Edit caption** action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an `m.replace` whose `m.new_content` preserves the media (`url`/`info`/encrypted `file`/`filename`) and only changes `body`/`formatted_body`. An empty caption removes it (`body` falls back to `filename`). Gated by `canEditCaption` (`utils/room.ts`) to your own image/video messages that carry an MSC2530 `filename`. Caption edits carry `m.mentions` (an @-mention in a caption notifies) and appear in Edit History (diffed by the word-diff) — even after a caption is removed, the "(edited)" marker remains so history stays reachable. No re-upload — encrypted media keeps its original file/key.
### Location Sharing
`m.location` events render an inline map tile using the coordinates from the event content.
`m.location` events render an inline map tile using the coordinates from the event content. The renderer reads the top-level `geo_uri` and **falls back to the MSC3488 `org.matrix.msc3488.location`/`m.location` `uri`**, so locations from clients that send only the new shape still render (previously they showed as broken); an MSC3488 `description`, if present, is shown above the coordinates.
Sharing your location (composer → location button) sends an **MSC3488-compliant** `m.location` event: the legacy `geo_uri` plus the `org.matrix.msc3488.location` (uri), `org.matrix.msc3488.asset` (`m.self`), and `org.matrix.msc3488.ts`/`m.ts` blocks, and a human-readable `body`. This makes Lotus-shared locations render as proper pins on Element and other clients instead of falling back to plain text.
### Deleted Message Placeholders
@@ -727,15 +785,27 @@ Redacted events display "This message has been deleted" along with the redaction
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
- Maximum of 500 bookmarked entries
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
- Live-renders edits/redactions, text search, jump-to-message, and remove
- **Author attribution**: each saved-message card shows who wrote it (`{sender} · {time ago}`). The author is snapshotted at save time (`senderId`/`senderName` on the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name.
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
- Hook: `src/app/hooks/useBookmarks.ts`
### Message Scheduling
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
- `ScheduleMessageModal.tsx` provides the date/time picker UI
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual cancel buttons
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual send-now, edit, and cancel buttons
- **Send now**: the tray's send button fires a pending message immediately via MSC4140 `action: 'send'` (the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable.
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
- Utilities in `src/app/utils/scheduledMessages.ts`
### Message Reminders
- Message context menu → **Remind Me** sets a personal reminder to revisit a message; reminders are stored in `io.lotus.reminders` account data (sync across devices) via `useReminders`, and fire from `ClientNonUIFeatures`.
- `RemindMeDialog.tsx` offers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) **plus a "Custom time…" option** that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).
- **Manage existing reminders**: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via `formatFriendlyDateTime`) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates.
- The date/time input helpers (`toLocalDate`, `toLocalTime`, `parseLocalDateTime`, `pickerInputStyle`) are shared, pure, and unit-tested in `src/app/utils/datetimeInput.ts` (`datetimeInput.test.ts`) — also used by `ScheduleMessageModal` (deduped from a prior inline copy).
### File Upload Compression (opt-in)
- Implemented in `UploadCardRenderer.tsx`
@@ -775,12 +845,12 @@ resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:
| kind | shape | providers |
| ----------- | ------------------------ | ----------------------------------------------------------------- |
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
| kind | shape | providers |
| ----------- | ------------------------- | ------------------------------------------------------------------ |
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
**Privacy-friendly facade.** The tile first shows the homeserver's cached
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
@@ -829,18 +899,31 @@ player.kick).
### Poll Creation
- `PollCreator.tsx` creates stable `m.poll.start` events
- Supports 2 to 10 answer options
- Supports both single-choice and multiple-choice modes
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
- Supports 2 to 10 answer options; single-choice or multiple-choice
- **Max selections** — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets `max_selections` (2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N")
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
- Accessible via the `Icons.OrderList` button in the composer toolbar
### Poll Display
### Poll Display & Voting (MSC3381, full lifecycle)
`PollContent.tsx` renders polls in read-only mode. Handles both the stable `m.poll` format and the legacy MSC3381 unstable `org.matrix.msc3381.poll.start` format. Displays current vote counts and a note directing users to Element to cast votes.
`PollContent.tsx` is a fully interactive, spec-correct poll card:
### Voice Message Playback Speed
- **Vote / change / clear** in place — sends stable `m.poll.response` (`m.selections`); latest response per voter wins; clearing removes you from the tally. Multi-choice enforces `max_selections` ("Select up to N").
- **Disclosed vs undisclosed** — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
- **See who voted** — a "Show who voted" toggle (shown only when results are visible, i.e. disclosed live or undisclosed-after-end) reveals the voter names under each answer. The voter list rides the same tally as the counts (`voters: Map<answerId, senderId[]>` populated in `tallyResponses`'s latest-response-per-sender loop), so it can never disagree with the numbers; a re-vote moves the voter, and a cleared vote drops them. Names via `getMemberName`; undisclosed polls stay secret until close.
- **End a poll** — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable `m.poll.end`. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count.
- **Cross-client** — reads **both** the stable (`m.poll`/`m.id`/`m.selections`) and unstable (`org.matrix.msc3381.poll.*`) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDK `Poll` model for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa.
- **Accessibility** — `radiogroup`/`radio` (single, with arrow-key roving) or `group`/`checkbox` (multi) semantics, `aria-checked`/`aria-disabled`, winner announced to AT.
- Pure tally/visibility/winner/voters + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 18 tests incl. the stable/unstable round-trip and voter attribution).
`AudioContent.tsx` adds a playback speed cycle button to voice message players. Available speeds: `[0.75, 1, 1.5, 2]×`. A `useEffect` sets `audioElement.playbackRate` whenever the speed selection changes.
### Voice Message Playback (waveform + speed)
`AudioContent.tsx` is the shared audio player (timeline + Media Gallery Audio tab):
- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.
---
@@ -852,13 +935,21 @@ Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`),
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
### Threads List Panel
A room-level overview of **all** threads, opened from a **Threads** button (🧵) in the room header (mirrors the gallery/widgets toggles). Each row shows the root sender + message snippet, an unread dot, a meta line ("N replies · last reply 5m ago") and a **participant avatar pile**. A segmented **filter** (All / Unread / Participating — the latter via `thread.hasCurrentUserParticipated`) and **sort** (Recent / Oldest, by last-reply time) sit in the toolbar; both persist in localStorage (`cinny_threads_filter_v1` / `cinny_threads_sort_v1`). Clicking a row opens the existing single-thread `ThreadPanel` (reuses `setActiveThreadId`), and reading it clears the row's unread badge live. The list stays live via `ThreadEvent.New/NewReply/Update/Delete` + `RoomEvent.UnreadNotifications` and is virtualized for busy rooms.
- Files: `features/room/thread/ThreadsListPanel.tsx`, `hooks/useRoomThreads.ts` (populates via `room.fetchRoomThreads()` + `room.getThreads()`; last-activity + reply-count + root-edit signature so rows refresh live), `state/threadsList.ts`, pure filter/sort in `utils/threadList.ts` (`filterThreads`/`sortThreads`, unit-tested). Unread mirrors `useThreadSummary`'s logic (`getThreadUnreadNotificationCount`, muted threads zeroed) and renders the app-wide `UnreadBadge` (red for mentions via the Highlight count). Reuses `StackedAvatar`/`useMemberAvatar` for the participant pile and the Bookmarks-panel segmented-control pattern.
### Summary Chips
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
### Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). **Slash commands work in threads** — content-transform commands (`/me`, `/notice`, `/shrug`, `/tableflip`, `/unflip`) route into the thread via the normal send path, and room-level commands (`/invite`, `/kick`, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply _in that thread_ — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
### Notifications (Slack-style, P4-1)
@@ -894,9 +985,11 @@ A presence status selector in the user panel offering five modes:
### Custom Status Message
- Up to 64 characters of free text plus an emoji
- **Emoji picker is unicode-only** (`EmojiBoard hideCustomEmojis`): a status is plain-text presence (`status_msg`) that can't render a custom mxc-image emoji, so the picker hides custom/image-pack emojis (packs, sidebar icons, search, and custom entries in Recent) — every emoji shown actually inserts. (Previously custom emojis were listed but silently did nothing when clicked, since there was no `onCustomEmojiSelect` on this field.)
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
- Character counter appears at 56/64 characters remaining to warn of the limit
- **Status presets**: a "Quick statuses" row of 11 built-in presets spanning gaming/social/life/work — 🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🍽️ Lunch, 🗓️ In a meeting, 🏠 Working remotely, 🎯 Focusing, 🌴 On vacation, 🤒 Out sick (see `BUILT_IN_STATUS_PRESETS`). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored in `io.lotus.status_presets` account data, synced across devices, de-duped by label, capped at 20; a saved preset matching a built-in is hidden to avoid a duplicate chip) and delete presets inline. Built-in list + pure `upsertPreset` de-dupe/cap logic live in `src/app/utils/statusPresets.ts` (unit-tested); persistence in `src/app/hooks/useStatusPresets.ts`.
### Presence Badges
@@ -926,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}`:
@@ -969,6 +1064,10 @@ Hook: `src/app/hooks/useUserNotes.ts`
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room _before_ the forwarded message (sequenced per room; a room counts as failed if either send fails).
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.
### Live Bookmark Previews (P6-3)
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
@@ -997,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
@@ -1063,7 +1161,8 @@ Persists via the `homeRoomSort` setting.
`RoomShareInvite.tsx` provides a shareable invite UI:
- 160×160px QR code generated via `api.qrserver.com`
- 160×160px QR code generated locally via `qrcode.react` (`QRCodeSVG`) — no third-party service, works offline and under strict CSP, on a white quiet-zone so it scans on any theme
- **Download QR**: exports the code as a PNG via an offscreen high-resolution (1024px, spec 4-module margin) `QRCodeCanvas` + `canvas.toBlob`, saved through `useSaveFile` (filename derived from the room name) with the standard download toast
- "Copy Link" button to copy the `matrix.to` URI
- Also accessible via a toggle button (⊞) in the Invite modal
@@ -1075,10 +1174,13 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
`MediaGallery.tsx` — a right-side drawer for browsing room media.
- Three tabs: **Images**, **Videos**, **Files**
- Reads already-decrypted events from the room timeline
- Encrypted images show a lock placeholder rather than an error
- "Load More" button triggers `mx.paginateEventTimeline()` to fetch older media
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or /+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item.
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
- **Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)
### Knock-to-Join
@@ -1187,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
---
@@ -1209,6 +1311,13 @@ Accessible via **Room/Space Settings → Policy Lists** (admin only).
- Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts)
- When active, notifications are silently dropped rather than queued
### Pause Notifications (snooze)
- A **cross-platform** "Pause Notifications" control in **Settings → Notifications** (the desktop-tray Do Not Disturb only worked on the desktop app; web/mobile had no manual pause).
- Quick presets: 30 minutes / 1 hour / 4 hours / Until 8 AM / **Until I resume** (indefinite), plus a **Resume** button; the tile shows the live "Paused until …" status (via `formatFriendlyDateTime`) and flips back to "on" the moment the snooze lapses.
- Persisted (`cinny_notification_snooze_until_v1`) as the epoch-ms instant to pause until (`0` = off), so a snooze survives a reload. Feeds the same notification gate as Focus Assist / Quiet Hours (`ClientNonUIFeatures`), suppressing both `notify()` and `playSound()`.
- Pure, unit-tested helpers `isSnoozeActive` / `nextTimeAtHour` / `SNOOZE_INDEFINITE` in `src/app/utils/snooze.ts` (`snooze.test.ts`); persisted atom in `src/app/state/notificationSnooze.ts`.
### Full Push Rule Editor
A complete UI for managing Matrix push notification rules:
+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
+148 -35
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**.
---
@@ -67,35 +66,86 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
### 🔍 Discovery pass 2 (2026-07) — perf / security / correctness (agent-surveyed, single-pass — NOT yet TPVR'd)
### Discovery pass 2 (2026-07) — perf / security / correctness — DONE
Three agents swept fresh lenses (performance, security/privacy, correctness in under-covered subsystems); each verified its own findings against the code + SDK. `Sev`/`Eff` = S/M/L. Confirm before fixing (no independent TPVR yet).
Agent-surveyed findings, each **verified against the code before fixing**, then implemented and gate-green (tsc + eslint + prettier + 857 tests + build), with **two review agents on every staged diff before commit**. Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then remove this note.
**Performance / rendering:**
- [x] **PERF-1 — presence: 3 client listeners PER avatar → one shared presence store** (3 listeners total). `8a154051`.
- [x] **PERF-2 — `#`-mention autocomplete mutated + re-sorted the shared `allRoomsAtom` every keystroke** → copy + `useMemo` (also fixed a real shared-array mutation hitting ~27 consumers). `4708a179`.
- [x] **PERF-3 — read-receipt rows: ~6 global `Members` listeners per row → one shared member-change store** (`useRoomMemberChange`). `1b8f5545`.
- [x] **PERF-4 — message-search room filter re-sorted every render**`useMemo`. `4708a179`.
- [x] **PERF-5 — DM-preview `Decrypted` listener ran for every nav item** → gated on `direct`. `4708a179`.
- [x] **SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout**`clearPlaintextCaches()` on both logout paths, extended to the whole `recent_*` family + nav-paths. `726cefb5`.
- [x] **SEC-3 — `window.open(_blank)` without `noopener`**`noopener,noreferrer` at 5 sites (SSOStage excluded — needs the handle). `3e1106b2`.
- [x] **SEC-4 — `/acl` self-lockout footgun** → shared `serverAcl.ts` validation, no-brick allow default, fail-closed self-ban guard. `3e1106b2`.
- [x] **COR-1 — space-child UNLINK over-deleted** → targeted `UNLINK` reducer action. `fd3b8b42`.
- [x] **COR-2 — `useCallJoined` stuck true on 2nd-call embed swap** → re-seed on `[embed]`. `1f80d1d1`.
- [x] **COR-3 — incoming-call lifetime guard only corrected future clock-skew**`Math.abs(...)` (±20s). `ab01d27a`.
- [x] **COR-4 — shared notify-dedupe slot double-notified** → key by `roomId|threadId`. `1f80d1d1`.
- [x] **COR-5 — upload cancel ignored during retry back-off**`AbortSignal` threaded into the retry loop. `ab01d27a`.
- [x] **COR-6 — `CallControl.forceState` dropped `screenshareAudioMuted`** → passes it. `ab01d27a`.
- [ ] **PERF-1 — Presence: 3 global client listeners registered PER avatar (Sev High · Eff M).** `hooks/useUserPresence.ts:43-53` (via `PresenceRingAvatar.tsx:19`, `MembersDrawer.tsx:142`) — every timeline/member avatar adds `mx.on` for `Presence`/`CurrentlyActive`/`LastPresenceTs` → 100250 global listeners; each presence event fans out across all, and fast scroll churns add/remove per Message mount. Fix: one shared presence store (context + `useSyncExternalStore` or a jotai `atomFamily`) with 3 listeners total; components select by userId.
- [ ] **PERF-2 — `#`-mention autocomplete re-sorts AND mutates the room list every keystroke (Sev Med · Eff S).** `components/editor/autocomplete/RoomMentionAutocomplete.tsx:83``useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx))` is unmemoized (O(N log N) `mx.getRoom` per compare, N=all joined rooms, per keypress) **and `.sort()` mutates the shared `allRoomsAtom` array in place.** Fix: `useMemo(() => [...rooms].sort(...), [rooms, mx])`.
- [ ] **PERF-3 — Read-receipt rows attach per-instance global `RoomStateEvent.Members` listeners (Sev Med · Eff M).** `components/read-receipt-avatars/ReadReceiptAvatars.tsx:74` + `hooks/useMemberAvatar.ts:44` — up to ~6 global `Members` listeners per receipt row; fires on any membership/name/avatar change in any room. Fix: one shared room-member-change subscription fanning out by roomId+userId (shares with PERF-1).
- [ ] **PERF-4 — Message-search room filter re-sorts full room list every render (Sev LowMed · Eff S).** `features/message-search/SearchFilters.tsx:155``Array.from(...).sort(factoryRoomIdByAtoZ(mx))` outside `useMemo` (re-sorts as the user types). Fix: `useMemo`.
- [ ] **PERF-5 — DM-preview hook subscribes a global `Decrypted` listener for EVERY nav item incl. non-DMs (Sev LowMed · Eff S).** `hooks/useRoomLatestRenderedEvent.ts:64-67` (called at `RoomNavItem.tsx:681`) — preview is only used for `direct` rooms but the hook + its `MatrixEventEvent.Decrypted` listener run for all; wasteful, and fans out across all during bulk decryption. Fix: gate the hook/subscription on `direct`.
- [ ] **PERF-6 — Avatar-decoration profile-fetch on first render of large member/timeline views (Sev Low · Eff M, likely leave).** `hooks/useAvatarDecoration.ts` — one `/profile/{userId}` per new user (well-guarded: module cache + in-flight dedupe + backoff). Network/HS-load note only; batch/skip only if it proves costly.
**Deferred / decided (not built):**
**Security / privacy:** _(no exploitable XSS found — `sanitize.ts` strips `javascript:`, forces mxc `<img>` + `rel=noopener` `<a>`; embeds use host-allowlist + origin-scoped `postMessage`; KaTeX `trust:false`. These are storage-hygiene / URL items.)_
- [DEFERRED] **PERF-6 — avatar-decoration `/profile` fetch** — well-guarded (module cache + in-flight dedupe + backoff); a network/HS-load note only. Batch/skip only if it proves costly.
- [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).
- [ ] **SEC-1 — Scheduled-message plaintext persists in `localStorage`, survives logout (Sev Med · Eff S).** `state/scheduledMessages.ts:12-19` — full `IContent` (incl. E2EE cleartext `body`) under `cinny_scheduled_messages_v1`; normal logout (`removeFallbackSession`, N98) keeps it. Leak on shared/kiosk browser. Fix: clear on logout (add to `removeFallbackSession`) — or encrypt-at-rest with N97.
- [ ] **SEC-2 — Recent search terms persist in `localStorage`, survive logout (Sev Low · Eff S).** `state/recentSearches.ts:4-12` — last 10 queries (often PII) under `cinny_recent_searches_v1`, survive normal logout. Fix: clear on logout.
- [ ] **SEC-3 — `window.open(url, '_blank')` without `'noopener'` → reverse tab-nabbing (Sev Low · Eff S).** `components/user-profile/UserChips.tsx:117` (+ `SSOStage.tsx:23`, `settings/devices/Verification.tsx:276`, `OtherDevices.tsx:36,49`, `OidcManageAccount.tsx:23`) — `window.open` doesn't null `window.opener`. UserChips opens a user/room-derived server URL. Fix: pass `'noopener,noreferrer'` (or set `w.opener = null`).
- [ ] **SEC-4 — `/acl` has no `*`/self-lockout guard or glob validation (Sev Low · Eff SM).** `hooks/useCommands.ts:489-538``/acl -d *` writes `deny:['*']` (can brick the room); empty/whitespace globs pushed unvalidated into `m.room.server_acl`. PL-gated + matches upstream, but a destructive footgun. Fix: reject a `*`/empty deny matching the local server, validate glob syntax, confirm on `*`.
- [ ] **SEC-5 — Embeds get `allow-popups-to-escape-sandbox` (Sev Low, informational · Eff M).** `components/url-preview/UrlPreviewCard.tsx:590-591` (`EMBED_SANDBOX`) — a hijacked provider script could spawn an un-sandboxed popup (phishing). Main-app hijack already prevented (no `allow-top-navigation`); popups arguably needed for "open in provider." Fix (if desired): drop the escape flag + verify per-provider.
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
**Correctness (under-covered subsystems):**
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.
- [ ] **COR-1 — Space-child UNLINK nukes a room's OTHER parents + orphans descendants (Sev Med · Eff M).** `state/room/roomToParents.ts:102-103` (DELETE reducer `:53-66`) — removing one `m.space.child` fires the same `DELETE` used for "room left," wiping ALL of the child's parents and stripping it as a parent everywhere. Remove C from space A only → C also loses parent B, and C's own children lose C, until a full resync. Fix: a targeted "remove one parent from one child" action. (Reducer inherited from upstream.)
- [ ] **COR-2 — `useCallJoined` stays `true` across a direct embed swap (answering a 2nd call) (Sev Med · Eff S).** `hooks/useCallEmbed.ts:140-158` — the reset effect only clears `joined` when `!embed`; answering call B from the banner swaps embed A→B directly, so `joined` stays true → B renders as live/joined before EC actually joins, skipping the loading/watchdog UI. Fix: `useEffect(() => setJoined(embed?.joined ?? false), [embed])`.
- [ ] **COR-3 — Incoming-call lifetime guard only corrects FUTURE clock-skew, not past (Sev Low · Eff S).** `components/CallEmbedProvider.tsx:476-477` — distrusts `sender_ts` only when >20 s ahead of `origin_server_ts`; a caller clock that's behind leaves `sender_ts` in the past → `remaining ≤ 0` → ring auto-dismisses/never shows for a fresh invite. Fix: `Math.abs(...) > 20000` or just use `event.getTs()`.
- [ ] **COR-4 — Shared notify-dedupe slot double-notifies a re-fired main message after an interleaving thread reply (Sev Low · Eff S).** `pages/client/ClientNonUIFeatures.tsx:521,530``lastNotifiedEventRef` is one `Map<roomId,eventId>` slot shared by the main + thread paths; a thread reply overwrites it, so a re-emitted main message (decrypt/edit re-fire, common in E2EE) mis-dedupes and notifies again. Fix: separate dedupe keys per path (or key by `roomId|eventId`).
- [ ] **COR-5 — Upload cancel during retry back-off is silently ignored (upload resurrects) (Sev Low · Eff M).** `utils/matrix.ts:204-238` + `state/upload.ts:125-129` — during the 130 s back-off the atom holds the previous (rejected) promise, so `cancelUpload` is a no-op and the loop wakes to start the next attempt. Fix: thread an abort flag into the retry loop.
- [ ] **COR-6 — `CallControl.forceState` resets `screenshareAudioMuted` to false (Sev Low · Eff S).** `plugins/call/CallControl.ts:155-162``forceState` passes only 5 args so the 6th defaults false; low impact today (runs at join, no screenshare yet) but silently discards the field. Fix: pass `this.screenshareAudioMuted`.
**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)
@@ -155,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**:
@@ -209,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.)
@@ -223,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`.
@@ -231,11 +331,11 @@ 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.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the *unstable* `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
@@ -244,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`.
@@ -292,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).
+1
View File
@@ -36,6 +36,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
- Search messages with a date range filter
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
- On-device message translation — foreign-language messages show a "Translate" action, then an inline "Translated from <language> · Show original" toggle. Runs entirely on your device via the browser's built-in translation models, so message text never leaves your device or touches a cloud service (no Google/DeepL/Microsoft) — preserving end-to-end encryption. Pick your target language and optionally auto-translate incoming messages at Settings → General → Messages. Chromium desktop (Chrome/Edge 138+) and the Lotus desktop app only; hidden where unavailable (Firefox, Safari, mobile)
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
- Room topics support rich formatting (bold, links, italics)
- Deleted messages show a placeholder instead of disappearing
+45 -4
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';
@@ -473,8 +473,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const sender = event.getSender();
const content = event.getContent<IRTCNotificationContent>();
// Trust the caller's sender_ts only when it's within 20s of the server's
// timestamp in EITHER direction. A fast caller clock made a fresh invite
// look expired-in-the-future; a SLOW one left sender_ts in the past so
// `Date.now() >= senderTs + lifetime` hid/dismissed the ring for a
// genuinely fresh invite (COR-3). Fall back to the server ts on large skew.
const senderTs =
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
typeof content.sender_ts === 'number' &&
Math.abs(content.sender_ts - event.getTs()) <= 20000
? content.sender_ts
: event.getTs();
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type;
const relation =
@@ -695,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;
@@ -706,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;
+139 -10
View File
@@ -1,10 +1,13 @@
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';
const PICKER_WIDTH = 312;
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
@@ -15,24 +18,147 @@ type GifPickerInnerProps = {
lotusTerminal: boolean;
};
// Small monospace section header matching the picker's `// GIF_SEARCH` treatment
// (lotusTerminal) / a muted label otherwise.
const RECENT_LABEL_ID = 'gif-picker-recent-label';
function SectionLabel({
text,
lotusTerminal,
id,
}: {
text: string;
lotusTerminal: boolean;
id?: string;
}) {
if (lotusTerminal) {
return (
<div
id={id}
style={{
padding: '4px 2px',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: 'var(--lt-accent-orange)',
userSelect: 'none',
}}
>
{`// ${text.toUpperCase()}`}
</div>
);
}
return (
<div
id={id}
style={{
padding: '2px 2px 4px',
fontSize: '11px',
fontWeight: 600,
color: color.Surface.OnContainer,
opacity: 0.6,
userSelect: 'none',
}}
>
{text}
</div>
);
}
function RecentGifs({
recents,
lotusTerminal,
onPick,
}: {
recents: RecentGif[];
lotusTerminal: boolean;
onPick: (gif: RecentGif) => void;
}) {
return (
<div style={{ marginBottom: 8 }}>
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} id={RECENT_LABEL_ID} />
<div
role="group"
aria-labelledby={RECENT_LABEL_ID}
style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}
>
{recents.map((g, i) => (
<button
key={g.url}
type="button"
aria-label={`Send recent GIF ${i + 1} of ${recents.length}`}
onClick={() => onPick(g)}
style={{
padding: 0,
border: 'none',
background: 'transparent',
cursor: 'pointer',
height: 72,
borderRadius: lotusTerminal ? '4px' : '8px',
overflow: 'hidden',
}}
>
{/* A still preview (falls back to the animated url for pre-existing
recents) so the Recent row doesn't autoplay many GIFs at once. */}
<img
src={g.previewUrl ?? g.url}
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</button>
))}
</div>
</div>
);
}
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
const { fetchGifs, searchKey } = React.useContext(SearchContext);
const { fetchGifs, searchKey, term } = React.useContext(SearchContext);
const [recents, setRecents] = useAtom(recentGifsAtom);
const sendGif = useCallback(
(gif: RecentGif) => {
setRecents((prev) => addRecentGif(prev, gif));
onSelect(gif.url, gif.width, gif.height);
requestClose();
},
[onSelect, requestClose, setRecents],
);
const handleClick = useCallback(
(gif: IGif, e: React.SyntheticEvent) => {
e.preventDefault();
const r = gif.images.downsized ?? gif.images.original;
const { url } = r;
const width = Number(r.width) || 200;
const height = Number(r.height) || 200;
onSelect(url, width, height);
requestClose();
const previewUrl =
gif.images.fixed_width_small_still?.url ??
gif.images.downsized_still?.url ??
gif.images.original_still?.url;
sendGif({
url: r.url,
width: Number(r.width) || 200,
height: Number(r.height) || 200,
previewUrl,
});
},
[onSelect, requestClose],
[sendGif],
);
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={{
@@ -57,10 +183,13 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
{showRecents && (
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
)}
<Grid
key={searchKey}
fetchGifs={fetchGifs}
width={PICKER_WIDTH - 16}
width={gridWidth}
columns={2}
gutter={4}
onGifClick={handleClick}
+14 -3
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import { config, Text } from 'folds';
import {
AudioContent,
DownloadFile,
@@ -11,6 +11,7 @@ import {
MAudio,
MBadEncrypted,
MEmote,
MessageEditedContent,
MFile,
MImage,
MLocation,
@@ -87,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>
@@ -119,6 +121,15 @@ export function RenderMessageContent({
/>
);
}
// No caption, but the media was edited (e.g. a caption was removed): keep the
// "(edited)" affordance so Edit History stays reachable.
if (edited) {
return (
<Text style={{ marginTop: config.space.S200 }} size="T200">
<MessageEditedContent onEditHistoryClick={onEditHistoryClick} />
</Text>
);
}
return null;
};
+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>
}
/>
+119 -36
View File
@@ -2,8 +2,9 @@ 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' | 'preview';
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
interface VoiceRecorderProps {
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
@@ -41,11 +42,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rawSamplesRef = useRef<number[]>([]);
const startTimeRef = useRef<number>(0);
// Active-recording duration excluding paused time: accumulated ms from prior
// segments + (now - segmentStart) for the current segment.
const accumulatedMsRef = useRef<number>(0);
const segmentStartRef = useRef<number>(0);
const animFrameRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -54,18 +59,67 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const [previewPlaying, setPreviewPlaying] = useState(false);
const stopAll = useCallback(() => {
// Start the waveform (rAF) + duration (interval) meters against the live
// analyser. Reused by startRecording and resumeRecording.
const startMeters = useCallback(() => {
const analyser = analyserRef.current;
if (!analyser) return;
// Guard against ever running two loops.
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (timerRef.current) clearInterval(timerRef.current);
const buf = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
rawSamplesRef.current.push(avg);
setWaveformBars((prev) => [...prev.slice(1), Math.round((avg / 255) * 100)]);
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
timerRef.current = setInterval(() => {
setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current));
}, 100);
}, []);
// Stop the meters (rAF + interval) without tearing down the audio graph, so a
// paused recording can resume.
const stopMeters = useCallback(() => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Release the microphone. The mic tracks are independent of the MediaRecorder
// and the AudioContext — neither mr.stop() nor audioCtx.close() releases them —
// so they must be stopped explicitly (else the OS mic indicator stays on).
const stopStream = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}, []);
const stopAll = useCallback(() => {
stopMeters();
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
analyserRef.current = null;
}, []);
}, [stopMeters]);
useEffect(
() => () => {
// Unmounting mid-recording/pause must release the mic — stopAll() only
// closes the AudioContext and cancels the meters, not the mic tracks.
const mr = mediaRecorderRef.current;
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
mr.ondataavailable = null;
mr.onstop = null;
mr.stop();
}
stopStream();
stopAll();
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
@@ -83,9 +137,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const mr = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = mr;
streamRef.current = stream;
chunksRef.current = [];
rawSamplesRef.current = [];
startTimeRef.current = Date.now();
accumulatedMsRef.current = 0;
segmentStartRef.current = Date.now();
const audioCtx = new AudioContext();
audioCtxRef.current = audioCtx;
@@ -95,24 +151,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
source.connect(analyser);
analyserRef.current = analyser;
const buf = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
rawSamplesRef.current.push(avg);
setWaveformBars((prev) => {
const next = [...prev.slice(1), Math.round((avg / 255) * 100)];
return next;
});
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
timerRef.current = setInterval(() => {
setDurationMs(Date.now() - startTimeRef.current);
}, 100);
startMeters();
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
@@ -120,8 +159,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
mr.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
const blob = new Blob(chunksRef.current, { type: mimeType });
previewDurationRef.current = Date.now() - startTimeRef.current;
setPreviewBlob(blob);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
@@ -135,33 +174,59 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
} catch {
onError?.('Microphone access denied');
}
}, [onError]);
}, [onError, startMeters]);
const pauseRecording = useCallback(() => {
const mr = mediaRecorderRef.current;
if (mr?.state !== 'recording') return;
accumulatedMsRef.current += Date.now() - segmentStartRef.current;
setDurationMs(accumulatedMsRef.current);
stopMeters();
mr.pause();
setState('paused');
}, [stopMeters]);
const resumeRecording = useCallback(() => {
const mr = mediaRecorderRef.current;
if (mr?.state !== 'paused') return;
segmentStartRef.current = Date.now();
mr.resume();
startMeters();
setState('recording');
}, [startMeters]);
const stopRecording = useCallback(() => {
const mr = mediaRecorderRef.current;
const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0;
previewDurationRef.current = accumulatedMsRef.current + activeMs;
stopAll();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
mr.stop();
}
}, [stopAll]);
const cancelRecording = useCallback(() => {
stopAll();
const mr = mediaRecorderRef.current;
if (mr?.state === 'recording') {
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
mr.ondataavailable = null;
// onstop (which would release the mic) is cleared, so release it here.
mr.onstop = null;
mr.stop();
}
stopStream();
setPreviewBlob(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
rawSamplesRef.current = [];
accumulatedMsRef.current = 0;
segmentStartRef.current = 0;
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
setDurationMs(0);
setState('idle');
}, [stopAll]);
}, [stopAll, stopStream]);
const sendVoice = useCallback(() => {
if (!previewBlob) return;
@@ -175,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"
@@ -187,10 +253,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
);
}
if (state === 'recording') {
if (state === 'recording' || state === 'paused') {
const paused = state === 'paused';
return (
<Box
data-voice-recorder="recording"
data-voice-recorder={paused ? 'paused' : 'recording'}
alignItems="Center"
gap="200"
style={{
@@ -209,13 +276,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
borderRadius: '50%',
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
flexShrink: 0,
animation: 'pttLivePulse 900ms ease-in-out infinite',
// Pulse only while actively recording; hold steady (dimmed) when paused.
animation: paused ? 'none' : 'pttLivePulse 900ms ease-in-out infinite',
opacity: paused ? 0.5 : 1,
}}
/>
<Text
size="T200"
role="timer"
aria-label={`Recording duration ${formatDuration(durationMs)}`}
aria-label={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
style={{
minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums',
@@ -249,16 +318,29 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
/>
))}
</Box>
<IconButton
onClick={paused ? resumeRecording : pauseRecording}
aria-label={paused ? 'Resume recording' : 'Pause recording'}
variant="SurfaceVariant"
fill="Soft"
size="300"
radii="300"
title={paused ? 'Resume' : 'Pause'}
style={{ flexShrink: 0 }}
>
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
</IconButton>
<IconButton
onClick={stopRecording}
aria-label="Stop recording"
aria-label="Finish recording"
variant="Primary"
fill="Soft"
size="300"
radii="300"
title="Stop recording"
title="Finish"
style={{ flexShrink: 0 }}
>
<Icon src={Icons.Pause} size="100" />
<Icon src={Icons.Check} size="100" />
</IconButton>
<IconButton
onClick={cancelRecording}
@@ -267,6 +349,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
size="300"
radii="300"
title="Cancel"
style={{ flexShrink: 0 }}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
@@ -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}
@@ -1,4 +1,4 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
@@ -80,7 +80,14 @@ export function RoomMentionAutocomplete({
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
// Copy before sorting: `.sort()` mutates in place, and `allRoomsAtom`'s array
// is shared app-wide — sorting it here reorders it for every other consumer.
// Also memoize so a keystroke doesn't re-run the O(N log N) getRoom compare.
const allRoomsList = useAtomValue(allRoomsAtom);
const allRooms = useMemo(
() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)),
[allRoomsList, mx],
);
const [result, search, resetSearch] = useAsyncSearch(
allRooms,
+1 -2
View File
@@ -7,8 +7,7 @@ import { BlockType } from './types';
// Loose Slate node builders for the test.
const txt = (text: string, marks: Record<string, unknown> = {}) =>
({ text, ...marks }) as unknown as Descendant;
const el = (type: BlockType, children: unknown[]) =>
({ type, children }) as unknown as Descendant;
const el = (type: BlockType, children: unknown[]) => ({ type, children }) as unknown as Descendant;
const OPTS = {
allowTextFormatting: true,
+1 -1
View File
@@ -42,7 +42,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
.map((seg) =>
seg.type === 'text'
? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false })
: mathToCustomHtml(seg.value, seg.type === 'block')
: mathToCustomHtml(seg.value, seg.type === 'block'),
)
.join('');
}
+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);
};
+53 -6
View File
@@ -14,7 +14,7 @@ import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
@@ -29,6 +29,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc
import { useDebounce } from '../../hooks/useDebounce';
import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
@@ -56,6 +57,9 @@ import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
// Stable empty pack list for hideCustomEmojis (unicode-only) mode — a fresh []
// each render would needlessly re-run the memoized group/search builders.
const NO_IMAGE_PACKS: ImagePack[] = [];
/**
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
@@ -98,10 +102,12 @@ type StickerGroupItem = {
const useGroups = (
tab: EmojiBoardTab,
imagePacks: ImagePack[],
hideCustomEmojis?: boolean,
): [EmojiGroupItem[], StickerGroupItem[]] => {
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 21);
const recentStickers = useAtomValue(recentStickersAtom);
const labels = useEmojiGroupLabels();
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
@@ -112,7 +118,9 @@ const useGroups = (
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
items: recentEmojis,
// In unicode-only mode drop recently-used CUSTOM emojis (PackImageReader);
// keep only unicode ones (IEmoji has `unicode`).
items: hideCustomEmojis ? recentEmojis.filter((e) => 'unicode' in e) : recentEmojis,
});
imagePacks.forEach((pack) => {
@@ -137,12 +145,22 @@ const useGroups = (
});
return g;
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups, hideCustomEmojis]);
const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = [];
if (tab !== EmojiBoardTab.Sticker) return g;
if (recentStickers.length > 0) {
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
// StickerItem only reads url/shortcode/body, so a minimal PackImageReader
// reconstructed from the stored data renders and re-sends correctly.
items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
});
}
imagePacks.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
@@ -157,7 +175,7 @@ const useGroups = (
});
return g;
}, [mx, imagePacks, tab]);
}, [mx, imagePacks, tab, recentStickers]);
return [emojiGroupItems, stickerGroupItems];
};
@@ -289,6 +307,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
const recentStickers = useAtomValue(recentStickersAtom);
const usage = ImageUsage.Sticker;
const packLabels = useMemo(() => {
@@ -308,6 +327,17 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
return (
<Sidebar>
{recentStickers.length > 0 && (
<SidebarStack>
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
onClick={handleScrollToGroup}
/>
</SidebarStack>
)}
<SidebarStack>
{packs.map((pack) => {
const label = packLabels.get(pack.id);
@@ -410,6 +440,11 @@ type EmojiBoardProps = {
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
allowTextCustomEmoji?: boolean;
addToRecentEmoji?: boolean;
// Unicode-only mode: hide custom/image-pack emojis (packs, sidebar icons,
// search, and custom entries in Recent). For targets that can only hold plain
// text — e.g. the presence status message, which can't render an mxc image —
// so users only see emojis that actually insert.
hideCustomEmojis?: boolean;
};
export function EmojiBoard({
@@ -423,6 +458,7 @@ export function EmojiBoard({
onStickerSelect,
allowTextCustomEmoji,
addToRecentEmoji = true,
hideCustomEmojis,
}: EmojiBoardProps) {
const mx = useMatrixClient();
@@ -435,8 +471,12 @@ export function EmojiBoard({
);
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
const setRecentStickers = useSetAtom(recentStickersAtom);
const relevantImagePacks = useRelevantImagePacks(usage, imagePackRooms);
// Unicode-only mode: drop all custom/image packs so the sidebar, group list,
// and search show only insertable unicode emojis.
const imagePacks = hideCustomEmojis ? NO_IMAGE_PACKS : relevantImagePacks;
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, hideCustomEmojis);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab);
const { emojis: loadedEmojis } = useEmojiData();
@@ -494,6 +534,13 @@ export function EmojiBoard({
}
if (emojiInfo.type === EmojiType.Sticker) {
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
setRecentStickers((prev) =>
addRecentSticker(prev, {
url: emojiInfo.data,
shortcode: emojiInfo.shortcode,
body: emojiInfo.label,
}),
);
}
if (!evt.altKey && !evt.shiftKey) requestClose();
};
@@ -46,7 +46,13 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const { address } = imagePack;
if (!address) return;
await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
await sendStateEvent(
mx,
address.roomId,
StateEvent.PoniesRoomEmotes,
packContent,
address.stateKey,
);
},
[mx, imagePack],
);
@@ -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>
@@ -81,16 +96,26 @@ export const ImageViewer = as<'div', ImageViewerProps>(
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
onWheel={(evt) => {
if (evt.deltaY < 0) zoomIn();
else if (evt.deltaY > 0) zoomOut();
}}
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
// translate runs inside scale(), so divide by zoom to make a dragged
// pixel move the image one screen pixel (1:1 with the cursor) rather
// than `zoom` pixels.
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
pan.translateY / zoom
}px)`,
}}
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>
+188 -46
View File
@@ -10,9 +10,12 @@ import {
MessageBrokenContent,
MessageDeletedContent,
MessageEditedContent,
MessageTranslatedContent,
MessageUnsupportedContent,
MessageVerificationRequestContent,
} from './content';
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
import { languageName } from '../../utils/translation/langUtils';
import {
IAudioContent,
IAudioInfo,
@@ -162,6 +165,76 @@ type RenderBodyProps = {
body: string;
customBody?: string;
};
// Shared body renderer for m.text / m.emote / m.notice. Handles the on-device
// translation swap: when translation is active and done, the translated text is
// rendered through the plain-text path (linkify + emoji, no stored HTML) inside
// a dir="auto" span for RTL, with a "Translated from … · Show original" chip.
type TranslatableBodyProps = {
variant: 'text' | 'emote' | 'notice';
displayName?: string;
eventId: string;
trimmedBody: string;
customBody?: string;
renderBody: (props: RenderBodyProps) => ReactNode;
edited?: boolean;
onEditHistoryClick?: () => void;
style?: CSSProperties;
};
function TranslatableBody({
variant,
displayName,
eventId,
trimmedBody,
customBody,
renderBody,
edited,
onEditHistoryClick,
style,
}: TranslatableBodyProps) {
const translation = useMessageTranslation(eventId, trimmedBody);
const showTranslated =
translation.active && translation.status === 'done' && !!translation.translated;
const shownBody = showTranslated ? translation.translated! : trimmedBody;
const chipStatus = translation.status === 'detecting' ? 'translating' : translation.status;
const showChip =
translation.active &&
(chipStatus === 'downloading' ||
chipStatus === 'translating' ||
chipStatus === 'done' ||
chipStatus === 'error');
return (
<MessageTextBody
emote={variant === 'emote'}
notice={variant === 'notice'}
preWrap={showTranslated || typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(shownBody)}
style={style}
>
{variant === 'emote' && <b>{`${displayName} `}</b>}
{showTranslated ? (
<span dir="auto">{renderBody({ body: shownBody, customBody: undefined })}</span>
) : (
renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})
)}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
{showChip && (
<MessageTranslatedContent
fromLangName={languageName(translation.fromLang ?? '')}
status={chipStatus as 'downloading' | 'translating' | 'done' | 'error'}
downloadProgress={translation.downloadProgress}
onToggle={translation.toggle}
/>
)}
</MessageTextBody>
);
}
type MTextProps = {
edited?: boolean;
onEditHistoryClick?: () => void;
@@ -190,17 +263,30 @@ export function MText({
return (
<>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
{eventId ? (
<TranslatableBody
variant="text"
eventId={eventId}
trimmedBody={trimmedBody}
customBody={typeof customBody === 'string' ? customBody : undefined}
renderBody={renderBody}
edited={edited}
onEditHistoryClick={onEditHistoryClick}
style={style}
/>
) : (
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
)}
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -235,18 +321,31 @@ export function MEmote({
return (
<>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
{eventId ? (
<TranslatableBody
variant="emote"
displayName={displayName}
eventId={eventId}
trimmedBody={trimmedBody}
customBody={typeof customBody === 'string' ? customBody : undefined}
renderBody={renderBody}
edited={edited}
onEditHistoryClick={onEditHistoryClick}
/>
) : (
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
)}
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -279,17 +378,29 @@ export function MNotice({
return (
<>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
{eventId ? (
<TranslatableBody
variant="notice"
eventId={eventId}
trimmedBody={trimmedBody}
customBody={typeof customBody === 'string' ? customBody : undefined}
renderBody={renderBody}
edited={edited}
onEditHistoryClick={onEditHistoryClick}
/>
) : (
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
)}
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -306,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;
@@ -321,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,
@@ -387,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,
@@ -411,6 +530,7 @@ type RenderAudioContentProps = {
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
waveform?: number[];
};
type MAudioProps = {
content: IAudioContent;
@@ -431,6 +551,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
}
const filename = content.filename ?? content.body ?? 'Audio';
const waveform = (content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } })[
'org.matrix.msc1767.audio'
]?.waveform;
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
@@ -454,6 +577,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
waveform,
})}
</AttachmentContent>
</AttachmentBox>
@@ -509,7 +633,19 @@ type MLocationProps = {
};
export function MLocation({ content }: MLocationProps) {
const { t } = useTranslation();
const geoUri = content.geo_uri;
// Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible
// location block so events from clients that only send the new shape (uri under
// org.matrix.msc3488.location / m.location) still render instead of appearing
// broken.
const msc3488 = (content['org.matrix.msc3488.location'] ?? content['m.location']) as
| { uri?: string; description?: string }
| undefined;
const geoUri =
typeof content.geo_uri === 'string'
? content.geo_uri
: typeof msc3488?.uri === 'string'
? msc3488.uri
: undefined;
if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
@@ -517,6 +653,7 @@ export function MLocation({ content }: MLocationProps) {
const lat = parseFloat(location.latitude);
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
@@ -537,13 +674,18 @@ export function MLocation({ content }: MLocationProps) {
loading="lazy"
sandbox="allow-scripts"
/>
{description && (
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
{description}
</Text>
)}
<Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
</Text>
<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"
@@ -1,8 +1,21 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Chip,
color,
Icon,
IconButton,
Icons,
ProgressBar,
Spinner,
Text,
toRem,
} from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
@@ -28,6 +41,127 @@ const PLAY_TIME_THROTTLE_OPS = {
immediate: true,
};
const WAVEFORM_DISPLAY_BARS = 48;
/** Reduce a stored MSC1767 waveform (up to ~100 samples) to a fixed display count. */
export function downsampleWaveform(waveform: number[], count: number): number[] {
if (waveform.length <= count) return waveform;
const out: number[] = [];
const step = waveform.length / count;
for (let i = 0; i < count; i += 1) {
out.push(waveform[Math.floor(i * step)] ?? 0);
}
return out;
}
// Voice-message waveform that doubles as a seek control: bars fill with the accent
// as the clip plays; click/drag/keyboard scrubs. Mirrors the recorder's bar styling
// (VoiceMessageRecorder). Falls back to the plain seek Range when there's no waveform.
function WaveformSeek({
waveform,
currentTime,
duration,
onSeek,
getCurrentTime,
}: {
waveform: number[];
currentTime: number;
duration: number;
onSeek: (time: number) => void;
/** Reads the live media time (the `currentTime` prop is throttled ~500ms). */
getCurrentTime: () => number;
}) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const containerRef = useRef<HTMLDivElement>(null);
const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]);
const barMax = useMemo(() => Math.max(...bars, 1), [bars]);
const progress = duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0;
const accent = lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main;
const unplayedColor = `color-mix(in srgb, ${accent} 32%, transparent)`;
const seekFromClientX = useCallback(
(clientX: number) => {
const el = containerRef.current;
if (!el || duration <= 0) return;
const rect = el.getBoundingClientRect();
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
onSeek(Math.min(duration, Math.max(0, ratio * duration)));
},
[duration, onSeek],
);
const handlePointerDown = (evt: React.PointerEvent<HTMLDivElement>) => {
evt.currentTarget.setPointerCapture(evt.pointerId);
seekFromClientX(evt.clientX);
};
const handlePointerMove = (evt: React.PointerEvent<HTMLDivElement>) => {
if (evt.currentTarget.hasPointerCapture(evt.pointerId)) seekFromClientX(evt.clientX);
};
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (duration <= 0) return;
// Base off the LIVE time so rapid presses accumulate (the prop is throttled).
const base = getCurrentTime();
if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
evt.preventDefault();
onSeek(Math.min(duration, base + 5));
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
evt.preventDefault();
onSeek(Math.max(0, base - 5));
} else if (evt.key === 'Home') {
evt.preventDefault();
onSeek(0);
} else if (evt.key === 'End') {
evt.preventDefault();
onSeek(duration);
}
};
return (
<div
ref={containerRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(currentTime)}
aria-valuetext={`${secondsToMinutesAndSeconds(currentTime)} of ${secondsToMinutesAndSeconds(
duration,
)}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
alignItems: 'center',
gap: toRem(2),
width: '100%',
height: toRem(24),
cursor: 'pointer',
touchAction: 'none',
overflow: 'hidden',
}}
>
{bars.map((v, i) => {
const played = bars.length > 0 && (i + 1) / bars.length <= progress;
return (
<div
key={i}
style={{
flex: 1,
minWidth: toRem(2),
height: toRem(2 + (v / barMax) * 16),
borderRadius: toRem(1),
background: played ? accent : unplayedColor,
transition: 'background 0.1s',
}}
/>
);
})}
</div>
);
}
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
@@ -39,6 +173,8 @@ export type AudioContentProps = {
url: string;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
/** MSC1767 voice waveform (01024 ints); when present, the seek bar renders it. */
waveform?: number[];
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
};
export function AudioContent({
@@ -46,6 +182,7 @@ export function AudioContent({
url,
info,
encInfo,
waveform,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
@@ -63,6 +200,8 @@ export function AudioContent({
);
const audioRef = useRef<HTMLAudioElement | null>(null);
// A seek requested before the media has loaded; applied once metadata arrives.
const pendingSeekRef = useRef<number | null>(null);
useEffect(
() => () => {
@@ -103,6 +242,11 @@ export function AudioContent({
if (!audio) return undefined;
const applyRate = () => {
audio.playbackRate = playbackSpeed;
// Apply a seek that was requested before the source loaded.
if (pendingSeekRef.current != null && audio.readyState >= 1) {
audio.currentTime = pendingSeekRef.current;
pendingSeekRef.current = null;
}
};
// Apply immediately, and re-apply whenever the media element (re)loads a new
// source — e.g. after async decrypt swaps in the blob URL — since the browser
@@ -132,14 +276,38 @@ export function AudioContent({
}
};
// Seeking before the media has loaded (e.g. clicking the waveform first) loads it
// and applies the position once metadata arrives (the <audio> autoPlays).
const handleSeek = useCallback(
(time: number) => {
if (srcState.status === AsyncStatus.Success) {
seek(time);
} else if (srcState.status !== AsyncStatus.Loading) {
pendingSeekRef.current = time;
loadSrc();
}
},
[srcState.status, seek, loadSrc],
);
const hasWaveform = !!waveform && waveform.length > 0 && duration > 0;
return renderMediaControl({
after: (
after: hasWaveform ? (
<WaveformSeek
waveform={waveform ?? []}
currentTime={currentTime}
duration={duration}
onSeek={handleSeek}
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
/>
) : (
<Range
step={1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
onChange={(values) => handleSeek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
@@ -89,3 +89,47 @@ export const MessageEditedContent = as<
</Text>
),
);
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
const translatedLabel = (
status: TranslatedStatus,
fromLangName: string,
downloadProgress?: number,
): string => {
if (status === 'downloading') {
const pct =
typeof downloadProgress === 'number' ? ` ${Math.round(downloadProgress * 100)}%` : '';
return `Downloading translation model…${pct}`;
}
if (status === 'translating') return 'Translating…';
if (status === 'error') return 'Translation failed — show original';
return `Translated from ${fromLangName} · Show original`;
};
// Inline chip shown beside a translated message body. Clicking it toggles back
// to the original text (the same per-event toggle the message menu drives).
export const MessageTranslatedContent = as<
'span',
{
children?: never;
fromLangName: string;
status: TranslatedStatus;
downloadProgress?: number;
onToggle: () => void;
}
>(({ fromLangName, status, downloadProgress, onToggle, ...props }, ref) => (
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
<button
type="button"
onClick={onToggle}
aria-pressed={status === 'done'}
aria-label="Toggle message translation"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
>
<Text as="span" size="T200" priority="300">
{` · ${translatedLabel(status, fromLangName, downloadProgress)}`}
</Text>
</button>
</span>
));
+392 -270
View File
@@ -1,179 +1,194 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useState } from 'react';
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk';
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName } from '../../../utils/room';
import {
ParsedPoll,
PollResponse,
PollTally,
parsePollStart,
parseResponseAnswerIds,
resultsVisible,
tallyResponses,
validateSelections,
winningAnswerIds,
} from '../../../utils/poll';
type PollTextValue = Array<{ body: string }> | string;
const EMPTY_TALLY: PollTally = {
counts: new Map(),
voters: new Map(),
myVotes: new Set(),
total: 0,
};
function extractText(val: PollTextValue | undefined): string {
if (!val) return '';
if (typeof val === 'string') return val;
return val[0]?.body ?? '';
type PollState = { tally: PollTally; isEnded: boolean };
function setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const x of a) if (!b.has(x)) return false;
return true;
}
type PollAnswer = {
'm.id'?: string;
id?: string;
'm.text'?: PollTextValue;
'org.matrix.msc3381.poll.answer'?: { body: string };
};
type PollData = {
question?: { body?: string; 'm.text'?: PollTextValue };
answers?: PollAnswer[];
max_selections?: number;
};
type VoteState = {
counts: Map<string, number>;
myVotes: Set<string>;
total: number;
};
function computeVotes(
function computePollState(
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
room: Room,
eventId: string,
_isStable: boolean,
): VoteState {
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
const room = mx.getRoom(roomId);
if (!room) return empty;
const timelineSet = room.getUnfilteredTimelineSet();
const stableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'm.reference',
'm.poll.response',
);
const unstableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'org.matrix.msc3381.poll.response' as any,
'org.matrix.msc3381.poll.response',
);
// Per-sender keep only the latest response (which may include multiple selections)
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
parsed: ParsedPoll,
): PollState {
const relations = room.getUnfilteredTimelineSet().relations;
const myUserId = mx.getSafeUserId();
const validIds = new Set(parsed.answers.map((a) => a.id));
const processRelations = (rels: typeof stableRels, stable: boolean) => {
const events = rels?.getRelations() ?? [];
for (const ev of events) {
const getRels = (type: string): MatrixEvent[] =>
relations.getChildEventsForEvent(eventId, 'm.reference', type as any)?.getRelations() ?? [];
const endEvents = [...getRels('m.poll.end'), ...getRels('org.matrix.msc3381.poll.end')];
const respEvents = [
...getRels('m.poll.response'),
...getRels('org.matrix.msc3381.poll.response'),
];
// End state: prefer the SDK Poll model (validates ender = creator or redact PL,
// refilters responses). Fall back to an end from the poll's own creator, which is
// always valid, in case the model hasn't processed it yet.
const poll = room.polls.get(eventId);
let isEnded = poll?.isEnded ?? false;
let endTs = poll?.endEventId ? room.findEventById(poll.endEventId)?.getTs() : undefined;
if (!isEnded) {
const creator = room.findEventById(eventId)?.getSender();
for (const ev of endEvents) {
if (ev.isRedacted()) continue;
const sender = ev.getSender();
if (!sender) continue;
const content = ev.getContent();
let answerIds: string[] = [];
if (stable) {
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
} else {
answerIds =
((content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined) ??
[];
if (creator && ev.getSender() === creator) {
isEnded = true;
const t = ev.getTs();
if (endTs === undefined || t < endTs) endTs = t;
}
if (answerIds.length === 0) continue;
const ts = ev.getTs();
const existing = latestBySender.get(sender);
if (!existing || ts > existing.ts) {
latestBySender.set(sender, { ts, answerIds });
}
}
};
processRelations(stableRels, true);
processRelations(unstableRels, false);
const counts = new Map<string, number>();
const myVotes = new Set<string>();
for (const [sender, { answerIds }] of latestBySender) {
for (const id of answerIds) {
counts.set(id, (counts.get(id) ?? 0) + 1);
if (sender === myUserId) myVotes.add(id);
}
}
return { counts, myVotes, total: latestBySender.size };
const responses: PollResponse[] = [];
for (const ev of respEvents) {
if (ev.isRedacted()) continue;
const sender = ev.getSender();
if (!sender) continue;
const ts = ev.getTs();
// Only responses cast on or before the end event are valid.
if (isEnded && endTs !== undefined && ts > endTs) continue;
const answerIds = validateSelections(
parseResponseAnswerIds(ev.getContent()),
validIds,
parsed.maxSelections,
);
responses.push({ sender, ts, answerIds });
}
return { tally: tallyResponses(responses, myUserId), isEnded };
}
export function PollContent({
content,
roomId,
eventId,
mEvent,
room,
canRedact,
}: {
content: Record<string, unknown>;
roomId?: string;
eventId?: string;
mEvent: MatrixEvent;
room: Room;
/** Whether the current user may redact in this room (gates the End-poll action). */
canRedact: boolean;
}) {
const mx = useMatrixClient();
const _isStable = !!content['m.poll'];
const roomId = room.roomId;
const eventId = mEvent.getId();
const senderId = mEvent.getSender();
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as
| PollData
| undefined;
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
const [votes, setVotes] = useState<VoteState>(() => {
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
return computeVotes(mx, roomId, eventId, _isStable);
const [state, setState] = useState<PollState>(() => {
if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false };
return computePollState(mx, room, eventId, parsed);
});
const [pending, setPending] = useState<Set<string> | null>(null);
const [confirmEnd, setConfirmEnd] = useState(false);
const [ending, setEnding] = useState(false);
const [showVoters, setShowVoters] = useState(false);
// Refresh votes whenever Relations events fire
const refresh = useCallback(() => {
if (!roomId || !eventId) return;
setVotes(computeVotes(mx, roomId, eventId, _isStable));
}, [mx, roomId, eventId, _isStable]);
if (!eventId || !parsed) return;
const next = computePollState(mx, room, eventId, parsed);
setState(next);
// Drop the optimistic selection only once our own vote is actually reflected,
// so an unrelated refresh (another user voting) can't revert our pending click.
setPending((prev) => (prev !== null && setsEqual(prev, next.tally.myVotes) ? null : prev));
}, [mx, room, eventId, parsed]);
useEffect(() => {
if (!roomId || !eventId) return;
const room = mx.getRoom(roomId);
if (!room) return;
const timelineSet = room.getUnfilteredTimelineSet();
if (!eventId || !parsed) return undefined;
const relations = room.getUnfilteredTimelineSet().relations;
const relObjs = [
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.response' as any),
relations.getChildEventsForEvent(
eventId,
'm.reference',
'org.matrix.msc3381.poll.response' as any,
),
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end' as any),
relations.getChildEventsForEvent(
eventId,
'm.reference',
'org.matrix.msc3381.poll.end' as any,
),
];
relObjs.forEach((r) => {
r?.on(RelationsEvent.Add, refresh);
r?.on(RelationsEvent.Remove, refresh);
r?.on(RelationsEvent.Redaction, refresh);
});
const stableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'm.reference',
'm.poll.response',
);
const unstableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'org.matrix.msc3381.poll.response' as any,
'org.matrix.msc3381.poll.response',
);
stableRels?.on(RelationsEvent.Add, refresh);
stableRels?.on(RelationsEvent.Remove, refresh);
stableRels?.on(RelationsEvent.Redaction, refresh);
unstableRels?.on(RelationsEvent.Add, refresh);
unstableRels?.on(RelationsEvent.Remove, refresh);
unstableRels?.on(RelationsEvent.Redaction, refresh);
// Also listen at room level: if no votes exist yet, the Relations object is null
// and the listeners above are no-ops. The room timeline event catches the first vote.
const onTimeline = (ev: any) => {
const type = ev.getType?.();
const relatesTo = ev.getContent?.()?.['m.relates_to'];
// The relations object for the first response/end may not exist yet at mount, and
// redactions (m.room.redaction) don't flow through the relations listeners for a
// post-mount response — the room timeline catches those.
const onTimeline = (ev: MatrixEvent) => {
const type = ev.getType();
if (type === 'm.room.redaction') {
refresh();
return;
}
const relatesTo = ev.getContent()['m.relates_to'] as { event_id?: string } | undefined;
if (
(type === 'm.poll.response' || type === 'org.matrix.msc3381.poll.response') &&
(type === 'm.poll.response' ||
type === 'org.matrix.msc3381.poll.response' ||
type === 'm.poll.end' ||
type === 'org.matrix.msc3381.poll.end') &&
relatesTo?.event_id === eventId
) {
refresh();
}
};
const room2 = mx.getRoom(roomId);
room2?.on(RoomEvent.Timeline, onTimeline);
room.on(RoomEvent.Timeline, onTimeline);
const poll = room.polls.get(eventId);
poll?.on(PollEvent.Update, refresh);
poll?.on(PollEvent.Responses, refresh);
poll?.on(PollEvent.End, refresh);
room.on(PollEvent.New, refresh);
return () => {
stableRels?.off(RelationsEvent.Add, refresh);
stableRels?.off(RelationsEvent.Remove, refresh);
stableRels?.off(RelationsEvent.Redaction, refresh);
unstableRels?.off(RelationsEvent.Add, refresh);
unstableRels?.off(RelationsEvent.Remove, refresh);
unstableRels?.off(RelationsEvent.Redaction, refresh);
room2?.off(RoomEvent.Timeline, onTimeline);
relObjs.forEach((r) => {
r?.off(RelationsEvent.Add, refresh);
r?.off(RelationsEvent.Remove, refresh);
r?.off(RelationsEvent.Redaction, refresh);
});
room.off(RoomEvent.Timeline, onTimeline);
poll?.off(PollEvent.Update, refresh);
poll?.off(PollEvent.Responses, refresh);
poll?.off(PollEvent.End, refresh);
room.off(PollEvent.New, refresh);
};
}, [mx, roomId, eventId, refresh]);
}, [room, eventId, parsed, refresh]);
if (!poll) {
if (!parsed) {
return (
<Text priority="300">
<i>Poll (unreadable format)</i>
@@ -181,61 +196,96 @@ export function PollContent({
);
}
const questionText =
extractText((poll.question as any)?.['m.text']) ||
(poll.question as any)?.body ||
'Untitled poll';
const canVote = !!roomId && !!eventId;
const maxSelections = (poll as any).max_selections ?? 1;
const { tally, isEnded } = state;
const { isUndisclosed, maxSelections, question, answers } = parsed;
const isMultiple = maxSelections > 1;
const { counts, myVotes, total } = votes;
const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isEnded;
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally;
const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set<string>();
// Voter identities are shown under the same rule as the counts: disclosed polls
// reveal them live, undisclosed polls only once ended.
const canShowVoters = showResults && total > 0;
const handleVote = (answerId: string) => {
if (!roomId || !eventId) return;
if (!roomId || !eventId || !canVote) return;
const newVotes = new Set(myVotes);
if (newVotes.has(answerId)) {
newVotes.delete(answerId);
const next = new Set(myVotes);
if (next.has(answerId)) {
next.delete(answerId);
} else if (isMultiple) {
if (next.size >= maxSelections) return; // enforce max_selections
next.add(answerId);
} else {
if (!isMultiple) newVotes.clear();
newVotes.add(answerId);
next.clear();
next.add(answerId);
}
// Optimistic local update
setVotes((prev) => {
const next = new Map(prev.counts);
// Remove all old vote counts for this user
for (const id of prev.myVotes) {
const c = next.get(id) ?? 1;
if (c <= 1) next.delete(id);
else next.set(id, c - 1);
}
// Add new vote counts
for (const id of newVotes) {
next.set(id, (next.get(id) ?? 0) + 1);
}
const hadVotes = prev.myVotes.size > 0;
const hasVotes = newVotes.size > 0;
const newTotal = prev.total + (hasVotes && !hadVotes ? 1 : !hasVotes && hadVotes ? -1 : 0);
return { counts: next, myVotes: newVotes, total: newTotal };
});
const selectionsArr = Array.from(newVotes);
if (_isStable) {
mx.sendEvent(roomId, 'm.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': selectionsArr,
}).catch(() => undefined);
} else {
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'org.matrix.msc3381.poll.response': { answers: selectionsArr },
}).catch(() => undefined);
}
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
mx.sendEvent(roomId, 'm.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
};
const answers = poll.answers ?? [];
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
mx.sendEvent(roomId, 'm.poll.end' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
};
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
const handleRadioKeyDown = (evt: KeyboardEvent<HTMLDivElement>) => {
if (isMultiple || !canVote) return;
const nav = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
if (!nav.includes(evt.key)) return;
evt.preventDefault();
const buttons = Array.from(
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]'),
);
if (buttons.length === 0) return;
const current = buttons.findIndex((b) => b === document.activeElement);
let idx = current < 0 ? 0 : current;
if (evt.key === 'ArrowDown' || evt.key === 'ArrowRight') idx = (idx + 1) % buttons.length;
else if (evt.key === 'ArrowUp' || evt.key === 'ArrowLeft')
idx = (idx - 1 + buttons.length) % buttons.length;
else if (evt.key === 'Home') idx = 0;
else if (evt.key === 'End') idx = buttons.length - 1;
buttons[idx]?.focus();
handleVote(answers[idx].id);
};
const headerLabel = isEnded
? 'Poll closed · Final results'
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
const footerNote = (() => {
if (isUndisclosed && !isEnded) {
return canVote
? 'Voting open · Results hidden until the poll ends'
: 'Results hidden until the poll ends';
}
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
if (isEnded) return `${votesPart} · Poll closed`;
if (!canVote) return votesPart;
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
})();
return (
<Box
@@ -256,116 +306,188 @@ export function PollContent({
marginBottom: config.space.S100,
}}
>
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
<span aria-hidden> </span>
{headerLabel}
</Text>
<Text size="T400" style={{ fontWeight: 600 }}>
{questionText}
{question}
</Text>
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
<Box
direction="Column"
gap="100"
style={{ marginTop: '2px' }}
role={isMultiple ? 'group' : 'radiogroup'}
aria-label={question}
onKeyDown={handleRadioKeyDown}
>
{answers.map((answer, i) => {
const text =
extractText((answer as any)['m.text']) ||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
`Option ${i + 1}`;
const id = answer['m.id'] ?? answer.id ?? String(i);
const id = answer.id;
const text = answer.text;
const selected = myVotes.has(id);
const voteCount = counts.get(id) ?? 0;
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id);
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
return (
<button
key={id}
type="button"
data-poll-answer
data-selected={selected}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
<span
<React.Fragment key={id}>
<button
type="button"
data-poll-answer
data-selected={selected}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined}
aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}`
: undefined
}
tabIndex={tabIndex}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
flexDirection: 'column',
gap: config.space.S100,
width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{showResults && total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
<span
style={{
flexShrink: 0,
width: toRem(14),
height: toRem(14),
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.Primary.ContainerLine
}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.Primary.OnMain,
transition: 'all 0.15s',
gap: config.space.S200,
position: 'relative',
}}
>
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
</Text>
{total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
<span
aria-hidden
style={{
flexShrink: 0,
width: toRem(14),
height: toRem(14),
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.Primary.ContainerLine
}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.Primary.OnMain,
transition: 'all 0.15s',
}}
>
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
</Text>
)}
</span>
</button>
{isWinner && (
<Icon
size="50"
src={Icons.Check}
style={{ flexShrink: 0, color: color.Success.Main }}
/>
)}
{showResults && total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
</Text>
)}
</span>
</button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text
id={`poll-voters-${eventId}-${id}`}
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
>
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text>
)}
</React.Fragment>
);
})}
</Box>
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
<i>
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
{canVote
? isMultiple
? 'Select all that apply'
: myVotes.size > 0
? 'Click to change'
: 'Click to vote'
: 'Voting not available'}
</i>
</Text>
{canShowVoters && (
<Box>
<Chip
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
</Chip>
</Box>
)}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
<i>{footerNote}</i>
</Text>
{canEnd &&
(confirmEnd ? (
<Box gap="100" shrink="No" alignItems="Center">
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200">Cancel</Text>
</Chip>
</Box>
) : (
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200">End poll</Text>
</Chip>
))}
</Box>
</Box>
);
}
+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"
/>
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import React, { useState } from 'react';
import { Room } from 'matrix-js-sdk';
import {
Icon,
Icons,
@@ -12,7 +12,6 @@ import {
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { getMemberName } from '../../utils/room';
@@ -21,8 +20,9 @@ import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useForceUpdate } from '../../hooks/useForceUpdate';
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;
@@ -50,32 +50,15 @@ export function ReadReceiptAvatars({
eventId: string;
userIds: string[];
}) {
const mx = useMatrixClient();
const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const modalStyle = useModalStyle(360);
const [, forceUpdate] = useForceUpdate();
// The avatars + names below are read from room member state at render time, so
// they only refresh when this component re-renders (which the timeline does on a
// receipt change). Re-render on a member-state change of any displayed reader so
// an avatar/display-name update shows live. RoomStateEvent.Members fires for name,
// avatar AND membership changes (RoomMemberEvent has no Avatar signal).
useEffect(() => {
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member,
) => {
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
forceUpdate();
}
};
mx.on(RoomStateEvent.Members, handleMembers);
return () => {
mx.removeListener(RoomStateEvent.Members, handleMembers);
};
}, [mx, room.roomId, userIds, forceUpdate]);
// The tooltip names below are read from room member state at render time.
// Re-render on a member-state change of any displayed reader so a display-name
// update shows live (each avatar handles its own via useMemberAvatar). Uses the
// shared member-change store — one global listener for the whole app (PERF-3).
useRoomMembersChange(room.roomId, userIds);
if (userIds.length === 0) return null;
@@ -110,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,
@@ -317,6 +318,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
readOnly={!canEdit || markedDeleted}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
aria-label="Clip name"
style={{ width: '100%' }}
/>
</Box>
<Box
@@ -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
// ---------------------------------------------------------------------------
+320 -56
View File
@@ -1,8 +1,21 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
as,
color,
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';
@@ -21,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,
@@ -103,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;
}
@@ -116,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;
}
@@ -207,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);
@@ -298,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
@@ -308,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';
@@ -714,6 +747,7 @@ function TwitterCard({
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
@@ -768,6 +802,7 @@ function TwitterCard({
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
@@ -1068,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
@@ -1237,6 +1274,7 @@ function MediaEmbedCard({
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
@@ -1247,6 +1285,7 @@ function MediaEmbedCard({
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
@@ -1360,9 +1399,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
onClick={start}
disabled={resolving}
aria-busy={resolving}
aria-label={
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
}
aria-label={resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`}
>
{facadeInner}
</button>
@@ -1397,6 +1434,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
@@ -1406,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"
>
@@ -1482,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);
@@ -1534,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 (
<>
@@ -1598,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'] ?? '';
@@ -1654,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 (
<>
@@ -1817,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 (
<>
@@ -1885,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
@@ -1965,16 +2194,30 @@ function GenericCard({
const description = prev['og:description'] ?? '';
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
// Synapse returns 400 from the thumbnail endpoint when it can't thumbnail a
// cached preview image (e.g. SVG/animated). Fall back to the full image, then
// hide entirely if that also fails — otherwise the card shows a broken image
// and the browser keeps re-requesting the failing thumbnail (console spam).
const [useFullImg, setUseFullImg] = useState(false);
const [imgFailed, setImgFailed] = useState(false);
const displayThumb = useFullImg ? imgUrl : thumbUrl;
const handleImgError = () => {
if (!useFullImg && imgUrl && imgUrl !== thumbUrl) setUseFullImg(true);
else setImgFailed(true);
};
return (
<>
{thumbUrl && (
{!imgFailed && displayThumb && (
<UrlPreviewImg
src={thumbUrl}
src={displayThumb}
alt={prev['og:title']}
title={prev['og:title']}
loading="lazy"
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
onClick={onOpenViewer}
onError={handleImgError}
/>
)}
{imgUrl && (
@@ -1997,7 +2240,7 @@ function GenericCard({
size="T200"
priority="300"
>
{!thumbUrl && (
{(!displayThumb || imgFailed) && (
<Icon
src={Icons.Link}
size="50"
@@ -2045,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} />;
}
@@ -2124,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
@@ -2152,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>
@@ -114,7 +114,7 @@ export function ServerChip({ server }: { server: string }) {
size="300"
radii="300"
onClick={() => {
window.open(`https://${server}`, '_blank');
window.open(`https://${server}`, '_blank', 'noopener,noreferrer');
close();
}}
>
+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}
+250 -40
View File
@@ -1,5 +1,7 @@
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import {
Avatar,
Box,
@@ -16,13 +18,19 @@ import {
} from 'folds';
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import {
BookmarkSort,
isBookmarkSort,
sortBookmarks,
groupBookmarksByRoom,
} from '../../utils/bookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { getRoomAvatarUrl, getMemberName } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
@@ -41,15 +49,57 @@ function formatTimeAgo(ts: number): string {
return new Date(ts).toLocaleDateString();
}
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
// getOnInit reads localStorage synchronously at init so the persisted sort is
// applied on the very first render (no flash from the 'newest' default).
const bookmarkSortAtom = atomWithStorage<BookmarkSort>(
'cinny_bookmarks_sort_v1',
'newest',
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
const SORT_OPTIONS: { value: BookmarkSort; label: string }[] = [
{ value: 'newest', label: 'Newest' },
{ value: 'oldest', label: 'Oldest' },
{ value: 'room', label: 'By room' },
];
// Segmented sort button — mirrors MediaGallery's tab styling for house consistency.
function SortButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
aria-pressed={active}
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
type BookmarkItemProps = {
bookmark: Bookmark;
onJump: (roomId: string, eventId: string) => void;
onRemove: (eventId: string) => void;
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
preview?: ReactNode;
// Live-resolved author name; falls back to the stored snapshot when absent.
senderName?: string;
};
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: BookmarkItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
@@ -57,6 +107,9 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
// Prefer a live-resolved author name, then the stored snapshot.
const author = senderName ?? bookmark.senderName;
const timeAgo = formatTimeAgo(bookmark.savedAt);
return (
<Box
@@ -82,8 +135,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
<Text size="T200" priority="300" truncate>
{author ? `${author} · ${timeAgo}` : timeAgo}
</Text>
</Box>
<IconButton
@@ -144,7 +197,81 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem
}
}
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
// Resolve the author's current display name from the live event when available.
const liveSender = liveEvent?.getSender();
const senderName = liveSender ? getMemberName(room, liveSender) : undefined;
return (
<BookmarkItem
bookmark={bookmark}
onJump={onJump}
onRemove={onRemove}
preview={preview}
senderName={senderName}
/>
);
}
type RoomGroupHeaderProps = {
roomId: string;
roomName: string;
count: number;
collapsed: boolean;
contentId: string;
onToggle: () => void;
};
// Collapsible section header for the "By room" grouping. Uses the live room name
// / avatar when the room is joined, falling back to the stored snapshot name.
function RoomGroupHeader({
roomId,
roomName,
count,
collapsed,
contentId,
onToggle,
}: RoomGroupHeaderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId) ?? undefined;
const displayRoomName = room?.name ?? roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
return (
<Button
variant="Secondary"
fill="None"
size="300"
radii="300"
onClick={onToggle}
aria-expanded={!collapsed}
aria-controls={contentId}
// Explicit name avoids the avatar alt + visible name being announced twice,
// and gives the bare count meaning for screen readers.
aria-label={`${displayRoomName}, ${count} saved message${count !== 1 ? 's' : ''}`}
style={{ justifyContent: 'flex-start', padding: config.space.S200 }}
>
<Box grow="Yes" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
<Icon size="100" src={collapsed ? Icons.ChevronRight : Icons.ChevronBottom} />
<Avatar size="200" radii="300">
<RoomAvatar
roomId={roomId}
src={avatarUrl}
alt=""
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
/>
</Avatar>
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
</Box>
</Button>
);
}
type BookmarksPanelProps = {
@@ -156,6 +283,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
const [storedSort, setSort] = useAtom(bookmarkSortAtom);
// Normalize a stale/corrupt persisted value so exactly one sort is always active.
const sort: BookmarkSort = isBookmarkSort(storedSort) ? storedSort : 'newest';
// roomIds whose group section is collapsed (only relevant in "By room" mode).
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggleGroup = useCallback((roomId: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(roomId)) next.delete(roomId);
else next.add(roomId);
return next;
});
}, []);
// Escape closes the panel (parity with the app's other overlays/drawers).
useEffect(() => {
@@ -182,14 +323,69 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
};
const query = filter.trim().toLowerCase();
const filtered: Bookmark[] =
query.length === 0
? bookmarks
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query),
);
const filtered: Bookmark[] = useMemo(
() =>
query.length === 0
? bookmarks
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query) ||
(bk.senderName?.toLowerCase().includes(query) ?? false),
),
[bookmarks, query],
);
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
// later doesn't reappear pre-collapsed and the Set can't grow unbounded.
useEffect(() => {
setCollapsed((prev) => {
if (prev.size === 0) return prev;
const live = new Set(bookmarks.map((bk) => bk.roomId));
let changed = false;
const next = new Set<string>();
prev.forEach((roomId) => {
if (live.has(roomId)) next.add(roomId);
else changed = true;
});
return changed ? next : prev;
});
}, [bookmarks]);
// Live render when the room is joined (useRoomEvent needs a non-null Room);
// otherwise fall back to the stored snapshot for rooms we've left. Shared by
// both the flat (newest/oldest) and grouped (by room) render paths.
const renderItem = useCallback(
(bk: Bookmark) => {
const room = mx.getRoom(bk.roomId);
return room ? (
<LiveBookmarkItem
key={bk.eventId}
room={room}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
) : (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
);
},
[mx, handleJump, removeBookmark],
);
const sortedItems = useMemo(
() => (sort === 'room' ? filtered : sortBookmarks(filtered, sort)),
[filtered, sort],
);
const groups = useMemo(
() => (sort === 'room' ? groupBookmarksByRoom(filtered) : []),
[filtered, sort],
);
return (
<Box
@@ -237,11 +433,23 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
}
/>
{bookmarks.length > 0 && (
<Text size="T200" priority="300">
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
<>
<Text size="T200" priority="300" truncate>
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
<Box as="div" role="group" aria-label="Sort saved messages" gap="100">
{SORT_OPTIONS.map((opt) => (
<SortButton
key={opt.value}
label={opt.label}
active={sort === opt.value}
onClick={() => setSort(opt.value)}
/>
))}
</Box>
</>
)}
</Box>
@@ -265,27 +473,29 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => {
// Live render when the room is joined (useRoomEvent needs a non-null Room);
// otherwise fall back to the stored snapshot for rooms we've left.
const room = mx.getRoom(bk.roomId);
return room ? (
<LiveBookmarkItem
key={bk.eventId}
room={room}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
) : (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
);
})}
{sort === 'room'
? groups.map((group) => {
const isCollapsed = collapsed.has(group.roomId);
const contentId = `bookmark-group-${group.roomId}`;
return (
<Box key={group.roomId} direction="Column" gap="200">
<RoomGroupHeader
roomId={group.roomId}
roomName={group.roomName}
count={group.items.length}
collapsed={isCollapsed}
contentId={contentId}
onToggle={() => toggleGroup(group.roomId)}
/>
{!isCollapsed && (
<Box id={contentId} direction="Column" gap="200">
{group.items.map((bk) => renderItem(bk))}
</Box>
)}
</Box>
);
})
: sortedItems.map((bk) => renderItem(bk))}
</Box>
)}
</Scroll>
@@ -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">
@@ -152,7 +152,10 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
getRoomNameStr,
SEARCH_OPTS,
);
const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
const rooms = useMemo(
() => Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx)),
[searchResult, roomList, mx],
);
const virtualizer = useVirtualizer({
count: rooms.length,
+27 -2
View File
@@ -1,4 +1,11 @@
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
import React, {
MouseEventHandler,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import {
Avatar,
@@ -27,6 +34,7 @@ import {
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
@@ -40,6 +48,9 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { hasMsgDraft } from '../../utils/draft';
import { DraftDot } from '../room/DraftIndicator.css';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -678,7 +689,18 @@ function RoomNavItem_({
const roomName = useLocalRoomName(room);
const hasLocalName = useHasLocalRoomName(room.roomId);
const latestEvent = useRoomLatestRenderedEvent(room);
// Whether this room has an unsent message draft. selectAtom maps to a boolean
// so the row only re-renders when that flips (the draft atom itself is written
// on room-leave, not per keystroke).
const hasDraftAtom = useMemo(
() => selectAtom(roomIdToMsgDraftAtomFamily(room.roomId), hasMsgDraft),
[room.roomId],
);
const hasDraft = useAtomValue(hasDraftAtom);
// Only DM rows render this preview — pass `direct` so non-DM nav items don't
// register the global decryption listener (PERF-5).
const latestEvent = useRoomLatestRenderedEvent(room, !!direct);
const dmPreview = (() => {
if (!direct || !latestEvent) return null;
const type = latestEvent.getType();
@@ -813,6 +835,9 @@ function RoomNavItem_({
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
{hasDraft && !selected && (
<span className={DraftDot} role="img" aria-label="Unsent draft" />
)}
</Box>
{dmPreview && (
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
@@ -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}
@@ -32,6 +32,8 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
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 ─────────────────────────────────────────────────────────────────────
@@ -48,57 +50,6 @@ const DEFAULT_ACL: ServerAclContent = {
allow_ip_literals: false,
};
// ── Validation ────────────────────────────────────────────────────────────────
/**
* Validate a server-name glob for an ACL entry.
*
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
* `?` (single char) may appear ANYWHERE e.g. `*`, `*.example.com`,
* `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
* rather than a concrete hostname:
* - reject empty / whitespace-only
* - allow only hostname/IP chars plus the wildcards `*` and `?`
* (letters, digits, dots, hyphens, colons for ports/IPv6 NO underscore)
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
*/
function isValidServerPattern(value: string): boolean {
const v = value.trim();
if (!v) return false;
// Only hostname/IP glob chars — wildcards may appear at any position.
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
// Structural rules for the dotted parts.
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
// Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
if (!/[A-Za-z0-9*?]/.test(v)) return false;
return true;
}
/**
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
* escaping every other regex metacharacter. Used only for local self-ban
* detection never sent to the server.
*/
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
return new RegExp(`^${pattern}$`, 'i');
}
function matchesAnyGlob(domain: string, globs: string[]): boolean {
return globs.some((glob) => {
try {
return globToRegExp(glob).test(domain);
} catch {
return false;
}
});
}
// ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = {
@@ -198,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 }}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
import { Box, Text, config } from 'folds';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { toPlainText } from '../../components/editor';
import { hasMsgDraft } from '../../utils/draft';
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
const PULSE_DURATION = 600;
@@ -27,7 +27,7 @@ type DraftIndicatorProps = {
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
// Real content, not just an empty paragraph.
const hasDraft = toPlainText(draft, false).trim().length > 0;
const hasDraft = hasMsgDraft(draft);
const [pulse, setPulse] = useState(false);
const hadDraft = useRef(false);
+30
View File
@@ -52,8 +52,38 @@ export const MediaGalleryGrid = style({
gap: config.space.S100,
});
// Wraps a tile + its floating download button so the download control is a
// sibling of the tile <button> (never nested inside it — that would be invalid
// interactive-in-interactive markup). The wrapper is the grid cell; the tile
// button fills it via aspect-ratio.
export const GalleryTileWrap = style({
position: 'relative',
display: 'flex',
});
export const GalleryTileDownload = style({
position: 'absolute',
top: config.space.S100,
right: config.space.S100,
zIndex: 1,
opacity: 0,
transition: 'opacity 100ms ease-in-out',
selectors: {
[`${GalleryTileWrap}:hover &, ${GalleryTileWrap}:focus-within &`]: {
opacity: 1,
},
},
// Touch devices have no hover; keep the control reachable there.
'@media': {
'(hover: none)': {
opacity: 1,
},
},
});
export const GalleryTile = style({
position: 'relative',
width: '100%',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
+359 -60
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Box,
Button,
Chip,
Header,
Icon,
IconButton,
@@ -20,25 +21,33 @@ import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-s
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
type GalleryTab = 'image' | 'video' | 'file';
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
const TAB_LABELS: Record<GalleryTab, string> = {
image: 'Images',
video: 'Videos',
audio: 'Audio',
file: 'Files',
};
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
image: MsgType.Image,
video: MsgType.Video,
audio: MsgType.Audio,
file: MsgType.File,
};
@@ -125,6 +134,22 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1048576).toFixed(1)} MB`;
}
// A sensible download filename: prefer the event body/filename; if it has no
// plausible extension already, append one derived from the mimetype so the
// saved file opens. "Plausible" = a short alphanumeric tail after the last dot,
// so "Screenshot 2024.01.05" still gets a real extension appended.
function hasFileExtension(name: string): boolean {
const dot = name.lastIndexOf('.');
if (dot <= 0 || dot === name.length - 1) return false;
return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1));
}
function mediaFilename(body: string, mimeType?: string): string {
const name = body.trim() || 'media';
if (hasFileExtension(name)) return name;
const ext = mimeType ? mimeTypeToExt(mimeType) : '';
return ext ? `${name}.${ext}` : name;
}
function monthLabel(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}
@@ -155,14 +180,27 @@ type LightboxItem = {
body: string;
sender: string;
ts: number;
eventId: string;
};
function LightboxMedia({
item,
useAuthentication,
zoom,
pan,
cursor,
onMouseDown,
onTouchStart,
onImageDoubleClick,
}: {
item: LightboxItem;
useAuthentication: boolean;
zoom: number;
pan: Pan;
cursor: string;
onMouseDown: React.MouseEventHandler<HTMLElement>;
onTouchStart: React.TouchEventHandler<HTMLElement>;
onImageDoubleClick: () => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(
@@ -215,12 +253,25 @@ function LightboxMedia({
<img
src={media.url}
alt={item.body}
draggable={false}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onDoubleClick={onImageDoubleClick}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 120px)',
objectFit: 'contain',
borderRadius: config.radii.R300,
display: 'block',
cursor,
// translate is nested inside scale(), so it runs in scaled space —
// divide by zoom so a dragged pixel moves the image one screen pixel
// (1:1 with the cursor) rather than `zoom` pixels.
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
pan.translateY / zoom
}px)`,
transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
willChange: 'transform',
}}
/>
))}
@@ -233,14 +284,30 @@ function Lightbox({
initialIndex,
useAuthentication,
onClose,
onJump,
}: {
items: LightboxItem[];
initialIndex: number;
useAuthentication: boolean;
onClose: () => void;
onJump: (eventId: string) => void;
}) {
const [index, setIndex] = useState(initialIndex);
const item = items[index];
const isImage = item?.msgtype === MsgType.Image;
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, 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).
useEffect(() => {
setZoom(1);
}, [index, setZoom]);
const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
const next = useCallback(
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
@@ -251,11 +318,21 @@ function Lightbox({
if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
else if (e.key === 'Escape') onClose();
else if (isImage && (e.key === '+' || e.key === '=')) zoomIn();
else if (isImage && e.key === '-') zoomOut();
else if (isImage && e.key === '0') setZoom(1);
},
[prev, next, onClose],
[prev, next, onClose, isImage, zoomIn, zoomOut, setZoom],
);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
if (!isImage) return;
if (e.deltaY < 0) zoomIn();
else if (e.deltaY > 0) zoomOut();
},
[isImage, zoomIn, zoomOut],
);
const item = items[index];
if (!item) return null;
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
@@ -309,6 +386,85 @@ function Lightbox({
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
{isImage && (
<Box
shrink="No"
alignItems="Center"
gap="100"
role="group"
aria-label="Zoom controls"
>
<IconButton
variant="Surface"
size="300"
radii="300"
aria-label="Zoom out"
onClick={zoomOut}
disabled={zoom <= 0.2}
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="Surface" radii="Pill" onClick={toggleZoom} aria-label="Reset zoom">
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant="Surface"
size="300"
radii="300"
aria-label="Zoom in"
onClick={zoomIn}
disabled={zoom >= 5}
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
</Box>
)}
{item.mxcUrl && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Download</Text>
</Tooltip>
}
>
{(ref) => (
<span ref={ref}>
<FileDownloadButton
filename={mediaFilename(item.body, item.mimeType)}
url={item.mxcUrl}
mimeType={item.mimeType ?? 'application/octet-stream'}
encInfo={item.encInfo}
/>
</span>
)}
</TooltipProvider>
)}
{item.eventId && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Go to message</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
ref={ref}
variant="Surface"
aria-label="Go to message"
onClick={() => onJump(item.eventId)}
>
<Icon src={Icons.Message} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
@@ -332,6 +488,7 @@ function Lightbox({
grow="Yes"
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
@@ -354,6 +511,12 @@ function Lightbox({
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
zoom={zoom}
pan={pan}
cursor={cursor}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onImageDoubleClick={toggleZoom}
/>
</Box>
{index < items.length - 1 && (
@@ -385,6 +548,10 @@ function GalleryTile({
ts,
useAuthentication,
onClick,
downloadUrl,
downloadEncInfo,
downloadMimeType,
downloadFilename,
}: {
mxcUrl: string;
encInfo?: IEncryptedFile;
@@ -395,6 +562,10 @@ function GalleryTile({
ts: number;
useAuthentication: boolean;
onClick: () => void;
downloadUrl?: string;
downloadEncInfo?: IEncryptedFile;
downloadMimeType?: string;
downloadFilename: string;
}) {
const mx = useMatrixClient();
const tileRef = useRef<HTMLButtonElement>(null);
@@ -410,55 +581,71 @@ function GalleryTile({
const relDate = formatRelativeDate(ts);
return (
<button
ref={tileRef}
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
className={css.GalleryTile}
>
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{ padding: config.space.S100 }}
>
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
<Text
size="T200"
truncate
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
<div className={css.GalleryTileWrap}>
<button
ref={tileRef}
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
className={css.GalleryTile}
>
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{ padding: config.space.S100 }}
>
{body}
</Text>
</Box>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text size="T200" truncate style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
<Text
size="T200"
truncate
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
>
{body}
</Text>
</Box>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text
size="T200"
truncate
style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}
>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
</Text>
</div>
</div>
)}
</button>
{downloadUrl && (
<div className={css.GalleryTileDownload}>
<FileDownloadButton
filename={downloadFilename}
url={downloadUrl}
mimeType={downloadMimeType ?? 'application/octet-stream'}
encInfo={downloadEncInfo}
/>
</div>
)}
</button>
</div>
);
}
@@ -503,6 +690,16 @@ type MediaGalleryProps = {
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
// Close the drawer and land the timeline on the source message.
const jumpToMessage = useCallback(
(eventId: string) => {
onClose();
navigateRoom(room.roomId, eventId);
},
[onClose, navigateRoom, room.roomId],
);
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
@@ -613,12 +810,13 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
body: c.body ?? '',
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
};
});
// Per-tab counts for the tab labels (single pass over loaded timeline)
const tabCounts = useMemo(() => {
const counts: Record<GalleryTab, number> = { image: 0, video: 0, file: 0 };
const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
room
.getLiveTimeline()
.getEvents()
@@ -627,6 +825,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.Audio) counts.audio += 1;
else if (mt === MsgType.File) counts.file += 1;
});
return counts;
@@ -739,6 +938,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
// Guard before incrementing: skipped tiles must not consume a slot
if (!thumbMxc) return null;
const idx = flatIdx++;
// Full-resolution source for download (not the thumb).
const fullMxc: string | undefined = c.file?.url ?? c.url;
const bodyStr: string = c.body ?? '';
return (
<GalleryTile
key={mEvent.getId()}
@@ -746,11 +948,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
encInfo={thumbEnc}
mimeType={thumbMime}
isVideo={isVideo}
body={c.body ?? ''}
body={bodyStr}
sender={getSenderName(room, mEvent.getSender() ?? '')}
ts={mEvent.getTs()}
useAuthentication={useAuthentication}
onClick={() => setLightboxIndex(idx)}
downloadUrl={fullMxc}
downloadEncInfo={isEnc ? c.file : undefined}
downloadMimeType={info?.mimetype}
downloadFilename={mediaFilename(bodyStr, info?.mimetype)}
/>
);
})}
@@ -784,9 +990,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const body: string = c.body ?? 'Unnamed file';
const size: number | undefined = c.info?.size;
const sender = getSenderName(room, mEvent.getSender() ?? '');
const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#';
return (
<Box
key={mEvent.getId()}
@@ -816,18 +1019,113 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={`Download ${body}`}
aria-label="Go to message"
onClick={() => {
const a = document.createElement('a');
a.href = downloadUrl;
a.download = body;
a.target = '_blank';
a.rel = 'noreferrer';
a.click();
const id = mEvent.getId();
if (id) jumpToMessage(id);
}}
>
<Icon size="200" src={Icons.Download} />
<Icon size="200" src={Icons.Message} />
</IconButton>
{mxcUrl && (
<FileDownloadButton
filename={body}
url={mxcUrl}
mimeType={c.info?.mimetype ?? 'application/octet-stream'}
encInfo={c.file}
/>
)}
</Box>
);
})}
</Box>
</>
)}
{/* ── Audio / voice list ── */}
{tab === 'audio' && (
<>
{events.length === 0 && !loading && (
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
>
<Icon src={Icons.VolumeHigh} size="600" />
<Text size="T300" priority="300" align="Center">
{hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'}
</Text>
</Box>
)}
<Box direction="Column" gap="200">
{events.map((mEvent) => {
const c = mEvent.getContent();
const url: string | undefined = c.file?.url ?? c.url;
if (!url) return null;
const body: string = c.body || 'Voice message';
const sender = getSenderName(room, mEvent.getSender() ?? '');
const relDate = formatRelativeDate(mEvent.getTs());
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
// audio/ogg) so the decrypted blob actually plays.
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
const filename = body.includes('.')
? body
: `${body}.${mimeTypeToExt(mimeType)}`;
const waveform = (
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
)['org.matrix.msc1767.audio']?.waveform;
return (
<Box
key={mEvent.getId()}
direction="Column"
gap="100"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Box alignItems="Center" gap="200">
<Box
grow="Yes"
direction="Column"
style={{ overflow: 'hidden', gap: '2px' }}
>
<Text size="T300" truncate title={body}>
{body}
</Text>
<Text size="T200" priority="300">
{sender} · {relDate}
</Text>
</Box>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Go to message"
onClick={() => {
const id = mEvent.getId();
if (id) jumpToMessage(id);
}}
>
<Icon size="200" src={Icons.Message} />
</IconButton>
<FileDownloadButton
filename={filename}
url={url}
mimeType={mimeType}
encInfo={c.file}
/>
</Box>
<AudioContent
mimeType={mimeType}
url={url}
info={c.info ?? {}}
encInfo={c.file}
waveform={waveform}
renderMediaControl={(p) => <MediaControl {...p} />}
/>
</Box>
);
})}
@@ -888,6 +1186,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
initialIndex={lightboxIndex}
useAuthentication={useAuthentication}
onClose={() => setLightboxIndex(null)}
onJump={jumpToMessage}
/>
)}
</>
+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>
+67 -3
View File
@@ -34,6 +34,13 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState<string[]>(['', '']);
const [isMultiple, setIsMultiple] = useState(false);
// For multiple-choice polls: the most options a voter may pick. Defaults high
// so an untouched multiple poll means "select all that apply" (the previous
// behavior); the effective value is clamped to the current option count.
const [maxSelections, setMaxSelections] = useState(10);
// Results visibility: disclosed (live results, default) vs undisclosed (hidden
// until the poll is ended).
const [disclosed, setDisclosed] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -73,14 +80,21 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
setError(null);
setSubmitting(true);
try {
// Text fallback for clients that don't understand polls: the question + a
// numbered list of the options.
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
'\n',
);
await mx.sendEvent(roomId, 'm.poll.start' as any, {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
max_selections: isMultiple ? filledOptions.length : 1,
kind: 'm.poll.undisclosed',
max_selections: isMultiple
? Math.min(Math.max(2, maxSelections), filledOptions.length)
: 1,
kind: disclosed ? 'm.poll.disclosed' : 'm.poll.undisclosed',
},
body: trimmedQuestion,
body: fallbackBody,
msgtype: 'm.text',
});
onClose();
@@ -214,6 +228,56 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
);
})}
</Box>
{isMultiple && (
<Box alignItems="Center" gap="200" style={{ marginTop: config.space.S200 }}>
<Text as="label" htmlFor="poll-max-select" size="T200" priority="400">
Voters can pick up to
</Text>
<Input
id="poll-max-select"
variant="Background"
size="300"
type="number"
min={2}
max={options.length}
value={Math.min(maxSelections, options.length)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxSelections(
Math.min(options.length, Math.max(2, parseInt(e.target.value, 10) || 2)),
)
}
style={{ width: '4rem' }}
/>
<Text size="T200" priority="300">
of {options.length} options
</Text>
</Box>
)}
</Box>
{/* Results visibility */}
<Box direction="Column" gap="100">
<Text size="L400">Results</Text>
<Box gap="200">
{(['live', 'hidden'] as const).map((mode) => {
const active = mode === 'live' ? disclosed : !disclosed;
return (
<Button
key={mode}
type="button"
size="300"
variant="Primary"
fill={active ? 'Solid' : 'None'}
radii="300"
onClick={() => setDisclosed(mode === 'live')}
>
<Text size="B300">
{mode === 'live' ? 'Show live results' : 'Hidden until ended'}
</Text>
</Button>
);
})}
</Box>
</Box>
{/* Error */}
+57 -2
View File
@@ -25,7 +25,9 @@ import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { threadsListAtom } from '../../state/threadsList';
import { ThreadPanel } from './thread';
import { ThreadsListPanel } from './thread/ThreadsListPanel';
export function Room() {
const { eventId } = useParams();
@@ -43,6 +45,8 @@ export function Room() {
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const threadsListOpen = useAtomValue(threadsListAtom);
const setThreadsListOpen = useSetAtom(threadsListAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
@@ -66,6 +70,17 @@ export function Room() {
),
);
// Stable handlers for the threads-list panel so its document-level Escape
// listener isn't torn down and re-added on every Room re-render.
const closeThreadsList = useCallback(() => setThreadsListOpen(false), [setThreadsListOpen]);
const openThreadFromList = useCallback(
(threadId: string) => {
setActiveThreadId(threadId);
setThreadsListOpen(false);
},
[setActiveThreadId, setThreadsListOpen],
);
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
// The content panels (thread / media gallery / widgets) are mutually exclusive
@@ -74,24 +89,43 @@ export function Room() {
const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
const prevThreadsListRef = useRef(threadsListOpen);
useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
const threadsListJustOpened = threadsListOpen && !prevThreadsListRef.current;
if (threadJustOpened) {
if (galleryOpen) setGalleryOpen(false);
if (widgetsOpen) setWidgetsOpen(false);
if (threadsListOpen) setThreadsListOpen(false);
} else if (galleryJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (widgetsOpen) setWidgetsOpen(false);
if (threadsListOpen) setThreadsListOpen(false);
} else if (widgetsJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
if (threadsListOpen) setThreadsListOpen(false);
} else if (threadsListJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
if (widgetsOpen) setWidgetsOpen(false);
}
prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen;
prevWidgetsRef.current = widgetsOpen;
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
prevThreadsListRef.current = threadsListOpen;
}, [
activeThreadId,
galleryOpen,
widgetsOpen,
threadsListOpen,
setGalleryOpen,
setActiveThreadId,
setWidgetsOpen,
setThreadsListOpen,
]);
// On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > widgets > members. On desktop thread + members may coexist
@@ -100,8 +134,16 @@ export function Room() {
const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
// The single-thread panel always replaces the list (they share the content slot).
const showThreadsList =
!callView &&
threadsListOpen &&
!activeThreadId &&
(isDesktop || (!galleryOpen && !widgetsOpen));
const showMembers =
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
!callView &&
isDrawer &&
(isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen));
return (
<PowerLevelsContextProvider value={powerLevels}>
@@ -151,6 +193,19 @@ export function Room() {
/>
</>
)}
{showThreadsList && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadsListPanel
key={room.roomId}
room={room}
onClose={closeThreadsList}
onOpenThread={openThreadFromList}
/>
</>
)}
{showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (
+127 -25
View File
@@ -151,9 +151,16 @@ interface RoomInputProps {
roomId: string;
room: Room;
threadRootId?: string;
// Identifies this composer to global key handlers (e.g. the up-arrow "edit last
// message" handler). Threads pass a distinct name so the main timeline's handler
// doesn't fire for the thread composer, and vice-versa.
editableName?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
(
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
ref,
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -223,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;
@@ -251,10 +264,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Location: ${geoUri}`,
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
} as any);
},
(err) => {
@@ -506,9 +527,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
// sends literally rather than being parsed as a command.
const commandName = threadRootId ? undefined : getBeginCommand(editor);
// Slash commands work in threads too: content-transform commands (/me,
// /notice, /shrug, /tableflip, /unflip) flow into the normal send below,
// which routes to the thread via `threadRootId`; the rest (/invite, /kick,
// …) are room-level actions. This also matches the command autocomplete,
// which is already shown in the thread composer.
const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
@@ -602,6 +626,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor);
resetEditorHistory(editor);
setCharCount(0);
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined);
sendTypingStatus(false);
@@ -614,6 +639,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
replyDraft,
sendTypingStatus,
setReplyDraft,
setMsgDraft,
isMarkdown,
commands,
setToast,
@@ -687,11 +713,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
resetEditor(editor);
resetEditorHistory(editor);
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`);
setReplyDraft(undefined);
sendTypingStatus(false);
},
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
[
setScheduledMessages,
roomId,
draftKey,
editor,
setReplyDraft,
setMsgDraft,
sendTypingStatus,
],
);
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -847,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 && (
@@ -962,7 +1003,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
)}
<ScheduledMessagesTray roomId={roomId} />
<CustomEditor
editableName="RoomInput"
editableName={editableName}
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}
@@ -1006,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 ? (
@@ -1277,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"
@@ -1336,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)} />}
+3 -17
View File
@@ -1314,13 +1314,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent.getType() === 'm.poll.start' ||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
)
return (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
);
return <PollContent mEvent={mEvent} room={room} canRedact={canRedact} />;
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
@@ -1449,11 +1443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
)}
</Message>
);
@@ -1506,11 +1496,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
)}
</Message>
);
+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>
+25
View File
@@ -75,6 +75,7 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -491,6 +492,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const [threadsListOpen, setThreadsListOpen] = useAtom(threadsListAtom);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
@@ -704,6 +706,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{threadsListOpen ? 'Hide Threads' : 'Threads'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setThreadsListOpen(!threadsListOpen)}
aria-label="Toggle threads"
aria-pressed={threadsListOpen}
>
<Icon size="400" src={Icons.Thread} filled={threadsListOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+29 -55
View File
@@ -21,11 +21,24 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { scheduleMessage } from '../../utils/scheduledMessages';
import { useModalStyle } from '../../hooks/useModalStyle';
import {
toLocalDate,
toLocalTime,
parseLocalDateTime,
pickerInputStyle,
formatFriendlyDateTime,
} from '../../utils/datetimeInput';
interface ScheduleMessageModalProps {
roomId: string;
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
initialBody?: string;
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
initialSendAt?: number;
/** Header title; defaults to "Schedule Message". */
title?: string;
/** Primary-button label; defaults to "Schedule" (e.g. "Reschedule" when editing). */
submitLabel?: string;
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
onClose: () => void;
}
@@ -40,54 +53,12 @@ function formatRelativeTime(ms: number): string {
return 'in less than a minute';
}
function formatSendAt(sendAt: Date): string {
const now = new Date();
const isToday =
sendAt.getFullYear() === now.getFullYear() &&
sendAt.getMonth() === now.getMonth() &&
sendAt.getDate() === now.getDate();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow =
sendAt.getFullYear() === tomorrow.getFullYear() &&
sendAt.getMonth() === tomorrow.getMonth() &&
sendAt.getDate() === tomorrow.getDate();
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${timeStr}`;
if (isTomorrow) return `Tomorrow at ${timeStr}`;
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
}
function toLocalDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function toLocalTime(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
// Shared style for date/time inputs — dark-mode calendar/clock popup via colorScheme.
const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSProperties => ({
background: c.SurfaceVariant.Container,
color: c.SurfaceVariant.OnContainer,
border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`,
borderRadius: cfg.radii.R300,
padding: `${cfg.space.S200} ${cfg.space.S300}`,
fontSize: '0.875rem',
width: '100%',
boxSizing: 'border-box',
outline: 'none',
fontFamily: 'inherit',
// Hint browser to render the calendar/clock popup in dark mode
colorScheme: 'dark',
});
export function ScheduleMessageModal({
roomId,
initialBody,
initialSendAt,
title = 'Schedule Message',
submitLabel = 'Schedule',
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
@@ -105,15 +76,15 @@ export function ScheduleMessageModal({
return d;
};
const def = defaultDate();
// When editing, seed the pickers from the existing send-time; else default to +1h.
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
const getSendAt = useCallback((): Date | null => {
if (!dateValue || !timeValue) return null;
const dt = new Date(`${dateValue}T${timeValue}:00`);
return Number.isNaN(dt.getTime()) ? null : dt;
}, [dateValue, timeValue]);
const getSendAt = useCallback(
(): Date | null => parseLocalDateTime(dateValue, timeValue),
[dateValue, timeValue],
);
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
@@ -128,7 +99,10 @@ export function ScheduleMessageModal({
setPreview(null);
return;
}
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
setPreview({
label: formatFriendlyDateTime(sendAt.getTime()),
relative: formatRelativeTime(diffMs),
});
}, [getSendAt]);
useEffect(() => {
@@ -172,7 +146,7 @@ export function ScheduleMessageModal({
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
initialFocus: '#schedule-message-body',
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
@@ -199,7 +173,7 @@ export function ScheduleMessageModal({
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" />
<Text id="schedule-message-title" size="H4">
Schedule Message
{title}
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
@@ -334,7 +308,7 @@ export function ScheduleMessageModal({
disabled={submitting || !preview}
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
>
<Text size="B400">Schedule</Text>
<Text size="B400">{submitLabel}</Text>
</Button>
</Box>
</Dialog>
+247 -79
View File
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtom } from 'jotai';
import { IContent } from 'matrix-js-sdk';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
import { ScheduleMessageModal } from './ScheduleMessageModal';
interface ScheduledMessagesTrayProps {
roomId: string;
@@ -34,6 +36,8 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const [expanded, setExpanded] = useState(false);
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
const [sendErrors, setSendErrors] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<ScheduledMessage | null>(null);
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
@@ -106,92 +110,256 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
[mx, roomId, cancelling, setScheduledMessages],
);
if (messages.length === 0) return null;
const handleSendNow = useCallback(
async (msg: ScheduledMessage) => {
if (cancelling.has(msg.delayId)) return;
setCancelling((prev) => new Set(prev).add(msg.delayId));
setSendErrors((prev) => {
if (!prev.has(msg.delayId)) return prev;
const next = new Set(prev);
next.delete(msg.delayId);
return next;
});
try {
await sendScheduledMessageNow(mx, msg.delayId);
// Only prune once the server confirms the send. The delayed event is now
// consumed and appears in the timeline; dropping it before confirmation
// could hide a still-live event that never actually sent.
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = next.get(roomId) ?? [];
const remaining = current.filter((m) => m.delayId !== msg.delayId);
if (remaining.length === 0) {
next.delete(roomId);
} else {
next.set(roomId, remaining);
}
return next;
});
} catch {
// Keep the item (still sendable/editable/cancellable) and surface an
// inline error; the delayed event is still scheduled on the server.
setSendErrors((prev) => new Set(prev).add(msg.delayId));
} finally {
setCancelling((prev) => {
const next = new Set(prev);
next.delete(msg.delayId);
return next;
});
}
},
[mx, roomId, cancelling, setScheduledMessages],
);
// Editing = cancel-old + schedule-new (MSC4140 has no in-place edit). The modal has
// already scheduled the NEW message by the time this fires; add it, then cancel the
// old one — removing the old from state only once the server confirms, so a failed
// cancel leaves it visible (and retriable) instead of letting it silently fire.
const handleEdit = useCallback(
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
// Add the newly-scheduled message up front (nothing lost yet).
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
return next;
});
// Mark the old message as cancelling so its row's Edit/Cancel buttons are
// disabled while we tear it down. Without this the old row stays live during
// the in-flight cancel and a second edit could orphan a still-scheduled event
// (both would fire). Also clear any stale error from a prior failed cancel.
setCancelling((prev) => new Set(prev).add(oldMsg.delayId));
setCancelErrors((prev) => {
if (!prev.has(oldMsg.delayId)) return prev;
const next = new Set(prev);
next.delete(oldMsg.delayId);
return next;
});
setSendErrors((prev) => {
if (!prev.has(oldMsg.delayId)) return prev;
const next = new Set(prev);
next.delete(oldMsg.delayId);
return next;
});
setEditing(null);
cancelScheduledMessage(mx, oldMsg.delayId)
.then(() => {
setScheduledMessages((prev) => {
const next = new Map(prev);
const remaining = (next.get(roomId) ?? []).filter((m) => m.delayId !== oldMsg.delayId);
if (remaining.length === 0) next.delete(roomId);
else next.set(roomId, remaining);
return next;
});
})
.catch(() => {
// Cancel failed — the old delayed event is still live server-side, so it
// must stay visible and retriable. Re-insert it if auto-prune removed the
// row while the modal was open, otherwise the failure (and the duplicate
// it will send) would be invisible.
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = next.get(roomId) ?? [];
if (!current.some((m) => m.delayId === oldMsg.delayId)) {
next.set(roomId, [...current, oldMsg]);
}
return next;
});
setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId));
})
.finally(() => {
setCancelling((prev) => {
const next = new Set(prev);
next.delete(oldMsg.delayId);
return next;
});
});
},
[mx, roomId, setScheduledMessages],
);
if (messages.length === 0 && !editing) return null;
return (
<Box
direction="Column"
style={{
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
{/* Tray header */}
<Button
variant="Secondary"
fill="None"
radii="0"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
before={<Icon src={Icons.Clock} size="50" />}
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
<>
{editing && (
<ScheduleMessageModal
roomId={roomId}
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
initialSendAt={editing.sendAt}
title="Edit scheduled message"
submitLabel="Reschedule"
onScheduled={(newDelayId, sendAt, content) =>
handleEdit(editing, newDelayId, sendAt, content)
}
onClose={() => setEditing(null)}
/>
)}
<Box
direction="Column"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
justifyContent: 'flex-start',
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
</Text>
</Button>
{/* Tray header */}
<Button
variant="Secondary"
fill="None"
radii="0"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
before={<Icon src={Icons.Clock} size="50" />}
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
style={{
padding: `${config.space.S100} ${config.space.S300}`,
justifyContent: 'flex-start',
}}
>
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
</Text>
</Button>
{/* Tray items */}
{expanded && (
<Box direction="Column">
{messages.map((msg) => (
<Box
key={msg.delayId}
direction="Column"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Box alignItems="Center" gap="200">
<Text
size="T200"
priority="400"
{/* Tray items */}
{expanded && (
<Box direction="Column">
{messages.map((msg) => {
const bodyPreview =
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
return (
<Box
key={msg.delayId}
direction="Column"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: `${config.space.S100} ${config.space.S300}`,
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
{typeof msg.content.body === 'string'
? (msg.content.body as string)
: '(message)'}
</Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Cancel scheduled message"
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleCancel(msg);
}}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
</Box>
{cancelErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not cancel this message. Try again.
</Text>
)}
</Box>
))}
</Box>
)}
</Box>
<Box alignItems="Center" gap="200">
<Text
size="T200"
priority="400"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{bodyPreview}
</Text>
<Text
size="T200"
priority="300"
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Send scheduled message now: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleSendNow(msg);
}}
>
<Icon src={Icons.Send} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Edit scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
setEditing(msg);
}}
>
<Icon src={Icons.Pencil} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Cancel scheduled message: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleCancel(msg);
}}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
</Box>
{cancelErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not cancel this message. Try again.
</Text>
)}
{sendErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not send now. Try again.
</Text>
)}
</Box>
);
})}
</Box>
)}
</Box>
</>
);
}
@@ -234,7 +234,9 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
</Box>
{timestampState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{timestampState.error.message}
{timestampState.error.errcode === 'M_UNRECOGNIZED'
? "Your homeserver doesn't support jumping to a date or time (MSC3030)."
: timestampState.error.message}
</Text>
)}
<Button
@@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import parse from 'html-react-parser';
import Linkify from 'linkify-react';
import FocusTrap from 'focus-trap-react';
@@ -15,7 +15,9 @@ import {
OverlayCenter,
Scroll,
Spinner,
Switch,
Text,
color,
config,
} from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
@@ -28,6 +30,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { diffWords } from '../../../utils/textDiff';
type RawEditEvent = {
type: string;
@@ -87,11 +90,76 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
return renderContent(newContent ?? content);
}
const asText = (source: Record<string, unknown> | null | undefined): string => {
const body = source?.body;
return typeof body === 'string' ? body : '';
};
// Plain-text (body) of the pre-edit message — mirrors getOriginalContent, but
// returns the raw string for diffing rather than a rendered node.
function getOriginalText(evt: MatrixEvent): string {
const raw =
(evt.getClearContent() as Record<string, unknown> | null) ??
(evt.event as { content?: Record<string, unknown> }).content ??
{};
return asText(raw);
}
// Plain-text (body) of an edit's new content.
function getVersionText(evt: MatrixEvent): string {
const content = evt.getContent();
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
return asText(newContent ?? content);
}
// Renders a word-level diff of prev -> next: added words highlighted, removed
// words struck-through, using semantic <ins>/<del> elements. Plain-text only
// (formatting isn't diffed — see the "Highlight changes" toggle).
function DiffText({ prev, next }: { prev: string; next: string }) {
const segments = useMemo(() => diffWords(prev, next), [prev, next]);
if (segments.length === 0) return <>(no text)</>;
return (
<>
{segments.map((seg, i) => {
const key = `${i}-${seg.type}`;
if (seg.type === 'added') {
return (
<ins
key={key}
style={{
background: color.Success.Container,
color: color.Success.OnContainer,
border: `${config.borderWidth.B300} solid ${color.Success.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `0 ${config.space.S100}`,
textDecoration: 'none',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
}}
>
{seg.text}
</ins>
);
}
if (seg.type === 'removed') {
return (
<del key={key} style={{ color: color.Critical.Main }}>
{seg.text}
</del>
);
}
return <span key={key}>{seg.text}</span>;
})}
</>
);
}
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(560);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [showDiff, setShowDiff] = useState(true);
const eventId = mEvent.getId();
const roomId = room.roomId;
@@ -171,6 +239,10 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
const originalTs = mEvent.getTs();
// Ordered plain-text of every version: [original, ...each edit]. Edit i diffs
// against versionTexts[i] (the version immediately before it).
const versionTexts = [getOriginalText(mEvent), ...edits.map(getVersionText)];
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -200,6 +272,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
Edit History
</Text>
</Box>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Text size="T200" priority="300">
Highlight changes
</Text>
<Switch variant="Primary" value={showDiff} onChange={setShowDiff} />
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
@@ -258,7 +336,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
size="T300"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{getVersionContent(editEvt)}
{showDiff ? (
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
) : (
getVersionContent(editEvt)
)}
</Text>
</Box>
))}
@@ -5,6 +5,7 @@ import {
Box,
Button,
Checkbox,
Chip,
color,
config,
Header,
@@ -21,9 +22,11 @@ import {
Scroll,
Spinner,
Text,
toRem,
} from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { useAtomValue, useAtom } from 'jotai';
import { IThumbnailContent } from '../../../../types/matrix/common';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
import { useModalStyle } from '../../../hooks/useModalStyle';
@@ -31,6 +34,14 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UserAvatar } from '../../../components/user-avatar';
import { ThumbnailContent } from '../../../components/message/content/ThumbnailContent';
import { getMemberAvatarMxc, getMemberName, trimReplyFromBody } from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import {
recentForwardTargetsAtom,
addRecentForwardTarget,
} from '../../../state/recentForwardTargets';
import { buildForwardContent } from './forwardContent';
type RoomRowProps = {
@@ -93,6 +104,143 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
);
}
// Compact, read-only preview of the message being forwarded — sender + body,
// plus a small thumbnail for image/video. We already hold mEvent, so no fetch.
function ForwardPreview({
mEvent,
useAuthentication,
}: {
mEvent: MatrixEvent;
useAuthentication: boolean;
}) {
const mx = useMatrixClient();
const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined;
const senderId = mEvent.getSender() ?? '';
const senderName = room
? getMemberName(room, senderId)
: senderId.split(':')[0]?.slice(1) || senderId;
const senderMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
const senderAvatarUrl = senderMxc
? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
const content = mEvent.getContent();
const msgtype = content.msgtype;
const isMedia =
msgtype === MsgType.Image ||
msgtype === MsgType.Video ||
msgtype === MsgType.File ||
msgtype === MsgType.Audio;
const bodyStr = typeof content.body === 'string' ? content.body : '';
const label = isMedia
? ((content.filename as string | undefined) ?? bodyStr) || '(media)'
: trimReplyFromBody(bodyStr) || '(message)';
const info = content.info as IThumbnailContent | undefined;
const showThumb =
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
!!info &&
(!!info.thumbnail_url || !!info.thumbnail_file);
return (
<Box
shrink="No"
gap="200"
alignItems="Center"
role="group"
aria-label="Message to forward"
style={{
margin: `${config.space.S200} ${config.space.S400} 0`,
padding: config.space.S200,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Avatar size="200" radii="300">
<UserAvatar
userId={senderId}
src={senderAvatarUrl}
alt={senderName}
renderFallback={() => <Text size="H6">{nameInitials(senderName)}</Text>}
/>
</Avatar>
{showThumb && info && (
<ThumbnailContent
info={info}
renderImage={(src) => (
<img
src={src}
alt=""
style={{
width: toRem(40),
height: toRem(40),
borderRadius: config.radii.R300,
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
/>
)}
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{senderName}
</Text>
<Text size="T200" priority="300" truncate>
{label}
</Text>
</Box>
</Box>
);
}
// A compact selectable chip for a recently-forwarded-to room.
function RecentChip({
room,
useAuthentication,
selected,
onToggle,
sending,
}: {
room: Room;
useAuthentication: boolean;
selected: boolean;
onToggle: () => void;
sending: boolean;
}) {
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
return (
<Chip
variant={selected ? 'Primary' : 'SurfaceVariant'}
fill="Soft"
radii="Pill"
disabled={sending}
onClick={onToggle}
aria-pressed={selected}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
)}
/>
</Avatar>
}
>
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name}
</Text>
</Chip>
);
}
type Props = {
mEvent: MatrixEvent;
onClose: () => void;
@@ -105,9 +253,16 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const useAuthentication = useMediaAuthentication();
const searchInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [comment, setComment] = useState('');
const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [recents, setRecents] = useAtom(recentForwardTargetsAtom);
// Rooms whose comment message already delivered this session — so a retry after
// a forward failure doesn't re-post the comment (only the missing forward). Kept
// for the dialog's lifetime: a room that already got the comment won't get it
// again even if the text is later edited, which is the safe (no-duplicate) choice.
const commentSentRef = useRef<Set<string>>(new Set());
// Selection persists across query changes: a room selected then filtered out
// of the rendered slice stays selected.
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
@@ -139,6 +294,15 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [allRooms, query]);
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
const recentRooms = useMemo(
() =>
recents
.map((id) => mx.getRoom(id))
.filter((r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom()),
[recents, mx],
);
const sendToSelected = useCallback(async () => {
if (sending || selectedRoomIds.size === 0) return;
const fwdContent = buildForwardContent(mx, mEvent);
@@ -150,20 +314,42 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
setError(null);
const ids = [...selectedRoomIds];
const commentBody = comment.trim();
const results = await Promise.allSettled(
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
ids.map((id) => {
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
// Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects.
// Track rooms whose comment already landed so a retry (after the FORWARD
// failed) doesn't post the comment twice — only the missing forward.
const needsComment = commentBody && !commentSentRef.current.has(id);
const step = needsComment
? mx
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
.then(() => {
commentSentRef.current.add(id);
})
: Promise.resolve();
return step.then(sendForward);
}),
);
const failedIds: string[] = [];
const failedNames: string[] = [];
const succeededIds: string[] = [];
results.forEach((result, i) => {
if (result.status === 'rejected') {
failedIds.push(ids[i]);
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
} else {
succeededIds.push(ids[i]);
}
});
// Remember successful targets (most-recent first) for the Recent row.
succeededIds.forEach((id) => setRecents((prev) => addRecentForwardTarget(prev, id)));
const total = ids.length;
const failed = failedNames.length;
@@ -184,7 +370,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
return;
}
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -221,10 +407,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{!sentTo && <ForwardPreview mEvent={mEvent} useAuthentication={useAuthentication} />}
{!sentTo && (
<Box
shrink="No"
direction="Column"
gap="200"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
>
<Input
@@ -233,15 +421,24 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
size="400"
radii="400"
outlined
aria-label="Search rooms"
placeholder="Search rooms…"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
<Input
variant="Background"
size="400"
radii="400"
outlined
aria-label="Add a comment"
placeholder="Add a comment (optional)…"
value={comment}
disabled={sending}
onChange={(e: ChangeEvent<HTMLInputElement>) => setComment(e.target.value)}
/>
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
@@ -262,7 +459,46 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<>
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
<Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S200, opacity: sending ? 0.5 : 1 }}
>
{/* Recent targets — quick access, hidden while searching */}
{!query && recentRooms.length > 0 && (
<Box
direction="Column"
gap="100"
style={{ paddingBottom: config.space.S100 }}
>
<Text
size="L400"
priority="300"
style={{ padding: `0 ${config.space.S200}` }}
>
Recent
</Text>
<Box
gap="100"
wrap="Wrap"
role="group"
aria-label="Recent forward targets"
style={{ padding: `0 ${config.space.S200}` }}
>
{recentRooms.map((room) => (
<RecentChip
key={room.roomId}
room={room}
useAuthentication={useAuthentication}
selected={selectedRoomIds.has(room.roomId)}
onToggle={() => toggleRoom(room.roomId)}
sending={sending}
/>
))}
</Box>
<Line size="300" style={{ marginTop: config.space.S100 }} />
</Box>
)}
{filtered.slice(0, 60).map((room) => (
<RoomRow
key={room.roomId}
@@ -291,12 +527,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Box
alignItems="Center"
justifyContent="Center"
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.35)',
borderRadius: config.radii.R500,
}}
style={{ position: 'absolute', inset: 0 }}
>
<Spinner variant="Secondary" size="400" />
</Box>
+102 -7
View File
@@ -38,6 +38,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom } from 'jotai';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
@@ -50,16 +51,21 @@ import {
UsernameBold,
} from '../../../components/message';
import {
canEditEvent,
canEditCaption,
canEditEventOrCaption,
getEventEdits,
getMemberAvatarMxc,
getMemberName,
sendStateEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
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';
@@ -255,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);
@@ -273,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>
@@ -410,6 +417,88 @@ export const MessageCopyLinkItem = as<
);
});
// Copies the message's plain-text body (reply fallback stripped) to the
// clipboard. Renders nothing for events without a usable text body (e.g. media
// without a caption), so the caller can list it unconditionally.
export const MessageCopyTextItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const content = mEvent.getContent();
// Text-only: for media the `body` is the filename (or caption). We don't want
// "Copy Text" to copy a filename, so gate to textual message types.
const msgtype = content.msgtype;
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
if (!isTextual || !body) return null;
const handleCopy = () => {
copyToClipboard(body);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Text} />}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Copy Text
</Text>
</MenuItem>
);
});
export const MessageTranslateItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const content = mEvent.getContent();
const msgtype = content.msgtype;
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
const eventId = mEvent.getId() ?? '';
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
// On-device translation is Chromium-desktop only; hide the action entirely
// where the engine can't run, and for non-textual/empty messages.
if (!chromeTranslationEngine.isSupported() || !isTextual || !body || !eventId) return null;
const handleToggle = () => {
setActive((a) => !a);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Globe} />}
radii="300"
onClick={handleToggle}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{active ? 'Show Original' : 'Translate'}
</Text>
</MenuItem>
);
});
export const MessagePinItem = as<
'button',
{
@@ -587,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) =>
@@ -628,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}`,
@@ -1072,13 +1162,13 @@ export const Message = React.memo(
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEvent(mx, mEvent) && onEditId && (
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Edit message"
aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
@@ -1189,6 +1279,7 @@ export const Message = React.memo(
savedAt: Date.now(),
previewText: body.slice(0, 120),
roomName: room.name,
senderName: senderDisplayName,
});
}
closeMenu();
@@ -1247,7 +1338,7 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{canEditEvent(mx, mEvent) && onEditId && (
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
@@ -1264,7 +1355,7 @@ export const Message = React.memo(
size="T300"
truncate
>
Edit Message
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
</Text>
</MenuItem>
)}
@@ -1282,6 +1373,8 @@ export const Message = React.memo(
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
@@ -1511,6 +1604,8 @@ export const Event = React.memo(
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
+106 -8
View File
@@ -21,7 +21,7 @@ import {
} from 'folds';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import {
AUTOCOMPLETE_PREFIXES,
@@ -75,6 +75,15 @@ export const MessageEditor = as<'div', MessageEditorProps>(
// message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender();
const editSenderName = editSenderId ? getMemberName(room, editSenderId) : '';
// Image/video messages carry an optional caption in `body` (a caption exists
// when body !== filename). Editing such a message edits the caption while
// preserving the media, rather than replacing the content with text.
const editMsgType = mEvent.getContent().msgtype;
const isMediaCaption = editMsgType === MsgType.Image || editMsgType === MsgType.Video;
const editFilename =
typeof mEvent.getContent().filename === 'string'
? (mEvent.getContent().filename as string)
: undefined;
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -118,6 +127,70 @@ export const MessageEditor = as<'div', MessageEditorProps>(
}),
);
// Media caption edit: preserve the media, change only body/formatted_body.
// An empty caption is valid (it removes the caption → body falls back to
// the filename).
if (isMediaCaption) {
const evtId = mEvent.getId()!;
const evtTimeline = room.getTimelineForEvent(evtId);
const editedEvent =
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
const orig: IContent = {
...(editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()),
};
delete orig['m.relates_to'];
delete orig['m.new_content'];
const filename = typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText);
const mediaContent: IContent = { ...orig };
if (plainText) {
mediaContent.body = plainText;
if (hasFormatting) {
mediaContent.format = 'org.matrix.custom.html';
mediaContent.formatted_body = customHtml;
} else {
delete mediaContent.format;
delete mediaContent.formatted_body;
}
} else {
// Caption removed.
mediaContent.body = filename;
delete mediaContent.format;
delete mediaContent.formatted_body;
}
// No-op guard: caption text/markup unchanged.
if (
mediaContent.body === orig.body &&
mediaContent.formatted_body === orig.formatted_body
) {
return undefined;
}
// Carry mentions typed into the caption (union with prior mentions), so
// an @-mention in a caption edit notifies — mirrors the text path.
const [, , prevMentions] = getPrevBodyAndFormattedBody();
const mentionData = getMentions(mx, roomId, editor);
prevMentions?.user_ids?.forEach((id) => mentionData.users.add(id));
mediaContent['m.mentions'] = getMentionContent(
Array.from(mentionData.users),
mentionData.room,
);
const content: IContent = {
...mediaContent,
'm.new_content': mediaContent,
'm.relates_to': {
event_id: evtId,
rel_type: RelationType.Replace,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
}
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
if (plainText === '') return undefined;
@@ -165,7 +238,17 @@ export const MessageEditor = as<'div', MessageEditorProps>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]),
}, [
mx,
editor,
roomId,
room,
mEvent,
isMarkdown,
getPrevBodyAndFormattedBody,
isMediaCaption,
editFilename,
]),
);
const handleSave = useCallback(() => {
@@ -220,10 +303,19 @@ export const MessageEditor = as<'div', MessageEditorProps>(
useEffect(() => {
const [body, customHtml] = getPrevBodyAndFormattedBody();
// For media, seed from the caption only: an empty caption is `body ===
// filename`, so don't prefill the filename into the editor.
let seedText = typeof body === 'string' ? body : '';
let seedHtml = typeof customHtml === 'string' ? customHtml : undefined;
if (isMediaCaption && seedText === editFilename) {
seedText = '';
seedHtml = undefined;
}
const initialValue =
typeof customHtml === 'string'
? htmlToEditorInput(customHtml, isMarkdown)
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
seedHtml !== undefined
? htmlToEditorInput(seedHtml, isMarkdown)
: plainToEditorInput(seedText, isMarkdown);
Transforms.select(editor, {
anchor: Editor.start(editor, []),
@@ -232,7 +324,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown, isMediaCaption, editFilename]);
useEffect(() => {
if (saveState.status === AsyncStatus.Success) {
@@ -268,8 +360,14 @@ export const MessageEditor = as<'div', MessageEditorProps>(
)}
<CustomEditor
editor={editor}
placeholder="Edit message..."
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
placeholder={isMediaCaption ? 'Add a caption…' : 'Edit message...'}
ariaLabel={
isMediaCaption
? `Editing caption for ${editSenderName}'s attachment`
: editSenderId
? `Editing message from ${editSenderName}`
: 'Edit message'
}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
bottom={
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
@@ -19,6 +19,13 @@ import {
import { stopPropagation } from '../../../utils/keyboard';
import { useReminders } from '../../../hooks/useReminders';
import { useModalStyle } from '../../../hooks/useModalStyle';
import {
toLocalDate,
toLocalTime,
parseLocalDateTime,
pickerInputStyle,
formatFriendlyDateTime,
} from '../../../utils/datetimeInput';
type RemindMeDialogProps = {
roomId: string;
@@ -40,14 +47,48 @@ function getPresets(): Array<{ label: string; ms: number }> {
];
}
// Default custom pick: 1 hour from now, rounded up to the nearest 5 minutes.
function defaultCustomDate(): Date {
const d = new Date(Date.now() + 60 * 60 * 1000);
d.setSeconds(0, 0);
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
return d;
}
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
const modalStyle = useModalStyle(320);
const { addReminder } = useReminders();
const { addReminder, removeReminder, reminders } = useReminders();
const presets = useMemo(() => getPresets(), []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const def = useMemo(() => defaultCustomDate(), []);
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
const [timeValue, setTimeValue] = useState(() => toLocalTime(def));
const dateInputRef = useRef<HTMLInputElement>(null);
const handlePick = async (ms: number) => {
// Reminders already set on this message (soonest first) — so the user can see
// and cancel them instead of silently stacking duplicates.
const existing = useMemo(
() => reminders.filter((r) => r.eventId === eventId).sort((a, b) => a.timestamp - b.timestamp),
[reminders, eventId],
);
// Move focus into the revealed date input for keyboard/SR users.
useEffect(() => {
if (customOpen) dateInputRef.current?.focus();
}, [customOpen]);
const handleCancelExisting = (timestamp: number) => {
// Optimistic, matching removeBookmark: the shared account-data store drops
// the reminder locally at once (no rollback) and re-syncs from the server.
// We deliberately show no inline error — the store has no rollback path, so a
// failed write simply reappears on the next sync rather than leaving a stale
// "couldn't cancel" message beside an already-vanished row.
removeReminder(eventId, timestamp).catch(() => undefined);
};
const commit = async (timestamp: number) => {
if (busy) return;
setBusy(true);
setError(null);
@@ -55,7 +96,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
await addReminder({
roomId,
eventId,
timestamp: Date.now() + ms,
timestamp,
message: previewText || 'Reminder',
});
onClose();
@@ -65,6 +106,23 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
}
};
const handlePick = (ms: number) => commit(Date.now() + ms);
const customDate = parseLocalDateTime(dateValue, timeValue);
const customValid = !!customDate && customDate.getTime() - Date.now() >= 60_000;
const handleCustom = () => {
if (!customDate) {
setError('Please select a valid date and time.');
return;
}
if (customDate.getTime() - Date.now() < 60_000) {
setError('Reminder time must be at least 1 minute in the future.');
return;
}
commit(customDate.getTime());
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -111,6 +169,40 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
<Line size="300" />
</>
)}
{existing.length > 0 && (
<>
<Box
direction="Column"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
>
<Text size="L400" priority="300" style={{ paddingLeft: config.space.S200 }}>
{existing.length === 1 ? 'Reminder set' : 'Reminders set'}
</Text>
{existing.map((r, idx) => (
// Composite key: two custom reminders on one message can share
// a minute-precision timestamp; index keeps React keys unique.
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
{formatFriendlyDateTime(r.timestamp)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
fill="None"
onClick={() => handleCancelExisting(r.timestamp)}
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Box>
))}
</Box>
<Line size="300" style={{ marginTop: config.space.S200 }} />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{presets.map((p) => (
<Button
@@ -127,6 +219,77 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
</Text>
</Button>
))}
{customOpen ? (
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
<Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="remind-date" size="T200" priority="400">
Date
</Text>
<input
ref={dateInputRef}
id="remind-date"
type="date"
value={dateValue}
min={toLocalDate(new Date())}
disabled={busy}
onChange={(e) => {
setDateValue(e.target.value);
setError(null);
}}
style={pickerInputStyle(color, config)}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="remind-time" size="T200" priority="400">
Time
</Text>
<input
id="remind-time"
type="time"
value={timeValue}
disabled={busy}
onChange={(e) => {
setTimeValue(e.target.value);
setError(null);
}}
style={pickerInputStyle(color, config)}
/>
</Box>
</Box>
{!customValid && (dateValue || timeValue) && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future
</Text>
)}
<Button
size="300"
variant="Primary"
radii="300"
disabled={busy || !customValid}
onClick={handleCustom}
>
<Text size="B300">Set reminder</Text>
</Button>
</Box>
) : (
<Button
size="300"
variant="Secondary"
fill="None"
radii="300"
disabled={busy}
onClick={() => {
setError(null);
setCustomOpen(true);
}}
before={<Icon src={Icons.Clock} size="100" />}
>
<Text size="B300">Custom time</Text>
</Button>
)}
{error && (
<Text
size="T200"
@@ -196,6 +196,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
editableName="ThreadInput"
fileDropContainerRef={fileDropContainerRef}
/>
</Box>
@@ -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
@@ -32,11 +32,12 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { isKeyHotkey } from 'is-hotkey';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
import { scrollToBottom } from '../../../utils/dom';
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
import {
DefaultPlaceholder,
MessageBase,
@@ -55,9 +56,11 @@ import {
renderMatrixMention,
} from '../../../plugins/react-custom-html-parser';
import {
canEditEvent,
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
getMemberName,
getReactionContent,
reactionOrEditEvent,
@@ -76,7 +79,8 @@ import {
today,
yesterday,
} from '../../../utils/time';
import { createMentionElement, moveCursor } from '../../../components/editor';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
@@ -580,6 +584,32 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
[editor],
);
// Up-arrow in the (empty) thread composer edits your last editable reply in
// this thread — parity with the main timeline. Gated on the thread composer's
// distinct editableName so it and the main handler never cross-fire.
useKeyDown(
window,
useCallback(
(evt) => {
if (
isKeyHotkey('arrowup', evt) &&
editableActiveElement() &&
document.activeElement?.getAttribute('data-editable-name') === 'ThreadInput' &&
isEmptyEditor(editor)
) {
const editableEvt = getLatestEditableEvt(thread.liveTimeline, (mEvt) =>
canEditEvent(mx, mEvt),
);
const editableEvtId = editableEvt?.getId();
if (!editableEvtId) return;
setEditId(editableEvtId);
evt.preventDefault();
}
},
[mx, thread, editor],
),
);
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
@@ -0,0 +1,44 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const ThreadsListPanel = style({
width: toRem(340),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const ThreadsListHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const ThreadsListToolbar = style({
flexShrink: 0,
padding: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
});
export const ThreadsListContent = style({
padding: config.space.S200,
});
export const ThreadRow = style({
width: '100%',
textAlign: 'left',
cursor: 'pointer',
border: 'none',
borderRadius: config.radii.R300,
padding: config.space.S300,
background: color.SurfaceVariant.Container,
selectors: {
'&:hover': { background: color.SurfaceVariant.ContainerHover },
'&:active': { background: color.SurfaceVariant.ContainerActive },
},
});
@@ -0,0 +1,382 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
import * as css from './ThreadsListPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { VirtualTile } from '../../../components/virtualizer';
import { UserAvatar } from '../../../components/user-avatar';
import { StackedAvatar } from '../../../components/stacked-avatar';
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
import { trimReplyFromBody } from '../../../utils/room';
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
import { threadNotificationsAtom } from '../../../state/threadNotifications';
import { getMutedThreads } from '../../../utils/threadNotifications';
import { nameInitials } from '../../../utils/common';
import {
ThreadFilter,
ThreadSort,
ThreadSnapshot,
filterThreads,
sortThreads,
isThreadFilter,
isThreadSort,
} from '../../../utils/threadList';
import { useRoomThreads } from '../../../hooks/useRoomThreads';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
const threadFilterAtom = atomWithStorage<ThreadFilter>(
'cinny_threads_filter_v1',
'all',
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
const threadSortAtom = atomWithStorage<ThreadSort>(
'cinny_threads_sort_v1',
'recent',
createJSONStorage(() => localStorage),
{ getOnInit: true },
);
const FILTER_OPTIONS: { value: ThreadFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'participating', label: 'Participating' },
];
const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [
{ value: 'recent', label: 'Recent' },
{ value: 'oldest', label: 'Oldest' },
];
const MAX_PARTICIPANTS = 5;
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'yesterday';
if (days < 7) return `${days}d ago`;
return new Date(ts).toLocaleDateString();
}
// Segmented button, mirroring the Bookmarks panel sort control for consistency.
function SegButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
aria-pressed={active}
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
}
function ParticipantAvatar({ room, userId }: { room: Room; userId: string }) {
const { name, avatarUrl } = useMemberAvatar(room, userId);
return (
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
}
type ThreadRowProps = {
room: Room;
thread: Thread;
unread: number;
highlight: number;
participants: string[];
onOpen: (threadId: string) => void;
};
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? '';
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
const bodyRaw =
typeof rootEvent?.getContent().body === 'string' ? (rootEvent.getContent().body as string) : '';
const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)';
const count = thread.length;
const lastTs = thread.replyToEvent?.getTs() ?? rootEvent?.getTs();
const extra = participants.length - MAX_PARTICIPANTS;
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : ''
}`;
return (
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<UserAvatar
userId={rootSender}
src={avatarUrl}
alt={rootName}
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
/>
</Avatar>
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
{rootName}
</Text>
{unread > 0 && (
<UnreadBadgeCenter>
<UnreadBadge highlight={highlight > 0} count={unread} />
</UnreadBadgeCenter>
)}
</Box>
<Text
size="T200"
priority="400"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
>
{snippet}
</Text>
<Box alignItems="Center" gap="200">
<Icon size="50" src={Icons.Thread} />
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
{count} {count === 1 ? 'reply' : 'replies'}
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
</Text>
<Box shrink="No" alignItems="Center">
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
<ParticipantAvatar key={userId} room={room} userId={userId} />
))}
{extra > 0 && (
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
+{extra}
</Text>
)}
</Box>
</Box>
</Box>
);
}
export type ThreadsListPanelProps = {
room: Room;
onClose: () => void;
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const threads = useRoomThreads(room);
const threadNotifications = useAtomValue(threadNotificationsAtom);
const [storedFilter, setFilter] = useAtom(threadFilterAtom);
const [storedSort, setSort] = useAtom(threadSortAtom);
const filter: ThreadFilter = isThreadFilter(storedFilter) ? storedFilter : 'all';
const sort: ThreadSort = isThreadSort(storedSort) ? storedSort : 'recent';
// Escape closes the panel (parity with the app's other drawers).
useEffect(() => {
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape' && !evt.defaultPrevented) {
evt.preventDefault();
evt.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { visible, unreadById, highlightById, participantsById, threadById } = useMemo(() => {
const mutedSet = getMutedThreads(threadNotifications, room.roomId);
const unreadMap = new Map<string, number>();
const highlightMap = new Map<string, number>();
const partsMap = new Map<string, string[]>();
const byId = new Map<string, Thread>();
const snaps: ThreadSnapshot[] = threads.map((t) => {
byId.set(t.id, t);
const muted = mutedSet.has(t.id);
const unread = muted
? 0
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0);
const highlight = muted
? 0
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Highlight) ?? 0);
unreadMap.set(t.id, unread);
highlightMap.set(t.id, highlight);
// Participant senders: root first, then timeline order, plus the last
// replier (from the server bundle) so it's present even if not paginated
// into the loaded timeline yet. Deduped, order preserved.
const seen = new Set<string>();
const parts: string[] = [];
const push = (s?: string | null) => {
if (s && !seen.has(s)) {
seen.add(s);
parts.push(s);
}
};
push(t.rootEvent?.getSender());
t.timeline.forEach((e) => push(e.getSender()));
push(t.replyToEvent?.getSender());
partsMap.set(t.id, parts);
return {
id: t.id,
latestTs: t.replyToEvent?.getTs() ?? t.rootEvent?.getTs() ?? 0,
unread,
participated: t.hasCurrentUserParticipated,
};
});
const visibleSnaps = sortThreads(filterThreads(snaps, filter), sort);
return {
visible: visibleSnaps,
unreadById: unreadMap,
highlightById: highlightMap,
participantsById: partsMap,
threadById: byId,
};
}, [threads, room, filter, sort, threadNotifications]);
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const virtualizer = useVirtualizer({
count: visible.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 96,
overscan: 8,
getItemKey: (index) => visible[index].id,
});
const totalThreads = threads.length;
const emptyMessage =
totalThreads === 0
? 'No threads in this room yet. Start one from a messages “Reply in Thread”.'
: 'No threads match this filter.';
return (
<Box
className={classNames(css.ThreadsListPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<Header className={css.ThreadsListHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Thread} size="200" />
<Box grow="Yes" direction="Column">
<Text size="H4" truncate>
Threads
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
</Text>
</Box>
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
<Box className={css.ThreadsListToolbar} direction="Column" gap="200">
<Box role="group" aria-label="Filter threads" gap="100" wrap="Wrap">
{FILTER_OPTIONS.map((opt) => (
<SegButton
key={opt.value}
label={opt.label}
active={filter === opt.value}
onClick={() => setFilter(opt.value)}
/>
))}
</Box>
<Box role="group" aria-label="Sort threads" gap="100">
{SORT_OPTIONS.map((opt) => (
<SegButton
key={opt.value}
label={opt.label}
active={sort === opt.value}
onClick={() => setSort(opt.value)}
/>
))}
</Box>
</Box>
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
{visible.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Thread} style={{ opacity: config.opacity.Disabled }} />
<Text size="T300" priority="300" align="Center">
{emptyMessage}
</Text>
</Box>
) : (
<Box className={css.ThreadsListContent} direction="Column">
<div
style={{ position: 'relative', height: virtualizer.getTotalSize(), width: '100%' }}
>
{virtualizer.getVirtualItems().map((vItem) => {
const snap = visible[vItem.index];
const thread = threadById.get(snap.id);
if (!thread) return null;
return (
<VirtualTile
key={snap.id}
virtualItem={vItem}
ref={virtualizer.measureElement}
style={{ width: '100%', paddingBottom: config.space.S200 }}
>
<ThreadRow
room={room}
thread={thread}
unread={unreadById.get(snap.id) ?? 0}
highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread}
/>
</VirtualTile>
);
})}
</div>
</Box>
)}
</Scroll>
</Box>
</Box>
);
}
+12 -3
View File
@@ -4,10 +4,12 @@ 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 pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
type MSC1929Contact = {
matrix_id?: 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();
@@ -20,7 +20,11 @@ export function OidcManageAccount() {
const open = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank');
window.open(
withSearchParam(authUrl, { action: accountManagementActions.profile }),
'_blank',
'noopener,noreferrer',
);
}, [authMetadata, accountManagementActions]);
if (!authMetadata) return null;
+174 -23
View File
@@ -27,6 +27,7 @@ import {
Spinner,
PopOut,
RectCords,
Chip,
} from 'folds';
import { Method } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
@@ -56,6 +57,13 @@ import { useCapabilities } from '../../../hooks/useCapabilities';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
import { useStatusPresets } from '../../../hooks/useStatusPresets';
import {
BUILT_IN_STATUS_PRESETS,
StatusPreset,
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
type ProfileProps = {
profile: UserProfile;
@@ -355,14 +363,26 @@ function ProfileStatus() {
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
);
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
// True while the user has unsaved local edits — prevents a server presence
// echo from overwriting what the user is currently typing/inserting.
const statusDirtyRef = useRef(false);
// The last remote status we synced into the input. Presence heartbeats fire
// every few seconds carrying the SAME status; the sync effect must only react
// when the remote value actually changes, otherwise a repeated heartbeat can
// overwrite an unsaved local edit (e.g. an emoji just inserted) the instant
// the dirty flag is out of sync. Seeded with the value the input started on.
const lastSyncedRemoteRef = useRef<string>(initialStatus);
// The value we most recently applied (with the apply time). A presence
// heartbeat can echo the PREVIOUS status just after we save the new one; that
// stale echo would otherwise revert the input. We ignore non-matching echoes
// until our own echo lands or a short window elapses (bounded so a genuinely
// dropped echo can't block real cross-device updates forever).
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const { presets, addPreset, removePreset } = useStatusPresets();
// Sync input when another device changes the status.
// Skipped while the user has unsaved local edits to avoid clobbering
@@ -375,6 +395,20 @@ function ProfileStatus() {
// wipe the saved status on every invisible toggle.
if (presence.presence === Presence.Offline) return;
const remoteStatus = presence.status ?? '';
// Only act on an actual remote change. Repeated heartbeats carrying the same
// status are ignored so they can never clobber an unsaved local edit.
if (remoteStatus === lastSyncedRemoteRef.current) return;
lastSyncedRemoteRef.current = remoteStatus;
const pending = pendingAppliedRef.current;
if (pending) {
if (remoteStatus === pending.value) {
pendingAppliedRef.current = null; // our own echo landed — accept it
} else if (Date.now() - pending.ts < 15_000) {
return; // stale echo of the previous status; ignore within the window
} else {
pendingAppliedRef.current = null; // window elapsed — accept external changes
}
}
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
@@ -412,32 +446,68 @@ function ProfileStatus() {
setStatusMsg(evt.currentTarget.value);
};
// Save a status message + auto-clear timer. Shared by the Save button and the
// one-click presets so all three go through exactly the same server write and
// localStorage bookkeeping.
const applyStatus = useCallback(
(rawMsg: string, clearAfterValue: string) => {
statusDirtyRef.current = false;
const msg = rawMsg.trim();
// Guard against a stale presence echo reverting this value (see the sync effect).
pendingAppliedRef.current = { value: msg, ts: Date.now() };
saveStatus(msg).catch(() => undefined);
if (msg) {
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
} else {
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
const delayMs = getMsFromOption(clearAfterValue);
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
}
},
[saveStatus, userId],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (saving) return;
statusDirtyRef.current = false;
const msg = statusMsg.trim();
saveStatus(msg).catch(() => undefined);
if (msg) {
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
} else {
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
const ts = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
}
applyStatus(statusMsg, clearAfter);
};
// Preset click = one-click apply: reflect it in the inputs and save immediately.
const applyPreset = useCallback(
(preset: StatusPreset) => {
if (saving) return;
setStatusMsg(preset.label);
setClearAfter(preset.clearAfter);
applyStatus(preset.label, preset.clearAfter);
},
[saving, applyStatus],
);
const handleSaveCurrent = useCallback(() => {
const label = statusMsg.trim();
if (!label) return;
addPreset({ id: makePresetId(), label, clearAfter }).catch(() => undefined);
}, [statusMsg, clearAfter, addPreset]);
// Hide any saved preset that duplicates a built-in (it already shows under
// "Quick statuses"), so the same status can't appear as two chips.
const customPresets = useMemo(() => {
const builtInLabels = new Set(BUILT_IN_STATUS_PRESETS.map((p) => normalizeLabel(p.label)));
return presets.filter((p) => !builtInLabels.has(normalizeLabel(p.label)));
}, [presets]);
const handleClear = () => {
statusDirtyRef.current = false;
pendingAppliedRef.current = { value: '', ts: Date.now() };
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
@@ -463,7 +533,83 @@ function ProfileStatus() {
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box direction="Column" grow="Yes" gap="200">
{/* Quick statuses — built-in presets, one click applies message + timer */}
<Box direction="Column" gap="100">
<Text id="status-quick-heading" size="T200" priority="300">
Quick statuses
</Text>
<Box gap="100" wrap="Wrap" role="group" aria-labelledby="status-quick-heading">
{BUILT_IN_STATUS_PRESETS.map((preset) => (
<Chip
key={preset.id}
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
{preset.label}
</Text>
</Chip>
))}
</Box>
</Box>
{/* Your presets — saved from the current status; synced via account data */}
<Box direction="Column" gap="100">
<Text id="status-custom-heading" size="T200" priority="300">
Your presets
</Text>
<Box gap="200" wrap="Wrap" role="group" aria-labelledby="status-custom-heading">
{customPresets.map((preset) => (
// gap="0" keeps the chip and its delete X reading as one unit; the
// parent row's larger gap="200" separates one preset from the next.
<Box key={preset.id} alignItems="Center" gap="0">
<Chip
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
disabled={saving}
onClick={() => applyPreset(preset)}
>
<Text as="span" size="B300">
{preset.label}
</Text>
</Chip>
<IconButton
type="button"
size="300"
radii="Pill"
variant="Secondary"
fill="None"
aria-label={`Delete preset ${preset.label}`}
onClick={() => removePreset(preset.id).catch(() => undefined)}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
))}
<Chip
type="button"
variant="Success"
fill="Soft"
radii="Pill"
outlined
disabled={saving || !statusMsg.trim()}
onClick={handleSaveCurrent}
before={<Icon size="100" src={Icons.Plus} />}
>
<Text as="span" size="B300">
Save current
</Text>
</Chip>
</Box>
</Box>
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column" gap="100">
<Input
@@ -502,6 +648,11 @@ function ProfileStatus() {
imagePackRooms={[]}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmojiSelect}
// A status message is plain-text presence (`status_msg`), so it
// can't hold a custom mxc-image emoji — show unicode only, so every
// emoji in the picker actually inserts (custom ones silently no-op'd
// because there's no onCustomEmojiSelect here).
hideCustomEmojis
requestClose={() => setEmojiAnchor(undefined)}
/>
}
@@ -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);
@@ -36,9 +36,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
content={
accountDataType ? getAccountData<object>(mx, accountDataType) : undefined
}
content={accountDataType ? getAccountData<object>(mx, accountDataType) : undefined}
submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
/>
@@ -38,6 +38,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
action: accountManagementActions.sessionsList,
}),
'_blank',
'noopener,noreferrer',
);
}, [authMetadata, accountManagementActions]);
@@ -52,6 +53,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
device_id: deviceId,
}),
'_blank',
'noopener,noreferrer',
);
},
[authMetadata, accountManagementActions],
@@ -278,6 +278,7 @@ export function DeviceVerificationOptions() {
action: accountManagementActions.crossSigningReset,
}),
'_blank',
'noopener,noreferrer',
);
return;
}
@@ -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();
+159 -57
View File
@@ -96,10 +96,17 @@ import {
useThemes,
} from '../../../hooks/useTheme';
import { stopPropagation } from '../../../utils/keyboard';
import { pickerInputStyle } from '../../../utils/datetimeInput';
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import {
TRANSLATE_TARGET_LANGUAGES,
normalizeLang,
isSupportedTargetLang,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
@@ -435,6 +442,12 @@ function Appearance() {
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
const [nightLightSchedule, setNightLightSchedule] = useSetting(
settingsAtom,
'nightLightSchedule',
);
const [nightLightStart, setNightLightStart] = useSetting(settingsAtom, 'nightLightStart');
const [nightLightEnd, setNightLightEnd] = useSetting(settingsAtom, 'nightLightEnd');
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
settingsAtom,
'glassmorphismSidebar',
@@ -536,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>
@@ -567,23 +584,71 @@ function Appearance() {
{nightLightEnabled && (
<Box
direction="Column"
gap="100"
gap="300"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<Text size="T200" priority="300">
Intensity: {nightLightOpacity}%
</Text>
<input
aria-label="Night light intensity"
type="range"
min={5}
max={80}
value={nightLightOpacity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNightLightOpacity(parseInt(e.target.value, 10))
}
style={{ width: '100%', accentColor: color.Primary.Main }}
/>
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
Intensity: {nightLightOpacity}%
</Text>
<input
aria-label="Night light intensity"
type="range"
min={5}
max={80}
value={nightLightOpacity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNightLightOpacity(parseInt(e.target.value, 10))
}
style={{ width: '100%', accentColor: color.Primary.Main }}
/>
</Box>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Box direction="Column">
<Text size="T300">Schedule</Text>
<Text size="T200" priority="300">
Only tint during the hours below
</Text>
</Box>
<Switch
variant="Primary"
value={nightLightSchedule}
onChange={setNightLightSchedule}
/>
</Box>
{nightLightSchedule && (
<Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="night-light-start" size="T200" priority="400">
From
</Text>
<input
id="night-light-start"
type="time"
value={nightLightStart}
onChange={(e) => setNightLightStart(e.target.value)}
style={pickerInputStyle(color, config)}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="night-light-end" size="T200" priority="400">
To
</Text>
<input
id="night-light-end"
type="time"
value={nightLightEnd}
onChange={(e) => setNightLightEnd(e.target.value)}
style={pickerInputStyle(color, config)}
/>
</Box>
</Box>
)}
{nightLightSchedule && nightLightStart === nightLightEnd && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Start and end times are the same, so the tint never turns on. Set different times.
</Text>
)}
</Box>
)}
</SequenceCard>
@@ -1837,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
@@ -1929,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>
@@ -2256,6 +2323,15 @@ function Messages() {
settingsAtom,
'enforceRetentionLocally',
);
const [translateTargetLang, setTranslateTargetLang] = useSetting(
settingsAtom,
'translateTargetLang',
);
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const translationSupported = chromeTranslationEngine.isSupported();
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
? normalizeLang(translateTargetLang)
: 'en';
return (
<Box direction="Column" gap="100">
@@ -2375,6 +2451,32 @@ function Messages() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Translate Messages Into"
description={
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
: 'On-device translation isnt available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
}
after={
<SettingsSelect
value={selectedTargetLang}
onChange={(v) => setTranslateTargetLang(v)}
disabled={!translationSupported}
aria-label="Translate messages into"
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
/>
}
/>
{translationSupported && (
<SettingTile
title="Auto-translate Incoming Messages"
description="Automatically translate messages that arent already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
/>
)}
</SequenceCard>
</Box>
);
}
@@ -1,4 +1,11 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
@@ -56,6 +63,13 @@ function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
const mx = useMatrixClient();
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
// Re-sync when the rule changes externally (e.g. toggled on another device →
// account-data sync). The useState initializer only runs once, so without
// this the Switch would show a stale value.
useEffect(() => {
setEnabled(pushRule.enabled !== false);
}, [pushRule.enabled]);
const [toggleState, toggle] = useAsyncCallback(
useCallback(
async (value: boolean) => {
@@ -1,12 +1,20 @@
import React, { useCallback } from 'react';
import { Box, Text, Switch, Button, color, config, Spinner } from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { useAtomValue, useSetAtom } from 'jotai';
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
isSnoozeActive,
nextTimeAtHour,
notificationSnoozeUntilAtom,
SNOOZE_INDEFINITE,
} from '../../../state/notificationSnooze';
import { formatFriendlyDateTime } from '../../../utils/datetimeInput';
import { getNotificationState, usePermissionState } from '../../../hooks/usePermission';
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -120,6 +128,70 @@ const selectStyle: React.CSSProperties = {
outline: 'none',
};
const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }> = [
{ label: '30 minutes', resolve: (now) => now + 30 * 60_000 },
{ label: '1 hour', resolve: (now) => now + 60 * 60_000 },
{ label: '4 hours', resolve: (now) => now + 4 * 60 * 60_000 },
{ label: 'Until 8 AM', resolve: (now) => nextTimeAtHour(8, now) },
{ label: 'Until I resume', resolve: () => SNOOZE_INDEFINITE },
];
// Cross-platform "pause notifications" — sets a snooze instant that the
// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds.
function PauseNotifications() {
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
// While paused, tick so the status flips to "on" the moment the snooze lapses.
const [, setTick] = useState(0);
const active = isSnoozeActive(snoozeUntil);
useEffect(() => {
if (!active) return undefined;
const id = setInterval(() => setTick((n) => n + 1), 30_000);
return () => clearInterval(id);
}, [active]);
const status = !active
? 'Notifications are on.'
: snoozeUntil >= SNOOZE_INDEFINITE
? 'Paused until you resume.'
: `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`;
return (
<SettingTile
title="Pause Notifications"
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
after={
active ? (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setSnoozeUntil(0)}
before={<Icon size="100" src={Icons.BellRing} />}
>
<Text size="B300">Resume</Text>
</Button>
) : undefined
}
>
<Box gap="200" wrap="Wrap" style={{ marginTop: config.space.S200 }}>
{SNOOZE_PRESETS.map((preset) => (
<Chip
key={preset.label}
variant="SurfaceVariant"
radii="Pill"
onClick={() => setSnoozeUntil(preset.resolve(Date.now()))}
>
<Text size="B300">{preset.label}</Text>
</Chip>
))}
</Box>
</SettingTile>
);
}
export function SystemNotification() {
const notifPermission = usePermissionState('notifications', getNotificationState());
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
@@ -174,6 +246,14 @@ export function SystemNotification() {
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<PauseNotifications />
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -301,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} />
))}
+1 -3
View File
@@ -6,9 +6,7 @@ import { getAccountData } from '../utils/room';
export function useAccountData(eventType: string): MatrixEvent | undefined {
const mx = useMatrixClient();
const [event, setEvent] = useState<MatrixEvent | undefined>(() =>
getAccountData(mx, eventType),
);
const [event, setEvent] = useState<MatrixEvent | undefined>(() => getAccountData(mx, eventType));
useAccountDataCallback(
mx,
+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]);
+6
View File
@@ -8,6 +8,12 @@ export type Bookmark = {
savedAt: number;
previewText: string;
roomName: string;
// Author display name, snapshotted at save time. Optional for backward
// compatibility with bookmarks stored before attribution was added; the panel
// re-resolves a live name when the room is joined and falls back to this
// snapshot otherwise. (We deliberately don't store the sender MXID — it's never
// read back and would only grow the 500-entry account-data blob.)
senderName?: string;
};
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
+5 -3
View File
@@ -149,9 +149,11 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
);
useEffect(() => {
if (!embed) {
setJoined(false);
}
// Re-seed from the (new) embed whenever it changes identity, not only when
// it becomes undefined. Answering a 2nd call swaps embed A→B directly (embed
// stays truthy), so a plain `if (!embed)` reset left `joined` stuck true and
// B rendered as already-joined, skipping the loading/watchdog UI.
setJoined(embed?.joined ?? false);
}, [embed]);
return joined;
+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;
};
+43 -1
View File
@@ -25,6 +25,7 @@ import {
import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent, sendStateEvent } from '../utils/room';
import { isValidServerPattern, matchesAnyGlob } from '../utils/serverAcl';
import { splitWithSpace } from '../utils/common';
import { createRoomEncryptionState } from '../components/create-room';
@@ -509,8 +510,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
StateEvent.RoomServerAcl,
)?.getContent<RoomServerAclEventContent>();
// When there is NO existing ACL, default allow to `*` (allow all) so a
// first `/acl -d evil.com` doesn't send `allow: []` — which per spec
// allows NO server and bricks the room. But do NOT synthesize `*` for a
// room that already HAS an ACL whose allow key is absent/empty: that
// would silently widen a deny-all room to allow-all. Such a state is
// preserved as-is and then rejected by the guard below (fail closed,
// rather than silently opening federation).
const aclContent: RoomServerAclEventContent = {
allow: serverAcl?.allow ? [...serverAcl.allow] : [],
allow: serverAcl ? (serverAcl.allow ? [...serverAcl.allow] : []) : ['*'],
allow_ip_literals: serverAcl?.allow_ip_literals,
deny: serverAcl?.deny ? [...serverAcl.deny] : [],
};
@@ -534,6 +542,40 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
aclContent.allow?.sort();
aclContent.deny?.sort();
// Validate every added glob (empty/whitespace/malformed patterns).
const invalidPattern = [...allowList, ...denyList].find(
(servername) => !isValidServerPattern(servername),
);
if (invalidPattern) {
throw new Error(`/acl: invalid server pattern "${invalidPattern}"`);
}
const resultAllow = aclContent.allow ?? [];
const resultDeny = aclContent.deny ?? [];
// Fail CLOSED on the universally-catastrophic cases regardless of
// whether we can resolve our own server: an empty allow list bans every
// server, and a `*` deny bans everyone.
if (resultAllow.length === 0 || resultDeny.includes('*')) {
throw new Error(
'/acl: refused — this would ban all servers from the room. Use Room Settings → Server ACL to edit safely.',
);
}
// Refuse a change that would lock THIS homeserver out of the room — a
// room-bricking footgun (denying the local server, or an allow list that
// no longer matches it). Mirrors the Room Settings ACL editor's self-ban
// detection.
const localDomain = mx.getDomain() ?? '';
if (
localDomain &&
(!matchesAnyGlob(localDomain, resultAllow) || matchesAnyGlob(localDomain, resultDeny))
) {
throw new Error(
`/acl: refused — this would ban this homeserver (${localDomain}) from the room`,
);
}
await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, aclContent);
},
},
+46
View File
@@ -0,0 +1,46 @@
import { useEffect } from 'react';
import { useStore } from 'jotai';
import { Descendant } from 'slate';
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
/**
* On startup, pre-fill the per-room message-draft atoms from their localStorage
* persistence (`draft-msg-<roomId>`). RoomInput only restores a draft into its
* atom when that room's composer mounts, so without this a background room's
* persisted draft wouldn't reflect in the room-nav draft indicator until the
* room is opened. Runs once; only touches the same atoms RoomInput restores from
* (identical content), so composer restore is unaffected.
*
* Thread drafts (key contains `::`) are skipped the nav indicator is room-level.
*/
export function useHydrateMsgDrafts(): void {
const store = useStore();
useEffect(() => {
let keys: string[];
try {
keys = Object.keys(localStorage);
} catch {
return;
}
keys.forEach((key) => {
if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return;
const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length);
// Skip thread drafts (`<roomId>::<$threadRootEventId>`) — room-level only.
// Match `::$` specifically so an IPv6-literal server name (e.g. `[::1]`)
// in a roomId isn't mistaken for a thread key.
if (!draftKey || draftKey.includes('::$')) return;
try {
const stored = localStorage.getItem(key);
if (!stored) return;
const nodes = JSON.parse(stored) as Descendant[];
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
}
} catch {
// Ignore a malformed stored draft.
}
});
}, [store]);
}
+5 -22
View File
@@ -1,8 +1,7 @@
import { useEffect } from 'react';
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { useForceUpdate } from './useForceUpdate';
import { useRoomMemberChange } from './useRoomMemberChange';
import { getMemberName } from '../utils/room';
import { mxcUrlToHttp } from '../utils/matrix';
@@ -15,9 +14,8 @@ export type MemberAvatar = {
* Resolve a room member's display name and avatar http url, staying reactive to
* that member's profile (name/avatar/membership) changes.
*
* Mirrors the reactivity pattern in ReadReceiptAvatars (N6): subscribe to
* `RoomStateEvent.Members` filtered to this room + userId and force a re-render,
* since `RoomMemberEvent` has no dedicated Avatar signal.
* Stays reactive to this member's profile (name/avatar/membership) changes via
* the shared member-change store (one global listener for the whole app).
*/
export const useMemberAvatar = (
room: Room,
@@ -28,23 +26,8 @@ export const useMemberAvatar = (
): MemberAvatar => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [, forceUpdate] = useForceUpdate();
useEffect(() => {
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member,
) => {
if (event.getRoomId() === room.roomId && member.userId === userId) {
forceUpdate();
}
};
mx.on(RoomStateEvent.Members, handleMembers);
return () => {
mx.removeListener(RoomStateEvent.Members, handleMembers);
};
}, [mx, room.roomId, userId, forceUpdate]);
useRoomMemberChange(room.roomId, userId);
const name = getMemberName(room, userId);
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
+170
View File
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import {
translationCacheAtom,
msgTranslationActiveAtomFamily,
makeCacheKey,
findTranslation,
addTranslation,
} from '../state/translation';
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
import { sameLanguage } from '../utils/translation/langUtils';
export type MsgTranslationStatus =
| 'idle' // not translating (original shown)
| 'detecting' // detecting source language
| 'downloading' // downloading the on-device model (first use of a pair)
| 'translating' // running the translation
| 'done' // translation available
| 'skipped' // already in the target language (nothing to do)
| 'error'; // detection/translation failed
export type MsgTranslation = {
/** Whether the on-device engine is usable at all in this browser. */
supported: boolean;
/** Whether the translated body should be shown in place of the original. */
active: boolean;
status: MsgTranslationStatus;
translated?: string;
/** Detected source language (normalized base code). */
fromLang?: string;
/** 0..1 while `status === 'downloading'`. */
downloadProgress?: number;
/** Toggle translation on/off for this message (satisfies the user gesture). */
toggle: () => void;
};
/**
* Per-message on-device translation. The message menu action and the inline
* "Show original" chip both drive the same per-event `active` atom; whichever
* component mounts the body runs the actual detecttranslate flow (once) and
* writes the result to the local cache. Nothing ever leaves the device.
*/
export const useMessageTranslation = (eventId: string, text: string): MsgTranslation => {
const supported = engine.isSupported();
const [targetLang] = useSetting(settingsAtom, 'translateTargetLang');
const [autoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
const [cache, setCache] = useAtom(translationCacheAtom);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale text.
const key = makeCacheKey(eventId, targetLang, text);
const cached = findTranslation(cache, key);
const [status, setStatus] = useState<MsgTranslationStatus>('idle');
const [translated, setTranslated] = useState<string | undefined>(undefined);
const [fromLang, setFromLang] = useState<string | undefined>(undefined);
const [downloadProgress, setDownloadProgress] = useState<number | undefined>(undefined);
const toggle = useCallback(() => setActive((a) => !a), [setActive]);
// Adopt a cached translation immediately (scrollback / re-render never
// re-translates).
useEffect(() => {
if (cached) {
setTranslated(cached.translated);
setFromLang(cached.fromLang);
if (active) setStatus('done');
}
}, [cached, active]);
// Auto-translate: when enabled, flip `active` on for foreign messages whose
// model is already downloaded (no gesture/download needed). Messages needing
// a first-time download keep the manual chip (which provides the gesture).
// Store the text we last auto-tried so an edit (new text) re-triggers auto
// detection instead of being suppressed by a one-shot flag.
const autoTriedText = useRef<string | undefined>(undefined);
useEffect(() => {
if (!supported || !autoTranslate || active || cached || !eventId) return;
if (!text.trim() || autoTriedText.current === text) return;
autoTriedText.current = text;
let alive = true;
(async () => {
const from = await engine.detect(text);
if (!alive || !from || sameLanguage(from, targetLang)) return;
const avail = await engine.availability(from, targetLang);
if (!alive || avail !== 'available') return;
setActive(true);
})();
return () => {
alive = false;
};
}, [supported, autoTranslate, active, cached, text, targetLang, eventId, setActive]);
// The detect→translate flow. Runs when translation is switched on and we have
// no cached result. Guarded against overlapping runs / stale writes.
const runIdRef = useRef(0);
useEffect(() => {
if (!active || cached || !supported) return undefined;
if (!text.trim()) {
setStatus('skipped');
return undefined;
}
const runId = runIdRef.current + 1;
runIdRef.current = runId;
const stale = () => runId !== runIdRef.current;
(async () => {
try {
setStatus('detecting');
setDownloadProgress(undefined);
const from = await engine.detect(text);
if (stale()) return;
if (!from || sameLanguage(from, targetLang)) {
setStatus('skipped');
setActive(false);
return;
}
setFromLang(from);
const avail = await engine.availability(from, targetLang);
if (stale()) return;
if (avail === 'unavailable') {
setStatus('error');
return;
}
setStatus(avail === 'available' ? 'translating' : 'downloading');
const result = await engine.translate(text, from, targetLang, (p) => {
if (!stale()) setDownloadProgress(p);
});
if (stale()) return;
setTranslated(result);
setDownloadProgress(undefined);
setStatus('done');
setCache((prev) => addTranslation(prev, { key, fromLang: from, translated: result }));
} catch {
if (!stale()) setStatus('error');
}
})();
return () => {
// Invalidate this run's writes if inputs change / unmount.
runIdRef.current += 1;
};
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
// When switched off, drop back to idle (keep the cached translation around so
// toggling back on is instant).
useEffect(() => {
if (!active && status !== 'idle') setStatus('idle');
}, [active, status]);
return {
supported,
active,
status: active ? status : 'idle',
translated: cached?.translated ?? translated,
fromLang: cached?.fromLang ?? fromLang,
downloadProgress,
toggle,
};
};
/** Lightweight helper for the message menu — is the translate action offerable? */
export const isTranslationSupported = (): boolean => engine.isSupported();
+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,
};
};
+11 -2
View File
@@ -12,13 +12,22 @@ import { useSetting } from '../state/hooks/settings';
import { MessageEvent, StateEvent } from '../../types/matrix/room';
import { isMembershipChanged, reactionOrEditEvent } from '../utils/room';
export const useRoomLatestRenderedEvent = (room: Room) => {
export const useRoomLatestRenderedEvent = (room: Room, enabled = true) => {
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [latestEvent, setLatestEvent] = useState<MatrixEvent>();
useEffect(() => {
// The latest-event preview is only rendered for DM rooms. Skip the work —
// and especially the GLOBAL client `Decrypted` listener, which every nav
// item would otherwise add and which fans out on every decryption app-wide
// (PERF-5) — when the caller isn't going to use the result.
if (!enabled) {
setLatestEvent(undefined);
return undefined;
}
const getLatestEvent = (): MatrixEvent | undefined => {
const liveEvents = room.getLiveTimeline().getEvents();
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
@@ -67,7 +76,7 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
};
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
}, [room, enabled, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
return latestEvent;
};
+67
View File
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import { memberChangeStore } from './useRoomMemberChange';
// Minimal fake client: records registered handlers and can emit a member change.
// The store registers exactly one RoomStateEvent.Members handler; we invoke it
// with the same (event, state, member) shape the SDK uses.
const makeFakeMx = () => {
const handlers: Array<(e: unknown, s: unknown, m: unknown) => void> = [];
const mx = {
on: (_event: unknown, h: (e: unknown, s: unknown, m: unknown) => void) => {
handlers.push(h);
},
removeListener: (_event: unknown, h: (e: unknown, s: unknown, m: unknown) => void) => {
const i = handlers.indexOf(h);
if (i >= 0) handlers.splice(i, 1);
},
};
return {
mx: mx as unknown as MatrixClient,
listenerCount: () => handlers.length,
emit: (roomId: string, userId: string) =>
handlers.forEach((h) => h({ getRoomId: () => roomId }, undefined, { userId })),
};
};
test('fans out only to the matching roomId|userId', () => {
const { mx, emit } = makeFakeMx();
let a = 0;
let b = 0;
memberChangeStore.subscribe(mx, '!fanout', '@a', () => {
a += 1;
});
memberChangeStore.subscribe(mx, '!fanout', '@b', () => {
b += 1;
});
emit('!fanout', '@a');
assert.equal(a, 1);
assert.equal(b, 0);
emit('!other', '@a'); // different room, same user -> no fire
assert.equal(a, 1);
});
test('registers exactly one client listener regardless of subscriber count', () => {
const { mx, listenerCount } = makeFakeMx();
memberChangeStore.subscribe(mx, '!one', '@a', () => {});
memberChangeStore.subscribe(mx, '!one', '@b', () => {});
memberChangeStore.subscribe(mx, '!one2', '@c', () => {});
assert.equal(listenerCount(), 1);
});
test('idempotent unsubscribe does not evict a re-subscribed key', () => {
const { mx, emit } = makeFakeMx();
const unsubA = memberChangeStore.subscribe(mx, '!idem', '@a', () => {});
unsubA();
let count = 0;
memberChangeStore.subscribe(mx, '!idem', '@a', () => {
count += 1;
});
unsubA(); // double-invoke of the first unsub must not drop the new subscriber
emit('!idem', '@a');
assert.equal(count, 1);
});
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from 'react';
import { MatrixClient, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useForceUpdate } from './useForceUpdate';
type MemberListener = () => void;
const memberKey = (roomId: string, userId: string): string => `${roomId}|${userId}`;
/**
* Shared room-member-change store. Previously every ReadReceiptAvatars row and
* every useMemberAvatar registered its own global `RoomStateEvent.Members`
* listener ~6 per receipt row each firing on ANY membership / display-name /
* avatar change in ANY room (PERF-3). This registers exactly ONE listener total
* and fans out to subscribers keyed by `roomId|userId`.
*
* (`RoomStateEvent.Members` is used rather than `RoomMemberEvent` because the
* latter has no dedicated avatar signal.)
*/
class MemberChangeStore {
private mx: MatrixClient | undefined;
private started = false;
private subscribers = new Map<string, Set<MemberListener>>();
private handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member,
) => {
const roomId = event.getRoomId();
if (!roomId) return;
const subs = this.subscribers.get(memberKey(roomId, member.userId));
if (!subs) return;
subs.forEach((cb) => cb());
};
private start(mx: MatrixClient): void {
if (this.started && this.mx === mx) return;
if (this.started && this.mx) {
this.mx.removeListener(RoomStateEvent.Members, this.handleMembers);
}
this.mx = mx;
mx.on(RoomStateEvent.Members, this.handleMembers);
this.started = true;
}
subscribe(mx: MatrixClient, roomId: string, userId: string, cb: MemberListener): () => void {
this.start(mx);
const key = memberKey(roomId, userId);
let subs = this.subscribers.get(key);
if (!subs) {
subs = new Set();
this.subscribers.set(key, subs);
}
subs.add(cb);
return () => {
subs.delete(cb);
// Only drop the registry entry if this is still the live set for the key,
// keeping unsubscribe idempotent against a double-invoke / re-subscribe.
if (subs.size === 0 && this.subscribers.get(key) === subs) {
this.subscribers.delete(key);
}
};
}
}
export const memberChangeStore = new MemberChangeStore();
/**
* Re-render the caller whenever the given room member's state (display name,
* avatar, or membership) changes, via the shared single-listener store.
*/
export const useRoomMemberChange = (roomId: string, userId: string): void => {
const mx = useMatrixClient();
const [, forceUpdate] = useForceUpdate();
useEffect(
() => memberChangeStore.subscribe(mx, roomId, userId, forceUpdate),
[mx, roomId, userId, forceUpdate],
);
};
/**
* Multi-user variant: re-render when ANY of `userIds` changes. Subscribes to
* each id through the shared store from a single effect (no per-id hook).
*/
export const useRoomMembersChange = (roomId: string, userIds: string[]): void => {
const mx = useMatrixClient();
const [, forceUpdate] = useForceUpdate();
// Stable, order-independent dependency for the set of ids (the array identity
// is unstable, and re-ordering the same members shouldn't churn subscriptions).
const idsKey = [...userIds].sort().join(',');
useEffect(() => {
const unsubs = userIds.map((userId) =>
memberChangeStore.subscribe(mx, roomId, userId, forceUpdate),
);
return () => unsubs.forEach((unsub) => unsub());
// `userIds` is captured via the stable `idsKey`; re-subscribes when it changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, roomId, idsKey, forceUpdate]);
};

Some files were not shown because too many files have changed in this diff Show More