Compare commits

..
Author SHA1 Message Date
jared 1b887de81d Merge pull request 'fix(consent): a retry that fails again reopens the terms prompt (#311)' (#406) from fix/consent-retry-snooze into lotus
CI / Build & Quality Checks (push) Waiting to run
CI / Trigger Desktop Build (push) Blocked by required conditions
CI / Secret scan (gitleaks) (push) Waiting to run
CI / Docker image build & smoke test (push) Blocked by required conditions
CI / Playwright smoke (e2e) (push) Blocked by required conditions
2026-10-18 06:51:24 -04:00
jared 1037d458f4 Merge pull request 'fix(privacy): "Remove Photo Metadata" also covers emoji/sticker packs and badges (#332)' (#405) from fix/strip-metadata-packs into lotus
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
2026-10-18 06:31:31 -04:00
jared 8a3b2eeb61 Merge pull request 'fix(previews): no genuine preview card for a site a deceptive link only pretends to be (#286)' (#404) from fix/preview-deceptive-links into lotus
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
2026-10-18 06:15:46 -04:00
jared 099ada5d18 Merge pull request 'fix(previews): a page can no longer pose as a YouTube (etc.) player via its og:url (#326)' (#403) from fix/embed-ogurl-provider into lotus
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
2026-10-18 05:54:22 -04:00
jared 80ef1179c9 Merge pull request 'fix(settings): valid HTML in setting tiles; start uploads in an effect (#291)' (#402) from fix/settingtile-nesting into lotus
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
2026-10-18 05:37:06 -04:00
jared 200ef4993c Merge pull request 'fix(a11y): the keyboard shortcuts dialog lists every shortcut (#334)' (#401) from fix/shortcuts-dialog into lotus
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
2026-10-18 05:21:22 -04:00
jared 0ebf7f0806 Merge pull request 'fix(forward): warn when forwarding out of an encrypted room into one that is not (#330)' (#400) from fix/forward-encryption-warning into lotus
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
2026-10-18 05:05:17 -04:00
jared 397eb7da81 Merge pull request 'fix(calls): no "the shared window was closed" toast when an admin turns sharing off (#297)' (#398) from fix/screenshare-double-toast into lotus
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
2026-10-18 04:23:18 -04:00
jared a20e68596a Merge pull request 'fix(notifications): notification replies re-check encryption at send time and use the right account (#299)' (#397) from fix/sw-reply-checks into lotus
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
2026-10-18 03:55:01 -04:00
jared df9591c44c Merge pull request 'fix(calls): the call PiP no longer opens on top of the composer (#323)' (#396) from fix/pip-default-position into lotus
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
2026-10-18 03:24:13 -04:00
jared ed24d7f78e Merge pull request 'fix(notifications): in-app toasts no longer need browser notification permission (#324)' (#395) from fix/inapp-toast-permission into lotus
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
2026-10-18 03:06:12 -04:00
jared 667925733e Merge pull request 'fix(notifications): timed mutes are lifted by any Lotus device, not just the one that set them (#333)' (#393) from fix/mute-timers-account-data into lotus
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
2026-10-18 02:22:21 -04:00
jared ea5a0d4906 Merge pull request 'fix(moderation): policy viewer lists a stable rule and its legacy twin once (#321)' (#392) from fix/policy-dedupe into lotus
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
2026-10-18 02:05:12 -04:00
jared a6e90c7bc4 Merge pull request 'fix(security): a dismissed status announcement no longer hides the security nudge (#307)' (#391) from fix/nudge-dismissed-strip into lotus
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
2026-10-18 01:49:48 -04:00
jared c87fa4f6d5 Merge pull request 'fix(status): banner Details strips Markdown syntax, not every #, _, >, * (#308)' (#390) from fix/kuma-plain-text into lotus
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
2026-10-18 01:33:17 -04:00
jared 5ecf4132f5 Merge pull request 'fix(calls): DM-aware rejoin toast, and no rejoining a DM call that ended (#302)' (#389) from fix/rejoin-dm into lotus
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
2026-10-18 01:16:23 -04:00
jared d412f9c56c Merge pull request 'fix(search): an edited message shows once, with its latest text (#288)' (#388) from fix/search-edit-dupes into lotus
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
2026-10-18 00:59:46 -04:00
jared ad5631819d Merge pull request 'fix(sidebar): the room filter hides empty sections and says when nothing matches (#289)' (#387) from fix/room-filter-empty into lotus
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
2026-10-18 00:45:28 -04:00
jared 83933fb5ac Merge pull request 'fix(composer): Escape closes the emoji and sticker boards (#304)' (#386) from fix/emoji-board-escape into lotus
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
2026-10-18 00:27:07 -04:00
Lotus CIandClaude Opus 5.5 5ce4692a7b fix(consent): a retry that fails again reopens the terms prompt (#311)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 19m50s
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
Closing the dialog via "I've accepted — retry" also deactivated its focus
trap, whose onDeactivate is "Later": every close set the 10 s snooze, so
a retry made before the terms were really accepted failed silently.
Retry now closes without snoozing (and clears any earlier snooze).

Fixes #311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:11:34 -04:00
Lotus CIandClaude Opus 5.5 4486f02bc6 fix(privacy): "Remove Photo Metadata" also covers emoji/sticker packs and badges (#332)
CI / Build & Quality Checks (pull_request) Successful in 3m25s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m42s
Custom emoji and sticker images, pack avatars and power-level badge
images were uploaded with their EXIF intact (GPS location, timestamp),
although they are published to every member of the room or space. Route
those three pickers through a new useStrippedFilePicker, which applies
the same stripImageMetadata as attachments and avatars.

Fixes #332

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:09:14 -04:00
jared a7c9d15824 Merge pull request 'fix(calls): the call-failed screen gets a Try again button (#301)' (#385) from fix/call-failed-retry into lotus
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
2026-10-18 00:08:25 -04:00
Lotus CIandClaude Opus 5.5 7b3d1bad8f fix(previews): no genuine preview card for a site a deceptive link only pretends to be (#286)
CI / Build & Quality Checks (pull_request) Successful in 3m6s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 21m16s
Previews are built from the plain body, clicks follow formatted_body's
hrefs, and a sender controls both: text "https://paypal.com" linked to
evil.example showed a real PayPal card. Drop body URLs whose site is
only claimed by the visible text of a mismatched link (linkSafety).

Fixes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:05:06 -04:00
Lotus CIandClaude Opus 5.5 2c11bd3b8e fix(previews): a page can no longer pose as a YouTube (etc.) player via its og:url (#326)
CI / Build & Quality Checks (pull_request) Successful in 4m30s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m17s
The embed fallback used the preview's og:url whatever site was posted.
og:url is set by the page, so evil.example could render as a playable
YouTube card (attacker's video) whose links go to evil.example. Only use
an og:url on the same provider as the posted link, including the
provider's own short-link domains (youtu.be, spotify.link, redd.it, …).

Fixes #326

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 23:54:50 -04:00
Lotus CIandClaude Opus 5.5 6f7a8f64e2 fix(settings): valid HTML in setting tiles; start uploads in an effect (#291)
CI / Build & Quality Checks (pull_request) Successful in 4m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m43s
SettingTile rendered its description in a <p>, and many tiles pass
blocks (badges, Box, Text), so Settings had <div>/<p> inside <p> and
React warned on every page. Render it as a <div>. UploadCardRenderer
called startUpload() during render, which now updates the desktop
taskbar-progress hook mid-render; start it from an effect instead.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 23:51:19 -04:00
jared e6e52cdc37 Merge pull request 'fix(share): room share and invite links open Lotus Chat, not matrix.to (#285)' (#384) from fix/share-links-lotus into lotus
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
2026-10-17 23:50:18 -04:00
Lotus CIandClaude Opus 5.5 4969ef9b6d fix(a11y): the keyboard shortcuts dialog lists every shortcut (#334)
CI / Build & Quality Checks (pull_request) Successful in 4m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m52s
The ? dialog showed 7 shortcuts and left out Ctrl+K, ↑ to edit, all
composer formatting keys and the push-to-talk / deafen keys. Add them:
formatting rows are generated from the editor's INLINE_HOTKEYS /
BLOCK_HOTKEYS so they can't drift, and the call keys show the user's
own bindings.

Fixes #334

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 23:43:49 -04:00
Lotus CIandClaude Opus 5.5 be71931a81 fix(forward): warn when forwarding out of an encrypted room into one that is not (#330)
CI / Build & Quality Checks (pull_request) Successful in 3m42s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m6s
Forwarding from an encrypted room into an unencrypted one posts the text
(and a re-uploaded plaintext attachment) where the server can read it,
with the original author attached, and the dialog said nothing. Show a
note above Send naming the unencrypted destination(s).

Refs #330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 23:40:58 -04:00
jared 4ab5a8fd06 Merge pull request 'fix(polls): you can vote on a poll you just created (#284)' (#383) from fix/poll-vote-local-echo into lotus
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
2026-10-17 23:26:31 -04:00
jared 4e30ae763e Merge pull request 'fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322)' (#382) from fix/scheduled-from-server into lotus
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
2026-10-17 23:05:38 -04:00
jared f9265d2d90 Merge pull request 'fix(calls): one delayed-event restart in flight per id, with a timeout (#318)' (#381) from fix/delayed-restart-dedupe into lotus
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
2026-10-17 22:37:01 -04:00
jared 82c5728a96 Merge pull request 'fix(calls): Reject sends the decline to the ring notification (#313)' (#375) from fix/decline-references-notification into lotus
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
2026-10-17 22:13:55 -04:00
jared dd6cf156ba Merge pull request 'fix(status): show a strip when the homeserver hangs, not 110 s later (#317)' (#380) from fix/stall-watchdog into lotus
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
2026-10-17 21:58:23 -04:00
jared e94cdfe5dc Merge pull request 'fix(timeline): media gallery / activity log no longer make the timeline drop messages (#259)' (#368) from fix/filtered-timeline-double-count into lotus
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
2026-10-17 21:37:08 -04:00
jared bb2ace82eb Merge pull request 'fix(calls): lift CallControl's listener limit so ended calls can be freed (#316)' (#379) from fix/call-listener-limit into lotus
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
2026-10-17 21:19:12 -04:00
Lotus CIandClaude Opus 5.5 7fc42b32d4 fix(calls): no "the shared window was closed" toast when an admin turns sharing off (#297)
CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m14s
When a room admin turned off Allow Screen Sharing, the sharer got two
toasts: the correct "turned off by a room admin" and the fork's
"ended" notice, "the shared window was closed", which was wrong. Skip
the ended notice while the room policy forbids sharing.

Fixes #297

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:55:05 -04:00
Lotus CIandClaude Opus 5.5 005fc9e768 fix(notifications): notification replies re-check encryption at send time and use the right account (#299)
CI / Build & Quality Checks (pull_request) Successful in 2m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m49s
The service worker posts inline notification replies in the clear (it
cannot encrypt). The Reply action was only withheld for rooms that were
encrypted when the notification was shown, so a reply typed hours later
into a room that has since turned on encryption went out unencrypted.
It also posted with the newest session the worker knew, not the
notification's account.

The worker now GETs the room's m.room.encryption state first and only
sends on a definite 404; notifications carry the user id and the worker
uses that account's session (or refuses). "Reply not sent" says why.

Fixes #299

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:52:12 -04:00
Lotus CIandClaude Opus 5.5 33cc0de97c fix(calls): the call PiP no longer opens on top of the composer (#323)
CI / Build & Quality Checks (pull_request) Successful in 3m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m4s
The first-use PiP position was bottom-right, 72px from the bottom: on
desktop over Send, on phones over Send, Emoji and the message box, so
the first thing you typed or tapped in the text room hit the call.
Default to top-right under the room header instead. A dragged position
is still remembered.

Refs #323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:47:34 -04:00
Lotus CIandClaude Opus 5.5 3bf040f17a fix(notifications): in-app toasts no longer need browser notification permission (#324)
CI / Build & Quality Checks (pull_request) Successful in 3m2s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m38s
notify() shows an in-page toast while the window is focused and only
falls back to an OS notification otherwise, but both message and invite
notifications were gated on Notification.permission === granted. Anyone
who declined (or was never asked) heard the ping with nothing on screen.
Gate only the OS path on the permission.

Fixes #324

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:41:48 -04:00
jared 906a034e26 Merge pull request 'fix(calls): starting a call in a voice channel no longer @room-pings everyone (#295)' (#378) from fix/voice-room-no-room-ping into lotus
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
2026-10-17 20:38:37 -04:00
Lotus CIandClaude Opus 5.5 69e05bbf8d fix(notifications): timed mutes are lifted by any Lotus device, not just the one that set them (#333)
CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m45s
A timed mute writes a server-side push rule (muted everywhere), but the
unmute timer lived in one browser's localStorage: close that laptop and
the room stayed muted on the phone until it was reopened, and forever
after a logout there. Timers now live in account data
(io.lotus.mute_timers: { timers: { [roomId]: unmuteAt } }). Every
client schedules from it, reschedules when it changes, and the first one
online after expiry unmutes. An indefinite mute clears a pending timer.
Local timers from before are migrated once.

Fixes #333

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:33:32 -04:00
Lotus CIandClaude Opus 5.5 15f2fcc4d1 fix(moderation): policy viewer lists a stable rule and its legacy twin once (#321)
CI / Build & Quality Checks (pull_request) Successful in 3m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m30s
dedupePolicyEntries keyed on the raw recommendation, so m.ban and
org.matrix.mjolnir.ban for the same entity never matched and the rule
showed twice (both labelled "Ban"). Key on the normalised value; move
the pure helpers to policyEntries.ts with unit tests.

Fixes #321

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:28:34 -04:00
Lotus CIandClaude Opus 5.5 8e7127a350 fix(security): a dismissed status announcement no longer hides the security nudge (#307)
CI / Build & Quality Checks (pull_request) Successful in 3m18s
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) Successful in 16m21s
SecurityBanner asked pickBanner whether another strip would show without
the dismissed set, so a dismissed Kuma announcement (often pinned for
weeks) still counted and suppressed the verify / backup nudge on every
device that had dismissed it. The dismissed set is now a shared jotai
atom (still persisted to localStorage) used by both banners. Adds a
Playwright regression.

Fixes #307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:26:54 -04:00
Lotus CIandClaude Opus 5.5 f30a65eb74 fix(status): banner Details strips Markdown syntax, not every #, _, >, * (#308)
CI / Build & Quality Checks (pull_request) Successful in 3m20s
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) Successful in 17m3s
plain() deleted those characters everywhere, so "join #lotus-general"
lost its #, snake_case and @some_user lost underscores, "->" became
"-", "5 * 3" became "5  3", and "<b>" became "<b". Strip only syntax:
heading / quote / list markers at line start, paired **x** *x* _x_ `x`,
and HTML tags.

Fixes #308

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:22:12 -04:00
Lotus CIandClaude Opus 5.5 0a2f18e3d5 fix(calls): DM-aware rejoin toast, and no rejoining a DM call that ended (#302)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 19s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m35s
After a reload during a DM call the toast said "You were in the call in
Bob Builder"; it now says "You were in a call with Bob Builder". DM
calls are never auto-rejoined (it could ring the other person), and if
they have left by the time you tap, the toast says "Call ended" instead
of starting a new, ringing call.

Fixes #302

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:20:12 -04:00
Lotus CIandClaude Opus 5.5 0d08ba0a55 fix(search): an edited message shows once, with its latest text (#288)
CI / Build & Quality Checks (pull_request) Successful in 2m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m21s
Server search also returns the m.replace events, so one message edited
twice came back as three rows. Fold edits into their message: one row
at the best-ranked position, showing the latest m.new_content and
pointing at the original event so Open jumps to the real message.

Fixes #288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:14:09 -04:00
Lotus CIandClaude Opus 5.5 6ae4d7adc0 fix(sidebar): the room filter hides empty sections and says when nothing matches (#289)
CI / Build & Quality Checks (pull_request) Successful in 3m7s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m7s
A filter matching nothing left Favorites / Projects / Rooms / Low
Priority headers with nothing under them. While filtering, sections
without a match are hidden, and with no match at all the list shows
"No rooms match “…”" with a Clear filter button.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:08:56 -04:00
jared b855eb505d Merge pull request 'fix(reactions): the reactions row no longer flickers or loses its spacing (#305)' (#377) from fix/reactions-collapse-loop into lotus
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
2026-10-17 20:07:27 -04:00
Lotus CIandClaude Opus 5.5 f7bdd2855a fix(composer): Escape closes the emoji and sticker boards (#304)
CI / Build & Quality Checks (pull_request) Successful in 2m46s
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) Successful in 15m19s
The board opens with focus in its search field, and its FocusTrap used
the shared stopPropagation helper, which ignores Escape while an input
is focused, so Escape never closed it (Chromium, Firefox, WebKit).
Always close on Escape here, still stopping the key from closing the
room or modal underneath. The GIF picker already uses the default.

Fixes #304

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:06:41 -04:00
Lotus CIandClaude Opus 5.5 26cf1204b5 fix(calls): the call-failed screen gets a Try again button (#301)
CI / Build & Quality Checks (pull_request) Successful in 2m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m57s
"The call failed to load… try again" only offered Back, so the user had
to back out and press Join again. Try again starts a fresh embed for
the same room with the same mic/camera/sound (which disposes the hung
one); Back stays as the secondary action.

Fixes #301

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:03:40 -04:00
Lotus CIandClaude Opus 5.5 e16c923bea fix(share): room share and invite links open Lotus Chat, not matrix.to (#285)
CI / Build & Quality Checks (pull_request) Successful in 2m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m6s
matrix.to can't send anyone to this deployment (its Cinny adapter is
app.cinny.in, web-instance[] is Element-only), so a friend scanning a
room QR or clicking an invite link was offered Element or app.cinny.in.
Share Room (link + QR) and the Invite dialog (link + QR) now use the
Lotus permalink (correct on desktop via webAppUrl). The space menus
keep both "Copy Link" (matrix.to, for other clients) and "Copy Lotus Link".

Fixes #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:59:57 -04:00
jared cc8e7444f6 Merge pull request 'fix(rooms): members can start and join calls in new private rooms (#328)' (#376) from fix/group-call-permission into lotus
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
2026-10-17 19:54:43 -04:00
Lotus CIandClaude Opus 5.5 f9172cc339 fix(polls): you can vote on a poll you just created (#284)
CI / Build & Quality Checks (pull_request) Successful in 2m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m12s
PollContent read mEvent.getId() once at render. For your own new poll
that is the local echo id (~!room:txn), and nothing re-rendered it when
the SDK swapped in the real $id, so the vote related to the local id
and the SDK threw synchronously (getPendingEvents under chronological
ordering): no vote was ever sent. Track the id via LocalEventIdReplaced
and Status, block voting while it is still local, and catch synchronous
throws. Adds the Playwright regression the issue asks for.

Fixes #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:54:01 -04:00
Lotus CIandClaude Opus 5.5 8bbb436ca7 fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322)
CI / Build & Quality Checks (pull_request) Successful in 2m56s
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 15m43s
The tray only knew what this browser had scheduled (localStorage), but
Synapse lists every pending one via MSC4140 GET /delayed_events. Load it
when the tray mounts and on window focus (localStorage stays as a first-
paint cache), keeping only non-state m.room.message events. Logout now
says "N scheduled messages still send after you log out" and offers to
cancel them.

Fixes #322

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:40:03 -04:00
Lotus CIandClaude Opus 5.5 4ddcda122a fix(calls): one delayed-event restart in flight per id, with a timeout (#318)
CI / Build & Quality Checks (pull_request) Successful in 3m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m31s
During a homeserver stall Element Call retries update_delayed_event every
2 s, and the host opened a new HTTP request each time: 12 pending in a
60 s stall, all landing together on recovery (404 x5, 429 x7). Share the
in-flight restart per delay id and abort it after 5 s.

Refs #318

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:35:06 -04:00
jared 9c433d9d77 Merge branch 'lotus' into fix/decline-references-notification
CI / Build & Quality Checks (pull_request) Successful in 2m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m45s
2026-10-17 19:29:23 -04:00
Lotus CIandClaude Opus 5.5 e8fd0a1112 fix(status): show a strip when the homeserver hangs, not 110 s later (#317)
CI / Build & Quality Checks (pull_request) Successful in 2m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m50s
A hung Synapse (accepts connections, never answers) kept sync SYNCING
for up to 110 s (pollTimeout 30 s + the SDK's 80 s buffer), so no strip
showed and Kuma was never asked. A watchdog now notices a /sync overdue
by 45 s, confirms with a 5 s GET /_matrix/client/versions, then shows
"Lotus Chat's server isn't responding…" and polls Kuma at once.

Fixes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:29:01 -04:00
jared 00e27a24e6 Merge branch 'lotus' into fix/filtered-timeline-double-count
CI / Build & Quality Checks (pull_request) Successful in 2m44s
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 18m21s
2026-10-17 19:08:38 -04:00
Lotus CIandClaude Opus 5.5 46d6570df3 fix(calls): lift CallControl's listener limit so ended calls can be freed (#316)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m30s
More than 10 call features subscribe to CallControl's state_update at once
(call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC). At
the default limit Node's events logged a MaxListenersExceededWarning, and
the console kept the warning's emitter (this CallControl, and through it
the whole Element Call document) alive: ~11 MB per call per the audit.
setMaxListeners(100); these are real, unsubscribed-on-cleanup listeners.

Not reproduced in this environment (headless web logs no warning: fewer
subscribers without the desktop hooks); this is the fix the issue
measured (swallowing the warning freed all CallControls).

Fixes #316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:57:22 -04:00
Lotus CIandClaude Opus 5.5 d7ea408fd0 chore(call): pin element-call-embedded 0.25.0-lotus.23 (sendNotificationType=none, #295)
CI / Build & Quality Checks (pull_request) Successful in 2m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:42:52 -04:00
Lotus CI d3f01b5116 Merge remote-tracking branch 'origin/lotus' into fix/voice-room-no-room-ping 2026-10-17 18:40:48 -04:00
jared f84c0d6e9e Merge pull request 'fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)' (#369) from fix/dialog-enter-closes into lotus
CI / Build & Quality Checks (push) Successful in 3m14s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 18:35:54 -04:00
Lotus CIandClaude Opus 5.5 1c62fb4722 fix(reactions): the reactions row no longer flickers or loses its spacing (#305)
CI / Build & Quality Checks (pull_request) Successful in 3m3s
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) Successful in 12m57s
Two bugs in the one-row "+N" collapse:
- measure() used the border box, which grows by 4 px while collapsed
  (margin -2 / padding 2), so a row within 4 px of fitting flipped
  collapsed↔expanded every frame (flicker, layout shifts, ~11 % of a core
  per looping message). It now measures the content box.
- The collapsed style used the `margin` shorthand, which overrode the
  timeline's marginTop and, when removed, cleared it; the row then stuck
  to the message text. Longhand margins keep marginTop (minus 2 px).

Verified headless (12 reactions, viewport sweep 1300–1400 px): before,
90 style flips in 1.5 s at 1344 px and margin-top 0px after a cycle;
now 0 flips at every width and margin-top stays 8px.

Fixes #305

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:26:16 -04:00
Lotus CIandClaude Opus 5.5 a6c7333ea3 fix(rooms): members can start and join calls in new private rooms (#328)
CI / Build & Quality Checks (pull_request) Successful in 2m58s
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) Successful in 14m30s
createRoom granted org.matrix.msc3401.call.member at user level only for
voice rooms. Every other room fell back to Synapse's state_default (50),
so in a private group room (where Lotus shows the call button and rings)
only admins could start a call; members saw "1 Live · Join" and then
"You don't have permission to join!". All new non-space rooms now get
call.member: 0 (spaces keep their own override).

Verified headless through Create Room → Private: before, no call.member
entry (state_default 50); now 0. Existing rooms are unchanged; an admin
can set it in room permissions.

Fixes #328

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:09:33 -04:00
Lotus CIandClaude Opus 5.5 a425aa3572 fix(calls): Reject sends the decline to the ring notification (#313)
CI / Build & Quality Checks (pull_request) Successful in 3m8s
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) Failing after 18m4s
handleReject related the MSC4310 decline to the caller's call.member event
(the notification's reference), but MSC4310 and Element Call match a
decline against the rtc.notification event itself, so the caller kept
"Calling…" for the full 90 s after being told "Declined", and declines
from other clients (correctly referencing the notification) didn't stop
Lotus ringing on other devices.

- The incoming call keeps the notification's own event id; Reject relates
  the decline to it.
- The "declined on another device" dismissal and declinedRing accept
  either id (older Lotus builds referenced the membership).
- The missed-call line is skipped for a call we declined.

Verified headless (DM, Bob rings, Reject): the decline used to reference
the membership; it now references the notification. Unit test added.

Fixes #313

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:01:06 -04:00
jared f0b7400d1b Merge branch 'lotus' into fix/dialog-enter-closes
CI / Build & Quality Checks (pull_request) Successful in 3m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m57s
2026-10-17 17:53:08 -04:00
Lotus CI d1270956ea Merge remote-tracking branch 'origin/lotus' into fix/filtered-timeline-double-count
CI / Secret scan (gitleaks) (pull_request) Successful in 29s
CI / Build & Quality Checks (pull_request) Failing after 11m33s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-17 17:52:35 -04:00
jared 3878a85339 Merge pull request 'fix(messages): the delivery indicator updates when a send completes (#306)' (#374) from fix/delivery-status-live into lotus
CI / Build & Quality Checks (push) Successful in 4m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 14m49s
2026-10-17 17:51:44 -04:00
jared 9edb76835c Merge pull request 'fix(rooms): upgrading a room keeps its Lotus settings (#327)' (#373) from fix/room-upgrade-lotus-state into lotus
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
2026-10-17 17:51:41 -04:00
jared 1784d40ba1 Merge pull request 'fix(notifications): quiet hours start and end while the app stays open (#325)' (#372) from fix/quiet-hours-tick into lotus
CI / Build & Quality Checks (push) Canceled after 1s
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
2026-10-17 17:51:40 -04:00
jared 76ef8a52c7 Merge pull request 'fix(logout): no draft or search opt-in left behind after logout (#319, #296)' (#371) from fix/logout-wipe-leftovers into lotus
CI / Build & Quality Checks (push) Canceled after 6s
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
2026-10-17 17:51:33 -04:00
jared a3ef2b4098 Merge pull request 'fix(fonts): the app uses Inter again (#320)' (#370) from fix/inter-font-name into lotus
CI / Build & Quality Checks (push) Canceled after 3s
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
2026-10-17 17:51:26 -04:00
jared 0c5cf4109a Merge pull request 'fix(retention): a tiny or brand-new retention value can't wipe a room (#298)' (#367) from fix/retention-floor-grace into lotus
CI / Build & Quality Checks (push) Successful in 3m55s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 14m5s
2026-10-16 22:48:18 -04:00
jared 39f74d51e6 Merge pull request 'fix(startup): Firefox no longer hangs on "Heating up" after login (#303)' (#366) from fix/firefox-persist-hang into lotus
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
2026-10-16 22:36:52 -04:00
Lotus CIandClaude Opus 5.5 36e697613b fix(messages): the delivery indicator updates when a send completes (#306)
CI / Build & Quality Checks (pull_request) Successful in 3m47s
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) Successful in 14m53s
DeliveryStatus showed whatever mEvent.status was at the last timeline
re-render, and a local echo's status changes (SENDING → SENT → confirmed,
NOT_SENT → retried) only emit event-level events, so the last message in
a quiet room kept "Sending..." (or a stale "Failed to send. Click to
retry" after an outbox retry) indefinitely. It now re-renders on
MatrixEventEvent.Status / LocalEventIdReplaced.

Not browser-verified in this change (checks: tsc, unit tests, eslint).

Fixes #306

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:25:41 -04:00
jared 2d31312a73 Merge pull request 'fix(calls): a finished call no longer rings again after a reload (#312)' (#365) from fix/phantom-ring-after-reload into lotus
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
2026-10-16 22:24:05 -04:00
Lotus CIandClaude Opus 5.5 57036bb6a4 fix(rooms): upgrading a room keeps its Lotus settings (#327)
CI / Build & Quality Checks (pull_request) Successful in 4m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m50s
POST /rooms/{id}/upgrade copies only Synapse's fixed list of state, so an
upgrade silently dropped everything Lotus keeps in room state: an
audio-only voice channel became camera/screenshare-allowed, user limits
and bitrate caps vanished, the soundboard pack was lost, and a room set
to delete messages after 7 days kept new ones forever.

RoomUpgrade now reads the client-owned state first (utils/roomUpgradeState,
unit-tested: io.lotus.*, m.room.retention, role tags, room emoji packs)
and re-creates it in the replacement room; a partial copy is reported in
the dialog instead of closing silently.

Verified headless through the UI (v10 room with voice limit, quality
policy, a soundboard pack and retention → upgrade): before, none
survived; now all four are in the new room.

Follow-up (not here): for spaces, the children's m.space.parent still
point at the old space.

Fixes #327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:23:15 -04:00
Lotus CIandClaude Opus 5.5 c6419044cb fix(notifications): quiet hours start and end while the app stays open (#325)
CI / Build & Quality Checks (pull_request) Successful in 4m7s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m45s
useNotificationsQuiet evaluated the quiet-hours window (and the
"Pause notifications" expiry) only at render time, and the long-lived
message/invite notifiers rarely re-render, so someone leaving Lotus open
overnight with quiet hours 22:00–07:00 got every sound all night (and a
30-minute pause could outlast itself). The hook now re-evaluates every
30 s.

Verified headless (notifications granted, window unfocused): quiet
hours set to start ~75 s after the app opened; a DM before the window
notified; one inside the window, with the app left alone: before it
still notified, now it's silent.

Fixes #325

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:19:24 -04:00
jared 29f6a35bfd Merge pull request 'fix(rooms): "Rename for me" names in search results, inbox and threads (#336)' (#364) from fix/local-room-names-rest into lotus
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
2026-10-16 22:12:55 -04:00
Lotus CIandClaude Opus 5.5 78691f1fff fix(logout): no draft or search opt-in left behind after logout (#319, #296)
CI / Build & Quality Checks (pull_request) Successful in 3m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m26s
- #319: logout wiped the drafts, then reloaded, and the reload's pagehide
  made RoomInput flush the open composer's (decrypted) draft straight back
  to localStorage. clearPlaintextCaches now sets a module flag and
  persistDraft stops writing once it's set (resets with the new page).
- #296: the device-wide encrypted-search opt-in (searchCacheEnabled)
  survived logout, so the next account started indexing decrypted text
  without opting in. It's now cleared, along with the SDK's per-user
  filter key.

Verified headless with a throwaway account (search opt-in on, "SECRET
half-typed reply" in the composer, Settings → Log out): before, the draft
and searchCacheEnabled=true remained; now neither does. Unit test added.

Fixes #319
Fixes #296

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:12:19 -04:00
Lotus CIandClaude Opus 5.5 a86947be48 fix(calls): starting a call in a voice channel no longer @room-pings everyone (#295)
CallEmbed asked Element Call to send a call-start notification for every
non-DM call. In persistent voice channels and space rooms it carries an
@room mention: every member got a highlighted ping (and phone push)
whenever a moderator or admin joined first, and other clients showed it
as a missed call. Now only rooms that can ring (isRingableRoom: DMs and
invite-only groups outside a space) get ring/notification; everything
else passes sendNotificationType=none (element-call-embedded 0.25.0-lotus.23).

Verified with a local build of the fork: an admin joining the Voice Lounge
sends none, and Bob gets no new notification (before: +1 highlighted).

Fixes #295

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:08:22 -04:00
Lotus CIandClaude Opus 5.5 37372b215a fix(fonts): the app uses Inter again (#320)
CI / Build & Quality Checks (pull_request) Successful in 3m49s
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) Successful in 14m34s
Since the switch to @fontsource-variable/inter 5 (May), the package
registers the family 'Inter Variable', but the app still asked for the
4.x name 'InterVariable', so every user got their platform's default
sans-serif (Arial / Helvetica / DejaVu) and Settings → Appearance →
font "Inter" did nothing.

--font-secondary (index.css) and the App font map now ask for
'Inter Variable' first, keeping 'InterVariable' as a fallback for a
locally installed old version.

Verified headless: before, 0 Inter faces loaded and
document.fonts.check('16px "Inter Variable"') false; now the face loads
and the check is true.

Fixes #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:01:05 -04:00
Lotus CIandClaude Opus 5.5 b6f9af3f58 fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)
CI / Build & Quality Checks (pull_request) Successful in 3m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m27s
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).

The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.

Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.

Fixes #315

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:58:05 -04:00
Lotus CIandClaude Opus 5.5 5b5fbdb7b7 fix(timeline): media gallery / activity log no longer make the timeline drop messages (#259)
CI / Build & Quality Checks (pull_request) Successful in 2m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m16s
The media gallery and activity log register filtered timeline sets on the
room (getOrCreateFilteredTimelineSet); the room re-emits their events as
live RoomEvent.Timeline, so every matching event reached "new event in
the room" listeners twice for the rest of the session. The timeline
window shifted by an extra message per event (loaded messages vanished),
and notifications, unread counts, widget feeds and ring handling saw
duplicates.

isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered
set; the room timeline, roomToUnread, message notifications, clock-skew
sampling, call ring/decline handling and widget event feeds ignore them.

Verified headless (40 messages, every 5th a file; gallery opened; Bob
sends 3 files + 1 text): before, the first rendered message drifted from
the room start to "file15"; now it stays put.

Fixes #259

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:42:47 -04:00
Lotus CIandClaude Opus 5.5 8b2322f751 fix(retention): a tiny or brand-new retention value can't wipe a room (#298)
CI / Build & Quality Checks (pull_request) Successful in 2m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m33s
m.room.retention can be set to anything by anyone with state power. A
fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus
user and, for members with "Enforce Message Retention" on, the sweeper
redacted all their messages within a minute. The RETENTION_MIN_MS floor
existed but was never applied.

- effectiveRetentionMs(): anything under 10 min counts as off; used by
  getRoomRetentionMs (sweeper) and the timeline's hiding.
- The sweeper never deletes under a policy less than 24 h old, so a
  mistake or a malicious change can be reverted before anything is lost.
  (Local hiding of expired messages still applies at once.)

Verified headless (the issue's repro, enforcement on): after
max_lifetime 1000, all 4 messages stay visible and none are redacted
after 45 s (before: timeline blank, all 4 redacted). Unit tests for the
floor and the grace window.

Fixes #298

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:37:16 -04:00
Lotus CIandClaude Opus 5.5 02950499ea fix(startup): Firefox no longer hangs on "Heating up" after login (#303)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m48s
initClient awaited navigator.storage.persist() before creating the client.
Chromium and WebKit answer at once, but Firefox shows a permission prompt
and leaves the promise pending until it's answered, so the app sat on
"Heating up" (no sync, no crypto, no IndexedDB) for every Firefox user who
didn't notice the prompt.

The request is still made (KE-1 only needs that), but startup waits at
most 1.5 s for the answer. The comment claiming persist() never prompts
is corrected.

Verified in Playwright Firefox with the prompt left unanswered
(persist() still pending): before, still "Heating up" after 20 s; now the
room list after 12 s.

Fixes #303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:31:55 -04:00
Lotus CIandClaude Opus 5.5 05d7e87d26 fix(calls): a finished call no longer rings again after a reload (#312)
CI / Build & Quality Checks (pull_request) Successful in 1m39s
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 10m58s
After a reload/restart within a ring's lifetime, the catch-up sync replays
the ring together with the caller's hang-up and our own decline, and the
MatrixRTC session can keep the stale join. The client rang again for up to
90 s and showed "1 Live" (call screen instead of chat) for minutes.

- Before ringing, check the room STATE (utils/callRing.ts, unit-tested):
  no ring if we already declined that ring or are in the call; if the
  caller has no active membership yet, re-check after 4 s (a real call's
  membership can land just after its ring) and ring only if they joined.
- The "caller gone" dismissal also consults room state, not only the
  possibly stale session.
- When sync becomes live (startup or reconnect), recompute every active
  RTC session from the current state, clearing a stale "Live".

Verified headless (Bob's membership + ring in an invite-only room):
live ring rings; hang-up stops it; reload within the window: before
rang again + "1 Live", now no ring and no "Live"; a ring that arrives
just before the membership still rings.

Fixes #312

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:28:33 -04:00
Lotus CIandClaude Opus 5.5 bbb2034080 fix(rooms): "Rename for me" names in search results, inbox and threads (#336)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m5s
Follow-up to #359: the personal room name now also shows in message-search
result groups and the search room filter (whose room search matches it
too), Inbox → Notifications, the threads list and the thread panel header.

Verified headless with general renamed to "Zebra Lounge": a global message
search and Inbox → Notifications both show "Zebra Lounge".

Fixes #336

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:19:47 -04:00
jared 400ee76b63 Merge pull request 'fix(calls): no "Missed call" in voice rooms or space channels' (#363) from fix/missed-call-voice-rooms into lotus
CI / Build & Quality Checks (push) Successful in 1m44s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m58s
2026-10-16 21:06:57 -04:00
jared 47c1e435b7 Merge pull request 'perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)' (#361) from fix/animated-bg-compositor into lotus
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Canceled after 8s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 20:52:34 -04:00
Lotus CIandClaude Opus 5.5 4650796120 fix(calls): no "Missed call" in voice rooms or space channels
CI / Build & Quality Checks (pull_request) Successful in 1m39s
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 10m48s
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.

utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.

Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 20:29:50 -04:00
jared d55a8ba8a5 Merge branch 'lotus' into fix/animated-bg-compositor
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m11s
2026-10-16 19:59:31 -04:00
jared 4b8c3219e9 Merge pull request 'fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)' (#360) from fix/link-safety-bypasses into lotus
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 5s
CI / Playwright smoke (e2e) (push) Successful in 12m20s
2026-10-16 19:59:19 -04:00
jared 47328d6fee Merge branch 'lotus' into fix/link-safety-bypasses
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m25s
2026-10-16 19:33:54 -04:00
jared b0eb3da830 Merge pull request 'test(e2e): allow the register probe's 403 now that sign-up is closed' (#362) from fix/e2e-register-403 into lotus
CI / Build & Quality Checks (push) Successful in 1m37s
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 10m28s
2026-10-16 19:33:43 -04:00
Lotus CIandClaude Opus 5.5 f22f9f07a3 test(e2e): allow the register probe's 403 now that sign-up is closed
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 9m50s
matrix.lotusguild.org turned off open registration (2026-10-16; accounts
come from SSO/LLDAP), so the login page's registration probe
(POST /_matrix/client/v3/register) now answers 403 "Registration has been
disabled" instead of 401 + UIA flows. The boot smoke test treated that
console line as unexpected and failed on every branch (seen on #360).

collectConsole now skips a 403 only when its source URL is the register
endpoint; any other 403 still fails the test.

Verified: boot.spec passes locally in chromium, webkit and iphone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:12:15 -04:00
Lotus CIandClaude Opus 5.5 2700aad209 perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)
CI / Build & Quality Checks (pull_request) Successful in 1m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Failing after 9m56s
background-position can't run on the compositor, so every frame of the
animated chat backgrounds repainted the whole chat area: 51 to 72% main-thread
busy while idle in a room at 60 fps. The drifts are slow (3 to 22 px/s,
under a pixel per frame), so each animation now uses steps(): about 12
updates/s for fireflies, stars and aurora and about 24/s for rain and
pulse.

Measured headless (20 s idle in a room, CDP TaskDuration):
  none 0.7% → 0.9%; rain 50.7 → 5.9; stars 71.2 → 4.5;
  pulse 64.9 → 5.8; aurora 58.9 → 4.2; fireflies 71.7 → 3.8.

Aurora and pulse used ease-in-out; with steps() their motion is now
even-paced. Pause animations / reduced motion still strip the animation.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:05:52 -04:00
Lotus CIandClaude Opus 5.5 a8a7e639a9 fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)
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) Failing after 11m30s
Two easy bypasses of the "this link goes somewhere else" check:

- Any whitespace in the link text disabled it, so "Log in at
  chat.lotusguild.org" → evil.example wasn't flagged. hostsFromText now
  scans each word for host-shaped tokens; a link is flagged if any named
  host is a different site. Bare file names (main.py, report.pdf) and
  version numbers are skipped inside sentences so ordinary links aren't
  flagged.
- Shared-hosting suffixes counted as one site (alice.github.io vs
  evil.github.io, *.pages.dev, netlify.app, vercel.app, …).
  registrableDomain now treats a curated list of them as public suffixes
  (longest match, incl. s3.amazonaws.com). Curated rather than the full
  PSL to stay inside the bundle budget.

Verified with unit tests and headless on real messages: the four bypass
cases are flagged; the honest sentence, file/version text and "click
here" are not.

Fixes #335

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 18:56:01 -04:00
jared 508a123150 Merge pull request 'fix(rooms): "Rename for me" names in Ctrl+K, forward/share pickers and notifications (#336)' (#359) from fix/local-room-names-everywhere into lotus
CI / Build & Quality Checks (push) Successful in 3m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 15m26s
2026-10-12 16:44:08 -04:00
jared fe61352d88 Merge pull request 'fix(messages): collapsed long messages work with the keyboard and on any background (#343)' (#358) from fix/collapsible-messages into lotus
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
2026-10-12 16:43:59 -04:00
jared 0bec28b651 Merge pull request 'fix(logout): clear the call-volume list and settings-sync stamp (#347)' (#357) from fix/logout-volume-residue into lotus
CI / Build & Quality Checks (push) Successful in 3m9s
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) Canceled after 6s
2026-10-12 16:07:43 -04:00
jared 75089ecd3f Merge pull request 'fix(settings): "ENTER for Newline" stays per device (#348)' (#356) from fix/enter-newline-device-local into lotus
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
2026-10-12 16:07:33 -04:00
Lotus CIandClaude Opus 5.5 a4a163df3d fix(rooms): "Rename for me" names in Ctrl+K, forward/share pickers and notifications (#336)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m29s
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.

useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.

Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".

Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.

Refs #336

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:40:56 -04:00
Lotus CIandClaude Opus 5.5 70f1d640ed fix(messages): collapsed long messages work with the keyboard and on any background (#343)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m15s
- Tab into a link in the hidden part used to scroll the overflow:hidden box
  itself (top cut off, and it stayed that way); focus inside now expands
  the message, and collapsing resets the scroll.
- "Read more" / "Show less" is announced as a toggle (aria-expanded,
  aria-controls); the arrows are aria-hidden.
- The fade is a mask on the content instead of a gradient in a fixed
  surface colour, so there's no band in bubble layout, on highlights or
  over chat backgrounds.
- The cap is 20rem instead of 320px, so a larger font shows as many lines.

Verified headless on a 41-line message with a link on the last line:
aria-expanded false → focusing the link expands it (aria-expanded true,
no clipping); bubble layout fades into the bubble with no band.

Fixes #343

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:31:50 -04:00
Lotus CIandClaude Opus 5.5 85a2abc46a fix(logout): clear the call-volume list and settings-sync stamp (#347)
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m33s
Logout left lotus-per-user-volume (Element Call's per-person volume map:
whom the previous user called and turned up/down; same origin on the web)
and settings-sync-meta:<userId> (a key naming the account) in
localStorage. clearPlaintextCaches now removes both; device preferences
(`settings`) are kept as before.

Verified: after joining/leaving a call and logging out via Settings, both
keys are gone (unit test added).

Fixes #347

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:28:41 -04:00
Lotus CIandClaude Opus 5.5 5130dfc9c8 fix(settings): "ENTER for Newline" stays per device (#348)
CI / Build & Quality Checks (pull_request) Successful in 3m6s
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) Successful in 14m37s
Settings sync mirrored enterForNewline to io.lotus.settings, so turning it
on for a phone keyboard switched the desktop to Ctrl+Enter-to-send (and
vice versa). It is now in DEVICE_LOCAL_KEYS.

Verified with one account on a phone and a desktop profile: toggling it
on the phone leaves the desktop at Enter-to-send, and it is no longer in
the synced account data.

Fixes #348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:27:01 -04:00
jared e57af60466 Merge pull request 'fix(presence): leaving Do Not Disturb restores your status instead of "dnd" (#283)' (#355) from fix/dnd-restore-status into lotus
CI / Build & Quality Checks (push) Successful in 3m28s
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) Successful in 13m55s
2026-10-12 13:12:50 -04:00
jared 1f10866b60 Merge pull request 'fix(presence): Invisible, Hide Online Status, Idle and DND stick (#282)' (#354) from fix/presence-sticky into lotus
CI / Build & Quality Checks (push) Successful in 3m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 14s
2026-10-12 12:53:55 -04:00
Lotus CIandClaude Opus 5.5 d9c797f3f5 fix(presence): leaving Do Not Disturb restores your status instead of "dnd" (#283)
CI / Build & Quality Checks (pull_request) Successful in 3m9s
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 12m36s
Lotus encodes DND as status_msg "dnd". After switching back to Auto, the
heartbeat read the server's status, adopted "dnd" as the user's own and
kept re-sending it, so the custom status was lost and other clients kept
showing "dnd"; the Settings status field showed "dnd" too.

pickHeartbeatStatus (unit-tested) never adopts the "dnd" sentinel and
falls back to the device's real status; Profile neither mirrors it into
the local status nor shows it in the field.

Verified on the local Synapse: "🎮 Gaming" → DND (unavailable/dnd) → Auto
→ online/"🎮 Gaming" (before: online/"dnd").

Still open in #283: while in DND other clients show the literal "dnd";
that needs MSC3026 busy presence on the homeserver (matrix#19).

Refs #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 12:39:38 -04:00
Lotus CIandClaude Opus 5.5 9b2ec91dd6 fix(presence): Invisible, Hide Online Status, Idle and DND stick (#282)
CI / Build & Quality Checks (pull_request) Successful in 3m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m50s
usePresenceUpdater sent a one-off PUT /presence, but the SDK kept
long-polling /sync with the default set_presence=online and Synapse treats
every sync as "online", so Invisible / Hide Online Status / Idle / DND
flipped back within seconds — a privacy setting that silently didn't work.

The presence helpers now also call mx.setSyncPresence(): offline for
Invisible / Hide Online Status, unavailable for Idle / DND (and auto-mode
idle / hidden tab), online otherwise. The PUT stays for the immediate
change.

Verified on the local Synapse, another account polling every 5 s for 60 s
after a reload in each mode: invisible → offline ×12, hidePresence →
offline ×12, idle → unavailable ×12, dnd → unavailable ×12, online →
online (currently_active) ×12; /sync carries set_presence=offline /
unavailable / online accordingly (before: online* throughout).

Fixes #282

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 12:35:26 -04:00
jared 1618d71e53 Merge pull request 'fix(location): confirm before sharing, with accuracy and an approximate option (#340)' (#353) from fix/location-confirm into lotus
CI / Build & Quality Checks (push) Successful in 3m8s
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 12m15s
2026-10-11 23:23:06 -04:00
jared 4736e23400 Merge pull request 'fix(reminders): a reminder stays until you've seen it (#338)' (#352) from fix/reminders-stick into lotus
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
2026-10-11 23:22:56 -04:00
jared 06294f5269 Merge pull request 'fix(calls): "Channel Full" counts people, not devices (#342)' (#351) from fix/voice-full-distinct-users into lotus
CI / Build & Quality Checks (push) Canceled after 44s
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
2026-10-11 22:57:38 -04:00
jared fea9484a03 Merge pull request 'fix(presence): closing a tab no longer erases your custom status (#339)' (#350) from fix/presence-pagehide-status into lotus
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
2026-10-11 22:57:25 -04:00
Lotus CIandClaude Opus 5.5 cf22fd8501 fix(location): confirm before sharing, with accuracy and an approximate option (#340)
CI / Build & Quality Checks (pull_request) Successful in 3m19s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m32s
The composer's location button sent the exact position (6 decimals) from a
single click once the browser permission had been granted, discarding the
fix's accuracy, so a ±3.5 km Wi-Fi/IP fix was shown as exact.

- ShareLocationDialog: "Share your location?" with the coordinates and
  ±accuracy, a "Check on OpenStreetMap" link, and Cancel / Send
  approximate (~1 km) / Send. Focus starts on Cancel; Esc cancels.
- utils/geoShare.ts (unit-tested): RFC 5870 geo URI with `;u=` accuracy;
  approximate shares round to 2 decimals and never claim better than 1 km.
- Location cards show the ± when the event carries `u=`.
- The toolbar icon is a pin instead of the globe.

Verified headless (fix 40.748817,-73.985428 ±3500 m): the click opens the
dialog and sends nothing; Cancel/Esc send nothing; Send approximate →
geo:40.75,-73.99;u=3500; Send → geo:40.748817,-73.985428;u=3500; the card
reads "40.74882, -73.98543 (±3.5 km)".

Fixes #340

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:16:05 -04:00
Lotus CIandClaude Opus 5.5 861293243c fix(calls): "Channel Full" counts people, not devices (#342)
CI / Build & Quality Checks (pull_request) Successful in 3m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m44s
useVoiceChannelFull compared the number of MatrixRTC memberships (one per
device) with io.lotus.voice_limit, so one person on desktop + phone, or a
crashed tab whose membership hadn't expired, filled two slots and locked
others out, while the server-side guard (which counts distinct users)
would have admitted them. utils/voiceChannelFull.ts (unit-tested) counts
distinct senders; the header/sidebar counter shows people too.

Verified on the local stack: limit 2, @aud_main in the call on two
devices; @aud_bob's prescreen now shows 1/2 with Join enabled (was
"Channel Full (2/2)"), matching the guard's 200.

Fixes #342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:07:18 -04:00
Lotus CIandClaude Opus 5.5 e50bd677d6 fix(presence): closing a tab no longer erases your custom status (#339)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m20s
The pagehide keepalive write sent { presence: 'offline' } with no
status_msg, which Synapse treats as clearing it, so closing or reloading
any Lotus tab wiped the user's status for everyone (intermittently, as
the 10 s presence rate limit sometimes 429'd the write). It now sends the
current status along with the offline state.

Verified against the local Synapse: with a status set from another
device, two reloads and a tab close keep it (server shows
offline/"Gone fishing" after the close; before, the first reload wiped it).

Fixes #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:04:57 -04:00
124 changed files with 2987 additions and 511 deletions
+12 -1
View File
@@ -42,7 +42,18 @@ export function collectConsole(page: Page): ConsoleCollector {
const errors: string[] = [];
const pageErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
if (msg.type() !== 'error') return;
// The login page's registration probe (POST /_matrix/client/v3/register)
// answers 403 "Registration has been disabled" on a homeserver with sign-up
// closed (matrix.lotusguild.org since 2026-10-16: accounts come from SSO).
// Allowed only for that URL so any other 403 still fails the test.
if (
/status of 403/.test(msg.text()) &&
/\/_matrix\/client\/v3\/register/.test(msg.location().url)
) {
return;
}
errors.push(msg.text());
});
page.on('pageerror', (err) => {
pageErrors.push(err.message);
+46
View File
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
expect(console_.unexpected()).toEqual([]);
});
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Poll Room');
await loginUI(page, alice);
await openRoom(page, room);
const question = uniq('Lunch? ');
await page.getByRole('button', { name: 'Create poll' }).first().click();
await page.getByPlaceholder('Ask a question…').fill(question);
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
await page.getByRole('button', { name: 'Create Poll' }).last().click();
const poll = page.locator('[data-message-id]', { hasText: question }).last();
await expect(poll).toBeVisible();
type Ev = {
type: string;
event_id: string;
content: { 'm.relates_to'?: { event_id?: string } };
};
const events = async () =>
(
await api<{ chunk: Ev[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
alice.token,
)
).chunk;
// Vote once the poll has reached the server (its row may still carry the local id).
await expect
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
.toBe(true);
await poll.getByText('Tacos', { exact: true }).first().click();
await expect
.poll(async () => {
const chunk = await events();
const start = chunk.find((e) => /poll\.start/.test(e.type));
return chunk.some(
(e) =>
/poll\.response/.test(e.type) &&
e.content['m.relates_to']?.event_id === start?.event_id &&
start?.event_id.startsWith('$'),
);
})
.toBe(true);
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>>(
+42
View File
@@ -29,6 +29,32 @@ async function settled(page: Page) {
await page.waitForTimeout(2000);
}
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
async function mockAnnouncement(page: Page) {
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const cors = { 'access-control-allow-origin': '*' };
if (route.request().url().includes('/heartbeat/')) {
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
maintenanceList: [],
publicGroupList: [],
},
});
});
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
test.setTimeout(200_000);
await mockAnnouncement(page);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// The announcement wins the one strip slot until it's dismissed.
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
await expect(announcement).toBeVisible();
await expect(strip(page)).toHaveCount(0);
await announcement.getByRole('button', { name: 'Dismiss' }).click();
await page.reload();
await settled(page);
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"version": "0.25.0-lotus.23",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+1 -1
View File
@@ -297,7 +297,7 @@ export function AccountDataEditor({
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
+77 -15
View File
@@ -18,9 +18,10 @@ import {
toRem,
} from 'folds';
import {
ClientEvent,
EventTimelineSetHandlerMap,
EventType,
JoinRule,
SyncState,
RelationType,
Room,
RoomEvent,
@@ -72,7 +73,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -87,6 +89,8 @@ import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
/** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72;
type Corner = 'se' | 'sw' | 'ne' | 'nw';
@@ -109,6 +113,8 @@ type IncomingCallInfo = {
intent?: string;
notificationType: RTCNotificationType;
refEventId: string;
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
notificationEventId?: string;
};
type IncomingCallProps = {
dm: boolean;
@@ -281,7 +287,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
onClick={() =>
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
}
before={<Icon size="200" src={Icons.Cross} filled />}
>
<Text as="span" size="B400">
@@ -453,7 +461,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
size="300"
radii="300"
outlined
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
onClick={() =>
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
}
before={<Icon size="100" src={Icons.Cross} filled />}
>
<Text as="span" size="B300">
@@ -494,6 +504,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => {
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// only process rtc notification reference events.
// we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
@@ -581,12 +592,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
// with no space parent. Persistent voice rooms (call rooms), space channels,
// restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return;
const isDirect = directs.has(room.roomId);
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
if (!isDirect && !isPrivateInviteGroup) return;
if (!isRingableRoom(room, directs.has(room.roomId))) return;
const info: IncomingCallInfo = {
room,
@@ -599,13 +605,59 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
: undefined,
notificationType,
refEventId,
notificationEventId: event.getId(),
};
setCallInfo(info);
// [Gitea #312] Don't ring for a call that is already over or handled: after
// a reload the first sync replays the ring together with the caller's
// hang-up and our own decline. Check the room STATE (not the possibly
// stale RTC session). The caller's membership can arrive just after the
// ring, so an absent one gets a short grace period before giving up.
const myUserId = mx.getSafeUserId();
if (
declinedRing(room, [refEventId, event.getId()], myUserId) ||
hasActiveCallMembership(room, myUserId)
) {
return;
}
if (hasActiveCallMembership(room, sender)) {
setCallInfo(info);
return;
}
setTimeout(() => {
if (Date.now() >= senderTs + lifetime) return;
if (
declinedRing(room, [refEventId, event.getId()], myUserId) ||
hasActiveCallMembership(room, myUserId)
) {
return;
}
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
}, 4000);
},
[mx, directs, callEmbed, setToast],
);
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
// call's join and leave in one batch, and the RTC session may keep the join
// (stale "1 Live", call screen instead of chat). Once sync is live again,
// recompute every active session from the room's current state.
useEffect(() => {
const onSync = (state: SyncState, prev: SyncState | null) => {
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
mx.getRooms().forEach((r) => {
mx.matrixRTC
.getActiveRoomSession(r)
?._onRTCSessionMemberUpdate()
.catch(() => undefined);
});
};
mx.on(ClientEvent.Sync, onSync);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
};
}, [mx]);
useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
@@ -634,7 +686,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const { memberships } = session;
if (memberships.some((m) => m.sender === myUserId))
dismiss(); // answered elsewhere
else if (memberships.length === 0) dismiss(); // caller gone
else if (memberships.length === 0)
dismiss(); // caller gone
// [Gitea #312] The session can be stale after a reload; the room state isn't.
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
};
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event,
@@ -644,9 +699,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
data,
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
const related = event.getRelation()?.event_id;
if (!related || related === refEventId) dismiss(); // declined elsewhere
// [Gitea #313] Correct clients reference the notification; older Lotus
// builds referenced the call membership. Accept both.
if (!related || related === refEventId || related === callInfo.notificationEventId)
dismiss(); // declined elsewhere
}
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
@@ -1007,8 +1066,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
} else {
el.style.left = `${window.innerWidth - 280 - 16}px`;
el.style.top = `${window.innerHeight - 158 - 72}px`;
// [Gitea #323] First use: top-right under the room header, over the
// timeline. Bottom-right sat on the composer (Send / Emoji, and on
// phones the whole message box), so typing landed on the call.
el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`;
el.style.top = `${PIP_DEFAULT_TOP}px`;
}
el.style.width = '280px';
el.style.height = '158px';
+12 -1
View File
@@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() {
const [opened, setOpened] = useState(false);
const [blockedCount, setBlockedCount] = useState(0);
const snoozedUntil = useRef(0);
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
const closingForRetry = useRef(false);
const blocked = useRef(new Map<MatrixEvent, Room>());
const modalStyle = useModalStyle(440);
const server = mx.getDomain() ?? 'Your homeserver';
@@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() {
}, [consentUri]);
const retry = useCallback(() => {
closingForRetry.current = true;
snoozedUntil.current = 0;
setOpen(false);
setOpened(false);
const pending = Array.from(blocked.current.entries());
@@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() {
focusTrapOptions={{
initialFocus: '#consent-review',
fallbackFocus: '#consent-later',
onDeactivate: later,
onDeactivate: () => {
if (closingForRetry.current) {
closingForRetry.current = false;
return;
}
later();
},
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
Setup Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
Reset Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+54 -14
View File
@@ -1,4 +1,4 @@
import React, { forwardRef, useCallback } from 'react';
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { useAtom } from 'jotai';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card';
import { ScheduledMessage } from '../state/scheduledMessages';
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
import {
useDeviceVerificationStatus,
VerificationStatus,
@@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
// 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 hangupCallAndWait(mx, callEmbed);
setCallEmbed(undefined);
}
await logoutClient(mx);
}, [mx, callEmbed, setCallEmbed]),
// [Gitea #322] Scheduled messages live on the server and still send after
// logout, where no client would show them. Say so, and offer to cancel.
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
useEffect(() => {
let alive = true;
fetchScheduledMessages(mx)
.then((list) => alive && setScheduled(list))
.catch(() => undefined);
return () => {
alive = false;
};
}, [mx]);
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
useCallback(
async (cancelScheduled?: boolean) => {
if (cancelScheduled) {
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
}
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
// 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 hangupCallAndWait(mx, callEmbed);
setCallEmbed(undefined);
}
await logoutClient(mx);
},
[mx, callEmbed, setCallEmbed, scheduled],
),
);
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
/>
))}
{scheduled.length > 0 && (
<InfoCard
variant="Warning"
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
/>
)}
<Text priority="400">You’re about to log out. Are you sure?</Text>
{logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
<Box direction="Column" gap="200">
<Button
variant="Critical"
onClick={logout}
onClick={() => logout()}
disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
>
<Text size="B400">Logout</Text>
<Text size="B400">
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
</Text>
</Button>
{scheduled.length > 0 && (
<Button
variant="Critical"
fill="Soft"
onClick={() => logout(true)}
disabled={ongoingLogout}
>
<Text size="B400">Cancel scheduled messages and logout</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">Cancel</Text>
</Button>
+5 -2
View File
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation,
data.additionalCreators,
),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
// [Gitea #328] Every non-space room gets call.member at user level, not just
// voice rooms: the call button is shown in private group rooms too, and with
// Synapse's state_default (50) only admins could start or join a call there
// ("You don't have permission to join!").
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
initial_state: initialState,
};
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
@@ -612,7 +612,13 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
escapeDeactivates: stopPropagation,
// [Gitea #304] The board opens with focus in its search field, where
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
}}
>
<EmojiBoardLayout
@@ -16,7 +16,7 @@ import { SettingTile } from '../setting-tile';
import { UsageSwitcher } from './UsageSwitcher';
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload';
import { getImageInfo, TUploadContent } from '../../utils/matrix';
@@ -60,7 +60,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
[imagePack, savedImages, uploadedImages],
);
const pickFiles = useFilePicker(
const pickFiles = useStrippedFilePicker(
useCallback(
(pickedFiles: File[]) => {
const uniqueFiles = pickedFiles.map((file) => {
@@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common';
import { BreakWord } from '../../styles/Text.css';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card';
@@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const pickFile = useStrippedFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive';
import { copyToClipboard } from '../../utils/dom';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const [linkCopied, setLinkCopied] = useState(false);
const [showQr, setShowQr] = useState(false);
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
const lotusBase = useLotusShareBase();
const inviteUrl = (() => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
return getMatrixToRoom(roomIdOrAlias, viaServers);
return room.isSpaceRoom()
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
})();
const handleCopyLink = () => {
+58 -22
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import React, { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent, MatrixEvent } from 'matrix-js-sdk';
@@ -37,10 +37,15 @@ import {
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
import { previewUrlsFor } from '../../utils/linkSafety';
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
const COLLAPSE_MAX_HEIGHT_REM = 20;
const collapseMaxHeightPx = (): number =>
COLLAPSE_MAX_HEIGHT_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
type CollapsibleBodyProps = {
eventId?: string;
@@ -78,7 +83,7 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
const el = bodyRef.current;
if (!el) return undefined;
const observer = new ResizeObserver(() => {
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
setNeedsCollapse(el.scrollHeight > collapseMaxHeightPx());
});
observer.observe(el);
return () => observer.disconnect();
@@ -88,16 +93,36 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
// (Gitea #85); useReducedMotion subscribes to the change event instead.
const prefersReducedMotion = useReducedMotion();
const bodyId = useId();
const isClipped = needsCollapse && collapsed;
// [Gitea #343] The clipped part stays in the DOM, so Tab can reach a link in
// it; the browser would then scroll the overflow:hidden box itself (top cut
// off, and it stayed that way). Expand instead when focus lands inside.
const handleFocus = () => {
if (isClipped) setCollapsed(false);
};
useEffect(() => {
if (isClipped && bodyRef.current) bodyRef.current.scrollTop = 0;
}, [isClipped]);
// Fade the content itself rather than painting a fixed-colour gradient over
// it, so it blends on any background (bubbles, highlights, chat backgrounds).
const fadeMask = 'linear-gradient(#000 calc(100% - 3rem), transparent)';
return (
<div>
<div
ref={bodyRef}
id={bodyId}
onFocus={handleFocus}
style={{
position: 'relative',
...(needsCollapse && collapsed
...(isClipped
? {
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
maxHeight: `${COLLAPSE_MAX_HEIGHT_REM}rem`,
overflow: 'hidden',
maskImage: fadeMask,
WebkitMaskImage: fadeMask,
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
}
: {
@@ -106,23 +131,12 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
}}
>
{children}
{needsCollapse && collapsed && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '3rem',
background: `linear-gradient(transparent, ${color.Surface.Container})`,
pointerEvents: 'none',
}}
/>
)}
</div>
{needsCollapse && (
<button
type="button"
aria-expanded={!collapsed}
aria-controls={bodyId}
onClick={() => setCollapsed((c) => !c)}
style={{
cursor: 'pointer',
@@ -133,7 +147,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
}}
>
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
{collapsed ? 'Read more ↓' : 'Show less ↑'}
{collapsed ? 'Read more' : 'Show less'}
<span aria-hidden="true">{collapsed ? ' ↓' : ' ↑'}</span>
</Text>
</button>
)}
@@ -329,7 +344,13 @@ export function MText({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -387,7 +408,13 @@ export function MEmote({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -444,7 +471,13 @@ export function MNotice({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -732,6 +765,8 @@ export function MLocation({ content }: MLocationProps) {
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
// RFC 5870 `;u=` uncertainty (meters), sent by Lotus since Gitea #340.
const accuracyLabel = formatAccuracy(Number(/;u=([\d.]+)/.exec(geoUri)?.[1]));
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
@@ -772,6 +807,7 @@ export function MLocation({ content }: MLocationProps) {
)}
<Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Button
as="a"
@@ -1,7 +1,7 @@
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 { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName, sendRoomEvent } from '../../../utils/room';
import {
@@ -99,7 +99,21 @@ export function PollContent({
}) {
const mx = useMatrixClient();
const roomId = room.roomId;
const eventId = mEvent.getId();
// [Gitea #284] A poll you just created starts with its local-echo id
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
// doesn't re-render us. Track the id so votes and the tally use the real one.
const [eventId, setEventId] = useState(() => mEvent.getId());
useEffect(() => {
const sync = () => setEventId(mEvent.getId());
sync();
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.on(MatrixEventEvent.Status, sync);
return () => {
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.off(MatrixEventEvent.Status, sync);
};
}, [mEvent]);
const isLocalEcho = !!eventId?.startsWith('~');
const senderId = mEvent.getSender();
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
@@ -192,8 +206,9 @@ export function PollContent({
const isMultiple = maxSelections > 1;
const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isEnded;
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
const canEnd =
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally;
@@ -219,25 +234,34 @@ export function PollContent({
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
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));
// The SDK can throw synchronously (e.g. a relation to a pending event).
try {
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));
} catch {
setPending(null);
}
};
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
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.',
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
try {
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.',
})
.catch(() => setEnding(false));
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
} catch {
setEnding(false);
}
};
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
@@ -43,7 +43,9 @@ export function SettingTile({ title, description, before, after, children }: Set
</Text>
)}
{description && (
<Text className={BreakWord} size="T200" priority="300">
// [Gitea #291] A <div>, not a <p>: many tiles pass blocks (badges,
// <Box>, <Text>) as the description, which is invalid inside a <p>.
<Text as="div" className={BreakWord} size="T200" priority="300">
{description}
</Text>
)}
@@ -310,9 +310,13 @@ export function UploadCardRenderer({
const { file } = upload;
const fileSizeExceeded = file.size >= allowSize;
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
startUpload();
}
// [Gitea #291] Start in an effect, not during render: the desktop taskbar
// progress hook subscribes to the upload atoms, so a render-time start
// updated another component mid-render (React warning on every image send).
const idle = upload.status === UploadStatus.Idle;
useEffect(() => {
if (idle && !fileSizeExceeded) startUpload();
}, [idle, fileSizeExceeded, startUpload]);
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
@@ -54,6 +54,7 @@ import {
GIF_PREVIEW_MAX_WIDTH,
gifPreviewBox,
} from '../../utils/gifPreviewSize';
import { sameProvider } from './embedProvider';
const linkStyles = { color: color.Success.Main };
@@ -2497,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
if (embed) return embed;
const ogUrl = prev['og:url'];
return typeof ogUrl === 'string' && ogUrl !== url
// [Gitea #326] …but only from an og:url on the posted link's own
// provider: the page sets og:url, so any site could otherwise show up
// as a playable YouTube (etc.) card that links to itself.
return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl)
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sameProvider } from './embedProvider';
test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => {
const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
assert.equal(sameProvider('https://evil.example/free-gift', yt), false);
assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false);
assert.equal(sameProvider('https://youtu.be/abc', yt), true);
assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true);
assert.equal(
sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'),
true,
);
assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true);
assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true);
assert.equal(
sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'),
true,
);
assert.equal(sameProvider('not a url', yt), false);
});
@@ -0,0 +1,36 @@
/**
* [Gitea #326] A page's og:url is chosen by the page itself, so a phishing
* link could serve `og:url = youtube.com/watch?v=…` and render as a playable
* YouTube card linking to the attacker. Only trust an og:url on the same
* provider as the link actually posted (including the provider's own
* short-link domains).
*/
const PROVIDER_ALIASES: Record<string, string> = {
'youtu.be': 'youtube.com',
'youtube-nocookie.com': 'youtube.com',
'spotify.link': 'spotify.com',
'redd.it': 'reddit.com',
'x.com': 'twitter.com',
'twitch.tv': 'twitch.tv',
'apple.co': 'apple.com',
};
/**
* Pure: the provider a host belongs to (last two labels, aliases folded). Hosts
* under two-part public suffixes (`*.co.uk`) share a value, which is harmless
* here: no embeddable provider lives under one.
*/
export const providerOf = (host: string): string => {
const parts = host.toLowerCase().replace(/\.$/, '').split('.');
const base = parts.slice(-2).join('.');
return PROVIDER_ALIASES[base] ?? base;
};
/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */
export function sameProvider(url: string, ogUrl: string): boolean {
try {
return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname);
} catch {
return false;
}
}
+15 -1
View File
@@ -6,12 +6,13 @@ import {
useCallJoined,
useCallEmbedPlacementSync,
useCallLoadError,
useCallStart,
} from '../../hooks/useCallEmbed';
import { callEmbedAtom } from '../../state/callEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -160,6 +161,16 @@ function CallLoadErrorMessage() {
// prescreen, where they can choose to join again.
const dismiss = () => setCallEmbed(undefined);
// [Gitea #301] Try again: start a fresh embed for the same room with the
// same mic/camera/sound choices (starting one disposes the hung one).
const callEmbed = useCallEmbed();
const startCall = useCallStart(useIsDirectRoom());
const retry = () => {
if (!callEmbed) return;
const { room, control } = callEmbed;
startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound });
};
return (
<Box
className={css.CallViewContent}
@@ -173,6 +184,9 @@ function CallLoadErrorMessage() {
The call failed to load. Check your connection and try again.
</Text>
<Box gap="200" alignItems="Center">
<Button variant="Primary" size="300" radii="400" onClick={retry} disabled={!callEmbed}>
<Text size="B400">Try again</Text>
</Button>
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Back</Text>
</Button>
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { stateToCarry } from '../../../utils/roomUpgradeState';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const [upgradeState, upgrade] = useAsyncCallback(
useCallback(
async (version: string, newAdditionalCreators?: string[]) => {
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
new_version: version,
additional_creators: newAdditionalCreators,
});
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
// policy, soundboard, retention, role tags, emoji packs) into the
// replacement room: read it first, re-create it after the upgrade.
const carried = stateToCarry(
Array.from(room.currentState.events.values()).flatMap((byKey) =>
Array.from(byKey.values()),
),
);
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
Method.Post,
`/rooms/${room.roomId}/upgrade`,
undefined,
{
new_version: version,
additional_creators: newAdditionalCreators,
},
);
const newRoomId = res?.replacement_room;
if (!newRoomId || carried.length === 0) return;
const results = await Promise.allSettled(
carried.map((st) =>
mx.http.authedRequest(
Method.Put,
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
undefined,
st.content,
),
),
);
const failed = results.filter((r) => r.status === 'rejected').length;
if (failed > 0) {
throw new Error(
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
);
}
},
[mx, room],
),
@@ -40,7 +40,7 @@ import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useStrippedFilePicker } from '../../../hooks/useStrippedFilePicker';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -68,7 +68,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
const [iconFile, setIconFile] = useState<File>();
const pickFile = useFilePicker(setIconFile, false);
const pickFile = useStrippedFilePicker(setIconFile, false);
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
animation: `${auroraFlow} 60s steps(180) infinite`,
};
const light: CSSProperties = {
@@ -75,7 +75,7 @@ const light: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`,
animation: `${auroraFlow} 60s steps(180) infinite`,
};
export const animAurora: ChatBgVariants = { dark, light };
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
animation: `${firefliesDrift} 44s steps(528) infinite`,
},
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static)
'0 0', // wash B (static)
].join(','),
animation: `${firefliesDrift} 44s linear infinite`,
animation: `${firefliesDrift} 44s steps(528) infinite`,
},
};
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
animation: `${gridPulse} 22s steps(528) infinite`,
},
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash
'0 0', // vignette
].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`,
animation: `${gridPulse} 22s steps(528) infinite`,
},
};
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
animation: `${rainFall} 12s steps(288) infinite`,
},
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`,
animation: `${rainFall} 12s steps(288) infinite`,
},
};
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
'0 0', // nebula A
'0 0', // nebula B
].join(','),
animation: `${starDrift} 90s linear infinite`,
animation: `${starDrift} 90s steps(1080) infinite`,
},
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
'0 0', // wash A
'0 0', // wash B
].join(','),
animation: `${starDrift} 100s linear infinite`,
animation: `${starDrift} 100s steps(1200) infinite`,
},
};
+5
View File
@@ -149,6 +149,11 @@ export const getChatBg = (
}
// For animated backgrounds, promote the element to its own compositor layer so
// background-position keyframes don't trigger repaints on descendant elements.
// [Gitea #337] background-position can't run on the compositor, so every
// animation frame repaints the whole chat area (45 to 70% main-thread busy
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
// same, a fraction of the repaints.
if (style.animation) {
return { ...style, willChange: 'background-position', contain: 'paint' };
}
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
import { VirtualTile } from '../../components/virtualizer';
import { stopPropagation } from '../../utils/keyboard';
import { MsgTypeFilter } from './useMessageSearch';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
onChange: (rooms?: string[]) => void;
};
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [localSelected, setLocalSelected] = useState(selectedRooms);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => mx.getRoom(rId)?.name ?? rId,
[mx],
(rId) => {
const name = mx.getRoom(rId)?.name ?? rId;
// [Gitea #336] match the personal "Rename for me" name too
return localNames[rId] ? [localNames[rId], name] : name;
},
[mx, localNames],
);
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
}
>
<Text truncate size="T300">
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</MenuItem>
</VirtualTile>
@@ -701,6 +707,7 @@ export function SearchFilters({
pinnedOnly,
onTogglePinnedOnly,
}: SearchFiltersProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
return (
@@ -748,7 +755,7 @@ export function SearchFilters({
}
after={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200">{room.name}</Text>
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
</Chip>
);
})}
@@ -50,6 +50,7 @@ import {
} from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
type SearchResultGroupProps = {
room: Room;
@@ -73,6 +74,7 @@ export function SearchResultGroup({
hour24Clock,
dateFormatString,
}: SearchResultGroupProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
@@ -212,7 +214,7 @@ export function SearchResultGroup({
/>
</Avatar>
<Text size="H4" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Box>
</Header>
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
foldEdits,
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
['$2'],
);
});
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
({
rank: 1,
result: {
event_id: eventId,
room_id: '!r:x',
origin_server_ts: ts,
type: 'm.room.message',
content,
},
}) as unknown as Parameters<typeof foldEdits>[0][number];
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
const edit = (id: string, ts: number, body: string) =>
sr(id, ts, {
body: `* ${body}`,
'm.new_content': { body },
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
});
const out = foldEdits([
edit('$e2', 30, 'Tuesday 11am room B'),
sr('$orig', 10, { body: 'Monday 10am room A' }),
edit('$e1', 20, 'Tuesday 10am room A'),
sr('$other', 5, { body: 'meeting notes' }),
]);
assert.deepEqual(
out.map((r) => r.result.event_id),
['$orig', '$other'],
);
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
assert.equal(out[0].result.content.body, 'Monday 10am room A');
});
test('foldEdits: an edit whose original did not match stands in for it', () => {
const out = foldEdits([
sr('$e1', 20, {
'm.new_content': { body: 'x' },
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
}),
]);
assert.equal(out.length, 1);
assert.equal(out[0].result.event_id, '$orig');
});
@@ -96,10 +96,58 @@ export const filterGroupsByDateRange = (
.filter((group) => group.items.length > 0);
};
/**
* [Gitea #288] Server search also returns `m.replace` events, so an edited
* message showed once per version. Fold each edit into its message: one row,
* at the first position any version ranked, showing the latest text and
* pointing at the original event (so "jump" lands on the real message).
*/
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
const targetOf = (r: ISearchResult): string => {
const rel = r.result.content?.['m.relates_to'];
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
? rel.event_id
: r.result.event_id;
};
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
const order: string[] = [];
const originals = new Map<string, ISearchResult>();
const latestEdit = new Map<string, ISearchResult>();
results.forEach((r) => {
const target = targetOf(r);
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
if (!isEdit(r)) {
originals.set(target, r);
return;
}
const prev = latestEdit.get(target);
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
latestEdit.set(target, r);
});
return order.map((target) => {
const original = originals.get(target);
const edit = latestEdit.get(target);
const newContent = edit?.result.content?.['m.new_content'];
if (original && !newContent) return original;
if (original) {
return {
...original,
result: {
...original.result,
content: { ...original.result.content, 'm.new_content': newContent },
},
};
}
// Only an edit matched: show it as its message.
const only = edit as ISearchResult;
return { ...only, result: { ...only.result, event_id: target } };
});
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
results.forEach((item) => {
foldEdits(results).forEach((item) => {
const roomId = item.result.room_id;
const resultItem: ResultItem = {
rank: item.rank,
+5 -4
View File
@@ -80,7 +80,7 @@ import {
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
@@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
prevMode,
).catch(() => {});
if (durationMs !== null) {
scheduleMuteTimer(room.roomId, durationMs, () => {
unmuteRoom(mx, room.roomId);
});
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
} else {
// An indefinite mute replaces any timed one.
await clearMuteTimer(mx, room.roomId);
}
requestClose();
},
+14 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
@@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => {
const entries = [
{ roomId: '!a:x', unmuteAt: 1000 },
{ roomId: '!b:x', unmuteAt: 2000 },
];
assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries);
assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [
{ roomId: '!b:x', unmuteAt: 5 },
]);
assert.deepEqual(parseMuteTimers(undefined), []);
assert.deepEqual(parseMuteTimers({ timers: null }), []);
});
+77 -16
View File
@@ -7,7 +7,7 @@ import {
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
// Legacy localStorage key (before #333); migrated into account data once.
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
@@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
const timers = (content as { timers?: unknown } | undefined)?.timers;
if (!timers || typeof timers !== 'object') return [];
return Object.entries(timers as Record<string, unknown>)
.filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]))
.map(([roomId, unmuteAt]) => ({ roomId, unmuteAt }));
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
/** Pure: account data content for `entries`. */
export const muteTimersContent = (entries: MuteTimerEntry[]) => ({
timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])),
});
/**
* [Gitea #333] Timed mutes live in account data: the mute itself is a
* server-side push rule on every device, so the timer must be too — kept in one
* browser's localStorage, only that device ever unmuted (and never after a
* logout there).
*/
export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] {
return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent());
}
async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise<void> {
await mx.setAccountData(
AccountDataEvent.LotusMuteTimers as never,
muteTimersContent(entries) as never,
);
}
/** Move timers kept in this browser's localStorage (pre-#333) into account data. */
export async function migrateLocalMuteTimers(mx: MatrixClient): Promise<void> {
let legacy: MuteTimerEntry[] = [];
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
legacy = Array.isArray(parsed)
? parsed.filter(
(e): e is MuteTimerEntry =>
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
)
: [];
} catch {
legacy = [];
}
if (legacy.length === 0) return;
const current = loadMuteTimers(mx);
const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))];
await saveMuteTimers(mx, merged);
try {
localStorage.removeItem(MUTE_TIMERS_KEY);
} catch {
/* ignore */
}
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
await clearMuteTimer(mx, roomId);
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
const timers = loadMuteTimers(mx);
if (!timers.some((e) => e.roomId === roomId)) return;
await saveMuteTimers(
mx,
timers.filter((e) => e.roomId !== roomId),
).catch(() => {});
}
/**
* Record when a timed mute ends. MuteTimerRestore (on every Lotus device)
* schedules the unmute from account data, so whichever is online at expiry
* lifts it.
*/
export async function scheduleMuteTimer(
mx: MatrixClient,
roomId: string,
durationMs: number,
): Promise<void> {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
}
@@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
// ── Policy event types ────────────────────────────────────────────────────────
@@ -38,13 +39,6 @@ type PolicyRuleContent = {
recommendation?: string;
};
type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
type PolicyTab = 'users' | 'rooms' | 'servers';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -53,13 +47,6 @@ function isGlob(entity: string): boolean {
return entity.includes('*') || entity.includes('?');
}
function recommendationLabel(rec: string): string {
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
// (pre-stabilization) recommendation still emitted by older bots.
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
return rec;
}
/**
* Fetch all state events of a given type from a room's live forward state.
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
@@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== '');
}
/**
* Merge policy entries from the stable and legacy event types for a rule
* kind, de-duplicating by entity+recommendation so a room that emits both a
* stable and a legacy rule for the same target isn't double-listed.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${entry.recommendation}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupePolicyEntries, recommendationLabel } from './policyEntries';
const e = (entity: string, recommendation: string, stateKey = entity) => ({
entity,
reason: 'spam waves',
recommendation,
stateKey,
});
test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => {
const out = dedupePolicyEntries([
e('@spammer:evil.example', 'm.ban'),
e('@spammer:evil.example', 'org.matrix.mjolnir.ban'),
e('@*:spam.example', 'm.ban'),
]);
assert.deepEqual(
out.map((x) => x.entity),
['@spammer:evil.example', '@*:spam.example'],
);
});
test('dedupePolicyEntries: different recommendations stay separate', () => {
assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2);
});
test('recommendationLabel', () => {
assert.equal(recommendationLabel('m.ban'), 'Ban');
assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban');
assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute');
});
@@ -0,0 +1,35 @@
export type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
/**
* `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
* (pre-stabilization) recommendation still emitted by older bots.
*/
export const normalizeRecommendation = (rec: string): string =>
rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec;
export function recommendationLabel(rec: string): string {
if (normalizeRecommendation(rec) === 'm.ban') return 'Ban';
return rec;
}
/**
* Merge stable and legacy rule lists, one entry per entity + recommendation.
* [Gitea #321] Keyed on the normalised recommendation: a bot publishing both
* `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
+1 -1
View File
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
Create Poll
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
Report Room
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
Report User
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+43 -21
View File
@@ -153,10 +153,14 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { PendingLocation, ShareLocationDialog } from './ShareLocationDialog';
import { buildGeoUri } from '../../utils/geoShare';
import { useLocalRoomName } from '../../hooks/useRoomMeta';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread';
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
import { plaintextCachesWiped } from '../../state/plaintextCaches';
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
@@ -356,6 +360,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const displayRoomName = useLocalRoomName(room);
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => {
@@ -368,21 +373,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
});
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
const { latitude, longitude, accuracy } = pos.coords;
setPendingLocation({ latitude, longitude, accuracy });
},
(err) => {
setLocating(false);
@@ -397,7 +390,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ timeout: 10000 },
);
}, [mx, roomId, threadRootId]);
}, []);
const [pendingLocation, setPendingLocation] = React.useState<PendingLocation | null>(null);
const handleSendLocation = useCallback(
(approximate: boolean) => {
if (!pendingLocation) return;
const { latitude, longitude, accuracy } = pendingLocation;
setPendingLocation(null);
const geoUri = buildGeoUri(latitude, longitude, accuracy, approximate);
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared ${approximate ? 'an approximate' : 'a'} location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
});
},
[pendingLocation, mx, roomId, threadRootId],
);
const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -547,6 +563,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// Persist the current editor state to the draft atom + localStorage; an empty
// editor clears both so the draft indicators never claim a draft that isn't there.
const persistDraft = useCallback(() => {
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
if (plaintextCachesWiped()) return;
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
@@ -1538,11 +1556,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={touchTarget}
className={MobileTouchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="400" />
) : (
<Icon src={Icons.SpaceGlobe} />
)}
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
</IconButton>
) : null;
@@ -1751,6 +1765,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onClose={() => setPollOpen(false)}
/>
)}
{pendingLocation && (
<ShareLocationDialog
location={pendingLocation}
roomName={displayRoomName}
onSend={handleSendLocation}
requestClose={() => setPendingLocation(null)}
/>
)}
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
+16 -2
View File
@@ -90,6 +90,7 @@ import {
isMembershipChanged,
reactionOrEditEvent,
sendRoomEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
@@ -121,7 +122,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, isExpired } from '../../utils/retention';
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -138,6 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { declinedRing, isRingableRoom } from '../../utils/callRing';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useRoomCreators } from '../../hooks/useRoomCreators';
@@ -409,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
data,
) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
// [Gitea #259] The media gallery / activity log register filtered timeline
// sets whose events the room re-emits as live: don't count them twice.
if (isFromFilteredTimelineSet(data)) return;
onArrive(mEvent);
};
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
@@ -493,7 +498,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// timeline (unless "show hidden events" is on). Reactive so a policy change
// re-renders. `undefined` = no policy.
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
const retentionMs = effectiveRetentionMs(
retentionEvent?.getContent<RetentionContent>().max_lifetime,
);
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
@@ -2030,6 +2038,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? '';
if (!senderId || senderId === mx.getUserId()) return null;
// Same rule as ringing: no "Missed call" in voice rooms or space
// channels, where starting a call is just joining the channel.
if (!isRingableRoom(room, direct)) return null;
// [Gitea #313] A call we declined isn't a missed call.
const ringRef = mEvent.getRelation()?.event_id;
if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null;
const content = mEvent.getContent();
const mention =
content['m.mentions']?.room ||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
{title}
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
import {
cancelScheduledMessage,
fetchScheduledMessages,
groupScheduledByRoom,
sendScheduledMessageNow,
} from '../../utils/scheduledMessages';
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
// [Gitea #322] The server is the source of truth: messages scheduled on
// another device (or before a logout) show up here and can be cancelled.
// localStorage is only a cache for the first paint; on error we keep it.
useEffect(() => {
let alive = true;
const load = () => {
fetchScheduledMessages(mx)
.then((list) => {
if (alive) setScheduledMessages(groupScheduledByRoom(list));
})
.catch(() => undefined);
};
load();
window.addEventListener('focus', load);
return () => {
alive = false;
window.removeEventListener('focus', load);
};
}, [mx, roomId, setScheduledMessages]);
// Remove scheduled messages whose time has passed
useEffect(() => {
if (messages.length === 0) return undefined;
@@ -0,0 +1,124 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard';
import { formatAccuracy } from '../../utils/geoShare';
export type PendingLocation = { latitude: number; longitude: number; accuracy?: number };
type ShareLocationDialogProps = {
location: PendingLocation;
roomName: string;
onSend: (approximate: boolean) => void;
requestClose: () => void;
};
/**
* [Gitea #340] Confirm step before an m.location is posted: the toolbar button
* used to send the exact position from a single click. Shows what will be
* shared (coordinates + the fix's accuracy) and offers an approximate share.
*/
export function ShareLocationDialog({
location,
roomName,
onSend,
requestClose,
}: ShareLocationDialogProps) {
const modalStyle = useModalStyle(420);
const { latitude, longitude, accuracy } = location;
const accuracyLabel = formatAccuracy(accuracy);
const mapUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#share-location-cancel',
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
role="dialog"
aria-modal="true"
aria-labelledby="share-location-title"
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="share-location-title" as="h2" size="H4">
Share your location?
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>
Everyone in <b>{roomName}</b> will see this position, and it can&apos;t be taken
back from their devices once sent.
</Text>
<Box direction="Column" gap="100">
<Text size="T400">
<b>
{latitude.toFixed(5)}, {longitude.toFixed(5)}
</b>
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Text size="T200" priority="300">
{accuracyLabel
? 'The ± is how sure your device is of this position.'
: 'Your device did not say how accurate this position is.'}{' '}
<a href={mapUrl} target="_blank" rel="noreferrer noopener">
Check on OpenStreetMap
</a>
</Text>
</Box>
<Box gap="200" justifyContent="End" wrap="Wrap">
<Button
id="share-location-cancel"
variant="Secondary"
fill="Soft"
onClick={requestClose}
>
<Text size="B400">Cancel</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={() => onSend(true)}>
<Text size="B400">Send approximate (~1 km)</Text>
</Button>
<Button variant="Primary" onClick={() => onSend(false)}>
<Text size="B400">Send</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -47,7 +47,12 @@ import {
recentForwardTargetsAtom,
addRecentForwardTarget,
} from '../../../state/recentForwardTargets';
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
import {
buildForwardContent,
buildPlaintextAttachmentContent,
forwardEncryptionWarning,
} from './forwardContent';
import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta';
type RoomRowProps = {
room: Room;
@@ -58,6 +63,7 @@ type RoomRowProps = {
sending: boolean;
};
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -97,7 +103,7 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
@@ -212,6 +218,7 @@ function RecentChip({
onToggle: () => void;
sending: boolean;
}) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -240,7 +247,7 @@ function RecentChip({
}
>
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Chip>
);
@@ -252,6 +259,7 @@ type Props = {
};
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom);
@@ -271,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
// Selection persists across query changes: a room selected then filtered out
// of the rendered slice stays selected.
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
const encryptionWarning = forwardEncryptionWarning(
!!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(),
[...selectedRoomIds]
.map((id) => mx.getRoom(id))
.filter((r): r is Room => !!r && !r.hasEncryptionStateEvent())
// The name the user sees (their own "Rename for me" name, if set).
.map((r) => getLocalRoomName(mx, r.roomId) ?? r.name),
);
const toggleRoom = useCallback((roomId: string) => {
setSelectedRoomIds((prev) => {
@@ -296,8 +312,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const filtered = useMemo(() => {
if (!query) return allRooms;
const q = query.toLowerCase();
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
}, [allRooms, query]);
return allRooms.filter((r) =>
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
);
}, [allRooms, query, localNames]);
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
const recentRooms = useMemo(
@@ -584,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
shrink="No"
direction="Column"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
gap="200"
>
{encryptionWarning && (
<Text size="T200" role="note" style={{ color: color.Warning.Main }}>
{encryptionWarning}
</Text>
)}
<Button
variant="Primary"
size="400"
+30 -3
View File
@@ -31,12 +31,13 @@ import React, {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai';
@@ -114,6 +115,20 @@ function DeliveryStatus({
}) {
const mx = useMatrixClient();
const offline = useAtomValue(sendOfflineAtom);
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
// Re-render on the event's own status updates.
const [, setStatusTick] = useState(0);
useEffect(() => {
const bump = () => setStatusTick((t) => t + 1);
mEvent.on(MatrixEventEvent.Status, bump);
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
return () => {
mEvent.off(MatrixEventEvent.Status, bump);
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
};
}, [mEvent]);
const { status } = mEvent;
if (status === null) return null; // confirmed by server — read receipts take over
let iconSrc: IconSrc;
@@ -710,7 +725,13 @@ export const MessageDeleteItem = as<
Delete Message
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -850,7 +871,13 @@ export const MessageReportItem = as<
Report Message
</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+24 -4
View File
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
const measure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const rowWidth = container.getBoundingClientRect().width;
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
// [Gitea #305] The width the chips actually get (content box). The border
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
const cs = getComputedStyle(container);
const rowWidth =
container.clientWidth -
(parseFloat(cs.paddingLeft) || 0) -
(parseFloat(cs.paddingRight) || 0);
const gap = parseFloat(cs.columnGap) || 8;
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
style={{
...props.style,
...(collapsed && rowHeight
? // 2px breathing room so row-1 focus outlines are not clipped.
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
// margins so the timeline's marginTop survives (Gitea #305: the
// `margin` shorthand overrode it and, once removed, cleared it).
{
maxHeight: rowHeight + 4,
overflow: 'hidden',
padding: 2,
marginLeft: -2,
marginRight: -2,
marginBottom: -2,
marginTop:
props.style?.marginTop !== undefined
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
: -2,
}
: {}),
}}
ref={(el) => {
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { buildForwardContent, getForwardedMeta } from './forwardContent';
import { buildForwardContent, forwardEncryptionWarning, getForwardedMeta } from './forwardContent';
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
@@ -171,3 +171,16 @@ test('getForwardedMeta rejects malformed metadata', () => {
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
assert.equal(getForwardedMeta({}), undefined);
});
test('forwardEncryptionWarning: only from an encrypted room into unencrypted ones (#330)', () => {
assert.equal(forwardEncryptionWarning(false, ['general']), undefined);
assert.equal(forwardEncryptionWarning(true, []), undefined);
assert.match(
forwardEncryptionWarning(true, ['general']) ?? '',
/^general isn't encrypted\..*that room, along with who originally wrote it\.$/,
);
assert.match(
forwardEncryptionWarning(true, ['a', 'b']) ?? '',
/^2 of the rooms you picked aren't encrypted\..*those rooms/,
);
});
@@ -122,3 +122,23 @@ export async function buildPlaintextAttachmentContent(
return plainContent;
}
/**
* [Gitea #330] The note shown when forwarding out of an encrypted room into
* rooms that aren't: the text (and a re-uploaded attachment) becomes readable
* by the server, and the original author is shown with it. Undefined when
* there's nothing to warn about.
*/
export function forwardEncryptionWarning(
sourceEncrypted: boolean,
unencryptedDestNames: string[],
): string | undefined {
if (!sourceEncrypted || unencryptedDestNames.length === 0) return undefined;
const where =
unencryptedDestNames.length === 1
? `${unencryptedDestNames[0]} isn't encrypted.`
: `${unencryptedDestNames.length} of the rooms you picked aren't encrypted.`;
return `${where} The forwarded message (and any attachment) will be readable by the server and everyone in ${
unencryptedDestNames.length === 1 ? 'that room' : 'those rooms'
}, along with who originally wrote it.`;
}
+3 -1
View File
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { getMemberName } from '../../../utils/room';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type ThreadPanelHeaderProps = {
room: Room;
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
requestClose: () => void;
};
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const localNames = useLocalRoomNames();
const mode = useThreadNotificationMode(room.roomId, threadId);
const { format } = useTimestampFormatter();
// [Gitea #165] Who started the thread and when — the first thing you want
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
Thread
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
{localNames[room.roomId] ?? room.name}
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
</Text>
</Box>
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const localNames = useLocalRoomNames();
const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const handleJump = useCallback(
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
Threads
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Box>
{totalUnread > 0 && (
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import { isFromFilteredTimelineSet } from '../../../utils/room';
type PendingAsk = {
requests: WidgetPermissionRequest[];
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
+11 -5
View File
@@ -37,6 +37,7 @@ import {
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import {
@@ -157,17 +158,20 @@ export function Search({ requestClose }: SearchProps) {
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
const localNames = useLocalRoomNames();
const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
// [Gitea #336] Match the user's own "Rename for me" name as well.
const names = localNames[roomId] ? [localNames[roomId], roomName] : [roomName];
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
if (targetUsername) return [...names, targetUsername];
}
return roomName;
return names.length === 1 ? names[0] : names;
},
[getRoom, mDirects, mx],
[getRoom, mDirects, mx, localNames],
);
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
@@ -388,8 +392,10 @@ export function Search({ requestClose }: SearchProps) {
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{queryHighlighRegex
? highlightText(queryHighlighRegex, [room.name])
: room.name}
? highlightText(queryHighlighRegex, [
localNames[room.roomId] ?? room.name,
])
: (localNames[room.roomId] ?? room.name)}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
const statusDismissed = useAtomValue(statusDismissedAtom);
const requestSettings = useSetAtom(settingsRequestAtom);
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
// never stacks under the "Connecting…" strip at startup.
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
});
// One strip at a time: outages, maintenance and a lost connection win.
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
// on screen and must not keep the nudge hidden for as long as it's posted.
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -7,20 +7,9 @@ import { useClientConfig } from '../../hooks/useClientConfig';
import { useSyncState } from '../../hooks/useSyncState';
import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
const bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
@@ -163,7 +152,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
});
}, []),
);
const syncLost = lostAt !== null;
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
// watchdog flags it sooner, and Kuma is asked at once.
const stalled = useHomeserverStall(mx);
const setStalled = useSetAtom(homeserverStalledAtom);
useEffect(() => setStalled(stalled), [setStalled, stalled]);
const syncLost = lostAt !== null || stalled;
const status = useServerStatus(page, syncLost);
const setAtom = useSetAtom(serverStatusAtom);
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
@@ -173,22 +167,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
/** The logged-in app's status strip. */
export function ServerStatusBanner() {
const { status, syncLost } = useAtomValue(serverStatusAtom);
const [dismissed, setDismissed] = useState(readDismissed);
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
const banner = pickBanner(status, {
syncLost,
where: 'client',
dismissed: new Set(dismissed),
});
if (!banner) return null;
const dismiss = () => {
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
setDismissed(next);
};
const dismiss = () => dismissKey(banner.key);
return <StatusStrip banner={banner} onDismiss={dismiss} />;
}
@@ -57,7 +57,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { noteLocalStatusWrite } from '../../../utils/ownStatusSync';
import { DND_STATUS_SENTINEL, noteLocalStatusWrite } from '../../../utils/ownStatusSync';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
import { useStatusPresets } from '../../../hooks/useStatusPresets';
@@ -396,7 +396,8 @@ function ProfileStatus() {
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
const serverStatus = presence?.status === DND_STATUS_SENTINEL ? undefined : presence?.status;
const initialStatus = serverStatus ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
// True while the user has unsaved local edits — prevents a server presence
// echo from overwriting what the user is currently typing/inserting.
@@ -442,6 +443,9 @@ function ProfileStatus() {
pendingAppliedRef.current = null; // window elapsed — accept external changes
}
}
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
// typed: don't show it in the status field or keep it as their status.
if (remoteStatus === DND_STATUS_SENTINEL) return;
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
@@ -25,6 +25,9 @@ import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import * as css from './KeyboardShortcutsDialog.css';
import { BLOCK_HOTKEYS, INLINE_HOTKEYS } from '../../components/editor/keyboard';
import { MarkType } from '../../components/editor/types';
import { BLOCK_LABELS, MARK_LABELS, hotkeyKeys, keyCodeLabel } from './shortcutLabels';
/** Global open-state for the keyboard shortcuts help dialog. */
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
@@ -98,6 +101,9 @@ export function KeyboardShortcutsDialog() {
const { open, closeDialog } = useKeyboardShortcutsDialog();
const modalStyle = useModalStyle(480);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
@@ -109,6 +115,8 @@ export function KeyboardShortcutsDialog() {
rows: [
{ description: 'Show keyboard shortcuts', keys: ['?'] },
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
// [Gitea #334] the quick switcher / search was missing
{ description: 'Quick switcher: jump to a room or person', keys: [modKey, 'K'] },
],
},
{
@@ -124,6 +132,26 @@ export function KeyboardShortcutsDialog() {
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
},
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
{ description: 'Edit your last message (empty composer)', keys: ['↑'] },
],
},
{
// [Gitea #334] generated from the editor's own bindings so they can't drift
title: 'Composer formatting',
rows: [
...Object.entries(INLINE_HOTKEYS).map(([hotkey, mark]) => ({
description:
mark === MarkType.StrikeThrough
? `${MARK_LABELS[mark]} (not the browser's Save)`
: MARK_LABELS[mark],
keys: hotkeyKeys(hotkey, modKey),
})),
{ description: 'Heading 1 / 2 / 3', keys: [modKey, '1 / 2 / 3'] },
...Object.entries(BLOCK_HOTKEYS).map(([hotkey, block]) => ({
description: BLOCK_LABELS[block] ?? block,
keys: hotkeyKeys(hotkey, modKey),
})),
{ description: 'Clear formatting', keys: [modKey, 'E'] },
],
},
{
@@ -132,6 +160,19 @@ export function KeyboardShortcutsDialog() {
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
],
},
{
// [Gitea #334] the user's own bindings (Settings → Calls)
title: 'Calls',
rows: [
{
description: pttMode
? 'Push to talk (hold)'
: 'Push to talk (hold; turn on in Settings → Calls)',
keys: [keyCodeLabel(pttKey)],
},
{ description: 'Deafen / undeafen', keys: [keyCodeLabel(deafenKey)] },
],
},
];
return (
@@ -0,0 +1,16 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hotkeyKeys, keyCodeLabel } from './shortcutLabels';
test('hotkeyKeys (#334)', () => {
assert.deepEqual(hotkeyKeys('mod+b', 'Ctrl'), ['Ctrl', 'B']);
assert.deepEqual(hotkeyKeys("mod+'", '⌘'), ['⌘', "'"]);
assert.deepEqual(hotkeyKeys('mod+[', 'Ctrl'), ['Ctrl', '[']);
});
test('keyCodeLabel (#334)', () => {
assert.equal(keyCodeLabel('KeyM'), 'M');
assert.equal(keyCodeLabel('Digit1'), '1');
assert.equal(keyCodeLabel('Space'), 'Space');
assert.equal(keyCodeLabel(''), 'Not set');
});
@@ -0,0 +1,29 @@
import { BlockType, MarkType } from '../../components/editor/types';
/** Pure: an is-hotkey string ('mod+b', "mod+'") as keycap labels. */
export const hotkeyKeys = (hotkey: string, modKey: string): string[] =>
hotkey.split('+').map((k) => (k === 'mod' ? modKey : k.length === 1 ? k.toUpperCase() : k));
/** Pure: a KeyboardEvent.code ('KeyM', 'Digit1', 'Space') as a keycap label. */
export const keyCodeLabel = (code: string | undefined): string => {
if (!code) return 'Not set';
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit\d$/.test(code)) return code.slice(5);
return code;
};
export const MARK_LABELS: Record<MarkType, string> = {
[MarkType.Bold]: 'Bold',
[MarkType.Italic]: 'Italic',
[MarkType.Underline]: 'Underline',
[MarkType.StrikeThrough]: 'Strikethrough',
[MarkType.Code]: 'Inline code',
[MarkType.Spoiler]: 'Spoiler',
};
export const BLOCK_LABELS: Partial<Record<BlockType, string>> = {
[BlockType.OrderedList]: 'Numbered list',
[BlockType.UnorderedList]: 'Bulleted list',
[BlockType.BlockQuote]: 'Quote',
[BlockType.CodeBlock]: 'Code block',
};
+25 -1
View File
@@ -17,6 +17,8 @@ import {
CALL_SESSION_HEARTBEAT_MS,
clearCallSession,
decideRejoin,
othersInCall,
rejoinToastBody,
readCallSession,
writeCallSession,
} from '../utils/callRejoin';
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
};
dismissToast(TOAST_ID);
clearCallSession();
// [Gitea #302] A DM call the other person has since left is over;
// starting it again would ring them, so just say so.
const dm = l.directs.has(room.roomId);
if (
dm &&
!othersInCall(
session.memberships.map((m) => m.userId ?? ''),
mx.getUserId() ?? '',
)
) {
// Own id: clicking a toast removes it by id after its onClick runs.
setToast({
id: `${TOAST_ID}-ended`,
iconSrc: Icons.Phone,
displayName: 'Call ended',
body: `${room.name ?? 'They'} already left the call.`,
roomName: room.name ?? '',
roomId: room.roomId,
});
return;
}
try {
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
} catch {
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
.filter((m) => m.userId === mx.getUserId())
.map((m) => m.deviceId),
memberCount: memberships.length,
dm: latest.current.directs.has(room.roomId),
});
// The RTC session fills in a moment after sync; keep waiting while it is
// empty unless this is the final (timed-out) look.
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
id: TOAST_ID,
iconSrc: Icons.Phone,
displayName: 'Rejoin voice?',
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
roomName: room.name ?? '',
roomId: room.roomId,
sticky: true,
+16
View File
@@ -0,0 +1,16 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SyncState } from 'matrix-js-sdk';
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
const now = 1_000_000;
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
assert.equal(
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
false,
'SyncStatus already shows that',
);
assert.equal(syncOverdue(now - 120_000, now, null), false);
});
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { atom } from 'jotai';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
/**
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
* /sync that's overdue and confirms with a short probe of /versions.
*/
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
const CHECK_EVERY_MS = 10_000;
const PROBE_TIMEOUT_MS = 5_000;
export const homeserverStalledAtom = atom(false);
/** Pure: should we probe, given the last sync response time? */
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
export function useHomeserverStall(mx: MatrixClient): boolean {
const [stalled, setStalled] = useState(false);
useEffect(() => {
let lastSyncAt = Date.now();
let probing = false;
const onSync = () => {
lastSyncAt = Date.now();
setStalled(false);
};
const check = async () => {
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
probing = true;
let answered = false;
try {
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
cache: 'no-store',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
answered = res.ok;
} catch {
answered = false;
}
probing = false;
// Still overdue and the server didn't answer a trivial request: stalled.
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
if (answered) setStalled(false);
};
mx.on(ClientEvent.Sync, onSync);
const id = setInterval(check, CHECK_EVERY_MS);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
clearInterval(id);
};
}, [mx]);
return stalled;
}
+12
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import { focusAssistActiveAtom } from '../state/focusAssist';
import { manualDndAtom } from '../state/manualDnd';
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
const QUIET_TICK_MS = 30_000;
export type NotificationsQuietInputs = {
focusAssistActive: boolean;
manualDnd: boolean;
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
// components.
export function useNotificationsQuiet(): boolean {
// [Gitea #325] The quiet-hours window and the snooze expiry depend on the
// clock, but the long-lived notifiers using this hook rarely re-render, so a
// window starting (or a 30-minute pause ending) while the app stayed open was
// never noticed. Re-evaluate on a tick, like the night-light overlay.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS);
return () => clearInterval(id);
}, []);
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
const manualDnd = useAtomValue(manualDndAtom);
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
+20 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { SetPresence } from 'matrix-js-sdk';
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
@@ -90,11 +91,21 @@ export function usePresenceUpdater() {
return status;
};
// [Gitea #282] A one-off PUT /presence doesn't stick: the SDK long-polls
// /sync with set_presence=online by default and Synapse treats every sync
// as "online", so Invisible / Idle / DND flipped back within seconds. Tell
// the sync loop which presence to report too (as Element does).
const syncPresence = (presence: SetPresence) => {
mx.setSyncPresence(presence).catch(() => undefined);
};
const setOnline = async () => {
syncPresence(SetPresence.Online);
const status = await resolveStatus();
return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
};
const setUnavailable = async (statusMsg?: string) => {
syncPresence(SetPresence.Unavailable);
const status = await resolveStatus();
return send(
{
@@ -104,7 +115,10 @@ export function usePresenceUpdater() {
'unavailable',
);
};
const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
const setOffline = () => {
syncPresence(SetPresence.Offline);
return send({ presence: 'offline', status_msg: '' }, 'offline');
};
// Manual presence overrides — no activity tracking needed.
if (hidePresence || presenceStatus === 'invisible') {
@@ -167,13 +181,17 @@ export function usePresenceUpdater() {
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.)
// [Gitea #339] Synapse treats a missing status_msg as "clear it", so a bare
// { presence: 'offline' } wiped the user's custom status for everyone whenever
// any tab closed or reloaded. Send the current status along with it.
const status = readStatus();
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ presence: 'offline' }),
body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
};
+16
View File
@@ -102,6 +102,22 @@ export const useLocalRoomName = (room: Room): string => {
return localNames[room.roomId] ?? name;
};
/**
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
* (quick switcher, forward/share pickers) so they use the name the user sees in
* the sidebar, not the server name.
*/
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
const mx = useMatrixClient();
return roomNamesStore.useValue(mx);
};
/** Non-hook read for notification paths (toast / OS notification titles). */
export const getLocalRoomName = (
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient();
const localNames = roomNamesStore.useValue(mx);
@@ -0,0 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldShowScreenshareNotice } from './useScreenshareNotices';
test('shouldShowScreenshareNotice: no "window was closed" when an admin turned sharing off (#297)', () => {
assert.equal(shouldShowScreenshareNotice('ended', false), false);
assert.equal(shouldShowScreenshareNotice('ended', true), true);
assert.equal(shouldShowScreenshareNotice('no-frames', false), true);
assert.equal(shouldShowScreenshareNotice('alone', true), true);
});
+17 -2
View File
@@ -1,8 +1,9 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { CallEmbed } from '../plugins/call';
import { toastQueueAtom } from '../state/toast';
import { useRoomCallPolicy } from './useRoomCallPolicy';
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
@@ -19,18 +20,32 @@ export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string |
}
};
/** Pure: a policy-forced stop is explained by its own toast (#297). */
export const shouldShowScreenshareNotice = (
kind: ScreenshareNoticeKind,
allowScreenshare: boolean,
): boolean => !(kind === 'ended' && !allowScreenshare);
/**
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
* closed, no frames for a while, or a long share with nobody watching.
*/
export function useScreenshareNotices(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
// [Gitea #297] When a room admin turns screen sharing off, the share ends
// and the fork reports `ended` ("the shared window was closed"), which is
// wrong; useCallPolicyRevokedToast already says what happened.
const { allowScreenshare } = useRoomCallPolicy(embed.room);
const allowRef = useRef(allowScreenshare);
allowRef.current = allowScreenshare;
useEffect(
() =>
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
'io.lotus.screenshare_notice',
(evt) => {
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
const kind = evt.detail?.data?.kind as ScreenshareNoticeKind;
if (!shouldShowScreenshareNotice(kind, allowRef.current)) return;
const body = describeScreenshareNotice(kind);
if (!body) return;
setToast({
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
+36
View File
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useFilePicker } from './useFilePicker';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { stripImageMetadata } from '../utils/stripImageMetadata';
/**
* [Gitea #332] `useFilePicker` for images that get published (emoji / sticker
* packs, pack avatars, power-level badges): applies "Remove Photo Metadata"
* like message attachments and avatars, so a phone photo's GPS location and
* timestamp don't go to every member of the room or space.
*/
export const useStrippedFilePicker = <M extends boolean | undefined = undefined>(
onSelect: (file: M extends true ? File[] : File) => void,
multiple?: M,
) => {
const [strip] = useSetting(settingsAtom, 'stripImageMetadata');
const handle = useCallback(
(picked: File | File[]) => {
if (!strip) {
onSelect(picked as M extends true ? File[] : File);
return;
}
const clean = (f: File) => stripImageMetadata(f).then((r) => r.file);
if (Array.isArray(picked)) {
Promise.all(picked.map(clean)).then((files) =>
onSelect(files as M extends true ? File[] : File),
);
} else {
clean(picked).then((file) => onSelect(file as M extends true ? File[] : File));
}
},
[strip, onSelect],
);
return useFilePicker(handle as (file: M extends true ? File[] : File) => void, multiple);
};
+7 -4
View File
@@ -5,6 +5,7 @@ import { StateEvent } from '../../types/matrix/room';
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
import { useCallMembers, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient';
import { voiceChannelOccupancy } from '../utils/voiceChannelFull';
export type VoiceChannelFull = {
channelFull: boolean;
@@ -26,9 +27,11 @@ export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
return useMemo(() => {
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
return { channelFull, current: callMembers.length, max: maxUsers };
// Distinct people, not per-device memberships (Gitea #342).
return voiceChannelOccupancy(
callMembers.map((m) => m.sender),
mx.getSafeUserId(),
maxUsers,
);
}, [limitEvent, callMembers, mx]);
};
+1 -1
View File
@@ -48,7 +48,7 @@ import {
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
const FONT_MAP: Record<string, string> = {
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
inter: `'Inter Variable', 'InterVariable', ${EMOJI_FONTS}, sans-serif`,
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
};
+54 -28
View File
@@ -14,6 +14,7 @@ import {
ThreadEvent,
} from 'matrix-js-sdk';
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
import { getLocalRoomName } from '../../hooks/useRoomMeta';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
@@ -48,6 +49,7 @@ import {
getNotificationType,
getUnreadInfo,
isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix';
@@ -57,8 +59,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
MAX_MUTE_TIMEOUT_MS,
MuteTimerEntry,
loadMuteTimers,
migrateLocalMuteTimers,
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
@@ -67,7 +69,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import {
getRoomRetentionMs,
getRoomRetentionSetAt,
isExpired,
retentionDeletesAllowed,
} from '../../utils/retention';
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { isNetworkUpdateError } from '../../utils/updateErrors';
@@ -85,6 +92,7 @@ import {
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
import { AccountDataEvent } from '../../../types/matrix/accountData';
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
@@ -328,7 +336,9 @@ function InviteNotifications() {
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
// [Gitea #324] The in-app toast (focused window) needs no browser
// permission; only the OS notification does.
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
notify(newCount);
}
if (notificationSound && inviteSoundId !== 'none') {
@@ -363,30 +373,35 @@ function PresenceUpdater() {
function MuteTimerRestore() {
const mx = useMatrixClient();
// [Gitea #333] Timers are in account data, so a mute set on another device
// is lifted here too, and changes there (a new mute, a manual unmute)
// reschedule. Moves any timers this browser kept locally (pre-#333) first.
useEffect(() => {
const timers = loadMuteTimers();
if (timers.length === 0) return undefined;
const now = Date.now();
const pastDue: MuteTimerEntry[] = [];
const future: MuteTimerEntry[] = [];
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
pastDue.forEach((entry) => {
unmuteRoom(mx, entry.roomId);
});
const handles = future.map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
),
);
migrateLocalMuteTimers(mx).catch(() => {});
}, [mx]);
useEffect(() => {
let handles: ReturnType<typeof setTimeout>[] = [];
const schedule = () => {
handles.forEach(clearTimeout);
const now = Date.now();
handles = loadMuteTimers(mx).map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)),
),
);
};
const onAccountData = (event: MatrixEvent) => {
if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule();
};
schedule();
mx.on(ClientEvent.AccountData, onAccountData);
return () => {
handles.forEach(clearTimeout);
mx.removeListener(ClientEvent.AccountData, onAccountData);
};
}, [mx]);
@@ -565,7 +580,8 @@ function MessageNotifications() {
// NotificationOptions typing.
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
}),
data: { path: roomPath, roomId, threadId: threadId || undefined },
// userId: [Gitea #299] the SW replies as this account only.
data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() },
},
() => {
window.focus();
@@ -573,7 +589,7 @@ function MessageNotifications() {
},
);
},
[navigate, setToast, mDirects],
[navigate, setToast, mDirects, mx],
);
// N105: when a service-worker-owned notification is clicked, the SW focuses
@@ -635,11 +651,16 @@ function MessageNotifications() {
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
// [Gitea #324] notify() shows an in-app toast while the window is focused,
// which needs no browser permission; only its OS-notification path does.
// Gating both on the permission left people who declined it (most web
// users) with a ping sound and nothing on screen.
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
// [Gitea #336] the user's own "Rename for me" name when they set one
roomName: getLocalRoomName(mx, room.roomId) ?? room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
@@ -677,6 +698,7 @@ function MessageNotifications() {
data,
) => {
if (mx.getSyncState() !== 'SYNCING') return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
if (
!room ||
@@ -808,7 +830,7 @@ function ReminderMonitor() {
if (!firedRef.current.has(key)) {
firedRef.current.add(key);
const room = mx.getRoom(r.roomId);
const roomName = room?.name ?? 'Unknown Room';
const roomName = getLocalRoomName(mx, r.roomId) ?? room?.name ?? 'Unknown Room';
// Reminders for E2EE rooms carry no text (account data is server-
// readable), so resolve the body from the local timeline at fire time.
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
@@ -908,6 +930,9 @@ function RetentionSweeper() {
mx.getRooms().forEach((room) => {
const maxLifetime = getRoomRetentionMs(room);
if (!maxLifetime) return;
// [Gitea #298] Never delete under a policy that is less than a day old.
const setAt = getRoomRetentionSetAt(room);
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
room
.getLiveTimeline()
.getEvents()
@@ -1116,6 +1141,7 @@ function ClockSkewFeature() {
data,
) => {
if (!data.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// Only events our homeserver stamped: a federated event's
// origin_server_ts is the other server's clock.
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
+31 -7
View File
@@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
type StateData = {
current: SyncState | null;
@@ -17,6 +18,7 @@ type SyncStatusProps = {
};
export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom);
const stalled = useAtomValue(homeserverStalledAtom);
const [stateData, setStateData] = useState<StateData>({
current: null,
previous: undefined,
@@ -34,6 +36,35 @@ export function SyncStatus({ mx }: SyncStatusProps) {
}, []),
);
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
role="status"
aria-live="polite"
>
<Text size="L400">
Lotus Chat&apos;s server isn&apos;t responding. Messages will send when it&apos;s back.
Still trying…
</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if (
(stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing ||
@@ -57,13 +88,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
if (stateData.current === SyncState.Reconnecting && !serverDown) {
return (
<Box direction="Column" shrink="No">
+185 -134
View File
@@ -255,6 +255,8 @@ function HomeCustomSection({
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// [Gitea #289] While filtering, a section with no matches is hidden.
if (filterQuery.trim() && items.length === 0) return null;
// Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller).
return (
@@ -443,6 +445,26 @@ export function Home() {
});
}, [mx, sortedRooms, filterQuery]);
// [Gitea #289] While filtering, hide empty sections; with no match at all,
// say so instead of leaving bare headers.
const filtering = filterQuery.trim().length > 0;
const customSectionMatch = useMemo(() => {
if (!filtering) return true;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return customSections.some((section) =>
section.rooms.some((rId) =>
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
),
);
}, [mx, customSections, filtering, filterQuery]);
const noFilterMatch =
filtering &&
!customSectionMatch &&
filteredFavoriteRooms.length === 0 &&
filteredRooms.length === 0 &&
filteredLowPriorityRooms.length === 0;
const favVirtualizer = useVirtualizer({
count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current,
@@ -580,7 +602,7 @@ export function Home() {
/>
</Box>
</NavCategory>
{favoriteRooms.length > 0 && (
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
@@ -629,142 +651,171 @@ export function Home() {
notificationPreferences={notificationPreferences}
/>
))}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
{noFilterMatch && (
<NavCategory>
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
<Text size="T300" priority="300" align="Center">
No rooms match &ldquo;{filterQuery.trim()}&rdquo;
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFilterQuery('')}
>
<Text size="B300">Clear filter</Text>
</Button>
</Box>
</NavCategory>
)}
{(!filtering || filteredRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A → Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A → Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
{lowPriorityRooms.length > 0 && (
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
+3 -1
View File
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type RoomNotificationsGroup = {
roomId: string;
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
hour24Clock,
dateFormatString,
}: RoomNotificationsGroupProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
/>
</Avatar>
<Text size="H4" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Box>
<Box shrink="No">
+10 -3
View File
@@ -38,6 +38,7 @@ import {
import { BlockType } from '../../../components/editor';
import { bytesToSize } from '../../../utils/common';
import { getHomePath } from '../../pathUtils';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type Shared = { payload: SharePayload; files: File[] };
@@ -47,6 +48,7 @@ type Shared = { payload: SharePayload; files: File[] };
* as the draft) — the user still presses Send.
*/
export function Share() {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate();
@@ -69,8 +71,12 @@ export function Share() {
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const q = query.trim().toLowerCase();
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
}, [mx, query]);
return q
? all.filter((r) =>
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
)
: all;
}, [mx, query, localNames]);
// Written straight into the room's draft atoms so its composer shows the
// upload board + text as soon as it mounts.
@@ -210,6 +216,7 @@ function ShareRoomRow({
disabled: boolean;
onPick: (room: Room) => void;
}) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -237,7 +244,7 @@ function ShareRoomRow({
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
+7
View File
@@ -91,6 +91,13 @@ export class CallControl extends EventEmitter implements CallControlState {
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super();
// [Gitea #316] More than 10 call features subscribe to state_update at once
// (call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
// announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC …).
// Hitting the default limit logged a MaxListenersExceededWarning, and the
// console then kept this CallControl — and through it the whole Element Call
// document — alive: ~11 MB leaked per call. These are not leaks, so lift it.
this.setMaxListeners(100);
this.state = state;
this.call = call;
+12 -1
View File
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -251,8 +252,18 @@ export class CallEmbed {
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
}
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
// (DMs, invite-only groups outside a space). In a voice channel or space
// room the notification carries an @room mention, so every member got a
// highlighted ping (and phone push) whenever a moderator hopped in, and
// other clients showed it as a missed call.
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
if (CallEmbed.startingCall(intent)) {
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
let notify = 'none';
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
}
params.append('sendNotificationType', notify);
}
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
+14 -1
View File
@@ -28,7 +28,10 @@ import {
} from 'matrix-js-sdk';
import { getCallCapabilities } from './utils';
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
import { dedupeInFlight } from '../../utils/inFlight';
/** A little longer than Element Call's own 2 s timeout for a restart. */
const RESTART_TIMEOUT_MS = 5000;
export class CallWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>;
@@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver {
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
}
// [Gitea #318] While the homeserver is stalled, Element Call times out each
// restart locally after 2 s and asks again, so requests for the same delay
// id piled up and hit the server together on recovery (404s, then 429s).
// Share one in-flight restart per id, and give it a timeout of its own.
private readonly restartOnce = dedupeInFlight<unknown>();
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
await this.mx._unstable_restartScheduledDelayedEvent(delayId);
await this.restartOnce(delayId, () =>
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
localTimeoutMs: RESTART_TIMEOUT_MS,
}),
);
}
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
+22 -1
View File
@@ -26,7 +26,7 @@ const api = {
: Object.getOwnPropertyDescriptor(target, key),
});
const { clearPlaintextCaches } = await import('./plaintextCaches');
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
store.clear();
@@ -83,3 +83,24 @@ test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
assert.ok(store.has('cinny_session'));
});
test('clearPlaintextCaches removes the call-volume list and the settings-sync stamp (#347)', () => {
store.clear();
store.set('lotus-per-user-volume', '{"@bob:server":0.3}');
store.set('settings-sync-meta:@me:server', '{"lastSyncedAt":1}');
store.set('settings', '{}');
clearPlaintextCaches('@me:server');
assert.equal(store.has('lotus-per-user-volume'), false);
assert.equal(store.has('settings-sync-meta:@me:server'), false);
assert.equal(store.has('settings'), true, 'device preferences are kept');
});
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
store.clear();
store.set('searchCacheEnabled', 'true');
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
clearPlaintextCaches('@me:server');
assert.equal(store.has('searchCacheEnabled'), false);
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
});
+53 -3
View File
@@ -47,6 +47,8 @@ const clearMsgDrafts = (): void => {
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
* - `lotus_outbox_v1` — decrypted content of messages not yet sent ([Gitea #112])
* - `lotus-per-user-volume`, `settings-sync-meta:<userId>` — whom you adjusted in
* calls, and a key naming the account ([Gitea #347])
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
* user — see [Gitea #41]; previously deliberately preserved across logout
* (N98), which let the next account on this device see/send a prior user's
@@ -62,9 +64,10 @@ const clearMsgDrafts = (): void => {
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
* swept with the rest — the server-side presence status survives, so a
* re-login loses nothing)
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
* preferences, not swept here
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
* Timed mutes moved to account data in [Gitea #333]; a leftover local
* `io.lotus.mute_timers` is migrated there on the next start.
*/
/**
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
@@ -92,7 +95,52 @@ const clearStatusMessage = (): void => {
});
};
/**
* [Gitea #347] Per-person residue that isn't message text but still says whom
* the previous user dealt with: Element Call's per-user volume memory (same
* origin on the web — element-call `lotusVolumeMemory.ts`) and the settings-sync
* stamp, whose key names the account.
*/
export const CALL_VOLUME_KEY = 'lotus-per-user-volume';
const clearCallVolumesAndSyncMeta = (userId?: string): void => {
const keys = [CALL_VOLUME_KEY, ...(userId ? [`settings-sync-meta:${userId}`] : [])];
keys.forEach((key) => {
try {
localStorage.removeItem(key);
} catch {
// best-effort
}
});
};
// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the
// page, and the reload's `pagehide` made RoomInput flush the open composer's
// draft straight back to localStorage; draft writes check this and stop.
// Module state, so it resets with the new page.
let wiped = false;
export const plaintextCachesWiped = (): boolean => wiped;
/**
* [Gitea #296] Device-wide flags/keys that carry the previous account's choice
* or id: the encrypted-search opt-in (the next account would otherwise start
* indexing decrypted text without opting in) and the SDK's per-user filter key.
*/
const clearAccountScopedFlags = (userId?: string): void => {
const keys = [
'searchCacheEnabled',
...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []),
];
keys.forEach((key) => {
try {
localStorage.removeItem(key);
} catch {
// best-effort
}
});
};
export const clearPlaintextCaches = (userId?: string): void => {
wiped = true;
clearTranslationCache();
clearScheduledMessages();
clearRecentSearches();
@@ -103,5 +151,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearOutbox();
clearCallSession();
clearStatusMessage();
clearCallVolumesAndSyncMeta(userId);
clearAccountScopedFlags(userId);
if (userId) clearNavToActivePathStore(userId);
};
+2
View File
@@ -24,6 +24,7 @@ import {
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
data: IRoomTimelineData,
) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// Single-owner rule: thread replies drive the room badge via
// RoomEvent.UnreadNotifications below — ignore them here so the count is
// never double-driven / mis-attributed to the main timeline.
+2 -2
View File
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
/**
* Global atom: Map<roomId, ScheduledMessage[]>
* Stores all locally-tracked scheduled messages across rooms.
* MSC4140 has no list endpoint, so we track them ourselves.
* Backed by localStorage so scheduled messages survive page refreshes.
* Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts
* (Gitea #322); localStorage is a cache for the first paint.
*/
export const scheduledMessagesAtom = atom(
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
+32
View File
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
syncLost: false,
lostAt: null,
});
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const baseDismissedAtom = atom<string[]>(readDismissed());
/**
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
* the security nudge knows a dismissed announcement isn't on screen.
* Persisted to localStorage; session-only if storage is unavailable.
*/
export const statusDismissedAtom = atom(
(get) => get(baseDismissedAtom),
(get, set, key: string) => {
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
set(baseDismissedAtom, next);
},
);
+25 -1
View File
@@ -1,6 +1,12 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
import {
CALL_REJOIN_MAX_AGE_MS,
decideRejoin,
othersInCall,
rejoinToastBody,
RejoinContext,
} from './callRejoin';
const base: RejoinContext = {
record: {
@@ -54,3 +60,21 @@ describe('decideRejoin', () => {
});
});
});
it('decideRejoin: DM calls are never auto-rejoined, only offered (#302)', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'auto', dm: true }), {
action: 'ask',
roomId: '!r',
});
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('rejoinToastBody and othersInCall (#302)', () => {
assert.equal(
rejoinToastBody('Bob Builder', true),
'You were in a call with Bob Builder before the restart. Tap to rejoin.',
);
assert.match(rejoinToastBody('Voice Lounge', false), /in the call in Voice Lounge/);
assert.equal(othersInCall(['@me:x'], '@me:x'), false);
assert.equal(othersInCall(['@me:x', '@bob:x'], '@me:x'), true);
});
+15
View File
@@ -67,6 +67,8 @@ export type RejoinContext = {
ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */
memberCount: number;
/** A DM call: rejoining one may ring the other person. */
dm?: boolean;
};
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
@@ -83,5 +85,18 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
// Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' };
// [Gitea #302] Never rejoin a DM call on our own: if the other person has
// just left, starting it again would ring them. Ask instead.
if (ctx.dm && mode === 'auto') return { action: 'ask', roomId: record.roomId };
return { action: mode, roomId: record.roomId };
}
/** Pure: whether anyone but us is still in the call (by call membership). */
export const othersInCall = (memberUserIds: string[], myUserId: string): boolean =>
memberUserIds.some((u) => u !== myUserId);
/** The rejoin toast's text; DMs name the person rather than "the call in <room>". */
export const rejoinToastBody = (roomName: string | undefined, dm: boolean): string =>
dm
? `You were in a call with ${roomName ?? 'them'} before the restart. Tap to rejoin.`
: `You were in the call in ${roomName ?? 'a room'} before the restart. Tap to rejoin.`;
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room } from 'matrix-js-sdk';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
({
isCallRoom: () => !!opts.callRoom,
getJoinRule: () => opts.joinRule ?? 'invite',
getLiveTimeline: () => ({
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
}),
}) as unknown as Room;
test('voice rooms never ring or show "missed call", even as DMs', () => {
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
});
test('DMs and invite-only groups outside a space ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
});
test('space channels, public and restricted rooms do not ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
});
const stateRoom = (
members: Array<{ sender: string; content: Record<string, unknown> }>,
timeline: unknown[] = [],
): Room =>
({
getLiveTimeline: () => ({
getState: () => ({
getStateEvents: () =>
members.map((m) => ({
getSender: () => m.sender,
getStateKey: () => `_${m.sender}_DEV_m.call`,
getContent: () => m.content,
})),
}),
getEvents: () => timeline,
}),
}) as unknown as Room;
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
const room = stateRoom([
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
{ sender: '@carol:x', content: {} },
]);
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
});
test('declinedRing finds our own decline of that ring only (#312)', () => {
const decline = (sender: string, rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => sender,
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
});
test('declinedRing matches a decline of the notification or of the membership (#313)', () => {
const decline = (rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => '@me:x',
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('$notification')]);
assert.equal(declinedRing(room, ['$membership', '$notification'], '@me:x'), true);
assert.equal(declinedRing(room, ['$membership'], '@me:x'), false);
assert.equal(
declinedRing(stateRoom([], [decline('$membership')]), ['$membership', undefined], '@me:x'),
true,
);
});
+63
View File
@@ -0,0 +1,63 @@
import { JoinRule, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvents } from './room';
/**
* Whether a call notification in this room may ring — and, by the same rule,
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
* call button is shown. Persistent voice rooms, space channels, restricted and
* public rooms never do: joining a voice channel alone is normal and isn't a
* "call" anyone missed.
*/
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
if (room.isCallRoom()) return false;
if (isDirect) return true;
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
};
/** A call-membership state event that is still "in the call" (left = empty content). */
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
!!content &&
(typeof content.application === 'string' ||
(Array.isArray(content.memberships) && content.memberships.length > 0));
/**
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
* current STATE. After a reload the MatrixRTC session can be stale (a join and
* leave replayed in one catch-up batch), but the state is right.
*/
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
(ev) =>
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
isActiveMembership(ev.getContent()),
);
/**
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
* the ring in the first sync after a restart, before any dismiss listener.
*/
export const declinedRing = (
room: Room,
ringIds: string | Array<string | undefined>,
myUserId: string,
): boolean => {
// [Gitea #313] A decline references the notification event (MSC4310); older
// Lotus builds referenced the call membership instead. Match either.
const ids = new Set((Array.isArray(ringIds) ? ringIds : [ringIds]).filter(Boolean));
return declinedAny(room, ids, myUserId);
};
const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string): boolean =>
room
.getLiveTimeline()
.getEvents()
.some(
(ev) =>
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
ev.getSender() === myUserId &&
ids.has(ev.getRelation()?.event_id),
);
+20
View File
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { IRoomTimelineData } from 'matrix-js-sdk';
import { isFromFilteredTimelineSet } from './room';
const data = (filter: unknown): IRoomTimelineData =>
({
liveEvent: true,
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
}) as unknown as IRoomTimelineData;
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
assert.equal(isFromFilteredTimelineSet(undefined), false);
assert.equal(
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
false,
);
});
+30
View File
@@ -0,0 +1,30 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildGeoUri, formatAccuracy } from './geoShare';
import { parseGeoUri } from './common';
test('buildGeoUri: exact share keeps 6 decimals and adds the accuracy as u=', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12.4), 'geo:40.748817,-73.985428;u=12');
assert.equal(buildGeoUri(40.748817, -73.985428), 'geo:40.748817,-73.985428');
assert.equal(buildGeoUri(40.748817, -73.985428, Number.NaN), 'geo:40.748817,-73.985428');
});
test('buildGeoUri: approximate share rounds to ~1 km and never claims better than 1 km', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12, true), 'geo:40.75,-73.99;u=1000');
assert.equal(buildGeoUri(40.748817, -73.985428, 3500, true), 'geo:40.75,-73.99;u=3500');
});
test('buildGeoUri output still parses with the renderer', () => {
const loc = parseGeoUri(buildGeoUri(51.5, -0.12, 30));
assert.ok(loc);
assert.equal(parseFloat(loc.latitude), 51.5);
assert.equal(parseFloat(loc.longitude), -0.12);
});
test('formatAccuracy', () => {
assert.equal(formatAccuracy(12.4), '±12 m');
assert.equal(formatAccuracy(3500), '±3.5 km');
assert.equal(formatAccuracy(25000), '±25 km');
assert.equal(formatAccuracy(undefined), undefined);
assert.equal(formatAccuracy(0), undefined);
});
+39
View File
@@ -0,0 +1,39 @@
/**
* [Gitea #340] Helpers for the "Share location" confirm step: the geo URI to
* send (RFC 5870, with the fix's accuracy as `u=` so receivers can draw a
* radius) and a human accuracy label, so a ±3 km Wi-Fi/IP fix isn't presented
* — or sent — as if it were exact.
*/
/** Decimals kept for an "approximate" share: 2 → ~1.1 km of latitude. */
export const APPROXIMATE_DECIMALS = 2;
/** Uncertainty reported for an approximate share (half a 0.01° cell, rounded up). */
export const APPROXIMATE_UNCERTAINTY_M = 1000;
const validAccuracy = (accuracy?: number): number | undefined =>
typeof accuracy === 'number' && Number.isFinite(accuracy) && accuracy > 0
? Math.round(accuracy)
: undefined;
export const buildGeoUri = (
latitude: number,
longitude: number,
accuracy?: number,
approximate = false,
): string => {
const decimals = approximate ? APPROXIMATE_DECIMALS : 6;
const u = approximate
? Math.max(APPROXIMATE_UNCERTAINTY_M, validAccuracy(accuracy) ?? 0)
: validAccuracy(accuracy);
const base = `geo:${latitude.toFixed(decimals)},${longitude.toFixed(decimals)}`;
return u ? `${base};u=${u}` : base;
};
/** "±12 m", "±3.5 km", or undefined when the browser gave no accuracy. */
export const formatAccuracy = (accuracy?: number): string | undefined => {
const m = validAccuracy(accuracy);
if (m === undefined) return undefined;
if (m < 1000) return `±${m} m`;
const km = m / 1000;
return `±${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
};
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupeInFlight } from './inFlight';
test('dedupeInFlight: one request per key while pending, a new one after (#318)', async () => {
const once = dedupeInFlight<number>();
let calls = 0;
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
const run = async () => {
calls += 1;
await gate;
return calls;
};
const a = once('d1', run);
const b = once('d1', run);
const c = once('d2', run);
assert.equal(a, b);
release();
assert.deepEqual(await Promise.all([a, b, c]), [2, 2, 2]);
assert.equal(calls, 2);
await once('d1', run);
assert.equal(calls, 3, 'settled requests are not reused');
});
test('dedupeInFlight: a rejection clears the key', async () => {
const once = dedupeInFlight<void>();
await assert.rejects(once('k', () => Promise.reject(new Error('boom'))));
await once('k', () => Promise.resolve());
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Share one in-flight promise per key: callers that ask again while a request
* for the same key is still pending get that request instead of a new one.
*/
export function dedupeInFlight<T>(): (key: string, run: () => Promise<T>) => Promise<T> {
const pending = new Map<string, Promise<T>>();
return (key, run) => {
const existing = pending.get(key);
if (existing) return existing;
const p = run().finally(() => pending.delete(key));
pending.set(key, p);
return p;
};
}
+25
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
plain,
DEFAULT_GROUPS,
kumaBeatTime,
kumaUrls,
@@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
'server-down',
);
});
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
assert.equal(
plain('join #lotus-general or #announcements:lotusguild.org'),
'join #lotus-general or #announcements:lotusguild.org',
);
assert.equal(
plain('ping @some_user about snake_case and C#'),
'ping @some_user about snake_case and C#',
);
assert.equal(
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
'calls -> fixed, > 5 people, 5 * 3 = 15',
);
assert.equal(
plain('# Heading\n* item one\n- item two\n> quoted'),
'Heading\nitem one\nitem two\nquoted',
);
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
assert.equal(
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
'Read the guide docs',
);
});

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