Compare commits

..
Author SHA1 Message Date
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
Lotus CIandClaude Opus 5.5 1ea6987083 fix(desktop): Lotus links use the public web app, and open in-app (#248)
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 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 9m19s
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.

- useLotusShareBase: in the desktop app, links for other people use config
  `webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
  form; otherwise the origin, as before. Used by "Copy Lotus Link" on
  messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
  desktop app.
- The web app is unchanged.

Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 19:39:48 -04:00
jared be8e49a2bb Merge pull request #247: readable poll card (#246)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 7m57s
2026-09-27 23:28:48 -04:00
Lotus CIandClaude Opus 5.5 1e217592fc fix(polls): readable poll card: counts, visible bars/indicators, no chip overflow (#246)
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 7m55s
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.

- Every row shows "N votes · P%". Multiple choice looked broken as
  "100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
  success for the winner, neutral otherwise. The old full-row fill was the
  same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
  from the theme's text colour. Primary.ContainerLine was nearly invisible,
  especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
  is now "Poll · Single choice | Pick up to N | Results hidden until the end |
  Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
  that never wrap. Previously the chip label broke onto two lines inside a
  one-line chip and spilled out of it. Reproduced before/after in Lotus
  Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 23:18:25 -04:00
jared ffe1d5a81e Merge pull request #245: desktop without WebRTC offers "Open in browser"
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 9m54s
2026-09-27 17:10:50 -04:00
Lotus CIandClaude Opus 5.5 fff583fca5 feat(call): desktop without WebRTC offers "Open in browser" instead of a dead end
CI / Build & Quality Checks (pull_request) Successful in 2m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m22s
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.

In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
  web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.

The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 16:54:29 -04:00
jared e2b23397bd Merge pull request #244: screenshare stays on the call bar in Firefox/Safari (#43)
CI / Build & Quality Checks (push) Successful in 3m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 11m34s
2026-09-27 13:54:11 -04:00
Lotus CIandClaude Opus 5.5 b929cbf74b chore(call): pin element-call-embedded 0.25.0-lotus.22 (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m14s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m39s
lotus.22 draws the "Share your screen?" prompt inside the call frame on
request and drops the corner button. Verified against the published
package: no-delegation (Firefox path) cross- and same-origin — bar button,
in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium
unchanged; picture-in-picture prompt fits and Share works; room policy
hides the button and refuses the prompt.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 12:48:12 -04:00
jared 773e41311e Merge pull request #243: optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (push) Successful in 4m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 14m40s
2026-09-27 02:24:47 -04:00
54 changed files with 3019 additions and 381 deletions
+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"
}
}
}
}
+15 -6
View File
@@ -257,12 +257,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);
+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);
});
});
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.21",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.21/element-call-embedded-0.25.0-lotus.21.tgz",
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==",
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+7 -8
View File
@@ -38,7 +38,7 @@ import {
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -82,6 +82,7 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
@@ -231,13 +232,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
</Text>
)}
{!webRTCSupported() && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
<CallsUnavailableMessage roomId={room.roomId} actionLabel="Answer in browser" />
)}
<Box direction="Column" gap="300">
{willAnswerWithCamera && (
@@ -882,6 +877,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate();
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
@@ -1363,6 +1359,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
zIndex: 1,
background: 'transparent',
cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
+170 -114
View File
@@ -261,52 +261,69 @@ export function PollContent({
handleVote(answers[idx].id);
};
const headerLabel = isEnded
? 'Poll closed · Final results'
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
// [Gitea #246] Type line under the "Poll" label.
const typeLabel = (() => {
if (isEnded) return 'Final results';
if (isUndisclosed) return 'Results hidden until the end';
return isMultiple ? `Pick up to ${maxSelections}` : 'Single choice';
})();
// Multiple choice counts people, not answers ("1 voter" who picked two).
const unit = isMultiple ? 'voter' : 'vote';
const footerNote = (() => {
const totalPart = total > 0 ? `${total} ${unit}${total === 1 ? '' : 's'}` : `No ${unit}s yet`;
if (isUndisclosed && !isEnded) {
return canVote
? 'Voting open · Results hidden until the poll ends'
: 'Results hidden until the poll ends';
? `${myVotes.size > 0 ? 'You voted' : 'Voting open'} · results shown when the poll ends`
: 'Results shown when the poll ends';
}
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
if (isEnded) return `${votesPart} · Poll closed`;
if (!canVote) return votesPart;
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
if (isEnded) return `${totalPart} · Poll closed`;
if (!canVote) return totalPart;
return `${totalPart} · ${myVotes.size > 0 ? 'Select another answer to change your vote' : 'Select an answer to vote'}`;
})();
// Neutral tones derived from the theme's text colour, so they have contrast
// in every theme (the Container/ContainerLine tokens were near-invisible).
const muted = (pct: number) =>
`color-mix(in srgb, ${color.SurfaceVariant.OnContainer} ${pct}%, transparent)`;
return (
<Box
data-poll-content
direction="Column"
gap="200"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
gap="300"
style={{
// Same width for every poll (the message body shrink-wraps its content).
width: toRem(460),
maxWidth: '100%',
margin: `${config.space.S100} 0`,
padding: config.space.S300,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${muted(18)}`,
background: muted(4),
boxSizing: 'border-box',
}}
>
<Text
as="div"
size="T200"
priority="300"
data-poll-content-label
style={{
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
marginBottom: config.space.S100,
}}
>
<span aria-hidden>◉ </span>
{headerLabel}
</Text>
<Text size="T400" style={{ fontWeight: 600 }}>
{question}
</Text>
<Box direction="Column" gap="100">
<Box alignItems="Center" gap="200" wrap="Wrap" data-poll-content-label>
<Icon size="100" src={Icons.OrderList} style={{ opacity: 0.8 }} />
<Text as="span" size="L400" style={{ fontWeight: 600 }}>
Poll
</Text>
<Text as="span" size="T200" priority="300">
·
</Text>
<Text as="span" size="T200" priority="300">
{typeLabel}
</Text>
</Box>
<Text size="T400" style={{ fontWeight: 600, overflowWrap: 'anywhere' }}>
{question}
</Text>
</Box>
<Box
direction="Column"
gap="100"
style={{ marginTop: '2px' }}
gap="200"
role={isMultiple ? 'group' : 'radiogroup'}
aria-label={question}
onKeyDown={handleRadioKeyDown}
@@ -316,20 +333,32 @@ export function PollContent({
const text = answer.text;
const selected = myVotes.has(id);
const voteCount = counts.get(id) ?? 0;
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
const showBar = showResults && total > 0;
const pct = showBar ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id);
const resultText = `${voteCount} ${voteCount === 1 ? 'vote' : 'votes'} · ${pct}%`;
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
let barColor = muted(40);
if (isWinner) barColor = color.Success.Main;
else if (selected) barColor = color.Primary.Main;
let rowBorder = muted(22);
if (isWinner) rowBorder = color.Success.Main;
else if (selected) rowBorder = color.Primary.Main;
const labelParts = [text];
if (showBar) labelParts.push(resultText);
if (isWinner) labelParts.push('winning answer');
return (
<React.Fragment key={id}>
<button
type="button"
data-poll-answer
data-selected={selected}
data-winner={isWinner || undefined}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined}
aria-label={labelParts.join(', ')}
aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}`
@@ -340,60 +369,36 @@ export function PollContent({
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
background: selected ? muted(8) : 'transparent',
border: `${config.borderWidth.B300} solid ${rowBorder}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
font: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
gap: config.space.S200,
width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s',
}}
>
{showResults && total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
<span
style={{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
position: 'relative',
width: '100%',
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: toRem(14),
height: toRem(14),
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.Primary.ContainerLine
}`,
boxSizing: 'border-box',
width: toRem(18),
height: toRem(18),
border: `2px solid ${selected ? color.Primary.Main : muted(55)}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
@@ -405,29 +410,72 @@ export function PollContent({
>
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
<Text
as="span"
size="T300"
style={{ flexGrow: 1, minWidth: 0, overflowWrap: 'anywhere' }}
>
{text}
</Text>
{isWinner && (
<Icon
size="50"
src={Icons.Check}
style={{ flexShrink: 0, color: color.Success.Main }}
/>
<Text
as="span"
size="T200"
style={{
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
color: color.Success.Main,
fontWeight: 600,
}}
>
<Icon size="50" src={Icons.Star} filled />
Winner
</Text>
)}
{showResults && total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
{showBar && (
<Text
as="span"
size="T200"
priority="300"
style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}
>
{resultText}
</Text>
)}
</span>
{showBar && (
<span
aria-hidden
style={{
display: 'block',
width: '100%',
height: toRem(6),
borderRadius: config.radii.Pill,
background: muted(12),
overflow: 'hidden',
}}
>
<span
style={{
display: 'block',
height: '100%',
width: `${pct}%`,
borderRadius: config.radii.Pill,
background: barColor,
transition: 'width 0.3s ease',
}}
/>
</span>
)}
</button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text
id={`poll-voters-${eventId}-${id}`}
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
style={{ padding: `0 ${config.space.S300}`, marginTop: `-${config.space.S100}` }}
>
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text>
@@ -436,48 +484,56 @@ export function PollContent({
);
})}
</Box>
{canShowVoters && (
<Box>
<Chip
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
</Chip>
</Box>
)}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
<i>{footerNote}</i>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200" wrap="Wrap">
<Text size="T200" priority="300" style={{ minWidth: 0, flex: '1 1 12rem' }}>
{footerNote}
</Text>
{canEnd &&
(confirmEnd ? (
<Box gap="100" shrink="No" alignItems="Center">
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200">Cancel</Text>
</Chip>
</Box>
) : (
<Box gap="100" shrink="No" alignItems="Center" wrap="Wrap">
{canShowVoters && (
<Chip
variant="SurfaceVariant"
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200">End poll</Text>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{showVoters ? 'Hide voters' : 'Who voted'}
</Text>
</Chip>
))}
)}
{canEnd &&
(confirmEnd ? (
<>
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{ending ? 'Ending…' : 'End poll'}
</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
Cancel
</Text>
</Chip>
</>
) : (
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
End poll
</Text>
</Chip>
))}
</Box>
</Box>
</Box>
);
+11 -7
View File
@@ -7,7 +7,7 @@ import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useFrameScreenshare,
useScreenshareMode,
} from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
@@ -197,10 +197,16 @@ export function CallControl({
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
const frameScreenshare = useFrameScreenshare(callEmbed.control);
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
// where the click can't be delegated, starting asks for the in-frame prompt.
const screenshareMode = useScreenshareMode(callEmbed.control);
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
const [shareConfirm, setShareConfirm] = useState(false);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
@@ -255,9 +261,7 @@ export function CallControl({
{!compact && showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
disabled={!callJoined}
/>
)}
+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>
</>
)}
+11 -8
View File
@@ -33,7 +33,7 @@ import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useFrameScreenshare,
useScreenshareMode,
} from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
// is hidden (this engine can't start a share from the host); the
// screenshare-audio mute stays.
const frameScreenshare = useFrameScreenshare(callEmbed.control);
// is hidden (the screenshare-audio mute stays); where the click can't be
// delegated, starting asks for the fork's in-frame "Share your screen?".
const screenshareMode = useScreenshareMode(callEmbed.control);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
{!frameScreenshare && (
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
+5 -9
View File
@@ -23,6 +23,7 @@ import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { CallsUnavailableMessage } from './CallsUnavailable';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
@@ -33,25 +34,19 @@ function LivekitServerMissingMessage() {
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
function JoinMessage({
roomId,
hasParticipant,
livekitSupported,
rtcSupported,
}: {
roomId: string;
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <WebRTCMissingError />;
return <CallsUnavailableMessage roomId={roomId} />;
}
if (livekitSupported === false) {
@@ -144,6 +139,7 @@ function CallPrescreen() {
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
roomId={room.roomId}
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
@@ -0,0 +1,76 @@
import React, { useMemo } from 'react';
import { Box, Button, Icon, Icons, Text, color } from 'folds';
import { useClientConfig } from '../../hooks/useClientConfig';
import { isTauri } from '../../hooks/useTauri';
import { webRTCSupported } from '../../utils/rtc';
import { getCallInBrowserUrl, resolveWebAppUrl } from '../../utils/callInBrowser';
/**
* [cinny-desktop: Linux calls] The desktop app on Linux runs on WebKitGTK,
* which ships without WebRTC (disabled in 2.54; a libwebrtc backend is due
* around 2.56). Where calls can't work here, send the user to the room in the
* web app instead of a dead end. Only in the desktop app, and only when
* config.json sets `webAppUrl`.
*/
export const useCallInBrowserUrl = (roomId: string): string | undefined => {
const { webAppUrl } = useClientConfig();
return useMemo(
() =>
isTauri() && !webRTCSupported()
? getCallInBrowserUrl(resolveWebAppUrl(webAppUrl), roomId)
: undefined,
[webAppUrl, roomId],
);
};
/** Opens through the desktop's new-window handler → the system browser. */
export const openCallInBrowser = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer');
};
const desktopUnavailableText = (): string =>
typeof navigator !== 'undefined' && /Linux/i.test(navigator.userAgent)
? 'Calls aren’t available in the desktop app on Linux yet: its web engine has no WebRTC.'
: 'Calls aren’t available in this desktop app: its web engine has no WebRTC.';
type CallsUnavailableMessageProps = {
roomId: string;
/** Button label, e.g. "Answer in browser" for an incoming call. */
actionLabel?: string;
};
export function CallsUnavailableMessage({
roomId,
actionLabel = 'Open in browser',
}: CallsUnavailableMessageProps) {
const url = useCallInBrowserUrl(roomId);
if (!isTauri()) {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
return (
<Box direction="Column" alignItems="Center" gap="200" style={{ margin: 'auto' }}>
<Text style={{ color: color.Warning.Main }} size="L400" align="Center">
{desktopUnavailableText()}
{url ? ' You can join from the web app in your browser.' : ''}
</Text>
{url && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="100" src={Icons.External} />}
onClick={() => openCallInBrowser(url)}
>
<Text size="B300">{actionLabel}</Text>
</Button>
)}
</Box>
);
}
@@ -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.
+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;
}
+39 -2
View File
@@ -80,6 +80,7 @@ import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
@@ -465,6 +466,36 @@ function CallRulesChip({ room }: { room: Room }) {
);
}
/**
* [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't
* make calls (desktop on WebKitGTK): opens the room in the web app instead.
*/
function CallInBrowserButton({ url }: { url: string }) {
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Call in your browser (this desktop app can’t make calls here)</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
aria-label="Start call in browser"
onClick={() => openCallInBrowser(url)}
>
<Icon size="400" src={Icons.VideoCamera} />
</IconButton>
)}
</TooltipProvider>
);
}
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
@@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const callInBrowserUrl = useCallInBrowserUrl(room.roomId);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
(rtcSupported || callInBrowserUrl) &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
(rtcSupported ? (
<CallButton />
) : (
callInBrowserUrl && <CallInBrowserButton url={callInBrowserUrl} />
))}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+67 -40
View File
@@ -26,6 +26,7 @@ import {
config,
} from 'folds';
import React, {
CSSProperties,
FormEventHandler,
MouseEventHandler,
ReactNode,
@@ -38,7 +39,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
@@ -83,8 +84,6 @@ import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
import { getOriginBaseUrl } from '../../../pages/pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
@@ -98,28 +97,41 @@ import { useLongPress } from '../../../hooks/useLongPress';
import { ActionSheet } from '../../../components/action-sheet';
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);
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;
@@ -130,27 +142,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>
);
}
@@ -460,18 +494,11 @@ export const MessageCopyLotusLinkItem = as<
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const { hashRouter } = useClientConfig();
const lotusBase = useLotusShareBase();
const handleCopy = () => {
const eventId = mEvent.getId();
if (!eventId) return;
copyToClipboard(
getLotusRoomPermalink(
getOriginBaseUrl(hashRouter),
room.roomId,
eventId,
getViaServers(room),
),
);
copyToClipboard(getLotusRoomPermalink(lotusBase, room.roomId, eventId, getViaServers(room)));
onClose?.();
};
@@ -1110,7 +1137,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>
);
@@ -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>
);
};
@@ -0,0 +1,168 @@
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';
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: StatusBanner): 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 function StatusStrip({
banner,
onDismiss,
}: {
banner: StatusBanner;
onDismiss?: () => void;
}) {
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={bannerIcon(banner)} />
<Text size="L400" truncate={mobile && !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>
)}
{banner.dismissable && onDismiss && (
<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();
});
}, []),
);
const syncLost = lostAt !== null;
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';
}
@@ -114,6 +114,7 @@ import { SequenceCardStyle } from '../styles.css';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
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 +239,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 +592,7 @@ function Appearance() {
<DesktopChromeSetting />
<AutostartSetting />
<KeychainMirrorSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
+22
View File
@@ -25,6 +25,28 @@ export type ClientConfig = {
* Unset: the bundled copy on this origin. Ignored in the desktop app.
*/
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.
*/
webAppUrl?: string;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lotusLinkBases, lotusShareBase } from './useLotusLinkBase';
const LOCAL = 'http://localhost:44548/#/';
const WEB = 'https://chat.lotusguild.org';
test('web: shared links use the page origin', () => {
assert.equal(lotusShareBase(WEB, false, WEB), WEB);
assert.deepEqual(lotusLinkBases(WEB, false, undefined), [WEB]);
});
test('desktop with webAppUrl: shared links use the public web app', () => {
assert.equal(lotusShareBase(LOCAL, true, `${WEB}/`), WEB);
assert.deepEqual(lotusLinkBases(LOCAL, true, WEB), [LOCAL, WEB]);
});
test('desktop without a valid webAppUrl falls back to the origin', () => {
[undefined, '', 'http://chat.lotusguild.org', 'nonsense'].forEach((v) => {
assert.equal(lotusShareBase(LOCAL, true, v), LOCAL, String(v));
assert.deepEqual(lotusLinkBases(LOCAL, true, v), [LOCAL]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { useClientConfig } from './useClientConfig';
import { isTauri } from './useTauri';
import { getOriginBaseUrl } from '../pages/pathUtils';
import { resolveWebAppUrl } from '../utils/callInBrowser';
/**
* [Gitea #248] Base URL for Lotus links meant for other people.
*
* On the web that's this page's origin. In the desktop app the page is served
* from a local address (and hash-routed), which nobody else can open, so use
* the public web app from config `webAppUrl` (web path routing) instead.
* Without a valid `webAppUrl` it falls back to the origin, as before.
*/
export const lotusShareBase = (originBase: string, desktop: boolean, webAppUrl: unknown): string =>
(desktop && resolveWebAppUrl(webAppUrl)) || originBase;
/**
* Every base a Lotus link to this deployment may start with: the origin, plus
* the public web app in the desktop app, so a shared https link opens in-app.
*/
export const lotusLinkBases = (
originBase: string,
desktop: boolean,
webAppUrl: unknown,
): string[] => {
const shared = lotusShareBase(originBase, desktop, webAppUrl);
return shared === originBase ? [originBase] : [originBase, shared];
};
export const useLotusShareBase = (): string => {
const { hashRouter, webAppUrl } = useClientConfig();
return useMemo(
() => lotusShareBase(getOriginBaseUrl(hashRouter), isTauri(), webAppUrl),
[hashRouter, webAppUrl],
);
};
+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;
};
+14 -2
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
@@ -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>
);
}
+11 -3
View File
@@ -31,6 +31,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';
@@ -77,6 +78,7 @@ import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../feat
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { lotusLinkBases } from '../../hooks/useLotusLinkBase';
import {
getThreadNotificationMode,
shouldNotifyThreadReply,
@@ -118,11 +120,12 @@ function SystemEmojiFeature() {
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function LotusPermalinkFeature() {
const { hashRouter } = useClientConfig();
const { hashRouter, webAppUrl } = useClientConfig();
useEffect(() => {
setLotusPermalinkBase(getOriginBaseUrl(hashRouter));
// [Gitea #248] In the desktop app, links to the public web app count too.
setLotusPermalinkBase(lotusLinkBases(getOriginBaseUrl(hashRouter), isTauriApp(), webAppUrl));
return () => setLotusPermalinkBase(undefined);
}, [hashRouter]);
}, [hashRouter, webAppUrl]);
return null;
}
@@ -1069,6 +1072,10 @@ function ClockSkewFeature() {
data,
) => {
if (!data.liveEvent) return;
// 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);
@@ -1105,6 +1112,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TauriDesktopFeatures />
<CloseBehaviorPrompt />
<ConsentRequiredPrompt />
<OutboxFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
+6
View File
@@ -44,6 +44,10 @@ 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 { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
@@ -245,6 +249,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} />}
+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"
+23 -4
View File
@@ -1,8 +1,11 @@
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';
type StateData = {
current: SyncState | null;
@@ -13,6 +16,7 @@ type SyncStatusProps = {
mx: MatrixClient;
};
export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom);
const [stateData, setStateData] = useState<StateData>({
current: null,
previous: undefined,
@@ -53,7 +57,14 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
if (stateData.current === SyncState.Reconnecting) {
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
if (stateData.current === SyncState.Reconnecting && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
@@ -64,14 +75,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 +97,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>
+4 -11
View File
@@ -49,13 +49,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall';
import { LiveDot } from './SpaceTabs.css';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import {
SidebarAvatar,
SidebarItem,
@@ -102,6 +96,7 @@ import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = {
@@ -112,7 +107,7 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const lotusBase = useLotusShareBase();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
@@ -152,9 +147,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
requestClose();
};
+4 -11
View File
@@ -39,13 +39,7 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpaceRoomPath,
getSpaceSearchPath,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
@@ -92,6 +86,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
import { useCallEmbed } from '../../../hooks/useCallEmbed';
type SpaceMenuProps = {
@@ -100,7 +95,7 @@ type SpaceMenuProps = {
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const lotusBase = useLotusShareBase();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
@@ -137,9 +132,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
requestClose();
};
+57 -8
View File
@@ -261,6 +261,15 @@ export class CallControl extends EventEmitter implements CallControlState {
private frameScreenshareListeners = new Set<() => void>();
// [Gitea #43] The fork draws the "Share your screen?" prompt inside the frame
// on request (io.lotus.prompt_screenshare), so where the click can't be
// delegated our call-bar button asks for that instead of starting the share.
private _screensharePrompt = false;
// [Gitea #43] The fork's in-frame prompt is showing: anything the host lays
// over the frame (the picture-in-picture overlay) must let clicks through.
private _screensharePromptOpen = false;
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
// hides its in-frame button when it doesn't.
private frameScreenshareAllowed = true;
@@ -274,7 +283,32 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._frameScreenshare;
}
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
/**
* Starting a share must go through the fork's in-frame prompt: this engine
* can't delegate the click (Firefox, Safari, WebKitGTK) and the fork has
* the prompt. Stopping never needs it.
*/
public get screenshareNeedsPrompt(): boolean {
return this._screensharePrompt && !canDelegateCapability();
}
/** The fork's in-frame "Share your screen?" prompt is showing. */
public get screensharePromptOpen(): boolean {
return this._screensharePromptOpen;
}
/**
* Ask the fork to show "Share your screen?" inside the call frame; its Share
* button starts the share with the frame's own click.
*/
public promptScreenshare(): void {
this.sendForkAction('io.lotus.prompt_screenshare', {});
}
/**
* Subscribe to `frameScreenshare` / `screenshareNeedsPrompt` /
* `screensharePromptOpen` changes. Returns an unsubscribe.
*/
public onFrameScreenshareChange(cb: () => void): () => void {
this.frameScreenshareListeners.add(cb);
return () => {
@@ -289,7 +323,8 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private sendFrameScreenshareAllowed(): void {
if (!this.joined || !this.forkReady || !this._frameScreenshare) return;
if (!this.joined || !this.forkReady) return;
if (!this._frameScreenshare && !this._screensharePrompt) return;
this.sendForkAction('io.lotus.set_frame_screenshare', {
visible: this.frameScreenshareAllowed,
});
@@ -324,17 +359,31 @@ export class CallControl extends EventEmitter implements CallControlState {
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return;
const { screensharing, layout, frameScreenshare } = data as {
screensharing?: unknown;
layout?: unknown;
frameScreenshare?: unknown;
};
const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } =
data as {
screensharing?: unknown;
layout?: unknown;
frameScreenshare?: unknown;
screensharePrompt?: unknown;
screensharePromptOpen?: unknown;
};
const firstReport = !this.forkReady;
this.forkReady = true;
const frame = frameScreenshare === true;
if (frame !== this._frameScreenshare) {
const prompt = screensharePrompt === true;
const promptOpen = screensharePromptOpen === true;
if (promptOpen !== this._screensharePromptOpen) {
this._screensharePromptOpen = promptOpen;
if (frame === this._frameScreenshare && prompt === this._screensharePrompt) {
this.frameScreenshareListeners.forEach((l) => l());
}
}
if (frame !== this._frameScreenshare || prompt !== this._screensharePrompt) {
const promptTurnedOn = prompt && !this._screensharePrompt;
this._frameScreenshare = frame;
this._screensharePrompt = prompt;
this.frameScreenshareListeners.forEach((l) => l());
if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed();
}
if (firstReport) {
this.sendHotkeyCodes();
+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 => {
+34 -7
View File
@@ -75,19 +75,46 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
};
/**
* [Gitea #43] True when EC's own screenshare button is shown inside the call
* frame (no Capability Delegation in this engine), so the host bar hides its.
* [Gitea #43] How the call bar's screenshare button behaves:
* - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides;
* - `prompt`: starting asks the fork for its in-frame "Share your screen?"
* (no Capability Delegation in this engine);
* - `direct`: our own confirm, then a (delegated) set_screenshare.
*/
export const useFrameScreenshare = (control: CallControl | undefined): boolean => {
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
const screenshareMode = (control: CallControl): ScreenshareMode => {
if (control.frameScreenshare) return 'hidden';
return control.screenshareNeedsPrompt ? 'prompt' : 'direct';
};
export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => {
const [mode, setMode] = useState<ScreenshareMode>(() =>
control ? screenshareMode(control) : 'direct',
);
useEffect(() => {
if (!control) {
setFrame(false);
setMode('direct');
return undefined;
}
const sync = () => setFrame(control.frameScreenshare);
const sync = () => setMode(screenshareMode(control));
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return frame;
return mode;
};
/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */
export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => {
const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false);
useEffect(() => {
if (!control) {
setOpen(false);
return undefined;
}
const sync = () => setOpen(control.screensharePromptOpen);
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return open;
};
+14 -6
View File
@@ -122,11 +122,15 @@ const cleanHref = (href: string): string =>
stripTrackingOnRender ? stripTrackingParams(href) : href;
// [Gitea #130] Links to THIS deployment's room routes render and click like
// matrix.to links. The base is set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks.
let lotusPermalinkBase: string | undefined;
export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
lotusPermalinkBase = baseUrl;
// matrix.to links. The bases are set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks. [Gitea
// #248] Several: the desktop app knows both its local origin and the public web
// app (`webAppUrl`), and people share the public one.
let lotusPermalinkBases: string[] = [];
export const setLotusPermalinkBase = (baseUrls: string | string[] | undefined): void => {
lotusPermalinkBases = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).filter(
(b): b is string => !!b,
);
};
/**
* The matrix.to form of `href` when it is a matrix.to link or a Lotus
@@ -134,7 +138,11 @@ export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
*/
export const toMatrixToHref = (href: string): string | undefined => {
if (testMatrixTo(href)) return href;
return lotusPermalinkBase ? lotusPermalinkToMatrixTo(lotusPermalinkBase, href) : undefined;
for (const base of lotusPermalinkBases) {
const matrixTo = lotusPermalinkToMatrixTo(base, href);
if (matrixTo) return matrixTo;
}
return undefined;
};
export const LINKIFY_OPTS: LinkifyOpts = {
+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;
};
+2
View File
@@ -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`);
}
+3
View File
@@ -7,6 +7,7 @@ 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';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -44,6 +45,7 @@ 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])
* - `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
@@ -93,6 +95,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
clearOutbox();
clearCallSession();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId);
+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
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getCallInBrowserUrl, resolveWebAppUrl } from './callInBrowser';
test('resolveWebAppUrl keeps an https origin (+ path) without a trailing slash', () => {
assert.equal(resolveWebAppUrl('https://chat.example.org'), 'https://chat.example.org');
assert.equal(resolveWebAppUrl('https://chat.example.org/'), 'https://chat.example.org');
assert.equal(resolveWebAppUrl('https://example.org/chat/'), 'https://example.org/chat');
assert.equal(resolveWebAppUrl('https://chat.example.org/?x=1#y'), 'https://chat.example.org');
});
test('resolveWebAppUrl rejects anything but absolute https', () => {
[
undefined,
null,
1,
'',
' ',
'/home',
'http://chat.example.org',
'not a url',
['javascript', 'alert(1)'].join(':'),
'data:text/html,x',
].forEach((v) => assert.equal(resolveWebAppUrl(v), undefined, String(v)));
});
test('getCallInBrowserUrl points at the room in the web app (no auto-join)', () => {
const url = getCallInBrowserUrl('https://chat.example.org', '!room:example.org');
assert.equal(url, 'https://chat.example.org/home/!room%3Aexample.org');
assert.ok(!/join|call=/i.test(url ?? ''));
assert.equal(getCallInBrowserUrl(undefined, '!room:example.org'), undefined);
});
+27
View File
@@ -0,0 +1,27 @@
import { getLotusRoomPermalink } from '../plugins/lotus-permalink';
/**
* The public web app's address from config.json `webAppUrl` (the desktop app
* sets it; its own origin is a local server). Absolute https only; anything
* else is ignored.
*/
export const resolveWebAppUrl = (value: unknown): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
try {
const url = new URL(value);
if (url.protocol !== 'https:') return undefined;
return url.origin + url.pathname.replace(/\/+$/, '');
} catch {
return undefined;
}
};
/**
* Where to send the user to take a call this app can't make: the room in the
* web app (they press Join there themselves — never an auto-join link, which
* a crafted URL could abuse to open someone's mic).
*/
export const getCallInBrowserUrl = (
webAppUrl: string | undefined,
roomId: string,
): string | undefined => (webAppUrl ? getLotusRoomPermalink(webAppUrl, roomId) : undefined);
+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));
+262
View File
@@ -0,0 +1,262 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_GROUPS,
kumaBeatTime,
kumaUrls,
parseKumaStatus,
pickBanner,
resolveStatusPage,
serverConfirmedUpSince,
ServerStatus,
} from './kumaStatus';
// The live `matrix` status page's groups (captured 2026-09-29).
const PAGE = {
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 30, name: 'Synapse HTTP' }] },
{
name: 'Voice calls',
monitorList: [
{ id: 37, name: 'Matrix: LiveKit (public /rtc)' },
{ id: 38, name: 'Matrix: call tokens (public /sfu/get)' },
{ id: 39, name: 'Matrix: call page (call.chat)' },
],
},
{ name: 'Login', monitorList: [{ id: 15, name: 'Authentication Server' }] },
],
maintenanceList: [],
incidents: [],
};
const UP = 1;
const DOWN = 0;
const PENDING = 2;
/** Kuma's beat format: "YYYY-MM-DD HH:MM:SS.mmm" in UTC, oldest first. */
const beats = (...statuses: number[]) =>
statuses.map((status, i) => ({
status,
time: `2026-09-29 14:${String(10 + i).padStart(2, '0')}:00.000`,
msg: '',
}));
const hb = (over: Record<number, number[]> = {}) => ({
heartbeatList: {
30: beats(...(over[30] ?? [UP, UP, UP])),
37: beats(...(over[37] ?? [UP, UP])),
38: beats(...(over[38] ?? [UP, UP])),
39: beats(...(over[39] ?? [UP, UP])),
15: beats(...(over[15] ?? [UP, UP])),
},
uptimeList: {},
});
const parse = (page: unknown, heartbeat: unknown) =>
parseKumaStatus(page, heartbeat, DEFAULT_GROUPS);
test('config: only a valid page for the listed homeserver', () => {
const cfg = {
'matrix.lotusguild.org': { url: 'https://isitup.lotusguild.org/', slug: 'matrix' },
};
const page = resolveStatusPage(cfg, 'matrix.lotusguild.org');
assert.deepEqual(page, {
url: 'https://isitup.lotusguild.org',
slug: 'matrix',
groups: DEFAULT_GROUPS,
});
assert.deepEqual(kumaUrls(page!), {
page: 'https://isitup.lotusguild.org/api/status-page/matrix',
heartbeat: 'https://isitup.lotusguild.org/api/status-page/heartbeat/matrix',
});
assert.equal(resolveStatusPage(cfg, 'matrix.org'), undefined, 'other homeservers: nothing');
assert.equal(resolveStatusPage(cfg, undefined), undefined);
assert.equal(resolveStatusPage(undefined, 'matrix.lotusguild.org'), undefined);
for (const bad of [
{ url: 'http://isitup.lotusguild.org', slug: 'matrix' },
{ url: ['javascript', 'alert(1)'].join(':'), slug: 'matrix' },
{ url: 'https://isitup.lotusguild.org', slug: '../admin' },
{ url: 'https://isitup.lotusguild.org', slug: '' },
{ url: 'not a url', slug: 'matrix' },
]) {
assert.equal(resolveStatusPage({ hs: bad }, 'hs'), undefined, JSON.stringify(bad));
}
assert.equal(
resolveStatusPage({ hs: { url: 'http://localhost:3001', slug: 'm' } }, 'hs')?.url,
'http://localhost:3001',
'http allowed for local development',
);
});
test('all green on the live page layout', () => {
const s = parse(PAGE, hb());
assert.equal(s.homeserver, 'up');
assert.equal(s.calls, 'up');
assert.equal(s.login, 'up');
assert.equal(pickBanner(s, { syncLost: false, where: 'client' }), undefined);
assert.equal(pickBanner(s, { syncLost: true, where: 'client' }), undefined);
});
test('down needs two failing checks: down+down or pending+down; one blip is not down', () => {
assert.equal(parse(PAGE, hb({ 30: [UP, UP, DOWN] })).homeserver, 'up', 'single failure');
assert.equal(parse(PAGE, hb({ 30: [UP, PENDING] })).homeserver, 'up', 'pending only');
assert.equal(parse(PAGE, hb({ 30: [UP, DOWN, DOWN] })).homeserver, 'down');
assert.equal(parse(PAGE, hb({ 30: [UP, PENDING, DOWN] })).homeserver, 'down');
assert.equal(parse(PAGE, hb({ 30: [DOWN, DOWN, UP] })).homeserver, 'up', 'recovered');
});
test('any failing monitor takes its group down (one piece breaks calls)', () => {
const s = parse(PAGE, hb({ 38: [DOWN, DOWN] }));
assert.equal(s.calls, 'down');
assert.equal(s.homeserver, 'up');
});
test('unknown when the group or its beats are missing, and unknown shows nothing', () => {
const s = parse({ publicGroupList: [] }, hb());
assert.deepEqual([s.homeserver, s.calls, s.login], ['unknown', 'unknown', 'unknown']);
assert.equal(parse(PAGE, { heartbeatList: {} }).homeserver, 'unknown');
assert.equal(pickBanner(s, { syncLost: true, where: 'client' }), undefined);
});
test('garbage from Kuma never throws and never shows a banner', () => {
for (const [page, heartbeat] of [
[null, null],
['<html>', 42],
[{ publicGroupList: 'x', maintenanceList: {}, incidents: 7 }, { heartbeatList: [] }],
[{ publicGroupList: [{ name: 'Homeserver', monitorList: [{ id: {} }] }] }, hb()],
]) {
const s = parse(page, heartbeat);
assert.equal(pickBanner(s, { syncLost: true, where: 'client' }), undefined);
}
assert.equal(pickBanner(null, { syncLost: true, where: 'client' }), undefined);
});
test('beat times are read as UTC; checkedAt is the oldest latest beat', () => {
assert.equal(kumaBeatTime('2026-09-29 14:16:11.804'), Date.UTC(2026, 8, 29, 14, 16, 11, 804));
assert.equal(kumaBeatTime('nope'), undefined);
const s = parse(PAGE, hb());
assert.equal(s.homeserverCheckedAt, Date.UTC(2026, 8, 29, 14, 12));
});
test('"your connection" only when Kuma checked AFTER the drop and the server was up', () => {
const s = parse(PAGE, hb());
const checked = s.homeserverCheckedAt!;
assert.equal(serverConfirmedUpSince(s, checked - 60_000), true, 'checked after the drop');
assert.equal(serverConfirmedUpSince(s, checked + 1), false, 'stale: checked before the drop');
assert.equal(serverConfirmedUpSince(s, null), false, 'not disconnected');
const down = parse(PAGE, hb({ 30: [UP, DOWN, DOWN] }));
assert.equal(serverConfirmedUpSince(down, checked - 60_000), false);
assert.equal(serverConfirmedUpSince(null, 1), false);
});
test('server down vs having problems depends on this client’s own connection', () => {
const s = parse(PAGE, hb({ 30: [DOWN, DOWN] }));
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.kind, 'server-down');
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.tone, 'Critical');
assert.equal(pickBanner(s, { syncLost: false, where: 'client' })?.kind, 'server-problems');
assert.equal(pickBanner(s, { syncLost: false, where: 'login' })?.kind, 'server-down');
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.dismissable, false);
});
test('priority: server > maintenance > calls > announcement; one strip only', () => {
const s: ServerStatus = {
...parse(PAGE, hb({ 30: [DOWN, DOWN], 37: [DOWN, DOWN] })),
maintenance: [{ id: '1', title: 'Upgrade', description: '' }],
incidents: [{ id: '2', title: 'Heads up', content: '', style: 'info', updated: 'x' }],
};
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.kind, 'server-down');
assert.equal(
pickBanner({ ...s, homeserver: 'up' }, { syncLost: false, where: 'client' })?.kind,
'maintenance',
);
assert.equal(
pickBanner({ ...s, homeserver: 'up', maintenance: [] }, { syncLost: false, where: 'client' })
?.kind,
'calls-down',
);
assert.equal(
pickBanner(
{ ...s, homeserver: 'up', maintenance: [], calls: 'up' },
{ syncLost: false, where: 'client' },
)?.kind,
'announcement',
);
});
test('calls-down is not shown on the login screen or when the whole server is down', () => {
const calls = parse(PAGE, hb({ 37: [DOWN, DOWN] }));
assert.equal(pickBanner(calls, { syncLost: false, where: 'login' }), undefined);
const both = parse(PAGE, hb({ 30: [DOWN, DOWN], 37: [DOWN, DOWN] }));
assert.equal(pickBanner(both, { syncLost: false, where: 'client' })?.kind, 'server-problems');
});
test('login-down only on the login screen', () => {
const s = parse(PAGE, hb({ 15: [DOWN, DOWN] }));
assert.equal(pickBanner(s, { syncLost: false, where: 'login' })?.kind, 'login-down');
assert.equal(pickBanner(s, { syncLost: false, where: 'client' }), undefined);
});
test('maintenance: title, end time and plain-text description', () => {
const end = '2026-09-29T22:15:00.000Z';
const s = parse(
{
...PAGE,
maintenanceList: [
{
id: 7,
title: 'Homeserver upgrade',
description: '**Synapse 1.160**, see [notes](https://x)',
status: 'under-maintenance',
timeslotList: [{ startDate: '2026-09-29T22:00:00.000Z', endDate: end }],
},
{ id: 8, title: 'Later', status: 'scheduled' },
],
},
hb(),
);
assert.equal(s.maintenance.length, 1, 'only windows under maintenance');
assert.equal(s.maintenance[0].end, Date.parse(end));
const b = pickBanner(s, { syncLost: false, where: 'client' })!;
assert.equal(b.kind, 'maintenance');
assert.match(b.text, /^Maintenance in progress until .+: Homeserver upgrade\. /);
assert.equal(b.detail, 'Synapse 1.160, see notes');
assert.equal(b.dismissable, false);
});
test('announcements: tone from style, dismissable, a new edit comes back', () => {
const incident = {
id: 3,
style: 'danger',
title: 'Planned maintenance tonight',
content: 'Calls will drop briefly.',
active: true,
createdDate: '2026-09-29 15:00:00',
lastUpdatedDate: null,
};
const s = parse({ ...PAGE, incidents: [incident] }, hb());
const b = pickBanner(s, { syncLost: false, where: 'client' })!;
assert.equal(b.kind, 'announcement');
assert.equal(b.tone, 'Critical');
assert.equal(b.dismissable, true);
const dismissed = new Set([b.key]);
assert.equal(pickBanner(s, { syncLost: false, where: 'client', dismissed }), undefined);
const edited = parse(
{ ...PAGE, incidents: [{ ...incident, lastUpdatedDate: '2026-09-29 16:00:00' }] },
hb(),
);
assert.equal(
pickBanner(edited, { syncLost: false, where: 'client', dismissed })?.kind,
'announcement',
);
const inactive = parse({ ...PAGE, incidents: [{ ...incident, active: false }] }, hb());
assert.equal(pickBanner(inactive, { syncLost: false, where: 'client' }), undefined);
const info = parse({ ...PAGE, incidents: [{ ...incident, style: 'info' }] }, hb());
assert.equal(pickBanner(info, { syncLost: false, where: 'client' })?.tone, 'Primary');
});
test('a dismissed calls-down banner lets the next one through, never hides server-down', () => {
const s = parse(PAGE, hb({ 37: [DOWN, DOWN] }));
const dismissed = new Set(['calls-down', 'server-down', 'server-problems']);
assert.equal(pickBanner(s, { syncLost: false, where: 'client', dismissed }), undefined);
const down = parse(PAGE, hb({ 30: [DOWN, DOWN] }));
assert.equal(
pickBanner(down, { syncLost: true, where: 'client', dismissed })?.kind,
'server-down',
);
});
+332
View File
@@ -0,0 +1,332 @@
/**
* [Gitea #124] Homeserver status from an Uptime Kuma status page.
*
* config.json maps a homeserver to a Kuma status page:
* "statusPages": { "matrix.lotusguild.org": { "url": "https://isitup.lotusguild.org",
* "slug": "matrix", "groups": { "homeserver": "Homeserver", "calls": "Voice calls",
* "login": "Login" } } }
* Users of any other homeserver never contact Kuma.
*
* Kuma 2.x public JSON:
* - GET /api/status-page/<slug>: publicGroupList[{name, monitorList[{id}]}],
* maintenanceList (only windows UNDER maintenance right now), incidents
* (announcements posted on the page).
* - GET /api/status-page/heartbeat/<slug>: heartbeatList{<monitorId>: [{status, time}]},
* status 0 down, 1 up, 2 pending, 3 maintenance.
*
* Everything here is pure and defensive: anything unexpected reads as
* "unknown", and unknown never shows a banner.
*/
export type StatusGroups = { homeserver?: string; calls?: string; login?: string };
export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups };
export type ServiceState = 'up' | 'down' | 'unknown';
export type KumaMaintenance = { id: string; title: string; description: string; end?: number };
export type KumaIncident = {
id: string;
title: string;
content: string;
style: string;
updated: string;
};
export type ServerStatus = {
homeserver: ServiceState;
/**
* When Kuma last checked every homeserver monitor (the OLDEST of their
* latest beats, ms). Lets the client tell "Kuma has looked since my
* connection dropped and the server was fine" from a stale "up".
*/
homeserverCheckedAt?: number;
calls: ServiceState;
login: ServiceState;
maintenance: KumaMaintenance[];
incidents: KumaIncident[];
};
export const DEFAULT_GROUPS: Required<StatusGroups> = {
homeserver: 'Homeserver',
calls: 'Voice calls',
login: 'Login',
};
const obj = (v: unknown): Record<string, unknown> | undefined =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
/** The status page for `serverName`, if config.json names a valid one. */
export const resolveStatusPage = (
statusPages: unknown,
serverName: string | undefined,
): StatusPageConfig | undefined => {
if (!serverName) return undefined;
const entry = obj(obj(statusPages)?.[serverName]);
if (!entry) return undefined;
const slug = str(entry.slug);
if (!/^[a-z0-9-]{1,64}$/i.test(slug)) return undefined;
try {
const url = new URL(str(entry.url));
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
const g = obj(entry.groups) ?? {};
return {
url: url.origin,
slug,
groups: {
homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver,
calls: str(g.calls) || DEFAULT_GROUPS.calls,
login: str(g.login) || DEFAULT_GROUPS.login,
},
};
} catch {
return undefined;
}
};
export const kumaUrls = (page: StatusPageConfig) => ({
page: `${page.url}/api/status-page/${page.slug}`,
heartbeat: `${page.url}/api/status-page/heartbeat/${page.slug}`,
});
const DOWN = 0;
const PENDING = 2;
/** Kuma beat time ("2026-09-29 14:16:11.804", UTC) → ms. */
export const kumaBeatTime = (time: unknown): number | undefined => {
const t = Date.parse(`${str(time).trim().replace(' ', 'T')}Z`);
return Number.isFinite(t) ? t : undefined;
};
const sortedBeats = (list: unknown): Record<string, unknown>[] =>
(Array.isArray(list) ? list : [])
.map((b) => obj(b))
.filter((b): b is Record<string, unknown> => !!b && typeof b.status === 'number')
.sort((a, b) => str(a.time).localeCompare(str(b.time)));
/**
* A group is down when ANY of its monitors is failing across two checks in a
* row: its latest beat is down and the one before is down or pending (Kuma
* marks a first failure "pending" when the monitor has a retry). One piece
* down, say the call token service, already breaks the feature; two checks so
* a single blip doesn't flash a banner. Up when every monitor we have beats
* for is fine; unknown when the group or its beats are missing.
*/
const groupState = (
groupName: string | undefined,
groups: Map<string, string[]>,
beats: Record<string, unknown>,
): ServiceState => {
if (!groupName) return 'unknown';
const ids = groups.get(groupName);
if (!ids || ids.length === 0) return 'unknown';
let known = 0;
let down = false;
ids.forEach((id) => {
const statuses = sortedBeats(beats[id]).map((b) => b.status as number);
if (statuses.length === 0) return;
known += 1;
const [prev, last] = statuses.slice(-2);
if (statuses.length >= 2 && last === DOWN && (prev === DOWN || prev === PENDING)) down = true;
});
if (down) return 'down';
return known > 0 ? 'up' : 'unknown';
};
export const parseKumaStatus = (
pageJson: unknown,
heartbeatJson: unknown,
groupNames: StatusGroups,
): ServerStatus => {
const page = obj(pageJson) ?? {};
const groups = new Map<string, string[]>();
(Array.isArray(page.publicGroupList) ? page.publicGroupList : []).forEach((g) => {
const group = obj(g);
if (!group) return;
const ids = (Array.isArray(group.monitorList) ? group.monitorList : [])
.map((m) => obj(m)?.id)
.filter((id): id is number | string => typeof id === 'number' || typeof id === 'string')
.map(String);
groups.set(str(group.name), ids);
});
const beats = obj(obj(heartbeatJson)?.heartbeatList) ?? {};
const maintenance: KumaMaintenance[] = (
Array.isArray(page.maintenanceList) ? page.maintenanceList : []
)
.map((m) => obj(m))
.filter((m): m is Record<string, unknown> => !!m && str(m.title) !== '')
.filter((m) => !m.status || m.status === 'under-maintenance')
.map((m) => {
const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]);
const end = Date.parse(str(slot?.endDate));
return {
id: String(m.id ?? str(m.title)),
title: str(m.title),
description: str(m.description),
...(Number.isFinite(end) ? { end } : {}),
};
});
const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : [])
.map((i) => obj(i))
.filter((i): i is Record<string, unknown> => !!i && str(i.title) !== '' && i.active !== false)
.map((i) => ({
id: String(i.id ?? str(i.title)),
title: str(i.title),
content: str(i.content),
style: str(i.style) || 'info',
updated: str(i.lastUpdatedDate) || str(i.createdDate),
}));
const hsIds = (groupNames.homeserver && groups.get(groupNames.homeserver)) || [];
const hsLatest = hsIds.map((id) => kumaBeatTime(sortedBeats(beats[id]).slice(-1)[0]?.time));
const homeserverCheckedAt =
hsLatest.length > 0 && hsLatest.every((t): t is number => t !== undefined)
? Math.min(...hsLatest)
: undefined;
return {
homeserver: groupState(groupNames.homeserver, groups, beats),
...(homeserverCheckedAt !== undefined ? { homeserverCheckedAt } : {}),
calls: groupState(groupNames.calls, groups, beats),
login: groupState(groupNames.login, groups, beats),
maintenance,
incidents,
};
};
/**
* Kuma checked the homeserver AFTER this client lost its connection, and it
* was fine: the problem is more likely on the user's side. A stale "up" from
* before the drop proves nothing (Kuma takes a minute or two to notice an
* outage), so it doesn't count.
*/
export const serverConfirmedUpSince = (
status: ServerStatus | null | undefined,
lostAt: number | null | undefined,
): boolean =>
!!status &&
!!lostAt &&
status.homeserver === 'up' &&
status.homeserverCheckedAt !== undefined &&
status.homeserverCheckedAt > lostAt;
export type BannerTone = 'Critical' | 'Warning' | 'Primary';
export type BannerKind =
| 'server-down'
| 'server-problems'
| 'maintenance'
| 'calls-down'
| 'announcement'
| 'login-down';
export type StatusBanner = {
kind: BannerKind;
/** Stable id for dismissing (announcements come back when edited). */
key: string;
tone: BannerTone;
text: string;
detail?: string;
dismissable: boolean;
};
const formatTime = (ms: number): string =>
new Date(ms).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const incidentTone = (style: string): BannerTone => {
if (style === 'danger') return 'Critical';
if (style === 'warning') return 'Warning';
return 'Primary';
};
/** Markdown-ish incident text as plain text for the Details line. */
const plain = (s: string): string =>
s
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`#>]/g, '')
.replace(/\s+\n/g, '\n')
.trim();
/**
* The one strip to show, highest priority first. `syncLost`: this client's
* connection is reconnecting/erroring. `where`: the logged-in app or the
* login screen.
*/
export const pickBanner = (
status: ServerStatus | null | undefined,
opts: { syncLost: boolean; where: 'client' | 'login'; dismissed?: ReadonlySet<string> },
): StatusBanner | undefined => {
if (!status) return undefined;
const dismissed = opts.dismissed ?? new Set<string>();
const candidates: StatusBanner[] = [];
if (status.homeserver === 'down') {
if (opts.syncLost || opts.where === 'login') {
candidates.push({
kind: 'server-down',
key: 'server-down',
tone: 'Critical',
text:
opts.where === 'login'
? "Lotus Chat's server is down. We're on it."
: "Lotus Chat's server is down. We're on it, reconnecting…",
dismissable: false,
});
} else {
candidates.push({
kind: 'server-problems',
key: 'server-problems',
tone: 'Warning',
text: "Lotus Chat's server is having problems. Messages may be slow to send or arrive.",
dismissable: false,
});
}
}
const maint = status.maintenance[0];
if (maint) {
const until = maint.end ? ` until ${formatTime(maint.end)}` : '';
candidates.push({
kind: 'maintenance',
key: `maintenance:${maint.id}`,
tone: 'Warning',
text: `Maintenance in progress${until}: ${maint.title}. Messages and calls may be interrupted.`,
detail: plain(maint.description) || undefined,
dismissable: false,
});
}
if (opts.where === 'login' && status.login === 'down') {
candidates.push({
kind: 'login-down',
key: 'login-down',
tone: 'Warning',
text: "Sign-in is having problems right now. If it doesn't work, try again in a few minutes.",
dismissable: false,
});
}
if (opts.where === 'client' && status.calls === 'down' && status.homeserver !== 'down') {
candidates.push({
kind: 'calls-down',
key: 'calls-down',
tone: 'Warning',
text: 'Voice calls are down right now. Messages still work.',
dismissable: true,
});
}
status.incidents.forEach((i) => {
candidates.push({
kind: 'announcement',
key: `announcement:${i.id}:${i.updated}`,
tone: incidentTone(i.style),
text: i.title,
detail: plain(i.content) || undefined,
dismissable: true,
});
});
return candidates.find((b) => !(b.dismissable && dismissed.has(b.key)));
};
+131
View File
@@ -0,0 +1,131 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OUTBOX_AUTOSEND_MS,
OUTBOX_MAX_ENTRIES,
OutboxEntry,
isRetryableSendError,
outboxRetryDelayMs,
parseOutbox,
planRestore,
shouldKeepInOutbox,
withEntry,
withoutEntry,
} from './outbox';
const entry = (txnId: string, ts: number, roomId = '!a:hs'): OutboxEntry => ({
txnId,
roomId,
threadId: null,
type: 'm.room.message',
content: { msgtype: 'm.text', body: txnId },
ts,
});
test('keeps message-like events, not call signalling or redactions', () => {
assert.equal(shouldKeepInOutbox('m.room.message', { body: 'hi' }), true);
assert.equal(shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '$x' } }), true);
assert.equal(shouldKeepInOutbox('org.matrix.msc3381.poll.start', {}), true);
assert.equal(shouldKeepInOutbox('org.matrix.msc4075.rtc.notification', {}), false);
assert.equal(shouldKeepInOutbox('m.call.invite', {}), false);
assert.equal(shouldKeepInOutbox('m.room.redaction', {}), false);
});
test('skips events that point at another unsent message (local id)', () => {
assert.equal(
shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '~!a:hs:m123' } }),
false,
);
assert.equal(
shouldKeepInOutbox('m.room.message', {
body: 'reply',
'm.relates_to': { 'm.in_reply_to': { event_id: '~!a:hs:m1' } },
}),
false,
);
assert.equal(
shouldKeepInOutbox('m.room.message', {
body: 'reply',
'm.relates_to': { 'm.in_reply_to': { event_id: '$real' } },
}),
true,
);
});
test('retryable: connection loss, timeouts, rate limits, server errors', () => {
const connectionError = new Error('fetch failed');
Object.defineProperty(connectionError, 'name', { value: 'ConnectionError' });
assert.equal(isRetryableSendError(connectionError), true);
assert.equal(isRetryableSendError({ httpStatus: 429 }), true);
assert.equal(isRetryableSendError({ httpStatus: 408 }), true);
assert.equal(isRetryableSendError({ httpStatus: 502 }), true);
});
test('not retryable: client errors, consent, unknown or missing errors', () => {
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_FORBIDDEN' }), false);
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_CONSENT_NOT_GIVEN' }), false);
assert.equal(isRetryableSendError({ httpStatus: 400 }), false);
assert.equal(isRetryableSendError(new Error('encryption failed')), false);
assert.equal(isRetryableSendError(undefined), false);
assert.equal(isRetryableSendError(null), false);
});
test('parse: only this user, only well-formed entries, junk tolerated', () => {
const good = entry('m1', 1);
const raw = JSON.stringify({ userId: '@me:hs', entries: [good, { txnId: 5 }, null] });
assert.deepEqual(parseOutbox(raw, '@me:hs'), [good]);
assert.deepEqual(parseOutbox(raw, '@other:hs'), []);
assert.deepEqual(parseOutbox('{not json', '@me:hs'), []);
assert.deepEqual(parseOutbox(null, '@me:hs'), []);
});
test('withEntry: first write wins, sorted, capped (oldest dropped)', () => {
const a = entry('a', 2);
let list = withEntry([], a);
assert.equal(withEntry(list, { ...a, content: { body: 'changed' } }), list);
list = withEntry(list, entry('b', 1));
assert.deepEqual(
list.map((e) => e.txnId),
['b', 'a'],
);
let many: OutboxEntry[] = [];
for (let i = 0; i < OUTBOX_MAX_ENTRIES + 5; i += 1) many = withEntry(many, entry(`t${i}`, i));
assert.equal(many.length, OUTBOX_MAX_ENTRIES);
assert.equal(many[0].txnId, 't5');
});
test('withoutEntry: removes, and returns the same list when absent', () => {
const list = [entry('a', 1), entry('b', 2)];
assert.deepEqual(
withoutEntry(list, 'a').map((e) => e.txnId),
['b'],
);
assert.equal(withoutEntry(list, 'zzz'), list);
});
test('restore plan: drops delivered / left rooms, auto-sends only recent ones', () => {
const now = 10 * OUTBOX_AUTOSEND_MS;
const recent = entry('recent', now - 60_000);
const old = entry('old', now - OUTBOX_AUTOSEND_MS - 1);
const delivered = entry('delivered', now - 1000);
const left = entry('left', now - 1000, '!left:hs');
const plan = planRestore(
[recent, left, old, delivered],
now,
(roomId) => roomId !== '!left:hs',
(e) => e.txnId === 'delivered',
);
assert.deepEqual(
plan.restore.map((e) => e.txnId),
['old', 'recent'],
);
assert.deepEqual([...plan.autoSend], ['recent']);
assert.deepEqual(plan.drop.map((e) => e.txnId).sort(), ['delivered', 'left']);
});
test('blip retry delay backs off and is capped at a minute', () => {
assert.deepEqual(
[0, 1, 2, 3, 4, 9].map(outboxRetryDelayMs),
[5000, 10000, 20000, 40000, 60000, 60000],
);
});
+182
View File
@@ -0,0 +1,182 @@
import { IContent } from 'matrix-js-sdk';
/**
* [Gitea #112] Offline outbox: unsent messages survive a reload and are
* retried when the connection comes back.
*
* The SDK keeps local echoes in memory only (chronological pending ordering),
* so a reload used to drop every message that hadn't reached the server. Each
* own message send is mirrored here (type + clear content + txnId) from its
* first local echo until the server confirms it or the user cancels it. On
* the next start it is put back as a failed local echo (Retry / Cancel as
* usual) and, if it's recent, sent again with the SAME txnId — the server
* deduplicates a transaction it already accepted.
*
* The content is the decrypted message, like a composer draft: it lives in
* localStorage until sent and is wiped on logout (clearPlaintextCaches).
*/
export type OutboxEntry = {
txnId: string;
roomId: string;
threadId: string | null;
type: string;
content: IContent;
/** When the message was first sent (local echo timestamp, ms). */
ts: number;
};
type Stored = { userId: string; entries: OutboxEntry[] };
const STORAGE_KEY = 'lotus_outbox_v1';
/** Hard cap so a long offline stretch can't grow localStorage without bound. */
export const OUTBOX_MAX_ENTRIES = 100;
/**
* Restored messages younger than this are sent again automatically after a
* reload; older ones come back as "Failed to send" and wait for the user
* (sending a message typed hours ago without asking would surprise people).
*/
export const OUTBOX_AUTOSEND_MS = 60 * 60 * 1000;
/** Auto-retries per message per session (one per reconnect). */
export const OUTBOX_MAX_AUTO_RETRIES = 10;
/** Delay before automatic retry number `attempt` (0-based) after a blip. */
export const outboxRetryDelayMs = (attempt: number): number =>
Math.min(5000 * 2 ** Math.max(0, attempt), 60_000);
/** Message-like events worth keeping. Not call signalling, not redactions. */
export const OUTBOX_EVENT_TYPES: ReadonlySet<string> = new Set([
'm.room.message',
'm.sticker',
'm.reaction',
'm.poll.start',
'm.poll.response',
'm.poll.end',
'org.matrix.msc3381.poll.start',
'org.matrix.msc3381.poll.response',
'org.matrix.msc3381.poll.end',
]);
const relatesToPendingEvent = (content: IContent): boolean => {
const rel = content['m.relates_to'] as
| { event_id?: unknown; 'm.in_reply_to'?: { event_id?: unknown } }
| undefined;
const ids = [rel?.event_id, rel?.['m.in_reply_to']?.event_id];
// A local echo's id ("~!room:txn") means nothing after a reload.
return ids.some((id) => typeof id === 'string' && id.startsWith('~'));
};
/** Whether a send should be mirrored into the outbox. */
export const shouldKeepInOutbox = (type: string, content: IContent): boolean =>
OUTBOX_EVENT_TYPES.has(type) && !relatesToPendingEvent(content);
/**
* A failure the network is to blame for: no connection (the SDK's
* ConnectionError), a timeout, rate limiting or a server error. Anything else
* (403, consent, bad request, encryption failure) needs the user.
*/
export const isRetryableSendError = (err: unknown): boolean => {
if (!err || typeof err !== 'object') return false;
const { name, httpStatus } = err as { name?: unknown; httpStatus?: unknown };
if (name === 'ConnectionError') return true;
if (typeof httpStatus !== 'number') return false;
return httpStatus === 408 || httpStatus === 429 || httpStatus >= 500;
};
const isEntry = (e: unknown): e is OutboxEntry => {
if (!e || typeof e !== 'object') return false;
const o = e as Record<string, unknown>;
return (
typeof o.txnId === 'string' &&
typeof o.roomId === 'string' &&
(o.threadId === null || typeof o.threadId === 'string') &&
typeof o.type === 'string' &&
!!o.content &&
typeof o.content === 'object' &&
typeof o.ts === 'number'
);
};
/** Parse the stored outbox, keeping only this user's well-formed entries. */
export const parseOutbox = (raw: string | null, userId: string): OutboxEntry[] => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Partial<Stored>;
if (parsed.userId !== userId || !Array.isArray(parsed.entries)) return [];
return parsed.entries.filter(isEntry);
} catch {
return [];
}
};
/** Add (or keep) an entry: first write wins, oldest dropped past the cap. */
export const withEntry = (entries: OutboxEntry[], entry: OutboxEntry): OutboxEntry[] => {
if (entries.some((e) => e.txnId === entry.txnId)) return entries;
const next = [...entries, entry].sort((a, b) => a.ts - b.ts);
return next.length > OUTBOX_MAX_ENTRIES ? next.slice(next.length - OUTBOX_MAX_ENTRIES) : next;
};
export const withoutEntry = (entries: OutboxEntry[], txnId: string): OutboxEntry[] =>
entries.some((e) => e.txnId === txnId) ? entries.filter((e) => e.txnId !== txnId) : entries;
export type RestorePlan = {
/** Put back as failed local echoes, oldest first. */
restore: OutboxEntry[];
/** Subset of `restore` to send again right away. */
autoSend: Set<string>;
/** Already delivered or no longer sendable: forget them. */
drop: OutboxEntry[];
};
/**
* Decide what to do with the stored outbox on start.
* `canSend(roomId)`: the user is still joined; `delivered(entry)`: the
* server already has it (the transaction id came back down /sync).
*/
export const planRestore = (
entries: OutboxEntry[],
now: number,
canSend: (roomId: string) => boolean,
delivered: (entry: OutboxEntry) => boolean,
): RestorePlan => {
const restore: OutboxEntry[] = [];
const autoSend = new Set<string>();
const drop: OutboxEntry[] = [];
[...entries]
.sort((a, b) => a.ts - b.ts)
.forEach((entry) => {
if (!canSend(entry.roomId) || delivered(entry)) {
drop.push(entry);
return;
}
restore.push(entry);
if (now - entry.ts < OUTBOX_AUTOSEND_MS) autoSend.add(entry.txnId);
});
return { restore, autoSend, drop };
};
export const loadOutbox = (userId: string): OutboxEntry[] => {
try {
return parseOutbox(localStorage.getItem(STORAGE_KEY), userId);
} catch {
return [];
}
};
export const saveOutbox = (userId: string, entries: OutboxEntry[]): void => {
try {
if (entries.length === 0) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, entries } satisfies Stored));
} catch {
// Storage full or blocked: the outbox is best-effort.
}
};
/** Wipe the outbox (logout): it holds decrypted message content. */
export const clearOutbox = (): void => {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage unavailable — nothing to clear */
}
};
+6
View File
@@ -17,10 +17,16 @@ import App from './app/pages/App';
import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
import { startKeychainMirror } from './app/state/keychainMirror';
import { tauriInvoke } from './app/hooks/useTauri';
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
document.body.classList.add(configClass, varsClass);
// [Gitea #105] Desktop: keep a copy of the login tokens in the OS keychain
// (step 1: mirror only; the session is still read from localStorage).
startKeychainMirror(tauriInvoke());
// Register Service Worker
// Service workers only register on http(s) pages. The desktop app loads from
// `tauri://localhost` in debug builds (and on any platform where the localhost
+8
View File
@@ -898,6 +898,14 @@ globalStyle(
boxShadow: '0 0 10px rgba(255,107,0,0.10)',
},
);
// [Gitea #246] The poll's winning answer (after it ends).
globalStyle(
`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer][data-winner="true"]`,
{
border: '1px solid rgba(0,255,136,0.55) !important',
boxShadow: '0 0 10px rgba(0,255,136,0.10)',
},
);
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-content-label]`, {
color: 'rgba(0,212,255,0.60) !important',
opacity: '1 !important',