Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 b929cbf74b chore(call): pin element-call-embedded 0.25.0-lotus.22 (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m14s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m39s
lotus.22 draws the "Share your screen?" prompt inside the call frame on
request and drops the corner button. Verified against the published
package: no-delegation (Firefox path) cross- and same-origin — bar button,
in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium
unchanged; picture-in-picture prompt fits and Share works; room policy
hides the button and refuses the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 13:32:45 -04:00
Lotus CIandClaude Opus 5.5 8520de232d feat(call): screenshare stays on the call bar in Firefox/Safari via the in-frame prompt (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare-
prompt), the call bar and status bar keep their screenshare button on every
engine. Where the click can't be delegated, starting asks the fork to show
"Share your screen?" inside the call frame (io.lotus.prompt_screenshare)
instead of our own confirm; its Share click starts the share. Stopping works
from the bar as before (no click needed in the frame). Chromium is unchanged.

- useScreenshareMode: hidden (older fork's corner button) | prompt | direct.
- The room's call policy is also pushed to the fork in prompt mode, so the
  prompt never opens where sharing is forbidden.
- Picture-in-picture: the "Return to call" overlay covers the frame; while
  the fork reports the prompt open it lets clicks through to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 12:48:12 -04:00
jared 773e41311e Merge pull request #243: optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (push) Successful in 4m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 14m40s
2026-09-27 02:24:47 -04:00
Lotus CIandClaude Opus 5.5 da78bff316 chore(call): pin element-call-embedded 0.25.0-lotus.21 (#43)
CI / Build & Quality Checks (pull_request) Successful in 5m10s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m21s
lotus.21 checks widget messages against the host's origin (works same- and
cross-origin) and accepts soundboard clip bytes. Same-origin behaviour is
unchanged; verified against the published package: join, both screenshare
paths, PTT/deafen in the frame, layout/reactions/settings, speaking and mic
level, soundboard, avatars, muted-speech warning, per-person volume, and the
foreign-frame spoof stays blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 02:05:18 -04:00
Lotus CIandClaude Opus 5.5 4dcc5176e2 feat(call): optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m27s
Groundwork for serving the call page from its own origin
(call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`:
without it the bundled same-origin page is used exactly as today.

- callPageUrl: resolves `elementCallUrl` — absolute https only (http only on
  localhost for development); anything else, and the desktop app, fall back
  to the bundled page so a bad value can't break calls. Set once from the
  loaded client config.
- CallEmbed builds the widget URL from it; the widget origin (used by the
  message guard and Capability Delegation) follows automatically.
- Soundboard: a host blob: URL can't be fetched from another origin, so
  io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks
  that predate it ignore the field and use `url`, so this is safe on the
  released fork.

Needs element-call's lotus-call-origin branch (host-origin message check +
inject_audio bytes) released and pinned before `elementCallUrl` is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 23:24:24 -04:00
Lotus CIandClaude Opus 5.5 df395776b4 fix(security): widget API only accepts messages from the widget's own frame
CI / Build & Quality Checks (push) Successful in 5m0s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 13m13s
matrix-widget-api's host transport handled a message from ANY window on the
page as long as it carried the widget's id; its strictOriginCheck only
compares with the host's own origin and is off by default. The call's id is
the fixed 'call-embed', so any other frame (a room widget, a URL-preview
embed) could post fromWidget actions as the call. Reproduced locally: an
opaque-origin frame posting one io.lotus.hotkey keydown for the PTT key
turned a push-to-talk user's mic on ("● Live").

restrictWidgetMessages() swaps each ClientWidgetApi transport's listener
for one that requires ev.source === the widget iframe's window and
ev.origin === the widget's origin. Applied to the call and to room widgets
(so one widget can't impersonate another). Verified: the spoof no longer
opens the mic; PTT/deafen from inside the call, screenshare, speaking
indicator and room widgets (capability prompt, send, live events) unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 22:22:57 -04:00
Lotus CIandClaude Opus 5.5 49dec686f1 feat(call): screenshare from inside the frame where delegation is missing; host stops reading the call frame (#43)
CI / Build & Quality Checks (push) Successful in 4m32s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to
the call frame (no Capability Delegation), and getDisplayMedia needs it. The
host used to click EC's hidden footer button through the DOM instead, which
dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20):

- those engines get `lotusFrameScreenshare`, the fork shows EC's own
  screenshare button in the frame, and the call bar and status bar hide
  theirs once controls_state reports `frameScreenshare`; the
  screenshare-audio mute stays;
- the room's call policy is pushed with io.lotus.set_frame_screenshare, so
  the frame button hides where the server would refuse a share, like ours;
- Chromium keeps the delegated io.lotus.set_screenshare from the host bar.

Removed the fallbacks for forks older than lotus.14, which read or clicked
EC's DOM: the screenshare/layout/settings/reactions/leave button lookups
and their MutationObservers, the frame-window hotkey binding, and the
speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the
only source now). getCallDocument is gone; the host's only handle on the
frame is postMessage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 22:04:54 -04:00
Lotus CIandClaude Opus 5.5 ba48e95993 feat(media): Media Gallery includes media posted in threads (#165)
CI / Build & Quality Checks (push) Successful in 4m26s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 12m0s
The SDK routes thread replies out of every room timeline set, the gallery's
detached one included, into the room's Thread objects, so photos posted in
a thread never reached the gallery. The gallery now merges media from the
loaded threads (deduped, newest first) and refreshes on ThreadEvent.NewReply,
waiting for decryption in encrypted rooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 19:31:10 -04:00
Lotus CIandClaude Opus 5.5 a8db61f79f fix(threads): viewing a room no longer marks threads you follow read (#217)
markAsRead ran on every room visit (timeline at the bottom and focused) and
sent a threaded receipt for every unread thread, so a reply in a thread you
started or replied in lost its unread badge the moment you glanced at the
room, without opening the thread.

Reads from just viewing the timeline are now "passive":
- threads you follow (started, replied in, or were mentioned in) stay unread
  until their panel is opened; other threads are still cleared so they don't
  keep the room dot lit forever;
- while a followed thread has an unread reply, the main receipt is scoped to
  the main timeline instead of unthreaded, because an unthreaded receipt
  also reads every older thread reply (the next main message would clear the
  thread anyway). The check also asks whether the latest reply is read, since
  the thread's count lags when the reply and a main message share a sync;
- the thread open in the panel is skipped, as the panel sends its own
  receipt (was two identical receipts per reply).

Explicit "mark as read" (room menu, Escape, bulk actions) still clears
everything. Unit tests for each rule plus a local-homeserver e2e that checks
the server's per-thread count survives a reply + newer main message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 19:31:00 -04:00
Lotus CIandClaude Opus 5.5 f5d3c43b6c fix(viewer): thread media steps through the thread; add Copy image (#164)
CI / Build & Quality Checks (push) Successful in 4m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 11m58s
CI / Trigger Desktop Build (push) Successful in 8s
Thread images opened the room media lightbox, which looks the event up in
the room's detached media timeline. Thread replies never reach that
timeline, so a thread image always showed "1 / 1" with no prev/next, after
paging the room's media up to six times. The thread panel now builds the
viewer's items from its own root + loaded replies, and "Go to message"
scrolls the thread panel instead of the room.

The viewer gains a "Copy image" button: fetches the displayed media (blob
URL for E2EE, authenticated URL otherwise), re-encodes to PNG when needed,
and writes it via ClipboardItem with a promise so Safari keeps the click's
user activation. Hidden where ClipboardItem is missing. No "open in new
tab": an E2EE blob URL is revoked when the viewer closes and authenticated
media 401s in a bare tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 19:02:49 -04:00
Lotus CIandClaude Opus 5.5 502b9dfd83 feat(call): stop injecting CSS and key listeners into the call frame (#43)
CI / Build & Quality Checks (push) Successful in 3m5s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 12m42s
Pins @lotusguild/element-call-embedded 0.25.0-lotus.19.

- Styles: the fork now hides its own footer (`lotusHostControls=1`) and
  sets its root color-scheme from the theme, so the host no longer
  injects `#lotus-ec-styles` or sets inline styles on EC's DOM. The two
  other injected rules matched nothing in EC 0.25 (dead). The
  transparent background was already the fork's (`lotusTransparent`).
- Hotkeys: PTT / deafen keys pressed with focus inside the call frame now
  arrive as `io.lotus.hotkey` (the host sends the codes via
  `io.lotus.set_hotkeys`), instead of listeners on the frame's window.
  The window binding stays only for a fork that doesn't report `hotkeys`.
- Fixes (with lotus.19): pressing the deafen key M with focus in the call
  also hit EC's own "M = toggle mic" shortcut, so the first press turned
  the mic ON instead of deafening — even in push-to-talk mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 14:35:44 -04:00
Lotus CIandClaude Opus 5.5 024edbf546 feat(call): screenshare over the widget API with Capability Delegation (#43)
CI / Build & Quality Checks (push) Successful in 2m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 16m14s
The call bar's Start/Stop Screenshare no longer clicks EC's hidden button
on Chromium (incl. WebView2): it sends io.lotus.set_screenshare with
postMessage `{ delegate: "display-capture" }` inside the user's click, so
the frame can call getDisplayMedia on engines that require the click.
matrix-widget-api has no postMessage options, so its sendInternal is
swapped for that one synchronous send; delegation needs the frame's real
origin, not `*`.

Firefox, Safari and WebKitGTK (Linux desktop) have no delegation and
still click EC's button (needs same-origin, which is still on). Gated on
the fork reporting `screenshareAction` in controls_state.

Pins @lotusguild/element-call-embedded 0.25.0-lotus.17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 13:27:42 -04:00
Lotus CIandClaude Opus 5.5 ba5b1ffe7d feat(embeds): fallback for hung embeds and deleted X posts (#200)
CI / Build & Quality Checks (push) Successful in 3m24s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 9m57s
Probed how real providers fail before picking signals:
- X renders an EMPTY frame for a deleted/private/suspended post and says
  so only via postMessage `twttr.private.no_results`. The post embed now
  swaps to "This post isn't available…" with an "Open on X" link.
- A hung frame never fires `load`. After 20 s every player (media, rich
  posts, TikTok, Steam widget, X) overlays "This embed is taking too long
  to load" with Retry (remounts the iframe) and "Open on <site>". A late
  `load` clears it.
- Instagram and Bluesky show their own "removed / not found" page, and a
  refused request still fires `load` (browser error page), so neither needs
  or can use a guess. A missing height message is not treated as failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 11:54:16 -04:00
Lotus CIandClaude Opus 5.5 1b5e6a37f5 fix(a11y): stable reaction labels — emoji glyph, custom emoji shortcode (#179)
CI / Secret scan (gitleaks) (push) Successful in 52s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The reaction button's aria-label used getShortcodeFor(), which returns
undefined until the lazily loaded emoji data arrives. The same button read
"🎉 reaction, 1 person" on first render and "tada reaction, 2 people" after
any later re-render. It now always uses the emoji itself (screen readers
speak it by its proper name, e.g. "party popper").

Custom (mxc) emoji were labelled just "custom emoji"; they now use the
shortcode carried on the reaction event (":lotus_blob: reaction").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 11:34:03 -04:00
Lotus CIandClaude Opus 5.5 52d94058cf fix(decorations): drop clyde_invaders (Discord Clyde artwork)
CI / Build & Quality Checks (push) Successful in 2m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 12m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 00:21:19 -04:00
Lotus CIandClaude Opus 5.5 e6388fe294 fix(decorations): drop warp_helmet (Clyde-visor design)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Same Discord Clyde-visor artwork as the helmets excluded in 78b1a2ff.
Anyone who had it equipped now shows no decoration: the slug no longer
passes isValidDecorationSlug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 00:10:45 -04:00
Lotus CIandClaude Opus 5.5 78b1a2ff6f feat(decorations): 530 new avatar decorations, thumbnail-first picker
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 10m56s
Adds 530 decorations in 19 new categories (631 total). Decorations that
contain Discord branding (the Clyde-visor helmets) are excluded.

The decorations are ~1 MB animated PNGs, so a picker that rendered every
one would pull ~590 MB while scrolling. The picker now:
- shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN)
  and loads the animated file only on hover, focus or selection, with a
  fallback to the full file if a thumbnail is missing;
- mounts one category at a time behind tabs, plus a name search across
  all categories.

scripts/makeDecorationThumbs.py builds the thumbnails from the busiest
frame of each animation (many start on an empty frame).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 23:50:12 -04:00
Lotus CIandClaude Opus 5.5 4323babfb9 fix(composer): a fully typed user ID + Enter sends as typed
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 8m35s
d2503332 made Enter pick the mention suggestion, which also caught
"/kick @alice:server" + Enter (the e2e "/kick failure is reported" test):
typing a complete ID opens the mention list too, and Enter picked it instead
of running the command. Enter now picks only while a partial name is being
typed (no ':' in the query); a complete @user:server or #alias:server sends
as typed, like before.

Chromium e2e 19 passed; "@bo" + Enter still picks, ":smi" + Enter still
sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:20:04 -04:00
Lotus CIandClaude Opus 5.5 d250333290 fix(composer): Enter picks the @-mention / #room / command suggestion
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
With the mention list open, pressing Enter sent the half-typed text
("hey @bo") instead of inserting the highlighted person — found while
investigating the "composer hit a snag" report; only Tab picked. Enter now
picks the top suggestion for people, rooms and commands, as in Discord,
Slack and Element.

useAutocompleteEnter listens in the window capture phase (so it runs before
the composer's submit handler) and only while the list has suggestions —
with no command match Enter still sends. Emoji suggestions are deliberately
left on Tab only, so "lol :p" + Enter still sends rather than inserting an
emoji. IME composition and Shift/Ctrl+Enter are untouched. Tab now inserts
the member's display name, same as a click.

Verified: "@bo" + Enter inserts the mention and sends nothing; the next
Enter sends "hey @bob hi" with m.mentions; ":smi" + Enter still sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:13:47 -04:00
Lotus CIandClaude Opus 5.5 4c9af57a97 fix(composer): recover silently from a composer render error; capture details
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 3m3s
Reported on chat.lotusguild.org: picking a name from the @-mention list
showed "The message composer hit a snag." — clicking OK continued with the
draft intact. The composer's known failure mode is a transient render error
right after an autocomplete insert (slate-react's DOM selection sync racing
the model; 477df4ae fixed one such path). It did not reproduce locally in 60
attempts (click / Enter / Tab, fast and slow, display names with spaces and
emoji, Chromium and WebKit), so this makes it harmless and diagnosable:

- ComposerErrorBoundary: the first failure clears the selection and remounts
  the composer immediately, with no notice (the draft is intact). A second
  failure within 5 s shows the notice, now saying the draft is safe, with
  "Reload composer" and "Copy details" (time, error, stack, component stack,
  browser). Every failure is logged to the console as "[composer] render
  error".
- Used for the room composer and, newly, the thread panel composer (which had
  no boundary, so the same error took the whole panel down).

Verified with a temporary injected crash (not committed): one crash recovers
with the draft kept and no notice; two within 5 s show the notice, Copy
details copies the report, Reload composer restores the full draft.
Chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:08:33 -04:00
Lotus CIandClaude Opus 5.5 afd14719ef fix(settings): say when GIF search isn't set up instead of a dead switch
CI / Build & Quality Checks (push) Successful in 1m45s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 12m9s
The GIF button needs a Giphy key in the client config. Without one (the
desktop app until now), switching "GIF Picker" on did nothing and nothing
said why. With no key the tile now explains it and the switch is disabled;
with a key it is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 15:50:22 -04:00
jaredandClaude Opus 5.5 7c26268e38 Merge pull request 'Seasonal: Halloween redesign (#239), needs human approval' (#242) from seasonal/halloween into lotus
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 9m32s
Merge seasonal/halloween: Halloween redesign (#239), approved by Jared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 15:33:53 -04:00
Lotus CIandClaude Opus 5.5 fda6e0f74e feat(seasonal): Halloween — more going on: wisps, a witch, a swarm (#239)
CI / Build & Quality Checks (pull_request) Successful in 1m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m39s
Jared felt the first redesign was too sparse for a full theme: most of the
time only two webs and a spider were on screen.

- Will-o'-the-wisps: six soft green/orange glows wandering and pulsing,
  always present while animating (not in the still scene, where static glows
  would sit on text).
- A witch on a broom crosses just below the header about once a minute
  (redrawn so she reads as a seated figure, not a blob).
- Bats every 20 s (was 30), plus a faster swarm of eight every 60 s.
- The big spider now climbs up its thread and drops back with a bounce.
- A second, small spider on the left web; a tiny jack-o'-lantern with a
  flickering candle hangs by the right web (desktop).

Rendered and inspected at the witch/swarm moments in light, dark and phone,
plus reduced; still 60 fps on vs off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 15:17:33 -04:00
Lotus CIandClaude Opus 5.5 14731f2229 feat(seasonal): Halloween redesign — webs, a spider, bats, a ghost; no tint (#239)
CI / Build & Quality Checks (pull_request) Successful in 1m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Canceled after 31s
Draft for human review (#239 must be approved and closed by a human).

- Removed the full-screen purple wash, fog and the blurred green "moon" that
  sat over the member list and made it unreadable.
- Corner cobwebs (generated radial threads + sagging rings), thin and
  readable in both themes, smaller on phones.
- A spider on a thread in the empty space under the member list (near the
  top on phones), bobbing and swaying.
- Every 30 s a group of four bats flaps across the top of the screen (a real
  bat silhouette; wing beat without flattening them into dashes).
- Every 50 s a small, see-through ghost rises and fades (outlined so it
  shows on light themes; text stays readable through it).
- Theme-aware ink via --hw-ink/--hw-web: dark silhouettes on light themes,
  pale dusk-violet on dark ones.
- Reduced motion: webs and the spider at rest. Settings swatch: night sky,
  crescent moon with a bat across it, a corner web.

Rendered and inspected: light/dark/phone at the bat and ghost moments,
reduced on desktop and phone, the Settings swatch; 60 fps on vs off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 14:50:35 -04:00
Lotus CIandClaude Opus 5.5 6bb3f2af27 perf: load the QR libraries only for device verification (bundle budget)
CI / Build & Quality Checks (push) Successful in 2m0s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 10m51s
The main chunk had crept to 356.9 kB gzip against a 349.9 kB budget (a
hard gate on pull requests, only a warning on pushes, so it went unnoticed
until PR #241). jsQR (camera scanning) and qrcode (drawing the QR) were
statically imported by the device-verification components, so every
startup loaded them. Both are now dynamic imports: jsQR when the scanner
opens (fetched in parallel with the camera permission), qrcode when a QR is
drawn.

Main chunk 356.9 → 302.5 kB gzip (largest chunk is now matrix-sdk at
304.7 kB, within budget); chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 14:33:24 -04:00
jaredandClaude Opus 5.5 27c6dacc60 Merge pull request 'Seasonal: Autumn redesign (#237), needs human approval' (#241) from seasonal/autumn into lotus
CI / Build & Quality Checks (push) Successful in 1m48s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 2m20s
Merge seasonal/autumn: Autumn redesign (#237), approved by Jared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 14:29:08 -04:00
Lotus CIandClaude Opus 5.5 3e132069c0 feat(seasonal): Autumn pass 2 — no tint, a gust, a real preview (#237)
CI / Build & Quality Checks (pull_request) Failing after 1m30s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 5s
Draft for human review (#237 must be approved and closed by a human).

- Removed the full-screen amber wash, sun-shaft bands and vignette: they
  turned the user's theme beige (muddy brown on dark) and lowered contrast
  everywhere. The leaves carry the season on their own. Also removed the
  dust motes (invisible specks) and the now-unused keyframes.
- Gust: every 42 s (first ~8 s after load) five leaves blow across the screen
  together, rising and spinning, in ~5 s. Linear timing — an eased curve is
  applied per keyframe segment and made them hang mid-air for a second.
- Softer flutter (leaves flattened into slivers mid-flip), stronger leaf
  colour (opacity 0.58–0.73, less pale gold tip), brighter on dark themes.
- Reduced motion on phones: no icon rail there, so the resting leaves tuck
  further off-edge and smaller instead of landing on the timeline icons.
- Settings swatch: new optional `preview` prop on seasonal overlays; Autumn
  draws a warm backdrop with arranged leaves (the reduced scene rendered in
  the tile as a black square with two edge fragments).

Rendered and inspected: light/dark/phone at 10/15/20 s, reduced on desktop
and phone, gust tracked frame by frame, the Settings swatch; 60 fps with the
theme on vs off (p95 frame 16.7 ms both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 13:58:15 -04:00
Lotus CIandClaude Opus 5.5 e080059f37 feat(seasonal): Autumn: leaves actually fall; real leaf shapes and palette (#237)
CI / Build & Quality Checks (pull_request) Failing after 17m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
Draft for human review (#237 must be approved and closed by a human).

- Bug: every falling leaf sat in a leaf-sized wrapper pinned at the top of
  the screen with `contain: layout paint style`. Paint containment clips
  children to that box, so a leaf stopped being drawn as soon as it fell out
  of its first ~40 px: the animated theme showed almost nothing while the DOM
  reported 10–16 leaves on screen. Now `contain: layout style`.
- Leaf shapes: a flag-style maple (bold lobes, stem) that stays recognisable
  at 30 px (the old outline read as a star/splat), an oak with a stem, and an
  aspen; gradient body with darker veins.
- Palette: crimson, pumpkin orange, gold, russet, burnt orange (was muted
  tans that read as beige on the light theme).
- 12 leaves at 28–52 px, opacity 0.5–0.65 so text under a passing leaf stays
  readable; brightened on the dark themes, where they sank into the wash.
- Reduced motion: resting leaves moved into empty layout areas (middle of
  the left rail, under the member list), partly off-edge, instead of on the
  sidebar, members, composer and corner controls.

Rendered and inspected at 10/15/20 s in light, dark, phone and reduced
motion (screenshots on #237).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 13:36:13 -04:00
Lotus CIandClaude Opus 5.5 75951f9040 feat(search): in:, before:/after:, has:, is:pinned operators (#106)
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m39s
Typed operators alongside the existing from:, each one setting the filter the
buttons already set (so typing and clicking end in the same state, shown in
the filter bar):

  in:general  in:"voice lounge"   room by name / alias / id (unique partial
                                  name ok); a room outside this page's scope
                                  switches to all rooms
  after:2026-09-01  before:7d     date range; relative h/d/w/m/y
  has:link | image | video | file contains-link / media filters
  is:pinned                       pinned-only filter

- searchOperators.ts: pure parser + room resolver (unit-tested); quoted
  values; keys case-insensitive; only at a word start, so URLs and times
  aren't mistaken for operators; anything it doesn't understand stays in the
  searched text with a notice listing the valid operators.
- Notices: no matching room; operators with no words or from: ("Add a word to
  search for, or a from:@user.") — then the text is kept in the box.
- from:bob now resolves to a known @bob:server (this homeserver first); the
  old path sent "@bob", which matched nobody unless picked from autocomplete.

Verified in Chromium on a local Synapse: `raid in:"Thread Lab" from:bob`
sets rooms=!…Thread Lab, senders=@bob:localhost and finds bob's message;
dates, an unknown room, operators-only and has:gif give the expected params
and notices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 12:54:10 -04:00
Lotus CIandClaude Opus 5.5 06bb896a00 chore(deps): element-call 0.25.0-lotus.16 (#146)
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 9m38s
io.lotus.mic_level for the mute-button level meter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 12:14:29 -04:00
Lotus CIandClaude Opus 5.5 3449338b3e feat(call): mic level meter on the mute button (#146)
While unmuted, the microphone button (call view and the bottom call bar)
shows three small green bars in the icon's corner driven by the fork's
io.lotus.mic_level (element-call v0.25.0-lotus.16), so you can see the mic is
actually picking you up. Nothing is drawn while muted or silent; the button's
label is unchanged (the bars are decorative).

Your own name no longer appears in the "… is speaking" line: that is now for
everyone else, and your voice is on the meter. Avatar speaking rings still
include you, and the "You're muted" talking-while-muted notice is unchanged.

Verified in a local call with a fake-tone mic: data-mic-level cycles 3/2/1
with the tone on both buttons, disappears on mute and returns on unmute; the
status line reads "bob is speaking..." only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 12:09:34 -04:00
Lotus CIandClaude Opus 5.5 75d55e861b feat(composer): offer to format pasted code as a code block (#107)
CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s
When a multi-line paste looks like code, a chip above the composer asks
"That looks like code. Format it as a code block (js)?" with Format as code /
No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and
Settings → Editor → "Offer to Format Pasted Code" turns it off.

- utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation
  with depth changes, statement terminators, operators + brackets, keywords,
  monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted
  replies, URL lists, long sentence-punctuated lines, plain-word lines,
  markdown lists. Language guess only when fairly sure (js/ts/python/sql/
  rust/c/php).
- Accept rebuilds the pasted paragraphs as one code block from their plain
  text (code lines hold text only) and leaves the caret after it.
- Code blocks carry an optional lang → <code class="language-js"> (whitelisted
  identifier only).

Verified in Chromium: pasting a JS function shows the chip; Format as code →
sent formatted_body is <pre><code class="language-js"> with indentation intact;
a three-line prose paste shows no chip; typing after a paste dismisses it.
Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 11:05:49 -04:00
Lotus CIandClaude Opus 5.5 e91b5fe10e chore(deps): element-call 0.25.0-lotus.15 (element-call #36)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 13m14s
Each person's call volume is now remembered across calls, reloads and
reconnects (per Matrix user, local only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 10:09:12 -04:00
Lotus CIandClaude Opus 5.5 1ab7514741 fix(a11y): open a message image from the keyboard (#164)
CI / Build & Quality Checks (push) Successful in 2m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 7m55s
Timeline images were focusable (tabIndex 0) but only opened on click, so a
keyboard user could Tab to one and do nothing with it, and screen readers
heard a plain image. They are now role="button" labelled "View image: <name>"
and open the viewer on Enter or Space. Focus already returns to the image when
the viewer closes (checked for mouse and keyboard).

Verified in Chromium: Tab → Enter opens the viewer, Escape returns focus to
"View image: red.png", Space opens it again; chromium e2e suite 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 23:39:43 -04:00
Lotus CIandClaude Opus 5.5 2b1f4ee5bc feat(threads): "View Thread (N replies)" on a thread's root message (#165)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 12m7s
The SDK gives a thread root its own id as threadRootId, so the
`!isThreadedMessage` guard hid the thread action on exactly the message that
has a thread; the "N replies" chip was the only way in. Roots now get the
action in both the hover bar and the ⋮ menu, labelled "View thread" /
"View Thread (14 replies)" with the thread icon; other messages keep
"Reply in Thread". Same handler, which opens the thread panel.

Verified in Chromium: on a 14-reply root both entries show and open the panel;
an ordinary message still shows "Reply in thread".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:35:39 -04:00
Lotus CIandClaude Opus 5.5 b71f9c95c6 fix(emoji): flags render on Windows; "Twitter emoji" works again
Windows' emoji font has no flag glyphs, so 🇺🇸 (:flag_us:) showed as the
letters "US". Two fixes:

- A TwemojiFlags @font-face over the bundled Twemoji file, limited by
  unicode-range to the regional-indicator letters (U+1F1E6–1F1FF), is put in
  the font stack on Windows only. Every other emoji stays native, other
  platforms keep their own flags, and the file is fetched only when a flag is
  on screen.
- The Appearance font setting overwrote --font-secondary with a stack that
  had no emoji families at all, so neither this nor the existing "Twitter
  emoji" switch reached message text. The font map now keeps
  var(--font-flags), var(--font-emoji) before the generic family.

Verified in Chromium via CDP platform fonts: with a Windows user agent 🇺🇸 is
drawn by "Twemoji Mozilla" while 😀 stays on the system emoji font; with a
Linux user agent both stay native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:33:23 -04:00
Lotus CIandClaude Opus 5.5 c0e8334f03 chore(deps): element-call 0.25.0-lotus.14 (#43)
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m5s
Layout / settings / reactions over the widget API plus the
io.lotus.controls_state report the call bar now uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:24:06 -04:00
Lotus CIandClaude Opus 5.5 3e69c715e9 test(e2e): thread-panel aria snapshot includes "started by …" (#165)
The thread header subtitle now says who started the thread and when
(64af736c); the snapshot's room-name line is a pattern for that.
CI run 2104's Playwright job failed on this; the chromium suite passes
locally (19 passed, 3 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:22:18 -04:00
Lotus CIandClaude Opus 5.5 a0d1c565d0 feat(call): drive layout / settings / reactions over the widget API (#43)
Once the fork reports io.lotus.controls_state (element-call
v0.25.0-lotus.14), the call bar's Spotlight/Grid View, Reactions and Settings
send io.lotus.set_layout / toggle_reactions / open_settings instead of
clicking Element Call's hidden buttons, and the call bar's screenshare and
layout state come from that report instead of a MutationObserver on EC's DOM.
Older forks never send the report and keep the DOM path.

Screensharing itself is still started/stopped via EC's button: it needs the
user's click to reach the frame (Capability Delegation, step 3 of #43).

Verified in a two-person local call (Synapse + LiveKit) against a locally
built fork: controls_state arrives on join; Spotlight View / Grid View flip
EC's layout and the reported layout; Reactions opens EC's reactions menu;
Settings opens EC's settings modal; the four actions go over postMessage;
starting and stopping a screenshare flips the host's button via the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 22:16:50 -04:00
Lotus CIandClaude Opus 5.5 a932b1999a feat(threads): "Go to message" from the Threads list (#165)
A row in the Threads list only opened the thread panel; there was no way to
get to the root message in the room's timeline. Each row now has a small
"Go to message" button (a sibling of the row button, laid over its corner —
no nested buttons) that navigates the room to the root event. On a phone,
where the list covers the timeline, it also closes the list, and the button
is the bigger touch size.

Verified in Chromium: desktop jumps to the root with the list still open;
Pixel 7 jumps and closes the list (34 px target).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:58:14 -04:00
Lotus CIandClaude Opus 5.5 64af736c6e feat(threads): thread panel header says who started it and when (#165)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Failing after 10m28s
The subtitle was only the room name. It now reads e.g. "Thread Lab · started
by alice, Fri 09:18 PM" (the user's clock/date preferences via the #139
formatter), which is what you want to know when arriving from the Threads
list. Verified in the 360 px desktop panel: fits without clipping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:53:13 -04:00
Lotus CIandClaude Opus 5.5 c088b4c0e6 feat(media): show the full caption under the media in the viewer (#164)
A captioned image/video (MSC2530: `filename` set and `body` differs) put the
caption in the header, truncated to one line, so a long caption was
unreadable. The header now shows the file name; the caption is shown in full
under the media (wrapped, scrolls past 25vh). The image alt text uses the
caption, and Download now saves under the real file name instead of the
caption.

Verified in Chromium: header "lake-sunset.png", the 150-character caption
fully visible below the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:51:33 -04:00
Lotus CIandClaude Opus 5.5 ea07fb5087 fix(media): bigger prev/next arrows in the media viewer on touch screens (#164)
The viewer's arrows were 32 px at the screen edges on a phone, under the
44 px target the rest of the mobile work uses. On a coarse pointer they use
the next IconButton size (48 px measured on a Pixel 7); desktop is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:49:36 -04:00
Lotus CIandClaude Opus 5.5 e39714a6e0 refactor(types): typed send helpers replace 16 as any casts (#210)
- sendRoomMessage (composer's fire-and-forget sends: text, location, voice,
  files, GIFs) and sendRoomEvent (polls, poll responses/ends, forwards,
  reactions, edits) in utils/room.ts carry the one cast each needs
  (`keyof TimelineEvents` / `RoomMessageEventContent`, no `any`).
- sendRoomMessage also swallows the rejected promise: a failed send already
  shows on the local echo (Failed to send + Retry, or the consent prompt), so
  it no longer surfaces as an unhandled error in the console.
- getAccountData narrows to `keyof AccountDataEvents`; ForwardMessageDialog's
  guard now narrows `contentToSend` itself (same behaviour).
- `as any` 39 → 23; eslint warnings 46 → 36, ratchet tightened to 36.

Verified in Chromium on a local Synapse: a text message, a quick reaction and
an edit all reach the server with the right content; a consent-blocked send
no longer logs an unhandled MatrixError. 1219 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:47:44 -04:00
Lotus CIandClaude Opus 5.5 39589b40f3 feat(media): two-finger pinch zoom in the media viewer (#164)
Zoom on a phone was only the +/- buttons; the pinch people try first did
nothing. usePinchZoom tracks a two-finger gesture on the viewer's media area
and scales from the zoom it started at (clamped 1×–5×, snapping back to
exactly 1× when released near it, so one-finger swipe navigation re-arms).
The area gets touch-action: none so the browser doesn't zoom the page
instead. One-finger swipe and pan already ignore multi-touch.

Verified on an emulated Pixel 7 with CDP two-point touch: spread 80→200 px
gives 250 %, closing to 120 px gives 150 %, closing fully returns to 100 %,
and a one-finger swipe afterwards still moves 2/2 → 1/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:33:13 -04:00
Lotus CIandClaude Opus 5.5 c1661e48fa feat(widgets): ask before a room widget may read or send in the room (#205)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 9m15s
Room widgets were limited to display-only capabilities because the driver
couldn't serve anything else. Now:

- classifyWidgetCapabilities: display caps are still granted silently; reading
  or sending events/state in the widget's OWN room is offered to the user;
  everything else (other rooms' timelines, to-device, account data, uploads,
  user directory, delayed events) stays denied. Writing protected state
  (power levels, join rules, encryption, membership, ACLs, widgets, …) is
  never offered, and the driver refuses it again at send time.
- WidgetPermissionPrompt names the widget, the host that runs it and who added
  it; each request in plain words ("Send messages of type m.text in this
  room · as you"); reading is pre-ticked, sending is not; Deny / Escape grant
  nothing extra. "Remember my choice" stores it per viewer (localStorage),
  tied to the widget URL, so a changed URL asks again.
- GeneralWidgetDriver implements sendEvent / readRoomTimeline / readRoomState
  / readEventRelations, each refusing any room but the widget's own;
  RoomWidgetView feeds the room's live (decrypted) events and state updates,
  which ClientWidgetApi forwards only if the widget holds the capability.

Verified in Chromium with a cross-origin test widget against a local Synapse:
power-levels and timeline:* requests are never shown; after allowing
send+read the widget's message lands on the server, its power-levels write
is rejected, it receives live messages, and after a reload the remembered
choice skips the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:17:18 -04:00
Lotus CIandClaude Opus 5.5 c96c47dd0d feat: explain "Failed to send" when the homeserver wants its terms accepted
CI / Build & Quality Checks (push) Successful in 1m45s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 14s
CI / Playwright smoke (e2e) (push) Successful in 6m58s
A homeserver with a consent requirement (Synapse user_consent) rejects sends
with 403 M_CONSENT_NOT_GIVEN until the user accepts its current terms. The
message just showed "Failed to send" with no reason.

Listen for the SDK's HttpApiEvent.NoConsent and show a dialog naming the
user's own homeserver (the client works with any server, so no Lotus-specific
wording), with "Review and accept" opening the server's consent_uri (http(s)
only; anything else is dropped) and "I've accepted — retry sending" resending
every event that failed for this reason. "Later" snoozes it for 10 s so
background retries don't re-open it immediately.

Verified in Chromium against a local Synapse with the send endpoint answering
M_CONSENT_NOT_GIVEN: dialog shows, link opens, retry delivers the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:08:15 -04:00
Lotus CIandClaude Opus 5.5 103c6f4624 fix(a11y): closing a header/sidebar menu returns focus to its button
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m15s
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.

Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:16:12 -04:00
Lotus CIandClaude Opus 5.5 7f2e93d389 fix(a11y): name the headless modals too — Seen by, source, file viewers (#185)
CI / Build & Quality Checks (push) Successful in 1m41s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 13m14s
Follow-up to ce8ed89f for the modals that have no visible heading to
point at: the three "Seen by" reader lists (read-receipt pill, "is
following", message menu), View source, the text/PDF file viewers, the
room-card join error and the user-profile modal get role="dialog",
aria-modal and an aria-label; their traps move focus in (fallbackFocus
on the dialog) where the trap is local.

Verified: the receipt pill opens a "Seen by" dialog with focus on
Close; Escape closes it and focus returns to the pill.

The remaining unnamed Modal/Dialog uses are startup/loading and error
screens (config, feature check, spec versions, client root, password
reset) and wrappers around components that carry their own role (image
viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:12:13 -04:00
Lotus CIandClaude Opus 5.5 ce8ed89fdc fix(a11y): 36 modal dialogs announce themselves and take focus (#185)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Most modals rendered a folds Dialog/Modal with no role and no name, and
their focus traps used `initialFocus: false`, so focus stayed behind the
modal and a screen reader never announced it.

- 32 dialogs with a visible heading: role="dialog", aria-modal,
  aria-labelledby → the heading (given an id), tabIndex=-1.
- 4 dialogs that already had a name (Leave Room, room topic viewer,
  server ACL, room-nav prompt): role + aria-modal.
- Their focus traps drop `initialFocus: false` for focus-trap's default
  (keep an already-focused autoFocus field, else the first tabbable
  element) with the dialog itself as fallbackFocus, so a dialog without
  a tabbable node can't crash the trap. Traps that live in a parent
  (UIA stages, Logout, Forward, Invite) get the semantics only.
- The file drop overlay is deliberately left alone (not a dialog).

Checked at runtime: Join with Address, Delete Message, Report Message,
Leave Room and Logout open as named dialogs with focus inside and close
with Escape (Tab first when a text field has focus — the shared
stopPropagation keeps Escape from discarding typed text, by design).
The axe e2e spec (6 tests) passes; eslint warnings unchanged (46).
17 modals with no heading (image/file viewers, loading screens) remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:06:10 -04:00
Lotus CIandClaude Opus 5.5 359c79a440 feat(desktop): ask once what closing the window should do (cinny-desktop #5)
CI / Build & Quality Checks (push) Successful in 1m57s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 12m7s
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).

The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:51:18 -04:00
Lotus CIandClaude Opus 5.5 206a3e933a fix(a11y): thread chip says it opens the thread, and whether it's unread/muted (#179)
CI / Build & Quality Checks (push) Successful in 2m15s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The "N replies · <time>" chip under a thread root was named only by its
visible text, so a screen reader never said it opens the thread, and the
unread dot / muted bell were visual-only. Its accessible name is now the
visible text first (WCAG 2.5.3) plus "view thread", "unread replies" and
"muted" as applicable, e.g. "1 reply · 12:16 PM, view thread".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:18:35 -04:00
Lotus CIandClaude Opus 5.5 d5aa18e3ab fix(pwa): maskable icons had a visible black box behind the logo (#181 L2)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Canceled after 12s
The logo sat on a pure-black (#000) square inside the icon's #0A0A0A
background, so the adaptive (circle/squircle) home-screen icon showed a
faint square around the logo — the "floating in an odd box" the
checklist warns about. Recoloured that backing square to the background.

Checked: both icons load in production (200, image/png); the logo's
furthest pixel is 29 % of the width from centre (Android safe zone: 40 %),
corners opaque — so no clipping in any launcher mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:16:39 -04:00
Lotus CIandClaude Opus 5.5 862a128102 fix(a11y): skip link focuses <main>; ? dialog and reaction viewer announce (#185)
CI / Build & Quality Checks (push) Canceled after 1m33s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
- Skip link: following #main-content left focus on <body> (the <main>
  target wasn't focusable), so nothing was announced and the URL got a
  stray fragment. <main> is now tabIndex=-1 and the link focuses it.
- Keyboard-shortcuts dialog (?): no role, and focus stayed in the
  timeline, so it opened silently. Now role="dialog" aria-modal, and focus
  moves into it (Escape still returns focus to where you were).
- Reaction viewer (both the reaction context-menu path and "View
  Reactions"): same — role="dialog" aria-modal aria-label="Reactions",
  focus moves in.

Keyboard-only checks (Playwright): Tab → skip link → Enter focuses
<main>; Tab reaches the room list, Enter opens a room, typing lands in the
composer, Enter sends (verified on the server); focus ring visible. "?"
opens the dialog with focus inside, Escape returns to the same element,
"?" in the composer stays text. Topic viewer, Search dialog and reaction
viewer all return focus to their opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:15:02 -04:00
Lotus CIandClaude Opus 5.5 c0a9b2da6b fix(a11y): call toggles no longer contradict their labels (#187 DP7/8)
CI / Build & Quality Checks (push) Successful in 2m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m15s
The call-bar and status-bar toggles change their label with the state
("Deafen"/"Undeafen", "Turn off/on microphone", "Start/Stop Video", …)
AND set aria-pressed, which made screen readers say "Deafen, pressed"
while you were not deafened, and gave mic/screenshare a pressed state in
one bar but not the other. Per the ARIA practice, a toggle uses either a
changing action label or aria-pressed with a fixed label; these use the
former, so aria-pressed is removed from all nine.

Checked in a live call: every control now announces only its action, in
both states (deafen → "Undeafen", mic → "Turn on microphone", …).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:59:45 -04:00
Lotus CIandClaude Opus 5.5 ed87b700d9 fix(a11y): announce the incoming-call overlay (role=alertdialog + name)
CI / Build & Quality Checks (push) Successful in 2m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The full-screen incoming-call overlay (shown when you're not already in a
call — the common case) had no role or accessible name on any ancestor of
its buttons, so a screen reader landed on an unlabelled button with no
hint that a call was ringing. The in-call banner variant already had
role="alert" + a label. The overlay's Dialog is now role="alertdialog",
aria-modal, labelled "Incoming voice|video call from <caller>".

Found while verifying #187 DP6 (decline still dismisses when the decline
send fails — it does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:56:51 -04:00
Lotus CIandClaude Opus 5.5 f62c5d5778 fix(status): a status cleared on another device stays cleared (#187 DP3)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Status messages are saved per device and re-sent with every presence
heartbeat (a presence write without status_msg clears it on Synapse). A
device never receives its own user's presence changes made on other
devices, so the DP3 fix in db864326 — mirroring remote changes from the
Profile page — could never fire: device B kept a status that device A had
cleared and re-published it on its next state change.

Heartbeats now reconcile with the server first: GET our own presence,
send the server's current status_msg and bring the local copy in line.
Falls back to the local copy when the read fails, when the server shows
us offline (invisible mode clears the status by design), and for 15 s
after this device saved/cleared its own status (a server read that
hasn't caught up yet can't override a fresh save).

Verified with two sessions of the same user against local Synapse:
B sets "dp3 old status" → A clears it → B goes hidden→visible → server
stays "" and B's local copy is removed (before: back to "dp3 old status").
A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:50:13 -04:00
Lotus CIandClaude Opus 5.5 cccd78fd43 fix(embeds): decode leftover HTML entities in link-preview text (#187 DP17)
CI / Build & Quality Checks (push) Successful in 4m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Steam double-encodes its meta tags (`&amp;quot;`), so after Synapse's
single decode the card showed "The &quot;Perpetual Testing
Initiative&quot; …". The preview's og:title / og:description /
og:site_name are now decoded once where the preview is fetched, so every
card (about 20 read those fields directly) gets clean text. Rendered as
React text only, so decoding can't inject markup; exactly one level is
decoded.

Verified on the Portal 2 store link: "The \"Perpetual Testing
Initiative\" has been expanded…", no literal &quot; left on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:24:28 -04:00
Lotus CIandClaude Opus 5.5 e06600afe1 fix(a11y): Escape no longer yanks focus into the composer (#187 DP9)
CI / Build & Quality Checks (push) Successful in 6m18s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Canceled after 14m59s
RoomView focuses the composer on any "typing" key pressed while nothing
editable has focus, and Escape counted as typing. Closing the GIF picker
with Esc (its focus trap returns focus to the GIF button) therefore
landed in the composer instead, and so did Esc on any other room control.
Escape, CapsLock, Insert, ContextMenu, PrintScreen and Pause are now
excluded like the other non-typing keys.

Verified at 1300 px and 320 px: Esc closes the picker and focus is back
on "Insert GIF"; the 320 px picker fits with no page overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:02:46 -04:00
Lotus CIandClaude Opus 5.5 f3722ae859 refactor(types): drop 127 unnecessary as any casts (#210)
Removed every `as any` in src/, then restored only the ones tsc still
needs: 163 → 36. 112 were in lotus-terminal.css.ts (`'… !important' as
any` etc. — vanilla-extract's style types accept these strings as-is),
so its file-wide eslint-disable goes too. The casts were type-only, so
emitted code and generated CSS are unchanged.

eslint warnings 49 → 46; the ratchet is tightened to match.
tsc clean; 1202 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 23:32:42 -04:00
149 changed files with 6522 additions and 1729 deletions
@@ -1,6 +1,6 @@
- complementary "Thread":
- paragraph: Thread
- paragraph: A11y Room
- paragraph: /A11y Room · started by e2e_a11y_\w+, .+/
- button "Thread notifications":
- img
- button "Close thread":
+50
View File
@@ -169,6 +169,56 @@ test.describe('local homeserver regression', () => {
await ctx.close();
});
test('a thread I started keeps its unread replies while I view the room (#217)', async ({
page,
}) => {
const room = await createRoom(alice, 'Thread Unread Room', { invite: [bob.userId] });
await joinRoom(bob, room);
const inThread = (root: string) => ({
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
const root = await sendText(alice, room, 'my thread root');
await loginUI(page, alice);
await openRoom(page, room);
// While the room is open and at the bottom: a thread reply, then a newer
// main-timeline message. Neither may clear the thread without opening it.
await sendText(bob, room, 'reply in your thread', inThread(root));
await sendText(bob, room, 'newer main message');
await expect(page.getByText('newer main message')).toBeVisible();
const chip = page
.locator('[data-message-item]', { hasText: 'my thread root' })
.getByRole('button', { name: /1 reply/ });
const threadUnread = async () => {
// not_types varies so Synapse's sync response cache can't serve a stale answer.
const filter = {
room: {
rooms: [room],
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
},
};
const sync = await api<{
rooms: {
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
};
}>(
'GET',
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
alice.token,
);
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
};
await page.waitForTimeout(2000); // let any receipt the room view would send go out
expect(await threadUnread()).toBe(true);
await expect(chip).toHaveAccessibleName(/unread replies/);
await chip.click();
await expect.poll(threadUnread).toBe(false);
});
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
const room = await createRoom(alice, 'Lightbox Room');
const png = Buffer.from(
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.13",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.13",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.13/element-call-embedded-0.25.0-lotus.13.tgz",
"integrity": "sha512-E+icN4zWyjHmZyGFi1Tf+99fIQXhYMjCRVvMflOJHP3h0Frph4AU0LpKSLsIEWQ3I+DEnYwpkMG/u7ZVEX+kMw==",
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+2 -2
View File
@@ -12,7 +12,7 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 49",
"check:eslint": "eslint src/* --max-warnings 36",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.13",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Build static picker thumbnails for avatar decorations.
The decorations are animated APNGs (~1 MB each). The settings picker shows
static WebP thumbnails instead and only loads the animated file on hover,
focus or selection, so browsing the catalog costs a few MB, not hundreds.
Usage: python3 scripts/makeDecorationThumbs.py <dir-of-slug.png> <out-dir>
Then upload <out-dir>/*.webp to `${DECORATION_CDN}/thumbs/`.
A missing thumbnail is harmless: the picker falls back to the full PNG.
Requires Pillow (pip install pillow).
"""
import sys
from pathlib import Path
from PIL import Image, ImageSequence
SIZE = 144 # 2x the 72px picker cell
def best_frame(im: Image.Image) -> Image.Image:
# Many animations start (or loop through) an empty frame, so pick the frame
# with the most visible pixels rather than frame 0.
best, best_score = None, -1
frames = list(ImageSequence.Iterator(im))
step = max(1, len(frames) // 24)
for frame in frames[::step]:
rgba = frame.convert('RGBA')
score = sum(rgba.getchannel('A').histogram()[33:])
if score > best_score:
best, best_score = rgba, score
return best
def main(src: str, out: str) -> None:
out_dir = Path(out)
out_dir.mkdir(parents=True, exist_ok=True)
for png in sorted(Path(src).glob('*.png')):
with Image.open(png) as im:
thumb = best_frame(im).resize((SIZE, SIZE), Image.LANCZOS)
thumb.save(out_dir / f'{png.stem}.webp', 'WEBP', quality=82, method=6)
if __name__ == '__main__':
if len(sys.argv) != 3:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2])
+21 -2
View File
@@ -38,7 +38,7 @@ import {
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -78,6 +78,7 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
@@ -180,7 +181,15 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
escapeDeactivates: false,
}}
>
<Dialog style={{ maxWidth: toRem(324) }}>
<Dialog
style={{ maxWidth: toRem(324) }}
// #187: the overlay had no role or name, so a screen reader landed
// on an unlabelled button with no hint that a call was ringing.
// Match the in-call banner (role="alert" + label).
role="alertdialog"
aria-modal="true"
aria-label={`Incoming ${info.intent === 'video' ? 'video' : 'voice'} call from ${getMemberName(info.room, info.sender) ?? info.sender}`}
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{getMemberName(info.room, info.sender)}
@@ -750,6 +759,12 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
// [Gitea #43] The in-frame screenshare button follows the same room policy
// as the host bar's (hidden where the server would refuse the share).
const { allowScreenshare } = useRoomCallPolicy(embed.room);
useEffect(() => {
embed.control.setFrameScreenshareAllowed(allowScreenshare);
}, [embed, allowScreenshare]);
usePttHaptics();
useCallAnnouncements(embed, joined);
useMutedTalkWarning(embed, joined);
@@ -867,6 +882,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate();
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
@@ -1348,6 +1364,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
zIndex: 1,
background: 'transparent',
cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
+114
View File
@@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Checkbox,
Dialog,
Header,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
config,
} from 'folds';
import { invokeTauri, isTauri, tauriInvoke, useTauriEvent } from '../hooks/useTauri';
import { useModalStyle } from '../hooks/useModalStyle';
/**
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
* should keep running in the tray (calls, messages, notifications) or quit —
* and, in the same moment, whether it should start when you sign in. Asked
* once; changeable in Settings → General. The native side sends
* `lotus-close-requested` only while the choice is still "ask", and never
* during a call (a call always goes to the tray).
*/
export function CloseBehaviorPrompt() {
const [open, setOpen] = useState(false);
const [launchOnLogin, setLaunchOnLogin] = useState(false);
const modalStyle = useModalStyle(420);
// Tells the native side we're listening (see get_close_behavior).
useEffect(() => {
if (!isTauri()) return;
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
}, []);
useTauriEvent('lotus-close-requested', () => setOpen(true));
const choose = useCallback(
(value: 'tray' | 'quit') => {
if (launchOnLogin) invokeTauri('plugin:autostart|enable');
setOpen(false);
invokeTauri('resolve_close_request', { value });
},
[launchOnLogin],
);
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#close-behavior-keep',
// Dismissing without choosing keeps the window open; the next close
// asks again.
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="close-behavior-title"
aria-describedby="close-behavior-body"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Text as="h2" size="H4" id="close-behavior-title">
Keep Lotus Chat running?
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="close-behavior-body">
Lotus Chat can keep running in the system tray when you close this window, so
messages, notifications and calls still reach you. Open it again from the tray icon.
</Text>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
size="300"
variant="Primary"
checked={launchOnLogin}
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
setLaunchOnLogin(e.currentTarget.checked)
}
/>
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
</Box>
<Box direction="Column" gap="200">
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
<Text size="B400">Keep running in the tray</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
<Text size="B400">Quit when I close the window</Text>
</Button>
</Box>
<Text size="T200" priority="300">
You can change this in Settings → General.
</Text>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
config,
} from 'folds';
import {
EventStatus,
HttpApiEvent,
HttpApiEventHandlerMap,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle';
import { failedForConsent, safeConsentUri } from '../utils/consent';
// After "Later", don't re-open for every background retry; the next send the
// user makes after this window explains again.
const SNOOZE_MS = 10_000;
/**
* A homeserver with a consent requirement (e.g. Synapse's user_consent) blocks
* sending until its terms are accepted, answering 403 M_CONSENT_NOT_GIVEN with a `consent_uri`. Without
* this the message just shows "Failed to send". The SDK emits
* HttpApiEvent.NoConsent for every such response; we explain why, link to the
* acceptance page, and offer to resend what was blocked once accepted.
*/
export function ConsentRequiredPrompt() {
const mx = useMatrixClient();
const [consentUri, setConsentUri] = useState<string>();
const [open, setOpen] = useState(false);
const [opened, setOpened] = useState(false);
const [blockedCount, setBlockedCount] = useState(0);
const snoozedUntil = useRef(0);
const blocked = useRef(new Map<MatrixEvent, Room>());
const modalStyle = useModalStyle(440);
const server = mx.getDomain() ?? 'Your homeserver';
useEffect(() => {
const onNoConsent: HttpApiEventHandlerMap[HttpApiEvent.NoConsent] = (_message, uri) => {
const safe = safeConsentUri(uri);
if (safe) setConsentUri(safe);
if (Date.now() >= snoozedUntil.current) setOpen(true);
};
const onLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, room) => {
if (failedForConsent(event)) blocked.current.set(event, room);
else if (!blocked.current.delete(event)) return;
setBlockedCount(blocked.current.size);
};
mx.on(HttpApiEvent.NoConsent, onNoConsent);
mx.on(RoomEvent.LocalEchoUpdated, onLocalEcho);
return () => {
mx.removeListener(HttpApiEvent.NoConsent, onNoConsent);
mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEcho);
};
}, [mx]);
const review = useCallback(() => {
if (!consentUri) return;
window.open(consentUri, '_blank', 'noopener,noreferrer');
setOpened(true);
}, [consentUri]);
const retry = useCallback(() => {
setOpen(false);
setOpened(false);
const pending = Array.from(blocked.current.entries());
blocked.current.clear();
setBlockedCount(0);
pending.forEach(([event, room]) => {
if (event.status === EventStatus.NOT_SENT) mx.resendEvent(event, room);
});
}, [mx]);
const later = useCallback(() => {
snoozedUntil.current = Date.now() + SNOOZE_MS;
setOpen(false);
}, []);
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#consent-review',
fallbackFocus: '#consent-later',
onDeactivate: later,
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
>
<Dialog
variant="Surface"
role="alertdialog"
aria-modal="true"
aria-labelledby="consent-title"
aria-describedby="consent-body"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Text as="h2" size="H4" id="consent-title">
Accept the terms to keep chatting
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box id="consent-body" direction="Column" gap="200">
<Text priority="400">
Your message wasn&apos;t sent. <b>{server}</b> requires you to review and accept
its terms of service before you can send messages.
</Text>
<Text priority="400">
{consentUri
? 'Nothing you sent is lost: accept the terms, then come back here and retry.'
: 'Your homeserver didn’t include a link to its terms. Contact its administrator, or accept them in another Matrix app, then retry.'}
</Text>
</Box>
<Box direction="Column" gap="200">
{consentUri && (
<Button
id="consent-review"
variant={opened ? 'Secondary' : 'Primary'}
fill={opened ? 'Soft' : 'Solid'}
onClick={review}
after={<Icon size="100" src={Icons.External} />}
>
<Text size="B400">Review and accept</Text>
</Button>
)}
<Button
variant={opened ? 'Primary' : 'Secondary'}
fill={opened ? 'Solid' : 'Soft'}
onClick={retry}
>
<Text size="B400">
{blockedCount > 0 ? "I've accepted — retry sending" : "I've accepted"}
</Text>
</Button>
<Button id="consent-later" variant="Secondary" fill="None" onClick={later}>
<Text size="B400">Later</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+21 -10
View File
@@ -8,7 +8,6 @@ import {
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import {
Box,
Button,
@@ -221,12 +220,16 @@ function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
const canvas = canvasRef.current;
if (!canvas) return;
// Byte-mode so the raw verification bytes round-trip (a string value would
// mangle high bytes via UTF-8).
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}).catch(() => undefined);
// mangle high bytes via UTF-8). The QR library is loaded on demand.
import('qrcode')
.then(({ default: QRCode }) =>
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}),
)
.catch(() => undefined);
}, [data]);
return (
<Box justifyContent="Center">
@@ -365,15 +368,23 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#deviceverification-dialog-1',
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="deviceverification-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="deviceverification-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="deviceverification-dialog-1-title" as="h2" size="H4">
Device Verification
</Text>
</Box>
+20 -4
View File
@@ -293,7 +293,15 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
const modalStyle = useModalStyle(480);
return (
<Dialog ref={ref} style={modalStyle}>
<Dialog
id="deviceverificationsetup-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="deviceverificationsetup-dialog-1-title"
tabIndex={-1}
ref={ref}
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -303,7 +311,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="deviceverificationsetup-dialog-1-title" as="h2" size="H4">
Setup Device Verification
</Text>
</Box>
@@ -331,7 +339,15 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
const modalStyle = useModalStyle(480);
return (
<Dialog ref={ref} style={modalStyle}>
<Dialog
id="deviceverificationsetup-dialog-2"
role="dialog"
aria-modal="true"
aria-labelledby="deviceverificationsetup-dialog-2-title"
tabIndex={-1}
ref={ref}
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -341,7 +357,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="deviceverificationsetup-dialog-2-title" as="h2" size="H4">
Reset Device Verification
</Text>
</Box>
+11 -2
View File
@@ -52,7 +52,16 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
return (
<Dialog variant="Surface" ref={ref} style={modalStyle}>
<Dialog
id="logoutdialog-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="logoutdialog-dialog-1-title"
tabIndex={-1}
variant="Surface"
ref={ref}
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -62,7 +71,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="logoutdialog-dialog-1-title" as="h2" size="H4">
Logout
</Text>
</Box>
+16 -6
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Button, color, config, Text } from 'folds';
import jsQR from 'jsqr';
type QrScannerProps = {
onScan: (bytes: Uint8ClampedArray) => void;
@@ -10,6 +9,7 @@ type QrScannerProps = {
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
// jsQR is loaded on demand: it is large and only needed while scanning.
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [error, setError] = useState<string>();
@@ -20,15 +20,22 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
let raf = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let decode: typeof import('jsqr').default | undefined;
const tick = () => {
const video = videoRef.current;
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
if (
decode &&
!doneRef.current &&
video &&
ctx &&
video.readyState === video.HAVE_ENOUGH_DATA
) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = jsQR(image.data, image.width, image.height);
const result = decode(image.data, image.width, image.height);
if (result && result.binaryData.length > 0) {
doneRef.current = true;
onScan(new Uint8ClampedArray(result.binaryData));
@@ -40,9 +47,12 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
const [lib, media] = await Promise.all([
import('jsqr'),
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }),
]);
decode = lib.default;
stream = media;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
@@ -71,7 +71,15 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Modal size="500" style={MOBILE_FULLSCREEN}>
<Modal
id="user-profile-dialog"
role="dialog"
aria-modal="true"
aria-label="User profile"
tabIndex={-1}
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 }}>
@@ -12,6 +12,7 @@ import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { useAutocompleteEnter } from '../../../hooks/useAutocompleteEnter';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
@@ -126,19 +127,21 @@ export function RoomMentionAutocomplete({
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteRoomIds.length === 0) {
const alias = roomAliasFromQueryText(mx, query.text);
handleAutocomplete(alias, alias);
return;
}
const rId = autoCompleteRoomIds[0];
const r = mx.getRoom(rId);
const name = r?.name ?? rId;
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
});
});
const pickTop = () => {
if (autoCompleteRoomIds.length === 0) {
const alias = roomAliasFromQueryText(mx, query.text);
handleAutocomplete(alias, alias);
return;
}
const rId = autoCompleteRoomIds[0];
const r = mx.getRoom(rId);
const name = r?.name ?? rId;
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
};
useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop));
// Same rule as people: a fully typed #alias:server sends as typed.
useAutocompleteEnter(!query.text.includes(':'), pickTop);
return (
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
@@ -13,6 +13,7 @@ import {
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { useAutocompleteEnter } from '../../../hooks/useAutocompleteEnter';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
@@ -124,21 +125,24 @@ export function UserMentionAutocomplete({
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (query.text === 'room') {
handleAutocomplete(roomAliasOrId, '@room');
return;
}
if (autoCompleteMembers.length === 0) {
const userId = userIdFromQueryText(mx, query.text);
handleAutocomplete(userId, userId);
return;
}
const roomMember = autoCompleteMembers[0];
handleAutocomplete(roomMember.userId, roomMember.name);
});
});
const pickTop = () => {
if (query.text === 'room') {
handleAutocomplete(roomAliasOrId, '@room');
return;
}
if (autoCompleteMembers.length === 0) {
const userId = userIdFromQueryText(mx, query.text);
handleAutocomplete(userId, userId);
return;
}
const roomMember = autoCompleteMembers[0];
handleAutocomplete(roomMember.userId, getMemberName(room, roomMember.userId));
};
useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop));
// Enter picks while a partial name is being typed ("hey @bo"). Once a full
// user ID has been typed ("/kick @alice:server"), Enter sends as typed.
useAutocompleteEnter(!query.text.includes(':'), pickTop);
const getName = (member: RoomMember) => getMemberName(room, member.userId);
+19
View File
@@ -89,3 +89,22 @@ test('markdown: no math conversion inside a backtick span, math outside still co
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
assert.ok(out.includes('data-mx-maths="y"'));
});
test('[#107] a code block language becomes class="language-…"', () => {
const block = {
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('let a = 1;')])]) as object),
lang: 'js',
} as unknown as Descendant;
assert.equal(
toMatrixCustomHTML(block, OPTS),
'<pre><code class="language-js">let a = 1;\n</code></pre>',
);
});
test('[#107] an unsafe language value is dropped', () => {
const block = {
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('x')])]) as object),
lang: 'js" onclick="x',
} as unknown as Descendant;
assert.equal(toMatrixCustomHTML(block, OPTS), '<pre><code>x\n</code></pre>');
});
+4 -1
View File
@@ -86,7 +86,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
case BlockType.CodeLine:
return `${children}\n`;
case BlockType.CodeBlock:
return `<pre><code>${children}</code></pre>`;
// [Gitea #107] Only a plain identifier is ever put in the class.
return node.lang && /^[a-z0-9+#-]{1,20}$/.test(node.lang)
? `<pre><code class="language-${node.lang}">${children}</code></pre>`
: `<pre><code>${children}</code></pre>`;
case BlockType.QuoteLine:
return `${children}<br/>`;
case BlockType.BlockQuote:
+2
View File
@@ -64,6 +64,8 @@ export type CodeLineElement = {
};
export type CodeBlockElement = {
type: BlockType.CodeBlock;
/** [Gitea #107] Optional language → `<code class="language-…">`. */
lang?: string;
children: CodeLineElement[];
};
export type QuoteLineElement = {
@@ -189,14 +189,21 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog style={modalStyle}>
<Dialog
id="inviteuserprompt-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="inviteuserprompt-dialog-1-title"
tabIndex={-1}
style={modalStyle}
>
<Box grow="Yes" direction="Column">
<Header
size="500"
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
>
<Box grow="Yes">
<Text size="H4" truncate>
<Text id="inviteuserprompt-dialog-1-title" size="H4" truncate>
{t('Organisms.InviteUser.invite')}
</Text>
</Box>
@@ -67,13 +67,21 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#joinaddressprompt-dialog-1',
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="joinaddressprompt-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="joinaddressprompt-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -82,7 +90,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="joinaddressprompt-dialog-1-title" as="h2" size="H4">
Join with Address
</Text>
</Box>
@@ -52,13 +52,21 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#leaveroomprompt-dialog',
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" aria-labelledby="leave-room-dialog-title" style={modalStyle}>
<Dialog
id="leaveroomprompt-dialog"
role="dialog"
aria-modal="true"
tabIndex={-1}
variant="Surface"
aria-labelledby="leave-room-dialog-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -52,13 +52,21 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#leavespaceprompt-dialog-1',
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="leavespaceprompt-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="leavespaceprompt-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -68,7 +76,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="leavespaceprompt-dialog-1-title" as="h2" size="H4">
Leave Space
</Text>
</Box>
+13 -5
View File
@@ -13,13 +13,21 @@ export const Reaction = as<
mx: MatrixClient;
count: number;
reaction: string;
/** Custom-emoji shortcode from the reaction event, used for its label. */
shortcode?: string;
useAuthentication?: boolean;
}
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
const shortcode = reaction.startsWith('mxc://')
? 'custom emoji'
: (getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction);
const label = `${shortcode} reaction, ${count} ${count === 1 ? 'person' : 'people'}`;
>(({ className, mx, count, reaction, shortcode, useAuthentication, ...props }, ref) => {
// Name a unicode reaction by the emoji itself: screen readers speak it by its
// proper name ("party popper"). Don't use getShortcodeFor here — it returns
// undefined until the lazy emoji data loads, so the same button would flip
// between "🎉 reaction" and "tada reaction" depending on timing (#179).
const name = reaction.startsWith('mxc://')
? shortcode
? `:${shortcode}:`
: 'custom emoji'
: reaction;
const label = `${name} reaction, ${count} ${count === 1 ? 'person' : 'people'}`;
return (
<Box
@@ -105,13 +105,18 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#file-viewer-2-dialog',
onDeactivate: () => setTextViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
id="file-viewer-2-dialog"
role="dialog"
aria-modal="true"
aria-label={`File: ${body}`}
tabIndex={-1}
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
@@ -194,13 +199,18 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#file-viewer-1-dialog',
onDeactivate: () => setPdfViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
id="file-viewer-1-dialog"
role="dialog"
aria-modal="true"
aria-label={`File: ${body}`}
tabIndex={-1}
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
@@ -47,6 +47,9 @@ type RenderImageProps = {
onError: () => void;
onClick: () => void;
tabIndex: number;
role: 'button';
'aria-label': string;
onKeyDown: (evt: React.KeyboardEvent<HTMLElement>) => void;
};
export type ImageContentProps = {
body: string;
@@ -188,6 +191,15 @@ export const ImageContent = as<'div', ImageContentProps>(
onError: handleError,
onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
tabIndex: 0,
// [Gitea #164] Keyboard users could Tab to the image but not open it.
role: 'button',
'aria-label': `View image: ${body}`,
onKeyDown: (evt) => {
if (evt.key !== 'Enter' && evt.key !== ' ') return;
evt.preventDefault();
if (onOpenViewer) onOpenViewer();
else setViewer(true);
},
})}
</Box>
)}
@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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 { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName } from '../../../utils/room';
import { getMemberName, sendRoomEvent } from '../../../utils/room';
import {
ParsedPoll,
PollResponse,
@@ -43,7 +42,7 @@ function computePollState(
const validIds = new Set(parsed.answers.map((a) => a.id));
const getRels = (type: string): MatrixEvent[] =>
relations.getChildEventsForEvent(eventId, 'm.reference', type as any)?.getRelations() ?? [];
relations.getChildEventsForEvent(eventId, 'm.reference', type)?.getRelations() ?? [];
const endEvents = [...getRels('m.poll.end'), ...getRels('org.matrix.msc3381.poll.end')];
const respEvents = [
@@ -127,18 +126,10 @@ export function PollContent({
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,
),
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.response'),
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.response'),
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end'),
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.end'),
];
relObjs.forEach((r) => {
r?.on(RelationsEvent.Add, refresh);
@@ -228,7 +219,7 @@ export function PollContent({
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, {
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
@@ -237,7 +228,7 @@ export function PollContent({
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
mx.sendEvent(roomId, 'm.poll.end' as any, {
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
@@ -76,13 +76,22 @@ export function ReadReceiptAvatars({
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#readreceipt-seenby-dialog',
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300" style={modalStyle}>
<Modal
id="readreceipt-seenby-dialog"
role="dialog"
aria-modal="true"
aria-label="Seen by"
tabIndex={-1}
variant="Surface"
size="300"
style={modalStyle}
>
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
+9 -2
View File
@@ -105,13 +105,20 @@ function ErrorDialog({
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#roomcard-error-dialog',
clickOutsideDeactivates: true,
onDeactivate: closeError,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog
id="roomcard-error-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
variant="Surface"
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text>{title}</Text>
@@ -26,6 +26,10 @@ export const RoomTopicViewer = as<
return (
<Modal
id="roomtopicviewer-dialog"
role="dialog"
aria-modal="true"
tabIndex={-1}
size="300"
flexHeight
className={classNames(css.ModalFlex, className)}
+10 -6
View File
@@ -3,13 +3,13 @@ import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { SeasonTheme, SeasonalOverlayProps } from './types';
import { resolveSeasonTheme } from './seasonSchedule';
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
// fetched only when that season is active or previewed in Settings — the
// date/override decision stays in the main bundle.
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<{ reduced: boolean }> }>) =>
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<SeasonalOverlayProps> }>) =>
React.lazy(load);
const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
@@ -52,16 +52,20 @@ export type { SeasonTheme };
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
function buildOverlayContent(
theme: SeasonTheme,
reduced: boolean,
preview = false,
): React.ReactNode {
switch (theme) {
case 'halloween':
return <HalloweenOverlay reduced={reduced} />;
return <HalloweenOverlay reduced={reduced} preview={preview} />;
case 'christmas':
return <ChristmasOverlay reduced={reduced} />;
case 'newyear':
return <NewYearOverlay reduced={reduced} />;
case 'autumn':
return <AutumnOverlay reduced={reduced} />;
return <AutumnOverlay reduced={reduced} preview={preview} />;
case 'aprilfools':
return <AprilFoolsOverlay reduced={reduced} />;
case 'lunar':
@@ -122,7 +126,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
containerType: 'inline-size',
}}
>
<Suspense fallback={null}>{buildOverlayContent(theme, true)}</Suspense>
<Suspense fallback={null}>{buildOverlayContent(theme, true, true)}</Suspense>
</div>
);
}
@@ -1,35 +1,23 @@
import { keyframes } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
/**
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without triggering
* layout or paint. keyframes() returns the generated animation-name string,
* which is applied inline in Autumn.tsx.
*
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
* drift up through the light; the whole frame has a barely-there warm pulse.
* `opacity` so the compositor can run them without layout or paint.
*/
/**
* A leaf falls from above to below the viewport while continuously rotating.
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
* create the parallax variety. Horizontal travel is intentionally small here
* because the real lateral motion comes from the sway wrapper below.
* A leaf falls from above to below the viewport while slowly turning. One tall
* translateY serves every leaf; per-leaf duration/delay/size give parallax.
*/
export const animLeafFall = keyframes({
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
'92%': { opacity: '0.85' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
'6%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(160deg)' },
'94%': { opacity: '0.9' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(340deg)', opacity: '0' },
});
/**
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
* catching the blade. Decoupled from the fall so the two compose into an
* organic, non-repeating-looking path.
*/
/** Lateral sway on the leaf's wrapper so the descent reads as wind. */
export const animLeafSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(34px, 0, 0)' },
@@ -37,55 +25,40 @@ export const animLeafSway = keyframes({
});
/**
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
* mimics the blade catching air as it spins. Cheap, transform-only.
* Soft flutter on the blade. [#237] Gentler than before (scaleX 0.82 made
* leaves flatten into slivers mid-flip).
*/
export const animLeafFlutter = keyframes({
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
'0%': { transform: 'rotate(-10deg) scaleX(1)' },
'50%': { transform: 'rotate(10deg) scaleX(0.92)' },
'100%': { transform: 'rotate(-10deg) scaleX(1)' },
});
/**
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
* translateX + opacity (never background-position) so it stays on the
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
* [#237] A gust: the leaf enters from the left, is blown across and up while
* spinning, and leaves on the right in ~6 s (14 % of a 42 s cycle). The rest
* of the cycle it waits off-screen, invisible.
*/
export const animSunShaft = keyframes({
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
export const animGust = keyframes({
'0%': { transform: 'translate3d(-12vw, 0, 0) rotate(0deg)', opacity: '0' },
'1%': { opacity: '1' },
// Fast in, then carried steadily across and up (linear timing: an eased
// curve would apply per segment and stop the leaves mid-air).
'3.5%': { transform: 'translate3d(22vw, -3vh, 0) rotate(200deg)' },
'8%': { transform: 'translate3d(62vw, -12vh, 0) rotate(420deg)' },
'12.5%': { opacity: '1' },
'14%': { transform: 'translate3d(112vw, -24vh, 0) rotate(700deg)', opacity: '0' },
'100%': { transform: 'translate3d(112vw, -24vh, 0) rotate(700deg)', opacity: '0' },
});
/**
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
* pulsing softly in brightness as it catches the sun. transform + opacity.
* Leaf blade. On the dark themes the crimson/russet leaves sank into the
* background, so they are lifted there.
*/
export const animMoteDrift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
'15%': { opacity: '0.85' },
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
'90%': { opacity: '0.2' },
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
});
/**
* Independent twinkle for motes — a brightness flicker layered on the drift so
* specks shimmer as if turning in the light. Opacity only.
*/
export const animMoteTwinkle = keyframes({
'0%': { opacity: '0.5' },
'50%': { opacity: '1' },
'100%': { opacity: '0.5' },
});
/**
* Barely-there breathing of the warm vignette frame so the static tint feels
* alive without any distracting motion. Opacity only.
*/
export const animEmberPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
export const leafBlade = style({
selectors: {
'body.dark-theme &, body.butter-theme &': {
filter: 'brightness(1.6) saturate(1.2)',
},
},
});
+232 -235
View File
@@ -1,97 +1,108 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLeafFall,
animLeafSway,
animLeafFlutter,
animSunShaft,
animMoteDrift,
animMoteTwinkle,
animEmberPulse,
} from './Autumn.css';
import { animGust, animLeafFall, animLeafFlutter, animLeafSway, leafBlade } from './Autumn.css';
/**
* AutumnOverlay — warm falling leaves.
* AutumnOverlay — falling leaves over the app as it is.
*
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
* z-index, so this component only returns absolutely-positioned aria-hidden
* children and never sets position:fixed / z-index / pointer-events.
*
* Composition (back to front):
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
* 2. soft angled sun shafts breathing high across the scene
* 3. drifting pollen / dust motes catching the light
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
* 5. a warm low-saturation vignette that frames + protects text contrast
* [#237] Deliberately no full-screen tint, light shafts or vignette: they
* turned the user's theme beige (muddy brown on dark) and lowered contrast
* everywhere. The leaves carry the season on their own.
*
* All motion is transform/opacity only (compositor-friendly). When `reduced`
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
* still sun shafts, and the warm vignette — no `animation` anywhere. The
* settings preview always passes reduced=true, so the still form stands alone.
* Composition:
* 1. maple / oak / aspen leaves drifting down with sway and a soft flutter
* 2. every ~42 s a gust: a small group of leaves blows across the screen
* All motion is transform/opacity only. When `reduced` is true: a few leaves
* at rest in empty parts of the layout, no animation, no gust. The settings
* preview always passes reduced=true, so the still form stands alone.
*/
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
// chat text underneath. Each leaf picks a tone for variety.
const LEAF_TONES = [
'oklch(0.75 0.15 70)', // amber
'oklch(0.55 0.16 40)', // rust
'oklch(0.82 0.13 85)', // warm gold
'oklch(0.62 0.16 55)', // burnt orange
'oklch(0.5 0.14 35)', // deep ember
// [#237] A real autumn palette: [body, tip highlight, vein]. The previous
// muted tans read as beige smudges on the light theme.
const LEAF_TONES: [string, string, string][] = [
['oklch(0.56 0.19 30)', 'oklch(0.68 0.17 45)', 'oklch(0.38 0.12 28)'], // crimson
['oklch(0.68 0.17 52)', 'oklch(0.8 0.15 75)', 'oklch(0.45 0.12 45)'], // pumpkin orange
['oklch(0.76 0.15 80)', 'oklch(0.85 0.13 92)', 'oklch(0.52 0.1 65)'], // gold
['oklch(0.5 0.1 50)', 'oklch(0.62 0.11 60)', 'oklch(0.34 0.06 45)'], // russet brown
['oklch(0.62 0.18 40)', 'oklch(0.74 0.16 60)', 'oklch(0.42 0.12 35)'], // burnt orange
];
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
const MAPLE_PATH =
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
const OAK_PATH =
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
type LeafKind = 'maple' | 'oak' | 'aspen';
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
// A faint vein line gives the blade depth without extra DOM nodes.
// Leaf silhouettes (viewBox 0 0 100 100, stem at the bottom). The maple is the
// flag-style silhouette on purpose: its bold lobes stay recognisable at 30 px,
// where the previous star-like outline read as a splat.
const LEAF_SHAPES: Record<LeafKind, { body: string; veins: string }> = {
maple: {
body:
'M50 5 L54 20 L62 14 L60 30 L74 22 L70 36 L86 34 L78 46 L90 52 L74 56 L78 66 ' +
'L60 62 L56 72 L52 64 L52 94 L48 94 L48 64 L44 72 L40 62 L22 66 L26 56 L10 52 ' +
'L22 46 L14 34 L30 36 L26 22 L40 30 L38 14 L46 20 Z',
veins: 'M50 66 L50 14 M50 58 L80 40 M50 58 L20 40 M50 64 L70 60 M50 64 L30 60',
},
oak: {
body:
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 80 52 86 52 96 L48 96 ' +
'C48 86 46 80 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z',
veins:
'M50 76 L50 12 M50 34 L64 28 M50 34 L36 28 M50 50 L66 44 M50 50 L34 44 M50 64 L62 60 M50 64 L38 60',
},
aspen: {
body: 'M50 8 C74 22 80 54 52 84 L52 96 L48 96 L48 84 C20 54 26 22 50 8 Z',
veins: 'M50 84 L50 16 M50 40 L64 32 M50 40 L36 32 M50 58 L66 50 M50 58 L34 50',
},
};
/** Build a CSS-ready data-URI of one leaf: gradient body, darker veins. */
function leafDataUri(kind: LeafKind, [body, tip, vein]: [string, string, string]): string {
const shape = LEAF_SHAPES[kind];
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
`<defs><linearGradient id='g' x1='0' y1='1' x2='0.4' y2='0'>` +
`<stop offset='0' stop-color='${body}'/><stop offset='1' stop-color='${tip}'/>` +
`</linearGradient></defs>` +
`<path d='${shape.body}' fill='url(#g)'/>` +
`<path d='${shape.veins}' stroke='${vein}' stroke-opacity='0.7' stroke-width='2' ` +
`fill='none' stroke-linecap='round'/>` +
`</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
type Leaf = {
kind: 'maple' | 'oak';
uri: string;
left: number; // vw column
size: number; // px
left: number;
size: number;
duration: number; // s — fall time
delay: number; // s
swayDuration: number; // s — wrapper sway
flutterDuration: number; // s — inner flutter
tilt: number; // deg — resting rotation (used for static scene)
swayDuration: number;
flutterDuration: number;
opacity: number;
};
type Mote = {
left: number;
bottom: number;
type GustLeaf = {
uri: string;
top: number; // % of height where it enters
size: number;
duration: number;
delay: number;
twinkle: number;
delay: number; // s within the gust
opacity: number;
};
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
// they read as "settled" near edges and corners, never over the busy center.
/** Seconds between gusts, and when the first one comes after load. */
const GUST_PERIOD = 42;
const GUST_FIRST = 8;
// Leaves at rest for the reduced/static scene, in areas that are empty in the
// layout (middle of the left icon rail, the space under the member list),
// partly off-edge — never on the sidebar, members, composer or controls.
const RESTING_LEAVES: ReadonlyArray<{
kind: 'maple' | 'oak';
kind: LeafKind;
left: number;
top: number;
size: number;
@@ -99,212 +110,198 @@ const RESTING_LEAVES: ReadonlyArray<{
tone: number;
opacity: number;
}> = [
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
{ kind: 'maple', left: 0.5, top: 44, size: 60, tilt: 24, tone: 0, opacity: 0.62 },
{ kind: 'aspen', left: 1.5, top: 54, size: 44, tilt: -30, tone: 2, opacity: 0.6 },
{ kind: 'oak', left: 99, top: 62, size: 54, tilt: 160, tone: 3, opacity: 0.6 },
{ kind: 'maple', left: 97.5, top: 72, size: 46, tilt: 200, tone: 4, opacity: 0.6 },
];
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
const { leaves, motes } = useMemo(() => {
const LEAF_COUNT = 16;
const MOTE_COUNT = 12;
// Composition for the Settings swatch (sizes are % of the tile width).
const PREVIEW_LEAVES: ReadonlyArray<{
kind: LeafKind;
left: number;
top: number;
size: number;
tilt: number;
tone: number;
}> = [
{ kind: 'maple', left: 30, top: 38, size: 42, tilt: -18, tone: 0 },
{ kind: 'oak', left: 70, top: 30, size: 30, tilt: 34, tone: 3 },
{ kind: 'aspen', left: 62, top: 70, size: 30, tilt: -60, tone: 2 },
{ kind: 'maple', left: 86, top: 80, size: 24, tilt: 20, tone: 1 },
{ kind: 'maple', left: 12, top: 82, size: 20, tilt: 40, tone: 4 },
];
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
const tone = LEAF_TONES[i % LEAF_TONES.length];
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
const size = 22 + sizeBucket * 9; // 22..49 px
export function AutumnOverlay({ reduced, preview }: SeasonalOverlayProps) {
// Deterministic field, computed ONCE. No per-frame state.
const { leaves, gust } = useMemo(() => {
const kinds = ['maple', 'oak', 'maple', 'aspen'] as const;
const builtLeaves: Leaf[] = Array.from({ length: 12 }, (_, i) => {
const depth = i % 4; // 0..3 → parallax bucket
return {
kind,
uri: leafDataUri(kind, tone),
left: (i * 6.13 + 4) % 100,
size,
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
uri: leafDataUri(kinds[i % 4], LEAF_TONES[i % LEAF_TONES.length]),
left: (i * 8.3 + 4) % 100,
size: 28 + depth * 8, // 28..52 px
// Nearer leaves fall a touch faster; all slow and calm.
duration: 16 - depth * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → already mid-fall on load
swayDuration: 5 + (i % 5) * 0.8,
flutterDuration: 1.6 + (i % 4) * 0.45,
tilt: ((i * 53) % 70) - 35,
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
flutterDuration: 2 + (i % 4) * 0.5,
// Nearer → bolder. Small, moving and semi-transparent, so text under a
// passing leaf stays readable.
opacity: 0.58 + depth * 0.05,
};
});
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
left: (i * 8.7 + 5) % 100,
bottom: (i * 4.3) % 30, // start in lower third, drift up
size: 2 + (i % 3),
duration: 16 + (i % 6) * 2.4,
delay: -((i * 2.1) % 16),
twinkle: 2.2 + (i % 4) * 0.6,
opacity: 0.4 + (i % 3) * 0.12,
const builtGust: GustLeaf[] = Array.from({ length: 5 }, (_, i) => ({
uri: leafDataUri(kinds[(i + 1) % 4], LEAF_TONES[(i * 2) % LEAF_TONES.length]),
top: 30 + ((i * 13) % 40),
size: 30 + (i % 3) * 10,
delay: GUST_FIRST + i * 0.35,
opacity: 0.7,
}));
return { leaves: builtLeaves, motes: builtMotes };
return { leaves: builtLeaves, gust: builtGust };
}, []);
return (
<>
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
depth: a warm low-sun glow from the upper-left, a rust pool at the
base, and a faint gold core. Kept very low opacity. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
].join(','),
contain: 'layout paint style',
}}
/>
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
late-afternoon light raking across the room. */}
{[
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
].map((shaft, i) => (
if (preview) {
// The Settings swatch: a warm backdrop (fine inside the tile, unlike over
// the app) with a few leaves arranged across it.
return (
<>
<div
key={`shaft-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: '-30%',
left: `${shaft.left}%`,
width: `${shaft.w}vw`,
height: '160%',
transformOrigin: 'top center',
transform: `rotate(${shaft.rotate}deg)`,
backgroundImage:
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
filter: 'blur(14px)',
mixBlendMode: 'screen',
opacity: reduced ? shaft.opacity * 0.85 : undefined,
willChange: reduced ? undefined : 'transform, opacity',
contain: 'layout paint style',
animation: reduced
? 'none'
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
inset: 0,
background:
'linear-gradient(160deg, oklch(0.42 0.09 55) 0%, oklch(0.32 0.08 40) 60%, oklch(0.24 0.06 35) 100%)',
}}
/>
))}
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
them — stillness reads cleaner at rest. */}
{!reduced &&
motes.map((m, i) => (
{PREVIEW_LEAVES.map((leaf, i) => (
<div
key={`mote-${i}`}
key={`pv-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${m.left}%`,
bottom: `${m.bottom}%`,
width: `${m.size}px`,
height: `${m.size}px`,
left: `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${leaf.size}%`,
aspectRatio: '1',
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
filter: 'brightness(1.25)',
}}
/>
))}
</>
);
}
if (reduced) {
// Phones have no icon rail and the timeline runs edge to edge, so the
// resting leaves tuck further off-edge and smaller there.
const narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 750px)').matches;
return (
<>
{RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
className={leafBlade}
style={{
position: 'absolute',
left: narrow ? `${leaf.left < 50 ? -2 : 102}%` : `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
height: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
opacity: leaf.opacity,
}}
/>
))}
</>
);
}
return (
<>
{leaves.map((leaf, i) => (
// Sway wrapper: horizontal wind, decoupled from the fall. No `paint`
// containment: it would clip the leaf to this box at the top of the
// screen, so it would never be drawn once falling (#237).
<div
key={`leaf-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${leaf.left}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
willChange: 'transform',
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}}
>
{/* Fall wrapper: vertical descent + tumble. */}
<div
style={{
width: '100%',
height: '100%',
willChange: 'transform, opacity',
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}}
>
{/* The leaf itself, with a soft flutter. */}
<div
className={leafBlade}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background:
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
opacity: m.opacity,
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}}
/>
</div>
))}
</div>
))}
{/* 4. Falling / resting maple & oak leaves. */}
{reduced
? RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
opacity: leaf.opacity,
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
}}
/>
))
: leaves.map((leaf, i) => (
// Sway wrapper: horizontal wind motion, decoupled from the fall.
<div
key={`leaf-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${leaf.left}%`,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
willChange: 'transform',
contain: 'layout paint style',
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}}
>
{/* Fall wrapper: vertical descent + tumble rotation. */}
<div
style={{
width: '100%',
height: '100%',
willChange: 'transform, opacity',
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}}
>
{/* Inner blade: the actual silhouette + flutter wobble. */}
<div
style={{
width: '100%',
height: '100%',
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}}
/>
</div>
</div>
))}
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
edges — protecting central chat text contrast. Breathes faintly. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
contain: 'layout paint style',
opacity: reduced ? 1 : undefined,
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
}}
/>
{/* Gust: a handful of leaves blown across the screen together, then a
long calm. Off-screen (and invisible) the rest of the cycle. */}
{gust.map((leaf, i) => (
<div
key={`gust-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${leaf.top}%`,
left: 0,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
opacity: 0,
willChange: 'transform, opacity',
animation: `${animGust} ${GUST_PERIOD}s linear ${leaf.delay}s infinite`,
}}
>
<div
className={leafBlade}
style={{
width: '100%',
height: '100%',
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
}}
/>
</div>
))}
</>
);
}
@@ -1,56 +1,146 @@
import { keyframes } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
/**
* Halloween overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without layout/paint.
* keyframes() returns the generated animation-name string, applied inline.
* Halloween overlay styles. Motion is transform/opacity only.
*
* [#239] Colours come from two custom properties set on the overlay root so
* the bats/spider stay dark silhouettes on light themes and turn to a pale
* dusk-violet on dark themes (a black bat on a black theme is invisible).
*/
/** Slow breathing of the sickly moon-glow vignette. */
export const animMoonPulse = keyframes({
'0%': { transform: 'scale(1)', opacity: '0.55' },
'50%': { transform: 'scale(1.06)', opacity: '0.8' },
'100%': { transform: 'scale(1)', opacity: '0.55' },
export const halloweenRoot = style({
position: 'absolute',
inset: 0,
vars: {
'--hw-ink': 'oklch(0.24 0.04 300)',
'--hw-web': 'oklch(0.42 0.02 290 / 0.42)',
'--hw-eye': 'oklch(0.72 0.18 55)',
},
selectors: {
'body.dark-theme &, body.butter-theme &': {
vars: {
'--hw-ink': 'oklch(0.72 0.06 300)',
'--hw-web': 'oklch(0.85 0.02 290 / 0.36)',
'--hw-eye': 'oklch(0.8 0.17 60)',
},
},
},
});
/** Low fog band: drifts sideways while gently rising and swelling. */
export const animFogDrift = keyframes({
'0%': { transform: 'translate3d(-12%, 6%, 0) scale(1.1)', opacity: '0' },
'15%': { opacity: '0.5' },
'50%': { transform: 'translate3d(6%, -2%, 0) scale(1.25)', opacity: '0.65' },
'85%': { opacity: '0.45' },
'100%': { transform: 'translate3d(18%, 4%, 0) scale(1.1)', opacity: '0' },
/** A bat group crosses the top of the screen in ~9 s, then a long calm. */
export const animBatFlight = keyframes({
'0%': { transform: 'translate3d(-10vw, 0, 0)', opacity: '0' },
'1%': { opacity: '1' },
'29%': { opacity: '1' },
'30%': { transform: 'translate3d(110vw, -6vh, 0)', opacity: '0' },
'100%': { transform: 'translate3d(110vw, -6vh, 0)', opacity: '0' },
});
/** A bat flaps slowly across the sky in a shallow arc. */
export const animBatGlide = keyframes({
'0%': { transform: 'translate3d(-12vw, 8vh, 0) scale(0.9)', opacity: '0' },
'10%': { opacity: '0.7' },
'45%': { transform: 'translate3d(45vw, -4vh, 0) scale(1)' },
'80%': { transform: 'translate3d(85vw, 6vh, 0) scale(0.95)', opacity: '0.6' },
'100%': { transform: 'translate3d(112vw, 2vh, 0) scale(0.9)', opacity: '0' },
/** Up-and-down bobbing on each bat, so the group doesn't move as one block. */
export const animBatBob = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(0, 14px, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/** The bat's wings beat — fast vertical squash of the wing element. */
export const animWingFlap = keyframes({
'0%': { transform: 'scaleY(1) scaleX(1)' },
'50%': { transform: 'scaleY(0.35) scaleX(1.08)' },
'100%': { transform: 'scaleY(1) scaleX(1)' },
/** Wing beat: the silhouette squashes vertically around the body. */
export const animBatFlap = keyframes({
'0%': { transform: 'scaleY(1)' },
'50%': { transform: 'scaleY(0.62)' },
'100%': { transform: 'scaleY(1)' },
});
/** Will-o'-wisp ember: floats upward, swaying, pulsing in brightness. */
export const animEmberFloat = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
'12%': { opacity: '0.85' },
'35%': { transform: 'translate3d(14px, -28vh, 0) scale(1)' },
'65%': { transform: 'translate3d(-12px, -55vh, 0) scale(0.9)', opacity: '0.7' },
'90%': { opacity: '0.25' },
'100%': { transform: 'translate3d(8px, -82vh, 0) scale(0.6)', opacity: '0' },
/** The spider bobs on its thread (thread and spider move together). */
export const animSpiderBob = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(0, 18px, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/** Soft twinkle for embers — independent opacity flicker layered on top. */
export const animEmberTwinkle = keyframes({
'0%': { opacity: '0.6' },
'50%': { opacity: '1' },
'100%': { opacity: '0.6' },
/** A slow sway of the spider itself on the end of the thread. */
export const animSpiderSway = keyframes({
'0%': { transform: 'rotate(-6deg)' },
'50%': { transform: 'rotate(6deg)' },
'100%': { transform: 'rotate(-6deg)' },
});
/**
* A small ghost rises from the lower part of the screen, drifting sideways,
* and fades out — ~8 s in a 50 s cycle.
*/
export const animGhost = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0' },
'3%': { opacity: '0.8' },
'8%': { transform: 'translate3d(6vw, -20vh, 0) scale(1)' },
'13%': { opacity: '0.75' },
'16%': { transform: 'translate3d(-2vw, -42vh, 0) scale(1.05)', opacity: '0' },
'100%': { transform: 'translate3d(-2vw, -42vh, 0) scale(1.05)', opacity: '0' },
});
/** The ghost's tail wisps, a gentle wobble. */
export const animGhostWobble = keyframes({
'0%': { transform: 'rotate(-4deg)' },
'50%': { transform: 'rotate(4deg)' },
'100%': { transform: 'rotate(-4deg)' },
});
/** [#239] Will-o'-the-wisp: a slow wandering loop (paired with a pulse). */
export const animWispWander = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'25%': { transform: 'translate3d(40px, -30px, 0)' },
'50%': { transform: 'translate3d(-10px, -60px, 0)' },
'75%': { transform: 'translate3d(-45px, -20px, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/** Soft breathing glow of a wisp. */
export const animWispPulse = keyframes({
'0%': { opacity: '0.35', transform: 'scale(0.85)' },
'50%': { opacity: '0.9', transform: 'scale(1.1)' },
'100%': { opacity: '0.35', transform: 'scale(0.85)' },
});
/** A witch on a broom crosses high up, right to left, in ~11 s of 60 s. */
export const animWitch = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) rotate(-4deg)', opacity: '0' },
'1%': { opacity: '1' },
'9%': { transform: 'translate3d(-60vw, -3vh, 0) rotate(3deg)' },
'17.5%': { opacity: '1' },
'18%': { transform: 'translate3d(-118vw, 2vh, 0) rotate(-3deg)', opacity: '0' },
'100%': { transform: 'translate3d(-118vw, 2vh, 0) rotate(-3deg)', opacity: '0' },
});
/**
* The big spider: bobs for most of the cycle, then climbs up its thread,
* pauses, and drops back with a little bounce (24 s cycle).
*/
export const animSpiderClimb = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'20%': { transform: 'translate3d(0, 14px, 0)' },
'40%': { transform: 'translate3d(0, 0, 0)' },
'55%': { transform: 'translate3d(0, -22vh, 0)' },
'68%': { transform: 'translate3d(0, -22vh, 0)' },
'71%': { transform: 'translate3d(0, 24px, 0)' },
'74%': { transform: 'translate3d(0, -8px, 0)' },
'77%': { transform: 'translate3d(0, 6px, 0)' },
'80%': { transform: 'translate3d(0, 0, 0)' },
'100%': { transform: 'translate3d(0, 0, 0)' },
});
/** Jack-o'-lantern candle flicker (opacity of the glow behind the face). */
export const animCandle = keyframes({
'0%': { opacity: '0.75' },
'20%': { opacity: '1' },
'35%': { opacity: '0.6' },
'55%': { opacity: '0.95' },
'70%': { opacity: '0.7' },
'100%': { opacity: '0.75' },
});
/** The swarm crosses faster than the regular bats: ~7 s of a 60 s cycle. */
export const animSwarmFlight = keyframes({
'0%': { transform: 'translate3d(-10vw, 0, 0)', opacity: '0' },
'0.5%': { opacity: '1' },
'11.5%': { opacity: '1' },
'12%': { transform: 'translate3d(110vw, -8vh, 0)', opacity: '0' },
'100%': { transform: 'translate3d(110vw, -8vh, 0)', opacity: '0' },
});
+437 -218
View File
@@ -1,267 +1,486 @@
import React, { useMemo } from 'react';
import React from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animMoonPulse,
animFogDrift,
animBatGlide,
animWingFlap,
animEmberFloat,
animEmberTwinkle,
animBatBob,
animBatFlap,
animBatFlight,
animCandle,
animGhost,
animGhostWobble,
animSpiderBob,
animSpiderClimb,
animSpiderSway,
animSwarmFlight,
animWispPulse,
animWispWander,
animWitch,
halloweenRoot,
} from './Halloween.css';
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
/**
* HalloweenOverlay — cobwebs, a dangling spider and bats, over the app as is.
*
* The parent supplies a fixed inset:0 overflow:hidden pointer-events:none
* container; everything here is aria-hidden and absolutely positioned.
*
* [#239] No full-screen purple wash or blurred "moon": they tinted the whole
* app and the moon glow sat over the member list, making it unreadable.
* Colours are theme-aware via --hw-ink / --hw-web (see Halloween.css.ts).
*
* Composition:
* 1. cobwebs in the two top corners (thin threads, readable in both themes)
* 2. a spider on a thread in the empty space under the member list, bobbing
* 3. every 30 s a small group of bats flaps across the top of the screen
* `reduced`: webs and the spider at rest, no bats flying.
* `preview`: a night-sky swatch for Settings.
*/
// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
const cobwebUri = (() => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
// radial threads
`<line x1='0' y1='0' x2='180' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='90'/>` +
`<line x1='0' y1='0' x2='90' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='40'/>` +
`<line x1='0' y1='0' x2='40' y2='180'/>` +
// concentric catch-threads (gentle sag via quadratic curves)
`<path d='M40 0 Q22 22 0 40'/>` +
`<path d='M85 0 Q48 48 0 85'/>` +
`<path d='M130 0 Q74 74 0 130'/>` +
`<path d='M180 0 Q104 104 0 180'/>` +
`<path d='M180 60 Q120 120 60 180'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
/** Corner web (corner at 0,0): radial threads + sagging catch-rings. */
function webPath(size = 100): string {
const angles = [0, 16, 32, 48, 64, 80, 90].map((a) => (a * Math.PI) / 180);
const spokes = angles
.map((a) => `M0 0 L${(Math.cos(a) * size).toFixed(1)} ${(Math.sin(a) * size).toFixed(1)}`)
.join(' ');
const rings = [0.28, 0.48, 0.68, 0.88].map((f) => {
const r = f * size;
const pts = angles.map((a) => [Math.cos(a) * r, Math.sin(a) * r]);
let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i += 1) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
// Control point pulled toward the corner so each segment sags.
const cx = ((x0 + x1) / 2) * 0.86;
const cy = ((y0 + y1) / 2) * 0.86;
d += ` Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`;
}
return d;
});
return `${spokes} ${rings.join(' ')}`;
}
const WEB = webPath();
// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
// glide while an inner element flaps independently — we re-use one body shape.
function BatSilhouette() {
function Cobweb({ corner, size }: { corner: 'left' | 'right'; size: number }) {
return (
<svg
width="46"
height="22"
viewBox="0 0 46 22"
aria-hidden="true"
style={{ display: 'block', overflow: 'visible' }}
viewBox="0 0 100 100"
width={size}
height={size}
style={{
position: 'absolute',
top: 0,
[corner]: 0,
transform: corner === 'right' ? 'scaleX(-1)' : undefined,
overflow: 'visible',
}}
>
<path
fill="oklch(0.12 0.04 300 / 0.85)"
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
d={WEB}
fill="none"
stroke="var(--hw-web)"
strokeWidth={0.9}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic per-mount generation — never per-frame React state.
const embers = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => {
const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
return {
left: (i * 6151 + 113) % 100,
bottom: (i * 3137 + 47) % 28, // start near floor
size: 3 + (i % 4),
duration: 11 + (i % 6) * 2.2,
delay: (i * 0.83) % 11,
twinkle: 2.4 + (i % 5) * 0.6,
color: green ? TOXIC_GREEN : EMBER_ORANGE,
};
}),
[],
);
const bats = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
top: 8 + i * 13,
duration: 22 + i * 7,
delay: i * 6.5,
flap: 0.5 + i * 0.12,
scale: 0.7 + i * 0.18,
})),
[],
);
const fogBands = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
bottom: -6 + i * 9,
duration: 26 + i * 8,
delay: i * 5,
height: 130 + i * 30,
})),
[],
);
// Classic bat silhouette, wings spread (viewBox 0 0 100 50).
const BAT_PATH =
'M50 18 L46 10 L45 17 C40 17 36 19 33 23 C28 15 18 11 4 14 C12 18 14 23 12 29 ' +
'C7 27 3 28 0 31 C10 31 17 35 21 42 C25 36 31 34 37 36 C41 41 46 43 50 46 ' +
'C54 43 59 41 63 36 C69 34 75 36 79 42 C83 35 90 31 100 31 C97 28 93 27 88 29 ' +
'C86 23 88 18 96 14 C82 11 72 15 67 23 C64 19 60 17 55 17 L54 10 Z';
function Bat({ width, flap }: { width: number; flap?: number }) {
return (
<>
{/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
<svg
aria-hidden="true"
viewBox="0 0 100 50"
width={width}
height={width / 2}
style={{
display: 'block',
transformOrigin: '50% 60%',
animation: flap ? `${animBatFlap} ${flap}s ease-in-out infinite` : undefined,
}}
>
<path d={BAT_PATH} fill="var(--hw-ink)" />
</svg>
);
}
function Spider({ size }: { size: number }) {
const legs = [-1, 1]
.flatMap((side) =>
[0, 1, 2, 3].map((i) => {
const y = 38 + i * 7;
const kx = 50 + side * (16 + i * 2);
const fx = 50 + side * (30 + (i === 1 || i === 2 ? 6 : 0));
const fy = y + (i < 2 ? -8 : 10);
return `M${50 + side * 7} ${y} Q${kx} ${y - 12} ${fx} ${fy}`;
}),
)
.join(' ');
return (
<svg
aria-hidden="true"
viewBox="0 0 100 100"
width={size}
height={size}
style={{ display: 'block' }}
>
<path d={legs} fill="none" stroke="var(--hw-ink)" strokeWidth={4} strokeLinecap="round" />
<ellipse cx="50" cy="52" rx="13" ry="16" fill="var(--hw-ink)" />
<circle cx="50" cy="33" r="8" fill="var(--hw-ink)" />
<circle cx="47" cy="32" r="1.8" fill="var(--hw-eye)" />
<circle cx="53" cy="32" r="1.8" fill="var(--hw-eye)" />
</svg>
);
}
/** Spider hanging from the top edge; thread and body move together. */
function HangingSpider({
left,
length,
animate,
size = 30,
climb = false,
}: {
left: string;
length: string;
animate: boolean;
size?: number;
climb?: boolean;
}) {
let motion: string | undefined;
if (animate) {
motion = climb
? `${animSpiderClimb} 24s ease-in-out 6s infinite`
: `${animSpiderBob} 7s ease-in-out infinite`;
}
return (
<div
aria-hidden="true"
style={{ position: 'absolute', top: 0, left, width: size, animation: motion }}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundColor: 'transparent',
backgroundImage: [
// sickly moon glow, upper-right
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
// cold counter-glow lower-left for depth
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
// overall indigo→black wash, darker toward edges (vignette)
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
].join(', '),
opacity: 0.5,
marginLeft: size / 2 - 0.5,
width: 1,
height: length,
background: 'var(--hw-web)',
}}
/>
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
right: '12%',
width: '160px',
height: '160px',
borderRadius: '50%',
willChange: 'transform, opacity',
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
filter: 'blur(2px)',
backdropFilter: 'saturate(1.15)',
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
}}
/>
{/* ── Low drifting fog bands ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
overflow: 'hidden',
marginTop: -size * 0.13,
transformOrigin: '50% 0',
animation: animate ? `${animSpiderSway} 5s ease-in-out infinite` : undefined,
}}
>
{fogBands.map((f, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: '-15%',
right: '-15%',
bottom: `${f.bottom}%`,
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: reduced ? undefined : 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
? 'none'
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
}}
/>
))}
<Spider size={size} />
</div>
</div>
);
}
{/* ── Will-o'-wisps / floating embers ── */}
{embers.map((e, i) => (
/** Small jack-o'-lantern hanging on a short thread, candle flickering. */
function HangingLantern({ right, length, size }: { right: number; length: number; size: number }) {
return (
<div aria-hidden="true" style={{ position: 'absolute', top: 0, right, width: size }}>
<div
style={{
marginLeft: size / 2 - 0.5,
width: 1,
height: length,
background: 'var(--hw-web)',
}}
/>
<svg viewBox="0 0 60 56" width={size} height={size * 0.93} style={{ display: 'block' }}>
<path
d="M30 2 C31 6 33 8 36 9"
stroke="oklch(0.45 0.08 140)"
strokeWidth="3"
fill="none"
strokeLinecap="round"
/>
<path
d="M30 10 C18 8 4 16 4 32 C4 46 16 54 30 54 C44 54 56 46 56 32 C56 16 42 8 30 10 Z"
fill="oklch(0.66 0.17 50)"
/>
<path
d="M30 11 C24 20 24 44 30 53 M30 11 C36 20 36 44 30 53"
stroke="oklch(0.55 0.15 45)"
strokeWidth="1.5"
fill="none"
/>
<g style={{ animation: `${animCandle} 1.6s ease-in-out infinite` }}>
<path d="M14 26 L22 22 L22 30 Z M46 26 L38 22 L38 30 Z" fill="oklch(0.93 0.14 95)" />
<path
d="M14 38 C20 46 40 46 46 38 L41 41 L37 38 L33 42 L30 38 L27 42 L23 38 L19 41 Z"
fill="oklch(0.93 0.14 95)"
/>
</g>
</svg>
</div>
);
}
// Witch on a broom, flying left (viewBox 0 0 120 60).
const WITCH_PATH = [
'M4 42 L80 37 L80 40 L4 45 Z', // broom handle
'M80 34 C88 32 98 28 108 24 C104 32 106 38 116 46 C104 44 92 42 80 42 Z', // bristles
'M32 20 C36 17 50 16 58 19 L55 21 C50 19 40 20 35 22 Z', // hat brim
'M40 19 L64 2 L52 19 Z', // hat cone, leaning back
'M39 25 A5 5 0 1 0 49 25 A5 5 0 1 0 39 25 Z', // head
'M39 24 L34 26 L39 27 Z', // nose
'M40 29 C44 28 52 28 56 30 C64 30 72 28 80 24 C76 30 70 34 62 38 L44 38 C40 36 38 32 40 29 Z', // cloak
'M42 31 L32 37 L34 39 L44 34 Z', // arm on the broom
'M48 38 L44 50 L38 52 L38 54 L47 53 L52 39 Z', // leg and boot
].join(' ');
function Witch({ width }: { width: number }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 120 60"
width={width}
height={width / 2}
style={{ display: 'block' }}
>
<path d={WITCH_PATH} fill="var(--hw-ink)" />
</svg>
);
}
const WISPS = [
{ left: 12, top: 62, size: 30, hue: 'oklch(0.82 0.19 145)', wander: 26, pulse: 3.2, delay: -3 },
{ left: 34, top: 80, size: 24, hue: 'oklch(0.8 0.16 60)', wander: 31, pulse: 2.6, delay: -11 },
{ left: 55, top: 48, size: 28, hue: 'oklch(0.82 0.19 145)', wander: 28, pulse: 3.8, delay: -7 },
{ left: 72, top: 72, size: 34, hue: 'oklch(0.8 0.16 60)', wander: 34, pulse: 2.9, delay: -19 },
{ left: 88, top: 55, size: 24, hue: 'oklch(0.82 0.19 145)', wander: 24, pulse: 3.4, delay: -5 },
{ left: 46, top: 30, size: 22, hue: 'oklch(0.8 0.16 60)', wander: 29, pulse: 2.4, delay: -15 },
];
const BAT_PERIOD = 20; // s between flights
const BAT_FIRST = 5; // s after load
const BATS = [
{ top: 9, width: 64, offset: 0, bob: 1.3, flap: 0.3 },
{ top: 16, width: 46, offset: 0.5, bob: 1.1, flap: 0.26 },
{ top: 6, width: 38, offset: 0.9, bob: 1.5, flap: 0.24 },
{ top: 20, width: 54, offset: 1.4, bob: 1.2, flap: 0.28 },
];
// Every 60 s a bigger, faster swarm of small bats, offset from the regular group.
const SWARM_PERIOD = 60;
const SWARM_FIRST = 38;
const SWARM = Array.from({ length: 8 }, (_, i) => ({
top: 4 + ((i * 7) % 22),
width: 22 + (i % 3) * 8,
offset: (i * 0.23) % 1.4,
bob: 0.9 + (i % 3) * 0.25,
flap: 0.18 + (i % 3) * 0.03,
}));
const WITCH_PERIOD = 60;
const WITCH_FIRST = 22;
const GHOST_PERIOD = 50;
const GHOST_FIRST = 16;
/** A small, friendly ghost: pale body with an ink outline (shows on light). */
function Ghost({ size }: { size: number }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 60 72"
width={size}
height={size * 1.2}
style={{ display: 'block' }}
>
<path
d="M30 4 C14 4 6 16 6 32 L6 62 L13 55 L20 63 L27 55 L34 63 L41 55 L48 63 L54 56 L54 32 C54 16 46 4 30 4 Z"
fill="oklch(0.98 0.01 290 / 0.55)"
stroke="var(--hw-ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
<ellipse cx="22" cy="28" rx="3.5" ry="5" fill="var(--hw-ink)" />
<ellipse cx="38" cy="28" rx="3.5" ry="5" fill="var(--hw-ink)" />
<ellipse cx="30" cy="41" rx="4" ry="3" fill="var(--hw-ink)" opacity="0.8" />
</svg>
);
}
export function HalloweenOverlay({ reduced, preview }: SeasonalOverlayProps) {
// Phones: smaller webs, and a shorter thread so the spider hangs near the top.
const narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 750px)').matches;
if (preview) {
return (
<div className={halloweenRoot} aria-hidden="true">
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
bottom: `${e.bottom}%`,
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(0.9)' : undefined,
opacity: reduced ? 0.4 : undefined,
animation: reduced
? 'none'
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
inset: 0,
background:
'linear-gradient(180deg, oklch(0.22 0.08 300) 0%, oklch(0.16 0.06 290) 70%, oklch(0.12 0.04 280) 100%)',
}}
>
<div
aria-hidden="true"
style={{
width: `${e.size}px`,
height: `${e.size}px`,
borderRadius: '50%',
backgroundColor: e.color,
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
animation: reduced
? 'none'
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
}}
/>
/>
{/* Crescent moon */}
<div
style={{
position: 'absolute',
right: '14%',
top: '14%',
width: '30%',
aspectRatio: '1',
borderRadius: '50%',
boxShadow: 'inset -9px 4px 0 0 oklch(0.93 0.06 95), 0 0 18px oklch(0.9 0.08 95 / 0.35)',
}}
/>
{/* The big bat crosses the moon, the classic silhouette. */}
<div style={{ position: 'absolute', left: '52%', top: '22%', width: '34%' }}>
<svg viewBox="0 0 100 50" style={{ width: '100%' }}>
<path d={BAT_PATH} fill="oklch(0.1 0.02 300)" />
</svg>
</div>
))}
<div style={{ position: 'absolute', left: '26%', top: '56%', width: '20%' }}>
<svg viewBox="0 0 100 50" style={{ width: '100%' }}>
<path d={BAT_PATH} fill="oklch(0.1 0.02 300)" />
</svg>
</div>
<svg
viewBox="0 0 100 100"
style={{ position: 'absolute', left: 0, top: 0, width: '42%', overflow: 'visible' }}
>
<path d={WEB} fill="none" stroke="oklch(0.85 0.02 290 / 0.6)" strokeWidth={1.2} />
</svg>
</div>
);
}
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
return (
<div className={halloweenRoot} aria-hidden="true">
<Cobweb corner="left" size={narrow ? 64 : 88} />
<Cobweb corner="right" size={narrow ? 76 : 108} />
{/* In the empty space under the member list on desktop; near the right
edge on narrow screens. */}
<HangingSpider
left="calc(100% - 64px)"
length={narrow ? '22vh' : '50vh'}
animate={!reduced}
climb
/>
{/* A second, smaller spider on the left web. */}
<HangingSpider
left={narrow ? '30px' : '46px'}
length={narrow ? '9vh' : '13vh'}
animate={!reduced}
size={20}
/>
{!narrow && <HangingLantern right={126} length={30} size={26} />}
{/* Will-o'-the-wisps: soft glows wandering slowly, always present while
animating. Not in the still scene: static glows would sit on text. */}
{!reduced &&
bats.map((b, i) => (
WISPS.map((w, i) => (
<div
key={i}
aria-hidden="true"
key={`wisp-${i}`}
style={{
position: 'absolute',
top: `${b.top}%`,
left: 0,
willChange: 'transform, opacity',
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
left: `${w.left}%`,
top: `${w.top}%`,
animation: `${animWispWander} ${w.wander}s ease-in-out ${w.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
transform: `scale(${b.scale})`,
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
width: w.size,
height: w.size,
borderRadius: '50%',
background: `radial-gradient(circle, oklch(0.97 0.05 100) 0%, ${w.hue} 22%, ${w.hue.replace(')', ' / 0.35)')} 48%, transparent 72%)`,
animation: `${animWispPulse} ${w.pulse}s ease-in-out ${w.delay}s infinite`,
}}
>
<BatSilhouette />
/>
</div>
))}
{!reduced &&
BATS.map((bat, i) => (
<div
key={`bat-${i}`}
style={{
position: 'absolute',
left: 0,
top: `${bat.top}%`,
opacity: 0,
willChange: 'transform, opacity',
animation: `${animBatFlight} ${BAT_PERIOD}s linear ${BAT_FIRST + bat.offset}s infinite`,
}}
>
<div style={{ animation: `${animBatBob} ${bat.bob}s ease-in-out infinite` }}>
<Bat width={bat.width} flap={bat.flap} />
</div>
</div>
))}
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
opacity: 0.7,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
right: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
transform: 'scaleX(-1)',
opacity: 0.7,
}}
/>
</>
{!reduced &&
SWARM.map((bat, i) => (
<div
key={`swarm-${i}`}
style={{
position: 'absolute',
left: 0,
top: `${bat.top}%`,
opacity: 0,
willChange: 'transform, opacity',
animation: `${animSwarmFlight} ${SWARM_PERIOD}s linear ${SWARM_FIRST + bat.offset}s infinite`,
}}
>
<div style={{ animation: `${animBatBob} ${bat.bob}s ease-in-out infinite` }}>
<Bat width={bat.width} flap={bat.flap} />
</div>
</div>
))}
{!reduced && (
<div
style={{
position: 'absolute',
right: '-14vw',
top: narrow ? '11%' : '12%',
opacity: 0,
willChange: 'transform, opacity',
animation: `${animWitch} ${WITCH_PERIOD}s linear ${WITCH_FIRST}s infinite`,
}}
>
<Witch width={narrow ? 84 : 120} />
</div>
)}
{!reduced && (
<div
style={{
position: 'absolute',
left: '18%',
top: '78%',
opacity: 0,
willChange: 'transform, opacity',
animation: `${animGhost} ${GHOST_PERIOD}s linear ${GHOST_FIRST}s infinite`,
}}
>
<div
style={{
transformOrigin: '50% 20%',
animation: `${animGhostWobble} 2.4s ease-in-out infinite`,
}}
>
<Ghost size={narrow ? 34 : 44} />
</div>
</div>
)}
</div>
);
}
+5
View File
@@ -21,4 +21,9 @@ export type SeasonTheme =
// passes reduced=true, so the static form has to stand on its own.
export type SeasonalOverlayProps = {
reduced: boolean;
/**
* Rendering in the small Settings swatch rather than over the app. Optional:
* a theme may draw a composition made for the tile (always also `reduced`).
*/
preview?: boolean;
};
@@ -59,13 +59,21 @@ export function SuspiciousLink({ href, safety, anchorProps, children }: Suspicio
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#suspiciouslink-dialog-1',
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={{ maxWidth: 440 }}>
<Dialog
id="suspiciouslink-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="suspiciouslink-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={{ maxWidth: 440 }}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -75,7 +83,7 @@ export function SuspiciousLink({ href, safety, anchorProps, children }: Suspicio
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="suspiciouslink-dialog-1-title" as="h2" size="H4">
This link doesn&apos;t go where it says
</Text>
</Box>
+8 -2
View File
@@ -15,10 +15,16 @@ function DummyErrorDialog({
onCancel: () => void;
}) {
return (
<Dialog>
<Dialog
id="dummystage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="dummystage-dialog-1-title"
tabIndex={-1}
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">
<Text id="dummystage-dialog-1-title" as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
+16 -4
View File
@@ -28,7 +28,13 @@ function EmailErrorDialog({
};
return (
<Dialog>
<Dialog
id="emailstage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="emailstage-dialog-1-title"
tabIndex={-1}
>
<Box
as="form"
onSubmit={handleFormSubmit}
@@ -37,7 +43,7 @@ function EmailErrorDialog({
gap="400"
>
<Box direction="Column" gap="100">
<Text as="h2" size="H4">
<Text id="emailstage-dialog-1-title" as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
@@ -145,10 +151,16 @@ export function EmailStageDialog({
if (emailTokenState.status === AsyncStatus.Success) {
return (
<Dialog>
<Dialog
id="emailstage-dialog-2"
role="dialog"
aria-modal="true"
aria-labelledby="emailstage-dialog-2-title"
tabIndex={-1}
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">
<Text id="emailstage-dialog-2-title" as="h2" size="H4">
Verification Request Sent
</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
@@ -36,7 +36,13 @@ export function PasswordStage({
};
return (
<Dialog>
<Dialog
id="passwordstage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="passwordstage-dialog-1-title"
tabIndex={-1}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -45,7 +51,7 @@ export function PasswordStage({
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="passwordstage-dialog-1-title" as="h2" size="H4">
{t('Organisms.PasswordStage.account_password')}
</Text>
</Box>
@@ -14,10 +14,18 @@ function ReCaptchaErrorDialog({
onCancel: () => void;
}) {
return (
<Dialog>
<Dialog
id="recaptchastage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="recaptchastage-dialog-1-title"
tabIndex={-1}
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">{title}</Text>
<Text id="recaptchastage-dialog-1-title" size="H4">
{title}
</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
@@ -26,7 +26,13 @@ function RegistrationTokenErrorDialog({
};
return (
<Dialog>
<Dialog
id="registrationtokenstage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="registrationtokenstage-dialog-1-title"
tabIndex={-1}
>
<Box
as="form"
onSubmit={handleFormSubmit}
@@ -35,7 +41,7 @@ function RegistrationTokenErrorDialog({
gap="400"
>
<Box direction="Column" gap="100">
<Text as="h2" size="H4">
<Text id="registrationtokenstage-dialog-1-title" as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
+8 -2
View File
@@ -45,7 +45,13 @@ export function SSOStage({
}, [ssoWindow, handleSubmit, ssoRedirectURL]);
return (
<Dialog>
<Dialog
id="ssostage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="ssostage-dialog-1-title"
tabIndex={-1}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -54,7 +60,7 @@ export function SSOStage({
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="ssostage-dialog-1-title" as="h2" size="H4">
SSO Login
</Text>
</Box>
+8 -2
View File
@@ -15,10 +15,16 @@ function TermsErrorDialog({
onCancel: () => void;
}) {
return (
<Dialog>
<Dialog
id="termsstage-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="termsstage-dialog-1-title"
tabIndex={-1}
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">
<Text id="termsstage-dialog-1-title" as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
@@ -315,6 +315,33 @@ export const EmbedIframeStatic = style([
},
]);
// Shown over (or instead of) an embed that hung or that the provider says is gone.
export const EmbedNotice = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
padding: config.space.S300,
textAlign: 'center',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
},
]);
export const EmbedNoticeStatic = style([
EmbedNotice,
{
position: 'static',
minHeight: toRem(120),
borderRadius: config.radii.R300,
},
]);
export const EmbedPlaceholder = style([
DefaultReset,
{
+217 -54
View File
@@ -17,6 +17,7 @@ import {
import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { decodeHtmlEntities } from '../../utils/htmlEntities';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
import {
@@ -33,11 +34,13 @@ import { onEnterOrSpace } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
EMBED_LOAD_TIMEOUT_MS,
extractEmbedHeight,
getSteamTarget,
getTikTokVideoId,
getTweetId,
isTikTokLink,
isTwitterNoResults,
MediaEmbed,
parseMediaEmbed,
steamWidgetEmbedUrl,
@@ -308,6 +311,23 @@ 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.
/** Text fields that are rendered as-is by the cards below. */
const PREVIEW_TEXT_KEYS = ['og:title', 'og:description', 'og:site_name'] as const;
/**
* Decode character references left in preview text once, before any card
* reads it (e.g. Steam double-encodes, so `&quot;` survived Synapse's decode
* and showed literally). #187 DP17.
*/
const decodePreviewText = (prev: IPreviewUrlResponse): IPreviewUrlResponse => {
const out: IPreviewUrlResponse = { ...prev };
PREVIEW_TEXT_KEYS.forEach((key) => {
const value = out[key];
if (typeof value === 'string') out[key] = decodeHtmlEntities(value);
});
return out;
};
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
if (prev['og:image:type'] === 'image/gif') return true;
try {
@@ -651,10 +671,84 @@ function useIframeAutoHeight(origins: string[], initial: number) {
return { ref, height };
}
// Embed iframes that never fire `load` (provider down, network stall) would
// otherwise sit as an empty box forever. After EMBED_LOAD_TIMEOUT_MS the card
// offers Retry / "Open on …"; a late `load` clears the notice on its own.
// `attempt` is the iframe's `key`, so Retry remounts it.
function useEmbedWatchdog(active: boolean) {
const [attempt, setAttempt] = useState(0);
const [loaded, setLoaded] = useState(false);
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
setLoaded(false);
setTimedOut(false);
if (!active) return undefined;
const t = window.setTimeout(() => setTimedOut(true), EMBED_LOAD_TIMEOUT_MS);
return () => window.clearTimeout(t);
}, [active, attempt]);
const onLoad = useCallback(() => setLoaded(true), []);
const retry = useCallback(() => setAttempt((a) => a + 1), []);
return { attempt, onLoad, stalled: active && timedOut && !loaded, retry };
}
function EmbedNotice({
message,
url,
site,
onRetry,
inFlow,
}: {
message: string;
url: string;
site: string;
onRetry?: () => void;
/** Render in the layout flow instead of as an overlay on the player box. */
inFlow?: boolean;
}) {
return (
<div className={inFlow ? previewCss.EmbedNoticeStatic : previewCss.EmbedNotice} role="status">
<Text size="T300">{message}</Text>
<Box gap="200" wrap="Wrap" justifyContent="Center">
{onRetry && (
<Chip variant="Secondary" radii="Pill" className={MobileTouchTarget} onClick={onRetry}>
<Text size="T200">Retry</Text>
</Chip>
)}
<Chip
as="a"
href={url}
target="_blank"
rel="noreferrer"
variant="Primary"
radii="Pill"
className={MobileTouchTarget}
>
<Text size="T200">Open on {site}</Text>
</Chip>
</Box>
</div>
);
}
const EMBED_STALLED_MESSAGE = 'This embed is taking too long to load.';
// Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets.
// Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade).
function TweetEmbed({ id }: { id: string }) {
function TweetEmbed({ id, url }: { id: string; url: string }) {
const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320);
const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(true);
// A deleted/private/suspended post renders an EMPTY frame; X only says so
// via `twttr.private.no_results`.
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (!TWITTER_ORIGINS.includes(e.origin)) return;
if (!ref.current || e.source !== ref.current.contentWindow) return;
if (isTwitterNoResults(e.data)) setUnavailable(true);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [ref]);
const theme =
typeof window !== 'undefined' &&
window.matchMedia &&
@@ -662,20 +756,38 @@ function TweetEmbed({ id }: { id: string }) {
? 'light'
: 'dark';
if (unavailable) {
return (
<EmbedNotice
inFlow
message="This post isn't available. It may have been deleted, or the account is private or suspended."
url={url}
site="X"
/>
);
}
return (
<iframe
ref={ref}
className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id,
)}&theme=${theme}&dnt=true`}
title="Post on X"
style={{ height: `${height}px` }}
sandbox={EMBED_SANDBOX}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
/>
<div style={{ position: 'relative' }}>
<iframe
key={attempt}
ref={ref}
className={previewCss.EmbedIframeStatic}
src={`https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(
id,
)}&theme=${theme}&dnt=true`}
title="Post on X"
style={{ height: `${height}px` }}
sandbox={EMBED_SANDBOX}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
loading="lazy"
scrolling="no"
onLoad={onLoad}
/>
{stalled && (
<EmbedNotice message={EMBED_STALLED_MESSAGE} url={url} site="X" onRetry={retry} />
)}
</div>
);
}
@@ -755,7 +867,7 @@ function TwitterCard({
</IconButton>
</div>
<UrlPreviewContent>
<TweetEmbed id={tweetId} />
<TweetEmbed id={tweetId} url={url} />
</UrlPreviewContent>
</>
);
@@ -1133,6 +1245,7 @@ function MediaEmbedCard({
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(playing);
const rawTitle = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
@@ -1210,30 +1323,54 @@ function MediaEmbedCard({
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<iframe
ref={resizeRef}
className={previewCss.EmbedIframeStatic}
src={embed.embedUrl}
title={title || badge.label}
style={{ height: `${resizeHeight}px` }}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
scrolling="no"
/>
<div style={{ position: 'relative' }}>
<iframe
key={attempt}
ref={resizeRef}
className={previewCss.EmbedIframeStatic}
src={embed.embedUrl}
title={title || badge.label}
style={{ height: `${resizeHeight}px` }}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
scrolling="no"
onLoad={onLoad}
/>
{stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site={badge.label}
onRetry={retry}
/>
)}
</div>
) : (
<div ref={mediaRef} className={mediaClass} style={mediaStyle}>
{playing ? (
<iframe
className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
/>
<>
<iframe
key={attempt}
className={previewCss.EmbedIframe}
src={embed.embedUrl}
title={title || badge.label}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
onLoad={onLoad}
/>
{stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site={badge.label}
onRetry={retry}
/>
)}
</>
) : inlineMediaEmbeds ? (
<button
type="button"
@@ -1329,6 +1466,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
const [failed, setFailed] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const tiktokWatch = useEmbedWatchdog(playing && !!videoId);
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
useEffect(() => () => abortRef.current?.abort(), []);
@@ -1389,15 +1527,27 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<iframe
className={previewCss.EmbedIframe}
src={tiktokPlayerEmbedUrl(videoId)}
title={title || 'TikTok'}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
/>
<>
<iframe
key={tiktokWatch.attempt}
className={previewCss.EmbedIframe}
src={tiktokPlayerEmbedUrl(videoId)}
title={title || 'TikTok'}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write"
sandbox={EMBED_SANDBOX}
allowFullScreen
loading="lazy"
onLoad={tiktokWatch.onLoad}
/>
{tiktokWatch.stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site="TikTok"
onRetry={tiktokWatch.retry}
/>
)}
</>
) : inlineMediaEmbeds ? (
<button
type="button"
@@ -1740,6 +1890,7 @@ function SteamAppCard({
const useAuthentication = useMediaAuthentication();
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [showWidget, setShowWidget] = useState(false);
const steamWatch = useEmbedWatchdog(showWidget);
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const mxcImage = prev['og:image'] as string | undefined;
@@ -1773,13 +1924,25 @@ function SteamAppCard({
</Text>
)}
{showWidget ? (
<iframe
className={previewCss.SteamWidget}
src={steamWidgetEmbedUrl(appId)}
title={title ? `Steam store: ${title}` : 'Steam store widget'}
sandbox={EMBED_SANDBOX}
loading="lazy"
/>
<div style={{ position: 'relative' }}>
<iframe
key={steamWatch.attempt}
className={previewCss.SteamWidget}
src={steamWidgetEmbedUrl(appId)}
title={title ? `Steam store: ${title}` : 'Steam store widget'}
sandbox={EMBED_SANDBOX}
loading="lazy"
onLoad={steamWatch.onLoad}
/>
{steamWatch.stalled && (
<EmbedNotice
message={EMBED_STALLED_MESSAGE}
url={url}
site="Steam"
onRetry={steamWatch.retry}
/>
)}
</div>
) : (
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<Text
@@ -2281,8 +2444,8 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState(false);
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]),
const [previewStatus, loadPreview] = useAsyncCallback<IPreviewUrlResponse, Error, []>(
useCallback(() => mx.getUrlPreview(url, ts).then(decodePreviewText), [url, ts, mx]),
);
useEffect(() => {
+20 -6
View File
@@ -55,20 +55,27 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#powerchip-dialog-1',
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog
id="powerchip-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="powerchip-dialog-1-title"
tabIndex={-1}
variant="Surface"
>
<Header
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="powerchip-dialog-1-title" as="h2" size="H4">
Self Demotion
</Text>
</Box>
@@ -107,20 +114,27 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#powerchip-dialog-2',
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog
id="powerchip-dialog-2"
role="dialog"
aria-modal="true"
aria-labelledby="powerchip-dialog-2-title"
tabIndex={-1}
variant="Surface"
>
<Header
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="powerchip-dialog-2-title" as="h2" size="H4">
Shared Power
</Text>
</Box>
+7 -1
View File
@@ -79,13 +79,18 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#userhero-dialog-1',
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
id="userhero-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="userhero-dialog-1-title"
tabIndex={-1}
size="500"
className={ModalMobileFull}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
@@ -131,6 +136,7 @@ export function UserHeroName({
<Box grow="Yes" direction="Column" gap="0">
<Box alignItems="Baseline" gap="200" wrap="Wrap">
<Text
id="userhero-dialog-1-title"
size="H4"
className={classNames(BreakWord, LineClamp3)}
title={displayName ?? username}
+11 -3
View File
@@ -185,13 +185,21 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#addexisting-dialog-1',
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" style={modalStyle}>
<Modal
id="addexisting-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="addexisting-dialog-1-title"
tabIndex={-1}
size="300"
style={modalStyle}
>
<Box grow="Yes" direction="Column">
<Header
size="500"
@@ -201,7 +209,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="addexisting-dialog-1-title" as="h2" size="H4">
Add Existing
</Text>
</Box>
+29 -11
View File
@@ -1,8 +1,14 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback, useState } from 'react';
import { useSetAtom } from 'jotai';
import { MicLevelBars } from '../call/MicLevelBars';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useScreenshareMode,
} from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
@@ -13,9 +19,11 @@ import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
level?: number;
disabled?: boolean;
};
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
function MicrophoneButton({ enabled, onToggle, disabled, level = 0 }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
@@ -40,9 +48,11 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
outlined
disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
aria-pressed={enabled}
>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
<span style={{ position: 'relative', display: 'inline-flex' }}>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
{enabled && <MicLevelBars level={level} />}
</span>
</IconButton>
)}
</TooltipProvider>
@@ -73,8 +83,10 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
size="300"
className={MobileTouchTarget}
onClick={() => onToggle()}
// No aria-pressed on these call toggles (#187 DP7/8): their labels already
// name the action ("Deafen" / "Undeafen"), and pressed-state on top
// read as "Deafen, pressed" while NOT deafened.
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined
disabled={disabled}
>
@@ -117,7 +129,6 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
className={MobileTouchTarget}
onClick={toggleVideo}
aria-label={enabled ? 'Stop Video' : 'Start Video'}
aria-pressed={enabled}
outlined
disabled={disabled || loading}
>
@@ -157,7 +168,6 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
className={MobileTouchTarget}
onClick={onToggle}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
aria-pressed={enabled}
outlined
disabled={disabled}
>
@@ -178,6 +188,7 @@ export function CallControl({
callJoined: boolean;
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const micLevel = useCallMicLevel(callEmbed);
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
@@ -186,8 +197,16 @@ export function CallControl({
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
// where the click can't be delegated, starting asks for the in-frame prompt.
const screenshareMode = useScreenshareMode(callEmbed.control);
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
const [shareConfirm, setShareConfirm] = useState(false);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
@@ -225,6 +244,7 @@ export function CallControl({
enabled={microphone}
onToggle={handleMicrophoneToggle}
disabled={!callJoined}
level={micLevel}
/>
<SoundButton
enabled={sound}
@@ -241,9 +261,7 @@ export function CallControl({
{!compact && showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
disabled={!callJoined}
/>
)}
+12 -3
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Box, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
@@ -14,6 +14,7 @@ import { StatusDivider } from './components';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
@@ -29,6 +30,14 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
// [Gitea #146] Your own voice is on the mic button's level meter; the
// "… is speaking" line is for everyone else. (Avatar rings still include you.)
const mx = useMatrixClient();
const myUserId = mx.getUserId();
const otherSpeakers = useMemo(
() => new Set([...speakers].filter((id) => id !== myUserId)),
[speakers, myUserId],
);
// [Gitea #158] Same warning as the top banner, where the user is looking during a call.
const clockSkew = useAtomValue(clockSkewAtom);
@@ -69,11 +78,11 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
</Text>
</>
)}
{speakers.size > 0 && (
{otherSpeakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
<MemberSpeaking room={room} speakers={otherSpeakers} />
</>
)}
</>
+27 -8
View File
@@ -29,7 +29,12 @@ import {
SoundButton,
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useScreenshareMode,
} from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
@@ -85,6 +90,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
const micLevel = useCallMicLevel(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
@@ -105,6 +111,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
// is hidden (the screenshare-audio mute stays); where the click can't be
// delegated, starting asks for the fork's in-frame "Share your screen?".
const screenshareMode = useScreenshareMode(callEmbed.control);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -200,7 +215,11 @@ export function CallControls({ callEmbed }: CallControlsProps) {
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
level={micLevel}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && showVideoGroup && <ControlDivider />}
@@ -211,12 +230,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
control since they're the same concern. */}
<ScreenshareAudioButton
+6 -1
View File
@@ -115,7 +115,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
try {
const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master;
const result = await callEmbed.control.injectAudio(url, vol);
// [Gitea #43] Send the bytes too: the call page may be on another
// origin, where this blob: URL can't be fetched.
const clipBytes = await fetch(url)
.then((r) => r.arrayBuffer())
.catch(() => undefined);
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
if (!result.played) {
// [EC#13] Refused fork-side (only reason today: local mic muted) —
// don't play it locally either, or the user would think it went out.
+11 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
import { useAtom } from 'jotai';
import { MicLevelBars } from './MicLevelBars';
import * as css from './styles.css';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { callChatAtom } from '../../state/callEmbed';
@@ -15,8 +16,10 @@ export function ControlDivider() {
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
/** [Gitea #146] Your input level, 0–3 bars; shown only while unmuted. */
level?: number;
};
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
export function MicrophoneButton({ enabled, onToggle, level = 0 }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
@@ -43,7 +46,10 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
outlined
disabled={loading}
>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
<span style={{ position: 'relative', display: 'inline-flex' }}>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
{enabled && <MicLevelBars level={level} />}
</span>
</IconButton>
)}
</TooltipProvider>
@@ -74,8 +80,10 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
// No aria-pressed on these call toggles (#187 DP7/8): their labels already
// name the action ("Deafen" / "Undeafen"), and pressed-state on top
// read as "Deafen, pressed" while NOT deafened.
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined
>
<Icon
@@ -124,7 +132,6 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
aria-label={
disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'
}
aria-pressed={enabled}
style={disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
>
<Icon
@@ -209,7 +216,6 @@ export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonPro
className={MobileTouchTarget}
onClick={onToggle}
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
aria-pressed={isFullscreen}
outlined
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
@@ -244,7 +250,6 @@ export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButt
className={MobileTouchTarget}
onClick={onToggle}
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
aria-pressed={muted}
outlined
>
<Icon size="400" src={muted ? Icons.VolumeMute : Icons.VolumeHigh} filled={muted} />
@@ -277,7 +282,6 @@ export function ChatButton() {
className={MobileTouchTarget}
onClick={() => setChat(!chat)}
aria-label={chat ? 'Close Chat' : 'Open Chat'}
aria-pressed={chat}
outlined
>
<Icon size="400" src={Icons.Message} filled={chat} />
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { color } from 'folds';
type MicLevelBarsProps = {
/** 0–3; nothing is drawn at 0. */
level: number;
};
const HEIGHTS = [3, 5, 7];
/**
* [Gitea #146] Three small bars in the corner of the mic button showing your
* own input level while unmuted, so you can see the mic is actually picking
* you up. Decorative: the button's label already says whether it's on.
*/
export function MicLevelBars({ level }: MicLevelBarsProps) {
if (level <= 0) return null;
return (
<span
aria-hidden
data-mic-level={level}
style={{
position: 'absolute',
right: 1,
bottom: 1,
display: 'flex',
alignItems: 'flex-end',
gap: 1,
pointerEvents: 'none',
}}
>
{HEIGHTS.map((h, i) => (
<span
key={h}
style={{
width: 2,
height: h,
borderRadius: 1,
background: i < level ? color.Success.Main : 'transparent',
}}
/>
))}
</span>
);
}
@@ -78,13 +78,21 @@ function ConfirmDialog({ onStart, requestClose }: ConfirmDialogProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#deletemymessages-dialog-1',
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="deletemymessages-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="deletemymessages-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -94,7 +102,7 @@ function ConfirmDialog({ onStart, requestClose }: ConfirmDialogProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="deletemymessages-dialog-1-title" as="h2" size="H4">
Delete all your messages
</Text>
</Box>
@@ -108,13 +108,21 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#roomencryption-dialog-1',
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="roomencryption-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="roomencryption-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -124,7 +132,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="roomencryption-dialog-1-title" as="h2" size="H4">
Enable Encryption
</Text>
</Box>
@@ -108,7 +108,6 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
@@ -89,13 +89,21 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#roomupgrade-dialog-1',
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="roomupgrade-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="roomupgrade-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -105,7 +113,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="roomupgrade-dialog-1-title" as="h2" size="H4">
{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
</Text>
</Box>
@@ -44,13 +44,22 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#createroommodal-dialog-1',
clickOutsideDeactivates: true,
onDeactivate: closeDialog,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight style={modalStyle}>
<Modal
id="createroommodal-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="createroommodal-dialog-1-title"
tabIndex={-1}
size="300"
flexHeight
style={modalStyle}
>
<Box direction="Column">
<Header
size="500"
@@ -60,7 +69,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="createroommodal-dialog-1-title" as="h2" size="H4">
{type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
</Text>
</Box>
@@ -43,13 +43,22 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#createspacemodal-dialog-1',
clickOutsideDeactivates: true,
onDeactivate: closeDialog,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight style={modalStyle}>
<Modal
id="createspacemodal-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="createspacemodal-dialog-1-title"
tabIndex={-1}
size="300"
flexHeight
style={modalStyle}
>
<Box direction="Column">
<Header
size="500"
@@ -60,7 +69,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">
<Text id="createspacemodal-dialog-1-title" as="h2" size="H4">
New Space
</Text>
</Box>
@@ -256,7 +256,6 @@ export function HierarchyItemMenu({
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
-1
View File
@@ -260,7 +260,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+1 -1
View File
@@ -330,7 +330,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
};
const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as any);
openCreateSpaceModal(item.roomId);
setCords(undefined);
};
+655 -2
View File
@@ -33,7 +33,6 @@ export const DECORATION_CATEGORIES: DecorationCategory[] = [
decorations: [
{ slug: 'slither_n_snack', name: "Slither 'n Snack" },
{ slug: 'joystick', name: 'Joystick' },
{ slug: 'clyde_invaders', name: 'Space Invaders' },
{ slug: 'mallow_jump', name: 'Mallow Jump' },
{ slug: 'hot_shot', name: 'Hot Shot' },
{ slug: 'pipedream', name: 'Pipedream' },
@@ -71,7 +70,6 @@ export const DECORATION_CATEGORIES: DecorationCategory[] = [
{ slug: 'solar_orbit', name: 'Solar Orbit' },
{ slug: 'astronaut_helmet', name: 'Astronaut Helmet' },
{ slug: 'ufo', name: 'UFO' },
{ slug: 'warp_helmet', name: 'Warp Helmet' },
{ slug: 'aurora', name: 'Aurora' },
],
},
@@ -182,6 +180,650 @@ export const DECORATION_CATEGORIES: DecorationCategory[] = [
{ slug: 'polar_bear_hat', name: 'Polar Bear Hat' },
],
},
{
id: 'reactions',
label: 'Reactions',
decorations: [
{ slug: 'shy', name: 'Shy' },
{ slug: 'shy_in_love', name: 'Shy (In Love)' },
{ slug: 'rumbling', name: 'Rumbling' },
{ slug: 'angry', name: 'Angry' },
{ slug: 'angry_pink', name: 'Angry (Pink)' },
{ slug: 'angry_yellow', name: 'Angry (Yellow)' },
{ slug: 'radiating_energy', name: 'Radiating Energy' },
{ slug: 'radiating_energy_yellow', name: 'Radiating Energy (Yellow)' },
{ slug: 'radiating_energy_blue', name: 'Radiating Energy (Blue)' },
{ slug: 'sweat_drops', name: 'Sweat Drops' },
{ slug: 'sweat_drops_cyan', name: 'Sweat Drops (Cyan)' },
{ slug: 'sweat_drops_pink', name: 'Sweat Drops (Pink)' },
{ slug: 'soul_leaving_body', name: 'Soul Leaving Body' },
{ slug: 'soul_leaving_body_blue', name: 'Soul Leaving Body (Blue)' },
{ slug: 'starry_eyed', name: 'Starry Eyed' },
{ slug: 'starry_eyed_green', name: 'Starry Eyed (Green)' },
{ slug: 'starry_eyed_pink', name: 'Starry Eyed (Pink)' },
{ slug: 'shocked', name: 'Shocked' },
{ slug: 'shocked_green', name: 'Shocked (Green)' },
{ slug: 'shocked_yellow', name: 'Shocked (Yellow)' },
{ slug: 'in_love', name: 'In Love' },
{ slug: 'in_love_blue', name: 'In Love (Blue)' },
{ slug: 'in_love_green', name: 'In Love (Green)' },
{ slug: 'ki_energy_green', name: 'Ki Energy (Green)' },
{ slug: 'ki_energy_cyan', name: 'Ki Energy (Cyan)' },
{ slug: 'ki_energy_blue', name: 'Ki Energy (Blue)' },
{ slug: 'ki_energy_fuchsia', name: 'Ki Energy (Fuchsia)' },
{ slug: 'heartbloom', name: 'Heartbloom' },
{ slug: 'heartbloom_purple', name: 'Heartbloom (Purple)' },
{ slug: 'heartbloom_blue', name: 'Heartbloom (Blue)' },
{ slug: 'heartbloom_yellow', name: 'Heartbloom (Yellow)' },
{ slug: 'heartbloom_green', name: 'Heartbloom (Green)' },
{ slug: 'dismay', name: 'Dismay' },
{ slug: 'dismay_purple', name: 'Dismay (Purple)' },
{ slug: 'dismay_pink', name: 'Dismay (Pink)' },
{ slug: 'dismay_yellow', name: 'Dismay (Yellow)' },
{ slug: 'dismay_green', name: 'Dismay (Green)' },
{ slug: 'in_tears', name: 'In Tears' },
{ slug: 'in_tears_purple', name: 'In Tears (Purple)' },
{ slug: 'in_tears_pink', name: 'In Tears (Pink)' },
{ slug: 'in_tears_yellow', name: 'In Tears (Yellow)' },
{ slug: 'in_tears_green', name: 'In Tears (Green)' },
{ slug: 'rage', name: 'Rage' },
{ slug: 'rage_green', name: 'Rage (Green)' },
{ slug: 'rage_blue', name: 'Rage (Blue)' },
{ slug: 'rage_purple', name: 'Rage (Purple)' },
{ slug: 'rage_red', name: 'Rage (Red)' },
{ slug: 'copium', name: 'Copium' },
{ slug: 'trash', name: 'Trash' },
{ slug: 'grass_toucher', name: 'Grass Toucher' },
{ slug: 'touch_grass', name: 'Touch Grass' },
{ slug: 'press_f_black', name: 'Press F (Black)' },
{ slug: 'press_f_white', name: 'Press F (White)' },
{ slug: 'git_gud', name: 'Git Gud' },
{ slug: 'ggez', name: 'GGEZ' },
{ slug: 'questionable', name: 'Questionable' },
{ slug: 'buffering', name: 'Buffering' },
{ slug: 'toilet_lid', name: 'Toilet Lid' },
{ slug: 'feeling_cute', name: 'Feeling Cute' },
{ slug: 'im_a_clown', name: "I'm a Clown" },
],
},
{
id: 'critters',
label: 'Critters',
decorations: [
{ slug: 'cat_1', name: 'Lazy Cat (Ginger)' },
{ slug: 'cat_2', name: 'Lazy Cat (Tabby)' },
{ slug: 'cat_3', name: 'Lazy Cat (Calico)' },
{ slug: 'cat_4', name: 'Lazy Cat (Siamese)' },
{ slug: 'dog_1', name: 'Dogs (Tan)' },
{ slug: 'dog_2', name: 'Dogs (Spotted)' },
{ slug: 'dog_3', name: 'Dogs (Brown)' },
{ slug: 'frog_1', name: 'Frog' },
{ slug: 'frog_derpy', name: 'Derpy Frog' },
{ slug: 'frog_3', name: 'Sleepy Frog' },
{ slug: 'frog_angry', name: 'Angry Frog' },
{ slug: 'mushroom_1', name: 'Mushroom (Moss)' },
{ slug: 'mushroom_2', name: 'Mushroom (Amber)' },
{ slug: 'mushroom_3', name: 'Mushroom (Rust)' },
{ slug: 'mushroom_4', name: 'Mushroom (Neon)' },
{ slug: 'hoppy_day', name: 'Hoppy Day' },
{ slug: 'hoppy_boi', name: 'Hoppy Boi' },
{ slug: 'cow_glider', name: 'Cow Glider' },
{ slug: 'moomoo_hood', name: 'Moomoo Hood' },
{ slug: 'cosy_deer', name: 'Cosy Deer' },
{ slug: 'berry_bunny', name: 'Berry Bunny' },
{ slug: 'mochi_bunny_pink', name: 'Mochi Bunny (Pink)' },
{ slug: 'mochi_bunny_blue', name: 'Mochi Bunny (Blue)' },
{ slug: 'nibbles', name: 'Nibbles' },
{ slug: 'hammy', name: 'Hammy' },
{ slug: 'hammy_blue', name: 'Hammy (Blue)' },
{ slug: 'hammy_green', name: 'Hammy (Green)' },
{ slug: 'hammy_pink', name: 'Hammy (Pink)' },
{ slug: 'shroomling', name: 'Shroomling' },
{ slug: 'shroomling_blue', name: 'Shroomling (Blue)' },
{ slug: 'shroomling_brown', name: 'Shroomling (Brown)' },
{ slug: 'shroomling_pink', name: 'Shroomling (Pink)' },
{ slug: 'shroomling_purple', name: 'Shroomling (Purple)' },
{ slug: 'sleepy_bee', name: 'Sleepy Bee' },
{ slug: 'baker_bear', name: 'Baker Bear' },
{ slug: 'dewdrop', name: 'Dewdrop' },
{ slug: 'gone_fishin', name: "Gone Fishin'" },
{ slug: 'happy_harvest', name: 'Happy Harvest' },
{ slug: 'cat_onesie', name: 'Cat Onesie' },
{ slug: 'cat_onesie_pink', name: 'Cat Onesie (Pink)' },
{ slug: 'cat_onesie_black', name: 'Cat Onesie (Black)' },
{ slug: 'cat_ears_yellow', name: 'Cat Ears (Yellow)' },
{ slug: 'cat_ears_green', name: 'Cat Ears (Green)' },
{ slug: 'cat_ears_blue', name: 'Cat Ears (Blue)' },
{ slug: 'cat_ears_purple', name: 'Cat Ears (Purple)' },
{ slug: 'greenhouse_cat', name: 'Greenhouse Cat' },
{ slug: 'graveyard_cat', name: 'Graveyard Cat' },
{ slug: 'forest_frolic', name: 'Forest Frolic' },
{ slug: 'a_real_fungi', name: 'A Real Fungi' },
{ slug: 'mokoko', name: 'Mokoko' },
],
},
{
id: 'monsters',
label: 'Little Monsters',
decorations: [
{ slug: 'beamchop', name: 'Beamchop' },
{ slug: 'chuck', name: 'Chuck' },
{ slug: 'winkle', name: 'Winkle' },
{ slug: 'chewbert', name: 'Chewbert' },
{ slug: 'doodlezard', name: 'Doodlezard' },
{ slug: 'glop', name: 'Glop' },
{ slug: 'gawblehop', name: 'Gawblehop' },
{ slug: 'stinkums', name: 'Stinkums' },
{ slug: 'goblin_stinkums', name: 'Goblin Stinkums' },
{ slug: 'chomp_chomp', name: 'Chomp Chomp' },
],
},
{
id: 'snacks',
label: 'Snacks',
decorations: [
{ slug: 'pancakes', name: 'Pancakes' },
{ slug: 'blueberry_jam', name: 'Blueberry Jam' },
{ slug: 'donut', name: 'Donut' },
{ slug: 'fried_egg', name: 'Fried Egg' },
{ slug: 'green_fried_egg', name: 'Green Fried Egg' },
{ slug: 'toast', name: 'Toast' },
{ slug: 'burnt_toast', name: 'Burnt Toast' },
{ slug: 'morning_coffee', name: 'Morning Coffee' },
{ slug: 'sushi_roll', name: 'Sushi Roll' },
{ slug: 'ramen_bowl', name: 'Ramen Bowl' },
{ slug: 'ramen_bowl_toppings', name: 'Ramen Bowl (Toppings)' },
{ slug: 'donut_cow', name: 'Donut Cow' },
{ slug: 'donut_cat', name: 'Donut Cat' },
{ slug: 'donut_bear', name: 'Donut Bear' },
{ slug: 'bread_doggy', name: 'Bread Doggy' },
{ slug: 'ice_cream_frog', name: 'Ice Cream Frog' },
{ slug: 'jelly_bear', name: 'Jelly Bear' },
{ slug: 'nom_kitty', name: 'Nom Kitty' },
{ slug: 'egg', name: 'Egg' },
{ slug: 'chicken_nugget', name: 'Chicken Nugget' },
{ slug: 'sippy_juice', name: 'Sippy Juice' },
{ slug: 'bubble_tea', name: 'Bubble Tea' },
{ slug: 'freshly_picked', name: 'Freshly Picked' },
{ slug: 'strawberry_vine', name: 'Strawberry Vine' },
{ slug: 'a_hint_of_clove', name: 'A Hint of Clove' },
],
},
{
id: 'scene',
label: 'Scene',
decorations: [
{ slug: 'neon_cat_hoodie', name: 'Neon Cat Hoodie' },
{ slug: 'neon_cat_hoodie_blue', name: 'Neon Cat Hoodie (Blue)' },
{ slug: 'neon_cat_hoodie_pink', name: 'Neon Cat Hoodie (Pink)' },
{ slug: 'neon_cat_hoodie_black', name: 'Neon Cat Hoodie (Black)' },
{ slug: 'neon_decora_hair', name: 'Neon Decora Hair' },
{ slug: 'neon_spike_choker', name: 'Neon Spike Choker' },
{ slug: 'neon_spike_choker_black', name: 'Neon Spike Choker (Black)' },
{ slug: 'mischievous_kitties', name: 'Mischievous Kitties' },
{ slug: 'mischievous_kitties_hearts', name: 'Mischievous Kitties (Hearts)' },
{ slug: 'mischievous_kitties_blue', name: 'Mischievous Kitties (Blue)' },
{ slug: 'skully_charms', name: 'Skully Charms' },
{ slug: 'rawr_xd', name: 'Rawr xD' },
{ slug: 'magical_girl', name: 'Magical Girl' },
{ slug: 'sakura_scholar', name: 'Sakura Scholar' },
],
},
{
id: 'dreamy',
label: 'Dreamy',
decorations: [
{ slug: 'jet_ring', name: 'Jet Ring' },
{ slug: 'pondering_portal', name: 'Pondering Portal' },
{ slug: 'magic_mists', name: 'Magic Mists' },
{ slug: 'infinite_swirl', name: 'Infinite Swirl' },
{ slug: 'blue_gyroscope', name: 'Blue Gyroscope' },
{ slug: 'pink_gyroscope', name: 'Pink Gyroscope' },
{ slug: 'green_gyroscope', name: 'Green Gyroscope' },
{ slug: 'saw', name: 'Saw' },
{ slug: 'blue_smoke', name: 'Blue Smoke' },
{ slug: 'pink_smoke', name: 'Pink Smoke' },
{ slug: 'green_smoke', name: 'Green Smoke' },
{ slug: 'starlight_whales', name: 'Starlight Whales' },
{ slug: 'head_in_the_clouds', name: 'Head In the Clouds' },
{ slug: 'head_in_the_clouds_sunset', name: 'Head In the Clouds (Sunset)' },
{ slug: 'mirage', name: 'Mirage' },
{ slug: 'mirage_void', name: 'Mirage (Void)' },
{ slug: 'mirage_twilight', name: 'Mirage (Twilight)' },
{ slug: 'mirage_nightshade', name: 'Mirage (Nightshade)' },
{ slug: 'lava_blobs', name: 'Lava Blobs' },
{ slug: 'lava_blobs_pink', name: 'Lava Blobs (Pink)' },
{ slug: 'lava_blobs_blue', name: 'Lava Blobs (Blue)' },
{ slug: 'lava_blobs_slime', name: 'Lava Blobs (Slime)' },
{ slug: 'luminescent_lotus', name: 'Luminescent Lotus' },
{ slug: 'woolgathering', name: 'Woolgathering' },
{ slug: 'travel_ring', name: 'Travel Ring' },
{ slug: 'dream_dive_stars', name: 'Dream Dive Stars' },
{ slug: 'rift_butterfly', name: 'Rift Butterfly' },
],
},
{
id: 'celestial',
label: 'Celestial',
decorations: [
{ slug: 'lunar_flowers', name: 'Lunar Flowers' },
{ slug: 'starlight_tiara', name: 'Starlight Tiara' },
{ slug: 'astral_aura', name: 'Astral Aura' },
{ slug: 'luna_moths', name: 'Luna Moths' },
{ slug: 'moonlit_charms', name: 'Moonlit Charms' },
{ slug: 'liquid_moon', name: 'Liquid Moon' },
],
},
{
id: 'garden',
label: 'Garden',
decorations: [
{ slug: 'terrarium', name: 'Terrarium' },
{ slug: 'mushcaps', name: 'Mushcaps' },
{ slug: 'blossomburst', name: 'Blossomburst' },
{ slug: 'rainy_canopy', name: 'Rainy Canopy' },
{ slug: 'snowdrops', name: 'Snowdrops' },
{ slug: 'afternoon_breeze', name: 'Afternoon Breeze' },
{ slug: 'shower_stroll', name: 'Shower Stroll' },
{ slug: 'sakura_gyoiko', name: 'Gyoiko Sakura' },
{ slug: 'sakura_ukon', name: 'Ukon Sakura' },
{ slug: 'forest', name: 'Forest' },
{ slug: 'fall_leaves_woodland', name: 'Woodland Leaves' },
{ slug: 'autumn_foliage', name: 'Autumn Foliage' },
{ slug: 'flower_clouds', name: 'Flower Clouds' },
{ slug: 'the_petal_pack', name: 'The Petal Pack' },
{ slug: 'chrysanthemums_morning', name: 'Morning Chrysanthemums' },
{ slug: 'chrysanthemums_twilight', name: 'Twilight Chrysanthemums' },
{ slug: 'dusk_and_dawn', name: 'Dusk and Dawn' },
{ slug: 'floral_harmony', name: 'Floral Harmony' },
{ slug: 'floral_harmony_sunburst', name: 'Floral Harmony (Sunburst)' },
{ slug: 'autumns_arbor', name: "Autumn's Arbor" },
{ slug: 'autumns_arbor_aurora', name: "Autumn's Arbor (Aurora)" },
{ slug: 'autumn_crown', name: 'Autumn Crown' },
{ slug: 'faces_of_the_moon', name: 'Faces of the Moon' },
{ slug: 'lover_girl_autumn', name: 'Lover Girl Autumn' },
],
},
{
id: 'summer',
label: 'Summer',
decorations: [
{ slug: 'sunflowers', name: 'Sunflowers' },
{ slug: 'beach_hat', name: 'Beach Hat' },
{ slug: 'a_duck', name: 'A Duck' },
{ slug: 'bubble', name: 'Bubble' },
{ slug: 'fireflies', name: 'Fireflies' },
],
},
{
id: 'love',
label: 'Love',
decorations: [
{ slug: 'rose_bearer_pink', name: 'Rose Bearer (Pink)' },
{ slug: 'rose_bearer_blue', name: 'Rose Bearer (Blue)' },
{ slug: 'angel', name: 'Angel' },
{ slug: 'devil', name: 'Devil' },
{ slug: 'aim_for_love', name: 'Aim for Love' },
{ slug: 'lovestruck', name: 'Lovestruck' },
{ slug: 'ring_of_roses_red', name: 'Ring of Roses (Red)' },
{ slug: 'ring_of_roses_blue', name: 'Ring of Roses (Blue)' },
{ slug: 'heartstrings_red', name: 'Heartstrings (Red)' },
{ slug: 'heartstrings_blue', name: 'Heartstrings (Blue)' },
{ slug: 'heart_to_heart', name: 'Heart-to-Heart' },
{ slug: 'ruby_hearts', name: 'Ruby Hearts' },
],
},
{
id: 'winter',
label: 'Winter',
decorations: [
{ slug: 'snowfall', name: 'Snowfall' },
{ slug: 'santa_cat_ears', name: 'Holiday Cat Ears' },
{ slug: 'string_lights', name: 'String Lights' },
{ slug: 'string_lights_aurora', name: 'String Lights (Aurora)' },
{ slug: 'string_lights_ember', name: 'String Lights (Ember)' },
{ slug: 'string_lights_dusk', name: 'String Lights (Dusk)' },
{ slug: 'string_lights_mix', name: 'String Lights (Mix)' },
{ slug: 'snowglobe', name: 'Snowglobe' },
{ slug: 'snowglobe_wood', name: 'Snowglobe (Wood)' },
{ slug: 'snowglobe_pink', name: 'Snowglobe (Pink)' },
{ slug: 'snowglobe_blue', name: 'Snowglobe (Blue)' },
{ slug: 'snowglobe_green', name: 'Snowglobe (Green)' },
{ slug: 'fresh_pine', name: 'Fresh Pine' },
{ slug: 'fresh_pine_cinnamon', name: 'Fresh Pine (Cinnamon)' },
{ slug: 'fresh_pine_ribbon', name: 'Fresh Pine (Ribbon)' },
{ slug: 'blanket_green', name: 'Green Blanket' },
{ slug: 'blanket_orange', name: 'Orange Blanket' },
{ slug: 'blanket_pink', name: 'Pink Blanket' },
{ slug: 'blanket_purple', name: 'Purple Blanket' },
{ slug: 'box_white_blue', name: 'Gift Box (White)' },
{ slug: 'box_blue_yellow', name: 'Gift Box (Blue)' },
{ slug: 'box_green_red', name: 'Gift Box (Green)' },
{ slug: 'box_red_white', name: 'Gift Box (Red)' },
{ slug: 'box_red_white_blue', name: 'Gift Box (Red, White & Blue)' },
{ slug: 'ice_cube', name: 'Ice Cube' },
{ slug: 'icicle_gleaming', name: 'Gleaming Icicle' },
{ slug: 'icicle_snowing', name: 'Snowing Icicle' },
{ slug: 'teacup_blue', name: 'Blue Teacup' },
{ slug: 'teacup_orange', name: 'Orange Teacup' },
{ slug: 'teacup_pink', name: 'Pink Teacup' },
{ slug: 'teacup_red', name: 'Red Teacup' },
],
},
{
id: 'celebrate',
label: 'Celebrate',
decorations: [
{ slug: 'new_year_2024', name: '2024 Balloons' },
{ slug: 'new_year_2025', name: '2025 Balloons' },
{ slug: 'confetti_festive', name: 'Festive Confetti' },
{ slug: 'confetti_fire', name: 'Fire Confetti' },
{ slug: 'confetti_ice', name: 'Ice Confetti' },
{ slug: 'confetti_mint', name: 'Mint Confetti' },
{ slug: 'confetti_star', name: 'Star Confetti' },
{ slug: 'confetti_vaporwave', name: 'Vaporwave Confetti' },
{ slug: 'snakes_hug', name: "Snake's Hug" },
{ slug: 'red_lantern', name: 'Red Lantern' },
{ slug: 'fan_flourish', name: 'Fan Flourish' },
{ slug: 'lucky_envelopes', name: 'Lucky Envelopes' },
],
},
{
id: 'steampunk',
label: 'Steampunk',
decorations: [
{ slug: 'steampunk_cat_ears', name: 'Steampunk Cat Ears' },
{ slug: 'mech_flora', name: 'Mech Flora' },
{ slug: 'bowler_hat', name: 'Bowler Hat' },
{ slug: 'brass_beats', name: 'Brass Beats' },
{ slug: 'timekeepers_clock', name: "Timekeeper's Clock" },
{ slug: 'flux_alchemy', name: 'Flux Alchemy' },
{ slug: 'magic_portal_purple', name: 'Magic Portal (Purple)' },
{ slug: 'magic_portal_blue', name: 'Magic Portal (Blue)' },
{ slug: 'fairy_sprites', name: 'Fairy Sprites' },
{ slug: 'fairy_sprites_pink', name: 'Fairy Sprites (Pink)' },
{ slug: 'fairy_sprites_blue', name: 'Fairy Sprites (Blue)' },
{ slug: 'wizard_hat_purple', name: 'Wizard Hat (Purple)' },
{ slug: 'wizard_hat_blue', name: 'Wizard Hat (Blue)' },
{ slug: 'magical_wand_purple', name: 'Magical Wand (Purple)' },
{ slug: 'magical_wand_green', name: 'Magical Wand (Green)' },
{ slug: 'mooncaps_pink', name: 'Mooncaps (Pink)' },
{ slug: 'mooncaps_blue', name: 'Mooncaps (Blue)' },
{ slug: 'cottage_home', name: 'Cottage Home' },
{ slug: 'treasure_and_key', name: 'Treasure and Key' },
],
},
{
id: 'pirates',
label: 'Pirates',
decorations: [
{ slug: 'pirate_captain', name: 'Pirate Captain' },
{ slug: 'scallywag', name: 'Scallywag' },
{ slug: 'good_ol_pepper', name: "Good Ol' Pepper" },
{ slug: 'crossbones', name: 'Crossbones' },
{ slug: 'cannon_fire', name: 'Cannon Fire' },
{ slug: 'helmsman', name: 'Helmsman' },
],
},
{
id: 'neo_tokyo',
label: 'Neo Tokyo',
decorations: [
{ slug: 'bonsai', name: 'Bonsai' },
{ slug: 'sakura_city', name: 'Sakura City' },
{ slug: 'hologram_dragon', name: 'Hologram Dragon' },
{ slug: 'cyber_katana', name: 'Cyber Katana' },
{ slug: 'o_n_i', name: 'O.N.I' },
{ slug: 'jingasa', name: 'Jingasa' },
{ slug: 'white_mana', name: 'White Mana' },
{ slug: 'blue_mana', name: 'Blue Mana' },
{ slug: 'black_mana', name: 'Black Mana' },
{ slug: 'red_mana', name: 'Red Mana' },
{ slug: 'green_mana', name: 'Green Mana' },
],
},
{
id: 'lofi',
label: 'Lofi',
decorations: [
{ slug: 'lofi_girl_outfit', name: 'Lofi Girl Outfit' },
{ slug: 'playful_lofi_cat', name: 'Playful Lofi Cat' },
{ slug: 'sleepy_chilledcow', name: 'Sleepy ChilledCow' },
{ slug: 'study_session', name: 'Study Session' },
{ slug: 'group_hug', name: 'Group Hug' },
{ slug: 'cozy_post_it', name: 'Cozy Post-It' },
{ slug: 'cozy_post_it_festive', name: 'Cozy Post-It (Festive)' },
{ slug: 'cat_ear_headset', name: 'Cat Ear Headset' },
{ slug: 'bunny_zzzs', name: 'Bunny Zzzs' },
{ slug: 'sproutling', name: 'Sproutling' },
{ slug: 'bloomling', name: 'Bloomling' },
{ slug: 'neon_nibbles', name: 'Neon Nibbles' },
{ slug: 'uwu_xp', name: 'UwU XP' },
{ slug: 'dancing_fairies', name: 'Dancing Fairies' },
{ slug: 'crystal_elk', name: 'Crystal Elk' },
{ slug: 'mermaid_serenade', name: 'Mermaid Serenade' },
],
},
{
id: 'game_night',
label: 'Game Night',
decorations: [
{ slug: 'hugh_the_rainbow', name: 'Hugh the Rainbow' },
{ slug: 'imagination', name: 'Imagination' },
{ slug: 'depresso', name: 'Depresso' },
{ slug: 'selyne', name: 'Selyne' },
{ slug: 'cattiva', name: 'Cattiva' },
{ slug: 'gold_laurel_wreath', name: 'Gold Laurel Wreath' },
{ slug: 'golden_hex', name: 'Golden Hex' },
{ slug: 'terrain_tiles', name: 'Terrain Tiles' },
{ slug: 'hex_tiles', name: 'Hex Tiles' },
{ slug: 'city_walls', name: 'City Walls' },
{ slug: 'next_turn_button', name: 'Next Turn Button' },
{ slug: 'armamenter', name: 'Armamenter' },
{ slug: 'e_d_hacker', name: 'E.D Hacker' },
{ slug: 'got_xenoglossy', name: 'Got Xenoglossy' },
{ slug: 'port_of_soul', name: 'Port of Soul' },
{ slug: 'rocket_puncher', name: 'Rocket Puncher' },
{ slug: 'lights_camera_action_orange', name: 'Lights, Camera, Action! (Orange)' },
{ slug: 'lights_camera_action_teal', name: 'Lights, Camera, Action! (Teal)' },
{ slug: 'victory_crown', name: 'Fortnite Victory Crown' },
{ slug: 'shield_potion', name: 'Shield Potion' },
{ slug: 'slurp_barrel', name: 'Slurp Barrel' },
{ slug: 'supply_llama', name: 'Supply Llama' },
{ slug: 'bush_camper', name: 'Bush Camper' },
],
},
{
id: 'crossover_screen',
label: 'Movies & TV',
decorations: [
{ slug: 'lightsabers_blue_and_red', name: 'Lightsabers (Blue and Red)' },
{ slug: 'lightsabers_green_and_red', name: 'Lightsabers (Green and Red)' },
{ slug: 'r2_d2_on_tatooine', name: 'R2-D2 on Tatooine' },
{ slug: 'curious_bb_8', name: 'Curious BB-8' },
{ slug: 'yoda_on_dagobah', name: 'Yoda on Dagobah' },
{ slug: 'millennium_falcon_hyperdrive', name: 'Millennium Falcon Hyperdrive' },
{ slug: 'space_battle', name: 'Space Battle' },
{ slug: 'the_thing', name: 'The Thing' },
{ slug: 'invisible_woman', name: 'Invisible Woman' },
{ slug: 'mister_fantastic', name: 'Mister Fantastic' },
{ slug: 'human_torch', name: 'Human Torch' },
{ slug: 'silver_surfer', name: 'Silver Surfer' },
{ slug: 'the_fantastic_four', name: 'The Fantastic Four' },
{ slug: 'dragon_balls', name: 'Dragon Balls' },
{ slug: 'gomah', name: 'Gomah' },
{ slug: 'shenron', name: 'Shenron' },
{ slug: 'mini_goku', name: 'Mini Goku' },
{ slug: 'mini_vegeta', name: 'Mini Vegeta' },
{ slug: 'glorio', name: 'Glorio' },
{ slug: 'mini_supreme_kai', name: 'Mini Supreme Kai' },
{ slug: 'mini_piccolo', name: 'Mini Piccolo' },
{ slug: 'panzy', name: 'Panzy' },
{ slug: 'izuku_midoriya', name: 'Izuku Midoriya' },
{ slug: 'katsuki_bakugo', name: 'Katsuki Bakugo' },
{ slug: 'ochaco_uraraka', name: 'Ochaco Uraraka' },
{ slug: 'shoto_todoroki', name: 'Shoto Todoroki' },
{ slug: 'endeavor', name: 'Endeavor' },
{ slug: 'hawks', name: 'Hawks' },
{ slug: 'all_might', name: 'All Might' },
{ slug: 'tomura_shigaraki', name: 'Tomura Shigaraki' },
{ slug: 'spongebob', name: 'SpongeBob' },
{ slug: 'patrick_star', name: 'Patrick Star' },
{ slug: 'gary_the_snail', name: 'Gary the Snail' },
{ slug: 'sandy_cheeks', name: 'Sandy Cheeks' },
{ slug: 'musclebob', name: 'MuscleBob' },
{ slug: 'skibidi_toilet', name: 'Skibidi Toilet' },
{ slug: 'g_toilet', name: 'G-Toilet' },
{ slug: 'cameraman_glitch', name: 'Cameraman Glitch' },
{ slug: 'speakerwoman', name: 'Speakerwoman' },
{ slug: 'tv_woman', name: 'TV Woman' },
{ slug: 'juggernaut_astro', name: 'Juggernaut Astro' },
{ slug: 'the_anomaly', name: 'The Anomaly' },
{ slug: 'the_mark', name: 'The Mark' },
{ slug: 'the_monster_you_created', name: 'The Monster You Created' },
{ slug: 'the_atlas_gauntlets', name: 'The Atlas Gauntlets' },
{ slug: 'flame_chompers', name: 'Flame Chompers' },
{ slug: 'fishbones', name: 'FISHBONES!' },
{ slug: 'the_hexcore', name: 'The Hexcore' },
{ slug: 'powered_by_shimmer', name: 'Powered by Shimmer' },
{ slug: 'minions', name: 'Minions' },
{ slug: 'daredevil', name: 'Daredevil' },
{ slug: 'jean_grey_phoenix', name: 'Phoenix' },
{ slug: 'emma_frost', name: 'Emma Frost' },
{ slug: 'ultron', name: 'Ultron' },
{ slug: 'marvel_snap_venom', name: 'Marvel Snap Venom' },
{ slug: 'jeff_the_land_shark', name: 'Jeff the Land Shark' },
{ slug: 'hank_hill', name: 'Hank Hill' },
{ slug: 'how_to_train_your_dragon', name: 'How to Train Your Dragon' },
{ slug: 'jurassic_world_rebirth', name: 'Jurassic World Rebirth Trailer' },
{ slug: 'mission_impossible', name: 'Mission: Impossible' },
{ slug: 'bad_guys_2', name: 'The Bad Guys 2 Trailer' },
{ slug: 'm3gan_2_0', name: 'M3GAN 2.0' },
{ slug: '28_years_later', name: '28 Years Later' },
{ slug: 'black_phone_2', name: 'Black Phone 2' },
{ slug: 'the_conjuring_last_rites', name: 'The Conjuring: Last Rites' },
{ slug: 'ballerina', name: 'Ballerina' },
{ slug: 'wendys_x_wednesday', name: "Wendy's x Wednesday" },
{ slug: 'batarang', name: 'Batarang' },
{ slug: 'espn', name: 'ESPN' },
{ slug: 'aespa_fanlight', name: 'aespa Fanlight' },
{ slug: 'bing_bong', name: 'Bing Bong' },
],
},
{
id: 'crossover_games',
label: 'Game Crossovers',
decorations: [
{ slug: 'trick_pumpkin', name: 'Trick Pumpkin' },
{ slug: 'treat_pumpkin', name: 'Treat Pumpkin' },
{ slug: 'trick_spider', name: 'Trick Spider' },
{ slug: 'treat_spider', name: 'Treat Spider' },
{ slug: 'trick_skull', name: 'Trick Skull' },
{ slug: 'treat_skull', name: 'Treat Skull' },
{ slug: 'trick_ghost', name: 'Trick Ghost' },
{ slug: 'treat_ghost', name: 'Treat Ghost' },
{ slug: 'the_final_peel', name: 'The Final Peel' },
{ slug: 'warframe_clem', name: 'Warframe Clem' },
{ slug: 'dart_monkey', name: 'Dart Monkey' },
{ slug: 'kom', name: 'KOM' },
{ slug: 'mr_m', name: 'Mr. M' },
{ slug: 'fc_26_icon', name: 'FC 26 Icon' },
{ slug: 'skate', name: 'Skate' },
{ slug: 'airona_fireworks', name: 'Airona Fireworks' },
{ slug: 'champions_tactibear', name: 'Champions Tactibear' },
{ slug: 'cab_monster', name: 'Cab Monster' },
{ slug: 'atsus_mask', name: "Atsu's Mask" },
{ slug: 'tactibear_flex', name: 'Tactibear Flex' },
{ slug: 'angela_avatar', name: 'Angela Avatar' },
{ slug: 'echo_4', name: 'Echo 4' },
{ slug: 'nba_2k26', name: 'NBA 2K26' },
{ slug: 'eye_of_prophesy', name: 'Eye of Prophesy' },
{ slug: 'inzoi_psycat', name: 'inZOI Psycat' },
{ slug: 'bf_soldier_helmet', name: 'BF Soldier Helmet' },
{ slug: 'the_entity', name: 'The Entity' },
{ slug: 'descendant', name: 'Descendant' },
{ slug: 'sweet_tooth', name: 'Sweet Tooth' },
{ slug: 'call_of_duty_mobile', name: 'Call of Duty: Mobile' },
{ slug: 'thps_half_pipe', name: 'THPS Half Pipe' },
{ slug: 'mecha_break', name: 'Mecha BREAK' },
{ slug: 'valorant_summer_kickoff', name: 'VALORANT Summer Kickoff' },
{ slug: 'palia', name: 'Palia' },
{ slug: 'supercell', name: 'SuperCell' },
{ slug: 'i_love_repo', name: 'I Love R.E.P.O.' },
{ slug: 'lego_fortnite', name: 'LEGO® Fortnite' },
{ slug: 'r6_siege_x_avatar', name: 'R6 Siege X Avatar' },
{ slug: 'towerborne_play', name: 'Towerborne Play' },
{ slug: 'starlight_revolver', name: 'Starlight Revolver' },
{ slug: 'open_beta', name: 'Open Beta' },
{ slug: 'fortnite_galactic_battle', name: 'Fortnite Galactic Battle' },
{ slug: 'shield_saw', name: 'Shield Saw' },
{ slug: 'friend_of_dex', name: 'Friend of Dex' },
{ slug: 'hackclaw', name: 'Hackclaw' },
{ slug: 'signal_from_tau_ceti', name: 'Signal from Tau Ceti' },
{ slug: 'face_of_corruption', name: 'Face of Corruption' },
{ slug: 'clicker', name: 'Clicker' },
{ slug: 'gallica', name: 'Gallica' },
{ slug: 'khazan', name: 'Khazan' },
{ slug: 'split', name: 'Split' },
{ slug: 'pathojen', name: 'Pathojen' },
{ slug: 'big_dill_chain', name: 'Big Dill Chain' },
{ slug: 'exoborne', name: 'Exoborne' },
{ slug: 'scout', name: 'Scout' },
{ slug: 'fortnite_boogie_bomb', name: 'Fortnite Boogie Bomb' },
{ slug: 'fuchsia_agent', name: 'Fuchsia Agent' },
{ slug: 'wingmans_got_it', name: "WINGMAN'S GOT IT" },
{ slug: 'rec_room_lightning', name: 'Rec Room Lightning' },
{ slug: 'shadow', name: 'Shadow' },
{ slug: 'tga_controller', name: 'TGA Controller' },
{ slug: 'wallach_spaceport', name: 'Wallach IX Spaceport' },
{ slug: 'wolf_morph', name: 'Wolf Morph' },
{ slug: 'street_fighter_6_battle_field', name: 'Street Fighter 6 Battle Field' },
{ slug: 'torgal_puppy', name: 'Torgal Puppy' },
{ slug: 'bunny', name: 'Bunny Mech' },
{ slug: 'hailey', name: 'Hailey' },
{ slug: 'los_santos', name: 'Los Santos' },
{ slug: 'wingman_boba', name: 'Wingman Boba' },
{ slug: 'freezer_bunny_lovebug', name: 'Freezer Bunny Lovebug' },
{ slug: 'im_dancin', name: "I'm Dancin'!" },
{ slug: 'finger_gun', name: 'Finger Gun' },
{ slug: 'bandit_mask', name: 'Bandit Mask' },
{ slug: 'mad_moxxi', name: 'Mad Moxxi' },
{ slug: 'elpis', name: 'Elpis' },
{ slug: 'loot_lightshow', name: 'Loot Lightshow' },
{ slug: 'spirit_blossom_zed', name: 'Spirit Blossom Zed' },
{ slug: 'spirit_blossom_springs_sett', name: 'Spirit Blossom Springs Sett' },
{ slug: 'spirit_blossom_morgana', name: 'Spirit Blossom Morgana' },
{ slug: 'spirit_blossom_karma', name: 'Spirit Blossom Karma' },
{ slug: 'spirit_blossom_springs', name: 'Spirit Blossom Springs' },
{ slug: 'spirit_blossom_springs_ahri', name: 'Spirit Blossom Springs Ahri' },
{ slug: 'yunara', name: 'Yunara' },
{ slug: 'gelatinous_cube_green', name: 'Green Gelatinous Cube' },
{ slug: 'gelatinous_cube_blue', name: 'Blue Gelatinous Cube' },
{ slug: 'spooky_cat_ears_midnight', name: 'Spooky Cat Ears (Midnight)' },
{ slug: 'candlelight_dark', name: 'Dark Candlelight' },
{ slug: 'zombie_food_purple', name: 'Zombie Food (Purple)' },
{ slug: 'bloodthirsty_green', name: 'Bloodthirsty (Green)' },
{ slug: 'ryu', name: 'Ryu' },
{ slug: 'chun_li', name: 'Chun-Li' },
{ slug: 'ken', name: 'Ken' },
{ slug: 'akuma', name: 'Akuma' },
{ slug: 'cammy', name: 'Cammy' },
{ slug: 'guile', name: 'Guile' },
{ slug: 'juri', name: 'Juri' },
{ slug: 'm_bison', name: 'M. Bison' },
{ slug: 'valorant_champions_2024', name: 'VALORANT Champions 2024' },
{ slug: 'yoru_dimensional_drift', name: 'Yoru Dimensional Drift' },
{ slug: 'viper_poison_cloud', name: 'Viper Poison Cloud' },
{ slug: 'cypher_neural_theft', name: 'Cypher Neural Theft' },
{ slug: 'omens_cowl', name: "Omen's Cowl" },
{ slug: 'reynas_leer', name: "Reyna's Leer" },
{ slug: 'frag_out', name: 'FRAG OUT' },
{ slug: 'blade_storm', name: 'Blade Storm' },
{ slug: 'chillet', name: 'Chillet' },
{ slug: 'pal_sphere', name: 'Pal Sphere' },
{ slug: 'lamball', name: 'Lamball' },
],
},
];
export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap(
@@ -204,3 +846,14 @@ export function isValidDecorationSlug(slug: string): boolean {
export function decorationUrl(slug: string): string {
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
}
/**
* Small static WebP preview (first busy frame, 144px) for the picker. The full
* decorations are ~1 MB animated PNGs, so the picker shows these and only
* loads `decorationUrl` on hover/focus/selection. Built by
* scripts/makeDecorationThumbs.py; callers fall back to `decorationUrl` if a
* thumbnail is missing.
*/
export function decorationThumbUrl(slug: string): string {
return `${RESOLVED_DECORATION_CDN}/thumbs/${slug}.webp`;
}
@@ -50,6 +50,7 @@ import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { ParsedSearch, resolveRoomOperand } from './searchOperators';
import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
@@ -205,6 +206,8 @@ export function MessageSearch({
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
const [pinnedOnly, setPinnedOnly] = useState(false);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
// [Gitea #106] Why a typed operator couldn't be applied (unknown room, …).
const [operatorNotice, setOperatorNotice] = useState<string>();
const [searchParams, setSearchParams] = useSearchParams();
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
@@ -400,6 +403,94 @@ export function MessageSearch({
setRecentSearches((prev) => addRecentSearch(prev, term));
};
// [Gitea #106] Typed operators → the same filters the buttons set.
const handleQuery = (parsed: ParsedSearch, raw: string): boolean => {
const notices: string[] = [];
const roomChoices = allRooms.map((roomId) => {
const room = mx.getRoom(roomId);
return {
roomId,
name: room?.name ?? '',
aliases: [room?.getCanonicalAlias(), ...(room?.getAltAliases() ?? [])].filter(
(a): a is string => !!a,
),
};
});
const roomIds: string[] = [];
parsed.rooms.forEach((value) => {
const id = resolveRoomOperand(value, roomChoices);
if (id) {
if (!roomIds.includes(id)) roomIds.push(id);
} else notices.push(`No room matches “in:${value}”.`);
});
const mergedSenders = [...(searchParamsSenders ?? [])];
// from:bob → a known @bob:server (this homeserver first), as autocomplete would.
const myServer = mx.getUserId()?.split(':')[1];
const resolveUser = (id: string): string => {
if (id.includes(':')) return id;
const local = id.slice(1).toLowerCase();
const found = new Set<string>();
mx.getRooms().forEach((room) =>
room.getMembers().forEach((m) => {
if (m.userId.slice(1).split(':')[0].toLowerCase() === local) found.add(m.userId);
}),
);
const list = [...found];
return list.find((u) => u.split(':')[1] === myServer) ?? list[0] ?? id;
};
parsed.senders.map(resolveUser).forEach((id) => {
if (!mergedSenders.includes(id)) mergedSenders.push(id);
});
if (!parsed.term && mergedSenders.length === 0) {
notices.push('Add a word to search for, or a from:@user.');
}
if (parsed.unknown.length > 0) {
notices.push(
`Not a search filter: ${parsed.unknown.join(', ')} (searched as text). Try in:, from:, before:, after:, has:link/image/video/file, is:pinned.`,
);
}
setOperatorNotice(notices.length > 0 ? notices.join(' ') : undefined);
if (!parsed.term && mergedSenders.length === 0) return false;
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('term');
p.append('term', parsed.term);
if (parsed.senders.length > 0) {
p.delete('senders');
p.append('senders', encodeSearchParamValueArray(mergedSenders));
}
if (roomIds.length > 0) {
p.delete('rooms');
p.append('rooms', encodeSearchParamValueArray(roomIds));
// A room outside this page's own scope needs the all-rooms scope.
if (allowGlobal && roomIds.some((id) => !rooms.includes(id))) {
p.delete('global');
p.append('global', 'true');
}
}
if (parsed.fromTs !== undefined) {
p.delete('fromTs');
p.append('fromTs', String(parsed.fromTs));
}
if (parsed.toTs !== undefined) {
p.delete('toTs');
p.append('toTs', String(parsed.toTs));
}
if (parsed.containsUrl) {
p.delete('containsUrl');
p.append('containsUrl', 'true');
}
return p;
});
if (parsed.msgTypes.length > 0) {
setMsgTypeFilters((prev) => [...new Set([...prev, ...parsed.msgTypes])]);
}
if (parsed.pinnedOnly) setPinnedOnly(true);
setRecentSearches((prev) => addRecentSearch(prev, raw));
return true;
};
const handleRecentSearch = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
@@ -548,10 +639,16 @@ export function MessageSearch({
onSearch={handleSearch}
onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
onQuery={handleQuery}
recentSearches={recentSearches}
onRecentSearch={handleRecentSearch}
onClearRecentSearches={handleClearRecentSearches}
/>
{operatorNotice && (
<Text size="T200" role="status" style={{ color: color.Warning.Main }}>
{operatorNotice}
</Text>
)}
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
allowGlobal={allowGlobal}
@@ -582,7 +679,7 @@ export function MessageSearch({
<PageHero
icon={<Icon size="600" src={Icons.Message} />}
title="Search Messages"
subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
subTitle="Search by keyword. Narrow it with from:@user, in:room, after:2026-09-01 or before:7d, has:link / has:image, and is:pinned."
/>
</PageHeroSection>
</PageHeroEmpty>
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ParsedSearch, hasOperators, parseSearchQuery } from './searchOperators';
// Matches "from:@?anything" anywhere — @ is optional
const FROM_REGEX = /from:@?([^\s:][^\s]*)/gi;
@@ -43,6 +44,9 @@ type SearchInputProps = {
onSearch: (term: string) => void;
onReset: () => void;
onSenderAdd?: (userId: string) => void;
/** [Gitea #106] Receives in:/before:/after:/has:/is: (and from:) as filters. */
/** Returns false when the query wasn't run, so the text is kept as typed. */
onQuery?: (parsed: ParsedSearch, raw: string) => boolean;
recentSearches?: string[];
onRecentSearch?: (term: string) => void;
onClearRecentSearches?: () => void;
@@ -54,6 +58,7 @@ export function SearchInput({
onSearch,
onReset,
onSenderAdd,
onQuery,
recentSearches,
onRecentSearch,
onClearRecentSearches,
@@ -167,6 +172,19 @@ export function SearchInput({
const rawValue = searchInput.value.trim();
if (onQuery) {
const parsed = parseSearchQuery(rawValue);
if (!parsed.term && !hasOperators(parsed)) return;
// Recognised operators become filters (chips / controls); drop them
// from the visible input like from: always did.
const ran = onQuery(parsed, rawValue);
if (ran && hasOperators(parsed) && searchInputRef.current) {
searchInputRef.current.value = parsed.term;
}
closeAutocomplete();
return;
}
// Extract from:user fragments and convert to sender filters
const fromMatches = [...rawValue.matchAll(FROM_REGEX)];
fromMatches.forEach((match) => {
@@ -0,0 +1,86 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseDateOperand, parseSearchQuery, resolveRoomOperand } from './searchOperators';
const NOW = new Date(2026, 8, 25, 12, 0, 0).getTime();
const DAY = 86_400_000;
describe('parseSearchQuery', () => {
it('plain text stays the term', () => {
const p = parseSearchQuery('loot rules tonight', NOW);
assert.equal(p.term, 'loot rules tonight');
assert.deepEqual(p.senders, []);
});
it('pulls every operator out of the term', () => {
const p = parseSearchQuery(
'raid from:bob in:general after:7d before:2026-09-24 has:link has:image is:pinned plan',
NOW,
);
assert.equal(p.term, 'raid plan');
assert.deepEqual(p.senders, ['@bob']);
assert.deepEqual(p.rooms, ['general']);
assert.equal(p.fromTs, NOW - 7 * DAY);
assert.equal(p.toTs, new Date(2026, 8, 24).getTime());
assert.equal(p.containsUrl, true);
assert.deepEqual(p.msgTypes, ['m.image']);
assert.equal(p.pinnedOnly, true);
assert.deepEqual(p.unknown, []);
});
it('keeps @ on from:, accepts quoted in:, and is case-insensitive on keys', () => {
const p = parseSearchQuery('FROM:@alice:x.org In:"voice lounge" hello', NOW);
assert.deepEqual(p.senders, ['@alice:x.org']);
assert.deepEqual(p.rooms, ['voice lounge']);
assert.equal(p.term, 'hello');
});
it('leaves operators it cannot understand in the term', () => {
const p = parseSearchQuery('has:gif after:someday is:starred hi', NOW);
assert.equal(p.term, 'has:gif after:someday is:starred hi');
assert.deepEqual(p.unknown, ['has:gif', 'after:someday', 'is:starred']);
});
it('does not treat a URL or a mid-word colon as an operator', () => {
const p = parseSearchQuery('see https://in:example.org and 10:30 before-lunch', NOW);
assert.equal(p.term, 'see https://in:example.org and 10:30 before-lunch');
assert.deepEqual(p.rooms, []);
});
it('has: file/video map to the media filters without duplicates', () => {
const p = parseSearchQuery('has:file has:files has:video', NOW);
assert.deepEqual(p.msgTypes, ['m.file', 'm.video']);
assert.equal(p.term, '');
});
});
describe('parseDateOperand', () => {
it('relative units', () => {
assert.equal(parseDateOperand('3h', NOW), NOW - 3 * 3_600_000);
assert.equal(parseDateOperand('2w', NOW), NOW - 14 * DAY);
assert.equal(parseDateOperand('1m', NOW), NOW - 30 * DAY);
});
it('rejects impossible dates and junk', () => {
assert.equal(parseDateOperand('2026-02-30', NOW), undefined);
assert.equal(parseDateOperand('yesterday', NOW), undefined);
});
});
describe('resolveRoomOperand', () => {
const rooms = [
{ roomId: '!a:x', name: 'General', aliases: ['#general:x.org'] },
{ roomId: '!b:x', name: 'Voice Lounge', aliases: [] },
{ roomId: '!c:x', name: 'Off Topic', aliases: ['#offtopic:x.org'] },
];
it('by alias, alias localpart, name, room id', () => {
assert.equal(resolveRoomOperand('#general:x.org', rooms), '!a:x');
assert.equal(resolveRoomOperand('offtopic', rooms), '!c:x');
assert.equal(resolveRoomOperand('voice lounge', rooms), '!b:x');
assert.equal(resolveRoomOperand('!b:x', rooms), '!b:x');
});
it('a unique partial name match, otherwise nothing', () => {
assert.equal(resolveRoomOperand('lounge', rooms), '!b:x');
assert.equal(resolveRoomOperand('o', rooms), undefined);
assert.equal(resolveRoomOperand('nope', rooms), undefined);
});
});
@@ -0,0 +1,140 @@
import type { MsgTypeFilter } from './useMessageSearch';
/**
* [Gitea #106] Discord-style operators typed into the search box. Each one
* maps onto a filter the search already has (sender chips, room picker, date
* range, "contains link", media type, pinned), so typing and clicking end up
* in the same state. Pure and unit-tested.
*
* from:@user sender (existing)
* in:general in:"a b" room, by name or alias (resolved by the caller)
* after:2026-09-01 from the start of that day (local time)
* before:2026-09-01 up to the start of that day
* after:7d before:2w relative: h, d, w, m (30 d), y (365 d) ago
* has:link | image | video | file
* is:pinned
*/
export type ParsedSearch = {
term: string;
senders: string[];
rooms: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
msgTypes: MsgTypeFilter[];
pinnedOnly?: boolean;
/** Operators we didn't understand, left in the term as typed. */
unknown: string[];
};
const UNIT_MS: Record<string, number> = {
h: 3_600_000,
d: 86_400_000,
w: 7 * 86_400_000,
m: 30 * 86_400_000,
y: 365 * 86_400_000,
};
/** A date operand → timestamp, or undefined if it isn't one. Exported for tests. */
export function parseDateOperand(value: string, now: number): number | undefined {
const rel = /^(\d{1,4})([hdwmy])$/i.exec(value);
if (rel) return now - Number(rel[1]) * UNIT_MS[rel[2].toLowerCase()];
const abs = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value);
if (abs) {
const [y, m, d] = [Number(abs[1]), Number(abs[2]), Number(abs[3])];
const date = new Date(y, m - 1, d);
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
return undefined;
}
return date.getTime();
}
return undefined;
}
const HAS_TYPES: Record<string, MsgTypeFilter> = {
image: 'm.image',
images: 'm.image',
video: 'm.video',
videos: 'm.video',
file: 'm.file',
files: 'm.file',
};
// key:"quoted value" or key:value
const OPERATOR = /(^|\s)(from|in|after|before|has|is):(?:"([^"]*)"|(\S*))/gi;
export function parseSearchQuery(raw: string, now = Date.now()): ParsedSearch {
const out: ParsedSearch = { term: '', senders: [], rooms: [], msgTypes: [], unknown: [] };
const kept: string[] = [];
let last = 0;
raw.replace(
OPERATOR,
(match, lead: string, keyRaw: string, quoted?: string, bare?: string, offset?: number) => {
const key = keyRaw.toLowerCase();
const value = (quoted ?? bare ?? '').trim();
const start = (offset ?? 0) + lead.length;
kept.push(raw.slice(last, start));
last = start + match.length - lead.length;
let understood = value !== '';
if (understood) {
if (key === 'from') {
out.senders.push(value.startsWith('@') ? value : `@${value}`);
} else if (key === 'in') {
out.rooms.push(value);
} else if (key === 'after' || key === 'before') {
const ts = parseDateOperand(value, now);
if (ts === undefined) understood = false;
else if (key === 'after') out.fromTs = ts;
else out.toTs = ts;
} else if (key === 'has') {
const v = value.toLowerCase();
if (v === 'link' || v === 'links' || v === 'url') out.containsUrl = true;
else if (HAS_TYPES[v]) {
if (!out.msgTypes.includes(HAS_TYPES[v])) out.msgTypes.push(HAS_TYPES[v]);
} else understood = false;
} else if (key === 'is') {
if (value.toLowerCase() === 'pinned') out.pinnedOnly = true;
else understood = false;
}
}
if (!understood) {
out.unknown.push(match.trim());
kept.push(match.trim());
}
return match;
},
);
kept.push(raw.slice(last));
out.term = kept.join(' ').replace(/\s+/g, ' ').trim();
return out;
}
/** True when the query contains any operator at all (for the submit path). */
export const hasOperators = (p: ParsedSearch): boolean =>
p.senders.length > 0 ||
p.rooms.length > 0 ||
p.fromTs !== undefined ||
p.toTs !== undefined ||
!!p.containsUrl ||
p.msgTypes.length > 0 ||
!!p.pinnedOnly;
/** Resolve an `in:` value to a room id among `rooms`. Exported for tests. */
export function resolveRoomOperand(
value: string,
rooms: { roomId: string; name: string; aliases: string[] }[],
): string | undefined {
const v = value.toLowerCase().replace(/^#/, '');
const exact = rooms.find(
(r) =>
r.roomId.toLowerCase() === value.toLowerCase() ||
r.aliases.some((a) => {
const al = a.toLowerCase().replace(/^#/, '');
return al === v || al.split(':')[0] === v;
}) ||
r.name.toLowerCase() === v,
);
if (exact) return exact.roomId;
const partial = rooms.filter((r) => r.name.toLowerCase().includes(v));
return partial.length === 1 ? partial[0].roomId : undefined;
}
@@ -174,8 +174,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
// date range is instead enforced client-side (see filterGroupsByDateRange).
// contains_url is a valid spec field not yet in SDK types.
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
include_state: false,
order_by: order as SearchOrderBy.Recent,
search_term: term,
+9 -3
View File
@@ -155,13 +155,20 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#roomnavitem-dialog',
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" aria-labelledby="rename-room-dialog-title">
<Dialog
id="roomnavitem-dialog"
role="dialog"
aria-modal="true"
tabIndex={-1}
variant="Surface"
aria-labelledby="rename-room-dialog-title"
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -1021,7 +1028,6 @@ function RoomNavItem_({
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
@@ -392,13 +392,17 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#roomserveracl-dialog',
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
id="roomserveracl-dialog"
role="dialog"
aria-modal="true"
tabIndex={-1}
variant="Surface"
aria-labelledby="server-acl-confirm-title"
style={modalStyle}
@@ -14,6 +14,7 @@ import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearc
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useKeyDown } from '../../hooks/useKeyDown';
import { onTabPress } from '../../utils/keyboard';
import { useAutocompleteEnter } from '../../hooks/useAutocompleteEnter';
type CommandAutoCompleteHandler = (commandName: string) => void;
@@ -60,15 +61,14 @@ export function CommandAutocomplete({
requestClose();
};
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteNames.length === 0) {
return;
}
const cmdName = autoCompleteNames[0];
handleAutocomplete(cmdName);
});
});
const pickTop = () => {
if (autoCompleteNames.length === 0) return;
handleAutocomplete(autoCompleteNames[0]);
};
useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop));
// Only while suggestions show: with no match Enter must still send.
useAutocompleteEnter(autoCompleteNames.length > 0, pickTop);
return autoCompleteNames.length === 0 ? null : (
<AutocompleteMenu
@@ -0,0 +1,124 @@
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { Box, Button, Text, config } from 'folds';
import { Editor, Transforms } from 'slate';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { copyToClipboard } from '../../utils/dom';
/** Two failures within this window show the notice instead of recovering again. */
const SILENT_RETRY_WINDOW_MS = 5000;
type CapturedError = { message: string; details: string };
/**
* Error boundary around a message composer.
*
* The composer's known failure mode is a transient render error right after an
* autocomplete insert (slate-react's DOM selection sync racing the model), and
* the draft is intact when it happens. So the first failure recovers silently:
* the selection is cleared and the composer remounts with the same content.
* Only a second failure within a few seconds shows the notice, which offers
* "Reload composer" and "Copy details" (the error and component stack), so the
* underlying bug can be diagnosed. Every failure is also logged to the console.
*/
export function ComposerErrorBoundary({
editor,
children,
}: {
editor: Editor;
children: ReactNode;
}) {
const lastSilentReset = useRef(0);
const captured = useRef<CapturedError | undefined>(undefined);
const clearSelection = () => {
try {
Transforms.deselect(editor);
} catch {
/* editor already in a safe state */
}
};
return (
<ErrorBoundary
onReset={clearSelection}
onError={(error, info) => {
const message = error instanceof Error ? error.message : String(error);
const details = [
`When: ${new Date().toISOString()}`,
`Error: ${error instanceof Error ? `${error.name}: ${message}` : message}`,
error instanceof Error && error.stack ? `Stack:\n${error.stack}` : '',
info.componentStack ? `Component stack:${info.componentStack}` : '',
`Browser: ${navigator.userAgent}`,
]
.filter(Boolean)
.join('\n');
captured.current = { message, details };
console.error('[composer] render error (draft kept):', error, info.componentStack);
}}
fallbackRender={(props) => (
<ComposerFallback
{...props}
captured={captured.current}
lastSilentReset={lastSilentReset}
/>
)}
>
{children}
</ErrorBoundary>
);
}
function ComposerFallback({
resetErrorBoundary,
captured,
lastSilentReset,
}: FallbackProps & {
captured?: CapturedError;
lastSilentReset: React.MutableRefObject<number>;
}) {
const silent = Date.now() - lastSilentReset.current > SILENT_RETRY_WINDOW_MS;
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!silent) return;
lastSilentReset.current = Date.now();
resetErrorBoundary();
}, [silent, resetErrorBoundary, lastSilentReset]);
if (silent) return null;
return (
<RoomInputPlaceholder
role="alert"
style={{ padding: config.space.S200 }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
<Text align="Center">
The message composer hit a snag. Your draft is safe — reload the composer to keep typing.
</Text>
<Box gap="200" justifyContent="Center" wrap="Wrap">
<Button size="300" variant="Secondary" fill="Soft" radii="300" onClick={resetErrorBoundary}>
<Text size="B300">Reload composer</Text>
</Button>
{captured && (
<Button
size="300"
variant="Secondary"
fill="None"
radii="300"
onClick={() => {
copyToClipboard(captured.details);
setCopied(true);
}}
>
<Text size="B300">{copied ? 'Copied' : 'Copy details'}</Text>
</Button>
)}
</Box>
</RoomInputPlaceholder>
);
}
+120 -5
View File
@@ -24,6 +24,8 @@ import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { useSwipeNav } from '../../hooks/useSwipeNav';
import { usePinchZoom } from '../../hooks/usePinchZoom';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -39,6 +41,7 @@ import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../utils/formatTimestamp';
import { canCopyImage, copyImageFromUrl } from '../../utils/copyImage';
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
@@ -104,6 +107,10 @@ export type LightboxItem = {
mimeType?: string;
msgtype: MsgType.Image | MsgType.Video;
body: string;
/** The file's name when the message also carries a caption (MSC2530). */
filename?: string;
/** Caption text: `body` when it differs from `filename`. */
caption?: string;
sender: string;
ts: number;
eventId: string;
@@ -125,12 +132,19 @@ export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
const filename = typeof c.filename === 'string' && c.filename ? c.filename : undefined;
const caption =
filename && typeof c.body === 'string' && c.body && c.body !== filename
? c.body
: undefined;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
filename,
caption,
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
@@ -147,6 +161,7 @@ function LightboxMedia({
onMouseDown,
onTouchStart,
onImageDoubleClick,
onUrl,
}: {
item: LightboxItem;
useAuthentication: boolean;
@@ -156,6 +171,7 @@ function LightboxMedia({
onMouseDown: React.MouseEventHandler<HTMLElement>;
onTouchStart: React.TouchEventHandler<HTMLElement>;
onImageDoubleClick: () => void;
onUrl: (url: string | undefined) => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(
@@ -165,6 +181,11 @@ function LightboxMedia({
useAuthentication,
item.mimeType,
);
const readyUrl = media.status === 'ok' ? media.url : undefined;
useEffect(() => {
onUrl(readyUrl);
return () => onUrl(undefined);
}, [readyUrl, onUrl]);
return (
<Box
@@ -207,7 +228,7 @@ function LightboxMedia({
) : (
<img
src={media.url}
alt={item.body}
alt={item.caption ?? item.body}
draggable={false}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
@@ -266,8 +287,37 @@ export function Lightbox({
useCallback(() => setIndex((i) => Math.max(0, i - 1)), []),
);
const dialogRef = useRef<HTMLDivElement>(null);
const onPinchStart = usePinchZoom(isImage, zoom, setZoom);
// [Gitea #164] 44 px prev/next targets on touch screens (32 px otherwise).
const coarsePointer = useMediaQuery('(pointer: coarse)');
const navSize = coarsePointer ? '500' : '400';
const onMediaTouchStart = useCallback<React.TouchEventHandler<HTMLElement>>(
(e) => {
swipe.onTouchStart(e);
onPinchStart(e);
},
[swipe, onPinchStart],
);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// [Gitea #164] Copy image. No "open in new tab": an E2EE image is a blob URL
// revoked when the viewer closes, and authenticated media 401s in a bare tab.
const [mediaUrl, setMediaUrl] = useState<string>();
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
useEffect(() => {
if (copyState === 'idle') return undefined;
const t = window.setTimeout(() => setCopyState('idle'), 2000);
return () => window.clearTimeout(t);
}, [copyState]);
useEffect(() => setCopyState('idle'), [index]);
const handleCopyImage = useCallback(() => {
if (!mediaUrl) return;
copyImageFromUrl(mediaUrl).then(
() => setCopyState('copied'),
() => setCopyState('failed'),
);
}, [mediaUrl]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
useEffect(() => {
setZoom(1);
@@ -343,7 +393,7 @@ export function Lightbox({
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
{item.filename || item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
@@ -385,6 +435,40 @@ export function Lightbox({
</IconButton>
</Box>
)}
{isImage && canCopyImage() && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>
{copyState === 'copied' && 'Copied'}
{copyState === 'failed' && 'Could not copy image'}
{copyState === 'idle' && 'Copy image'}
</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
ref={ref}
variant="Surface"
aria-label={copyState === 'copied' ? 'Image copied' : 'Copy image'}
onClick={handleCopyImage}
disabled={!mediaUrl}
>
<Icon
src={
(copyState === 'copied' && Icons.Check) ||
(copyState === 'failed' && Icons.Warning) ||
Icons.Photo
}
/>
</IconButton>
)}
</TooltipProvider>
)}
{item.mxcUrl && (
<TooltipProvider
position="Bottom"
@@ -399,7 +483,7 @@ export function Lightbox({
{(ref) => (
<span ref={ref}>
<FileDownloadButton
filename={mediaFilename(item.body, item.mimeType)}
filename={mediaFilename(item.filename ?? item.body, item.mimeType)}
url={item.mxcUrl}
mimeType={item.mimeType ?? 'application/octet-stream'}
encInfo={item.encInfo}
@@ -455,13 +539,16 @@ export function Lightbox({
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
onTouchStart={swipe.onTouchStart}
onTouchStart={onMediaTouchStart}
onTouchEnd={swipe.onTouchEnd}
style={{ overflow: 'hidden', padding: config.space.S400 }}
// Our own swipe/pinch/pan handle touch here; stop the browser from
// zooming or scrolling the page underneath instead.
style={{ overflow: 'hidden', padding: config.space.S400, touchAction: 'none' }}
>
{index > 0 && (
<IconButton
variant="Surface"
size={navSize}
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
@@ -485,11 +572,13 @@ export function Lightbox({
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onImageDoubleClick={toggleZoom}
onUrl={setMediaUrl}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
size={navSize}
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
@@ -498,6 +587,32 @@ export function Lightbox({
</IconButton>
)}
</Box>
{item.caption && (
// [Gitea #164] The full caption, readable — the header only has room
// for one truncated line.
<Box
shrink="No"
justifyContent="Center"
style={{
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
maxHeight: '25vh',
overflowY: 'auto',
}}
>
<Text
size="T300"
style={{
color: 'rgba(255,255,255,0.88)',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxWidth: '70ch',
textAlign: 'center',
}}
>
{item.caption}
</Text>
</Box>
)}
</div>
</FocusTrap>
</Overlay>
+2 -1
View File
@@ -21,6 +21,7 @@ import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { sendRoomEvent } from '../../utils/room';
interface PollCreatorProps {
roomId: string;
@@ -89,7 +90,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
);
// Pass the thread id explicitly (like the sticker path in RoomInput); the
// legacy 3-arg form always resolves to the main timeline.
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
await sendRoomEvent(mx, roomId, threadRootId ?? null, 'm.poll.start', {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+131 -16
View File
@@ -13,8 +13,9 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import { Transforms, Editor, Element as SlateElement, Node as SlateNode, Range } from 'slate';
import {
Button,
Box,
Dialog,
Icon,
@@ -58,6 +59,7 @@ import {
getBeginCommand,
trimCommand,
getMentions,
BlockType,
} from '../../components/editor';
import { EmojiBoardTab } from '../../components/emoji-board/types';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -99,6 +101,7 @@ import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { looksLikeCode } from '../../utils/looksLikeCode';
import { useAlive } from '../../hooks/useAlive';
import {
ComposerToolbarButtonKey,
@@ -112,7 +115,12 @@ import {
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import {
getMemberName,
getMentionContent,
sendRoomMessage,
trimReplyFromBody,
} from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import {
Command,
@@ -292,6 +300,48 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
// [Gitea #107] "Paste as code block?" offer for the top-level blocks a
// paste just filled. Dismissed by typing, by No, or after 8 s.
const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>();
useEffect(() => {
if (!codeOffer) return undefined;
const t = window.setTimeout(() => setCodeOffer(undefined), 8000);
return () => window.clearTimeout(t);
}, [codeOffer]);
const acceptCodeOffer = useCallback(() => {
if (!codeOffer) return;
const { start, end, lang } = codeOffer;
setCodeOffer(undefined);
if (end >= editor.children.length || start > end) return;
const blocks = editor.children.slice(start, end + 1);
// Only plain paragraphs (what a paste produces); anything else, leave it.
if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return;
// Rebuild from plain text: code lines hold text only, and a paste may
// have produced links/mentions.
const lines = blocks.map((b) => SlateNode.string(b));
Editor.withoutNormalizing(editor, () => {
for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] });
Transforms.insertNodes(
editor,
{
type: BlockType.CodeBlock,
lang,
children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })),
},
{ at: [start] },
);
if (start + 1 >= editor.children.length) {
Transforms.insertNodes(
editor,
{ type: BlockType.Paragraph, children: [{ text: '' }] },
{ at: [start + 1] },
);
}
});
Transforms.select(editor, Editor.start(editor, [start + 1]));
ReactEditor.focus(editor);
}, [codeOffer, editor]);
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
const showLocation = composerToolbarButtons?.showLocation ?? true;
@@ -324,7 +374,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// 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, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
@@ -332,7 +382,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
} as any);
});
},
(err) => {
setLocating(false);
@@ -363,19 +413,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(blob);
const uploadResult = await mx.uploadContent(encBlob);
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: uploadResult.content_uri },
} as any);
});
} else {
const uploadResult = await mx.uploadContent(blob, {
name: 'voice-message.ogg',
type: mimeType,
});
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
url: uploadResult.content_uri,
} as any);
});
}
},
[mx, room, roomId, threadRootId],
@@ -406,7 +456,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handlePaste = useCallback<React.ClipboardEventHandler>(
(evt) => {
handleFilePaste(evt);
if (evt.defaultPrevented || !stripTracking) return;
if (evt.defaultPrevented) return;
if (offerCodePaste && editor.selection) {
const pasted = evt.clipboardData?.getData('text/plain') ?? '';
const startBlock = Range.start(editor.selection).path[0];
const startNode = editor.children[startBlock];
const inCode =
SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock;
const guess =
!inCode && pasted
? looksLikeCode(pasted, evt.clipboardData?.getData('text/html'))
: undefined;
if (guess?.isCode) {
// Slate inserts after this handler returns; read where it ended.
window.setTimeout(() => {
const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock;
setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang });
}, 0);
}
}
if (!stripTracking) return;
const text = evt.clipboardData?.getData('text/plain');
if (!text) return;
const cleaned = stripTrackingParamsInText(text);
@@ -419,7 +488,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
dt.setData('text/plain', cleaned);
ReactEditor.insertData(editor, dt);
},
[handleFilePaste, stripTracking, editor],
[handleFilePaste, stripTracking, editor, offerCodePaste],
);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const { gifApiKey } = useClientConfig();
@@ -625,7 +694,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
contents.forEach((content) => sendRoomMessage(mx, roomId, threadRootId ?? null, content));
},
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
);
@@ -732,7 +801,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, threadRootId ?? null, content as any);
sendRoomMessage(mx, roomId, threadRootId ?? null, content);
resetEditor(editor);
resetEditorHistory(editor);
setCharCount(0);
@@ -848,6 +917,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
// Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer.
if (
evt.key.length === 1 ||
evt.key === 'Backspace' ||
evt.key === 'Delete' ||
evt.key === 'Enter'
) {
setCodeOffer(undefined);
}
if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
@@ -955,10 +1033,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const uploadRes = await mx.uploadContent(encBlob);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
});
} else {
const uploadRes = await mx.uploadContent(gifFile, {
type: 'image/gif',
@@ -967,10 +1045,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
});
}
} catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
@@ -1144,6 +1222,43 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
</Box>
)}
{codeOffer && (
<Box
role="group"
aria-label="Paste as code block"
alignItems="Center"
gap="200"
wrap="Wrap"
style={{
margin: `0 ${config.space.S300} ${config.space.S100}`,
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Icon size="100" src={Icons.BlockCode} style={{ flexShrink: 0 }} />
<Text size="T200" style={{ flexGrow: 1 }}>
That looks like code. Format it as a code block
{codeOffer.lang ? ` (${codeOffer.lang})` : ''}?
</Text>
<Button size="300" variant="Primary" radii="300" onClick={acceptCodeOffer}>
<Text size="B300">Format as code</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="None"
radii="300"
onClick={() => {
setCodeOffer(undefined);
ReactEditor.focus(editor);
}}
>
<Text size="B300">No thanks</Text>
</Button>
</Box>
)}
<ScheduledMessagesTray roomId={roomId} />
<CustomEditor
editableName={editableName}
+21 -6
View File
@@ -89,6 +89,7 @@ import {
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
sendRoomEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
@@ -107,7 +108,7 @@ import {
getIntersectionObserverEntry,
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { markAsRead } from '../../utils/notifications';
import { markAsRead, MarkAsReadOptions } from '../../utils/notifications';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
@@ -504,6 +505,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
// [Gitea #217] Reads from just viewing the timeline are passive: threads the
// user follows stay unread, and the open thread panel sends its own receipts.
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(room.roomId));
const passiveReadRef = useRef<MarkAsReadOptions>({ passive: true });
passiveReadRef.current = { passive: true, openThreadId: activeThreadId ?? undefined };
// Thread summary chips only mount for events that already carry thread data
// (perf: a chip subscribes room-level listeners, so mounting one per rendered
// message would exceed the SDK's emitter cap). This single room-level
@@ -706,7 +712,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt.
const _roomId = mEvt.getRoomId();
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
if (_roomId)
requestAnimationFrame(() =>
markAsRead(mx, _roomId, hideActivity, passiveReadRef.current),
);
}
if (!document.hasFocus() && !unreadInfo) {
@@ -828,13 +837,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
requestAnimationFrame(() =>
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
);
return;
}
const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
requestAnimationFrame(() =>
markAsRead(mx, room.roomId, hideActivity, passiveReadRef.current),
);
}
}, [mx, room, hideActivity]);
@@ -1160,9 +1173,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
sendRoomEvent(
mx,
room.roomId,
MessageEvent.Reaction as any,
null,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
);
},
+15 -38
View File
@@ -1,9 +1,8 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Box, Button, Text, config } from 'folds';
import { Box, 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';
@@ -15,6 +14,7 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { ComposerErrorBoundary } from './ComposerErrorBoundary';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { useSetting } from '../../state/hooks/settings';
@@ -51,7 +51,17 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
code === 'Space' ||
code === 'Enter' ||
code === 'NumLock' ||
code === 'ScrollLock'
code === 'ScrollLock' ||
// Not typing: Escape in particular dismisses popouts, and a focus-trap
// hands focus back to the opener on Escape. Treating it as "start typing"
// yanked focus into the composer instead (#187 DP9: closing the GIF picker
// with Esc left focus in the composer, not on the GIF button).
code === 'Escape' ||
code === 'CapsLock' ||
code === 'Insert' ||
code === 'ContextMenu' ||
code === 'PrintScreen' ||
code === 'Pause'
) {
return false;
}
@@ -152,40 +162,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
) : (
<>
{canMessage && (
<ErrorBoundary
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">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>
)}
>
<ComposerErrorBoundary editor={editor}>
<RoomInput
room={room}
editor={editor}
@@ -193,7 +170,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
</ErrorBoundary>
</ComposerErrorBoundary>
)}
{!canMessage && (
<RoomInputPlaceholder
+11 -2
View File
@@ -51,13 +51,22 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#following-seenby-dialog',
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300" style={modalStyle}>
<Modal
id="following-seenby-dialog"
role="dialog"
aria-modal="true"
aria-label="Seen by"
tabIndex={-1}
variant="Surface"
size="300"
style={modalStyle}
>
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
-3
View File
@@ -519,7 +519,6 @@ function CallButton() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
@@ -769,7 +768,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
@@ -940,7 +938,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
@@ -94,13 +94,21 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#jumptotime-dialog-1',
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="jumptotime-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="jumptotime-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -110,7 +118,9 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
size="500"
>
<Box grow="Yes">
<Text size="H4">Jump to Time</Text>
<Text id="jumptotime-dialog-1-title" size="H4">
Jump to Time
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -36,7 +36,12 @@ 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 {
getMemberAvatarMxc,
getMemberName,
sendRoomEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import {
recentForwardTargetsAtom,
@@ -349,24 +354,21 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
// content; unencrypted ones get the plaintext version, or fail outright if
// that couldn't be built — never fall back to sending the encrypted `file`.
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
if (fwdContent.file && !destEncrypted && !contentToSend) {
// Only undefined when a plaintext copy was needed and couldn't be built.
if (!contentToSend) {
return Promise.reject(new Error(plaintextContentError));
}
// 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, contentToSend);
// Explicit null thread = send to the main timeline.
const sendForward = () => sendRoomEvent(mx, id, null, mEvent.getType(), contentToSend);
// 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);
})
? mx.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody }).then(() => {
commentSentRef.current.add(id);
})
: Promise.resolve();
return step.then(sendForward);
};
@@ -423,6 +425,11 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
}}
>
<Modal
id="forwardmessagedialog-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="forwardmessagedialog-dialog-1-title"
tabIndex={-1}
size="400"
style={{
maxHeight: '480px',
@@ -438,7 +445,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
>
<Box grow="Yes">
<Text as="h2" size="H4" truncate>
<Text id="forwardmessagedialog-dialog-1-title" as="h2" size="H4" truncate>
Forward message
</Text>
</Box>
+81 -22
View File
@@ -223,14 +223,25 @@ export const MessageAllReactionItem = as<
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// #185 P5: move focus into the viewer so it is announced (it opened
// silently with focus left on the reaction behind it).
initialFocus: '[data-reaction-viewer]',
returnFocusOnDeactivate: false,
onDeactivate: () => handleClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal
variant="Surface"
size="300"
role="dialog"
aria-modal="true"
aria-label="Reactions"
data-reaction-viewer=""
tabIndex={-1}
style={{ outline: 'none' }}
>
<ReactionViewer
room={room}
relations={relations}
@@ -279,13 +290,22 @@ export const MessageReadReceiptItem = as<
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#message-seenby-dialog',
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300" style={modalStyle}>
<Modal
id="message-seenby-dialog"
role="dialog"
aria-modal="true"
aria-label="Seen by"
tabIndex={-1}
variant="Surface"
size="300"
style={modalStyle}
>
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal>
</FocusTrap>
@@ -357,13 +377,21 @@ export const MessageSourceCodeItem = as<
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#message-source-dialog',
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="500">
<Modal
id="message-source-dialog"
role="dialog"
aria-modal="true"
aria-label="View source"
tabIndex={-1}
variant="Surface"
size="500"
>
<TextViewer
name="Source Code"
langName="json"
@@ -628,13 +656,20 @@ export const MessageDeleteItem = as<
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#message-dialog-1',
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog
id="message-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="message-dialog-1-title"
tabIndex={-1}
variant="Surface"
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -644,7 +679,9 @@ export const MessageDeleteItem = as<
size="500"
>
<Box grow="Yes">
<Text size="H4">Delete Message</Text>
<Text id="message-dialog-1-title" size="H4">
Delete Message
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
@@ -758,13 +795,21 @@ export const MessageReportItem = as<
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#message-dialog-2',
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Dialog
id="message-dialog-2"
role="dialog"
aria-modal="true"
aria-labelledby="message-dialog-2-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -774,7 +819,9 @@ export const MessageReportItem = as<
size="500"
>
<Box grow="Yes">
<Text size="H4">Report Message</Text>
<Text id="message-dialog-2-title" size="H4">
Report Message
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
@@ -1129,6 +1176,18 @@ export const Message = React.memo(
};
const isThreadedMessage = mEvent.threadRootId !== undefined;
// [Gitea #165] A root that already has a thread: the same action opens it,
// so say "View Thread (N replies)" rather than "Reply in Thread".
// The SDK gives a thread root its own id as threadRootId, so without this
// a root (the one message that HAS a thread) got no thread action at all.
const isThreadRoot = !!mEvent.getId() && mEvent.threadRootId === mEvent.getId();
const showThreadAction = !isThreadedMessage || isThreadRoot;
const existingThread = mEvent.getId() ? room.getThread(mEvent.getId()!) : null;
const threadReplies = existingThread?.length ?? 0;
const threadActionLabel =
threadReplies > 0
? `View Thread (${threadReplies} ${threadReplies === 1 ? 'reply' : 'replies'})`
: 'Reply in Thread';
// The full action menu, shared by the desktop PopOut and the touch
// bottom sheet (#166).
@@ -1226,10 +1285,12 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
{showThreadAction && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
after={
<Icon src={threadReplies > 0 ? Icons.Thread : Icons.ThreadPlus} size="100" />
}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
@@ -1238,7 +1299,7 @@ export const Message = React.memo(
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply in Thread
{threadActionLabel}
</Text>
</MenuItem>
)}
@@ -1283,8 +1344,7 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.Send} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).resendEvent(mEvent, room);
mx.resendEvent(mEvent, room);
closeMenu();
}}
>
@@ -1297,8 +1357,7 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.Cross} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).cancelPendingEvent(mEvent);
mx.cancelPendingEvent(mEvent);
closeMenu();
}}
>
@@ -1420,16 +1479,16 @@ export const Message = React.memo(
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{!isThreadedMessage && (
{showThreadAction && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Reply in thread"
aria-label={threadReplies > 0 ? 'View thread' : 'Reply in thread'}
>
<Icon src={Icons.ThreadPlus} size="100" />
<Icon src={threadReplies > 0 ? Icons.Thread : Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEventOrCaption(mx, mEvent) && onEditId && (
@@ -21,7 +21,15 @@ import {
} from 'folds';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import {
EventType,
IContent,
IMentions,
MatrixEvent,
MsgType,
RelationType,
Room,
} from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import {
AUTOCOMPLETE_PREFIXES,
@@ -55,6 +63,7 @@ import {
getEditedEvent,
getMemberName,
getMentionContent,
sendRoomEvent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
@@ -192,8 +201,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
rel_type: RelationType.Replace,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
return sendRoomEvent(mx, roomId, null, EventType.RoomMessage, content);
}
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
@@ -241,8 +249,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
return sendRoomEvent(mx, roomId, null, EventType.RoomMessage, content);
}, [
stripTracking,
mx,
+15 -3
View File
@@ -23,7 +23,7 @@ import { Room } from 'matrix-js-sdk';
import { type Relations } from 'matrix-js-sdk/lib/models/relations';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { factoryEventSentBy } from '../../../utils/matrix';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { Reaction, ReactionTooltipMsg } from '../../../components/message';
import { useRelations } from '../../../hooks/useRelations';
import * as css from './styles.css';
@@ -138,6 +138,7 @@ export const Reactions = as<'div', ReactionsProps>(
key={key}
mx={mx}
reaction={key}
shortcode={rEvents.find(eventWithShortcode)?.getContent().shortcode}
count={events.size}
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
onContextMenu={handleViewReaction}
@@ -211,13 +212,24 @@ export const Reactions = as<'div', ReactionsProps>(
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// #185 P5: move focus into the viewer so it is announced (it opened
// silently with focus left on the reaction behind it).
initialFocus: '[data-reaction-viewer]',
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal
variant="Surface"
size="300"
role="dialog"
aria-modal="true"
aria-label="Reactions"
data-reaction-viewer=""
tabIndex={-1}
style={{ outline: 'none' }}
>
<ReactionViewer
room={room}
initialKey={typeof viewer === 'string' ? viewer : undefined}
@@ -107,6 +107,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
key={key}
mx={mx}
reaction={key}
shortcode={Array.from(evts).find(eventWithShortcode)?.getContent().shortcode}
count={evts.size}
role="option"
aria-selected={key === selectedKey}
+21 -9
View File
@@ -23,11 +23,14 @@ import { useKeyDown } from '../../../hooks/useKeyDown';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { RoomInput } from '../RoomInput';
import { ComposerErrorBoundary } from '../ComposerErrorBoundary';
import {
getThreadNotificationModeIcon,
ThreadNotificationModeSwitcher,
} from '../../../components/ThreadNotificationModeSwitcher';
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { getMemberName } from '../../../utils/room';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadPanelHeaderProps = {
@@ -37,6 +40,12 @@ type ThreadPanelHeaderProps = {
};
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const mode = useThreadNotificationMode(room.roomId, threadId);
const { format } = useTimestampFormatter();
// [Gitea #165] Who started the thread and when — the first thing you want
// when arriving from the Threads list.
const root = room.findEventById(threadId) ?? room.getThread(threadId)?.rootEvent;
const rootSender = root?.getSender();
const startedBy = rootSender ? getMemberName(room, rootSender) : undefined;
return (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
@@ -47,6 +56,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="100">
@@ -195,15 +205,17 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
<ThreadTimeline room={room} thread={thread} editor={editor} />
</Box>
<Box className={css.ThreadPanelInput} shrink="No" direction="Column">
<RoomInput
room={room}
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
editableName="ThreadInput"
fileDropContainerRef={fileDropContainerRef}
compactLayout
/>
<ComposerErrorBoundary editor={editor}>
<RoomInput
room={room}
roomId={room.roomId}
threadRootId={threadId}
editor={editor}
editableName="ThreadInput"
fileDropContainerRef={fileDropContainerRef}
compactLayout
/>
</ComposerErrorBoundary>
</Box>
</>
)}
+14 -4
View File
@@ -19,6 +19,18 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
const { count, latestTs } = summary;
const latestStr = latestTs !== undefined ? format(latestTs) : undefined;
const visibleText = `${count === 1 ? '1 reply' : `${count} replies`}${latestStr ? ` · ${latestStr}` : ''}`;
// #179: the chip was named only by its visible text, so a screen reader never
// heard that it opens the thread, nor about the unread dot / muted bell. Keep
// the visible text first (WCAG 2.5.3, label in name) and add the rest.
const ariaLabel = [
visibleText,
'view thread',
unread > 0 ? 'unread replies' : undefined,
mode === ThreadNotificationMode.Mute ? 'muted' : undefined,
]
.filter(Boolean)
.join(', ');
return (
<Box style={{ marginTop: config.space.S200 }}>
@@ -26,6 +38,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
variant="SurfaceVariant"
radii="300"
className={MobileTouchTarget}
aria-label={ariaLabel}
before={<Icon size="50" src={Icons.Thread} />}
after={
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
@@ -35,10 +48,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
if (threadId) onOpen(threadId);
}}
>
<Text size="T200">
{count === 1 ? '1 reply' : `${count} replies`}
{latestStr ? ` · ${latestStr}` : ''}
</Text>
<Text size="T200">{visibleText}</Text>
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
</Chip>
</Box>
+48 -14
View File
@@ -67,12 +67,13 @@ import {
getMemberName,
getReactionContent,
reactionOrEditEvent,
sendRoomEvent,
} from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { RoomMediaLightbox } from '../RoomMediaLightbox';
import { Lightbox, toLightboxItems } from '../MediaGallery';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
@@ -307,7 +308,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Thread images open the room's shared media lightbox too.
// [Gitea #219] Thread images open the shared media lightbox too.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
const linkifyOpts = useMemo<LinkifyOpts>(
@@ -576,12 +577,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
sendRoomEvent(
mx,
room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply.
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
);
},
@@ -626,11 +627,9 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
),
);
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return;
// best-effort: scroll to referenced event if it is inside the loaded thread window
// best-effort: scroll to an event if it is inside the loaded thread window
const scrollToThreadEvent = useCallback(
(targetId: string) => {
let absIndex = -1;
let acc = 0;
timeline.linkedTimelines.some((tl) => {
@@ -653,6 +652,39 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
[timeline.linkedTimelines, scrollToItem],
);
const handleOpenReply: MouseEventHandler = useCallback(
(evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id');
if (targetId) scrollToThreadEvent(targetId);
},
[scrollToThreadEvent],
);
// [Gitea #164] The viewer steps through this thread's media (root + loaded
// replies). Thread replies never reach the room's media timeline, so the
// room lightbox could only ever show a thread image as "1 / 1".
const lightboxItems = useMemo(() => {
if (!lightboxEventId) return [];
const seen = new Set<string>();
const events = [thread.rootEvent, ...timeline.linkedTimelines.flatMap((tl) => tl.getEvents())]
.filter((ev): ev is MatrixEvent => !!ev)
.filter((ev) => {
const id = ev.getId();
if (!id || seen.has(id) || ev.isRedacted()) return false;
seen.add(id);
return true;
});
return toLightboxItems(room, events);
}, [lightboxEventId, thread.rootEvent, timeline.linkedTimelines, room]);
const lightboxIndex = lightboxItems.findIndex((it) => it.eventId === lightboxEventId);
const handleLightboxJump = useCallback(
(id: string) => {
setLightboxEventId(undefined);
scrollToThreadEvent(id);
},
[scrollToThreadEvent],
);
const renderMessageContent = useCallback(
(mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => {
// Evaluated lazily so EncryptedContent can re-run it (re-reading getType())
@@ -1050,11 +1082,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
{lightboxEventId && lightboxIndex !== -1 && (
<Lightbox
items={lightboxItems}
initialIndex={lightboxIndex}
useAuthentication={useAuthentication}
onClose={() => setLightboxEventId(undefined)}
onJump={handleLightboxJump}
/>
)}
</Box>
@@ -14,6 +14,7 @@ import {
Scroll,
Text,
config,
toRem,
} from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -45,6 +46,8 @@ import {
import { useRoomThreads } from '../../../hooks/useRoomThreads';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
@@ -118,8 +121,20 @@ type ThreadRowProps = {
highlight: number;
participants: string[];
onOpen: (threadId: string) => void;
onJump: (threadId: string) => void;
/** Phone layout: a bigger touch target for the jump button. */
touch?: boolean;
};
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
function ThreadRow({
room,
thread,
unread,
highlight,
participants,
onOpen,
onJump,
touch,
}: ThreadRowProps) {
const { prefs } = useTimestampFormatter();
const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? '';
@@ -136,64 +151,80 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
}`;
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',
}}
<Box direction="Column" style={{ position: 'relative' }}>
<Box
as="button"
direction="Column"
gap="100"
className={css.ThreadRow}
onClick={() => onOpen(thread.id)}
aria-label={ariaLabel}
>
{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' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</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>
{/* Leaves room for the "Go to message" button laid over this corner. */}
<Box alignItems="Center" gap="200" style={{ paddingRight: toRem(touch ? 36 : 28) }}>
<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' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</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>
{/* [Gitea #165] Jump to the thread's first message in the room. A sibling
of the row button, not nested in it (no buttons inside buttons). */}
<IconButton
size={touch ? '400' : '300'}
radii="300"
variant="SurfaceVariant"
aria-label={`Go to the message that started ${rootName}'s thread`}
title="Go to message"
onClick={() => onJump(thread.id)}
style={{ position: 'absolute', top: toRem(6), right: toRem(6) }}
>
<Icon size="100" src={Icons.ArrowGoRight} />
</IconButton>
</Box>
);
}
@@ -204,6 +235,16 @@ export type ThreadsListPanelProps = {
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const handleJump = useCallback(
(threadId: string) => {
navigateRoom(room.roomId, threadId);
// On a phone the list covers the timeline; get out of the way.
if (screenSize === ScreenSize.Mobile) onClose();
},
[navigateRoom, room.roomId, screenSize, onClose],
);
const threads = useRoomThreads(room);
const threadNotifications = useAtomValue(threadNotificationsAtom);
const [storedFilter, setFilter] = useAtom(threadFilterAtom);
@@ -403,6 +444,8 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
highlight={highlightById.get(snap.id) ?? 0}
participants={participantsById.get(snap.id) ?? []}
onOpen={onOpenThread}
onJump={handleJump}
touch={screenSize === ScreenSize.Mobile}
/>
</VirtualTile>
);
@@ -1,15 +1,190 @@
import { type Capability, WidgetDriver } from 'matrix-widget-api';
import { filterWidgetCapabilities } from './widgetUtils';
import {
type Capability,
type IReadEventRelationsResult,
type IRoomEvent,
type ISendEventDetails,
WidgetDriver,
} from 'matrix-widget-api';
import {
Direction,
EventType,
type IContent,
type MatrixClient,
type MatrixEvent,
type StateEvents,
type TimelineEvents,
} from 'matrix-js-sdk';
import {
PROTECTED_STATE_TYPES,
type WidgetPermissionRequest,
classifyWidgetCapabilities,
loadWidgetConsent,
saveWidgetConsent,
} from './widgetUtils';
// A minimal, conservative WidgetDriver for general room widgets. It only narrows
// the capabilities a widget may hold (to a benign display-only subset — see
// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice
// / uploads …) are inherited from the base WidgetDriver and are never reached,
// because the capabilities that would gate them are denied here. A richer,
// consent-prompt-driven driver is a follow-up.
/** Shows the consent prompt; resolves with the capabilities the user allowed. */
export type AskWidgetPermissions = (
requests: WidgetPermissionRequest[],
) => Promise<{ allowed: Set<Capability>; remember: boolean }>;
type WidgetIdentity = { id: string; url: string };
/**
* WidgetDriver for general room widgets. Display-only capabilities are granted
* silently. Reading or sending events/state in the widget's own room needs the
* user's OK through `ask` (remembered per viewer when they choose). Everything
* else is denied — see classifyWidgetCapabilities. The data methods below are
* only reached for capabilities the user granted (ClientWidgetApi checks), and
* additionally refuse any room other than the widget's own.
*/
export class GeneralWidgetDriver extends WidgetDriver {
// eslint-disable-next-line class-methods-use-this
public constructor(
private readonly mx: MatrixClient,
private readonly roomId: string,
private readonly widget: WidgetIdentity,
private readonly ask: AskWidgetPermissions,
) {
super();
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
return filterWidgetCapabilities(requested);
const { auto, ask } = classifyWidgetCapabilities(requested);
const granted = new Set(auto);
if (ask.length === 0) return granted;
const stored = loadWidgetConsent(this.roomId, this.widget.id, this.widget.url);
const remembered = new Set([...(stored?.allowed ?? []), ...(stored?.denied ?? [])]);
ask.forEach((r) => {
if (stored?.allowed.includes(r.capability)) granted.add(r.capability);
});
const unanswered = ask.filter((r) => !remembered.has(r.capability));
if (unanswered.length === 0) return granted;
const { allowed, remember } = await this.ask(unanswered);
unanswered.forEach((r) => {
if (allowed.has(r.capability)) granted.add(r.capability);
});
if (remember) {
saveWidgetConsent(this.roomId, this.widget.id, {
url: this.widget.url,
allowed: [
...(stored?.allowed ?? []),
...unanswered.filter((r) => allowed.has(r.capability)).map((r) => r.capability),
],
denied: [
...(stored?.denied ?? []),
...unanswered.filter((r) => !allowed.has(r.capability)).map((r) => r.capability),
],
});
}
return granted;
}
private assertOwnRoom(roomId: string | null | undefined): string {
const target = roomId || this.roomId;
if (target !== this.roomId) throw new Error('Widgets can only access their own room');
return target;
}
public async sendEvent(
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null,
): Promise<ISendEventDetails> {
const roomId = this.assertOwnRoom(targetRoomId);
let r: { event_id: string };
if (typeof stateKey === 'string') {
if (PROTECTED_STATE_TYPES.has(eventType)) {
throw new Error(`Widgets may not change ${eventType}`);
}
r = await this.mx.sendStateEvent(
roomId,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey,
);
} else if (eventType === EventType.RoomRedaction) {
r = await this.mx.redactEvent(roomId, content.redacts);
} else {
r = await this.mx.sendEvent(
roomId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents],
);
}
return { roomId, eventId: r.event_id };
}
public async readRoomTimeline(
roomId: string,
eventType: string,
msgtype: string | undefined,
stateKey: string | undefined,
limit: number,
since: string | undefined,
): Promise<IRoomEvent[]> {
const room = this.mx.getRoom(this.assertOwnRoom(roomId));
if (!room) return [];
const max = limit > 0 ? limit : Number.MAX_SAFE_INTEGER;
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents();
for (let i = events.length - 1; i >= 0 && results.length < max; i -= 1) {
const ev = events[i];
if (since !== undefined && ev.getId() === since) break;
if (
ev.getType() === eventType &&
!ev.isState() &&
(eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) &&
(ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey)
) {
results.push(ev);
}
}
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
}
public async readRoomState(
roomId: string,
eventType: string,
stateKey: string | undefined,
): Promise<IRoomEvent[]> {
const room = this.mx.getRoom(this.assertOwnRoom(roomId));
const state = room?.getLiveTimeline().getState(Direction.Forward);
if (!state) return [];
if (stateKey === undefined) {
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
}
const ev = state.getStateEvents(eventType, stateKey);
return ev ? [ev.getEffectiveEvent() as IRoomEvent] : [];
}
public async readEventRelations(
eventId: string,
roomId?: string,
relationType?: string,
eventType?: string,
from?: string,
to?: string,
limit?: number,
direction?: 'f' | 'b',
): Promise<IReadEventRelationsResult> {
const target = this.assertOwnRoom(roomId);
const { events, nextBatch, prevBatch } = await this.mx.relations(
target,
eventId,
relationType ?? null,
eventType ?? null,
{ from, to, limit, dir: direction as Direction },
);
return {
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
nextBatch: nextBatch ?? undefined,
prevBatch: prevBatch ?? undefined,
};
}
public getKnownRooms(): string[] {
return [this.roomId];
}
}
@@ -1,10 +1,25 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
import {
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { isWidgetUrlSafe } from './widgetUtils';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
type RoomWidgetViewProps = {
room: Room;
@@ -12,7 +27,9 @@ type RoomWidgetViewProps = {
};
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
// that wait on the client handshake load), with a conservative capability driver.
// that wait on the client handshake load). Display-only access is granted
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
// and, once granted, the room's live events are fed to the widget.
// Re-mounts only when the widget id or its (template) URL changes — not on every
// unrelated room-state update — so viewing a widget doesn't reload constantly.
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
@@ -21,6 +38,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const widgetRef = useRef(widget);
widgetRef.current = widget;
const [blocked, setBlocked] = useState(false);
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
useEffect(() => {
const container = containerRef.current;
@@ -53,10 +71,60 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
iframe.style.border = 'none';
container.append(iframe);
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: current.id, url: current.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
setPendingAsk(undefined);
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
setPendingAsk({ requests, origin, resolve: done });
}),
);
const clientApi = new ClientWidgetApi(current, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, current.origin);
clientApi.setViewedRoomId(room.roomId);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
cancelAsk?.();
clientApi.stop();
iframe.remove();
};
@@ -73,5 +141,22 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
);
}
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
const addedBy = widget.creatorUserId
? (room.getMember(widget.creatorUserId)?.rawDisplayName ?? widget.creatorUserId)
: undefined;
return (
<>
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{pendingAsk && (
<WidgetPermissionPrompt
widgetName={widget.name || 'Widget'}
origin={pendingAsk.origin}
addedBy={addedBy}
requests={pendingAsk.requests}
onDone={pendingAsk.resolve}
/>
)}
</>
);
}
@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Checkbox,
Dialog,
Header,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
color,
config,
} from 'folds';
import type { Capability } from 'matrix-widget-api';
import type { WidgetPermissionRequest } from './widgetUtils';
import { useModalStyle } from '../../../hooks/useModalStyle';
type WidgetPermissionPromptProps = {
widgetName: string;
/** Hostname the widget is served from — who actually gets the access. */
origin: string;
/** Who added the widget to the room, if known. */
addedBy?: string;
requests: WidgetPermissionRequest[];
onDone: (allowed: Set<Capability>, remember: boolean) => void;
};
/**
* Asks before a room widget may read or send events in this room as you.
* Reading is pre-ticked, sending is not; "Deny" (or Escape / clicking away)
* grants nothing beyond display-only access.
*/
export function WidgetPermissionPrompt({
widgetName,
origin,
addedBy,
requests,
onDone,
}: WidgetPermissionPromptProps) {
const modalStyle = useModalStyle(460);
const [checked, setChecked] = useState<Set<Capability>>(
() => new Set(requests.filter((r) => !r.sends).map((r) => r.capability)),
);
const [remember, setRemember] = useState(true);
const toggle = (cap: Capability, on: boolean) =>
setChecked((prev) => {
const next = new Set(prev);
if (on) next.add(cap);
else next.delete(cap);
return next;
});
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#widget-perm-deny',
onDeactivate: () => onDone(new Set(), false),
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="widget-perm-title"
aria-describedby="widget-perm-body"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Text as="h2" size="H4" id="widget-perm-title" truncate>
Allow “{widgetName}” to access this room?
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="widget-perm-body">
This widget is run by <b>{origin}</b>
{addedBy ? `, added to the room by ${addedBy}` : ''}. It is asking to act for you in
this room. Only allow what you trust {origin} with.
</Text>
<Box as="ul" direction="Column" gap="200" style={{ margin: 0, padding: 0 }}>
{requests.map((r) => (
<Box
as="li"
key={r.capability}
alignItems="Center"
gap="200"
style={{ listStyle: 'none' }}
>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
size="300"
variant={r.sends ? 'Warning' : 'Primary'}
checked={checked.has(r.capability)}
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
toggle(r.capability, e.currentTarget.checked)
}
/>
<Text size="T300">
{r.label}
{r.sends && (
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
{' '}
· as you
</Text>
)}
</Text>
</Box>
</Box>
))}
</Box>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
size="300"
variant="Secondary"
checked={remember}
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
setRemember(e.currentTarget.checked)
}
/>
<Text size="T300">Remember my choice for this widget on this device</Text>
</Box>
<Box gap="200" justifyContent="End">
<Button
id="widget-perm-deny"
variant="Secondary"
fill="Soft"
onClick={() => onDone(new Set(), remember)}
>
<Text size="B400">Deny</Text>
</Button>
<Button variant="Primary" onClick={() => onDone(new Set(checked), remember)}>
<Text size="B400">
{checked.size === 0 ? 'Continue without access' : 'Allow selected'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -6,6 +6,8 @@ import {
isWidgetUrlSafe,
filterWidgetCapabilities,
generateWidgetId,
classifyWidgetCapabilities,
loadWidgetConsent,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
@@ -47,3 +49,60 @@ test('generateWidgetId is prefixed and unique across calls', () => {
assert.match(a, /^lotus_/);
assert.notEqual(a, b);
});
test('classifyWidgetCapabilities: display caps are automatic, event caps are asked', () => {
const { auto, ask } = classifyWidgetCapabilities([
MatrixCapabilities.AlwaysOnScreen,
'org.matrix.msc2762.receive.event:m.room.message#m.text',
'org.matrix.msc2762.send.event:m.reaction',
'org.matrix.msc2762.receive.state_event:m.room.topic',
]);
assert.deepEqual([...auto], [MatrixCapabilities.AlwaysOnScreen]);
assert.deepEqual(
ask.map((r) => [r.label, r.sends]),
[
['See messages of type “m.text” in this room', false],
['Send reactions in this room', true],
['See the room topic in this room', false],
],
);
});
test('classifyWidgetCapabilities never offers protected state, other rooms, to-device or account data', () => {
const { auto, ask } = classifyWidgetCapabilities([
'org.matrix.msc2762.send.state_event:m.room.power_levels',
'org.matrix.msc2762.send.state_event:m.room.join_rules',
'org.matrix.msc2762.send.state_event:m.room.encryption',
'org.matrix.msc2762.send.state_event:m.room.member#@a:x',
'org.matrix.msc2762.timeline:*',
'org.matrix.msc2762.timeline:!other:x',
'org.matrix.msc3819.send.to_device:m.custom',
'org.matrix.msc3819.receive.to_device:m.custom',
'org.matrix.msc4157.send.delayed_event',
'org.matrix.msc4039.upload_file',
'org.matrix.msc3973.user_directory_search',
'org.matrix.msc2762.receive.room_account_data:m.fully_read',
]);
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
test('classifyWidgetCapabilities: reading protected state is fine, changing unprotected state is asked', () => {
const { ask } = classifyWidgetCapabilities([
'org.matrix.msc2762.receive.state_event:m.room.power_levels',
'org.matrix.msc2762.send.state_event:m.room.topic',
'org.matrix.msc2762.send.state_event:com.example.board#main',
]);
assert.deepEqual(
ask.map((r) => r.label),
[
'See room permissions in this room',
'Change the room topic',
'Send “com.example.board” state (key “main”) in this room',
],
);
});
test('loadWidgetConsent tolerates missing storage', () => {
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
});
+131 -5
View File
@@ -1,9 +1,16 @@
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
import {
Capability,
EventDirection,
EventKind,
MatrixCapabilities,
WidgetEventCapability,
} from 'matrix-widget-api';
// Conservative v1 capability policy: approve only benign display capabilities.
// Everything else (room-event send/receive, to-device, uploads, user-directory,
// delayed events, TURN servers) is denied — a random widget must not be able to
// act as the user or read room data without an explicit consent flow (follow-up).
// Capability policy. Benign display capabilities are granted silently. Reading
// or sending events/state in the widget's OWN room can be granted by the user
// through the consent prompt (see classifyWidgetCapabilities). Everything else
// (other rooms' timelines, to-device, account data, uploads, user directory,
// delayed events, TURN servers) is always denied.
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
MatrixCapabilities.AlwaysOnScreen,
MatrixCapabilities.RequiresClient,
@@ -43,3 +50,122 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
export const generateWidgetId = (): string =>
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
// State a widget may never write with the user's authority, even if approved:
// these change who can do what in the room, or its encryption/identity.
export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
'm.room.create',
'm.room.power_levels',
'm.room.join_rules',
'm.room.history_visibility',
'm.room.guest_access',
'm.room.encryption',
'm.room.member',
'm.room.server_acl',
'm.room.tombstone',
'm.room.canonical_alias',
'm.room.third_party_invite',
'im.vector.modular.widgets',
'm.widget',
]);
export type WidgetPermissionRequest = {
capability: Capability;
/** Plain-language description, e.g. "Send messages". */
label: string;
/** Sending as you (vs only reading). Shown with a warning tone. */
sends: boolean;
};
export type ClassifiedCapabilities = {
/** Granted without asking. */
auto: Set<Capability>;
/** Needs the user's OK. */
ask: WidgetPermissionRequest[];
};
const FRIENDLY_EVENT_NAMES: Record<string, string> = {
'm.room.message': 'messages',
'm.reaction': 'reactions',
'm.sticker': 'stickers',
'm.room.redaction': 'message deletions',
'm.room.name': 'the room name',
'm.room.topic': 'the room topic',
'm.room.avatar': 'the room avatar',
'm.room.pinned_events': 'pinned messages',
'm.room.member': 'the member list',
'm.room.power_levels': 'room permissions',
};
const describeEventCapability = (cap: WidgetEventCapability): string => {
const verb = cap.direction === EventDirection.Send ? 'Send' : 'See';
const friendly = FRIENDLY_EVENT_NAMES[cap.eventType];
const what =
friendly ?? `“${cap.eventType}” ${cap.kind === EventKind.State ? 'state' : 'events'}`;
let detail = '';
if (cap.keyStr) {
detail = cap.kind === EventKind.State ? ` (key “${cap.keyStr}”)` : ` of type “${cap.keyStr}”`;
}
if (cap.kind === EventKind.State && cap.direction === EventDirection.Send && friendly) {
return `Change ${friendly}${detail}`;
}
return `${verb} ${what}${detail} in this room`;
};
/**
* Split a widget's requested capabilities into those granted silently and
* those the user may approve. Anything in neither list is denied outright.
*/
export const classifyWidgetCapabilities = (
requested: Iterable<Capability>,
): ClassifiedCapabilities => {
const list = [...requested];
const auto = new Set(list.filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
const ask: WidgetPermissionRequest[] = [];
WidgetEventCapability.findEventCapabilities(list).forEach((cap) => {
if (cap.kind !== EventKind.Event && cap.kind !== EventKind.State) return;
const sends = cap.direction === EventDirection.Send;
if (sends && cap.kind === EventKind.State && PROTECTED_STATE_TYPES.has(cap.eventType)) return;
ask.push({ capability: cap.raw, label: describeEventCapability(cap), sends });
});
return { auto, ask };
};
/**
* Remembered approvals, per viewer (localStorage), keyed by room + widget id and
* tied to the widget's URL: if the URL changes, the widget is asked again.
*/
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`lotus.widgetConsent.${roomId}.${widgetId}`;
export const loadWidgetConsent = (
roomId: string,
widgetId: string,
url: string,
): StoredWidgetConsent | undefined => {
try {
const raw = localStorage.getItem(widgetConsentKey(roomId, widgetId));
if (!raw) return undefined;
const parsed = JSON.parse(raw) as StoredWidgetConsent;
if (parsed.url !== url || !Array.isArray(parsed.allowed) || !Array.isArray(parsed.denied)) {
return undefined;
}
return parsed;
} catch {
return undefined;
}
};
export const saveWidgetConsent = (
roomId: string,
widgetId: string,
consent: StoredWidgetConsent,
) => {
try {
localStorage.setItem(widgetConsentKey(roomId, widgetId), JSON.stringify(consent));
} catch {
// Storage unavailable: the widget just asks again next time.
}
};
+26 -5
View File
@@ -57,6 +57,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { noteLocalStatusWrite } from '../../../utils/ownStatusSync';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
import { useStatusPresets } from '../../../hooks/useStatusPresets';
@@ -180,13 +181,22 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#profile-dialog-1',
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<Modal
id="profile-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="profile-dialog-1-title"
tabIndex={-1}
className={ModalWide}
variant="Surface"
size="500"
>
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
@@ -202,13 +212,20 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: '#profile-dialog-2',
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog
id="profile-dialog-2"
role="dialog"
aria-modal="true"
aria-labelledby="profile-dialog-1-title"
tabIndex={-1}
variant="Surface"
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -218,7 +235,9 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
<Text id="profile-dialog-1-title" size="H4">
Remove Avatar
</Text>
</Box>
<IconButton
size="300"
@@ -472,6 +491,7 @@ function ProfileStatus() {
const msg = rawMsg.trim();
// Guard against a stale presence echo reverting this value (see the sync effect).
pendingAppliedRef.current = { value: msg, ts: Date.now() };
noteLocalStatusWrite();
saveStatus(msg).catch(() => undefined);
if (msg) {
@@ -525,6 +545,7 @@ function ProfileStatus() {
const handleClear = () => {
statusDirtyRef.current = false;
pendingAppliedRef.current = { value: '', ts: Date.now() };
noteLocalStatusWrite();
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
@@ -1,10 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text, Spinner, color } from 'folds';
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
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, decorationUrl } from '../../lotus/avatarDecorations';
import {
ALL_DECORATIONS,
DECORATION_CATEGORIES,
decorationThumbUrl,
decorationUrl,
} from '../../lotus/avatarDecorations';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -21,6 +26,12 @@ function DecorationPreviewCell({
selected: boolean;
onSelect: (slug: string) => void;
}) {
// The full decorations are ~1 MB animated PNGs. Show the small static
// thumbnail and only pull the animated file while the cell is hovered,
// focused or selected. A missing thumbnail falls back to the full file.
const [active, setActive] = useState(false);
const [thumbFailed, setThumbFailed] = useState(false);
const animate = active || selected || thumbFailed;
return (
<button
type="button"
@@ -28,6 +39,10 @@ function DecorationPreviewCell({
aria-label={name}
aria-pressed={selected}
onClick={() => onSelect(slug)}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
style={{
position: 'relative',
width: CELL_SIZE,
@@ -44,10 +59,11 @@ function DecorationPreviewCell({
}}
>
<img
src={decorationUrl(slug)}
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
alt={name}
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
style={{
position: 'absolute',
inset: 0,
@@ -61,6 +77,12 @@ function DecorationPreviewCell({
);
}
function categoryOf(slug: string | null): string {
const found =
slug && DECORATION_CATEGORIES.find((c) => c.decorations.some((d) => d.slug === slug));
return found ? found.id : DECORATION_CATEGORIES[0].id;
}
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
@@ -75,6 +97,16 @@ export function ProfileDecoration() {
// mount-time fetch below clobbering a fresh selection if it resolves late
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
const dirtyRef = useRef(false);
const [activeCategory, setActiveCategory] = useState(DECORATION_CATEGORIES[0].id);
const [search, setSearch] = useState('');
const query = search.trim().toLowerCase();
const visible = useMemo(
() =>
query
? ALL_DECORATIONS.filter((d) => d.name.toLowerCase().includes(query))
: (DECORATION_CATEGORIES.find((c) => c.id === activeCategory)?.decorations ?? []),
[query, activeCategory],
);
const fetchDecoration = useCallback(() => {
let cancelled = false;
@@ -92,6 +124,7 @@ export function ProfileDecoration() {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
setActiveCategory(categoryOf(val));
})
.catch(() => {
if (cancelled) return;
@@ -196,9 +229,7 @@ export function ProfileDecoration() {
{loadError
? 'Failed to load'
: selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
: 'None'}
</Text>
{selected && !loadError && (
@@ -256,42 +287,72 @@ export function ProfileDecoration() {
</Text>
)}
{/* Category grid */}
{/* Search + category tabs. Only one category (or the search results)
is mounted at a time so the picker never loads the whole catalog. */}
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
maxHeight: 480,
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
display: 'flex',
flexDirection: 'column',
gap: 24,
}}
>
{DECORATION_CATEGORIES.map((category) => (
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Text size="L400" priority="400">
{category.label}
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
}}
>
{category.decorations.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
))}
)}
</div>
</Box>
</SettingTile>
+52 -2
View File
@@ -49,6 +49,7 @@ import {
Edge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { useAtom } from 'jotai';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -164,11 +165,18 @@ function AutostartSetting() {
// [cinny-desktop #3] null until the native side answers (older builds don't
// have the command, so the switch just stays hidden there).
const [startMinimized, setStartMinimized] = useState<boolean | null>(null);
// [cinny-desktop #5] "ask" | "tray" | "quit"; null on builds without it.
const [closeBehavior, setCloseBehavior] = useState<string | null>(null);
useEffect(() => {
tauriInvoke()?.('plugin:autostart|is_enabled')
.then((value) => setEnabled(value === true))
.catch(() => undefined);
tauriInvoke()?.('get_close_behavior')
.then((value) => {
if (typeof value === 'string') setCloseBehavior(value);
})
.catch(() => undefined);
tauriInvoke()?.('get_start_minimized')
.then((value) => {
if (typeof value === 'boolean') setStartMinimized(value);
@@ -181,6 +189,11 @@ function AutostartSetting() {
setEnabled(value);
};
const handleCloseBehavior = (value: string) => {
invokeTauri('set_close_behavior', { value });
setCloseBehavior(value);
};
const handleStartMinimized = (value: boolean) => {
invokeTauri('set_start_minimized', { value });
setStartMinimized(value);
@@ -194,6 +207,24 @@ function AutostartSetting() {
description="Start Lotus Chat automatically when you sign in to your computer."
after={<Switch variant="Primary" value={enabled} onChange={handleChange} />}
/>
{closeBehavior !== null && (
<SettingTile
title="When I close the window"
description="Keep Lotus Chat running in the system tray (messages, notifications and calls keep arriving), or quit it. During a call, closing always keeps it running."
after={
<SettingsSelect
value={closeBehavior}
onChange={handleCloseBehavior}
aria-label="When I close the window"
options={[
{ value: 'tray', label: 'Keep running in the tray' },
{ value: 'quit', label: 'Quit Lotus Chat' },
{ value: 'ask', label: 'Ask me' },
]}
/>
}
/>
)}
{enabled && startMinimized !== null && (
<SettingTile
title="Start minimized"
@@ -1375,6 +1406,7 @@ function ComposerToolbarReorder({
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [offerCodePaste, setOfferCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
settingsAtom,
@@ -1425,6 +1457,13 @@ function Editor() {
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Offer to Format Pasted Code"
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Formatting Toolbar"
@@ -2543,6 +2582,8 @@ function Messages() {
);
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const [gifPickerEnabled, setGifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// No Giphy key in this build's config: the switch can't show a GIF button.
const gifAvailable = !!useClientConfig().gifApiKey;
const translationSupported = chromeTranslationEngine.isSupported();
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
? normalizeLang(translateTargetLang)
@@ -2648,9 +2689,18 @@ function Messages() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="GIF Picker"
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
description={
gifAvailable
? 'Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default.'
: "GIF search isn't set up for this version of the app (no Giphy key in its configuration), so there is no GIF button even when this is on."
}
after={
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
<Switch
variant="Primary"
value={gifPickerEnabled && gifAvailable}
onChange={setGifPickerEnabled}
disabled={!gifAvailable}
/>
}
/>
</SequenceCard>

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