Compare commits

..
127 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 d250333290 fix(composer): Enter picks the @-mention / #room / command suggestion
CI / Trigger Desktop Build (push) Blocked by required conditions
CI / Docker image build & smoke test (push) Blocked by required conditions
CI / Playwright smoke (e2e) (push) Blocked by required conditions
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Secret scan (gitleaks) (push) In progress
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
Lotus CIandClaude Opus 5.5 4a9823890b feat(desktop): "Start minimized" setting under Launch on login (cinny-desktop #3)
CI / Build & Quality Checks (push) Successful in 5m19s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 24s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 15m37s
Shown only while Launch on login is on, and only when the native side
answers `get_start_minimized` (older desktop builds just don't show it).
Toggling calls `set_start_minimized`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:46:10 -04:00
Lotus CIandClaude Opus 5.5 e80b170fd1 fix(sw): only register the service worker on http(s) pages
register() rejects on non-http(s) origins, and the rejection was
unhandled. The desktop app's debug build loads from tauri://localhost,
which surfaced as a Sentry "serviceWorker.register() must be called with
a script URL whose protocol is either HTTP or HTTPS". Skip registration
there, and catch any other failure (e.g. SWs disabled) with a warning.
The app works without a SW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:46:05 -04:00
Lotus CIandClaude Opus 5.5 76929a8763 docs(embeds): SoundCloud short links already embed via og:url (#200)
CI / Build & Quality Checks (push) Successful in 4m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Canceled after 4m39s
The comment said on.soundcloud.com links need an oEmbed round-trip. They
don't: Synapse follows the redirect for the preview and the og:url
fallback re-parses the canonical track URL. Checked with a real short
link (the play facade loads the w.soundcloud player).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:09:34 -04:00
Lotus CIandClaude Opus 5.5 146ba4d2ff feat(desktop): upload progress on the taskbar button (cinny-desktop #10)
Upload atoms now also report into an app-wide `uploadProgressAtom`, and
`aggregateUploadProgress` turns every in-flight upload into one taskbar
state: byte-weighted percentage while uploading, indeterminate until a
size is known, red after a failure (held 4 s, then cleared), none when
done. Cancelled uploads just disappear. `useTauriTaskbarProgress` sends it
to the native `set_taskbar_progress`, at most ~4 times a second, always
ending on the latest state.

Verified with a throttled 6 MB upload: 0% → 99% at 250 ms steps, then
cleared; with the upload request aborted: 0% → error → cleared after 4 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:04:48 -04:00
Lotus CIandClaude Opus 5.5 25fa0f6ef9 feat(desktop): mirror call state onto the tray icon (cinny-desktop #4)
Sends `set_tray_call_state` from the same effect as the taskbar thumbbar,
so tray and thumbbar can't disagree. Verified through a real local call:
idle → active → active+muted → active+muted+deafened → active+muted → idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:53:48 -04:00
Lotus CIandClaude Opus 5.5 b77720c3da fix(calls): no stale "muted + deafened" render when a call starts
useCallControlState kept the previous control's state (the all-off
default before a call) for one render after the control changed, until
its effect caught up. The thumbbar, SMTC and tray hooks pushed that render
to the OS, so every join flashed "muted + deafened" first (recorded:
idle → active+muted+deafened → active). The state now remembers which
control it belongs to and reads the new control directly when they differ
(now: idle → active).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:53:43 -04:00
Lotus CIandClaude Opus 5.5 6edfe70020 feat(desktop): mirror a pending update into the tray (cinny-desktop #6)
CI / Secret scan (gitleaks) (push) Successful in 21s
CI / Build & Quality Checks (push) Successful in 4m54s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 12m56s
While an update is available the web client asks the native side to show
"Restart to update (vX)" in the tray menu and an "update ready" tooltip;
it's cleared once a check reports we're current, and kept while
installing or after a failed attempt. Clicking the tray item runs the
same install flow as the toast, so progress and failures show in-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:31:29 -04:00
Lotus CIandClaude Opus 5.5 d9e7f77402 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.13
CI / Build & Quality Checks (push) Successful in 3m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Canceled after 15m41s
Brings the plain-language "Couldn't connect to voice" dialog with a
Try again button (replaces "Failed to connect to Livekit server
(Reason: ServerUnreachable)").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:11:11 -04:00
Lotus CIandClaude Opus 5.5 568f218fe9 feat(desktop): update failures say what happened and what to do
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Build & Quality Checks (push) Canceled after 53s
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
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.

- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
  server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
  native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
  error prefix) in plain language, with Try again and a Download
  installer button (Windows: the setup .exe; else the release page), and
  the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
  toast, and on failure a sticky "Update didn't install" toast that
  retries on click and points at Settings → General → App Updates.
  Before, the toast vanished and the failure was only visible in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 20:52:16 -04:00
Lotus CIandClaude Opus 5.5 23649f1255 fix(media): catch autoplay rejections from play()
CI / Build & Quality Checks (push) Successful in 5m10s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 7s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Notification sounds called `audio.play()` without handling the promise,
so every message that arrived before the user interacted with the page
(e.g. right after launch) logged an uncaught NotAllowedError — 15 in a
short test run. Same pattern in the video thumbnail loader, the voice
preview (which now also resets its Play button) and useMediaPlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:48:29 -04:00
Lotus CIandClaude Opus 5.5 343de27cad feat(desktop): toast "Mark as read" marks the room read (cinny-desktop #9)
Handles the native `lotus-notification-mark-read` event through the
same `markAsRead` path as the room menu's "Mark as Read", honouring the
private-receipt settings. Verified: 3 unread → 0 on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:48:23 -04:00
Lotus CIandClaude Opus 5.5 3b6de2fdac fix(desktop): hydrate Focus Assist on mount; thread-aware toast quick reply
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
- useTauriFocusAssist queries the new `get_focus_assist` command on mount.
  The native poll's first reading is emitted during app setup, before the
  page listens, and the atom resets on every reload — so with Focus Assist
  already on, notifications leaked until the OS state flipped
  (cinny-desktop #15).
- The toast quick reply takes the real `threadId` from the notification
  data and replies inside the thread (cinny-desktop #17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:32:42 -04:00
jaredandClaude Opus 5 f5ee3c0d0e fix(assets): rename Lotus.png so it no longer collides with lotus.png (#229)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 8m41s
public/res/ carried two DIFFERENT files whose names differ only in case —
Lotus.png (19 897 B, the 256px logo used by the welcome page, auth layout,
OIDC config and About) and lotus.png (2 073 B, the notification icon).
Windows and macOS filesystems are case-insensitive, so a checkout there
collapses them into one path: one of the two references then resolves to
the wrong bytes or to nothing, which is why the desktop client's home
logo rendered as alt text from
http://localhost:44548/public/res/Lotus.png.

Renamed the logo to lotus-logo.png and updated its four references;
verified no case-only filename collisions remain anywhere in the repo,
and that both logos still load (256x256 on the welcome page and About).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:39:26 -04:00
jaredandClaude Opus 5 08abf5aec8 fix(embeds): players fill the card and keep their aspect ratio (#228)
Two sizing bugs, both visible the moment a video starts:

1. The video/TikTok embed columns had no width. UrlPreview is a flex ROW,
   so they shrink-to-fit: the facade's <img> supplied the width, but the
   player <iframe> is absolutely positioned and supplies none, so pressing
   play collapsed the whole embed to the iframe's ~200px intrinsic size
   (measured 606x341 -> 204x115 in a 608px card). Both columns are now
   width: 100%.
2. EmbedMediaPortrait's 9:16 came from a 177.78% padding-top, but a
   padding percentage resolves against the CONTAINING BLOCK's width, not
   the element's — so inside a wide card a Short/TikTok rendered
   300x1077 instead of 300x533. Capped with min() so it is exact at
   >= 300px and still correct on narrower phones.

Audited every provider before/after play at 1300 px, 500 px and Pixel 7:
YouTube, Vimeo, Twitch 16:9 (0.56); Shorts, TikTok 9:16 (1.78); Spotify
152 px and SoundCloud 166 px fixed-height — all stable across play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:37:57 -04:00
jaredandClaude Opus 5 e393c45f50 fix(status): auto-clear survives a rate-limited presence write (#227)
The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.

The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.

Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:31:57 -04:00
jaredandClaude Opus 5 fdec3ed7f2 feat(threads): "Mark all read" in the threads list (#165)
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
A "Mark all read" chip appears in the Threads panel header whenever any
thread in the room has unread replies, and sends one threaded receipt per
unread thread (never the root — a root lives in the main timeline and a
receipt there would drag the MAIN read marker backwards, the P6
regression). Honours the private-receipt settings.

Verified: two threads with unread replies → the chip appears, the rows
say "unread" in their labels; after clicking, no unread rows and the chip
is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:25:53 -04:00
jaredandClaude Opus 5 cf80729a5c feat(media): swipe between items in the full-screen viewer (#164)
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Touch had no way to move between images except the small arrow buttons.
A one-finger horizontal swipe now goes next/previous, and is inert while
the image is zoomed in (where the same gesture pans). The classifier is
pure and unit-tested: ≥ 60 px horizontal, not mostly vertical, under
800 ms, single finger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:20:22 -04:00
jaredandClaude Opus 5 738067cfc6 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.12
CI / Build & Quality Checks (push) Successful in 2m52s
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 2m31s
Brings io.lotus.screenshare_notice (EC #39) and the screenshare tile's
audio-state glyph (EC #38).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:15:25 -04:00
jaredandClaude Opus 5 2bed70d335 feat(calls): toast the fork's screenshare reminders (EC #39)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m4s
io.lotus.screenshare_notice → "Screen sharing stopped — the shared window
was closed.", "Your screen share is showing nothing — the shared window
may be minimised or hidden.", and a sticky "Still sharing?" after 30 min
with nobody else in the call (fork ≥ 0.25.0-lotus.12).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:51:59 -04:00
jaredandClaude Opus 5 c7aa4b9b19 ci: debounce the desktop build trigger to one bump per hour (matrix #9)
trigger-desktop checks the age of cinny-desktop's last main commit and
skips the submodule bump when it is under 60 minutes old; cinny-desktop's
new nightly catch-up workflow (or a manual dispatch) moves the submodule
to lotus HEAD for whatever landed inside the window. The desktop cadence
stops tracking every web commit, which was the biggest runner-load source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:39:55 -04:00
jaredandClaude Opus 5 81777ddfe1 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.11
Brings io.lotus.set_audio_output for the call-bar output switch (#119).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:38:14 -04:00
jaredandClaude Opus 5 eb4b88a028 fix(math): $…$ inside a backtick code span stays literal (#194 P4-4)
Verifying the KaTeX checklist: `$x^2$` in inline code was turned into
math and the backticks were left as literal text, because the math split
runs before markdown. The splitter now skips backtick code spans (N ticks
close with N), so markdown's inline code wins: wire is
<code>$y^2$</code>. Fenced blocks were already fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:35:54 -04:00
jaredandClaude Opus 5 e3883e0fce test(haptics): swap globalThis.navigator via defineProperty (Node 22)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 9m50s
CI's Node exposes a read-only navigator getter; assigning threw
"Cannot set property navigator ... which has only a getter".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:32:32 -04:00
jaredandClaude Opus 5 98c80f36cb feat(calls): audio output quick-switch in the call bar (#119)
Verified first: the embed hides Element Call's footer, so its in-call
settings sheet (which has the output picker) is unreachable from Lotus;
the cinny call bar had no output control.

Now a speaker button next to Deafen (desktop bar only; hidden where
setSinkId is unavailable — Firefox, Safari, Android Chrome) opens a menu
of enumerateDevices() audio outputs with the current one checked; picking
one sends io.lotus.set_audio_output to the fork (≥ 0.25.0-lotus.11),
which selects it, and the choice is re-sent with the rest of the sticky
fork state after an EC remount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:31:47 -04:00
jaredandClaude Opus 5 ad1cbcf792 feat(room-settings): one-line "who can see this" under the room name (#133)
CI / Build & Quality Checks (push) Failing after 1m43s
CI / Trigger Desktop Build (push) Skipped
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
Room Settings → General now reads e.g. "Encrypted · Invite only ·
History visible to members since they joined" under the name — derived
from m.room.encryption, m.room.join_rules and m.room.history_visibility
with the same words the preview-card chips and the history setting use.
One Text line, no card, no icons, no controls; spec defaults (invite-only,
shared history) when a state event is absent. Unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:18:08 -04:00
jaredandClaude Opus 5 492b57c60d feat(mobile): haptic tick on PTT press/release and on reactions (#125)
utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms
navigator.vibrate, a no-op without the API (iOS), when the system prefers
reduced motion, or when the new Settings → Calls "Haptic Feedback" switch
(default on, only rendered where the API exists) is off. PTT is observed
once through pttActiveAtom so the keyboard, global-hotkey and on-screen
paths all tick; reactions tick where the reaction event is sent in the
room and thread timelines (quick bar, hover bar, sheet and emoji board
all funnel there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:15:04 -04:00
jaredandClaude Opus 5 0c45bde832 feat(composer): a single pasted/dropped image lands in its caption field (#129)
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
Verified first: after a paste the focus stayed in the composer and the
caption needed a click. Now, when exactly one image is pasted or dropped
into an empty composer, its upload card's caption input takes focus;
Enter there sends the board (with any composer text) and Escape returns
focus to the composer. Multi-file drops and non-empty composers are
unchanged. The target card is matched by file name + mtime because the
metadata strip re-wraps the File.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:11:05 -04:00
jaredandClaude Opus 5 082b8fc879 feat(sidebar): custom room sections via u.* tags (#108)
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
Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.

Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:05:30 -04:00
jaredandClaude Opus 5 c6c2e88df5 feat(settings): storage usage tile with persistence status (#120)
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
One tile under Settings → General → Storage: total local usage vs the
browser's quota from navigator.storage.estimate(), the Chromium
usageDetails breakdown when available (IndexedDB = sync cache + encryption
keys + search index; Cache Storage = offline app files), and whether the
browser granted persistent storage — green "Protected" or amber "May be
evicted" with a "Keep my data" button that calls storage.persist(). That
last bit is the useful diagnostic for the KE-1 storage-eviction cluster.

No clear button: media lives in the browser's own HTTP cache (not in the
estimate, and not clearable from a page), the crypto store must never be
casually cleared, and the search-index clear already lives in Message
Search. The About page's "Clear Cache & Reload" now says it deletes this
device's encryption keys too. Hidden entirely when estimate() is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:53:42 -04:00
jaredandClaude Opus 5 dc0d524989 feat(calls): rejoin the voice room after a crash, update or reload (#118)
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.

Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:50:57 -04:00
jaredandClaude Opus 5 22d46a7922 feat(messages): reply quotes show a media thumbnail (#151)
CI / Build & Quality Checks (push) Successful in 1m46s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 9m44s
A reply to an image, video or sticker used to quote just the filename.
The quote (timeline) and the composer's reply-draft preview now carry a
36 px thumbnail from the event's own thumbnail, decrypted for E2EE media
via the same hook the gallery uses — never the full-size file. Clicking
still jumps to the original.

useDecryptedMediaUrl and getThumbMxc moved out of MediaGallery into
hooks/ and utils/ so components/message can use them without a cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:27:23 -04:00
jaredandClaude Opus 5 be2c202543 feat(privacy): strip EXIF/XMP/IPTC from image uploads by default (#109)
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
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.

Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".

Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:22:34 -04:00
jaredandClaude Opus 5 6363939654 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.10
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
Brings io.lotus.call_summary for the hangup readout (#143).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:14:28 -04:00
jaredandClaude Opus 5 af244bba75 chore(lint): retire 19 explicit anys, ratchet to 49 warnings (#210)
Event handlers typed as React.MouseEvent, join errors as Error, the
service-worker session setter as strings, the UIA policy map and the
webkitAudioContext probe as narrow shapes. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:13:55 -04:00
jaredandClaude Opus 5 af1c0ee184 feat(calls): "Call ended · 41 min · connection was good" toast (#143)
One line in the existing toast style when a call you were in ends: the
duration from our own join clock, plus the fork's io.lotus.call_summary
readout (fork ≥ 0.25.0-lotus.10) when it arrives — "connection was
good", "3 reconnects", "connection was poor for 4 min". Nothing is
stored or sent; the summary is one postMessage at hangup. Without the
fork summary the toast still shows the duration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:48:14 -04:00
jaredandClaude Opus 5 96a97a2f86 feat(pwa): register as an Android share target (#155)
public/manifest.json declares share_target (POST multipart to
/share-target: title/text/url + image/video/audio/pdf/text files). The
service worker answers that POST itself: it stashes the form in a Cache
API bucket and 303s to the in-app /share page, which lists what arrived,
offers a room search, and on pick writes the files into that room's
upload-board atom (encrypting first for E2EE rooms via the composer's
shared filesToUploadItems) and the title/text/url into its draft, then
opens the room — the user still presses Send. The stash is cleared once
placed; reopening /share afterwards says so.

nginx/caddy examples and the prod image config gain a 303 for
/share-target so a POST that reaches the origin before the worker
controls the page lands on /share instead of a 405. iOS has no share
target support and ignores the manifest entry.

Verified headless against the built preview: SW-controlled page → POST
/share-target (two PNGs + title + text) → /share lists both files and the
text → pick the DM → composer shows both files on the upload board and
the text in the draft → /share reports nothing pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:36:05 -04:00
jaredandClaude Opus 5 6aa77552b8 ci(a11y): axe-core gate + accessibility-tree snapshots (#222)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 8m52s
e2e/a11y.spec.ts runs @axe-core/playwright (WCAG 2.x A/AA) over the login
page, room timeline + composer, message options menu, thread panel, user
settings and room settings, failing on critical/serious findings other
than colour contrast (reported, not gated: generated avatar colours and
portal false positives). Aria snapshots of the composer, message menu,
thread panel and settings nav catch lost names/roles/live regions.

Burned down what the first run found:
- NavItem: callers' aria-selected is not valid on a div (axe critical);
  it now drives data-selected for styling and aria-current="page".
- Composer placeholder at 0.5 opacity was ~2.3:1; now P300.
- Voice-limit and explore custom-limit number inputs had no label.
- Thread panel is an <aside aria-label="Thread">; the settings modal is a
  role=dialog; the settings sections are a <nav>; the message action
  menu carries data-message-menu + a label.

Also allows WebKit's CI wording for the well-known probe ("Could not
connect … Connection refused") that failed run #2003.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:25:57 -04:00
jaredandClaude Opus 5 52e0cfaa83 ci(e2e): run the tagged specs under WebKit as the Safari/iOS proxy (#221)
CI / Build & Quality Checks (push) Successful in 3m11s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Trigger Desktop Build (push) Successful in 17s
CI / Playwright smoke (e2e) (push) Failing after 8m58s
Two new Playwright projects — 'webkit' (Desktop Safari) for tests tagged
@webkit and 'iphone' (iPhone 14 descriptor) for @ios — covering boot,
login + send/receive, the thread panel and the gallery lightbox. The CI
e2e job installs webkit next to chromium.

WebKit reports handled fetch failures (well-known probes, a wasm fetch cut
short by our own navigation) as page errors with its own wording, so the
benign allowlist now applies to page errors as well.

Locally: 8/8 green twice in a row against the local Synapse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:11:38 -04:00
jaredandClaude Opus 5 bd8c79e0e6 feat(media): consecutive photos/videos render as one gallery grid (#137)
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) Successful in 5m9s
Client-side only: every file is still its own standard m.image/m.video
event, so Element and friends keep seeing N plain images. In Lotus a run
of media from one sender — contiguous, ≤ 60 s apart, no reply/thread/edit
relation, up to 10 — renders once, at its last event, as a 2–4 column
grid of square thumbnails (blurhash placeholder, video play badge,
tap-to-load when media auto-load is off). A member with reactions or a
thread closes its group so those stay visible under the rendered event.

Tapping a tile opens the lightbox on just that group in send order
(←/→, zoom, download, jump). "Show separately" splits a group back into
individual messages for the session; "Show as gallery" undoes it.

Planning is lazy per render pass (utils/mediaGroups.ts, unit-tested):
the first media event met plans its whole run in both directions, so a
virtual window that starts mid-run agrees with one that starts before it.

Verified: 5 files dropped at once in an encrypted room — both sender and
recipient see one 5-tile grid with decrypted thumbnails; desktop + phone;
a reaction on photo 3 yields [1–3]+👍 and [4–5].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:48:13 -04:00
jaredandClaude Opus 5 4d4a76214a refactor(time): one timestamp formatter honouring the clock/date settings (#139)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting:
the shared Time component + copies of its today/yesterday branch
(forwarded header, thread summary, read receipts, device tile, moderation
alerts, edit history), locale-default toLocale*String calls that ignored
the user's 12/24 h and date-format settings (scheduled tray, reminders,
schedule preview, notification snooze, bookmarks, threads list, search
cache line, room insights, media gallery), a hard-coded en-US date in the
activity log, and three relative-age variants.

utils/formatTimestamp.ts now holds the rules — today → time; yesterday /
tomorrow → day word + time; last 6 days → weekday + time; older → date +
time in dateFormatString — plus autoDate / time / date / dateTime styles,
formatDayDivider (full weekday), formatShortAge (room list) and
formatRelativeAge (list rows). useTimestampFormatter binds them to the
settings. 11 unit tests with an injected 'now'.

Visible changes are limited to consistency: 12 h times keep the existing
zero-padded hh:mm A; the a11y label and Created-by line use the user's
date format instead of a fixed long month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:28:47 -04:00
jaredandClaude Opus 5 8d11a62e14 feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147)
CI / Build & Quality Checks (push) Successful in 1m59s
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 4m38s
Fat-finger guard for phones. On coarse-pointer devices the first tap on a
sticker (emoji board Sticker tab) or a GIF parks it in a small bar with a
thumbnail, name, Send and Cancel; a second tap on the same item or Send
sends it; tapping a different item switches the preview; tapping empty
picker space or Cancel clears it. Mouse clicks, keyboard activation and
screen-reader activation (bare click without touch events) still send in
one step, and the emoji tab is untouched.

useRecentTouch records touches inside the picker at the document level so
it survives the Sticker tab remount, and reports false without a coarse
pointer.

Also fixes the compact composer's GIF picker opening mostly off-screen:
end-aligning the 312px popout to a button near the left edge of the
overflow row pushed it to x≈-95; it now anchors to the row itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:15:09 -04:00
jaredandClaude Opus 5 bf05751eca feat(messages): collapse reactions to one row with a +N chip (#138)
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Many distinct reactions used to wrap into a tall stack (16 reactions =
4 rows at phone width). Now only the first row is shown, ending in a
"+N" chip; tapping it expands inline and a "less" chip collapses again.
Expanded state is remembered per message for the session. Nothing changes
when the reactions already fit on one row.

Overflowing chips stay in the DOM (invisible, aria-hidden, untabbable,
clipped by max-height) so the container keeps its natural width — which
keeps shrink-to-fit bubble layout stable — and each chip stays measurable.
utils/reactionOverflow.ts holds the unit-tested fit calculation; a
ResizeObserver re-fits on width changes. "+N" is forced LTR for RTL UIs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:54:52 -04:00
jaredandClaude Opus 5 2bdb2eb4cb feat(messages): word diff of the last edit on "(edited)" hover (#144)
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Canceled after 1m37s
Hovering or focusing "(edited)" shows a tooltip with only the most recent
edit as a word diff — removed words struck, added words bold — plus a
+N/−N summary. Clicking still opens the full history viewer. On touch, a
long-press on the label shows the same diff as a popout (a plain tap opens
the viewer; the message's own long-press action sheet is not triggered).

utils/wordDiff.ts is a unit-tested LCS over words that ignores whitespace-
only changes and gives up past 400 words. Only plain-text bodies are
diffed; formatted edits fall back to the viewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:34:38 -04:00
jaredandClaude Opus 5 4fe9c87010 feat(calls): live-call dot on the space rail (#148)
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
A small red dot on a space tab when any room in that space (recursively)
has an active MatrixRTC session, so a live call is visible even when you
are looking at a different space or your DMs. Hidden while the space is
selected — the room list already shows the Live badge there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:25:49 -04:00
jaredandClaude Opus 5 b69099a862 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.9
CI / Build & Quality Checks (push) Successful in 1m31s
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 3m59s
Brings speakingWhileMuted in io.lotus.call_state for the 'You're muted' nudge (#117).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:17:21 -04:00
jaredandClaude Opus 5 dea1f7afc0 feat(profile): 'Active now' / 'Last active …' line from presence (#150)
One line under the handle in the user profile popover, from the presence the
SDK already receives (currently_active + last_active_ago): 'Active now',
'Active just now', 'Last active 12 min ago / 3 hours ago / yesterday / 5 days
ago / over a month ago'. Nothing is shown without presence data or for an
offline user with no timestamp (Hide Online Status users), so nothing new is
exposed; re-renders on the existing presence events, no polling. Wording
unit-tested; verified headless ('Active now' for an online member).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:16:40 -04:00
jaredandClaude Opus 5 7da91bd803 feat(timeline): 'Back to where you were' after a jump (#127)
Clicking a reply quote (or arriving via a thread-panel / search / permalink
jump, i.e. an eventId route change) remembers the message that was in the
middle of the viewport and shows a Primary chip next to Jump to Latest —
'Back to where you were' — which scrolls/navigates back to it. Nothing is
rendered normally; the chip expires after 20 s or once used. Verified headless:
reply quote → original 120 messages up is shown + chip; chip → back at the
reply, chip gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:13:29 -04:00
jaredandClaude Opus 5 2a0c409180 feat(messages): code blocks get a Wrap toggle next to Copy (#131)
Verified upstream first: the Copy chip (with a 'Copied' state) already exists
on every code block. Added the missing half: a Wrap chip that switches long
lines to pre-wrap (default stays no-wrap + horizontal scroll); the choice is
remembered for the session, not persisted. Markdown/HTML output, layout widths
and the syntax theme are untouched. Verified headless: scrollWidth 1016 →
612 px with wrapping on, 'Copied' state on copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:09:25 -04:00
jaredandClaude Opus 5 9363629ea2 feat(security): recovery key leaves the clipboard after 60 s, visibly (#156)
useSensitiveCopy: the recovery key's Copy button becomes 'Copied · clears in
60 s' and counts down; at zero the clipboard is cleared only if it still holds
the key (readText() where permitted — if the browser refuses to read, nothing
is wiped rather than risk eating something else). Any other copy made in the
app cancels the timer. No setting. Verified headless with a fake clock:
countdown ticks, clipboard emptied at 0; copying something else mid-countdown
cancels and leaves that content untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:06:02 -04:00
jaredandClaude Opus 5 74d8e3119b feat(security): confirm before opening a link whose text names a different site (#122)
utils/linkSafety.ts compares the registrable domain the visible text claims
(when it looks like a URL/host) with the href's; a mismatch, or a punycode
(IDN) destination, renders the anchor as SuspiciousLink, whose click opens a
confirm — "It shows matrix.lotusguild.org but goes to evil.example." with the
full URL, Cancel / Open anyway (opens in a new tab with noopener). Honest links
are untouched: same registrable domain (youtube.com text over www.youtube.com,
bbc.co.uk over news.bbc.co.uk), plain-word text, mailto:, matrix.to and Lotus
permalinks, anchors with non-text children. Comparator unit-tested (incl. a
Cyrillic-а paypal homograph); verified headless that the phish and IDN
messages are flagged, the honest ones are not, the click shows the confirm and
does not navigate, Cancel keeps you put, Open anyway opens the real target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:57:07 -04:00
jaredandClaude Opus 5 73b1d1e3a7 feat(calls): 'You're muted' nudge when talking into a muted mic (#117)
Host half of the talking-while-muted feature. The fork (0.25.0-lotus.9)
reports speakingWhileMuted for the local participant in io.lotus.call_state —
a level tap on the muted track, so cinny captures no audio. useMutedTalkWarning
polls that flag while muted; after 1.5 s of it being true it shows one sticky
toast, "You're muted — click to unmute", whose click unmutes; it fires at most
once per mute session (re-arms after unmute → mute), is retired the moment the
mic comes back on by any means, and never runs in Push-to-Talk mode. Setting
'Warn me when I talk while muted' (default on) sits next to AFK Auto-Mute.

Verified headless with a tone mic: no toast at +1 s, toast at +4 s, click
unmutes and clears it, second mute re-arms, unmute via the button clears it,
PTT mode → nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:50:32 -04:00
jaredandClaude Opus 5 ef5d06eea3 ci(e2e): tier-3 regression suite against a Synapse the job starts itself (#220)
CI / Build & Quality Checks (push) Successful in 1m29s
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 4m17s
The e2e job now runs scripts/dev-homeserver.sh start + dev-seed.py before
Playwright (installing python3-venv if the runner lacks it) and stops it
afterwards. e2e/local-homeserver.spec.ts registers its own users and rooms
over the CS API and drives the built client — no prod secrets — covering the
fixes that were reproduced with scratch scripts this week: login/send/receive,
own-message scroll (#212), /kick toast (#216), upload 413 wording (#213),
forward provenance, thread panel at 1400px (#218), timeline lightbox (#219),
clock-skew banner via page.clock (#158), status save under the presence limit
(#226), long-press action sheet on a Pixel 7 emulation (#166). Skips itself
when no homeserver answers, so
> lotus-chat@4.12.7-lotus test:e2e
> playwright test

Running 16 tests using 1 worker

  ✓   1 [chromium] › e2e/boot.spec.ts:8:3 › boot › client boots to the login screen without errors (1.5s)
  ✓   2 [chromium] › e2e/boot.spec.ts:27:3 › boot › service worker script is served and registers (1.4s)
  ✓   3 [chromium] › e2e/boot.spec.ts:54:3 › boot › bundled Element Call loads in a frame (3.8s)
  -   4 [chromium] › e2e/e2ee-composer.spec.ts:67:3 › E2EE composer › logs in with a password and reaches the client
  -   5 [chromium] › e2e/e2ee-composer.spec.ts:84:3 › E2EE composer › creates a private encrypted room and sends a text message
  -   6 [chromium] › e2e/e2ee-composer.spec.ts:146:3 › E2EE composer › attaches a compressed image and it is sent encrypted
  ✓   7 [chromium] › e2e/local-homeserver.spec.ts:31:3 › local homeserver regression › logs in, opens a room, sends and receives (4.5s)
  ✓   8 [chromium] › e2e/local-homeserver.spec.ts:58:3 › local homeserver regression › your own message scrolls into view even after scrolling up (#212) (11.4s)
  ✓   9 [chromium] › e2e/local-homeserver.spec.ts:76:3 › local homeserver regression › /kick failure is reported, not swallowed (#216) (5.4s)
  ✓  10 [chromium] › e2e/local-homeserver.spec.ts:90:3 › local homeserver regression › upload failure shows a plain sentence, never the raw MatrixError (#213) (3.7s)
  ✓  11 [chromium] › e2e/local-homeserver.spec.ts:122:3 › local homeserver regression › forwarded message carries its provenance header (6.7s)
  ✓  12 [chromium] › e2e/local-homeserver.spec.ts:143:3 › local homeserver regression › thread panel: opens from the chip and yields the member drawer at 1400px (#218) (4.7s)
  ✓  13 [chromium] › e2e/local-homeserver.spec.ts:172:3 › local homeserver regression › timeline image opens the gallery lightbox (#219) (3.9s)
  ✓  14 [chromium] › e2e/local-homeserver.spec.ts:204:3 › local homeserver regression › warns when the local clock is far off the server (#158) (6.5s)
  ✓  15 [chromium] › e2e/local-homeserver.spec.ts:221:3 › local homeserver regression › status save survives the presence rate limit (#226) (12.6s)
  ✓  16 [chromium] › e2e/local-homeserver.spec.ts:265:3 › local homeserver regression › touch: long-press opens the message action sheet (#166) (5.0s)

  3 skipped
  13 passed (1.2m) still works cold. 13 pass
locally against dist + the dev homeserver in 1.8 min.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:38:39 -04:00
jaredandClaude Opus 5 7f17940d34 feat(calls): room header chip for voice limit / audio-only policy (#134)
CI / Build & Quality Checks (push) Successful in 1m35s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m38s
One small Surface chip next to the header controls, only when a rule exists:
'0/10' (participants / io.lotus.voice_limit) and/or a muted-camera glyph when
the room's io.lotus.room_quality forbids camera and screenshare; the tooltip
and aria-label carry the words ('Voice limit 10 (0 in call) · Audio-only
room'). Desktop only — the mobile header is already tight. Verified headless:
appears with the rules set, disappears when they are cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:42:00 -04:00
jaredandClaude Opus 5 f5e7fb4746 fix(status): status save no longer fails on Synapse's 1-per-10s presence limit
Reported: 'Failed to save status — server may be rate limiting. Try again.'
on the first status change in a month. Root cause: Synapse rate-limits
PUT /presence/{user}/status to ONE request per 10 s per user by default
(rc_presence.per_user: per_second 0.1, burst_count 1), shared across all of
the user's devices, and our presence heartbeat (online/away on visibility and
activity changes, from every open tab/device) spends that budget — so a manual
save that lands within 10 s of a heartbeat gets a 429, which the form showed
as a dead end.

Two fixes: (1) the status save waits out Retry-After (bounded to ~25 s) via
setPresenceWithRetry instead of failing, and the error text now says what
actually happened (rate limit / server text / offline); (2) the heartbeat
dedupes — it only sends when presence or status actually changes (/sync
already keeps us online), so it stops burning the budget in the first place.
Unit-tested; reproduced headless with a routed 10 s limiter: heartbeat ok →
save 429 → retried 8 s later → saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:41:09 -04:00
jaredandClaude Opus 5 bbe91a24d8 fix(timeline): remember 'Read more' expansion per message for the session (#152)
CollapsibleBody kept its collapsed flag in component state, which the
timeline's windowed rendering throws away when a row unmounts. A module-level
Set<eventId> (never persisted, cleared on reload) now seeds the state, so
scrolling away and back, or a re-render, keeps an expanded message open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:32:15 -04:00
jaredandClaude Opus 5 f7d40460f5 paste: when the clipboard carries files, never let the HTML/URL fragment into the composer (#132)
Verified headless that pasting an image copied from a web page (bitmap +
text/html <img src alt> + text/plain URL) already produced only the upload
card and an empty composer; make that explicit with a preventDefault so a
future editor change can't regress it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:28:25 -04:00
jaredandClaude Opus 5 d2f56817b3 feat(calls): say when answering turns the camera on, offer audio-only (#135)
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
The only join path that skips the prescreen and can start with the camera on
is answering a video call with 'Join with Camera On' enabled. The incoming
dialog and the in-call banner now say 'Answering turns your camera on', label
the primary button 'Answer with camera' and add 'Answer without camera'.
Nothing changes for voice calls or when the setting is off. The other
no-prescreen paths already can't start on camera: the sidebar join uses a
session video pref that is only ever true after the user turned it on
themselves, and it is guarded by the same setting.

Verified headless: video call + setting on → notice + both buttons, 'Answer
without camera' joins with videoEnabled=false; voice call → plain Answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:26:31 -04:00
jaredandClaude Opus 5 33e16e85b3 feat(calls): undeafen catch-up toast (#128)
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 2m23s
On deafen the participant set is snapshotted; on undeafen it is diffed and,
only if it changed and you were deafened for at least 10 s, one auto-dismissing
toast says 'While you were deafened: Alice, Bob joined · Cole left' (names
capped at 3 + N more). Rides the membership stream that already drives the
join/leave sounds — no new subscriptions; PTT holds don't touch deafen so they
can't trigger it. Verified headless: short deafen → nothing; bob leaves during
an 11 s deafen → 'bob left'; long deafen with no change → nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:17:58 -04:00
jaredandClaude Opus 5 f23b215efa fix(calls): release a push-to-talk hold after 5 minutes (#136)
Elbow-on-the-keyboard guard: a PTT hold longer than 5 min (fixed, not a
setting) is released exactly like a keyup — pttActive off, mic muted — with a
toast 'Push to talk released after 5 minutes — press the key again to keep
talking.' A fresh press re-engages normally. One watchdog shared by the DOM
path and the desktop-global hotkey path; keydown auto-repeat no longer
restarts anything; non-PTT mode, deafen and the mic button are untouched.
Helper unit-tested with fake timers; verified headless with Playwright's clock:
held → still open at +4 min → released + toast at +5 min → re-press works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:13:14 -04:00
jaredandClaude Opus 5 821760131c chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.8
Picks up the fork change that shows the SFU token service's refusal reason
("Can't join this call — This voice channel is full.") instead of the generic
OPEN_ID_ERROR page. Verified headless with a routed 403 on /sfu/get.
(lotus.6/7 were tagged but failed CI lint; lotus.8 is the published one.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:09:44 -04:00
jaredandClaude Opus 5 edb4624796 feat(privacy): delete all my messages in a room (#169)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m40s
Room Settings → General → Privacy: 'Your messages in this room' with a
Delete all… flow. The confirm dialog first counts your events with a
server-side sender-filtered /messages walk (live count), then asks to confirm
with the number — typing the room name above 50 — and offers 'Leave the room
afterwards'. Files are called out as not purged by a redaction.

The job runs outside React (closing settings is fine): sequential redactEvent
with 429 back-off, 404/already-redacted skipped, progress on the tile with
Cancel, pending ids persisted per room so a reload shows Resume/Discard, a
toast when done. State events are never touched; reactions, edits and thread
replies you sent are included; encrypted rooms work the same (nothing is
decrypted). Own events need no power level, so it is purely self-service.

Unit tests cover candidate filtering, the server filter, pagination, 429/404
handling and cancel. Verified headless: 62 of bob's events (60 messages, a
reaction, a thread reply) redacted in ~34 s while alice's 10 stayed; cancel at
17/40 → reload → Resume → 'Deleted 40 messages.'

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:33:33 -04:00
jaredandClaude Opus 5 d4420905e6 feat(notifications): inline reply from a browser notification (#203)
CI / Build & Quality Checks (push) Successful in 1m38s
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 2m50s
Message notifications shown through the service worker now carry a text-input
'Reply' action (Chrome desktop/Android). On notificationclick with
action==='reply' the SW sends the typed text itself — it already holds the
newest session's access token for authenticated media — as m.room.message
(threaded when the notification was for a thread), so it works with the tab in
the background or closed; a failed send shows a 'Reply not sent' notification
that opens the room. Not offered for encrypted rooms (the SW cannot encrypt).
The sender lives in swReply.ts so it is unit-tested; verified headless that
the SW notification carries actions + {roomId, threadId}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:12:23 -04:00
jaredandClaude Opus 5 53a80adb57 feat(mobile): long-press a message opens a bottom sheet of actions (#166)
On coarse-pointer devices the hover action bar is unreachable and iOS Safari
has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement,
cancelled by scroll/lift) now opens an ActionSheet — slides up from the
bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick
reactions row + 'Add reaction' on top and the same folds Menu the desktop
popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu
echo of the same press is dropped; the press-induced text/image selection is
cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu
item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is
unchanged (right-click → anchored popout).

Verified with Playwright touch emulation (Pixel 7, CDP touch events):
long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark /
Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses;
a scroll gesture does not open it; tapping Reply closes it and arms the
composer. Still wants a real iPhone + Android pass before it's called done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:59:03 -04:00
jaredandClaude Opus 5 e078a2cc10 privacy: wipe the local status-message mirror on logout (#204)
CI / Build & Quality Checks (push) Successful in 1m35s
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) Successful in 3m23s
Of the two plaintext-localStorage items in #204, composer drafts were already
swept on logout (#41); the presence status message + expiry were deliberately
kept. They are PII with an authoritative copy in server presence, so sweep
them too. The test's localStorage mock now enumerates keys like the real
Storage object, so the prefix sweeps (drafts, status) are actually exercised —
the old 'draft preserved' assertion only passed because Object.keys() saw
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:38:47 -04:00
jaredandClaude Opus 5 c921f11521 feat(calls): 'Missed call from X' line in the timeline (#162)
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
The ring event (m.rtc.notification) now renders — only once the ring is over
(its lifetime lapsed or the caller hung up) and only if we did not join between
the ring and the hang-up — as a red 'Missed call from bob' line with the ring's
time, in the same style as the call-membership summaries. Derived entirely from
events already in the timeline; nothing is sent; a call we answered shows
nothing extra, and a later separate call cannot retro-actively 'answer' an
earlier missed one.

Verified headless in a DM: ring ignored → line appears once bob hangs up; a
second call that alice answers adds no line and leaves the first one in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:35:02 -04:00
jaredandClaude Opus 5 0e2671891f feat(e2ee): undecryptable placeholder says why and offers the fix (#159)
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
'Unable to decrypt message' now carries one sentence per matrix-js-sdk
DecryptionFailureCode (describeDecryptionFailure, unit-tested against every
code so no raw code can leak into the copy) and, where something fixes it,
one button: no key backup → 'Set up key backup'; backup exists but this
session can't open it / key withheld for an unverified session → 'Unlock key
backup' / 'Verify this session' (both open Settings → Devices via a new
settingsRequestAtom that SettingsTab consumes); backup working or unknown
session (rust-crypto re-requests keys itself) → 'Retry', which re-runs
decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits
in the placeholder's tooltip for support.

Verified headless on a fresh session in the encrypted seed room: each event
shows 'Sent before you signed in here, and no key backup exists…' with
tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:23:25 -04:00
jaredandClaude Opus 5 6f25035341 privacy: self-host the VT323 font instead of Google Fonts (#214)
CI / Build & Quality Checks (push) Failing after 1m34s
CI / Trigger Desktop Build (push) Skipped
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
Every page load contacted fonts.googleapis.com / fonts.gstatic.com (IP +
referrer to Google, GDPR exposure) for the Lotus Terminal font. VT323 (OFL)
now ships in public/fonts as latin + latin-ext woff2 subsets with @font-face
declarations in the existing custom-fonts.css; the three Google tags are gone
from index.html. Also deletes three unreferenced JetBrainsMono-*.woff2 files
that were actually 1.6 kB Google 404 HTML pages.

Verified headless: no third-party host on a plain load; document.fonts.load
('16px VT323') resolves from /fonts/VT323-latin.woff2. CSP tightening
(nginx + tauri) follows in their repos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:16:32 -04:00
jaredandClaude Opus 5 f111b3c9af feat(media): timeline images open the gallery lightbox at that event (#219)
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
Clicking an image in the room or thread timeline now opens the same viewer
the media gallery uses — dark backdrop, sender + date, 1/N counter, ←/→ across
the room's media, +/-/0 and wheel/double-click zoom, download, jump to
message — positioned at the clicked event. RoomMediaLightbox feeds it from the
detached media timeline (#163); when the event isn't in the loaded window it
pages back (bounded, 6 pages) and shows the clicked image alone meanwhile, so
the viewer opens instantly. ImageContent gains onOpenViewer (RenderMessageContent
passes onOpenImageViewer); its built-in viewer remains for stickers, search
results, pins, notifications and avatars.

Verified headless: click → 'Media viewer' dialog focused, counter 1/2, '+' →
120 %, Jump to message present, Esc closes; an older image 10 pages up →
found at 10/45 after paging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:13:28 -04:00
jaredandClaude Opus 5 8b1c9fa610 fix(threads): thread panel no longer squeezes the composers (#218)
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
Two causes. (1) Three right-hand columns don't fit under ~1500 px: the member
drawer now yields to an open content panel (thread / gallery / widgets /
threads list) on desktops ≤ 1500 px and returns when it closes, like Element.
(2) The thread composer rendered the full ten-button toolbar inside a 360 px
panel, leaving 24 px for the input ('S…') at every width — RoomInput gains a
compactLayout prop (the phone one-row '+ | input | emoji | send' layout) and
ThreadPanel uses it.

Measured headless: 1400 px thread open → drawer hidden, composers 328/168 px
(was 422 → 'Send a mes…' + 24 px); 1600 px → drawer stays, thread composer 168 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:05:10 -04:00
jaredandClaude Opus 5 8658ec05c3 perf: lazy-load seasonal overlays as their own chunks (#167)
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
All 11 seasonal overlays (particles + vanilla-extract keyframes) were in the
main JS and CSS for everyone year-round. Each is now a React.lazy chunk fetched
only when that season is active (auto date or override) or previewed in
Settings → Appearance; the schedule/override decision stays in the main bundle.

check-bundle-size: largest chunk 332.4 kB → 320.1 kB gzip; main CSS 68.4 kB →
56.5 kB; total gzip 1551.6 kB → 1561.3 kB (per-chunk overhead, only paid when
a season is on). Verified headless: no theme chunk requested at startup with
'auto' out of season; the Settings grid fetches the 11 previews; picking
Halloween renders the full-screen overlay (20 particle nodes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:59:28 -04:00
jaredandClaude Opus 5 2e7915d086 feat(a11y): screen-reader announcements for call events (#168)
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
A visually hidden aria-live=polite region (rendered by CallEmbedProvider so it
outlives the embed) announces joins/leaves — batched over 1.5 s: 'alice
joined', 'alice and bob joined', '3 people joined' — your own mute/unmute,
deafen/undeafen and screenshare start/stop, and 'Call ended'. Nothing visible,
nothing audible for anyone else, no setting. Verified headless by observing
the region: bob joined → You are muted → … → bob left → Call ended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:55:01 -04:00
jaredandClaude Opus 5 84c906fe33 feat: warn when the local clock is far off the homeserver's (#158)
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.

Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.

UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.

Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:52:03 -04:00
jaredandClaude Opus 5 464951edf4 fix(timeline): your own new message always scrolls into view (#212)
CI / Build & Quality Checks (push) Successful in 1m34s
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 3m1s
Auto-scroll on a live event only ran while the at-bottom flag was true, so a
stale flag (in-place growth like a poll gaining a row briefly pushes the anchor
out of view; or simply having scrolled up) left your own just-sent message
below the fold behind 'Jump to Latest'. An own non-relation, non-state,
non-reaction event now always brings the live end into view: advance the
range + smooth scroll when the window is at the live end, otherwise reset to
the live timeline like the Jump to Latest button. Others' messages keep the
current behaviour.

Verified headless: scrolled up 600 px → Send → message visible, no Jump pill
(before: Jump pill, message off-screen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:37:54 -04:00
jaredandClaude Opus 5 6df160a7bf fix(upload): plain-language upload failure text instead of the raw MatrixError (#213)
The upload card printed the SDK's toString — 'MatrixError: [413] nope
(http://<hs>/_matrix/media/v3/upload?filename=…)'. describeUploadError() maps
the common cases to one sentence: 413/M_TOO_LARGE → 'This file is larger than
the server allows (limit N)' using m.upload.size when known, 429 → 'Slow down —
try again in a moment.', 401/403 → 'The server refused this upload: <server
text>', 5xx/transport after the retry loop → 'Couldn't reach the server. Check
your connection and retry.', other 4xx → the server's own sentence, URL
stripped. Both card renderers use it; the raw error is still console.warn-ed
by uploadContent for debugging. Unit-tested; verified headless with routed
413/403/503 responses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:31:26 -04:00
jaredandClaude Opus 5 60076a48d0 fix(commands): /kick and /ban failures are no longer silent (#216)
rateLimitedActions now collects non-429 failures (and a 429 that exhausted its
retries) and returns them instead of swallowing them; existing callers ignore
the return. /kick and /ban turn the list into a CommandError whose message
names who and why, using the server's own sentence (MatrixError.data.error),
never the URL-bearing toString(); RoomInput's toast shows it verbatim.

Verified headless as a non-moderator: '/kick @alice' → "Could not kick
@alice:localhost: You cannot kick user @alice:localhost."; '/ban @nobody
@alice' → "Could not ban @nobody:localhost, @alice:localhost: You don't have
permission to ban".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:27:15 -04:00
jaredandClaude Opus 5 a475531b2b feat(calls): 'N Live' pill names who is in the call on hover/focus (#157)
CI / Build & Quality Checks (push) Successful in 1m51s
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 1m37s
Tooltip (folds TooltipProvider, 300 ms) on the room-list Live badge listing the
call members' display names — deduped per user, capped at 6 + 'and N more' —
plus an aria-label with the same text so keyboard/screen-reader users get it
too (the badge is focusable). Names come from the same MatrixRTC memberships
that produce the count; no new data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:18:20 -04:00
jaredandClaude Opus 5 34574178a9 feat(calls): toast when a room admin turns off screen sharing / camera mid-call (#223)
Re-measured the revoke on the current stack (guard fix + fork lotus.4): the
SFU drops the sharer's tracks and EC's own button, the captured tracks and our
control bar all follow within ~2-4 s — the 30 s lag is gone. What remained was
that the button simply vanished with no explanation, so a call-lifetime hook
now toasts 'Screen sharing was turned off by a room admin.' (or the camera
variant) when a track ends while the room policy forbids it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 01:12:25 -04:00
jaredandClaude Opus 5 070a1ea012 fix(calls): hang up explicitly when answering another call while in one (#195)
Answering from the in-call banner is the only call-to-call switch path; it
started the new embed and let the atom dispose the old one, which just removed
the iframe — our m.call.member in the old room lingered ~17 s (until the
delayed leave expired), so everyone there still saw us in the call.

hangupAndWait moves out of LogoutDialog into plugins/call/hangup.ts and
handleAnswer now hangs up, waits for our membership to clear (bounded 4 s),
disposes the old embed itself (its HangupCall echo would otherwise land after
startCall and clear the NEW embed from the atom — seen in testing), then joins
the new call. Measured headless: old membership gone in 1 s, new call live
with both participants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 01:04:26 -04:00
jaredandClaude Opus 5 bbcbdad55a refactor(calls): drop the deafen iframe-DOM fallback now that the fork owns it (#209)
CallControl.setSound()/applyScreenshareAudioMuted() no longer poke <audio>.muted
inside the EC frame, and the per-membership re-apply (useCallMemberSoundSync)
is gone: with the pin at 0.25.0-lotus.4 the fork applies deafen and the
screenshare-audio mute through its own renderer, including for late joiners.
Verified headless: deafen set before a second participant joins mutes their
track on arrival; deafen/undeafen leaves the screenshare-audio mute in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:53:24 -04:00
jaredandClaude Opus 5 a9e0b893be fix(calls): opaque backdrop for the PiP call window (#224)
The fork renders EC transparent so the room wallpaper shows through the
full-size embed; in PiP the element behind the frame is whatever room you
navigated to, so the timeline bled through the participant tiles. The PiP
container now gets the surface colour (the full embed keeps the wallpaper).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:45:38 -04:00
jaredandClaude Opus 5 68ce13f081 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.4
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 6s
CI / Playwright smoke (e2e) (push) Successful in 2m3s
Picks up the fork fix where "Mute Screenshare Audio" was undone whenever the
sharer stopped and re-shared (EC's own volume controls wrote volume 1 through
the same setter). The mute now rides the renderer's `muted` prop like deafen
does. Docs: fork feature table gains the deafen/screenshare-audio row; the
soundboard + quality rows are marked verified end-to-end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:16:49 -04:00
jaredandClaude Opus 5 70620d4b43 fix(calls): read the EC iframe document defensively when the widget failed to load
When the Element Call frame's navigation fails (offline, blocked) it becomes a
cross-origin error page and `iframe.contentWindow.document` throws a
SecurityError. Every DOM-driven call hook (CallControl selectors, deafen DOM
fallback, useCallSpeakers/useRemoteAllMuted observers, CallEmbed theme
injection) read it unguarded, so the moment the 25 s load watchdog fired the
page logged a burst of uncaught SecurityErrors. Reproduced headless by aborting
the EC index request. One `getCallDocument()` helper now treats "can't read" as
"not loaded yet"; the watchdog overlay itself was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:15:37 -04:00
jaredandClaude Opus 5 4e455ae42e feat(calls): debounce join/leave cues for a flapping participant (#145)
Verified first on the local calls stack: a participant who left and rejoined
~3 s later played "leave" then "join" every time. Cues now go through
createCallSoundDebouncer (per USER, not per device):

- a leave cue waits 5 s; if the same user is back before it fires, the leave is
  cancelled and no join cue is played either;
- a join cue is suppressed for a user who left < 60 s ago;
- same-kind cues within 250 ms collapse, so a batch of leaves still sounds once;
- a second device of someone already present is not a new arrival, and a
  device switch is quiet.

Only the sound is debounced; membership UI is unchanged. Sound style, volume
and PTT interplay untouched (the style is read at play time). Timers are
injected — unit-tested with a manual clock. Re-ran the flap scenario headless:
join → flap → (silence) → real leave → one "leave" 5 s later → rejoin within
60 s → silence.

Also enables msc4133 (custom profile fields → in-call avatar decorations) on
the dev Synapse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:15:34 -04:00
jaredandClaude Opus 5 30fd22a5c5 fix(calls): Push to Deafen can be switched off, and a typable deafen key can no longer fire while typing
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m0s
Root cause of the 'went deaf while typing' reports: the deafen key (default
M) and Cinny's type-anywhere-to-focus-the-composer both listen on window,
so the first letter of a message typed after clicking the timeline toggled
deafen and was swallowed (reproduced: typing 'mom' → deafened, composer
shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off
switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles
where no composer is on screen — typing wins; (3) such keys are never
bound system-wide on desktop — only F-keys, numpad and the lock/navigation
cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app
can't deafen you. Verified in live calls: M still toggles in the call
view, is ignored on a chat screen, switch off disables it entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 23:15:00 -04:00
jaredandClaude Opus 5 da2d7a7d4f dev: one-command local calls stack (LiveKit + JWT issuer + voice-limit guard + https well-known) and a seeded voice room
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:29:47 -04:00
325 changed files with 14874 additions and 2261 deletions
+42 -2
View File
@@ -132,11 +132,27 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
runs-on: ubuntu-latest
steps:
# [matrix #9] Debounce: a Tauri build takes ~30 min on the shared runner,
# so at most one bump per DEBOUNCE_MIN. Commits that land inside the
# window are picked up by cinny-desktop's nightly catch-up workflow (or a
# manual dispatch of it) — the desktop cadence no longer tracks every web
# commit. The bump is also skipped when nothing changed.
- name: Bump cinny submodule
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEBOUNCE_MIN: '60'
run: |
CINNY_SHA="${{ github.sha }}"
LAST=$(curl -fsSL -H "Authorization: token $TOKEN" \
"https://code.lotusguild.org/api/v1/repos/LotusGuild/cinny-desktop/commits?sha=main&limit=1&stat=false&verification=false&files=false" \
| python3 -c 'import sys,json; c=json.load(sys.stdin); print(c[0]["commit"]["committer"]["date"] if c else "")' 2>/dev/null || true)
if [ -n "$LAST" ]; then
AGE=$(python3 -c "import sys,datetime; d=datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')); print(int((datetime.datetime.now(datetime.timezone.utc)-d).total_seconds()//60))" "$LAST")
if [ "$AGE" -lt "$DEBOUNCE_MIN" ]; then
echo "Last desktop bump was ${AGE} min ago (< ${DEBOUNCE_MIN}); skipping — the nightly catch-up will pick this up."
exit 0
fi
fi
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
cd desktop
git config user.email "ci@lotusguild.org"
@@ -238,6 +254,8 @@ jobs:
# as m.room.encrypted. Skips itself unless the E2E_* secrets
# below are set (create them under repo → Settings → Actions
# → Secrets; they are empty until then).
# local tier — (#220) regression suite against a Synapse this job starts
# itself; skips itself if that homeserver isn't reachable.
# dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work
# on this Gitea runner (see LOTUS_REFERENCE.md → CI/CD), so `needs: build` only gates on the
# main job having passed, not on its artifact.
@@ -273,8 +291,11 @@ jobs:
sleep $((attempt * 15))
done
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
# [Gitea #221] WebKit too — Playwright's Safari/iOS proxy for the tagged
# subset (see playwright.config.ts projects). `--with-deps` pulls the
# GTK/GStreamer libraries WebKit needs on the runner.
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium webkit
- name: Build
run: npm run build
@@ -282,6 +303,21 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=6144'
VITE_APP_VERSION: ${{ github.sha }}
# ── #220 — tier 3: a throwaway Synapse the job starts itself (SQLite,
# open registration, no rate limits; scripts/dev-homeserver.sh). The
# regression spec (e2e/local-homeserver.spec.ts) registers its own users
# and rooms through the CS API and drives the built client against it —
# no prod secrets involved. If the homeserver fails to come up the spec
# skips itself and the step below still reports why.
- name: Start local homeserver
id: local_hs
continue-on-error: true
run: |
python3 -m venv --help >/dev/null 2>&1 || { (command -v sudo >/dev/null && sudo -n true 2>/dev/null && sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv) || (apt-get update -qq && apt-get install -y -qq python3-venv); }
scripts/dev-homeserver.sh start
python3 scripts/dev-seed.py 20 > .dev-homeserver/seed.json
echo "E2E_LOCAL_HS=http://localhost:8008" >> "$GITHUB_ENV"
- name: Playwright smoke test
run: npm run test:e2e
env:
@@ -289,3 +325,7 @@ jobs:
E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }}
E2E_USER: ${{ secrets.E2E_USER }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
- name: Stop local homeserver
if: always()
run: scripts/dev-homeserver.sh stop || true
+16 -9
View File
@@ -349,15 +349,16 @@ so a stock EC config is byte-for-byte upstream behavior.
**Active (cinny drives them today):**
| # | Feature | Mechanism | Replaces (old hack) |
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
| # | Feature | Mechanism | Replaces (old hack) |
| ---- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
| P6-2 | **Deafen / screenshare-audio mute** | host sends `io.lotus.set_deafen {deafened, screenshareAudioMuted}`; both become the `muted` prop of EC's audio elements (server stops sending), so they hold across re-renders, late joiners and a sharer stopping + re-sharing (fixed in `0.25.0-lotus.4`) | the iframe-DOM `.muted` hack (kept only as a transitional fallback) and `setVolume(0, ScreenShareAudio)`, which EC's own volume controls reset on every new share |
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
**Now wired (cinny drives them — verified end-to-end on the local calls stack, 2026-09-18):**
| # | Capability | Widget action | cinny surface |
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
@@ -365,7 +366,9 @@ so a stock EC config is byte-for-byte upstream behavior.
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
> Both were dormant capabilities; cinny now drives them (armed via
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
> `lotusAudioInject=1`). Verified headless with two clients: a clip publishes as
> an extra `AUDIO/UNKNOWN` track on the SFU and is refused while muted; a room
> cap set mid-call re-sends `io.lotus.set_quality` with the clamped values. The **only** EC item still open is the P5-31
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
> client cap is best-effort.
@@ -1179,6 +1182,10 @@ Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
### Push to Deafen: off switch + typing-safe
**Settings → Calls → Push to Deafen** now has a switch (off = no key toggles deafen; the call-bar headphone button remains). Root cause of the "I went deaf while typing" reports: Cinny's type-anywhere-to-focus-the-composer and the deafen key both listen on `window`, so with the default `M` the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (`mom` → `om`). Rules now: a typable key (letter/digit/Space/…) only toggles deafen in the call view — on any screen with a composer, typing wins; and such keys are never bound system-wide on desktop (`isSafeGlobalToggleKey`: F-keys, numpad, lock/navigation cluster qualify), so the letter `m` typed in Discord or a game can't deafen you either. The tile explains this and suggests an F-key/Numpad key for an everywhere binding.
### Forwarded messages show their provenance
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
+1 -1
View File
@@ -79,7 +79,7 @@ Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** sinc
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.3`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.3`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.12`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.12`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
+12 -5
View File
@@ -19,6 +19,8 @@ python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an
npm start # Vite on :5173
```
**Calls too:** `scripts/dev-homeserver.sh calls` adds a LiveKit SFU, a JWT issuer, the real `voice-limit-guard` from the `matrix` checkout and an https well-known — real two-party calls in headless Chromium with fake mic/camera (`--use-fake-device-for-media-stream`, `--use-file-for-fake-audio-capture=<tone.wav>` to trigger speaking detection, `--auto-select-desktop-capture-source="Entire screen"` for screenshare; `ignoreHTTPSErrors: true`). This is how #29, #161, #173/#174 and the guard's live-revoke bug were found and fixed on 2026-09-18.
Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down.
## Automated coverage map — what the unit tests already pin (2026-07)
@@ -47,12 +49,17 @@ Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgro
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Two tiers:
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Three tiers:
| Tier | File | When it runs | What it proves |
| :------------------------ | :-------------------------- | :----------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
| Tier | File | When it runs | What it proves |
| :-------------------------------- | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script |
**Accessibility gate** (Gitea #222): `e2e/a11y.spec.ts` runs `@axe-core/playwright` (WCAG 2.x A/AA tags) over the login page, room timeline + composer, message options menu, thread panel, user settings and room settings, and fails on any **critical/serious** finding except `color-contrast` (reported in the log, not gated — generated avatar colours and portal false positives). It also keeps accessibility-tree snapshots (`e2e/a11y.spec.ts-snapshots/*.aria.yml`) of the composer, message menu, thread panel and settings nav, so a lost name/role/live-region shows as a diff; update them deliberately with `npx playwright test e2e/a11y --update-snapshots` and keep dynamic bits as regexes. A real NVDA/VoiceOver pass is still manual.
**Browsers** (Gitea #221): everything runs under Chromium; the tests tagged `@webkit` (boot, login + send/receive, thread panel, lightbox) also run under Playwright's WebKit as desktop Safari, and those tagged `@ios` under the `iPhone 14` descriptor — the closest CI gets to Safari/iOS. It catches WebKit-only breakage (CSS, `dvh`, IndexedDB, media decode) but does not emulate the on-screen keyboard or Home-Screen install; a real iPhone pass (#166/#199) stays manual. Locally: `npx playwright install --with-deps webkit` once, then `npx playwright test --project=webkit --project=iphone`. WebKit words handled fetch failures as page errors (`TypeError: Load failed`, `due to access control checks`), so the allowlist in `e2e/helpers.ts` applies to page errors too.
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
+1 -1
View File
@@ -196,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.25.0-lotus.3`, upstream base v0.25.0, source at
(`@lotusguild/element-call-embedded` `0.25.0-lotus.12`, upstream base v0.25.0, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
+4
View File
@@ -1,6 +1,10 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld {
root * /path/to/cinny/dist
# [Gitea #155] PWA share target: the service worker answers this POST; if it
# isn't controlling the page yet, land on /share instead of a 405.
redir /share-target /share 303
try_files {path} /index.html
file_server
+7
View File
@@ -26,6 +26,13 @@ server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
# [Gitea #155] PWA share target. The service worker normally answers this
# POST itself; if it isn't controlling the page yet, land on /share
# (the shared files are lost, but nothing 405s).
location = /share-target {
return 303 /share;
}
location / {
root /opt/cinny/dist/;
+125
View File
@@ -0,0 +1,125 @@
import { test, expect, Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import {
createRoom,
hsReachable,
loginUI,
openRoom,
ensureUser,
sendText,
uniq,
TestUser,
} from './localHs';
// [Gitea #222] Machine-checkable accessibility on every push. Two parts:
// 1. axe-core over the main surfaces, failing on critical/serious findings.
// Colour-contrast is reported but not gated: several hits are generated
// avatar colours and portal false positives (see the issue for the list).
// 2. Accessibility-tree snapshots of the composer, message menu, thread
// panel and settings nav, so a lost name/role/live-region shows up as a
// diff. Update deliberately with `npx playwright test e2e/a11y --update-snapshots`.
// A real screen-reader pass still needs a human.
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
async function auditPage(page: Page, label: string) {
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const describe = (v: (typeof results.violations)[number]) =>
`[${v.impact}] ${v.id}: ${v.help}\n${v.nodes
.slice(0, 5)
.map((n) => ` ${n.html.replace(/\s+/g, ' ').slice(0, 140)}`)
.join('\n')}`;
const contrast = results.violations.filter((v) => v.id === 'color-contrast');
if (contrast.length) {
// eslint-disable-next-line no-console
console.log(`axe ${label}: contrast (not gated)\n${contrast.map(describe).join('\n')}`);
}
const gated = results.violations.filter(
(v) => v.id !== 'color-contrast' && (v.impact === 'critical' || v.impact === 'serious'),
);
expect(gated.map(describe), `axe ${label}: critical/serious findings`).toEqual([]);
}
test.describe('accessibility', () => {
test('login page passes axe @webkit', async ({ page }) => {
await page.goto('/');
await expect(page.getByLabel('Username or email')).toBeVisible();
await auditPage(page, 'login');
});
test.describe('signed in', () => {
let alice: TestUser;
let room: string;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'no local homeserver');
alice = await ensureUser(uniq('e2e_a11y_'));
room = await createRoom(alice, 'A11y Room');
const root = await sendText(alice, room, 'thread root for a11y');
await sendText(alice, room, 'a reply', {
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 850 });
await loginUI(page, alice);
await openRoom(page, room);
await expect(page.getByText('thread root for a11y')).toBeVisible();
});
test('room timeline + composer', async ({ page }) => {
await auditPage(page, 'room');
await expect(page.locator('[data-slate-editor]').first()).toMatchAriaSnapshot({
name: 'composer-editor.aria.yml',
});
await expect(page.getByRole('button', { name: 'Send message' })).toMatchAriaSnapshot({
name: 'composer-send.aria.yml',
});
});
test('message options menu', async ({ page }) => {
const msg = page.locator('[data-message-item]', { hasText: 'thread root for a11y' });
await msg.hover();
await msg.getByRole('button', { name: 'More options' }).click();
const menu = page.locator('[data-message-menu]').first();
await expect(menu).toBeVisible();
await auditPage(page, 'message menu');
await expect(menu).toMatchAriaSnapshot({ name: 'message-menu.aria.yml' });
});
test('thread panel', async ({ page }) => {
await page
.locator('[data-message-item]', { hasText: 'thread root for a11y' })
.getByText(/1 reply/)
.click();
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
await auditPage(page, 'thread panel');
await expect(page.getByRole('complementary').first()).toMatchAriaSnapshot({
name: 'thread-panel.aria.yml',
});
});
test('user settings', async ({ page }) => {
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
await expect(dialog).toBeVisible();
await auditPage(page, 'settings');
await expect(dialog.getByRole('navigation').first()).toMatchAriaSnapshot({
name: 'settings-nav.aria.yml',
});
});
test('room settings', async ({ page }) => {
await page.getByRole('button', { name: 'More Options' }).first().click();
await page.getByText('Room Settings', { exact: true }).click();
await expect(page.getByRole('dialog').first()).toBeVisible();
await auditPage(page, 'room settings');
});
});
});
@@ -0,0 +1,2 @@
- textbox "Send a message...":
- paragraph: Send a message...
@@ -0,0 +1,2 @@
- button "Send message":
- img
@@ -0,0 +1,39 @@
- button "Add Reaction":
- text: ''
- img
- button "Reply":
- text: ''
- img
- button "Forward":
- text: ''
- img
- button "Bookmark Message":
- text: ''
- img
- button "Remind Me":
- text: ''
- img
- button "Edit Message":
- text: ''
- img
- button "Read Receipts":
- text: ''
- img
- button "Copy Text":
- text: ''
- img
- button "Translate":
- text: ''
- img
- button "Copy Link":
- text: ''
- img
- button "Copy Lotus Link":
- text: ''
- img
- button "Pin Message":
- text: ''
- img
- button "Delete":
- text: ''
- img
@@ -0,0 +1,22 @@
- navigation "Settings sections":
- button "General" [pressed]:
- img
- paragraph: General
- button "Account":
- img
- paragraph: Account
- button "Notifications":
- img
- paragraph: Notifications
- button "Devices":
- img
- paragraph: Devices
- button "Emojis & Stickers":
- img
- paragraph: Emojis & Stickers
- button "Developer Tools":
- img
- paragraph: Developer Tools
- button "About":
- img
- paragraph: About
@@ -0,0 +1,31 @@
- complementary "Thread":
- paragraph: Thread
- paragraph: /A11y Room · started by e2e_a11y_\w+, .+/
- button "Thread notifications":
- img
- button "Close thread":
- img
- log "Thread timeline":
- article:
- button /e2e_a11y_\w+, open profile/:
- img
- button /e2e_a11y_\w+/
- time: /\d+:\d+ (AM|PM)/
- text: thread root for a11y
- paragraph: 1 reply
- article:
- button /e2e_a11y_\w+, open profile/:
- img
- button /e2e_a11y_\w+/
- time: /\d+:\d+ (AM|PM)/
- text: a reply
- button "More actions":
- img
- textbox "Send a message...":
- paragraph: Send a message...
- button "Insert sticker":
- img
- button "Insert emoji":
- img
- button "Send message":
- img
+2 -2
View File
@@ -5,7 +5,7 @@ import { collectConsole } from './helpers';
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
test.describe('boot', () => {
test('client boots to the login screen without errors', async ({ page }) => {
test('client boots to the login screen without errors @webkit @ios', async ({ page }) => {
const consoleLog = collectConsole(page);
await page.goto('/');
@@ -24,7 +24,7 @@ test.describe('boot', () => {
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
});
test('service worker script is served and registers', async ({ page }) => {
test('service worker script is served and registers @webkit @ios', async ({ page }) => {
const swResponse = await page.request.get('/sw.js');
expect(swResponse.status(), 'GET /sw.js').toBe(200);
expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/);
+14 -1
View File
@@ -14,6 +14,15 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
// Homeserver discovery pings can fail on a runner with no outbound network.
/\/\.well-known\/matrix\/client/i,
/Failed to fetch|NetworkError|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED/i,
// Tier 3 (local homeserver named "localhost"): the client's well-known
// autodiscovery probes https://localhost/, which is either not listening
// (CI) or a self-signed dev server (local calls stack).
/ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i,
// WebKit's spellings of the same discovery failures (#221).
/Unacceptable TLS certificate|Could not connect to|Connection refused|TypeError: Load failed/i,
// Also a fetch cut short by our own navigation (e.g. the crypto wasm while
// the test moves from /home to a room) — WebKit words that the same way.
/due to access control checks/i,
// React devtools hint in production bundles.
/Download the React DevTools/i,
];
@@ -41,8 +50,12 @@ export function collectConsole(page: Page): ConsoleCollector {
return {
errors,
pageErrors,
// WebKit surfaces handled fetch failures (well-known probes) as page
// errors rather than console lines, so the allowlist applies to both.
unexpected: () => [
...pageErrors.map((m) => `pageerror: ${m}`),
...pageErrors
.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m)))
.map((m) => `pageerror: ${m}`),
...errors.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m))),
],
};
+285
View File
@@ -0,0 +1,285 @@
import { test, expect, devices } from '@playwright/test';
import {
HS,
api,
createRoom,
enc,
ensureUser,
hsReachable,
joinRoom,
loginUI,
openRoom,
sendText,
uniq,
TestUser,
} from './localHs';
import { collectConsole } from './helpers';
// Tier 3 — regression suite against the job's own Synapse (Gitea #220).
// Each test seeds what it needs through the CS API and drives the built
// client; nothing here touches a real deployment.
test.describe('local homeserver regression', () => {
let alice: TestUser;
let bob: TestUser;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
alice = await ensureUser(uniq('e2e_alice_'));
bob = await ensureUser(uniq('e2e_bob_'));
});
test('logs in, opens a room, sends and receives @webkit @ios', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Regression Room', { invite: [bob.userId] });
await joinRoom(bob, room);
await sendText(bob, room, 'hello from bob');
await loginUI(page, alice);
await openRoom(page, room);
await expect(page.getByText('hello from bob')).toBeVisible();
await page.locator('[data-slate-editor]').first().click();
await page.keyboard.type('hello from alice');
await page.keyboard.press('Enter');
await expect(page.getByText('hello from alice')).toBeVisible();
await expect
.poll(
async () =>
(
await api<{ chunk: { content: { body: string } }[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=1`,
bob.token,
)
).chunk[0]?.content.body,
)
.toBe('hello from alice');
expect(console_.unexpected()).toEqual([]);
});
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
const room = await createRoom(alice, 'Scroll Room');
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
(chain, _, i) => chain.then(() => sendText(alice, room, `filler ${i}`)),
Promise.resolve(),
);
await loginUI(page, alice);
await openRoom(page, room);
await page.mouse.move(600, 350);
await page.mouse.wheel(0, -600);
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible();
await page.locator('[data-slate-editor]').first().click();
await page.keyboard.type('sent while scrolled up');
await page.keyboard.press('Enter');
await expect(page.getByText('sent while scrolled up')).toBeInViewport();
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toHaveCount(0);
});
test('/kick failure is reported, not swallowed (#216)', async ({ page }) => {
const room = await createRoom(alice, 'Kick Room', { invite: [bob.userId] });
await joinRoom(bob, room);
await loginUI(page, bob);
await openRoom(page, room);
const editor = page.locator('[data-slate-editor]').first();
await editor.click();
await page.keyboard.type('/kick', { delay: 40 });
await page.keyboard.press('Tab'); // accept the command chip
await page.keyboard.type(` ${alice.userId}`, { delay: 20 });
await page.keyboard.press('Enter');
await expect(page.getByText(/Could not kick .*You cannot kick/)).toBeVisible();
});
test('upload failure shows a plain sentence, never the raw MatrixError (#213)', async ({
page,
}) => {
const room = await createRoom(alice, 'Upload Room');
await loginUI(page, alice);
await openRoom(page, room);
await page.route(/\/_matrix\/media\/v3\/upload/, (route) =>
route.fulfill({
status: 413,
contentType: 'application/json',
body: JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'nope' }),
}),
);
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Attach file' }).first().click();
await (
await chooser
).setFiles({
name: 'pic.png',
mimeType: 'image/png',
buffer: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
),
});
const send = page.getByRole('button', { name: /^Send$/ });
if (await send.count()) await send.first().click();
else await page.getByRole('button', { name: 'Send message' }).click();
await expect(page.getByText(/This file is larger than the server allows/)).toBeVisible();
await expect(page.getByText(/MatrixError|_matrix\/media/)).toHaveCount(0);
});
test('forwarded message carries its provenance header', async ({ page }) => {
const src = await createRoom(bob, 'Source Room', { invite: [alice.userId] });
const dst = await createRoom(alice, 'Destination Room');
await joinRoom(alice, src);
await sendText(bob, src, 'the original message');
await loginUI(page, alice);
await openRoom(page, src);
const msg = page.locator('[data-message-item]', { hasText: 'the original message' }).first();
await msg.hover();
await msg.getByRole('button', { name: 'More options' }).click();
await page.getByText('Forward', { exact: true }).click();
const search = page.getByPlaceholder('Search rooms…');
await search.fill('Destination Room');
await page.locator('button', { hasText: 'Destination Room' }).first().click();
await page.getByRole('button', { name: /^Send to 1 room/ }).click();
await expect(page.getByText(/Forwarded to/)).toBeVisible();
await openRoom(page, dst);
await expect(page.getByText(/Forwarded from .* in Source Room/)).toBeVisible();
await expect(page.getByText('the original message')).toBeVisible();
});
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218) @webkit', async ({
browser,
}) => {
const ctx = await browser.newContext({ viewport: { width: 1400, height: 850 } });
const page = await ctx.newPage();
const room = await createRoom(alice, 'Thread Room');
const root = await sendText(alice, room, 'thread root');
await sendText(alice, room, 'a reply', {
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
await loginUI(page, alice);
await openRoom(page, room);
await page
.locator('[data-message-item]', { hasText: 'thread root' })
.getByText(/1 reply/)
.click();
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
const widths = await page
.locator('[data-slate-editor]')
.evaluateAll((els) => els.map((e) => e.getBoundingClientRect().width));
expect(Math.min(...widths)).toBeGreaterThan(120);
await ctx.close();
});
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
const room = await createRoom(alice, 'Lightbox Room');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const up = await fetch(`${HS}/_matrix/media/v3/upload?filename=a.png`, {
method: 'POST',
headers: { Authorization: `Bearer ${alice.token}`, 'Content-Type': 'image/png' },
body: png,
}).then((r) => r.json() as Promise<{ content_uri: string }>);
await api(
'PUT',
`/_matrix/client/v3/rooms/${enc(room)}/send/m.room.message/img1`,
alice.token,
{
msgtype: 'm.image',
body: 'a.png',
url: up.content_uri,
info: { mimetype: 'image/png', size: png.length, w: 1, h: 1 },
},
);
await loginUI(page, alice);
await openRoom(page, room);
await page.locator('[data-message-item] img[alt="a.png"]').click();
const viewer = page.getByRole('dialog', { name: 'Media viewer' });
await expect(viewer).toBeVisible();
await expect(viewer.getByText('1 / 1')).toBeVisible();
await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
});
test('warns when the local clock is far off the server (#158)', async ({ page }) => {
const room = await createRoom(alice, 'Skew Room', { invite: [bob.userId] });
await joinRoom(bob, room);
await page.clock.install({ time: Date.now() + 14 * 60 * 1000 });
await loginUI(page, alice);
await openRoom(page, room);
for (let i = 0; i < 4; i += 1) {
// eslint-disable-next-line no-await-in-loop
await sendText(bob, room, `tick ${i}`);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(500);
}
await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible();
await page.getByRole('button', { name: 'Dismiss for 24 h' }).click();
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
});
test('status save survives the presence rate limit (#226)', async ({ page }) => {
let lastOk = 0;
await page.route(/\/presence\/[^/]+\/status/, (route) => {
const now = Date.now();
if (now - lastOk < 10_000) {
return route.fulfill({
status: 429,
contentType: 'application/json',
body: JSON.stringify({
errcode: 'M_LIMIT_EXCEEDED',
error: 'Too Many Requests',
retry_after_ms: 10_000 - (now - lastOk),
}),
});
}
lastOk = now;
return route.continue();
});
await loginUI(page, alice);
await page
.locator('button', { hasText: new RegExp(`^${alice.localpart[0]}$`) })
.first()
.click();
await page.getByText('Account', { exact: true }).first().click();
const input = page.getByLabel('Status message');
const status = uniq('status ');
await input.fill(status);
await input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(/Failed to save status/)).toHaveCount(0);
await expect
.poll(
async () =>
(
await api<{ status_msg?: string }>(
'GET',
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
alice.token,
)
).status_msg,
{ timeout: 30_000 },
)
.toBe(status);
});
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
const page = await ctx.newPage();
const room = await createRoom(alice, 'Touch Room');
await sendText(alice, room, 'press and hold me');
await loginUI(page, alice);
await openRoom(page, room);
const msg = page.locator('[data-message-item]', { hasText: 'press and hold me' });
const box = (await msg.boundingBox())!;
const cdp = await ctx.newCDPSession(page);
const x = box.x + 100;
const y = box.y + box.height / 2;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
await page.waitForTimeout(700);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const sheet = page.getByRole('dialog', { name: 'Message actions' });
await expect(sheet).toBeVisible();
await expect(sheet.getByText('Reply', { exact: true })).toBeVisible();
await ctx.close();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { Page, expect } from '@playwright/test';
/**
* Tier 3 (Gitea #220): regression tests against a throwaway Synapse the job
* starts itself (`scripts/dev-homeserver.sh start`). No prod secrets. Set
* E2E_LOCAL_HS (default http://localhost:8008) — the spec skips when the
* server isn't reachable.
*/
export const HS = process.env.E2E_LOCAL_HS || 'http://localhost:8008';
export const PASSWORD = 'password123';
export const enc = encodeURIComponent;
export async function hsReachable(): Promise<boolean> {
try {
const res = await fetch(`${HS}/_matrix/client/versions`);
return res.ok;
} catch {
return false;
}
}
export async function api<T = Record<string, unknown>>(
method: string,
path: string,
token?: string,
body?: unknown,
): Promise<T> {
const res = await fetch(`${HS}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return (await res.json()) as T;
}
export type TestUser = { localpart: string; userId: string; token: string };
/** Registers (open registration) or logs in a test user. */
export async function ensureUser(localpart: string): Promise<TestUser> {
const reg = await api<{ user_id?: string; access_token?: string }>(
'POST',
'/_matrix/client/v3/register',
undefined,
{ username: localpart, password: PASSWORD, auth: { type: 'm.login.dummy' } },
);
if (reg.access_token && reg.user_id) {
return { localpart, userId: reg.user_id, token: reg.access_token };
}
const login = await api<{ user_id: string; access_token: string }>(
'POST',
'/_matrix/client/v3/login',
undefined,
{
type: 'm.login.password',
identifier: { type: 'm.id.user', user: localpart },
password: PASSWORD,
},
);
return { localpart, userId: login.user_id, token: login.access_token };
}
export async function createRoom(
owner: TestUser,
name: string,
opts: { invite?: string[]; preset?: string } = {},
): Promise<string> {
const res = await api<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', owner.token, {
name,
preset: opts.preset ?? 'public_chat',
invite: opts.invite,
});
return res.room_id;
}
export async function joinRoom(user: TestUser, roomId: string): Promise<void> {
await api('POST', `/_matrix/client/v3/rooms/${enc(roomId)}/join`, user.token, {});
}
let txn = 0;
export async function sendText(
user: TestUser,
roomId: string,
body: string,
extra: Record<string, unknown> = {},
): Promise<string> {
txn += 1;
const res = await api<{ event_id: string }>(
'PUT',
`/_matrix/client/v3/rooms/${enc(roomId)}/send/m.room.message/e2e${Date.now()}_${txn}`,
user.token,
{ msgtype: 'm.text', body, ...extra },
);
return res.event_id;
}
/** Password login through the UI against the local homeserver. */
export async function loginUI(page: Page, user: TestUser): Promise<void> {
await page.goto(`/login/${enc(HS)}/`);
await page.getByLabel('Username or email').fill(user.localpart);
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL(/\/home/, { timeout: 60_000 });
}
export async function openRoom(page: Page, roomId: string): Promise<void> {
await page.goto(`/home/${enc(roomId)}`);
await expect(page.locator('[data-slate-editor]').first()).toBeVisible({ timeout: 30_000 });
}
export const uniq = (prefix: string): string =>
`${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
-3
View File
@@ -27,9 +27,6 @@
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
+22 -7
View File
@@ -80,7 +80,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.3",
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.16",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -172,6 +173,19 @@
"@babel/runtime": "^7.0.0"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.13.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2681,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.3",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.3/element-call-embedded-0.25.0-lotus.3.tgz",
"integrity": "sha512-UjOi8DXosVjBeVb5SiPNFApJSI9gggpU67xAUn0EWevD3oj9wQFhtjdMkxGbhnQ7B9r6QEUivDGWsBpsfKe0/w==",
"version": "0.25.0-lotus.16",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.16/element-call-embedded-0.25.0-lotus.16.tgz",
"integrity": "sha512-X/MzZ2Ip7IpZD3B+SkBoBzgMfURyZuYD6OaGL9r7sw2nl7Y/0JzgpT8RWV12lu4DlOfWbbeO+ka1l+pTYVewdQ==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
@@ -4847,10 +4861,11 @@
}
},
"node_modules/axe-core": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
"integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
+4 -3
View File
@@ -12,13 +12,13 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 68",
"check:eslint": "eslint src/* --max-warnings 36",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install chromium",
"test:e2e:install": "playwright install chromium webkit",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -107,7 +107,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.3",
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.16",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+10 -1
View File
@@ -25,7 +25,16 @@ export default defineConfig({
trace: 'retain-on-failure',
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// [Gitea #221] WebKit as the Safari/iOS proxy. It runs the subset tagged
// @webkit / @ios (boot, composer round-trip, thread panel, lightbox): it
// catches WebKit-only breakage (CSS, dvh, IndexedDB, media decode) that
// Chromium emulation can't, but does not emulate the iOS keyboard or the
// Home-Screen install flow.
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, grep: /@webkit/ },
{ name: 'iphone', use: { ...devices['iPhone 14'] }, grep: /@ios/ },
],
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: BASE_URL,
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
Binary file not shown.
Binary file not shown.
+22
View File
@@ -49,3 +49,25 @@
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* VT323 (OFL) for the Lotus Terminal theme — was Google Fonts (Gitea #214) */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/VT323-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/VT323-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+16
View File
@@ -69,6 +69,22 @@
}
],
"categories": ["social", "communication", "productivity"],
"share_target": {
"action": "./share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "video/*", "audio/*", "application/pdf", "text/plain"]
}
]
}
},
"shortcuts": [
{
"name": "New Message",
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

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+60 -3
View File
@@ -4,9 +4,19 @@
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
#
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
# scripts/dev-homeserver.sh calls # + LiveKit SFU, JWT issuer, voice-limit guard, https well-known
# scripts/dev-homeserver.sh stop
# scripts/dev-homeserver.sh reset # wipe the database and media
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images (+ "Voice Lounge" call room)
#
# Calls: `calls` downloads livekit-server (v1.13.7 release binary) into
# .dev-homeserver/, runs it on :7880 with key devkey, a minimal lk-jwt-service
# clone (scripts/dev-lk-jwt.py, :8071), the real voice-limit-guard from the
# sibling matrix repo in front of it (:8070, needs ../matrix checked out and an
# admin user — dev-seed makes alice one), and a self-signed https server on
# :443 serving /.well-known/matrix/client with the LiveKit focus (the client
# autodiscovers the server NAME, not :8008). Drive it with Playwright using
# --use-fake-device-for-media-stream and ignoreHTTPSErrors.
#
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
# as alice / password123.
@@ -32,7 +42,7 @@ install() {
python3 - "$CFG" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
s = s.replace(" - client\n", " - client\n - media\n", 1)
s = s.replace(" - client\n", " - client\n - media\n - federation\n", 1)
s += """
enable_registration: true
enable_registration_without_verification: true
@@ -46,10 +56,17 @@ suppress_key_server_warning: true
# URL previews (embed facades use the homeserver's cached thumbnail)
url_preview_enabled: true
url_preview_ip_range_blacklist: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '::1/128', 'fe80::/10', 'fc00::/7']
# scheduled messages (MSC4140 delayed events)
# scheduled messages (MSC4140 delayed events) + MatrixRTC transports (MSC4143) + room summaries (MSC3266)
max_event_delay_duration: 24h
experimental_features:
msc4140_enabled: true
msc4143_enabled: true
msc3266_enabled: true
msc4133_enabled: true
matrix_rtc:
transports:
- type: livekit
livekit_service_url: http://127.0.0.1:8070
"""
open(p, "w").write(s)
PY
@@ -72,10 +89,50 @@ start() {
stop() {
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
for f in "$DIR"/calls-*.pid; do [ -f "$f" ] && { kill "$(cat "$f")" 2>/dev/null || true; rm -f "$f"; }; done
}
calls() {
start
local LK="$DIR/livekit-server"
if [ ! -x "$LK" ]; then
curl -sL "https://github.com/livekit/livekit/releases/download/v1.13.7/livekit_1.13.7_linux_amd64.tar.gz" | tar xz -C "$DIR" livekit-server
fi
cat > "$DIR/livekit.yaml" <<'YAML'
port: 7880
bind_addresses: ["127.0.0.1"]
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: false
node_ip: 127.0.0.1
keys:
devkey: devsecretdevsecretdevsecretdevsecret
room:
auto_create: true
YAML
local bg
bg() { local name="$1"; shift; ( setsid nohup "$@" > "$DIR/calls-$name.log" 2>&1 < /dev/null & echo $! > "$DIR/calls-$name.pid" ); }
bg livekit "$LK" --config "$DIR/livekit.yaml"
bg jwt env PORT=8071 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py"
if [ ! -f "$DIR/wk.crt" ]; then openssl req -x509 -newkey rsa:2048 -nodes -keyout "$DIR/wk.key" -out "$DIR/wk.crt" -days 365 -subj "/CN=localhost" 2>/dev/null; fi
bg wellknown env CERT="$DIR/wk.crt" KEY="$DIR/wk.key" python3 "$ROOT/scripts/dev-wellknown.py"
local GUARD="$ROOT/../matrix/livekit/voice-limit-guard.py"
local TOKEN_FILE="$DIR/admin.token"
if [ -f "$GUARD" ] && [ -f "$TOKEN_FILE" ]; then
bg guard env GUARD_BIND_HOST=127.0.0.1 GUARD_BIND_PORT=8070 GUARD_UPSTREAM=http://127.0.0.1:8071 LIVEKIT_API=http://127.0.0.1:7880 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret SYNAPSE_API=http://127.0.0.1:8008 MATRIX_TOKEN="$(cat "$TOKEN_FILE")" python3 "$GUARD"
else
echo "voice-limit-guard not started (need ../matrix checkout and $TOKEN_FILE from dev-seed.py); pointing the focus straight at the issuer"
bg guard env PORT=8070 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py"
fi
sleep 2
echo "calls stack up: livekit :7880, jwt :8071, guard :8070, well-known https://localhost/.well-known/matrix/client"
}
case "${1:-}" in
start) start ;;
calls) calls ;;
stop) stop ;;
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Minimal lk-jwt-service clone for local testing.
POST /sfu/get {room, openid_token{access_token, matrix_server_name}, device_id}
-> validates the OpenID token against Synapse (federation openid/userinfo),
mints a LiveKit JWT for identity "<user_id>:<device_id>" and returns {url, jwt}.
"""
import base64, hashlib, hmac, json, os, time, urllib.parse, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("PORT", "8071"))
LK_KEY = os.environ.get("LIVEKIT_KEY", "devkey")
LK_SECRET = os.environ.get("LIVEKIT_SECRET", "devsecretdevsecretdevsecretdevsecret")
LK_URL = os.environ.get("LIVEKIT_URL", "ws://127.0.0.1:7880")
SYNAPSE = os.environ.get("SYNAPSE_API", "http://127.0.0.1:8008")
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def jwt(payload):
h = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()); p = b64(json.dumps(payload).encode())
sig = b64(hmac.new(LK_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest())
return f"{h}.{p}.{sig}"
def userinfo(access_token):
q = urllib.parse.urlencode({"access_token": access_token})
with urllib.request.urlopen(f"{SYNAPSE}/_matrix/federation/v1/openid/userinfo?{q}", timeout=5) as r:
return json.load(r)["sub"]
class H(BaseHTTPRequestHandler):
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
def do_OPTIONS(self):
self.send_response(204); self._cors(); self.end_headers()
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
data = json.loads(self.rfile.read(n) or b"{}")
room = data.get("room") or data.get("room_id") or ""
oid = data.get("openid_token") or {}
try:
user = userinfo(oid.get("access_token", ""))
except Exception as e: # noqa
self.send_response(401); self._cors(); self.end_headers(); self.wfile.write(json.dumps({"errcode": "M_LOOKUP_FAILED", "error": str(e)}).encode()); return
identity = f"{user}:{data.get('device_id', '')}"
now = int(time.time())
token = jwt({"iss": LK_KEY, "sub": identity, "name": user, "nbf": now - 10, "exp": now + 3600,
"video": {"room": room, "roomJoin": True, "roomCreate": True, "canPublish": True, "canSubscribe": True, "canPublishData": True}})
body = json.dumps({"url": LK_URL, "jwt": token}).encode()
self.send_response(200); self._cors(); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
print(f"issued {identity} room={room}", flush=True)
def log_message(self, *a): pass
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
+24 -5
View File
@@ -1,11 +1,15 @@
#!/usr/bin/env python3
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users
and a busy unencrypted room: alice + bob, "Busy Room", N messages, one image
every 10th message. Idempotent for the users; every run creates a new room.
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users,
a busy unencrypted room and a voice room: alice + bob, "Busy Room" (N
messages, one image every 10th), "Voice Lounge" (org.matrix.msc3417.call with
the call-member power level Lotus applies). Makes alice a server admin (the
voice-limit guard reads room state through the admin API) and writes her
token to .dev-homeserver/admin.token for `dev-homeserver.sh calls`.
Idempotent for the users; every run creates new rooms.
python3 scripts/dev-seed.py [N=400]
"""
import json, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
import json, os, sqlite3, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
HS = "http://127.0.0.1:8008"
PASSWORD = "password123"
@@ -56,7 +60,22 @@ def main():
content = {"msgtype": "m.text", "body": f"message #{i}"}
txn += 1
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
print(json.dumps({"room": room, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
voice = req("POST", "/_matrix/client/v3/createRoom", {
"name": "Voice Lounge", "preset": "public_chat",
"creation_content": {"type": "org.matrix.msc3417.call"},
"initial_state": [{"type": "org.matrix.msc3401.call", "state_key": "", "content": {}}],
"power_level_content_override": {"events": {"org.matrix.msc3401.call.member": 0}},
}, ta)["room_id"]
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(voice)}", {}, tb)
# admin flag for the guard (takes effect for tokens issued after a Synapse restart-free
# cache miss; the guard only needs it for the admin state API)
here = os.path.dirname(os.path.abspath(__file__))
db = os.path.join(here, "..", ".dev-homeserver", "homeserver.db")
if os.path.exists(db):
c = sqlite3.connect(db); c.execute("UPDATE users SET admin=1 WHERE name=?", (alice["user_id"],)); c.commit(); c.close()
with open(os.path.join(here, "..", ".dev-homeserver", "admin.token"), "w") as f:
f.write(ta)
print(json.dumps({"room": room, "voice_room": voice, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
if __name__ == "__main__":
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Self-signed https server on :443 serving the client well-known with the
LiveKit focus for the local calls stack (see scripts/dev-homeserver.sh calls)."""
import http.server, os, ssl, json
BODY = json.dumps({"m.homeserver": {"base_url": "http://localhost:8008"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "http://127.0.0.1:8070"}]}).encode()
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith('/.well-known/matrix/client'):
self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Content-Length',str(len(BODY))); self.end_headers(); self.wfile.write(BODY)
else:
self.send_response(404); self.end_headers()
def log_message(self,*a): pass
srv = http.server.ThreadingHTTPServer(('127.0.0.1', 443), H)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain(os.environ.get('CERT', 'wk.crt'), os.environ.get('KEY', 'wk.key')); srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
srv.serve_forever()
+112 -10
View File
@@ -34,18 +34,27 @@ import {
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
import { useCallEndedToast } from '../hooks/useCallEndedToast';
import { useCallRejoin } from '../hooks/useCallRejoin';
import { usePttHaptics } from '../hooks/usePttHaptics';
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
import { callAnnouncementAtom } from '../state/callAnnouncement';
import { SrOnly } from '../features/call-status/styles.css';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
@@ -113,6 +122,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported;
const { room } = info;
// [Gitea #135] Answer follows cameraOnJoin: say so, and offer the audio-only
// alternative, rather than putting the user on video without warning.
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
const willAnswerWithCamera = info.intent === 'video' && !!cameraOnJoinPref;
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
@@ -167,7 +180,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)}
@@ -218,6 +239,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
</Text>
)}
<Box direction="Column" gap="300">
{willAnswerWithCamera && (
<Text size="T200" align="Center" priority="300">
Answering turns your camera on.
</Text>
)}
<Button
style={{ flexGrow: 1 }}
variant="Success"
@@ -234,9 +260,25 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
disabled={!canAnswer}
>
<Text as="span" size="B400">
Answer
{willAnswerWithCamera ? 'Answer with camera' : 'Answer'}
</Text>
</Button>
{willAnswerWithCamera && (
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Soft"
size="400"
radii="400"
onClick={() => onAnswer(room, false)}
before={<Icon size="200" src={Icons.Phone} filled />}
disabled={!canAnswer}
>
<Text as="span" size="B400">
Answer without camera
</Text>
</Button>
)}
<Button
style={{ flexGrow: 1 }}
variant={dm ? 'Critical' : 'Secondary'}
@@ -278,6 +320,8 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
const useAuthentication = useMediaAuthentication();
const { room } = info;
const isVideo = info.intent === 'video';
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
const bannerCameraOn = isVideo && !!cameraOnJoinPref;
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
@@ -376,7 +420,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
</Text>
</Box>
</Box>
<Box gap="200">
<Box gap="200" wrap="Wrap">
<Button
style={{ flexGrow: 1 }}
variant="Success"
@@ -385,11 +429,27 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
radii="300"
onClick={() => onAnswer(room, isVideo)}
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
title={bannerCameraOn ? 'Answering turns your camera on' : undefined}
>
<Text as="span" size="B300">
Answer
{bannerCameraOn ? 'Answer with camera' : 'Answer'}
</Text>
</Button>
{bannerCameraOn && (
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Soft"
size="300"
radii="300"
onClick={() => onAnswer(room, false)}
before={<Icon size="100" src={Icons.Phone} filled />}
>
<Text as="span" size="B300">
Answer without camera
</Text>
</Button>
)}
<Button
style={{ flexGrow: 1 }}
variant={dm ? 'Critical' : 'Secondary'}
@@ -422,6 +482,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const setCallEmbed = useSetAtom(callEmbedAtom);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
@@ -623,16 +684,26 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
async (room: Room, video: boolean) => {
setCallInfo(undefined);
// [Gitea #195] Answering from the in-call banner is the one call-to-call
// switch path. Disposing the current embed alone leaves our ghost
// `m.call.member` in the old room for ~17 s (until the delayed leave
// expires) — hang up explicitly and wait for the membership to clear.
if (callEmbed?.joined) {
await hangupCallAndWait(mx, callEmbed);
// Dispose it ourselves now: its HangupCall echo would otherwise land
// after startCall() and clear the NEW embed from the atom.
setCallEmbed(undefined);
}
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
// forcing camera+mic+sound on — every other join path does this, and
// Answer was skipping it, publishing the camera with no prescreen.
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
startCall(room, { microphone, video: cameraOnJoin && video, sound });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
[startCall, navigateRoom, microphone, sound, cameraOnJoin, callEmbed, mx, setCallEmbed],
);
if (!callInfo) return null;
@@ -664,6 +735,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
}
// [Gitea #168] Lives outside the embed so "Call ended" is still announced.
function CallAnnouncementRegion() {
const announcement = useAtomValue(callAnnouncementAtom);
return (
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
{announcement}
</span>
);
}
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@@ -673,8 +754,13 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
// the mobile in-call chat. Both are gated on `joined`.
useCallHotkeys(embed, joined);
useAfkAutoMute(joined ? embed : undefined);
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
usePttHaptics();
useCallAnnouncements(embed, joined);
useMutedTalkWarning(embed, joined);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
@@ -769,6 +855,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
type CallEmbedProviderProps = {
children?: ReactNode;
};
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
useCallRejoin(callEmbed, joined);
return null;
}
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
@@ -1218,8 +1310,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
<CallAnnouncementRegion />
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
<CallRejoin callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div
@@ -1231,7 +1325,15 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
left: 0,
width: '100%',
height: '50%',
...(callVisible && !pipMode ? wallpaperStyle : {}),
// [Gitea #224] The fork renders EC transparent (lotusTransparent) so the
// wallpaper shows through the full-size embed; in PiP the thing behind
// the frame is whatever room you navigated to, so give it an opaque
// surface instead of letting the timeline bleed through the tiles.
...(pipMode
? { background: color.Surface.Container }
: callVisible
? wallpaperStyle
: {}),
}}
ref={callEmbedRef}
>
+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>
+27 -10
View File
@@ -19,7 +19,7 @@ import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
import { useSensitiveCopy } from '../hooks/useSensitiveCopy';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
@@ -232,9 +232,8 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => {
copyToClipboard(recoveryKey);
};
// [Gitea #156] The key leaves the clipboard again after 60 s, visibly.
const { copy: handleCopy, secondsLeft } = useSensitiveCopy(recoveryKey);
const handleDownload = () => {
const blob = new Blob([recoveryKey], {
@@ -272,8 +271,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
<Button onClick={handleCopy} aria-live="polite">
<Text size="B400">
{secondsLeft !== null ? `Copied · clears in ${secondsLeft} s` : 'Copy'}
</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text>
@@ -292,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}`,
@@ -302,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>
@@ -330,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}`,
@@ -340,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>
+36 -5
View File
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box, color, config } from 'folds';
import { TapToSendBar } from './tap-to-send/TapToSendBar';
import { useRecentTouch } from '../hooks/useRecentTouch';
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
const sendGif = useCallback(
(gif: RecentGif) => {
setRecents((prev) => addRecentGif(prev, gif));
const { url, width, height, previewUrl } = gif;
setRecents((prev) => addRecentGif(prev, { url, width, height, previewUrl }));
onSelect(gif.url, gif.width, gif.height);
requestClose();
},
[onSelect, requestClose, setRecents],
);
// [Gitea #147] Touch: first tap parks the GIF in a preview bar, second tap
// (or Send) sends. Mouse/keyboard/screen reader: one step, as before.
const containerRef = useRef<HTMLDivElement>(null);
const { wasTouch } = useRecentTouch(containerRef);
const [pending, setPending] = useState<(RecentGif & { title?: string }) | undefined>();
const pick = useCallback(
(gif: RecentGif & { title?: string }) => {
if (wasTouch() && pending?.url !== gif.url) {
setPending(gif);
return;
}
setPending(undefined);
sendGif(gif);
},
[wasTouch, pending, sendGif],
);
const handleClick = useCallback(
(gif: IGif, e: React.SyntheticEvent) => {
e.preventDefault();
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
gif.images.fixed_width_small_still?.url ??
gif.images.downsized_still?.url ??
gif.images.original_still?.url;
sendGif({
pick({
url: r.url,
width: Number(r.width) || 200,
height: Number(r.height) || 200,
previewUrl,
title: gif.title,
});
},
[sendGif],
[pick],
);
const showRecents = recents.length > 0 && !(term ?? '').trim();
@@ -150,7 +171,6 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
// narrower than 312px with a fixed 296px grid.
const containerRef = useRef<HTMLDivElement>(null);
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
<SearchBar />
</div>
</Box>
{pending && (
<TapToSendBar
previewUrl={pending.previewUrl ?? pending.url}
label={pending.title || 'GIF'}
onSend={() => {
setPending(undefined);
sendGif(pending);
}}
onCancel={() => setPending(undefined)}
/>
)}
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
{showRecents && (
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
)}
<Grid
key={searchKey}
+1 -1
View File
@@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src,
+13 -26
View File
@@ -1,11 +1,10 @@
import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { useAtom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix';
import { callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
@@ -20,27 +19,6 @@ import {
* membership has actually been removed from the room, so the leave reaches
* the homeserver before the client is stopped and the token is revoked.
*/
const hangupAndWait = async (mx: MatrixClient, embed: CallEmbed): Promise<void> => {
const myUserId = mx.getUserId();
const myDeviceId = mx.getDeviceId();
const stillIn = (): boolean =>
mx.matrixRTC
.getRoomSession(embed.room)
.memberships.some((m) => m.sender === myUserId && m.deviceId === myDeviceId);
try {
await embed.hangup();
} catch {
// widget already gone — fall through to the wait/timeout
}
const deadline = Date.now() + 4000;
while (stillIn() && Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, 150);
});
}
};
type LogoutDialogProps = {
handleClose: () => void;
};
@@ -64,7 +42,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
// membership (expires: 4 h) stays behind as a ghost participant and
// everyone else sees you "in call" until it times out.
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
await hangupAndWait(mx, callEmbed);
await hangupCallAndWait(mx, callEmbed);
setCallEmbed(undefined);
}
await logoutClient(mx);
@@ -74,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}`,
@@ -84,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>
+2
View File
@@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) {
<Modal
size="500"
variant="Background"
role="dialog"
aria-modal="true"
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+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();
+22 -4
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config, Text } from 'folds';
@@ -26,6 +26,8 @@ import {
VerificationRequestContent,
VideoContent,
} from './message';
import { DiffToken } from '../utils/wordDiff';
import { EditDiffContext } from './message/content/EditDiffContext';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
@@ -70,8 +72,21 @@ type RenderMessageContentProps = {
linkifyOpts: Opts;
outlineAttachment?: boolean;
eventId?: string;
/** [Gitea #219] Open the room's shared media lightbox at this event. */
onOpenImageViewer?: () => void;
/** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */
mEvent?: MatrixEvent;
/** [Gitea #144] Word diff of the last edit, shown on "(edited)" hover. */
editDiff?: DiffToken[];
};
export function RenderMessageContent({
export function RenderMessageContent({ editDiff, ...props }: RenderMessageContentProps) {
return (
<EditDiffContext.Provider value={editDiff}>
<RenderMessageContentBody {...props} />
</EditDiffContext.Provider>
);
}
function RenderMessageContentBody({
displayName,
msgType,
ts,
@@ -85,7 +100,9 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
eventId,
}: RenderMessageContentProps) {
onOpenImageViewer,
mEvent,
}: Omit<RenderMessageContentProps, 'editDiff'>) {
const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview
// fetches + iframes at once. De-dupe first: a message linking the same URL
@@ -241,6 +258,7 @@ export function RenderMessageContent({
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
onOpenViewer={onOpenImageViewer}
/>
)}
outlined={outlineAttachment}
@@ -342,7 +360,7 @@ export function RenderMessageContent({
}
if (msgType === 'm.bad.encrypted') {
return <MBadEncrypted />;
return <MBadEncrypted mEvent={mEvent} />;
}
if (msgType === 'm.key.verification.request') {
@@ -1,6 +1,7 @@
import { useTauriCallPower } from '../hooks/useTauriCallPower';
import { useTauriJumpList } from '../hooks/useTauriJumpList';
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
import { useTauriTaskbarProgress } from '../hooks/useTauriTaskbarProgress';
import { useTauriSmtc } from '../hooks/useTauriSmtc';
import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions';
@@ -18,6 +19,7 @@ export function TauriDesktopFeatures(): null {
useTauriCallPower(); // P5-46 no-sleep during calls
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
useTauriTaskbarProgress(); // cinny-desktop #10 upload progress on the taskbar button
useTauriSmtc(); // P5-43 system media transport controls
useTauriNetwork(); // P5-49 network-change awareness → sync retry
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
@@ -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 }}>
+1 -1
View File
@@ -383,7 +383,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
audio.pause();
setPreviewPlaying(false);
} else {
audio.play();
audio.play().catch(() => setPreviewPlaying(false));
setPreviewPlaying(true);
}
}}
@@ -0,0 +1,100 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Box, Overlay, OverlayBackdrop, config, color, toRem } from 'folds';
import { stopPropagation } from '../../utils/keyboard';
type ActionSheetProps = {
open: boolean;
onClose: () => void;
'aria-label'?: string;
children: ReactNode;
};
/**
* [Gitea #166] A bottom sheet for touch screens: slides up from the bottom,
* swipe-down or backdrop tap dismisses. Content is whatever the caller
* renders (quick reactions + a folds Menu, for message actions).
*/
export function ActionSheet({
open,
onClose,
children,
'aria-label': ariaLabel,
}: ActionSheetProps) {
const [dragY, setDragY] = useState(0);
const startY = useRef<number | null>(null);
// The long-press that opened us ends with a touchend/click that lands
// "outside" the sheet — ignore outside clicks for the first moments.
const openedAt = useRef(Date.now());
const onTouchStart = useCallback((evt: React.TouchEvent) => {
startY.current = evt.touches[0]?.clientY ?? null;
}, []);
const onTouchMove = useCallback((evt: React.TouchEvent) => {
if (startY.current === null) return;
const dy = (evt.touches[0]?.clientY ?? startY.current) - startY.current;
setDragY(Math.max(0, dy));
}, []);
const onTouchEnd = useCallback(() => {
const dismiss = dragY > 80;
startY.current = null;
setDragY(0);
if (dismiss) onClose();
}, [dragY, onClose]);
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: () => Date.now() - openedAt.current > 600,
escapeDeactivates: stopPropagation,
}}
>
<Box
role="dialog"
aria-modal
aria-label={ariaLabel}
direction="Column"
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75vh',
background: color.Surface.Container,
color: color.Surface.OnContainer,
borderTopLeftRadius: toRem(16),
borderTopRightRadius: toRem(16),
boxShadow: '0 -8px 32px rgba(0,0,0,0.35)',
paddingBottom: 'env(safe-area-inset-bottom)',
transform: dragY ? `translateY(${dragY}px)` : undefined,
transition: dragY ? undefined : 'transform 120ms ease-out',
overflow: 'hidden',
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
<Box justifyContent="Center" shrink="No" style={{ padding: config.space.S200 }}>
<div
aria-hidden
style={{
width: toRem(36),
height: toRem(4),
borderRadius: toRem(2),
background: color.Surface.ContainerLine,
}}
/>
</Box>
<Box direction="Column" style={{ overflowY: 'auto' }}>
{children}
</Box>
</Box>
</FocusTrap>
</Overlay>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './ActionSheet';
+3 -1
View File
@@ -67,7 +67,9 @@ export const EditorTextarea = style([
export const EditorPlaceholderContainer = style([
DefaultReset,
{
opacity: config.opacity.Placeholder,
// [Gitea #222] folds' Placeholder opacity (0.5) lands at ~2.3:1 on the
// composer surface; P300 keeps it visibly secondary at AA contrast.
opacity: config.opacity.P300,
pointerEvents: 'none',
userSelect: 'none',
},
@@ -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,20 @@ 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));
useAutocompleteEnter(true, 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));
// The list always shows a suggestion (a member, or the typed id), so Enter
// always picks rather than sending "hey @bo".
useAutocompleteEnter(true, 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 = {
+39 -11
View File
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRecentTouch } from '../../hooks/useRecentTouch';
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
import {
@@ -52,7 +54,7 @@ import {
EmojiGroup,
EmojiBoardLayout,
} from './components';
import { EmojiBoardTab, EmojiType } from './types';
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_group';
@@ -518,10 +520,27 @@ export function EmojiBoard({
});
const vItems = virtualizer.getVirtualItems();
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
const { wasTouch } = useRecentTouch(contentScrollRef);
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
const stickerUseAuthentication = useMediaAuthentication();
const sendSticker = (info: EmojiItemInfo, close: boolean) => {
onStickerSelect?.(info.data, info.shortcode, info.label);
setRecentStickers((prev) =>
addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }),
);
setPendingSticker(undefined);
if (close) requestClose();
};
const handleGroupItemClick: MouseEventHandler = (evt) => {
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
if (!emojiInfo) return;
if (!emojiInfo) {
if (pendingSticker) setPendingSticker(undefined);
return;
}
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
@@ -533,14 +552,12 @@ export function EmojiBoard({
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
}
if (emojiInfo.type === EmojiType.Sticker) {
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
setRecentStickers((prev) =>
addRecentSticker(prev, {
url: emojiInfo.data,
shortcode: emojiInfo.shortcode,
body: emojiInfo.label,
}),
);
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
setPendingSticker(emojiInfo);
return;
}
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
return;
}
if (!evt.altKey && !evt.shiftKey) requestClose();
};
@@ -670,7 +687,18 @@ export function EmojiBoard({
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder>
</Box>
<Preview previewAtom={previewAtom} />
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
<TapToSendBar
previewUrl={
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
}
label={pendingSticker.label}
onSend={() => sendSticker(pendingSticker, true)}
onCancel={() => setPendingSticker(undefined)}
/>
) : (
<Preview previewAtom={previewAtom} />
)}
</EmojiBoardLayout>
</FocusTrap>
);
@@ -24,32 +24,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
import { TimestampPrefs, formatTimestamp } from '../../utils/formatTimestamp';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
function formatReadTs(ts: number, hour24Clock: boolean): string {
const timeStr = timeHourMinute(ts, hour24Clock);
if (today(ts)) return `Today at ${timeStr}`;
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
const sameYear = timeYear(ts) === timeYear(Date.now());
return sameYear
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
}
const formatReadTs = (ts: number, prefs: TimestampPrefs): string => formatTimestamp(ts, prefs);
type EventReaderItemProps = {
room: Room;
readerId: string;
hour24Clock: boolean;
prefs: TimestampPrefs;
lotusTerminal: boolean;
onSelect: React.MouseEventHandler<HTMLButtonElement>;
};
function EventReaderItem({
room,
readerId,
hour24Clock,
lotusTerminal,
onSelect,
}: EventReaderItemProps) {
function EventReaderItem({ room, readerId, prefs, lotusTerminal, onSelect }: EventReaderItemProps) {
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
@@ -86,7 +73,7 @@ function EventReaderItem({
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
{formatReadTs(receiptTs, prefs)}
</Text>
)}
</Box>
@@ -106,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const { prefs } = useTimestampFormatter();
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
return (
@@ -157,7 +144,7 @@ export const EventReaders = as<'div', EventReadersProps>(
key={readerId}
room={room}
readerId={readerId}
hour24Clock={hour24Clock}
prefs={prefs}
lotusTerminal={lotusTerminal}
onSelect={(event) => {
openProfile(
@@ -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>
@@ -6,7 +6,7 @@ import * as css from './Reply.css';
import { ForwardedMeta } from '../../features/room/message/forwardContent';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import { formatTimestamp } from '../../utils/formatTimestamp';
type ForwardedHeaderProps = {
mx: MatrixClient;
@@ -32,11 +32,7 @@ export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
getMxIdLocalPart(meta.sender) ??
meta.sender;
const ts = meta.origin_server_ts;
const when = today(ts)
? timeHourMinute(ts, hour24Clock)
: yesterday(ts)
? `Yesterday ${timeHourMinute(ts, hour24Clock)}`
: `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`;
const when = formatTimestamp(ts, { hour24Clock, dateFormatString });
const canJump = !!sourceRoom && !!onJump;
return (
@@ -1,7 +1,11 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk';
import { IContent, MatrixEvent } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { describeDecryptionFailure } from '../../utils/decryptionReason';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
@@ -42,14 +46,31 @@ type CollapsibleBodyProps = {
eventId?: string;
children: ReactNode;
};
// [Gitea #152] Messages the user expanded this session. The timeline unmounts
// rows as they scroll away, so component state alone forgot the choice;
// module-scoped (never persisted) survives remounts and clears on reload.
const expandedMessages = new Set<string>();
function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [needsCollapse, setNeedsCollapse] = useState(false);
const [collapsed, setCollapsed] = useState(true);
const [collapsed, setCollapsedState] = useState(
() => !(eventId && expandedMessages.has(eventId)),
);
const setCollapsed = (next: boolean | ((c: boolean) => boolean)) => {
setCollapsedState((c) => {
const value = typeof next === 'function' ? next(c) : next;
if (eventId) {
if (value) expandedMessages.delete(eventId);
else expandedMessages.add(eventId);
}
return value;
});
};
// Reset collapsed state when the event changes (new message)
useEffect(() => {
setCollapsed(true);
setCollapsedState(!(eventId && expandedMessages.has(eventId)));
setNeedsCollapse(false);
}, [eventId]);
@@ -120,11 +141,59 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
);
}
export function MBadEncrypted() {
type MBadEncryptedProps = {
mEvent?: MatrixEvent;
};
/**
* [Gitea #159] The undecryptable placeholder says WHY and offers the one
* action that fixes it (set up key backup / verify this session / retry).
* The raw SDK code stays in the tooltip for support.
*/
export function MBadEncrypted({ mEvent }: MBadEncryptedProps) {
const mx = useMatrixClient();
const requestSettings = useSetAtom(settingsRequestAtom);
const [retrying, setRetrying] = useState(false);
const code = mEvent?.decryptionFailureReason ?? null;
const reason = describeDecryptionFailure(code);
const handleAction = async () => {
if (reason.action === 'setup-backup' || reason.action === 'verify-session') {
requestSettings('devices');
return;
}
if (reason.action === 'retry' && mEvent) {
setRetrying(true);
try {
await mx.decryptEventIfNeeded(mEvent, { forceRedecryptIfUntrusted: true });
} catch {
// still undecryptable — the placeholder re-renders with the current reason
} finally {
setRetrying(false);
}
}
};
return (
<Text>
<MessageBadEncryptedContent />
</Text>
<Box direction="Column" gap="100" alignItems="Start">
<Text>
<MessageBadEncryptedContent title={code ?? undefined} />
</Text>
<Text size="T200" priority="300">
{reason.text}
</Text>
{reason.action !== 'none' && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleAction}
disabled={retrying}
>
<Text size="B300">{retrying ? 'Retrying…' : reason.actionLabel}</Text>
</Button>
)}
</Box>
);
}
+7 -3
View File
@@ -8,6 +8,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import { ReplyMediaThumb, hasReplyMedia } from './ReplyMediaThumb';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
@@ -143,9 +144,12 @@ export const Reply = as<'div', ReplyProps>(
<i>Original message not available</i>
</Text>
) : (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{hasReplyMedia(replyEvent) && <ReplyMediaThumb mEvent={replyEvent} />}
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
</Box>
)}
</ReplyLayout>
</Box>
@@ -0,0 +1,75 @@
import React from 'react';
import { Icon, Icons, config, toRem } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { MessageEvent } from '../../../types/matrix/room';
const SIZE = 36;
/** Whether a reply quote for this event should carry a thumbnail. */
export const hasReplyMedia = (mEvent: MatrixEvent | null | undefined): boolean => {
if (!mEvent || mEvent.isRedacted()) return false;
if (mEvent.getType() === MessageEvent.Sticker) return true;
const msgtype = mEvent.getContent().msgtype;
return msgtype === MsgType.Image || msgtype === MsgType.Video;
};
/**
* [Gitea #151] 36 px thumbnail in a reply quote for an image/video/sticker.
* Uses the event's own thumbnail (decrypting it for E2EE media), never the
* full-size file.
*/
export function ReplyMediaThumb({ mEvent }: { mEvent: MatrixEvent }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType);
return (
<span
aria-hidden
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(SIZE),
height: toRem(SIZE),
flexShrink: 0,
borderRadius: config.radii.R300,
overflow: 'hidden',
background: 'rgba(127, 127, 127, 0.15)',
}}
>
{media.status === 'ok' ? (
<img
src={media.url}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Icon size="100" src={isVideo ? Icons.Play : Icons.Photo} />
)}
{isVideo && media.status === 'ok' && (
<Icon
size="50"
src={Icons.Play}
filled
style={{ position: 'absolute', color: 'white', filter: 'drop-shadow(0 0 2px black)' }}
/>
)}
</span>
);
}
+4 -15
View File
@@ -1,6 +1,6 @@
import React, { ComponentProps } from 'react';
import { Text, as } from 'folds';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import { formatTimestamp } from '../../utils/formatTimestamp';
export type TimeProps = {
compact?: boolean;
@@ -12,8 +12,8 @@ export type TimeProps = {
/**
* Renders a formatted timestamp, supporting compact and full display modes.
*
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
* For older messages, it shows the date and time.
* `compact` always shows the clock time; otherwise the shared `formatTimestamp`
* rules apply (today → time, yesterday/this week → day word + time, else date + time).
*
* @param {number} ts - The timestamp to display.
* @param {boolean} [compact=false] - If true, always show only the time.
@@ -23,18 +23,7 @@ export type TimeProps = {
*/
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock);
let time = '';
if (compact) {
time = formattedTime;
} else if (today(ts)) {
time = formattedTime;
} else if (yesterday(ts)) {
time = `Yesterday ${formattedTime}`;
} else {
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
}
const time = formatTimestamp(ts, { hour24Clock, dateFormatString }, compact ? 'time' : 'auto');
return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
@@ -0,0 +1,6 @@
import { createContext, useContext } from 'react';
import { DiffToken } from '../../../utils/wordDiff';
/** [Gitea #144] Word diff of the message's last edit, for the "(edited)" hover. */
export const EditDiffContext = createContext<DiffToken[] | undefined>(undefined);
export const useEditDiff = () => useContext(EditDiffContext);
@@ -1,5 +1,20 @@
import { Box, Icon, Icons, Text, as, color, config } from 'folds';
import React from 'react';
import {
Box,
Icon,
Icons,
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
as,
color,
config,
} from 'folds';
import React, { useEffect, useState } from 'react';
import { useLongPress } from '../../../hooks/useLongPress';
import { DiffToken, countChanges } from '../../../utils/wordDiff';
import { useEditDiff } from './EditDiffContext';
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
@@ -66,29 +81,157 @@ export const MessageVerificationRequestContent = as<'div', { children?: never }>
),
);
const editDiffStyle: React.CSSProperties = {
maxWidth: '40ch',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
maxHeight: '12lh',
overflow: 'hidden',
};
/** The last edit as a word diff: removed words struck, added words bold. */
export function EditDiffBody({ tokens, hint }: { tokens: DiffToken[]; hint: string }) {
const { added, removed } = countChanges(tokens);
return (
<Box direction="Column" gap="100" style={editDiffStyle}>
<Text size="T200" priority="300">
Last edit
{added > 0 && ` · +${added}`}
{removed > 0 && ` · −${removed}`}
</Text>
<Text as="span" size="T300" dir="auto">
{tokens.map((t, i) => {
if (t.op === 'add')
return (
<ins key={i} style={{ textDecoration: 'none', fontWeight: 'bold' }}>
{t.text}
</ins>
);
if (t.op === 'del')
return (
<del key={i} style={{ opacity: config.opacity.P300 }}>
{t.text}
</del>
);
return <React.Fragment key={i}>{t.text}</React.Fragment>;
})}
</Text>
<Text size="T200" priority="300">
{hint}
</Text>
</Box>
);
}
const editedButtonStyle: React.CSSProperties = {
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
};
/**
* "(edited)" label. With an edit-history handler it is a button that opens
* the viewer; when the surrounding message also provides a last-edit diff
* (see `EditDiffContext`) hover/focus shows it as a tooltip and a touch
* long-press shows it as a popout. [Gitea #144]
*/
export const MessageEditedContent = as<
'span',
{ children?: never; onEditHistoryClick?: () => void }
>(({ onEditHistoryClick, ...props }, ref) =>
onEditHistoryClick ? (
>(({ onEditHistoryClick, ...props }, ref) => {
const diff = useEditDiff();
const [pressAnchor, setPressAnchor] = useState<RectCords | undefined>();
const { coarse, handlers, suppressContextMenu } = useLongPress((x, y) => {
if (diff) setPressAnchor({ x, y, width: 1, height: 1 });
});
useEffect(() => {
if (!pressAnchor) return undefined;
const close = () => setPressAnchor(undefined);
const t = window.setTimeout(close, 6000);
window.addEventListener('touchstart', close, { passive: true });
window.addEventListener('scroll', close, { passive: true, capture: true });
return () => {
window.clearTimeout(t);
window.removeEventListener('touchstart', close);
window.removeEventListener('scroll', close, { capture: true });
};
}, [pressAnchor]);
if (!onEditHistoryClick) {
return (
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
{' (edited)'}
</Text>
);
}
const button = (tipRef?: React.RefCallback<HTMLElement>) => (
<button
ref={tipRef}
type="button"
onClick={() => {
if (suppressContextMenu.current) return;
onEditHistoryClick();
}}
onContextMenu={(evt) => {
if (suppressContextMenu.current) evt.preventDefault();
}}
{...handlers}
// Keep the message's own long-press (action sheet) from firing too.
onTouchStart={(evt) => {
evt.stopPropagation();
handlers.onTouchStart?.(evt);
}}
aria-label={diff ? 'View edit history — hover for the last change' : 'View edit history'}
style={editedButtonStyle}
>
<Text as="span" size="T200" priority="300">
{' (edited)'}
</Text>
</button>
);
return (
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
<button
type="button"
onClick={onEditHistoryClick}
aria-label="View edit history"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
>
<Text as="span" size="T200" priority="300">
{' (edited)'}
</Text>
</button>
{diff ? (
<TooltipProvider
position="Top"
align="Start"
delay={300}
tooltip={
<Tooltip>
<EditDiffBody
tokens={diff}
hint={coarse ? 'Tap for full history' : 'Click for full history'}
/>
</Tooltip>
}
>
{(tipRef) => button(tipRef)}
</TooltipProvider>
) : (
button()
)}
{diff && pressAnchor && (
<PopOut
anchor={pressAnchor}
position="Top"
align="Start"
content={
<Tooltip>
<EditDiffBody
tokens={diff}
hint={coarse ? 'Tap for full history' : 'Click for full history'}
/>
</Tooltip>
}
/>
)}
</span>
) : (
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
{' (edited)'}
</Text>
),
);
);
});
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
@@ -105,16 +105,21 @@ 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: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@@ -194,16 +199,21 @@ 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: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@@ -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;
@@ -59,6 +62,9 @@ export type ImageContentProps = {
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
// [Gitea #219] When given, a click opens THIS instead of the built-in
// viewer — the room timeline hands it to the shared media lightbox.
onOpenViewer?: () => void;
};
export const ImageContent = as<'div', ImageContentProps>(
(
@@ -74,6 +80,7 @@ export const ImageContent = as<'div', ImageContentProps>(
spoilerReason,
renderViewer,
renderImage,
onOpenViewer,
...props
},
ref,
@@ -139,7 +146,7 @@ export const ImageContent = as<'div', ImageContentProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src: srcState.data,
@@ -182,8 +189,17 @@ export const ImageContent = as<'div', ImageContentProps>(
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: () => setViewer(true),
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.',
+29 -10
View File
@@ -9,16 +9,35 @@ export const NavItem = as<
{
highlight?: boolean;
} & css.RoomSelectorVariants
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
{...props}
ref={ref}
>
{children}
</AsNavItem>
));
>(
(
{
as: AsNavItem = 'div',
className,
highlight,
variant,
radii,
children,
'aria-selected': selected,
...props
},
ref,
) => (
// [Gitea #222] Callers pass `aria-selected`, but that attribute is only
// valid on option/tab/row roles; on a plain div axe flags it as critical.
// Keep the prop for callers and styling, expose the state as aria-current.
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
data-selected={selected === true || selected === 'true' ? true : undefined}
aria-current={selected === true || selected === 'true' ? 'page' : undefined}
{...props}
ref={ref}
>
{children}
</AsNavItem>
),
);
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
({ className, ...props }, ref) => (
+1 -1
View File
@@ -69,7 +69,7 @@ const NavItemBase = style({
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
'&[data-selected=true]': {
backgroundColor: ContainerActive,
},
[`&:has(.${NavLink}:focus-visible)`]: {
@@ -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>
+3 -5
View File
@@ -19,15 +19,13 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
import { RoomTopicViewer } from '../room-topic-viewer';
import { stopPropagation } from '../../utils/keyboard';
@@ -68,7 +66,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]),
);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const { format } = useTimestampFormatter();
return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
@@ -135,7 +133,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<Text size="T200" priority="300">
{'Created by '}
<b>@{creatorName}</b>
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
{` on ${format(ts, 'dateTime')}`}
</Text>
)}
</Box>
@@ -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)}
+50 -18
View File
@@ -1,21 +1,49 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { Suspense, useEffect, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { SeasonTheme, SeasonalOverlayProps } from './types';
import { resolveSeasonTheme } from './seasonSchedule';
import { HalloweenOverlay } from './themes/Halloween';
import { ChristmasOverlay } from './themes/Christmas';
import { NewYearOverlay } from './themes/NewYear';
import { AutumnOverlay } from './themes/Autumn';
import { AprilFoolsOverlay } from './themes/AprilFools';
import { LunarNewYearOverlay } from './themes/LunarNewYear';
import { ValentinesOverlay } from './themes/Valentines';
import { StPatricksOverlay } from './themes/StPatricks';
import { EarthDayOverlay } from './themes/EarthDay';
import { DeepSpaceOverlay } from './themes/DeepSpace';
import { ArcadeOverlay } from './themes/Arcade';
// [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<SeasonalOverlayProps> }>) =>
React.lazy(load);
const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
);
const ChristmasOverlay = lazyOverlay(() =>
import('./themes/Christmas').then((m) => ({ default: m.ChristmasOverlay })),
);
const NewYearOverlay = lazyOverlay(() =>
import('./themes/NewYear').then((m) => ({ default: m.NewYearOverlay })),
);
const AutumnOverlay = lazyOverlay(() =>
import('./themes/Autumn').then((m) => ({ default: m.AutumnOverlay })),
);
const AprilFoolsOverlay = lazyOverlay(() =>
import('./themes/AprilFools').then((m) => ({ default: m.AprilFoolsOverlay })),
);
const LunarNewYearOverlay = lazyOverlay(() =>
import('./themes/LunarNewYear').then((m) => ({ default: m.LunarNewYearOverlay })),
);
const ValentinesOverlay = lazyOverlay(() =>
import('./themes/Valentines').then((m) => ({ default: m.ValentinesOverlay })),
);
const StPatricksOverlay = lazyOverlay(() =>
import('./themes/StPatricks').then((m) => ({ default: m.StPatricksOverlay })),
);
const EarthDayOverlay = lazyOverlay(() =>
import('./themes/EarthDay').then((m) => ({ default: m.EarthDayOverlay })),
);
const DeepSpaceOverlay = lazyOverlay(() =>
import('./themes/DeepSpace').then((m) => ({ default: m.DeepSpaceOverlay })),
);
const ArcadeOverlay = lazyOverlay(() =>
import('./themes/Arcade').then((m) => ({ default: m.ArcadeOverlay })),
);
// SeasonTheme + the date-window logic now live in leaf modules (single source
// of truth, shared with the settings UI). Re-exported here for existing
@@ -24,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':
@@ -69,7 +101,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
overflow: 'hidden',
}}
>
{buildOverlayContent(theme, reduced)}
<Suspense fallback={null}>{buildOverlayContent(theme, reduced)}</Suspense>
</div>
);
}
@@ -94,7 +126,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
containerType: 'inline-size',
}}
>
{buildOverlayContent(theme, true)}
<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;
};
@@ -0,0 +1,133 @@
import React, { MouseEvent, ReactNode, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
color,
config,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { LinkSafety } from '../../utils/linkSafety';
type SuspiciousLinkProps = {
href: string;
safety: LinkSafety;
anchorProps: Record<string, unknown>;
children: ReactNode;
};
/**
* [Gitea #122] A link whose visible text names a different site than its
* destination (or whose destination is a punycode host) opens a small
* confirm instead of navigating straight away. Honest links never see this.
*/
export function SuspiciousLink({ href, safety, anchorProps, children }: SuspiciousLinkProps) {
const [open, setOpen] = useState(false);
const handleClick = (evt: MouseEvent) => {
evt.preventDefault();
evt.stopPropagation();
setOpen(true);
};
const proceed = () => {
setOpen(false);
window.open(href, '_blank', 'noopener,noreferrer');
};
return (
<>
<a
{...anchorProps}
href={href}
onClick={handleClick}
data-lotus-suspicious-link
title={`Goes to ${safety.realHost}`}
>
{children}
</a>
{open && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
fallbackFocus: '#suspiciouslink-dialog-1',
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<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}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="suspiciouslink-dialog-1-title" as="h2" size="H4">
This link doesn&apos;t go where it says
</Text>
</Box>
<IconButton
size="300"
onClick={() => setOpen(false)}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>
{safety.shownHost ? (
<>
It shows <b>{safety.shownHost}</b> but goes to{' '}
<b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
</>
) : (
<>
It goes to <b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
</>
)}
{safety.punycode &&
' The destination uses look-alike (internationalised) characters in its name.'}
</Text>
<Text size="T200" priority="300" style={{ wordBreak: 'break-all' }}>
{href}
</Text>
<Box gap="200" justifyContent="End">
<Button variant="Secondary" fill="Soft" onClick={() => setOpen(false)}>
<Text size="B400">Cancel</Text>
</Button>
<Button variant="Critical" onClick={proceed}>
<Text size="B400">Open anyway</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</>
);
}
@@ -0,0 +1 @@
export * from './SuspiciousLink';
@@ -0,0 +1,75 @@
import React, { useEffect, useRef } from 'react';
import { Box, Button, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
type TapToSendBarProps = {
previewUrl?: string;
label: string;
onSend: () => void;
onCancel: () => void;
};
/**
* [Gitea #147] Fat-finger guard for touch screens: the first tap on a sticker
* or GIF parks it here with a preview; "Send" (or a second tap on the same
* item) sends it.
*/
export function TapToSendBar({ previewUrl, label, onSend, onCancel }: TapToSendBarProps) {
const liveRef = useRef<HTMLDivElement>(null);
useEffect(() => {
liveRef.current?.focus?.();
}, [label]);
return (
<Box
ref={liveRef}
role="status"
aria-live="polite"
tabIndex={-1}
shrink="No"
alignItems="Center"
gap="300"
style={{
margin: `0 ${config.space.S300} ${config.space.S200}`,
padding: config.space.S200,
borderRadius: config.radii.R400,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
outline: 'none',
}}
>
{previewUrl && (
<img
src={previewUrl}
alt=""
style={{
width: toRem(40),
height: toRem(40),
objectFit: 'contain',
borderRadius: config.radii.R300,
flexShrink: 0,
}}
/>
)}
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T200" priority="300">
Tap again to send
</Text>
<Text size="T300" truncate>
{label}
</Text>
</Box>
<Button size="300" variant="Primary" fill="Solid" radii="300" onClick={onSend}>
<Text size="B300">Send</Text>
</Button>
<IconButton
size="300"
variant="SurfaceVariant"
radii="300"
onClick={onCancel}
aria-label="Cancel"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</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>
@@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { describeUploadError } from '../../utils/uploadError';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
@@ -91,7 +92,7 @@ export function CompactUploadCardRenderer({
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
<Text size="T200">{describeUploadError(upload.error, allowSize)}</Text>
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
@@ -24,6 +24,7 @@ import {
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { describeUploadError } from '../../utils/uploadError';
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
type PreviewImageProps = {
@@ -282,6 +283,12 @@ type UploadCardRendererProps = {
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
/** [Gitea #129] Focus the caption field on mount (single image into an empty composer). */
autoFocusCaption?: boolean;
/** Enter in the caption field: send the board (and any composer text). */
onCaptionSubmit?: () => void;
/** Escape in the caption field: hand focus back to the composer. */
onCaptionEscape?: () => void;
};
export function UploadCardRenderer({
isEncrypted,
@@ -289,6 +296,9 @@ export function UploadCardRenderer({
setMetadata,
onRemove,
onComplete,
autoFocusCaption,
onCaptionSubmit,
onCaptionEscape,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
@@ -378,9 +388,27 @@ export function UploadCardRenderer({
size="300"
radii="300"
style={{ marginTop: config.space.S200, width: '100%' }}
autoFocus={autoFocusCaption}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onCaptionSubmit?.();
} else if (e.key === 'Escape') {
e.preventDefault();
onCaptionEscape?.();
}
}}
/>
)}
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
{metadata.metadataStripped && (
<Box alignItems="Center" gap="100" style={{ marginTop: config.space.S100 }}>
<Icon size="50" src={Icons.Shield} style={{ color: color.Success.Main }} />
<Text size="T200" priority="300">
Photo metadata removed (location, camera, time)
</Text>
</Box>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
@@ -389,7 +417,7 @@ export function UploadCardRenderer({
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
<Text size="T200">{describeUploadError(upload.error, allowSize)}</Text>
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
@@ -246,11 +246,15 @@ export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
width: toRem(300),
maxWidth: '100%',
// 9:16 via the padding-top hack (177.78%) — see EmbedMediaLandscape.
// [Gitea #228] `min()` on both axes so the 9:16 ratio holds whatever the
// parent's width is. A padding-top percentage resolves against the
// CONTAINING BLOCK's width, not the element's, so a bare `177.78%` inside a
// wide card made the box ~2x too tall (606px card → 1077px instead of 533).
// Capped at the pixel height of a 300px-wide 9:16 box, it is exact when the
// parent is at least 300px and falls back to the percentage below that.
width: `min(${toRem(300)}, 100%)`,
height: 0,
paddingTop: '177.78%',
paddingTop: `min(${toRem(533)}, 177.78%)`,
margin: '0 auto',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
@@ -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 {
@@ -308,6 +309,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 {
@@ -1203,7 +1221,11 @@ function MediaEmbedCard({
};
return (
<Box direction="Column">
// [Gitea #228] `width: 100%` — UrlPreview is a flex ROW, so this column
// shrink-to-fits its content. The facade's <img> supplied that width, but
// the player <iframe> is absolutely positioned and contributes none, so on
// play the whole embed collapsed to the iframe's ~200px intrinsic size.
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<iframe
@@ -1380,7 +1402,9 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
);
return (
<Box direction="Column">
// [Gitea #228] See the video embed above: this column must fill the card's
// flex row, not shrink to the iframe's intrinsic width.
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<iframe
@@ -2275,8 +2299,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>

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