Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 37b3ae1ad7 fix(desktop): without a tray, the first-close dialog defaults to Quit (cinny-desktop #37)
CI / Build & Quality Checks (pull_request) Successful in 3m13s
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 18m14s
On desktops with no tray host (stock GNOME), "Keep running in the tray"
hid the window with no icon to bring it back. Ask the desktop app
(has_tray_host); without a tray, say so, focus "Quit when I close the
window", and offer "Keep running in the background" as the secondary
choice. Older desktop builds keep the current dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 02:41:04 -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
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
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
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
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 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 ddf9da1a55 fix(reminders): a reminder stays until you've seen it (#338)
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 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m6s
ReminderMonitor pushed an ordinary toast (auto-dismissed after 4 s) and
deleted the reminder at once, with no OS notification — one that came due
while the app was in the background or the user was away was lost.

Now the reminder toast is sticky; clicking it opens the message and
dismissing it closes it, and only then is the reminder deleted (an unseen
one returns on the next start). When the window isn't focused it also
raises an OS notification (respecting "show notifications" and quiet
hours; no decrypted E2EE text, per N106).

Verified headless (notifications granted, window unfocused): due at
t+24 s, the toast stayed up for the remaining 51 s, one OS notification
"Reminder :: call the dentist before 5", the reminder stayed in
io.lotus.reminders until dismissed, then was removed.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:11:31 -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
jared 022e5e6987 Merge pull request 'fix(embeds): GIF previews keep their own size and shape (#341)' (#344) from fix/gif-card-aspect into lotus
CI / Build & Quality Checks (push) Successful in 3m6s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m45s
2026-10-11 00:53:25 -04:00
jared 5c765925d2 Merge pull request 'deps: fix the npm audit advisories that turned CI red (#345)' (#346) from deps/npm-audit-fixes into lotus
CI / Build & Quality Checks (push) Canceled after 13s
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 00:53:12 -04:00
Lotus CIandClaude Opus 5.5 747714b2f3 Merge branch 'deps/npm-audit-fixes' into fix/gif-card-aspect
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 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m31s
Brings in #346 (npm audit fixes) so this PR's CI audit step passes; the
shared commit drops out of this PR's diff once #346 is merged into lotus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-10-10 22:45:04 -04:00
Lotus CIandClaude Opus 5.5 c9d276d2a5 deps: fix the npm audit advisories that turned CI red (#345)
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 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m35s
`npm audit --audit-level=high --omit=dev` (Build & Quality Checks) started
failing on every branch after new advisories were published:

- source-map-js 1.2.1 → 1.2.2 (high, GHSA-68fv-2mgg-jv7q; transitive via
  sanitize-html → postcss, in range — lockfile only)
- i18next-http-backend 4.0.0 → 4.0.2 (low, GHSA-xvq9-wjp8-hwqf)
- katex 0.16.47 → 0.18.11 (low, GHSA-238p-pmpm-9mq7; fixed in 0.18.2).
  0.17/0.18's breaking changes are an internal __defineFunction API and
  prefixed internal CSS classes; Lotus uses neither (KaTeX.tsx calls
  renderToString and imports the package's own stylesheet).

Only these four lockfile entries change (plus katex's own CLI dependency
commander 8 → 15). npm audit --omit=dev: 0 vulnerabilities.

Verified headless against Vite with a fresh dep cache (katex 0.18.11
served): inline and display math render (\frac, \sqrt, \sum with limits),
KaTeX fonts load, no page errors; translations still load (en strings,
no raw keys). tsc clean, 1319 unit tests pass, eslint 0 errors (36
warnings, unchanged), prettier clean, production build OK.

Fixes #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-10-10 22:28:30 -04:00
Lotus CIandClaude Opus 5.5 68f44f0f1c fix(embeds): GIF previews keep their own size and shape (#341)
CI / Build & Quality Checks (pull_request) Failing after 3m21s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
Giphy and Tenor link previews forced every GIF into the same box — full
card width, max 200px tall, object-fit: cover — whatever its shape: a
square GIF lost half its height, a portrait one three quarters (only the
middle band showed, so captions and faces at the top/bottom were cut), and
a 100×80 GIF was blown up 4×.

utils/gifPreviewSize.ts (unit-tested) fits the GIF inside 400×320 from
og:image:width/height, keeping its aspect ratio and enlarging small GIFs
at most 2×. The card centres the GIF on the surface colour with
object-fit: contain, reserves the box before it loads, and the GIF badge
sits on the GIF's corner. Without dimensions it shows at natural size,
capped by the card. The >10 MB thumbnail fallback is requested at 2× the
box (800×640) so it isn't upscaled.

Verified headless (fixture previews, desktop / Pixel 7 / 320px phone):
480×200 → 398×166, 300×300 → 320×320 (292×292 on 320px), 240×480 →
160×320, 100×80 → 200×160, all 100% visible; the no-dimension fallback
and narrow cards keep the ratio. tsc clean, 1325 unit tests pass, eslint
0 errors (36 warnings, unchanged), prettier clean.

Fixes #341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-10-10 22:15:56 -04:00
jared 30e2dbcde9 Remove stray config backup; ignore *.bak
CI / Build & Quality Checks (push) Successful in 2m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 11m33s
2026-10-03 00:16:58 -04:00
jared d1993f2084 Merge pull request 'Device-security nudge: verify this device / key backup (#110, #123)' (#258) from security-nudge 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 6s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 11m5s
Merge pull request #258
2026-09-30 21:03:23 -04:00
Lotus CIandClaude Opus 5.5 6a7627fa26 feat: device-security nudge — verify this device / key backup (#110, #123)
CI / Build & Quality Checks (pull_request) Successful in 1m48s
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 11m38s
One "security" strip, in the same top slot and style as the status banner
(#124), for this device, in priority order:
- "Verify this device" — cross-signing exists but this device isn't
  verified (can't unlock backed-up history, untrusted to others). First,
  because verifying with the recovery key also connects the backup.
- "Connect this device to your key backup" — a backup exists, this device
  isn't using it.
- "Set up key backup" — no backup at all (includes accounts without
  cross-signing; the setup flow does both).
The button opens Settings → Devices (existing flows); "Not now" snoozes.

Rules: nothing in a device's first 24 h; "Not now" snoozes that nudge 7
days; 3 dismissals stop it; never on a healthy device; waits until sync
has settled (never under "Connecting…"); outage/maintenance/connection
strips win. Per-device record in localStorage, wiped on logout. Mounted
inside the Matrix client context and an error boundary (an early version
outside the context crashed the app on load — caught before pushing).

Also: the status strip wraps its text on phones instead of truncating it
when there's no Details button (benefits #124's strips too).

Design approved on #123 (real-client screenshots there). Tests: 4 unit
(the #123 state table, loading, timing, stored record); e2e: nothing during
the grace period, "Set up key backup" → Settings → Devices, "Not now"
holds across a reload. Unit 1319, Playwright 29 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 20:28:32 -04:00
jared 8e30c73e2f Merge pull request 'Desktop updates: Linux package installs update via their package manager' (#256) from linux-package-updates 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 7s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 12m6s
Merge pull request #256
2026-09-30 20:09:50 -04:00
Lotus CIandClaude Opus 5.5 6acdd439d5 fix(desktop updates): pacman command downloads first, installs the local file
CI / Build & Quality Checks (pull_request) Successful in 1m44s
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 10m38s
`sudo pacman -U <url>` also fetches `<url>.sig` and failed (404) on
CachyOS: remote packages fall under RemoteFileSigLevel (signature
required) and we don't sign the package. A downloaded file installs under
LocalFileSigLevel (optional): `curl -LO <url> && sudo pacman -U ./…`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 19:57:12 -04:00
Lotus CI 9aec3691ac Merge remote-tracking branch 'origin/lotus' into linux-package-updates
CI / Build & Quality Checks (pull_request) Successful in 1m38s
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) Canceled after 2m32s
2026-09-30 19:41:39 -04:00
jared 4c36c03066 Merge pull request 'deps: brace-expansion 1.1.21 (unblocks CI audit)' (#257) from audit-brace-expansion 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 2s
CI / Playwright smoke (e2e) (push) Successful in 9m31s
Merge pull request #257
2026-09-30 19:41:38 -04:00
Lotus CIandClaude Opus 5.5 fbdac30d18 deps: brace-expansion 1.1.18 → 1.1.21 (GHSA-q2hr-2g5m-vwhr and two related DoS advisories)
CI / Build & Quality Checks (pull_request) Successful in 1m44s
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 11m56s
npm audit --omit=dev started failing every PR: brace-expansion <=1.1.20
(via @eslint/eslintrc → minimatch 3) has three high-severity DoS
advisories. Lockfile-only patch bump; nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 19:04:47 -04:00
Lotus CIandClaude Opus 5.5 27d659118f fix(desktop updates): Linux package installs update via their package manager
CI / Build & Quality Checks (pull_request) Failing after 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
Reported on CachyOS: "Check for Updates → The update downloaded but
couldn't be installed … Permission denied (os error 13) at path
/usr/bin/tauri_current_app…". The app was installed from the Arch package;
Tauri's Linux updater can only replace an AppImage.

With cinny-desktop's new update_install_kind command:
- pacman / deb installs: the toast says the update is available and opens
  Settings → General → App Updates, which shows the package-manager command
  (`sudo pacman -U …pkg.tar.zst`, or the .deb + `sudo apt install`) with
  Copy command and Download package — no Install & Restart that can't work.
  "Copied" only when the clipboard write actually succeeded.
- other distros: a link to the downloads page.
- Windows, AppImage, and desktop builds without the command: unchanged
  in-app update.
- a native "package-managed" refusal shows the same help.

Tests: unit (kinds, commands, refusal detection); in the real client with a
simulated desktop bridge: pacman → toast + Settings command/buttons,
install never attempted; older desktop → in-app flow as before. Unit 1321,
Playwright 26 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 19:00:10 -04:00
jared 747400ea25 Merge pull request 'Homeserver status banner from Uptime Kuma (#124)' (#255) from server-status-banner into lotus
CI / Build & Quality Checks (push) Successful in 4m10s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 14m59s
Merge pull request #255: homeserver status banner from Uptime Kuma (#124)
2026-09-29 12:29:00 -04:00
Lotus CIandClaude Opus 5.5 6ae0087213 feat: homeserver status banner from Uptime Kuma (#124)
CI / Build & Quality Checks (pull_request) Successful in 4m12s
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 11m58s
When the homeserver, voice calls or sign-in break, or maintenance is under
way, say so in the app — driven by the Kuma status page
(isitup.lotusguild.org/status/matrix), managed from Kuma's UI. The client
asks Kuma directly (not via our servers) so it still hears "the server is
down" when our servers can't tell it.

- config.json `statusPages`, keyed by homeserver: users of other servers
  never contact Kuma.
- utils/kumaStatus.ts (pure, unit-tested): parse Kuma 2.x's public JSON;
  a group is down when any monitor fails two checks in a row (down+down or
  pending+down); maintenance = windows under way; announcements = incidents.
  One strip at a time: server down (connection lost AND Kuma confirms) >
  server having problems > maintenance > calls down > announcement;
  sign-in problems on the login screen only.
- Wording about the user's own connection: "Connection lost … our status
  checks say the server is up, so it may be your internet connection" ONLY
  when Kuma checked the server after this client's connection dropped and
  it passed; a stale "up" (Kuma needs a minute or two to notice an outage)
  keeps the plain "Connection Lost!".
- useServerStatus: polls only while visible; 5 min, 60 s while something is
  wrong or the connection is lost, at once when it drops; backoff; any
  failure = no banner (Kuma being unreachable never looks like Matrix
  being down); GET only, no cookies.
- UI in the existing banner slot and style (ContainerColor/Line like the
  sync and clock banners); Details expands; dismiss for calls-down and
  announcements (an edited announcement comes back); calls-down note above
  Join; phone: one line + Details.

Needs the CSP connect-src to allow https://isitup.lotusguild.org (matrix
repo) before it can fetch in production; until then it fails quiet.

Tests: 15 unit tests (live page layout, two-check rule, any-monitor rule,
unknown/garbage, UTC beat times, stale-vs-fresh "up", priorities, login vs
client, maintenance, announcements + dismiss/edit); e2e (fixtures for Kuma):
calls-down strip + dismiss across reload, other homeservers make no
requests, Kuma 500 → nothing, lost connection + Kuma down → critical strip
instead of "Connection Lost", + fresh "up" → "may be your connection",
+ stale "up" → plain "Connection Lost". Unit 1315, Playwright 26 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 12:08:08 -04:00
jared d6548c56fd Merge pull request 'Desktop: mirror the login tokens into the OS keychain (#105, step 1)' (#254) from desktop-keychain-mirror 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 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 9m10s
Merge pull request #254: desktop keychain mirror (#105, step 1)
2026-09-29 09:34:57 -04:00
jared 23f059d9a4 Merge pull request 'Desktop: call page on its own loopback origin, opt-in (#43)' (#252) from desktop-call-origin into lotus
CI / Build & Quality Checks (push) Canceled after 8s
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
Merge pull request #252: desktop call page on its own loopback origin, opt-in (#43)
2026-09-29 09:34:48 -04:00
Lotus CIandClaude Opus 5.5 9b9f33b270 feat(desktop): mirror the login tokens into the OS keychain (#105, step 1)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
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 8m44s
Step 1 of moving the desktop app's login out of the webview's plaintext
localStorage: keep a verified copy of the tokens in the OS keychain
(Windows Credential Manager, via cinny-desktop's new secure_session_*
commands). The session is still read from localStorage exactly as before,
so nothing about login changes and a keychain problem can't log anyone out.
Step 2 (a later release, once this has run on real installs) switches reads
to the keychain and drops the tokens from localStorage.

- sessions.ts: onSessionPersisted — listeners told about every session
  write (login, token rotation) and removal (logout); a throwing listener
  can't break the write.
- keychainMirror.ts: desktop only. Mirrors userId/deviceId/accessToken/
  refreshToken (not the rest of the session); reads first and writes only
  when the copy differs, then verifies by reading back; serialized, 5 s
  timeouts; no session → clear (also covers a logout whose reload beat the
  clear). Every failure is a status, never an exception. A desktop build
  without the commands reads as "unsupported", so this can ship before the
  desktop side.
- Settings → General (desktop): "Login in the system keychain" status.

Tested: unit tests (fake keychain: store, no rewrite when current, rotation,
clear, unsupported, missing commands, denied write, read-back mismatch,
timeout); a simulated desktop app with a fake keychain, 14/14 (login
mirrors only the secrets, Settings status, reload verifies without
rewriting, logout clears, an existing session is mirrored after upgrade,
Linux/denied show an honest status and stay logged in); the real Linux
desktop binary (commands answer "unsupported", login unaffected, Settings
says so). Unit 1295 pass, Playwright 20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:24:46 -04:00
Lotus CIandClaude Opus 5.5 7d7a379ce0 feat(desktop): call page on its own loopback origin, opt-in (#43)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
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 10m24s
The desktop app loads the bundled Element Call page from its own origin
(http://localhost:<port>), so the call frame can read the app's storage
(login token) and DOM — the hole #43 closed on the web by moving the page
to call.chat.lotusguild.org.

The desktop's local server can also answer on http://127.0.0.1:<port>: the
same server and bundle, but a different origin (and still a secure
context). resolveDesktopCallPageUrl loads the bundled page from there when
the desktop config sets `desktopCallOrigin`:
- only a loopback http origin on the SAME port as the app, no path, query
  or credentials;
- only when the app itself runs on http://localhost (release builds; debug
  builds on tauri:// keep the same-origin page);
- unset (every desktop build until cinny-desktop opts in, together with the
  server bind, CSP and permission changes it needs): unchanged.

The web app is unchanged (elementCallUrl as before).

Tested in a simulated desktop app (Tauri bridge stub + the desktop
config.json, served on localhost and 127.0.0.1) against a local Synapse +
LiveKit, two users: call page from http://127.0.0.1:<port>, parentUrl =
the app origin; the frame gets SecurityError on parent.localStorage and
parent.document (same-origin control: readable); join, speaking indicator,
mic off/on, screenshare start/stop, layout switch and hang-up all work, no
page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4;
the misses on both sides were the local LiveKit connection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:27 -04:00
jared 91f82d60e3 Merge pull request 'A stalled server no longer reads as "your clock is ahead"' (#251) from clock-skew-lag into lotus
CI / Build & Quality Checks (push) Successful in 3m4s
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 10m32s
Merge pull request #251: a stalled server no longer reads as a wrong clock
2026-09-28 22:40:13 -04:00
Lotus CI f0865115a4 Merge remote-tracking branch 'origin/lotus' into clock-skew-lag
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 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m59s
2026-09-28 22:11:31 -04:00
jared 899e160aed Merge pull request 'Offline outbox: unsent messages survive reload and retry (#112)' (#250) from offline-outbox into lotus
CI / Build & Quality Checks (push) Successful in 2m58s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 10m52s
Merge pull request #250: Offline outbox (#112)
2026-09-28 22:08:28 -04:00
Lotus CIandClaude Opus 5.5 3e5fdd0dab test(e2e): clock-ahead warning needs a minute of samples (#158)
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 10s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
"Ahead" is now reported only once it has held for a minute of fresh
samples (a stalled server delivers late and reads as ahead). The test sends
its ticks, checks nothing is shown yet, fast-forwards the page clock past a
minute and sends two more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 22:08:13 -04:00
Lotus CIandClaude Opus 5.5 bb569d69a2 fix: a stalled server no longer reads as "your clock is ahead"
CI / Build & Quality Checks (pull_request) Successful in 3m1s
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 11m28s
Incident 2026-09-29: the homeserver's host ran out of memory and stalled for
~2 minutes. The /sync that finally went out carried events whose `age` was
computed ~30 s before it arrived, so every client showed "Your computer's
clock is 30 seconds ahead of the server" while the real problem was the
server (all host clocks were within 0.25 s the whole evening).

The skew estimate was the median of the last 5 samples, and a sample is
local skew + delivery delay, so one late /sync with a handful of events
tripped it.

- Estimate = the LOWEST sample of the last 5 minutes: delay only ever adds,
  so the fastest-delivered event is the truest.
- "Behind" (which a delay can't cause) is reported as soon as there are 3
  samples, like before. "Ahead" must hold across samples received at least
  a minute apart, so a single late burst never trips it.
- Samples are aged on the monotonic clock, and a change of the local clock
  (someone fixing it) resets the measurement, so the warning clears at once.
- Only events stamped by our own homeserver are sampled: a federated event's
  origin_server_ts is the other server's clock.
- Wording: "This device's clock is … Voice calls and encrypted messages can
  fail until it's corrected." / call bar "Device clock … : calls may fail"
  (was "will fail").

Unit tests: the incident (late burst after normal traffic, and a fresh
client whose first samples are all late), mixed slow/fast deliveries,
ahead only after a minute, behind at once, hysteresis, clock fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 21:40:02 -04:00
Lotus CIandClaude Opus 5.5 02d86caeb0 feat: offline outbox — unsent messages survive reload and retry (#112)
CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).

- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
  sends (text, stickers, reactions, polls; not call signalling or
  redactions) are mirrored to localStorage from their first local echo until
  the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
  same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
  same txnId; older ones come back as "Failed to send" for the user to
  retry or cancel. Ones the server already has (transaction id seen in
  /sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
  the connection returns (sync recovers or the browser goes back online),
  and after a blip while online (5 s, backing off, max 10 per message).
  Oldest first, in order per room. 4xx / consent / encryption failures are
  left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
  when you're back online" (thread view too), not the red ✕. The ✕ is now
  a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
  decrypted, like drafts).

Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 21:02:45 -04:00
jared c0c93213c1 Merge pull request 'Desktop: Lotus links use the public web app and open in-app (#248)' (#249) from desktop-lotus-links into lotus
CI / Build & Quality Checks (push) Successful in 2m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 9m6s
Merge pull request #249: Desktop Lotus links use the public web app (#248)
2026-09-28 19:53:20 -04:00
131 changed files with 5484 additions and 604 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+3
View File
@@ -14,3 +14,6 @@ test-results/
# local dev homeserver (scripts/dev-homeserver.sh)
.dev-homeserver/
__pycache__/
# Local config backups
*.bak
+12 -1
View File
@@ -12,5 +12,16 @@
"enabled": false,
"basename": "/"
},
"gifApiKey": ""
"gifApiKey": "",
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
"login": "Login"
}
}
}
}
+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);
+61 -6
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>>(
@@ -257,12 +303,21 @@ test.describe('local homeserver regression', () => {
await page.clock.install({ time: Date.now() + 14 * 60 * 1000 });
await loginUI(page, alice);
await openRoom(page, room);
for (let i = 0; i < 4; i += 1) {
// eslint-disable-next-line no-await-in-loop
await sendText(bob, room, `tick ${i}`);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(500);
}
const ticks = async (from: number, n: number) => {
for (let i = from; i < from + n; i += 1) {
// eslint-disable-next-line no-await-in-loop
await sendText(bob, room, `tick ${i}`);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(500);
}
};
await ticks(0, 4);
// "Ahead" could be a late delivery (a stalled server), so it is only
// reported once it has held for a minute of fresh samples.
await page.waitForTimeout(2000);
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
await page.clock.fastForward('01:05');
await ticks(4, 2);
await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible();
await page.getByRole('button', { name: 'Dismiss for 24 h' }).click();
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
+74
View File
@@ -0,0 +1,74 @@
import { test, expect, Page } from '@playwright/test';
import { HS, ensureUser, hsReachable, loginUI, TestUser, uniq } from './localHs';
// [Gitea #110, #123] The device-security nudge. A fresh account has no key
// backup and no cross-signing → "Set up key backup", but only after the 24 h
// grace period on this device; "Not now" snoozes it.
const strip = (page: Page) => page.getByRole('status').filter({ hasText: /encryption keys/ });
/** Back-date this device's first-seen record past the 24 h grace period, then reload. */
async function pastGrace(page: Page) {
await page.evaluate(() => {
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
localStorage.setItem(
`lotus-security-nudge-${deviceId}`,
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
);
});
await page.reload();
}
/** The nudge waits until sync has settled (after "Connecting…"). */
async function settled(page: Page) {
await page
.getByText('Connecting...')
.first()
.waitFor({ timeout: 30_000 })
.catch(() => undefined);
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
await page.waitForTimeout(2000);
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
});
test.beforeEach(async () => {
user = await ensureUser(uniq('e2e_nudge_'));
});
test('nothing during the first 24 h on a device', async ({ page }) => {
test.setTimeout(150_000);
await loginUI(page, user);
await settled(page);
// Past the point where the nudge shows once the grace period is over (~5 s).
await page.waitForTimeout(8000);
await expect(strip(page)).toHaveCount(0);
});
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
test.setTimeout(150_000);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
const nudge = strip(page);
await expect(nudge).toContainText("Your encryption keys aren't backed up");
await nudge.getByRole('button', { name: 'Set up' }).click();
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
await strip(page).getByRole('button', { name: 'Not now' }).click();
await expect(strip(page)).toHaveCount(0);
await page.reload();
await settled(page);
await expect(strip(page)).toHaveCount(0);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { test, expect, Page } from '@playwright/test';
import {
HS,
createRoom,
ensureUser,
hsReachable,
loginUI,
openRoom,
uniq,
TestUser,
} from './localHs';
// [Gitea #124] Status banner from the homeserver's Uptime Kuma page. Kuma is
// answered by fixtures here; nothing contacts a real Kuma.
const KUMA = 'https://isitup.lotusguild.org';
const PAGE_CFG = { url: KUMA, slug: 'matrix' };
const UP = 1;
const DOWN = 0;
type Fixture = { hs?: number; calls?: number; staleMinutes?: number; fail?: boolean };
const kumaTime = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
/** Serve config.json with a status page for `serverName` and answer Kuma from `fx()`. */
async function mockKuma(page: Page, serverName: string | null, fx: () => Fixture) {
const kumaRequests: string[] = [];
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = serverName ? { [serverName]: PAGE_CFG } : {};
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const url = route.request().url();
kumaRequests.push(url);
const f = fx();
if (f.fail) {
await route.fulfill({ status: 500, body: 'oops' });
return;
}
const cors = { 'access-control-allow-origin': '*' };
if (url.includes('/heartbeat/')) {
// Two checks, the latest `staleMinutes` ago (0 = just now).
const last = Date.now() - (f.staleMinutes ?? 0) * 60_000;
const beats = (s: number) => [
{ status: s, time: kumaTime(last - 60_000) },
{ status: s, time: kumaTime(last) },
];
await route.fulfill({
headers: cors,
json: { heartbeatList: { 1: beats(f.hs ?? UP), 2: beats(f.calls ?? UP) }, uptimeList: {} },
});
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [],
maintenanceList: [],
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 1 }] },
{ name: 'Voice calls', monitorList: [{ id: 2 }] },
],
},
});
});
return kumaRequests;
}
const serverOf = (u: TestUser) => u.userId.split(':').slice(1).join(':');
const strip = (page: Page, text: RegExp) =>
page.locator('[role="status"], [role="alert"]').filter({ hasText: text });
const cutSync = (page: Page) =>
page.route(/\/_matrix\/client\/v3\/sync/, (route) => route.abort('connectionfailed'));
test.describe('server status banner (#124)', () => {
let alice: TestUser;
let room: string;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
alice = await ensureUser(uniq('e2e_status_'));
room = await createRoom(alice, 'Status Room');
});
test('calls down: amber strip, dismiss sticks across a reload', async ({ page }) => {
await mockKuma(page, serverOf(alice), () => ({ calls: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
const calls = strip(page, /Voice calls are down right now\. Messages still work\./);
await expect(calls).toBeVisible();
await calls.getByRole('button', { name: 'Dismiss' }).click();
await expect(calls).toHaveCount(0);
await page.reload();
await openRoom(page, room);
await page.waitForTimeout(2000);
await expect(calls).toHaveCount(0);
});
test('other homeservers never contact Kuma', async ({ page }) => {
const requests = await mockKuma(page, 'some.other.server', () => ({ hs: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
await page.waitForTimeout(3000);
expect(requests).toEqual([]);
});
test('Kuma failing shows nothing (fail quiet)', async ({ page }) => {
const requests = await mockKuma(page, serverOf(alice), () => ({ fail: true }));
await loginUI(page, alice);
await openRoom(page, room);
await expect.poll(() => requests.length).toBeGreaterThan(0);
await page.waitForTimeout(1500);
await expect(strip(page, /server|Voice calls|Maintenance/i)).toHaveCount(0);
});
test('connection lost + Kuma says the server is down: critical strip, not "Connection Lost"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
// Connected: the server is "having problems" for others, not for us.
await expect(strip(page, /server is having problems/)).toBeVisible();
await cutSync(page);
const down = page.getByRole('alert').filter({ hasText: /server is down\. We're on it/ });
await expect(down).toBeVisible({ timeout: 120_000 });
await expect(page.getByText('Connection Lost! Reconnecting...')).toHaveCount(0);
});
test('connection lost + Kuma checked since and the server is up: "may be your connection"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: UP }));
await loginUI(page, alice);
await openRoom(page, room);
await cutSync(page);
await expect(page.getByText(/server is up, so it may be your internet connection/)).toBeVisible(
{
timeout: 120_000,
},
);
});
test('connection lost + only a stale "up" from Kuma: plain "Connection Lost"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: UP, staleMinutes: 10 }));
await loginUI(page, alice);
await openRoom(page, room);
await cutSync(page);
await expect(page.getByText('Connection Lost! Reconnecting...')).toBeVisible({
timeout: 120_000,
});
await page.waitForTimeout(3000);
await expect(page.getByText(/may be your internet connection/)).toHaveCount(0);
});
});
+25 -23
View File
@@ -45,12 +45,12 @@
"html-react-parser": "6.1.2",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0",
"i18next-http-backend": "4.0.2",
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.47",
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
@@ -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": {
@@ -5026,9 +5026,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -8168,9 +8168,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz",
"integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9162,28 +9162,29 @@
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"version": "0.18.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
"integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==",
"deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
"commander": "^15.0.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">= 12"
"node": ">=22.12.0"
}
},
"node_modules/keyv": {
@@ -12164,9 +12165,10 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
+3 -3
View File
@@ -72,12 +72,12 @@
"html-react-parser": "6.1.2",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0",
"i18next-http-backend": "4.0.2",
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.47",
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
@@ -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>
+70 -13
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';
@@ -109,6 +111,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 +285,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 +459,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 +502,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 +590,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 +603,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 +684,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 +697,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);
+56 -10
View File
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
export function CloseBehaviorPrompt() {
const [open, setOpen] = useState(false);
const [launchOnLogin, setLaunchOnLogin] = useState(false);
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
// tray" would hide the window with no icon to bring it back. Older desktop
// builds lack the command; assume a tray then (the old behaviour).
const [hasTray, setHasTray] = useState(true);
const modalStyle = useModalStyle(420);
// Tells the native side we're listening (see get_close_behavior).
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
}, []);
useTauriEvent('lotus-close-requested', () => setOpen(true));
useTauriEvent('lotus-close-requested', () => {
const ask = tauriInvoke()?.('has_tray_host');
if (!ask) {
setOpen(true);
return;
}
ask
.then((v) => setHasTray(v !== false))
.catch(() => setHasTray(true))
.finally(() => setOpen(true));
});
const choose = useCallback(
(value: 'tray' | 'quit') => {
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#close-behavior-keep',
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
// Dismissing without choosing keeps the window open; the next close
// asks again.
onDeactivate: () => setOpen(false),
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="close-behavior-body">
Lotus Chat can keep running in the system tray when you close this window, so
messages, notifications and calls still reach you. Open it again from the tray icon.
{hasTray
? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.'
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
</Text>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
</Box>
<Box direction="Column" gap="200">
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
<Text size="B400">Keep running in the tray</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
<Text size="B400">Quit when I close the window</Text>
</Button>
{hasTray ? (
<>
<Button
id="close-behavior-keep"
variant="Primary"
onClick={() => choose('tray')}
>
<Text size="B400">Keep running in the tray</Text>
</Button>
<Button
id="close-behavior-quit"
variant="Secondary"
fill="Soft"
onClick={() => choose('quit')}
>
<Text size="B400">Quit when I close the window</Text>
</Button>
</>
) : (
<>
<Button
id="close-behavior-quit"
variant="Primary"
onClick={() => choose('quit')}
>
<Text size="B400">Quit when I close the window</Text>
</Button>
<Button
id="close-behavior-keep"
variant="Secondary"
fill="Soft"
onClick={() => choose('tray')}
>
<Text size="B400">Keep running in the background</Text>
</Button>
</>
)}
</Box>
<Text size="T200" priority="300">
You can change this in Settings → General.
@@ -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
@@ -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 = () => {
+36 -19
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,14 @@ 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';
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 +82,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 +92,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 +130,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 +146,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>
)}
@@ -732,6 +746,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 +788,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.
@@ -1,5 +1,6 @@
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
import { GIF_PREVIEW_MAX_HEIGHT } from '../../utils/gifPreviewSize';
export const UrlPreview = style([
DefaultReset,
@@ -955,12 +956,14 @@ export const PortraitSideLayout = style([
// GIF card (Giphy / Tenor)
// ---------------------------------------------------------------------------
// Full card width; a GIF narrower than the card (portrait, square, small) is
// centred on the surface colour instead of being stretched and cropped.
export const GifThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
display: 'flex',
justifyContent: 'center',
width: '100%',
maxHeight: toRem(200),
overflow: 'hidden',
flexShrink: 0,
backgroundColor: color.Surface.Container,
@@ -972,14 +975,26 @@ export const GifThumbnailWrapper = style([
},
]);
// Wraps just the GIF so the "GIF" badge sits on the GIF's own corner.
export const GifFrame = style([
DefaultReset,
{
position: 'relative',
display: 'block',
maxWidth: '100%',
},
]);
// The inline width/aspect-ratio (from og:image:width/height) sets the size;
// max-width lets a phone's narrower card shrink it, keeping the ratio.
export const GifThumbnailImg = style([
DefaultReset,
{
width: '100%',
maxHeight: toRem(200),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
maxWidth: '100%',
maxHeight: toRem(GIF_PREVIEW_MAX_HEIGHT),
height: 'auto',
objectFit: 'contain',
},
]);
@@ -13,6 +13,7 @@ import {
as,
color,
config,
toRem,
} from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css';
@@ -48,6 +49,11 @@ import {
tiktokOembedUrl,
tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed';
import {
GIF_PREVIEW_MAX_HEIGHT,
GIF_PREVIEW_MAX_WIDTH,
gifPreviewBox,
} from '../../utils/gifPreviewSize';
const linkStyles = { color: color.Success.Main };
@@ -2277,6 +2283,9 @@ function GifCard({
}) {
const title = (prev['og:title'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// Size the GIF from its own dimensions (Giphy and Tenor publish them) rather
// than forcing every GIF into one cropped band.
const box = gifPreviewBox(prev['og:image:width'], prev['og:image:height']);
// A GIF card exists to show a moving GIF, so request the original rather than
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
@@ -2284,7 +2293,15 @@ function GifCard({
const thumbSrc = mxcImage
? shouldServeGifOriginal(url, prev)
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
: mxcUrlToHttp(
mx,
mxcImage,
useAuthentication,
GIF_PREVIEW_MAX_WIDTH * 2,
GIF_PREVIEW_MAX_HEIGHT * 2,
'scale',
false,
)
: null;
// If there's no image, fall back to a generic-style layout
@@ -2316,7 +2333,9 @@ function GifCard({
return (
<Box direction="Column" style={{ width: '100%' }}>
{/* GIF thumbnail — full width */}
{/* The whole GIF at its own aspect ratio, centred; never cropped. With a
known size the box is reserved before it loads (no layout jump);
without one it shows at its natural size, capped by the card. */}
<a
href={url}
target="_blank"
@@ -2324,8 +2343,22 @@ function GifCard({
className={previewCss.GifThumbnailWrapper}
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
>
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
<span className={previewCss.GifBadge}>GIF</span>
<span className={previewCss.GifFrame}>
<img
className={previewCss.GifThumbnailImg}
src={thumbSrc}
alt={title}
loading="lazy"
width={box?.width}
height={box?.height}
style={
box
? { width: toRem(box.width), aspectRatio: `${box.width} / ${box.height}` }
: undefined
}
/>
<span className={previewCss.GifBadge}>GIF</span>
</span>
</a>
{/* Footer row */}
<UrlPreviewContent>
+2 -2
View File
@@ -72,9 +72,9 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
size="T200"
truncate
style={{ color: color.Warning.Main }}
title="Fix your computer's clock — calls and encryption depend on it"
title="This device's clock is off. Calls and encryption depend on it: turn on automatic time in your system settings."
>
Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
Device clock {describeSkewVsServer(clockSkew.skewMs)}: calls may fail
</Text>
</>
)}
+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>
@@ -8,6 +8,7 @@ import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEm
import { useCallPreferences } from '../../state/hooks/callPreferences';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useCallsDown } from '../server-status/ServerStatusBanner';
type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown';
@@ -61,6 +62,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const micPermission = useMediaPermissions();
const micDenied = micPermission === 'denied';
const callsDown = useCallsDown();
const disabled = inOtherCall || !canJoin || micDenied;
@@ -91,6 +93,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
<ChatButton />
</Box>
<Box grow="Yes" direction="Column" gap="200">
{callsDown && (
<Text size="T200" style={{ color: color.Warning.Main, textAlign: 'center' }}>
Voice calls are down right now, so joining may fail.
</Text>
)}
{micDenied && (
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
Microphone access is blocked. Enable it in your browser settings to join.
@@ -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],
),
@@ -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,
+268
View File
@@ -0,0 +1,268 @@
import { useSetAtom } from 'jotai';
import { useEffect } from 'react';
import {
ClientEvent,
ClientEventHandlerMap,
ConnectionError,
EventStatus,
KnownMembership,
MatrixClient,
MatrixError,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
SyncState,
} from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { sendOfflineAtom } from '../../state/sendOffline';
import {
OUTBOX_MAX_AUTO_RETRIES,
OutboxEntry,
isRetryableSendError,
loadOutbox,
outboxRetryDelayMs,
planRestore,
saveOutbox,
shouldKeepInOutbox,
withEntry,
withoutEntry,
} from '../../utils/outbox';
const PENDING: ReadonlySet<EventStatus | null> = new Set([
EventStatus.SENDING,
EventStatus.ENCRYPTING,
EventStatus.QUEUED,
EventStatus.NOT_SENT,
]);
const ONLINE: ReadonlySet<SyncState | null> = new Set([
SyncState.Prepared,
SyncState.Syncing,
SyncState.Catchup,
]);
const OFFLINE: ReadonlySet<SyncState | null> = new Set([SyncState.Reconnecting, SyncState.Error]);
/** The server already has it: its transaction id came back down /sync. */
const isDelivered = (room: Room, txnId: string): boolean => {
const hasTxn = (events: MatrixEvent[]) =>
events.some((e) => e.getUnsigned().transaction_id === txnId);
return (
hasTxn(room.getLiveTimeline().getEvents()) ||
room.getThreads().some((t) => hasTxn(t.liveTimeline.getEvents()))
);
};
type OutboxSession = {
/** Own pending events of this session, by txnId. */
live: Map<string, { event: MatrixEvent; room: Room }>;
autoRetries: Map<string, number>;
restored: boolean;
retrying: boolean;
};
// Per client, not per mount: a remount must neither restore twice nor forget
// the events it is tracking.
const sessions = new WeakMap<MatrixClient, OutboxSession>();
const getSession = (mx: MatrixClient): OutboxSession => {
let session = sessions.get(mx);
if (!session) {
session = { live: new Map(), autoRetries: new Map(), restored: false, retrying: false };
sessions.set(mx, session);
}
return session;
};
/**
* [Gitea #112] Offline outbox (see utils/outbox.ts): mirrors own message sends
* into localStorage until the server confirms them, puts unsent ones back
* after a reload, and re-sends network failures when the connection returns.
*/
export function OutboxFeature() {
const mx = useMatrixClient();
const setOffline = useSetAtom(sendOfflineAtom);
useEffect(() => {
const userId = mx.getSafeUserId();
let entries = loadOutbox(userId);
const session = getSession(mx);
const { live, autoRetries } = session;
let disposed = false;
let offlineNow = false;
const timers = new Set<ReturnType<typeof setTimeout>>();
const isOffline = () =>
OFFLINE.has(mx.getSyncState()) ||
(typeof navigator !== 'undefined' && navigator.onLine === false);
const persist = (next: OutboxEntry[]) => {
if (next === entries) return;
entries = next;
saveOutbox(userId, entries);
};
/**
* A network failure while we think we're online (a blip neither sync nor
* the browser noticed): try again shortly, backing off. Real outages are
* handled by the reconnect / back-online triggers below.
*/
const scheduleBlipRetry = (event: MatrixEvent) => {
const attempts = autoRetries.get(event.getTxnId() ?? '') ?? 0;
if (!isRetryableSendError(event.error) || attempts >= OUTBOX_MAX_AUTO_RETRIES || isOffline())
return;
const timer = setTimeout(() => {
timers.delete(timer);
retryQueued();
}, outboxRetryDelayMs(attempts));
timers.add(timer);
};
const onLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, room) => {
const txnId = event.getTxnId();
if (!txnId || event.getSender() !== userId) return;
if (PENDING.has(event.status)) {
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
live.set(txnId, { event, room });
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
persist(
withEntry(entries, {
txnId,
roomId: room.roomId,
threadId: event.threadRootId ?? null,
type: event.getType(),
content: event.getContent(),
ts: event.getTs(),
}),
);
return;
}
// SENT, CANCELLED, or the remote echo replaced it (status null).
live.delete(txnId);
autoRetries.delete(txnId);
persist(withoutEntry(entries, txnId));
};
/** Re-send network failures, oldest first; a room stops at its first failure. */
const retryQueued = async () => {
if (session.retrying || disposed) return;
session.retrying = true;
try {
const byRoom = new Map<Room, MatrixEvent[]>();
Array.from(live.values())
.filter(
({ event }) =>
event.status === EventStatus.NOT_SENT &&
isRetryableSendError(event.error) &&
(autoRetries.get(event.getTxnId() ?? '') ?? 0) < OUTBOX_MAX_AUTO_RETRIES,
)
.sort((a, b) => a.event.getTs() - b.event.getTs())
.forEach(({ event, room }) => {
byRoom.set(room, [...(byRoom.get(room) ?? []), event]);
});
await Promise.all(
Array.from(byRoom.entries()).map(async ([room, events]) => {
for (const event of events) {
if (disposed || event.status !== EventStatus.NOT_SENT) continue;
const txnId = event.getTxnId() ?? '';
autoRetries.set(txnId, (autoRetries.get(txnId) ?? 0) + 1);
try {
// eslint-disable-next-line no-await-in-loop
await mx.resendEvent(event, room);
} catch (e) {
// Still offline: keep the rest of this room in order for the next try.
if (isRetryableSendError(e)) break;
}
}
}),
);
} finally {
session.retrying = false;
}
};
const restore = () => {
if (session.restored) return;
session.restored = true;
const plan = planRestore(
entries,
Date.now(),
(roomId) => mx.getRoom(roomId)?.getMyMembership() === KnownMembership.Join,
(entry) => {
const room = mx.getRoom(entry.roomId);
return !!room && isDelivered(room, entry.txnId);
},
);
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
plan.restore.forEach((entry) => {
const room = mx.getRoom(entry.roomId);
if (!room || live.has(entry.txnId)) return;
// Same shape as the SDK's own local echo (client.sendCompleteEvent).
const event = new MatrixEvent({
type: entry.type,
content: entry.content,
event_id: `~${entry.roomId}:${entry.txnId}`,
sender: userId,
room_id: entry.roomId,
origin_server_ts: entry.ts,
});
const thread = entry.threadId ? room.getThread(entry.threadId) : undefined;
if (thread) event.setThread(thread);
event.setTxnId(entry.txnId);
event.setStatus(EventStatus.NOT_SENT);
if (plan.autoSend.has(entry.txnId)) {
// Recent: treat like a send that lost the network (shown as Queued
// while offline, re-sent below and on reconnect).
// (The SDK types `error` as MatrixError but stores any send error there.)
event.error = new ConnectionError(
'not sent before the app was closed',
) as unknown as MatrixError;
} else {
// Old: back as "Failed to send"; the user decides (Retry / Cancel).
autoRetries.set(entry.txnId, OUTBOX_MAX_AUTO_RETRIES);
}
try {
room.addPendingEvent(event, entry.txnId); // emits LocalEchoUpdated → `live`
} catch {
// Already pending under this txnId: nothing to restore.
}
});
if (!isOffline()) retryQueued();
};
const updateOffline = () => {
const offline = isOffline();
// Back online (sync recovered or the browser says so): send what's queued.
if (offlineNow && !offline && session.restored) retryQueued();
offlineNow = offline;
setOffline(offline);
};
const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state, prevState) => {
updateOffline();
if (!ONLINE.has(state)) return;
if (!session.restored) {
restore();
return;
}
if (OFFLINE.has(prevState) || prevState === SyncState.Catchup) retryQueued();
};
const onBrowserOnline = () => updateOffline();
mx.on(RoomEvent.LocalEchoUpdated, onLocalEcho);
mx.on(ClientEvent.Sync, onSync);
window.addEventListener('online', onBrowserOnline);
window.addEventListener('offline', onBrowserOnline);
updateOffline();
if (ONLINE.has(mx.getSyncState())) restore();
return () => {
disposed = true;
timers.forEach((t) => clearTimeout(t));
mx.off(RoomEvent.LocalEchoUpdated, onLocalEcho);
mx.off(ClientEvent.Sync, onSync);
window.removeEventListener('online', onBrowserOnline);
window.removeEventListener('offline', onBrowserOnline);
};
}, [mx, setOffline]);
return null;
}
+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>
);
}
@@ -48,6 +48,7 @@ import {
addRecentForwardTarget,
} from '../../../state/recentForwardTargets';
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type RoomRowProps = {
room: Room;
@@ -58,6 +59,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 +99,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 +214,7 @@ function RecentChip({
onToggle: () => void;
sending: boolean;
}) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
@@ -240,7 +243,7 @@ function RecentChip({
}
>
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Chip>
);
@@ -252,6 +255,7 @@ type Props = {
};
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom);
@@ -296,8 +300,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(
+94 -32
View File
@@ -26,19 +26,21 @@ import {
config,
} from 'folds';
import React, {
CSSProperties,
FormEventHandler,
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 } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
@@ -98,27 +100,53 @@ import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
import { sendOfflineAtom } from '../../../state/sendOffline';
import { isRetryableSendError } from '../../../utils/outbox';
// Delivery status indicator for own messages
function DeliveryStatus({
status,
mEvent,
room,
lotusTerminal,
}: {
status: string | null;
mEvent: MatrixEvent;
room: Room;
lotusTerminal: boolean;
}) {
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;
let label: string;
let colorStyle: string;
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
// [Gitea #112] A network failure while offline is queued, not failed: the
// outbox sends it again when the connection is back.
const queued = status === EventStatus.NOT_SENT && offline && isRetryableSendError(mEvent.error);
const failed = !queued && (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED);
if (failed) {
iconSrc = Icons.Cross;
label = 'Failed to send';
label = status === EventStatus.NOT_SENT ? 'Failed to send. Click to retry' : 'Failed to send';
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
} else if (status === EventStatus.QUEUED || isSending) {
iconSrc = Icons.Send;
label = isSending ? 'Sending...' : 'Queued';
} else if (queued || status === EventStatus.QUEUED || isSending) {
iconSrc = queued ? Icons.RecentClock : Icons.Send;
if (queued) label = "Queued. Will send when you're back online";
else label = isSending ? 'Sending...' : 'Queued';
colorStyle = lotusTerminal
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
: color.Secondary.Main;
@@ -129,27 +157,49 @@ function DeliveryStatus({
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
: color.Secondary.Main;
}
const retryable = failed && status === EventStatus.NOT_SENT;
const handleRetry: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.stopPropagation();
if (mEvent.status === EventStatus.NOT_SENT) mx.resendEvent(mEvent, room).catch(() => undefined);
};
const style: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
userSelect: 'none',
...(lotusTerminal && failed ? { textShadow: 'var(--lt-glow-red)' } : {}),
};
const icon = (
<span className={isSending ? SendingSpinClass : undefined}>
<Icon size="100" src={iconSrc} />
</span>
);
if (retryable) {
return (
<button
type="button"
onClick={handleRetry}
aria-label={label}
title={label}
style={{
...style,
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
}}
>
{icon}
</button>
);
}
return (
<Box
as="span"
aria-label={label}
title={label}
style={{
display: 'inline-flex',
alignItems: 'center',
marginTop: '2px',
lineHeight: 1,
color: colorStyle,
opacity: 0.85,
userSelect: 'none',
...(lotusTerminal && status === EventStatus.NOT_SENT
? { textShadow: 'var(--lt-glow-red)' }
: {}),
}}
>
<span className={isSending ? SendingSpinClass : undefined}>
<Icon size="100" src={iconSrc} />
</span>
<Box as="span" aria-label={label} title={label} style={style}>
{icon}
</Box>
);
}
@@ -675,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>
@@ -815,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>
@@ -1102,7 +1164,7 @@ export const Message = React.memo(
/>
)}
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
<DeliveryStatus mEvent={mEvent} room={room} lotusTerminal={!!lotusTerminal} />
)}
</Box>
);
+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) => {
+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>
@@ -33,6 +33,8 @@ import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, conf
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { isKeyHotkey } from 'is-hotkey';
import { isRetryableSendError } from '../../../utils/outbox';
import { sendOfflineAtom } from '../../../state/sendOffline';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
@@ -253,6 +255,7 @@ export type ThreadTimelineProps = {
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const mx = useMatrixClient();
const sendOffline = useAtomValue(sendOfflineAtom);
const alive = useAlive();
const useAuthentication = useMediaAuthentication();
@@ -992,8 +995,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const showEmptyReplies = ready && thread.length === 0;
const renderPendingEvent = (mEvent: MatrixEvent) => {
// [Gitea #112] Network failures while offline are queued, not failed.
const queued =
mEvent.status === EventStatus.NOT_SENT && sendOffline && isRetryableSendError(mEvent.error);
const failed =
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
!queued &&
(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED);
return (
<div
key={mEvent.getId() ?? mEvent.getTxnId()}
@@ -1007,6 +1014,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
</Text>
</Box>
)}
{queued && (
<Box style={{ padding: `0 ${config.space.S400}` }}>
<Text size="T200" priority="300">
Queued. Will send when you&apos;re back online
</Text>
</Box>
)}
</div>
);
};
@@ -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>
@@ -0,0 +1,137 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { SyncState } from 'matrix-js-sdk';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import { Icons } from 'folds';
import { ErrorBoundary } from 'react-error-boundary';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../../hooks/useDeviceVerificationStatus';
import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBackup';
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
NUDGE_COPY,
NUDGE_KEY_PREFIX,
nudgeFor,
NudgeRecord,
parseNudgeRecord,
shouldShowNudge,
} from '../../utils/securityNudge';
import { StatusStrip } from '../server-status/ServerStatusBanner';
/** This device's backup connection: undefined until known, then true/false. */
const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
const alive = useAlive();
const [active, setActive] = useState<boolean>();
useEffect(() => {
crypto.getActiveSessionBackupVersion().then((v) => {
if (alive()) setActive(typeof v === 'string');
});
}, [crypto, alive]);
useKeyBackupStatusChange(useCallback((enabled: boolean) => setActive(enabled), []));
return active;
};
const readRecord = (key: string): NudgeRecord => {
let raw: string | null = null;
try {
raw = localStorage.getItem(key);
} catch {
/* storage unavailable */
}
const record = parseNudgeRecord(raw, Date.now());
if (!raw) {
try {
localStorage.setItem(key, JSON.stringify(record));
} catch {
/* best effort */
}
}
return record;
};
function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const mx = useMatrixClient();
const deviceId = mx.getDeviceId() ?? undefined;
const crossSigning = useCrossSigningActive();
const status = useDeviceVerificationStatus(crypto, mx.getSafeUserId(), deviceId);
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
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.
const [settled, setSettled] = useState(false);
useSyncState(
mx,
useCallback((state: SyncState | null, prev?: SyncState | null) => {
setSettled(state === SyncState.Syncing && prev === SyncState.Syncing);
}, []),
);
const key = `${NUDGE_KEY_PREFIX}${deviceId ?? 'unknown'}`;
const [record, setRecord] = useState(() => readRecord(key));
let deviceVerified: boolean | undefined;
if (status === VerificationStatus.Verified) deviceVerified = true;
else if (status === VerificationStatus.Unverified) deviceVerified = false;
const kind = nudgeFor({
crossSigning,
deviceVerified,
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
backupActive,
});
// One strip at a time: outages, maintenance and a lost connection win.
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
const copy = NUDGE_COPY[kind];
const dismiss = () => {
const next = dismissNudge(kind, record, Date.now());
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
/* dismissed for this session only */
}
setRecord(next);
};
return (
<StatusStrip
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
icon={Icons.ShieldUser}
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
dismissLabel="Not now"
onDismiss={dismiss}
/>
);
}
/**
* [Gitea #110, #123] The device-security nudge: verify this device, connect
* it to the key backup, or set up key backup. See utils/securityNudge.ts.
*/
function SecurityBannerInner() {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
if (!crypto) return null;
return <SecurityBannerFor crypto={crypto} />;
}
// A nudge must never take the app down: any error just hides it.
export function SecurityBanner() {
return (
<ErrorBoundary fallback={null}>
<SecurityBannerInner />
</ErrorBoundary>
);
}
@@ -0,0 +1,214 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { 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';
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 { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
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 bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info;
return Icons.Warning;
};
/**
* [Gitea #124] One status strip, in the same slot and style as the sync and
* clock banners. Presentational: the caller picks the banner.
*/
export type StripContent = Pick<
StatusBanner,
'key' | 'tone' | 'text' | 'detail' | 'dismissable'
> & {
kind?: StatusBanner['kind'];
};
export function StatusStrip({
banner,
onDismiss,
icon,
action,
dismissLabel,
}: {
banner: StripContent;
onDismiss?: () => void;
/** Overrides the icon picked from the banner's tone/kind. */
icon?: IconSrc;
/** A primary action button (e.g. "Verify"). */
action?: { label: string; onClick: () => void };
/** A text button instead of the ✕ for dismissing (e.g. "Not now"). */
dismissLabel?: string;
}) {
const [open, setOpen] = useState(false);
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
useEffect(() => setOpen(false), [banner.key]);
const critical = banner.tone === 'Critical';
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: banner.tone })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
gap="100"
role={critical ? 'alert' : 'status'}
aria-live={critical ? 'assertive' : 'polite'}
>
<Box
alignItems="Center"
justifyContent={mobile ? 'Start' : 'Center'}
gap="200"
style={{ minHeight: config.size.X300 }}
>
<Icon size="100" src={icon ?? bannerIcon(banner)} />
<Text size="L400" truncate={mobile && !!banner.detail && !open} style={{ minWidth: 0 }}>
{banner.text}
</Text>
{banner.detail && (
<Button
size="300"
variant={banner.tone}
fill="None"
radii="300"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{ flexShrink: 0 }}
>
<Text size="B300" style={{ textDecoration: 'underline' }}>
{open ? 'Hide' : 'Details'}
</Text>
</Button>
)}
{action && (
<Button
size="300"
variant={banner.tone}
fill="Solid"
radii="300"
onClick={action.onClick}
style={{ flexShrink: 0 }}
>
<Text size="B300">{action.label}</Text>
</Button>
)}
{banner.dismissable && onDismiss && dismissLabel && (
<Button
size="300"
variant={banner.tone}
fill="None"
radii="300"
onClick={onDismiss}
style={{ flexShrink: 0 }}
>
<Text size="B300">{dismissLabel}</Text>
</Button>
)}
{banner.dismissable && onDismiss && !dismissLabel && (
<IconButton
size="300"
variant={banner.tone}
fill="None"
radii="300"
aria-label="Dismiss"
onClick={onDismiss}
style={{ flexShrink: 0 }}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)}
</Box>
{banner.detail && open && (
<Box justifyContent={mobile ? 'Start' : 'Center'}>
<Text size="T200" style={{ maxWidth: 680, whiteSpace: 'pre-line' }}>
{banner.detail}
</Text>
</Box>
)}
</Box>
<Line variant={banner.tone} size="300" />
</Box>
);
}
/** Runs the Kuma poller for the logged-in homeserver and publishes it. */
export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
const { statusPages } = useClientConfig();
const page = useMemo(
() => resolveStatusPage(statusPages, mx.getDomain() ?? undefined),
[statusPages, mx],
);
const [lostAt, setLostAt] = useState<number | null>(null);
useSyncState(
mx,
useCallback((state: SyncState | null) => {
const lost = state === SyncState.Reconnecting || state === SyncState.Error;
setLostAt((prev) => {
if (!lost) return null;
return prev ?? Date.now();
});
}, []),
);
// [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]);
return null;
}
/** The logged-in app's status strip. */
export function ServerStatusBanner() {
const { status, syncLost } = useAtomValue(serverStatusAtom);
const [dismissed, setDismissed] = useState(readDismissed);
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);
};
return <StatusStrip banner={banner} onDismiss={dismiss} />;
}
/** The sign-in screen's strip, for the homeserver being signed in to. */
export function LoginStatusBanner({ serverName }: { serverName: string }) {
const { statusPages } = useClientConfig();
const page = useMemo(() => resolveStatusPage(statusPages, serverName), [statusPages, serverName]);
const status = useServerStatus(page);
const banner = pickBanner(status, { syncLost: false, where: 'login' });
return banner ? <StatusStrip banner={banner} /> : null;
}
/** Calls-down note for the call Join controls (null when calls are fine). */
export function useCallsDown(): boolean {
const { status } = useAtomValue(serverStatusAtom);
return status?.calls === 'down' && status.homeserver !== 'down';
}
@@ -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);
+128 -35
View File
@@ -112,8 +112,15 @@ import {
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
import {
describeUpdateError,
isPackageManagedError,
manualDownloadUrl,
packageUpdateHelp,
} from '../../../utils/updateErrors';
import { copyToClipboard } from '../../../utils/dom';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
import { useKeychainMirrorStatus } from '../../../state/keychainMirror';
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
@@ -238,6 +245,27 @@ function AutostartSetting() {
);
}
// [Gitea #105] Desktop: whether the login is also kept in the OS keychain.
function KeychainMirrorSetting() {
const status = useKeychainMirrorStatus();
if (!isTauriEnv() || status.state === 'idle' || status.state === 'cleared') return null;
let description: string;
if (status.state === 'ok') {
description =
"A copy of your login is kept in the system keychain (Windows Credential Manager). A later update will keep it only there, out of the app's data folder.";
} else if (status.state === 'unsupported') {
description =
"Not available on this system yet. Your login is saved in the app's data folder, as before.";
} else {
description = `Couldn't save a copy to the system keychain (${status.error}). You stay logged in; your login is saved in the app's data folder, as before.`;
}
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Login in the system keychain" description={description} />
</SequenceCard>
);
}
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
@@ -570,6 +598,7 @@ function Appearance() {
<DesktopChromeSetting />
<AutostartSetting />
<KeychainMirrorSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
@@ -2771,12 +2800,76 @@ function updateProgressText(progress: UpdateProgress | undefined): string {
return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`;
}
function AppUpdates() {
const { isTauri, status, check, install } = useTauriUpdater();
if (!isTauri) return null;
/**
* A Linux package install is updated with its package manager (the in-app
* updater can't write to /usr/bin): the command, Copy and a download link.
*/
function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUpdateHelp>> }) {
const [copied, setCopied] = useState<'no' | 'yes' | 'failed'>('no');
const copy = () => {
const text = help.command ?? '';
if (!navigator.clipboard) {
copyToClipboard(text);
setCopied('yes');
return;
}
navigator.clipboard.writeText(text).then(
() => setCopied('yes'),
() => setCopied('failed'),
);
};
let copyLabel = 'Copy command';
if (copied === 'yes') copyLabel = 'Copied';
else if (copied === 'failed') copyLabel = 'Copy failed: select it above';
return (
<Box direction="Column" gap="200">
<Text size="T200">
Lotus Chat was installed as a system package, so update it the same way
{help.command ? ' (your chats and settings are kept):' : '.'}
</Text>
{help.command && (
<Text
size="T200"
style={{ fontFamily: 'monospace', wordBreak: 'break-all', userSelect: 'all' }}
>
{help.command}
</Text>
)}
<Box gap="200" wrap="Wrap">
{help.command && (
<Button size="300" radii="300" variant="Secondary" onClick={copy}>
<Text size="B300">{copyLabel}</Text>
</Button>
)}
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(help.url, '_blank')}
>
<Text size="B300">{help.download}</Text>
</Button>
</Box>
</Box>
);
}
const description =
status.state === 'checking'
function AppUpdates() {
const { isTauri, status, check, install, installKind } = useTauriUpdater();
if (!isTauri) return null;
const packageHelp = packageUpdateHelp(installKind);
const packageUpdate =
!!packageHelp &&
(status.state === 'available' ||
(status.state === 'error' && isPackageManagedError(status.message)));
const description = packageUpdate
? status.state === 'available'
? `Update available: v${status.version}`
: 'An update is available.'
: status.state === 'checking'
? 'Checking for updates...'
: status.state === 'up-to-date'
? 'Lotus Chat is up to date.'
@@ -2793,43 +2886,43 @@ function AppUpdates() {
else check();
};
const after =
status.state === 'available' ? (
<Button size="300" radii="300" onClick={() => install()}>
<Text size="B300">Install &amp; Restart</Text>
const after = packageUpdate ? undefined : status.state === 'available' ? (
<Button size="300" radii="300" onClick={() => install()}>
<Text size="B300">Install &amp; Restart</Text>
</Button>
) : status.state === 'checking' || status.state === 'installing' ? (
<Spinner variant="Secondary" size="200" />
) : status.state === 'error' ? (
<Box gap="200" wrap="Wrap" justifyContent="End">
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
<Text size="B300">Try again</Text>
</Button>
) : status.state === 'checking' || status.state === 'installing' ? (
<Spinner variant="Secondary" size="200" />
) : status.state === 'error' ? (
<Box gap="200" wrap="Wrap" justifyContent="End">
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
<Text size="B300">Try again</Text>
{status.phase !== 'check' && (
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
>
<Text size="B300">Download installer</Text>
</Button>
{status.phase !== 'check' && (
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
>
<Text size="B300">Download installer</Text>
</Button>
)}
</Box>
) : (
<Button size="300" radii="300" variant="Secondary" onClick={check}>
<Text size="B300">Check</Text>
</Button>
);
)}
</Box>
) : (
<Button size="300" radii="300" variant="Secondary" onClick={check}>
<Text size="B300">Check</Text>
</Button>
);
return (
<Box direction="Column" gap="100">
<Text size="L400">App Updates</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Check for Updates" description={description} after={after} />
{status.state === 'error' && (
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
{status.state === 'error' && !packageUpdate && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
Details: {status.message}
</Text>
+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
@@ -26,6 +26,22 @@ export type ClientConfig = {
*/
elementCallUrl?: string;
/**
* [Gitea #43] Desktop only: the loopback origin the desktop app's local
* server also answers on (e.g. "http://127.0.0.1:44548"), to load the
* bundled call page from a different origin than the app
* ("http://localhost:44548"). Set by cinny-desktop together with the server
* and CSP changes it needs; unset keeps the same-origin call page.
*/
desktopCallOrigin?: string;
/**
* [Gitea #124] Uptime Kuma status page per homeserver, for the status banner:
* { "<server name>": { url, slug, groups?: { homeserver, calls, login } } }.
* Homeservers not listed never contact Kuma.
*/
statusPages?: Record<string, unknown>;
/**
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
* The desktop app sets it so it can hand calls it can't make to the browser.
+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);
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { kumaUrls, parseKumaStatus, ServerStatus, StatusPageConfig } from '../utils/kumaStatus';
/** Normal poll, and while a problem is showing (so recovery clears quickly). */
export const STATUS_POLL_MS = 5 * 60 * 1000;
export const STATUS_POLL_PROBLEM_MS = 60 * 1000;
const STATUS_FETCH_TIMEOUT_MS = 10 * 1000;
const STATUS_BACKOFF_MAX_MS = 15 * 60 * 1000;
const fetchJson = async (url: string, signal: AbortSignal): Promise<unknown> => {
const res = await fetch(url, { cache: 'no-store', credentials: 'omit', signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
};
export const hasProblem = (s: ServerStatus | null): boolean =>
!!s &&
(s.homeserver === 'down' || s.calls === 'down' || s.login === 'down' || s.maintenance.length > 0);
/**
* [Gitea #124] Poll the homeserver's Kuma status page (none configured → no
* requests, null). Only while the page is visible; every 5 min, every 60 s
* while something is wrong, and at once when `urgent` turns true (this
* client's connection dropped). Any failure → null ("no banner"), with
* backoff: Kuma being unreachable must never look like Matrix being down.
*/
export const useServerStatus = (
page: StatusPageConfig | undefined,
urgent = false,
): ServerStatus | null => {
const [status, setStatus] = useState<ServerStatus | null>(null);
const pageKey = page ? `${page.url}/${page.slug}` : '';
useEffect(() => {
if (!page) {
setStatus(null);
return undefined;
}
const urls = kumaUrls(page);
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let failures = 0;
let stopped = false;
let last: ServerStatus | null = null;
const schedule = (ms: number) => {
if (timer) clearTimeout(timer);
timer = setTimeout(poll, ms);
};
async function poll() {
if (stopped) return;
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
controller?.abort();
controller = new AbortController();
const abort = controller;
const timeout = setTimeout(() => abort.abort(), STATUS_FETCH_TIMEOUT_MS);
try {
const [pageJson, heartbeatJson] = await Promise.all([
fetchJson(urls.page, abort.signal),
fetchJson(urls.heartbeat, abort.signal),
]);
if (stopped) return;
failures = 0;
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
setStatus(last);
// While this client's connection is lost, keep asking every minute: that
// is how it learns Kuma has checked the server since the drop.
schedule(hasProblem(last) || urgent ? STATUS_POLL_PROBLEM_MS : STATUS_POLL_MS);
} catch {
if (stopped) return;
failures += 1;
last = null;
setStatus(null);
schedule(Math.min(STATUS_POLL_PROBLEM_MS * 2 ** failures, STATUS_BACKOFF_MAX_MS));
} finally {
clearTimeout(timeout);
}
}
const onVisible = () => {
if (document.visibilityState === 'visible') poll();
};
document.addEventListener('visibilitychange', onVisible);
poll();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
controller?.abort();
document.removeEventListener('visibilitychange', onVisible);
};
// pageKey identifies the page; the object itself is rebuilt from config.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageKey, urgent]);
return status;
};
+18 -3
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useTauriEvent } from './useTauri';
import { UpdatePhase, parseUpdateError } from '../utils/updateErrors';
import { InstallKind, UpdatePhase, parseUpdateError, toInstallKind } from '../utils/updateErrors';
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
@@ -36,6 +36,11 @@ export type UpdateFailure = { phase: UpdatePhase; message: string };
// mid-request by the resolve/reject below, so nothing gets stuck.
const updateStatusAtom = atom<UpdateStatus>({ state: 'idle' });
// How this install gets updated; asked once. A desktop build without the
// command (older than it) rejects the call: keep the old in-app behaviour.
const installKindAtom = atom<InstallKind | undefined>(undefined);
let installKindRequested = false;
/**
* Mirror native download progress into the status. Mounted once (in
* TauriUpdateFeature) so the listener isn't duplicated per consumer.
@@ -50,6 +55,16 @@ export function useTauriUpdateProgress(): void {
export function useTauriUpdater() {
const isTauri = !!tauriInvoke();
const [status, setStatus] = useAtom(updateStatusAtom);
const [installKind, setInstallKind] = useAtom(installKindAtom);
useEffect(() => {
const invoke = tauriInvoke();
if (!invoke || installKindRequested) return;
installKindRequested = true;
invoke('update_install_kind')
.then((kind) => setInstallKind(toInstallKind(kind)))
.catch(() => setInstallKind('in-app'));
}, [setInstallKind]);
const check = useCallback(async () => {
const invoke = tauriInvoke();
@@ -89,5 +104,5 @@ export function useTauriUpdater() {
}
}, [setStatus]);
return { isTauri, status, check, install };
return { isTauri, status, check, install, installKind: installKind ?? 'in-app' };
}
+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]);
};
+15 -3
View File
@@ -36,7 +36,11 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl';
import {
resolveCallPageUrl,
resolveDesktopCallPageUrl,
setCallPageUrl,
} from '../plugins/call/callPageUrl';
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
@@ -44,7 +48,7 @@ import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl'
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`,
};
@@ -223,7 +227,15 @@ function App() {
>
{(clientConfig) => {
// [Gitea #43] Idempotent: where the call page is loaded from.
setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri()));
setCallPageUrl(
isTauri()
? resolveDesktopCallPageUrl(
clientConfig.desktopCallOrigin,
window.location.origin,
import.meta.env.BASE_URL,
)
: resolveCallPageUrl(clientConfig.elementCallUrl, false),
);
return (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
+85 -81
View File
@@ -30,6 +30,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
import { LoginStatusBanner } from '../../features/server-status/ServerStatusBanner';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
@@ -125,89 +126,92 @@ export function AuthLayout() {
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
return (
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
justifyContent="SpaceBetween"
gap="400"
>
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
<Text as="h1" size="H3">
Lotus Chat
</Text>
<Box direction="Column" style={{ height: '100%' }}>
<LoginStatusBanner serverName={server} />
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
justifyContent="SpaceBetween"
gap="400"
>
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
<Text as="h1" size="H3">
Lotus Chat
</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Homeserver
</Text>
<ServerPicker
server={server}
serverList={clientConfig.homeserverList ?? []}
allowCustomServer={clientConfig.allowCustomHomeservers}
onServerChange={selectServer}
/>
</Box>
{discoveryState.status === AsyncStatus.Loading && (
<AuthLayoutLoading message="Looking for homeserver..." />
)}
{discoveryState.status === AsyncStatus.Error && (
<AuthLayoutError message="Failed to find homeserver." />
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
<AuthLayoutError
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
/>
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
)}
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
<AuthServerProvider value={discoveryState.data.serverName}>
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
<SpecVersionsLoader
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
fallback={() => (
<AuthLayoutLoading
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
/>
)}
error={() => (
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
)}
>
{(specVersions) => (
<SpecVersionsProvider value={specVersions}>
<AuthFlowsLoader
fallback={() => (
<AuthLayoutLoading message="Loading authentication flow..." />
)}
error={() => (
<AuthLayoutError message="Failed to get authentication flow information." />
)}
>
{(authFlows) => (
<AuthFlowsProvider value={authFlows}>
<Outlet />
</AuthFlowsProvider>
)}
</AuthFlowsLoader>
</SpecVersionsProvider>
)}
</SpecVersionsLoader>
</AutoDiscoveryInfoProvider>
</AuthServerProvider>
)}
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Homeserver
</Text>
<ServerPicker
server={server}
serverList={clientConfig.homeserverList ?? []}
allowCustomServer={clientConfig.allowCustomHomeservers}
onServerChange={selectServer}
/>
</Box>
{discoveryState.status === AsyncStatus.Loading && (
<AuthLayoutLoading message="Looking for homeserver..." />
)}
{discoveryState.status === AsyncStatus.Error && (
<AuthLayoutError message="Failed to find homeserver." />
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
<AuthLayoutError
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
/>
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
)}
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
<AuthServerProvider value={discoveryState.data.serverName}>
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
<SpecVersionsLoader
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
fallback={() => (
<AuthLayoutLoading
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
/>
)}
error={() => (
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
)}
>
{(specVersions) => (
<SpecVersionsProvider value={specVersions}>
<AuthFlowsLoader
fallback={() => (
<AuthLayoutLoading message="Loading authentication flow..." />
)}
error={() => (
<AuthLayoutError message="Failed to get authentication flow information." />
)}
>
{(authFlows) => (
<AuthFlowsProvider value={authFlows}>
<Outlet />
</AuthFlowsProvider>
)}
</AuthFlowsLoader>
</SpecVersionsProvider>
)}
</SpecVersionsLoader>
</AutoDiscoveryInfoProvider>
</AuthServerProvider>
)}
</Box>
<AuthFooter />
</Box>
<AuthFooter />
</Box>
</Scroll>
</Scroll>
</Box>
);
}
+82 -19
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';
@@ -31,6 +32,7 @@ import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { OutboxFeature } from '../../features/outbox/OutboxFeature';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import { ClockSkewMonitor } from '../../utils/clockSkew';
import { clockSkewAtom } from '../../state/clockSkew';
@@ -47,6 +49,7 @@ import {
getNotificationType,
getUnreadInfo,
isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix';
@@ -66,8 +69,14 @@ 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';
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
@@ -637,7 +646,8 @@ function MessageNotifications() {
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,
@@ -675,6 +685,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 ||
@@ -781,15 +792,20 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map via refs so the poll interval below is
// created once — not torn down and restarted (which resets its 30s countdown
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
// Read the latest reminders / DM map / notification prefs via refs so the poll
// interval below is created once — not torn down and restarted (which resets
// its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
const remindersRef = useRef(reminders);
remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => {
const check = () => {
@@ -801,7 +817,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;
@@ -813,6 +829,23 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
// [Gitea #338] A reminder is only done once the user has SEEN it: the
// toast stays until clicked or dismissed (it used to vanish after 4 s
// and the reminder was deleted at once, so one due while the user was
// away was lost). The removal is persisted on that click/dismiss; an
// unseen reminder comes back on the next start.
const done = () => {
if (removingRef.current.has(key)) return;
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
};
const open = () => {
done();
// window.location.hash setter auto-prepends '#' (as the toast does)
window.location.hash = hashPath;
};
setToast({
id: `reminder-${key}`,
displayName: 'Reminder',
@@ -820,16 +853,30 @@ function ReminderMonitor() {
roomName,
roomId: r.roomId,
hashPath,
sticky: true,
onClick: open,
onDismiss: done,
});
}
// Persist the removal, retrying on a later tick if it fails — without
// re-showing the toast (N114). The server echo drops it from
// `reminders` once the write lands.
if (!removingRef.current.has(key)) {
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
// Away from the window: also raise an OS notification (the reminder is
// an explicit request, but quiet hours / notifications-off win). Never
// put decrypted E2EE text in it (N106).
if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
const encrypted = !!room?.hasEncryptionStateEvent();
showOsNotification(
'Reminder',
{
icon: LogoSVG,
badge: LogoSVG,
body: encrypted ? `A message in ${roomName}` : body,
tag: `reminder-${key}`,
data: { path: hashPath, roomId: r.roomId },
},
() => {
window.focus();
open();
},
).catch(() => undefined);
}
}
});
};
@@ -870,6 +917,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()
@@ -912,7 +962,8 @@ const UPDATE_PROGRESS_TOAST = 'tauri-update-progress';
const UPDATE_FAILED_TOAST = 'tauri-update-failed';
function TauriUpdateFeature() {
const { isTauri, status, check, install } = useTauriUpdater();
const { isTauri, status, check, install, installKind } = useTauriUpdater();
const requestSettings = useSetAtom(settingsRequestAtom);
useTauriUpdateProgress();
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
@@ -971,18 +1022,24 @@ function TauriUpdateFeature() {
if (status.state !== 'available') return;
if (firedRef.current === status.version) return;
firedRef.current = status.version;
// A Linux package install can't be updated in place: point at the
// package-manager command in Settings instead of an install that fails.
const viaPackage = installKind !== 'in-app';
setToast({
id: `tauri-update-${status.version}`,
displayName: '⬆ Update Available',
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
body: viaPackage
? `Lotus Chat ${status.version} is available. Click for the command to update it with your package manager.`
: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
roomName: 'System',
roomId: '',
onClick: () => {
installFromToast();
if (viaPackage) requestSettings('general');
else installFromToast();
},
sticky: true,
});
}, [status, setToast, installFromToast]);
}, [status, setToast, installFromToast, installKind, requestSettings]);
// [cinny-desktop #6] Mirror a pending update into the tray ("Restart to
// update" + tooltip) so a dismissed toast isn't the only reminder. Kept while
@@ -1071,6 +1128,11 @@ 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(':');
if (senderServer !== mx.getDomain()) return;
monitor.sample(mEvent.getTs(), mEvent.getAge(), mEvent.localTimestamp);
};
mx.on(RoomEvent.Timeline, onTimeline);
@@ -1107,6 +1169,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TauriDesktopFeatures />
<CloseBehaviorPrompt />
<ConsentRequiredPrompt />
<OutboxFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
+9
View File
@@ -44,6 +44,11 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { ClockSkewBanner } from './ClockSkewBanner';
import {
ServerStatusBanner,
ServerStatusFeature,
} from '../../features/server-status/ServerStatusBanner';
import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
@@ -245,6 +250,8 @@ export function ClientRoot({ children }: ClientRootProps) {
return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && <ServerStatusFeature mx={mx} />}
{mx && !syncError && <ServerStatusBanner />}
{mx && !syncError && <SyncStatus mx={mx} />}
{mx && !syncError && <ClockSkewBanner />}
{loading && <ClientRootOptions mx={mx} />}
@@ -307,6 +314,8 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading />
) : (
<MatrixClientProvider value={mx}>
{/* [Gitea #110/#123] Needs the client context (its hooks read it). */}
{!syncError && <SecurityBanner />}
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
+3 -3
View File
@@ -19,7 +19,7 @@ const readDismissedUntil = (): number => {
};
/**
* [Gitea #158] "Your computer's clock is 14 minutes ahead of the server."
* [Gitea #158] "This device's clock is 14 minutes ahead of the server."
* Same slot and style as the sync banners. Shown while the skew monitor is
* over its threshold; the direction matters, so it is said. Dismissable for
* 24 h; never auto-corrects anything.
@@ -53,8 +53,8 @@ export function ClockSkewBanner() {
>
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
<Text size="L400" align="Center">
Your computer&apos;s clock is <b>{describeSkewVsServer(skewMs)}</b>. Encrypted messages
and voice calls will fail until it is fixed.
This device&apos;s clock is <b>{describeSkewVsServer(skewMs)}</b>. Voice calls and
encrypted messages can fail until it&apos;s corrected.
</Text>
<Button
size="300"
+47 -4
View File
@@ -1,8 +1,12 @@
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import React, { useCallback, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, config, Line, Text } from 'folds';
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;
@@ -13,6 +17,8 @@ type SyncStatusProps = {
mx: MatrixClient;
};
export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom);
const stalled = useAtomValue(homeserverStalledAtom);
const [stateData, setStateData] = useState<StateData>({
current: null,
previous: undefined,
@@ -30,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 ||
@@ -53,7 +88,7 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
if (stateData.current === SyncState.Reconnecting) {
if (stateData.current === SyncState.Reconnecting && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
@@ -64,14 +99,18 @@ export function SyncStatus({ mx }: SyncStatusProps) {
role="status"
aria-live="polite"
>
<Text size="L400">Connection Lost! Reconnecting...</Text>
<Text size="L400">
{serverUp
? "Connection lost. Our status checks say Lotus Chat's server is up, so it may be your internet connection. Reconnecting…"
: 'Connection Lost! Reconnecting...'}
</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if (stateData.current === SyncState.Error) {
if (stateData.current === SyncState.Error && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
@@ -82,7 +121,11 @@ export function SyncStatus({ mx }: SyncStatusProps) {
role="alert"
aria-live="assertive"
>
<Text size="L400">Connection Lost!</Text>
<Text size="L400">
{serverUp
? "Connection lost. Our status checks say Lotus Chat's server is up, so check your internet connection."
: 'Connection Lost!'}
</Text>
</Box>
<Line variant="Critical" size="300" />
</Box>
+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> {
+42 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveCallPageUrl } from './callPageUrl';
import { resolveCallPageUrl, resolveDesktopCallPageUrl } from './callPageUrl';
const URL_OK = 'https://call.chat.example.org/public/element-call/index.html';
@@ -38,3 +38,44 @@ test('anything else falls back to the bundled page', () => {
'data:text/html,x',
].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v)));
});
const APP = 'http://localhost:44548';
const PAGE = '/public/element-call/index.html';
test('desktop: the bundled page from the loopback origin on the same port', () => {
assert.equal(
resolveDesktopCallPageUrl('http://127.0.0.1:44548', APP, '/'),
`http://127.0.0.1:44548${PAGE}`,
);
assert.equal(
resolveDesktopCallPageUrl('http://127.0.0.1:44548/', APP, '/app/'),
`http://127.0.0.1:44548/app${PAGE}`,
);
});
test('desktop: unset or anything but same-port loopback http keeps the same-origin page', () => {
[
undefined,
'',
'http://127.0.0.1:44549',
'http://127.0.0.1',
'https://127.0.0.1:44548',
'http://localhost:44548',
'http://[::1]:44548',
'http://10.0.0.5:44548',
'https://call.chat.lotusguild.org',
'http://127.0.0.1:44548/evil/',
'http://127.0.0.1:44548/?x=1',
'http://user:pw@127.0.0.1:44548',
'not a url',
42,
].forEach((v) => assert.equal(resolveDesktopCallPageUrl(v, APP, '/'), undefined, String(v)));
});
test('desktop: only when the app itself runs on http://localhost (release builds)', () => {
const v = 'http://127.0.0.1:44548';
assert.equal(resolveDesktopCallPageUrl(v, 'tauri://localhost', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'http://tauri.localhost', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'https://chat.lotusguild.org', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'http://localhost', '/'), undefined);
});
+37 -3
View File
@@ -7,9 +7,9 @@
* app loads it from that origin instead, so the call frame can no longer
* reach this origin's storage (login token, crypto store) or service worker.
*
* Web only: the desktop app keeps its bundled copy (its CSP doesn't allow
* another frame origin, and a network copy could drift from the bundle).
* Anything that isn't an absolute https URL (http only on localhost, for
* Web only: the desktop app keeps its bundled copy (a network copy could
* drift from the bundle); see resolveDesktopCallPageUrl for how it isolates
* it. Anything that isn't an absolute https URL (http only on localhost, for
* development) is ignored, so a bad value falls
* back to the bundled page instead of breaking calls.
*/
@@ -28,6 +28,40 @@ export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | u
}
};
/**
* [Gitea #43] Desktop: the bundled call page from a second origin.
*
* The desktop app is served by its local server at http://localhost:<port>.
* The same server answers on http://127.0.0.1:<port>, which is a different
* origin (and still a secure context), so loading the bundled call page from
* there cuts the call frame off from the app's storage (login token, crypto
* store) without a network copy that could drift from the bundle.
*
* Only used when cinny-desktop sets `desktopCallOrigin` (it ships the server
* and CSP changes this needs in the same release), only for a loopback http
* origin on the SAME port as the app, and only when the app itself runs on
* http://localhost (release builds). Anything else keeps the same-origin page.
*/
export const resolveDesktopCallPageUrl = (
value: unknown,
appOrigin: string,
basePath: string,
): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
try {
const app = new URL(appOrigin);
const call = new URL(value);
if (app.protocol !== 'http:' || app.hostname !== 'localhost' || !app.port) return undefined;
if (call.protocol !== 'http:' || call.hostname !== '127.0.0.1') return undefined;
if (call.port !== app.port || call.username || call.password) return undefined;
if (call.pathname !== '/' || call.search || call.hash) return undefined;
const base = basePath.replace(/\/+$/, '');
return `${call.origin}${base}/public/element-call/index.html`;
} catch {
return undefined;
}
};
let callPageUrl: string | undefined;
export const setCallPageUrl = (url: string | undefined): void => {
+117
View File
@@ -0,0 +1,117 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { KeychainInvoke, sameTokens, syncKeychain, tokensOf } from './keychainMirror';
import type { Session } from './sessions';
const session: Session = {
baseUrl: 'https://matrix.example.org',
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_token',
refreshToken: 'mar_refresh',
};
/** An in-memory keychain behind the same commands the desktop app exposes. */
const fakeKeychain = (opts: { supported?: boolean; failSet?: boolean; corrupt?: boolean } = {}) => {
let stored: unknown = null;
const calls: string[] = [];
const invoke: KeychainInvoke = async (cmd, args) => {
calls.push(cmd);
switch (cmd) {
case 'secure_session_supported':
return opts.supported ?? true;
case 'secure_session_get':
return stored;
case 'secure_session_set':
if (opts.failSet) throw new Error('Access is denied.');
stored = opts.corrupt ? { ...(args?.tokens as object), accessToken: 'x' } : args?.tokens;
return null;
case 'secure_session_clear':
stored = null;
return null;
default:
throw new Error(`unknown ${cmd}`);
}
};
return { invoke, calls, get: () => stored };
};
test('stores the tokens (not the whole session) and verifies them', async () => {
const kc = fakeKeychain();
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
assert.deepEqual(kc.get(), {
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_token',
refreshToken: 'mar_refresh',
});
assert.deepEqual(kc.calls, [
'secure_session_supported',
'secure_session_get',
'secure_session_set',
'secure_session_get',
]);
});
test('an up-to-date copy is not rewritten', async () => {
const kc = fakeKeychain();
await syncKeychain(kc.invoke, session);
kc.calls.length = 0;
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
assert.deepEqual(kc.calls, ['secure_session_supported', 'secure_session_get']);
});
test('a token rotation rewrites; no session clears', async () => {
const kc = fakeKeychain();
await syncKeychain(kc.invoke, session);
await syncKeychain(kc.invoke, { ...session, accessToken: 'syt_new', refreshToken: undefined });
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
assert.equal('refreshToken' in (kc.get() as object), false);
assert.deepEqual(await syncKeychain(kc.invoke, null), { state: 'cleared' });
assert.equal(kc.get(), null);
});
test('unsupported platform: nothing is touched', async () => {
const kc = fakeKeychain({ supported: false });
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'unsupported' });
assert.deepEqual(kc.calls, ['secure_session_supported']);
});
test('failures become a status, never an exception', async () => {
assert.deepEqual(await syncKeychain(fakeKeychain({ failSet: true }).invoke, session), {
state: 'error',
error: 'Access is denied.',
});
assert.deepEqual(await syncKeychain(fakeKeychain({ corrupt: true }).invoke, session), {
state: 'error',
error: 'read-back did not match',
});
// The credential store hangs (the support check itself is instant).
const hung: KeychainInvoke = async (cmd) =>
cmd === 'secure_session_supported'
? true
: new Promise(() => {
/* never settles */
});
assert.deepEqual(await syncKeychain(hung, session, 50), {
state: 'error',
error: 'keychain timed out',
});
});
test('a desktop build without the commands reads as unsupported, not an error', async () => {
const missingCommand: KeychainInvoke = async (cmd) => {
throw new Error(`Command ${cmd} not found`);
};
assert.deepEqual(await syncKeychain(missingCommand, session), { state: 'unsupported' });
});
test('sameTokens compares only the secrets, treating a missing refresh token as absent', () => {
const t = tokensOf({ ...session, refreshToken: undefined });
assert.equal(sameTokens({ ...t }, t), true);
assert.equal(sameTokens({ ...t, refreshToken: undefined }, t), true);
assert.equal(sameTokens({ ...t, accessToken: 'other' }, t), false);
assert.equal(sameTokens({ ...t, refreshToken: 'r' }, t), false);
assert.equal(sameTokens(null, t), false);
assert.equal(sameTokens('string', t), false);
});
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
/**
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
*
* The session is still read from localStorage exactly as before; this only
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
* reading it back, so real installs prove the keychain works before step 2
* switches reads over to it. Nothing here can log anyone out: every failure
* just records a status for Settings.
*
* Writes are serialized (a token rotation right after login must not race
* the login's write) and time out, so a hung credential store can't pile up.
* When there's no session the keychain entry is cleared, which also covers a
* logout whose page reload beat the clear.
*/
export type KeychainTokens = {
userId: string;
deviceId: string;
accessToken: string;
refreshToken?: string;
};
export type KeychainMirrorStatus =
| { state: 'idle' }
| { state: 'unsupported' }
| { state: 'ok' }
| { state: 'cleared' }
| { state: 'error'; error: string };
export type KeychainInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
export const KEYCHAIN_TIMEOUT_MS = 5000;
export const tokensOf = (session: Session): KeychainTokens => ({
userId: session.userId,
deviceId: session.deviceId,
accessToken: session.accessToken,
...(session.refreshToken ? { refreshToken: session.refreshToken } : {}),
});
export const sameTokens = (a: unknown, b: KeychainTokens): boolean => {
if (!a || typeof a !== 'object') return false;
const t = a as Partial<KeychainTokens>;
return (
t.userId === b.userId &&
t.deviceId === b.deviceId &&
t.accessToken === b.accessToken &&
(t.refreshToken ?? undefined) === (b.refreshToken ?? undefined)
);
};
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
const errorText = (e: unknown): string =>
(e instanceof Error ? e.message : String(e)).slice(0, 200);
/**
* Bring the keychain in line with `session` (null = no session). Reads first
* and only writes when the stored copy differs, then verifies by reading back.
*/
export const syncKeychain = async (
invoke: KeychainInvoke,
session: Session | null,
timeoutMs = KEYCHAIN_TIMEOUT_MS,
): Promise<KeychainMirrorStatus> => {
// A desktop build without the commands (older than this feature) rejects
// the call: that is "not supported", not an error to show the user.
let supported: unknown;
try {
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
} catch {
supported = false;
}
if (supported !== true) return { state: 'unsupported' };
try {
if (!session) {
await withTimeout(invoke('secure_session_clear'), timeoutMs);
return { state: 'cleared' };
}
const tokens = tokensOf(session);
const stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
if (sameTokens(stored, tokens)) return { state: 'ok' };
await withTimeout(invoke('secure_session_set', { tokens }), timeoutMs);
const back = await withTimeout(invoke('secure_session_get'), timeoutMs);
if (!sameTokens(back, tokens)) return { state: 'error', error: 'read-back did not match' };
return { state: 'ok' };
} catch (e) {
return { state: 'error', error: errorText(e) };
}
};
let status: KeychainMirrorStatus = { state: 'idle' };
const statusListeners = new Set<(s: KeychainMirrorStatus) => void>();
const setStatus = (next: KeychainMirrorStatus): void => {
status = next;
statusListeners.forEach((cb) => cb(next));
};
export const getKeychainMirrorStatus = (): KeychainMirrorStatus => status;
let started = false;
/** Start mirroring (desktop only; call once at boot). */
export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void => {
if (!invoke || started) return;
started = true;
let chain: Promise<unknown> = Promise.resolve();
const enqueue = (session: Session | null) => {
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
};
enqueue(getFallbackSession() ?? null);
onSessionPersisted((session) => enqueue(session));
};
export const useKeychainMirrorStatus = (): KeychainMirrorStatus => {
const [value, setValue] = useState(status);
useEffect(() => {
setValue(status);
statusListeners.add(setValue);
return () => {
statusListeners.delete(setValue);
};
}, []);
return value;
};
+24 -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();
@@ -36,6 +36,7 @@ test('clearPlaintextCaches removes every plaintext/PII localStorage key', () =>
store.set('cinny_recent_forward_targets_v1', '[]');
store.set('cinny_recent_gifs_v1', '[]');
store.set('cinny_recent_stickers_v1', '[]');
store.set('lotus_outbox_v1', '{"userId":"@me:server","entries":[]}');
removed.length = 0;
clearPlaintextCaches();
@@ -47,6 +48,7 @@ test('clearPlaintextCaches removes every plaintext/PII localStorage key', () =>
'cinny_recent_forward_targets_v1',
'cinny_recent_gifs_v1',
'cinny_recent_stickers_v1',
'lotus_outbox_v1', // [Gitea #112] unsent message content
]) {
assert.ok(removed.includes(key), `${key} cleared`);
}
@@ -81,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');
});
+58 -1
View File
@@ -7,6 +7,8 @@ import { clearRecentStickers } from './recentStickers';
import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
import { clearCallSession } from '../utils/callRejoin';
import { clearOutbox } from '../utils/outbox';
import { NUDGE_KEY_PREFIX } from '../utils/securityNudge';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -44,6 +46,9 @@ const clearMsgDrafts = (): void => {
* - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII)
* - `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
@@ -75,7 +80,11 @@ const clearStatusMessage = (): void => {
return;
}
keys.forEach((key) => {
if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) {
if (
key.startsWith('lotus-status-msg-') ||
key.startsWith('lotus-status-expiry-') ||
key.startsWith(NUDGE_KEY_PREFIX)
) {
try {
localStorage.removeItem(key);
} catch {
@@ -85,7 +94,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();
@@ -93,7 +147,10 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
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))),
+9
View File
@@ -0,0 +1,9 @@
import { atom } from 'jotai';
/**
* [Gitea #112] True while the client can't reach the homeserver (sync is
* reconnecting / erroring, or the browser reports offline). Messages that
* failed for network reasons show "Queued" instead of "Failed to send" while
* this is set; the outbox sends them again once it clears.
*/
export const sendOfflineAtom = atom(false);
+18
View File
@@ -0,0 +1,18 @@
import { atom } from 'jotai';
import { ServerStatus } from '../utils/kumaStatus';
/**
* [Gitea #124] The homeserver's status from its Kuma status page (null: none
* configured, not fetched yet, or Kuma unreachable — all mean "no banner"),
* plus whether this client's own connection is currently lost.
*/
export const serverStatusAtom = atom<{
status: ServerStatus | null;
syncLost: boolean;
/** When this client's connection dropped (ms), null while connected. */
lostAt: number | null;
}>({
status: null,
syncLost: false,
lostAt: null,
});
+17
View File
@@ -432,3 +432,20 @@ test('subscribeSessionChanges ignores unrelated storage keys', () => {
listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
assert.equal(fired, false);
});
test('onSessionPersisted: told about writes and removals; a throwing listener is harmless', async () => {
installStorage();
const { onSessionPersisted } = await import('./sessions');
const seen: (string | null)[] = [];
const offBad = onSessionPersisted(() => {
throw new Error('boom');
});
const off = onSessionPersisted((s) => seen.push(s ? s.accessToken : null));
setFallbackSession('tok-a', 'DEV', '@a:hs', 'https://hs', { refreshToken: 'ref-a' });
removeFallbackSession();
off();
offBad();
setFallbackSession('tok-b', 'DEV', '@a:hs', 'https://hs');
assert.deepEqual(seen, ['tok-a', null]);
assert.equal(getFallbackSession()?.accessToken, 'tok-b', 'write still happened');
});
+23
View File
@@ -233,6 +233,27 @@ export type SessionStoreName = {
// crypto: 'crypto-store',
// } as const;
// [Gitea #105] Listeners told about every session write in THIS tab (login,
// token rotation) and removal (logout), e.g. the desktop keychain mirror.
// A throwing listener never breaks the write.
type PersistListener = (session: Session | null) => void;
const persistListeners = new Set<PersistListener>();
const notifyPersisted = (session: Session | null): void => {
persistListeners.forEach((cb) => {
try {
cb(session);
} catch {
/* listener errors must not affect login/logout */
}
});
};
export const onSessionPersisted = (cb: PersistListener): (() => void) => {
persistListeners.add(cb);
return () => {
persistListeners.delete(cb);
};
};
// Persist the session. Writes the atomic blob FIRST (so the consistent,
// never-torn copy is established before the multi-key legacy write), then
// dual-writes the legacy keys for rollback safety. Signature is unchanged —
@@ -249,6 +270,7 @@ export function setFallbackSession(
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
// Dual-write the legacy keys (removal of this half is a future release).
writeLegacyKeys(persisted);
notifyPersisted(sessionFromPersisted(persisted));
}
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
@@ -257,6 +279,7 @@ export const removeFallbackSession = () => {
localStorage.removeItem(SESSION_BLOB_KEY);
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
notifyPersisted(null);
};
// Read the session, preferring the atomic blob. If the blob is absent or
+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),
);
+76 -27
View File
@@ -8,44 +8,96 @@ import {
formatSkew,
} from './clockSkew';
// A live event received when the local clock is `skew` ms ahead of the server:
// origin_server_ts = T (server clock), age = a, localTimestamp = (T + a + skew) - a.
const feed = (m: ClockSkewMonitor, skew: number, age = 500, t = 1_700_000_000_000) =>
m.sample(t, age, t + skew);
const T = 1_700_000_000_000;
test('needs three samples, then reports the median with direction', () => {
/**
* A live event received `atSec` seconds into the test, when the local clock is
* `skew` ms off the server and the response took `delay` ms to arrive:
* localTimestamp − origin_server_ts = skew + delay.
*/
const feed = (m: ClockSkewMonitor, skew: number, atSec = 0, delay = 0, wallJump = 0) =>
m.sample(T, 500, T + skew + delay, { wall: T + atSec * 1000 + wallJump, mono: atSec * 1000 });
test('behind: reported as soon as there are three samples', () => {
const m = new ClockSkewMonitor();
assert.equal(feed(m, 60_000).skewMs, null);
assert.equal(feed(m, 61_000).skewMs, null);
const s = feed(m, 59_000);
assert.equal(s.skewMs, 60_000);
assert.equal(feed(m, -60_000, 0).skewMs, null);
assert.equal(feed(m, -61_000, 1).skewMs, null);
const s = feed(m, -59_000, 2);
assert.equal(s.skewMs, -61_000);
assert.equal(s.warning, true);
assert.equal(formatSkew(s.skewMs!), '60 seconds ahead');
assert.equal(formatSkew(s.skewMs!), '61 seconds behind');
});
test('one bad sample cannot trip the warning (median) and hysteresis clears only under 15 s', () => {
test('ahead: only once it has held for a minute', () => {
const m = new ClockSkewMonitor();
feed(m, 1000);
feed(m, 1500);
assert.equal(feed(m, 90_000).warning, false); // outlier
assert.equal(m.getState().skewMs, 1500);
feed(m, 60_000, 0);
feed(m, 60_000, 10);
assert.equal(feed(m, 60_000, 20).warning, false);
assert.equal(m.getState().skewMs, 60_000);
assert.equal(feed(m, 60_000, 59).warning, false);
assert.equal(feed(m, 60_000, 61).warning, true);
});
test('server stall (2026-09-29): a burst of late events does not read as a wrong clock', () => {
const m = new ClockSkewMonitor();
// Normal traffic, then the homeserver stalls and one /sync arrives 30 s late
// with a pile of events, then normal traffic again.
feed(m, 200, 0);
feed(m, 150, 5);
feed(m, 300, 10);
[1, 2, 3, 4, 5, 6].forEach(() => feed(m, 0, 130, 31_000));
assert.equal(m.getState().warning, false);
assert.ok(m.getState().skewMs! < 1000);
// Fresh client whose first samples are all from the late burst.
const fresh = new ClockSkewMonitor();
[1, 2, 3, 4, 5, 6].forEach(() => feed(fresh, 0, 0, 31_000));
assert.equal(fresh.getState().warning, false);
// …and the next timely event brings the estimate back down.
feed(fresh, 0, 70, 100);
assert.equal(fresh.getState().warning, false);
assert.equal(fresh.getState().skewMs, 100);
});
test('slow deliveries mixed with fast ones: the fastest one wins', () => {
const m = new ClockSkewMonitor();
[0, 20, 40, 60, 80].forEach((at, i) => feed(m, 45_000, at, i === 2 ? 0 : 20_000));
assert.equal(m.getState().skewMs, 45_000);
assert.equal(m.getState().warning, true);
});
test('hysteresis: once on, clears only under 15 s', () => {
const w = new ClockSkewMonitor();
[40_000, 41_000, 39_000, 40_000, 40_000].forEach((s) => feed(w, s));
[0, 1, 2].forEach((at) => feed(w, -40_000, at));
assert.equal(w.getState().warning, true);
// drifting down to 20 s: still >= 15 s → stays on
[20_000, 20_000, 20_000, 20_000, 20_000].forEach((s) => feed(w, s));
// Samples expire after 5 minutes; drifting to -20 s keeps it on (>= 15 s).
[400, 401, 402].forEach((at) => feed(w, -20_000, at));
assert.equal(w.getState().skewMs, -20_000);
assert.equal(w.getState().warning, true);
[10_000, 10_000, 10_000, 10_000, 10_000].forEach((s) => feed(w, s));
[800, 801, 802].forEach((at) => feed(w, -10_000, at));
assert.equal(w.getState().warning, false);
});
test('fixing the local clock starts the measurement afresh', () => {
const m = new ClockSkewMonitor();
[0, 1, 2].forEach((at) => feed(m, -14 * 60_000, at));
assert.equal(m.getState().warning, true);
// The user sets the clock forward 14 minutes: wall jumps vs the monotonic clock.
const jump = 14 * 60_000;
feed(m, 0, 10, 0, jump);
assert.equal(m.getState().warning, false);
assert.equal(m.getState().skewMs, null);
feed(m, 0, 11, 0, jump);
feed(m, 0, 12, 0, jump);
assert.equal(m.getState().skewMs, 0);
assert.equal(m.getState().warning, false);
});
test('stale or missing age is ignored (cache replay must not read as skew)', () => {
const m = new ClockSkewMonitor();
const t = 1_700_000_000_000;
m.sample(t, undefined, t + 3_600_000);
m.sample(t, 40 * 24 * 60 * 60 * 1000, t + 3_600_000);
m.sample(t, -5, t);
m.sample(T, undefined, T + 3_600_000);
m.sample(T, 40 * 24 * 60 * 60 * 1000, T + 3_600_000);
m.sample(T, -5, T);
assert.equal(m.getState().skewMs, null);
});
@@ -53,10 +105,7 @@ test('subscribe fires on change only; reset clears', () => {
const m = new ClockSkewMonitor();
const seen: (number | null)[] = [];
m.subscribe((s) => seen.push(s.skewMs));
feed(m, -120_000);
feed(m, -120_000);
feed(m, -120_000);
feed(m, -120_000);
[0, 1, 2, 3].forEach((at) => feed(m, -120_000, at));
assert.deepEqual(seen, [-120_000]);
assert.equal(formatSkew(-120_000), '2 minutes behind');
m.reset();
+56 -11
View File
@@ -22,8 +22,23 @@
export const SKEW_WARN_MS = 30_000;
export const SKEW_CLEAR_MS = 15_000;
export const SKEW_SAMPLES = 5;
export const SKEW_MIN_SAMPLES = 3;
/** Samples older than this are forgotten. */
export const SKEW_WINDOW_MS = 5 * 60 * 1000;
export const SKEW_MAX_SAMPLES = 30;
/**
* "Ahead" must hold across samples received at least this far apart.
*
* Incident 2026-09-29: the homeserver's host ran out of memory and stalled for
* ~2 minutes; the /sync that finally went out carried events whose `age` was
* computed ~30 s before it arrived, so every client read "your clock is 30 s
* ahead" — while the real problem was the server. A late delivery can only make
* the local clock look AHEAD (never behind), so the estimate is the LOWEST
* recent sample (the one delivered fastest), and "ahead" has to persist across
* a minute of fresh samples before it is reported. "Behind" can't come from a
* delay and is reported as soon as there are enough samples.
*/
export const SKEW_AHEAD_SPAN_MS = 60_000;
/**
* Sanity cap on `age`. Old events are still valid samples (the server computes
* `age` at response time, so `ts + age` is its clock regardless of the event's
@@ -38,14 +53,21 @@ export type ClockSkewState = {
warning: boolean;
};
const median = (xs: number[]): number => {
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
/** A wall-clock change larger than this (vs the monotonic clock) resets the samples. */
export const CLOCK_JUMP_MS = 5_000;
type Sample = { skew: number; at: number };
const currentClock = (): { wall: number; mono: number } => {
const wall = Date.now();
const mono = typeof performance !== 'undefined' ? performance.now() : wall;
return { wall, mono };
};
export class ClockSkewMonitor {
private samples: number[] = [];
private samples: Sample[] = [];
private clockOffset: number | undefined;
private state: ClockSkewState = { skewMs: null, warning: false };
@@ -64,25 +86,47 @@ export class ClockSkewMonitor {
/**
* Feed one live event. `originServerTs` + `age` come from the event;
* `localTimestamp` is the SDK's `Date.now() − age` at construction.
* `localTimestamp` is the SDK's `Date.now() − age` at construction; `clock`
* is when the sample was taken: wall clock and a monotonic clock
* (performance.now()), so samples are aged by real elapsed time and a change
* of the local clock (someone fixing it) starts the measurement afresh.
* Only feed events stamped by OUR homeserver: another server's
* `origin_server_ts` carries that server's clock.
* Returns the new state (unchanged object when nothing moved).
*/
public sample(
originServerTs: number,
age: number | undefined,
localTimestamp: number,
clock: { wall: number; mono: number } = currentClock(),
): ClockSkewState {
if (age === undefined || !Number.isFinite(age) || age < 0 || age > SKEW_MAX_AGE_MS) {
return this.state;
}
if (!Number.isFinite(originServerTs) || !Number.isFinite(localTimestamp)) return this.state;
this.samples.push(localTimestamp - originServerTs);
if (this.samples.length > SKEW_SAMPLES) this.samples.shift();
const now = clock.mono;
const offset = clock.wall - clock.mono;
if (this.clockOffset !== undefined && Math.abs(offset - this.clockOffset) > CLOCK_JUMP_MS) {
// The local clock was changed: earlier samples measured the old clock.
this.reset();
}
this.clockOffset = offset;
this.samples.push({ skew: localTimestamp - originServerTs, at: now });
this.samples = this.samples.filter((s) => now - s.at <= SKEW_WINDOW_MS);
if (this.samples.length > SKEW_MAX_SAMPLES) this.samples.shift();
if (this.samples.length < SKEW_MIN_SAMPLES) return this.state;
const skewMs = median(this.samples);
// Delivery delay only ever adds to a sample: the smallest is the truest.
const skewMs = Math.min(...this.samples.map((s) => s.skew));
const abs = Math.abs(skewMs);
const warning = this.state.warning ? abs >= SKEW_CLEAR_MS : abs > SKEW_WARN_MS;
let warning: boolean;
if (this.state.warning) warning = abs >= SKEW_CLEAR_MS;
else if (skewMs < -SKEW_WARN_MS) warning = true;
else if (skewMs > SKEW_WARN_MS) {
// Ahead: only if the fastest-delivered samples stayed high for a minute.
const span = now - Math.min(...this.samples.map((s) => s.at));
warning = span >= SKEW_AHEAD_SPAN_MS;
} else warning = false;
if (skewMs === this.state.skewMs && warning === this.state.warning) return this.state;
this.state = { skewMs, warning };
this.listeners.forEach((cb) => cb(this.state));
@@ -91,6 +135,7 @@ export class ClockSkewMonitor {
public reset(): void {
this.samples = [];
this.clockOffset = undefined;
if (this.state.skewMs !== null || this.state.warning) {
this.state = { skewMs: null, warning: false };
this.listeners.forEach((cb) => cb(this.state));
+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);
});

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