Compare commits

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

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

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

Fixes #340

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:16:05 -04:00
Lotus CIandClaude Opus 5.5 ddf9da1a55 fix(reminders): a reminder stays until you've seen it (#338)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m6s
ReminderMonitor pushed an ordinary toast (auto-dismissed after 4 s) and
deleted the reminder at once, with no OS notification — one that came due
while the app was in the background or the user was away was lost.

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

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

Fixes #338

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

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

Fixes #342

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

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

Fixes #339

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

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

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

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

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

Fixes #345

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

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

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

Fixes #341

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The web app is unchanged (elementCallUrl as before).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:27 -04:00
jared 91f82d60e3 Merge pull request 'A stalled server no longer reads as "your clock is ahead"' (#251) from clock-skew-lag into lotus
CI / Build & Quality Checks (push) Successful in 3m4s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m32s
Merge pull request #251: a stalled server no longer reads as a wrong clock
2026-09-28 22:40:13 -04:00
47 changed files with 2764 additions and 221 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+3
View File
@@ -14,3 +14,6 @@ test-results/
# local dev homeserver (scripts/dev-homeserver.sh)
.dev-homeserver/
__pycache__/
# Local config backups
*.bak
+12 -1
View File
@@ -12,5 +12,16 @@
"enabled": false,
"basename": "/"
},
"gifApiKey": ""
"gifApiKey": "",
"statusPages": {
"matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org",
"slug": "matrix",
"groups": {
"homeserver": "Homeserver",
"calls": "Voice calls",
"login": "Login"
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { test, expect, Page } from '@playwright/test';
import { HS, ensureUser, hsReachable, loginUI, TestUser, uniq } from './localHs';
// [Gitea #110, #123] The device-security nudge. A fresh account has no key
// backup and no cross-signing → "Set up key backup", but only after the 24 h
// grace period on this device; "Not now" snoozes it.
const strip = (page: Page) => page.getByRole('status').filter({ hasText: /encryption keys/ });
/** Back-date this device's first-seen record past the 24 h grace period, then reload. */
async function pastGrace(page: Page) {
await page.evaluate(() => {
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
localStorage.setItem(
`lotus-security-nudge-${deviceId}`,
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
);
});
await page.reload();
}
/** The nudge waits until sync has settled (after "Connecting…"). */
async function settled(page: Page) {
await page
.getByText('Connecting...')
.first()
.waitFor({ timeout: 30_000 })
.catch(() => undefined);
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
await page.waitForTimeout(2000);
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
});
test.beforeEach(async () => {
user = await ensureUser(uniq('e2e_nudge_'));
});
test('nothing during the first 24 h on a device', async ({ page }) => {
test.setTimeout(150_000);
await loginUI(page, user);
await settled(page);
// Past the point where the nudge shows once the grace period is over (~5 s).
await page.waitForTimeout(8000);
await expect(strip(page)).toHaveCount(0);
});
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
test.setTimeout(150_000);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
const nudge = strip(page);
await expect(nudge).toContainText("Your encryption keys aren't backed up");
await nudge.getByRole('button', { name: 'Set up' }).click();
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
await strip(page).getByRole('button', { name: 'Not now' }).click();
await expect(strip(page)).toHaveCount(0);
await page.reload();
await settled(page);
await expect(strip(page)).toHaveCount(0);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { test, expect, Page } from '@playwright/test';
import {
HS,
createRoom,
ensureUser,
hsReachable,
loginUI,
openRoom,
uniq,
TestUser,
} from './localHs';
// [Gitea #124] Status banner from the homeserver's Uptime Kuma page. Kuma is
// answered by fixtures here; nothing contacts a real Kuma.
const KUMA = 'https://isitup.lotusguild.org';
const PAGE_CFG = { url: KUMA, slug: 'matrix' };
const UP = 1;
const DOWN = 0;
type Fixture = { hs?: number; calls?: number; staleMinutes?: number; fail?: boolean };
const kumaTime = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
/** Serve config.json with a status page for `serverName` and answer Kuma from `fx()`. */
async function mockKuma(page: Page, serverName: string | null, fx: () => Fixture) {
const kumaRequests: string[] = [];
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = serverName ? { [serverName]: PAGE_CFG } : {};
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const url = route.request().url();
kumaRequests.push(url);
const f = fx();
if (f.fail) {
await route.fulfill({ status: 500, body: 'oops' });
return;
}
const cors = { 'access-control-allow-origin': '*' };
if (url.includes('/heartbeat/')) {
// Two checks, the latest `staleMinutes` ago (0 = just now).
const last = Date.now() - (f.staleMinutes ?? 0) * 60_000;
const beats = (s: number) => [
{ status: s, time: kumaTime(last - 60_000) },
{ status: s, time: kumaTime(last) },
];
await route.fulfill({
headers: cors,
json: { heartbeatList: { 1: beats(f.hs ?? UP), 2: beats(f.calls ?? UP) }, uptimeList: {} },
});
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [],
maintenanceList: [],
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 1 }] },
{ name: 'Voice calls', monitorList: [{ id: 2 }] },
],
},
});
});
return kumaRequests;
}
const serverOf = (u: TestUser) => u.userId.split(':').slice(1).join(':');
const strip = (page: Page, text: RegExp) =>
page.locator('[role="status"], [role="alert"]').filter({ hasText: text });
const cutSync = (page: Page) =>
page.route(/\/_matrix\/client\/v3\/sync/, (route) => route.abort('connectionfailed'));
test.describe('server status banner (#124)', () => {
let alice: TestUser;
let room: string;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
alice = await ensureUser(uniq('e2e_status_'));
room = await createRoom(alice, 'Status Room');
});
test('calls down: amber strip, dismiss sticks across a reload', async ({ page }) => {
await mockKuma(page, serverOf(alice), () => ({ calls: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
const calls = strip(page, /Voice calls are down right now\. Messages still work\./);
await expect(calls).toBeVisible();
await calls.getByRole('button', { name: 'Dismiss' }).click();
await expect(calls).toHaveCount(0);
await page.reload();
await openRoom(page, room);
await page.waitForTimeout(2000);
await expect(calls).toHaveCount(0);
});
test('other homeservers never contact Kuma', async ({ page }) => {
const requests = await mockKuma(page, 'some.other.server', () => ({ hs: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
await page.waitForTimeout(3000);
expect(requests).toEqual([]);
});
test('Kuma failing shows nothing (fail quiet)', async ({ page }) => {
const requests = await mockKuma(page, serverOf(alice), () => ({ fail: true }));
await loginUI(page, alice);
await openRoom(page, room);
await expect.poll(() => requests.length).toBeGreaterThan(0);
await page.waitForTimeout(1500);
await expect(strip(page, /server|Voice calls|Maintenance/i)).toHaveCount(0);
});
test('connection lost + Kuma says the server is down: critical strip, not "Connection Lost"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
// Connected: the server is "having problems" for others, not for us.
await expect(strip(page, /server is having problems/)).toBeVisible();
await cutSync(page);
const down = page.getByRole('alert').filter({ hasText: /server is down\. We're on it/ });
await expect(down).toBeVisible({ timeout: 120_000 });
await expect(page.getByText('Connection Lost! Reconnecting...')).toHaveCount(0);
});
test('connection lost + Kuma checked since and the server is up: "may be your connection"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: UP }));
await loginUI(page, alice);
await openRoom(page, room);
await cutSync(page);
await expect(page.getByText(/server is up, so it may be your internet connection/)).toBeVisible(
{
timeout: 120_000,
},
);
});
test('connection lost + only a stale "up" from Kuma: plain "Connection Lost"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: UP, staleMinutes: 10 }));
await loginUI(page, alice);
await openRoom(page, room);
await cutSync(page);
await expect(page.getByText('Connection Lost! Reconnecting...')).toBeVisible({
timeout: 120_000,
});
await page.waitForTimeout(3000);
await expect(page.getByText(/may be your internet connection/)).toHaveCount(0);
});
});
+21 -19
View File
@@ -45,12 +45,12 @@
"html-react-parser": "6.1.2",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0",
"i18next-http-backend": "4.0.2",
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.47",
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
@@ -5026,9 +5026,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -8168,9 +8168,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz",
"integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9162,28 +9162,29 @@
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"version": "0.18.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
"integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==",
"deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
"commander": "^15.0.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT",
"engines": {
"node": ">= 12"
"node": ">=22.12.0"
}
},
"node_modules/keyv": {
@@ -12164,9 +12165,10 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
+2 -2
View File
@@ -72,12 +72,12 @@
"html-react-parser": "6.1.2",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0",
"i18next-http-backend": "4.0.2",
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.47",
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
@@ -37,6 +37,7 @@ import {
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -732,6 +733,8 @@ export function MLocation({ content }: MLocationProps) {
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
// RFC 5870 `;u=` uncertainty (meters), sent by Lotus since Gitea #340.
const accuracyLabel = formatAccuracy(Number(/;u=([\d.]+)/.exec(geoUri)?.[1]));
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
@@ -772,6 +775,7 @@ export function MLocation({ content }: MLocationProps) {
)}
<Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Button
as="a"
@@ -1,5 +1,6 @@
import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
import { GIF_PREVIEW_MAX_HEIGHT } from '../../utils/gifPreviewSize';
export const UrlPreview = style([
DefaultReset,
@@ -955,12 +956,14 @@ export const PortraitSideLayout = style([
// GIF card (Giphy / Tenor)
// ---------------------------------------------------------------------------
// Full card width; a GIF narrower than the card (portrait, square, small) is
// centred on the surface colour instead of being stretched and cropped.
export const GifThumbnailWrapper = style([
DefaultReset,
{
position: 'relative',
display: 'flex',
justifyContent: 'center',
width: '100%',
maxHeight: toRem(200),
overflow: 'hidden',
flexShrink: 0,
backgroundColor: color.Surface.Container,
@@ -972,14 +975,26 @@ export const GifThumbnailWrapper = style([
},
]);
// Wraps just the GIF so the "GIF" badge sits on the GIF's own corner.
export const GifFrame = style([
DefaultReset,
{
position: 'relative',
display: 'block',
maxWidth: '100%',
},
]);
// The inline width/aspect-ratio (from og:image:width/height) sets the size;
// max-width lets a phone's narrower card shrink it, keeping the ratio.
export const GifThumbnailImg = style([
DefaultReset,
{
width: '100%',
maxHeight: toRem(200),
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
maxWidth: '100%',
maxHeight: toRem(GIF_PREVIEW_MAX_HEIGHT),
height: 'auto',
objectFit: 'contain',
},
]);
@@ -13,6 +13,7 @@ import {
as,
color,
config,
toRem,
} from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css';
@@ -48,6 +49,11 @@ import {
tiktokOembedUrl,
tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed';
import {
GIF_PREVIEW_MAX_HEIGHT,
GIF_PREVIEW_MAX_WIDTH,
gifPreviewBox,
} from '../../utils/gifPreviewSize';
const linkStyles = { color: color.Success.Main };
@@ -2277,6 +2283,9 @@ function GifCard({
}) {
const title = (prev['og:title'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
// Size the GIF from its own dimensions (Giphy and Tenor publish them) rather
// than forcing every GIF into one cropped band.
const box = gifPreviewBox(prev['og:image:width'], prev['og:image:height']);
// A GIF card exists to show a moving GIF, so request the original rather than
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
@@ -2284,7 +2293,15 @@ function GifCard({
const thumbSrc = mxcImage
? shouldServeGifOriginal(url, prev)
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
: mxcUrlToHttp(
mx,
mxcImage,
useAuthentication,
GIF_PREVIEW_MAX_WIDTH * 2,
GIF_PREVIEW_MAX_HEIGHT * 2,
'scale',
false,
)
: null;
// If there's no image, fall back to a generic-style layout
@@ -2316,7 +2333,9 @@ function GifCard({
return (
<Box direction="Column" style={{ width: '100%' }}>
{/* GIF thumbnail — full width */}
{/* The whole GIF at its own aspect ratio, centred; never cropped. With a
known size the box is reserved before it loads (no layout jump);
without one it shows at its natural size, capped by the card. */}
<a
href={url}
target="_blank"
@@ -2324,8 +2343,22 @@ function GifCard({
className={previewCss.GifThumbnailWrapper}
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
>
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
<span className={previewCss.GifBadge}>GIF</span>
<span className={previewCss.GifFrame}>
<img
className={previewCss.GifThumbnailImg}
src={thumbSrc}
alt={title}
loading="lazy"
width={box?.width}
height={box?.height}
style={
box
? { width: toRem(box.width), aspectRatio: `${box.width} / ${box.height}` }
: undefined
}
/>
<span className={previewCss.GifBadge}>GIF</span>
</span>
</a>
{/* Footer row */}
<UrlPreviewContent>
@@ -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.
+40 -21
View File
@@ -153,6 +153,9 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { PendingLocation, ShareLocationDialog } from './ShareLocationDialog';
import { buildGeoUri } from '../../utils/geoShare';
import { useLocalRoomName } from '../../hooks/useRoomMeta';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread';
@@ -356,6 +359,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const displayRoomName = useLocalRoomName(room);
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => {
@@ -368,21 +372,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
});
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
const { latitude, longitude, accuracy } = pos.coords;
setPendingLocation({ latitude, longitude, accuracy });
},
(err) => {
setLocating(false);
@@ -397,7 +389,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
},
{ timeout: 10000 },
);
}, [mx, roomId, threadRootId]);
}, []);
const [pendingLocation, setPendingLocation] = React.useState<PendingLocation | null>(null);
const handleSendLocation = useCallback(
(approximate: boolean) => {
if (!pendingLocation) return;
const { latitude, longitude, accuracy } = pendingLocation;
setPendingLocation(null);
const geoUri = buildGeoUri(latitude, longitude, accuracy, approximate);
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared ${approximate ? 'an approximate' : 'a'} location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
});
},
[pendingLocation, mx, roomId, threadRootId],
);
const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -1538,11 +1553,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={touchTarget}
className={MobileTouchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="400" />
) : (
<Icon src={Icons.SpaceGlobe} />
)}
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
</IconButton>
) : null;
@@ -1751,6 +1762,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onClose={() => setPollOpen(false)}
/>
)}
{pendingLocation && (
<ShareLocationDialog
location={pendingLocation}
roomName={displayRoomName}
onSend={handleSendLocation}
requestClose={() => setPendingLocation(null)}
/>
)}
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
@@ -0,0 +1,124 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard';
import { formatAccuracy } from '../../utils/geoShare';
export type PendingLocation = { latitude: number; longitude: number; accuracy?: number };
type ShareLocationDialogProps = {
location: PendingLocation;
roomName: string;
onSend: (approximate: boolean) => void;
requestClose: () => void;
};
/**
* [Gitea #340] Confirm step before an m.location is posted: the toolbar button
* used to send the exact position from a single click. Shows what will be
* shared (coordinates + the fix's accuracy) and offers an approximate share.
*/
export function ShareLocationDialog({
location,
roomName,
onSend,
requestClose,
}: ShareLocationDialogProps) {
const modalStyle = useModalStyle(420);
const { latitude, longitude, accuracy } = location;
const accuracyLabel = formatAccuracy(accuracy);
const mapUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#share-location-cancel',
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
role="dialog"
aria-modal="true"
aria-labelledby="share-location-title"
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="share-location-title" as="h2" size="H4">
Share your location?
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>
Everyone in <b>{roomName}</b> will see this position, and it can&apos;t be taken
back from their devices once sent.
</Text>
<Box direction="Column" gap="100">
<Text size="T400">
<b>
{latitude.toFixed(5)}, {longitude.toFixed(5)}
</b>
{accuracyLabel && ` (${accuracyLabel})`}
</Text>
<Text size="T200" priority="300">
{accuracyLabel
? 'The ± is how sure your device is of this position.'
: 'Your device did not say how accurate this position is.'}{' '}
<a href={mapUrl} target="_blank" rel="noreferrer noopener">
Check on OpenStreetMap
</a>
</Text>
</Box>
<Box gap="200" justifyContent="End" wrap="Wrap">
<Button
id="share-location-cancel"
variant="Secondary"
fill="Soft"
onClick={requestClose}
>
<Text size="B400">Cancel</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={() => onSend(true)}>
<Text size="B400">Send approximate (~1 km)</Text>
</Button>
<Button variant="Primary" onClick={() => onSend(false)}>
<Text size="B400">Send</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -0,0 +1,137 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { SyncState } from 'matrix-js-sdk';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import { Icons } from 'folds';
import { ErrorBoundary } from 'react-error-boundary';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../../hooks/useDeviceVerificationStatus';
import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBackup';
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
NUDGE_COPY,
NUDGE_KEY_PREFIX,
nudgeFor,
NudgeRecord,
parseNudgeRecord,
shouldShowNudge,
} from '../../utils/securityNudge';
import { StatusStrip } from '../server-status/ServerStatusBanner';
/** This device's backup connection: undefined until known, then true/false. */
const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
const alive = useAlive();
const [active, setActive] = useState<boolean>();
useEffect(() => {
crypto.getActiveSessionBackupVersion().then((v) => {
if (alive()) setActive(typeof v === 'string');
});
}, [crypto, alive]);
useKeyBackupStatusChange(useCallback((enabled: boolean) => setActive(enabled), []));
return active;
};
const readRecord = (key: string): NudgeRecord => {
let raw: string | null = null;
try {
raw = localStorage.getItem(key);
} catch {
/* storage unavailable */
}
const record = parseNudgeRecord(raw, Date.now());
if (!raw) {
try {
localStorage.setItem(key, JSON.stringify(record));
} catch {
/* best effort */
}
}
return record;
};
function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const mx = useMatrixClient();
const deviceId = mx.getDeviceId() ?? undefined;
const crossSigning = useCrossSigningActive();
const status = useDeviceVerificationStatus(crypto, mx.getSafeUserId(), deviceId);
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
const requestSettings = useSetAtom(settingsRequestAtom);
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
// never stacks under the "Connecting…" strip at startup.
const [settled, setSettled] = useState(false);
useSyncState(
mx,
useCallback((state: SyncState | null, prev?: SyncState | null) => {
setSettled(state === SyncState.Syncing && prev === SyncState.Syncing);
}, []),
);
const key = `${NUDGE_KEY_PREFIX}${deviceId ?? 'unknown'}`;
const [record, setRecord] = useState(() => readRecord(key));
let deviceVerified: boolean | undefined;
if (status === VerificationStatus.Verified) deviceVerified = true;
else if (status === VerificationStatus.Unverified) deviceVerified = false;
const kind = nudgeFor({
crossSigning,
deviceVerified,
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
backupActive,
});
// One strip at a time: outages, maintenance and a lost connection win.
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
const copy = NUDGE_COPY[kind];
const dismiss = () => {
const next = dismissNudge(kind, record, Date.now());
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
/* dismissed for this session only */
}
setRecord(next);
};
return (
<StatusStrip
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
icon={Icons.ShieldUser}
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
dismissLabel="Not now"
onDismiss={dismiss}
/>
);
}
/**
* [Gitea #110, #123] The device-security nudge: verify this device, connect
* it to the key backup, or set up key backup. See utils/securityNudge.ts.
*/
function SecurityBannerInner() {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
if (!crypto) return null;
return <SecurityBannerFor crypto={crypto} />;
}
// A nudge must never take the app down: any error just hides it.
export function SecurityBanner() {
return (
<ErrorBoundary fallback={null}>
<SecurityBannerInner />
</ErrorBoundary>
);
}
@@ -0,0 +1,208 @@
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: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info;
return Icons.Warning;
};
/**
* [Gitea #124] One status strip, in the same slot and style as the sync and
* clock banners. Presentational: the caller picks the banner.
*/
export type StripContent = Pick<
StatusBanner,
'key' | 'tone' | 'text' | 'detail' | 'dismissable'
> & {
kind?: StatusBanner['kind'];
};
export function StatusStrip({
banner,
onDismiss,
icon,
action,
dismissLabel,
}: {
banner: StripContent;
onDismiss?: () => void;
/** Overrides the icon picked from the banner's tone/kind. */
icon?: IconSrc;
/** A primary action button (e.g. "Verify"). */
action?: { label: string; onClick: () => void };
/** A text button instead of the ✕ for dismissing (e.g. "Not now"). */
dismissLabel?: string;
}) {
const [open, setOpen] = useState(false);
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
useEffect(() => setOpen(false), [banner.key]);
const critical = banner.tone === 'Critical';
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: banner.tone })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
gap="100"
role={critical ? 'alert' : 'status'}
aria-live={critical ? 'assertive' : 'polite'}
>
<Box
alignItems="Center"
justifyContent={mobile ? 'Start' : 'Center'}
gap="200"
style={{ minHeight: config.size.X300 }}
>
<Icon size="100" src={icon ?? bannerIcon(banner)} />
<Text size="L400" truncate={mobile && !!banner.detail && !open} style={{ minWidth: 0 }}>
{banner.text}
</Text>
{banner.detail && (
<Button
size="300"
variant={banner.tone}
fill="None"
radii="300"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{ flexShrink: 0 }}
>
<Text size="B300" style={{ textDecoration: 'underline' }}>
{open ? 'Hide' : 'Details'}
</Text>
</Button>
)}
{action && (
<Button
size="300"
variant={banner.tone}
fill="Solid"
radii="300"
onClick={action.onClick}
style={{ flexShrink: 0 }}
>
<Text size="B300">{action.label}</Text>
</Button>
)}
{banner.dismissable && onDismiss && dismissLabel && (
<Button
size="300"
variant={banner.tone}
fill="None"
radii="300"
onClick={onDismiss}
style={{ flexShrink: 0 }}
>
<Text size="B300">{dismissLabel}</Text>
</Button>
)}
{banner.dismissable && onDismiss && !dismissLabel && (
<IconButton
size="300"
variant={banner.tone}
fill="None"
radii="300"
aria-label="Dismiss"
onClick={onDismiss}
style={{ flexShrink: 0 }}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)}
</Box>
{banner.detail && open && (
<Box justifyContent={mobile ? 'Start' : 'Center'}>
<Text size="T200" style={{ maxWidth: 680, whiteSpace: 'pre-line' }}>
{banner.detail}
</Text>
</Box>
)}
</Box>
<Line variant={banner.tone} size="300" />
</Box>
);
}
/** Runs the Kuma poller for the logged-in homeserver and publishes it. */
export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
const { statusPages } = useClientConfig();
const page = useMemo(
() => resolveStatusPage(statusPages, mx.getDomain() ?? undefined),
[statusPages, mx],
);
const [lostAt, setLostAt] = useState<number | null>(null);
useSyncState(
mx,
useCallback((state: SyncState | null) => {
const lost = state === SyncState.Reconnecting || state === SyncState.Error;
setLostAt((prev) => {
if (!lost) return null;
return prev ?? Date.now();
});
}, []),
);
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';
}
+128 -35
View File
@@ -112,8 +112,15 @@ import {
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
import {
describeUpdateError,
isPackageManagedError,
manualDownloadUrl,
packageUpdateHelp,
} from '../../../utils/updateErrors';
import { copyToClipboard } from '../../../utils/dom';
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
import { useKeychainMirrorStatus } from '../../../state/keychainMirror';
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
@@ -238,6 +245,27 @@ function AutostartSetting() {
);
}
// [Gitea #105] Desktop: whether the login is also kept in the OS keychain.
function KeychainMirrorSetting() {
const status = useKeychainMirrorStatus();
if (!isTauriEnv() || status.state === 'idle' || status.state === 'cleared') return null;
let description: string;
if (status.state === 'ok') {
description =
"A copy of your login is kept in the system keychain (Windows Credential Manager). A later update will keep it only there, out of the app's data folder.";
} else if (status.state === 'unsupported') {
description =
"Not available on this system yet. Your login is saved in the app's data folder, as before.";
} else {
description = `Couldn't save a copy to the system keychain (${status.error}). You stay logged in; your login is saved in the app's data folder, as before.`;
}
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Login in the system keychain" description={description} />
</SequenceCard>
);
}
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
@@ -570,6 +598,7 @@ function Appearance() {
<DesktopChromeSetting />
<AutostartSetting />
<KeychainMirrorSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
@@ -2771,12 +2800,76 @@ function updateProgressText(progress: UpdateProgress | undefined): string {
return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`;
}
function AppUpdates() {
const { isTauri, status, check, install } = useTauriUpdater();
if (!isTauri) return null;
/**
* A Linux package install is updated with its package manager (the in-app
* updater can't write to /usr/bin): the command, Copy and a download link.
*/
function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUpdateHelp>> }) {
const [copied, setCopied] = useState<'no' | 'yes' | 'failed'>('no');
const copy = () => {
const text = help.command ?? '';
if (!navigator.clipboard) {
copyToClipboard(text);
setCopied('yes');
return;
}
navigator.clipboard.writeText(text).then(
() => setCopied('yes'),
() => setCopied('failed'),
);
};
let copyLabel = 'Copy command';
if (copied === 'yes') copyLabel = 'Copied';
else if (copied === 'failed') copyLabel = 'Copy failed: select it above';
return (
<Box direction="Column" gap="200">
<Text size="T200">
Lotus Chat was installed as a system package, so update it the same way
{help.command ? ' (your chats and settings are kept):' : '.'}
</Text>
{help.command && (
<Text
size="T200"
style={{ fontFamily: 'monospace', wordBreak: 'break-all', userSelect: 'all' }}
>
{help.command}
</Text>
)}
<Box gap="200" wrap="Wrap">
{help.command && (
<Button size="300" radii="300" variant="Secondary" onClick={copy}>
<Text size="B300">{copyLabel}</Text>
</Button>
)}
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(help.url, '_blank')}
>
<Text size="B300">{help.download}</Text>
</Button>
</Box>
</Box>
);
}
const description =
status.state === 'checking'
function AppUpdates() {
const { isTauri, status, check, install, installKind } = useTauriUpdater();
if (!isTauri) return null;
const packageHelp = packageUpdateHelp(installKind);
const packageUpdate =
!!packageHelp &&
(status.state === 'available' ||
(status.state === 'error' && isPackageManagedError(status.message)));
const description = packageUpdate
? status.state === 'available'
? `Update available: v${status.version}`
: 'An update is available.'
: status.state === 'checking'
? 'Checking for updates...'
: status.state === 'up-to-date'
? 'Lotus Chat is up to date.'
@@ -2793,43 +2886,43 @@ function AppUpdates() {
else check();
};
const after =
status.state === 'available' ? (
<Button size="300" radii="300" onClick={() => install()}>
<Text size="B300">Install &amp; Restart</Text>
const after = packageUpdate ? undefined : status.state === 'available' ? (
<Button size="300" radii="300" onClick={() => install()}>
<Text size="B300">Install &amp; Restart</Text>
</Button>
) : status.state === 'checking' || status.state === 'installing' ? (
<Spinner variant="Secondary" size="200" />
) : status.state === 'error' ? (
<Box gap="200" wrap="Wrap" justifyContent="End">
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
<Text size="B300">Try again</Text>
</Button>
) : status.state === 'checking' || status.state === 'installing' ? (
<Spinner variant="Secondary" size="200" />
) : status.state === 'error' ? (
<Box gap="200" wrap="Wrap" justifyContent="End">
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
<Text size="B300">Try again</Text>
{status.phase !== 'check' && (
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
>
<Text size="B300">Download installer</Text>
</Button>
{status.phase !== 'check' && (
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
>
<Text size="B300">Download installer</Text>
</Button>
)}
</Box>
) : (
<Button size="300" radii="300" variant="Secondary" onClick={check}>
<Text size="B300">Check</Text>
</Button>
);
)}
</Box>
) : (
<Button size="300" radii="300" variant="Secondary" onClick={check}>
<Text size="B300">Check</Text>
</Button>
);
return (
<Box direction="Column" gap="100">
<Text size="L400">App Updates</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Check for Updates" description={description} after={after} />
{status.state === 'error' && (
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
{status.state === 'error' && !packageUpdate && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
Details: {status.message}
</Text>
+16
View File
@@ -26,6 +26,22 @@ export type ClientConfig = {
*/
elementCallUrl?: string;
/**
* [Gitea #43] Desktop only: the loopback origin the desktop app's local
* server also answers on (e.g. "http://127.0.0.1:44548"), to load the
* bundled call page from a different origin than the app
* ("http://localhost:44548"). Set by cinny-desktop together with the server
* and CSP changes it needs; unset keeps the same-origin call page.
*/
desktopCallOrigin?: string;
/**
* [Gitea #124] Uptime Kuma status page per homeserver, for the status banner:
* { "<server name>": { url, slug, groups?: { homeserver, calls, login } } }.
* Homeservers not listed never contact Kuma.
*/
statusPages?: Record<string, unknown>;
/**
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
* The desktop app sets it so it can hand calls it can't make to the browser.
+5 -1
View File
@@ -167,13 +167,17 @@ export function usePresenceUpdater() {
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.)
// [Gitea #339] Synapse treats a missing status_msg as "clear it", so a bare
// { presence: 'offline' } wiped the user's custom status for everyone whenever
// any tab closed or reloaded. Send the current status along with it.
const status = readStatus();
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ presence: 'offline' }),
body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
};
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { kumaUrls, parseKumaStatus, ServerStatus, StatusPageConfig } from '../utils/kumaStatus';
/** Normal poll, and while a problem is showing (so recovery clears quickly). */
export const STATUS_POLL_MS = 5 * 60 * 1000;
export const STATUS_POLL_PROBLEM_MS = 60 * 1000;
const STATUS_FETCH_TIMEOUT_MS = 10 * 1000;
const STATUS_BACKOFF_MAX_MS = 15 * 60 * 1000;
const fetchJson = async (url: string, signal: AbortSignal): Promise<unknown> => {
const res = await fetch(url, { cache: 'no-store', credentials: 'omit', signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
};
export const hasProblem = (s: ServerStatus | null): boolean =>
!!s &&
(s.homeserver === 'down' || s.calls === 'down' || s.login === 'down' || s.maintenance.length > 0);
/**
* [Gitea #124] Poll the homeserver's Kuma status page (none configured → no
* requests, null). Only while the page is visible; every 5 min, every 60 s
* while something is wrong, and at once when `urgent` turns true (this
* client's connection dropped). Any failure → null ("no banner"), with
* backoff: Kuma being unreachable must never look like Matrix being down.
*/
export const useServerStatus = (
page: StatusPageConfig | undefined,
urgent = false,
): ServerStatus | null => {
const [status, setStatus] = useState<ServerStatus | null>(null);
const pageKey = page ? `${page.url}/${page.slug}` : '';
useEffect(() => {
if (!page) {
setStatus(null);
return undefined;
}
const urls = kumaUrls(page);
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let failures = 0;
let stopped = false;
let last: ServerStatus | null = null;
const schedule = (ms: number) => {
if (timer) clearTimeout(timer);
timer = setTimeout(poll, ms);
};
async function poll() {
if (stopped) return;
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
controller?.abort();
controller = new AbortController();
const abort = controller;
const timeout = setTimeout(() => abort.abort(), STATUS_FETCH_TIMEOUT_MS);
try {
const [pageJson, heartbeatJson] = await Promise.all([
fetchJson(urls.page, abort.signal),
fetchJson(urls.heartbeat, abort.signal),
]);
if (stopped) return;
failures = 0;
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
setStatus(last);
// While this client's connection is lost, keep asking every minute: that
// is how it learns Kuma has checked the server since the drop.
schedule(hasProblem(last) || urgent ? STATUS_POLL_PROBLEM_MS : STATUS_POLL_MS);
} catch {
if (stopped) return;
failures += 1;
last = null;
setStatus(null);
schedule(Math.min(STATUS_POLL_PROBLEM_MS * 2 ** failures, STATUS_BACKOFF_MAX_MS));
} finally {
clearTimeout(timeout);
}
}
const onVisible = () => {
if (document.visibilityState === 'visible') poll();
};
document.addEventListener('visibilitychange', onVisible);
poll();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
controller?.abort();
document.removeEventListener('visibilitychange', onVisible);
};
// pageKey identifies the page; the object itself is rebuilt from config.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageKey, urgent]);
return status;
};
+18 -3
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useTauriEvent } from './useTauri';
import { UpdatePhase, parseUpdateError } from '../utils/updateErrors';
import { InstallKind, UpdatePhase, parseUpdateError, toInstallKind } from '../utils/updateErrors';
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
@@ -36,6 +36,11 @@ export type UpdateFailure = { phase: UpdatePhase; message: string };
// mid-request by the resolve/reject below, so nothing gets stuck.
const updateStatusAtom = atom<UpdateStatus>({ state: 'idle' });
// How this install gets updated; asked once. A desktop build without the
// command (older than it) rejects the call: keep the old in-app behaviour.
const installKindAtom = atom<InstallKind | undefined>(undefined);
let installKindRequested = false;
/**
* Mirror native download progress into the status. Mounted once (in
* TauriUpdateFeature) so the listener isn't duplicated per consumer.
@@ -50,6 +55,16 @@ export function useTauriUpdateProgress(): void {
export function useTauriUpdater() {
const isTauri = !!tauriInvoke();
const [status, setStatus] = useAtom(updateStatusAtom);
const [installKind, setInstallKind] = useAtom(installKindAtom);
useEffect(() => {
const invoke = tauriInvoke();
if (!invoke || installKindRequested) return;
installKindRequested = true;
invoke('update_install_kind')
.then((kind) => setInstallKind(toInstallKind(kind)))
.catch(() => setInstallKind('in-app'));
}, [setInstallKind]);
const check = useCallback(async () => {
const invoke = tauriInvoke();
@@ -89,5 +104,5 @@ export function useTauriUpdater() {
}
}, [setStatus]);
return { isTauri, status, check, install };
return { isTauri, status, check, install, installKind: installKind ?? 'in-app' };
}
+7 -4
View File
@@ -5,6 +5,7 @@ import { StateEvent } from '../../types/matrix/room';
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
import { useCallMembers, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient';
import { voiceChannelOccupancy } from '../utils/voiceChannelFull';
export type VoiceChannelFull = {
channelFull: boolean;
@@ -26,9 +27,11 @@ export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
return useMemo(() => {
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
return { channelFull, current: callMembers.length, max: maxUsers };
// Distinct people, not per-device memberships (Gitea #342).
return voiceChannelOccupancy(
callMembers.map((m) => m.sender),
mx.getSafeUserId(),
maxUsers,
);
}, [limitEvent, callMembers, mx]);
};
+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>
);
}
+60 -16
View File
@@ -69,6 +69,7 @@ import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { isNetworkUpdateError } from '../../utils/updateErrors';
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
@@ -782,15 +783,20 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map via refs so the poll interval below is
// created once — not torn down and restarted (which resets its 30s countdown
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
// Read the latest reminders / DM map / notification prefs via refs so the poll
// interval below is created once — not torn down and restarted (which resets
// its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
const remindersRef = useRef(reminders);
remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => {
const check = () => {
@@ -814,6 +820,23 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
// [Gitea #338] A reminder is only done once the user has SEEN it: the
// toast stays until clicked or dismissed (it used to vanish after 4 s
// and the reminder was deleted at once, so one due while the user was
// away was lost). The removal is persisted on that click/dismiss; an
// unseen reminder comes back on the next start.
const done = () => {
if (removingRef.current.has(key)) return;
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
};
const open = () => {
done();
// window.location.hash setter auto-prepends '#' (as the toast does)
window.location.hash = hashPath;
};
setToast({
id: `reminder-${key}`,
displayName: 'Reminder',
@@ -821,16 +844,30 @@ function ReminderMonitor() {
roomName,
roomId: r.roomId,
hashPath,
sticky: true,
onClick: open,
onDismiss: done,
});
}
// Persist the removal, retrying on a later tick if it fails — without
// re-showing the toast (N114). The server echo drops it from
// `reminders` once the write lands.
if (!removingRef.current.has(key)) {
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
// Away from the window: also raise an OS notification (the reminder is
// an explicit request, but quiet hours / notifications-off win). Never
// put decrypted E2EE text in it (N106).
if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
const encrypted = !!room?.hasEncryptionStateEvent();
showOsNotification(
'Reminder',
{
icon: LogoSVG,
badge: LogoSVG,
body: encrypted ? `A message in ${roomName}` : body,
tag: `reminder-${key}`,
data: { path: hashPath, roomId: r.roomId },
},
() => {
window.focus();
open();
},
).catch(() => undefined);
}
}
});
};
@@ -913,7 +950,8 @@ const UPDATE_PROGRESS_TOAST = 'tauri-update-progress';
const UPDATE_FAILED_TOAST = 'tauri-update-failed';
function TauriUpdateFeature() {
const { isTauri, status, check, install } = useTauriUpdater();
const { isTauri, status, check, install, installKind } = useTauriUpdater();
const requestSettings = useSetAtom(settingsRequestAtom);
useTauriUpdateProgress();
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
@@ -972,18 +1010,24 @@ function TauriUpdateFeature() {
if (status.state !== 'available') return;
if (firedRef.current === status.version) return;
firedRef.current = status.version;
// A Linux package install can't be updated in place: point at the
// package-manager command in Settings instead of an install that fails.
const viaPackage = installKind !== 'in-app';
setToast({
id: `tauri-update-${status.version}`,
displayName: '⬆ Update Available',
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
body: viaPackage
? `Lotus Chat ${status.version} is available. Click for the command to update it with your package manager.`
: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
roomName: 'System',
roomId: '',
onClick: () => {
installFromToast();
if (viaPackage) requestSettings('general');
else installFromToast();
},
sticky: true,
});
}, [status, setToast, installFromToast]);
}, [status, setToast, installFromToast, installKind, requestSettings]);
// [cinny-desktop #6] Mirror a pending update into the tray ("Restart to
// update" + tooltip) so a dismissed toast isn't the only reminder. Kept while
+9
View File
@@ -44,6 +44,11 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { ClockSkewBanner } from './ClockSkewBanner';
import {
ServerStatusBanner,
ServerStatusFeature,
} from '../../features/server-status/ServerStatusBanner';
import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
@@ -245,6 +250,8 @@ export function ClientRoot({ children }: ClientRootProps) {
return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && <ServerStatusFeature mx={mx} />}
{mx && !syncError && <ServerStatusBanner />}
{mx && !syncError && <SyncStatus mx={mx} />}
{mx && !syncError && <ClockSkewBanner />}
{loading && <ClientRootOptions mx={mx} />}
@@ -307,6 +314,8 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading />
) : (
<MatrixClientProvider value={mx}>
{/* [Gitea #110/#123] Needs the client context (its hooks read it). */}
{!syncError && <SecurityBanner />}
<ServerConfigsLoader>
{(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
+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>
+42 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveCallPageUrl } from './callPageUrl';
import { resolveCallPageUrl, resolveDesktopCallPageUrl } from './callPageUrl';
const URL_OK = 'https://call.chat.example.org/public/element-call/index.html';
@@ -38,3 +38,44 @@ test('anything else falls back to the bundled page', () => {
'data:text/html,x',
].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v)));
});
const APP = 'http://localhost:44548';
const PAGE = '/public/element-call/index.html';
test('desktop: the bundled page from the loopback origin on the same port', () => {
assert.equal(
resolveDesktopCallPageUrl('http://127.0.0.1:44548', APP, '/'),
`http://127.0.0.1:44548${PAGE}`,
);
assert.equal(
resolveDesktopCallPageUrl('http://127.0.0.1:44548/', APP, '/app/'),
`http://127.0.0.1:44548/app${PAGE}`,
);
});
test('desktop: unset or anything but same-port loopback http keeps the same-origin page', () => {
[
undefined,
'',
'http://127.0.0.1:44549',
'http://127.0.0.1',
'https://127.0.0.1:44548',
'http://localhost:44548',
'http://[::1]:44548',
'http://10.0.0.5:44548',
'https://call.chat.lotusguild.org',
'http://127.0.0.1:44548/evil/',
'http://127.0.0.1:44548/?x=1',
'http://user:pw@127.0.0.1:44548',
'not a url',
42,
].forEach((v) => assert.equal(resolveDesktopCallPageUrl(v, APP, '/'), undefined, String(v)));
});
test('desktop: only when the app itself runs on http://localhost (release builds)', () => {
const v = 'http://127.0.0.1:44548';
assert.equal(resolveDesktopCallPageUrl(v, 'tauri://localhost', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'http://tauri.localhost', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'https://chat.lotusguild.org', '/'), undefined);
assert.equal(resolveDesktopCallPageUrl(v, 'http://localhost', '/'), undefined);
});
+37 -3
View File
@@ -7,9 +7,9 @@
* app loads it from that origin instead, so the call frame can no longer
* reach this origin's storage (login token, crypto store) or service worker.
*
* Web only: the desktop app keeps its bundled copy (its CSP doesn't allow
* another frame origin, and a network copy could drift from the bundle).
* Anything that isn't an absolute https URL (http only on localhost, for
* Web only: the desktop app keeps its bundled copy (a network copy could
* drift from the bundle); see resolveDesktopCallPageUrl for how it isolates
* it. Anything that isn't an absolute https URL (http only on localhost, for
* development) is ignored, so a bad value falls
* back to the bundled page instead of breaking calls.
*/
@@ -28,6 +28,40 @@ export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | u
}
};
/**
* [Gitea #43] Desktop: the bundled call page from a second origin.
*
* The desktop app is served by its local server at http://localhost:<port>.
* The same server answers on http://127.0.0.1:<port>, which is a different
* origin (and still a secure context), so loading the bundled call page from
* there cuts the call frame off from the app's storage (login token, crypto
* store) without a network copy that could drift from the bundle.
*
* Only used when cinny-desktop sets `desktopCallOrigin` (it ships the server
* and CSP changes this needs in the same release), only for a loopback http
* origin on the SAME port as the app, and only when the app itself runs on
* http://localhost (release builds). Anything else keeps the same-origin page.
*/
export const resolveDesktopCallPageUrl = (
value: unknown,
appOrigin: string,
basePath: string,
): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
try {
const app = new URL(appOrigin);
const call = new URL(value);
if (app.protocol !== 'http:' || app.hostname !== 'localhost' || !app.port) return undefined;
if (call.protocol !== 'http:' || call.hostname !== '127.0.0.1') return undefined;
if (call.port !== app.port || call.username || call.password) return undefined;
if (call.pathname !== '/' || call.search || call.hash) return undefined;
const base = basePath.replace(/\/+$/, '');
return `${call.origin}${base}/public/element-call/index.html`;
} catch {
return undefined;
}
};
let callPageUrl: string | undefined;
export const setCallPageUrl = (url: string | undefined): void => {
+117
View File
@@ -0,0 +1,117 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { KeychainInvoke, sameTokens, syncKeychain, tokensOf } from './keychainMirror';
import type { Session } from './sessions';
const session: Session = {
baseUrl: 'https://matrix.example.org',
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_token',
refreshToken: 'mar_refresh',
};
/** An in-memory keychain behind the same commands the desktop app exposes. */
const fakeKeychain = (opts: { supported?: boolean; failSet?: boolean; corrupt?: boolean } = {}) => {
let stored: unknown = null;
const calls: string[] = [];
const invoke: KeychainInvoke = async (cmd, args) => {
calls.push(cmd);
switch (cmd) {
case 'secure_session_supported':
return opts.supported ?? true;
case 'secure_session_get':
return stored;
case 'secure_session_set':
if (opts.failSet) throw new Error('Access is denied.');
stored = opts.corrupt ? { ...(args?.tokens as object), accessToken: 'x' } : args?.tokens;
return null;
case 'secure_session_clear':
stored = null;
return null;
default:
throw new Error(`unknown ${cmd}`);
}
};
return { invoke, calls, get: () => stored };
};
test('stores the tokens (not the whole session) and verifies them', async () => {
const kc = fakeKeychain();
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
assert.deepEqual(kc.get(), {
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_token',
refreshToken: 'mar_refresh',
});
assert.deepEqual(kc.calls, [
'secure_session_supported',
'secure_session_get',
'secure_session_set',
'secure_session_get',
]);
});
test('an up-to-date copy is not rewritten', async () => {
const kc = fakeKeychain();
await syncKeychain(kc.invoke, session);
kc.calls.length = 0;
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
assert.deepEqual(kc.calls, ['secure_session_supported', 'secure_session_get']);
});
test('a token rotation rewrites; no session clears', async () => {
const kc = fakeKeychain();
await syncKeychain(kc.invoke, session);
await syncKeychain(kc.invoke, { ...session, accessToken: 'syt_new', refreshToken: undefined });
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
assert.equal('refreshToken' in (kc.get() as object), false);
assert.deepEqual(await syncKeychain(kc.invoke, null), { state: 'cleared' });
assert.equal(kc.get(), null);
});
test('unsupported platform: nothing is touched', async () => {
const kc = fakeKeychain({ supported: false });
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'unsupported' });
assert.deepEqual(kc.calls, ['secure_session_supported']);
});
test('failures become a status, never an exception', async () => {
assert.deepEqual(await syncKeychain(fakeKeychain({ failSet: true }).invoke, session), {
state: 'error',
error: 'Access is denied.',
});
assert.deepEqual(await syncKeychain(fakeKeychain({ corrupt: true }).invoke, session), {
state: 'error',
error: 'read-back did not match',
});
// The credential store hangs (the support check itself is instant).
const hung: KeychainInvoke = async (cmd) =>
cmd === 'secure_session_supported'
? true
: new Promise(() => {
/* never settles */
});
assert.deepEqual(await syncKeychain(hung, session, 50), {
state: 'error',
error: 'keychain timed out',
});
});
test('a desktop build without the commands reads as unsupported, not an error', async () => {
const missingCommand: KeychainInvoke = async (cmd) => {
throw new Error(`Command ${cmd} not found`);
};
assert.deepEqual(await syncKeychain(missingCommand, session), { state: 'unsupported' });
});
test('sameTokens compares only the secrets, treating a missing refresh token as absent', () => {
const t = tokensOf({ ...session, refreshToken: undefined });
assert.equal(sameTokens({ ...t }, t), true);
assert.equal(sameTokens({ ...t, refreshToken: undefined }, t), true);
assert.equal(sameTokens({ ...t, accessToken: 'other' }, t), false);
assert.equal(sameTokens({ ...t, refreshToken: 'r' }, t), false);
assert.equal(sameTokens(null, t), false);
assert.equal(sameTokens('string', t), false);
});
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
/**
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
*
* The session is still read from localStorage exactly as before; this only
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
* reading it back, so real installs prove the keychain works before step 2
* switches reads over to it. Nothing here can log anyone out: every failure
* just records a status for Settings.
*
* Writes are serialized (a token rotation right after login must not race
* the login's write) and time out, so a hung credential store can't pile up.
* When there's no session the keychain entry is cleared, which also covers a
* logout whose page reload beat the clear.
*/
export type KeychainTokens = {
userId: string;
deviceId: string;
accessToken: string;
refreshToken?: string;
};
export type KeychainMirrorStatus =
| { state: 'idle' }
| { state: 'unsupported' }
| { state: 'ok' }
| { state: 'cleared' }
| { state: 'error'; error: string };
export type KeychainInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
export const KEYCHAIN_TIMEOUT_MS = 5000;
export const tokensOf = (session: Session): KeychainTokens => ({
userId: session.userId,
deviceId: session.deviceId,
accessToken: session.accessToken,
...(session.refreshToken ? { refreshToken: session.refreshToken } : {}),
});
export const sameTokens = (a: unknown, b: KeychainTokens): boolean => {
if (!a || typeof a !== 'object') return false;
const t = a as Partial<KeychainTokens>;
return (
t.userId === b.userId &&
t.deviceId === b.deviceId &&
t.accessToken === b.accessToken &&
(t.refreshToken ?? undefined) === (b.refreshToken ?? undefined)
);
};
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
const errorText = (e: unknown): string =>
(e instanceof Error ? e.message : String(e)).slice(0, 200);
/**
* Bring the keychain in line with `session` (null = no session). Reads first
* and only writes when the stored copy differs, then verifies by reading back.
*/
export const syncKeychain = async (
invoke: KeychainInvoke,
session: Session | null,
timeoutMs = KEYCHAIN_TIMEOUT_MS,
): Promise<KeychainMirrorStatus> => {
// A desktop build without the commands (older than this feature) rejects
// the call: that is "not supported", not an error to show the user.
let supported: unknown;
try {
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
} catch {
supported = false;
}
if (supported !== true) return { state: 'unsupported' };
try {
if (!session) {
await withTimeout(invoke('secure_session_clear'), timeoutMs);
return { state: 'cleared' };
}
const tokens = tokensOf(session);
const stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
if (sameTokens(stored, tokens)) return { state: 'ok' };
await withTimeout(invoke('secure_session_set', { tokens }), timeoutMs);
const back = await withTimeout(invoke('secure_session_get'), timeoutMs);
if (!sameTokens(back, tokens)) return { state: 'error', error: 'read-back did not match' };
return { state: 'ok' };
} catch (e) {
return { state: 'error', error: errorText(e) };
}
};
let status: KeychainMirrorStatus = { state: 'idle' };
const statusListeners = new Set<(s: KeychainMirrorStatus) => void>();
const setStatus = (next: KeychainMirrorStatus): void => {
status = next;
statusListeners.forEach((cb) => cb(next));
};
export const getKeychainMirrorStatus = (): KeychainMirrorStatus => status;
let started = false;
/** Start mirroring (desktop only; call once at boot). */
export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void => {
if (!invoke || started) return;
started = true;
let chain: Promise<unknown> = Promise.resolve();
const enqueue = (session: Session | null) => {
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
};
enqueue(getFallbackSession() ?? null);
onSessionPersisted((session) => enqueue(session));
};
export const useKeychainMirrorStatus = (): KeychainMirrorStatus => {
const [value, setValue] = useState(status);
useEffect(() => {
setValue(status);
statusListeners.add(setValue);
return () => {
statusListeners.delete(setValue);
};
}, []);
return value;
};
+6 -1
View File
@@ -8,6 +8,7 @@ import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
import { clearCallSession } from '../utils/callRejoin';
import { clearOutbox } from '../utils/outbox';
import { NUDGE_KEY_PREFIX } from '../utils/securityNudge';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -77,7 +78,11 @@ const clearStatusMessage = (): void => {
return;
}
keys.forEach((key) => {
if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) {
if (
key.startsWith('lotus-status-msg-') ||
key.startsWith('lotus-status-expiry-') ||
key.startsWith(NUDGE_KEY_PREFIX)
) {
try {
localStorage.removeItem(key);
} catch {
+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
+30
View File
@@ -0,0 +1,30 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildGeoUri, formatAccuracy } from './geoShare';
import { parseGeoUri } from './common';
test('buildGeoUri: exact share keeps 6 decimals and adds the accuracy as u=', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12.4), 'geo:40.748817,-73.985428;u=12');
assert.equal(buildGeoUri(40.748817, -73.985428), 'geo:40.748817,-73.985428');
assert.equal(buildGeoUri(40.748817, -73.985428, Number.NaN), 'geo:40.748817,-73.985428');
});
test('buildGeoUri: approximate share rounds to ~1 km and never claims better than 1 km', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12, true), 'geo:40.75,-73.99;u=1000');
assert.equal(buildGeoUri(40.748817, -73.985428, 3500, true), 'geo:40.75,-73.99;u=3500');
});
test('buildGeoUri output still parses with the renderer', () => {
const loc = parseGeoUri(buildGeoUri(51.5, -0.12, 30));
assert.ok(loc);
assert.equal(parseFloat(loc.latitude), 51.5);
assert.equal(parseFloat(loc.longitude), -0.12);
});
test('formatAccuracy', () => {
assert.equal(formatAccuracy(12.4), '±12 m');
assert.equal(formatAccuracy(3500), '±3.5 km');
assert.equal(formatAccuracy(25000), '±25 km');
assert.equal(formatAccuracy(undefined), undefined);
assert.equal(formatAccuracy(0), undefined);
});
+39
View File
@@ -0,0 +1,39 @@
/**
* [Gitea #340] Helpers for the "Share location" confirm step: the geo URI to
* send (RFC 5870, with the fix's accuracy as `u=` so receivers can draw a
* radius) and a human accuracy label, so a ±3 km Wi-Fi/IP fix isn't presented
* — or sent — as if it were exact.
*/
/** Decimals kept for an "approximate" share: 2 → ~1.1 km of latitude. */
export const APPROXIMATE_DECIMALS = 2;
/** Uncertainty reported for an approximate share (half a 0.01° cell, rounded up). */
export const APPROXIMATE_UNCERTAINTY_M = 1000;
const validAccuracy = (accuracy?: number): number | undefined =>
typeof accuracy === 'number' && Number.isFinite(accuracy) && accuracy > 0
? Math.round(accuracy)
: undefined;
export const buildGeoUri = (
latitude: number,
longitude: number,
accuracy?: number,
approximate = false,
): string => {
const decimals = approximate ? APPROXIMATE_DECIMALS : 6;
const u = approximate
? Math.max(APPROXIMATE_UNCERTAINTY_M, validAccuracy(accuracy) ?? 0)
: validAccuracy(accuracy);
const base = `geo:${latitude.toFixed(decimals)},${longitude.toFixed(decimals)}`;
return u ? `${base};u=${u}` : base;
};
/** "±12 m", "±3.5 km", or undefined when the browser gave no accuracy. */
export const formatAccuracy = (accuracy?: number): string | undefined => {
const m = validAccuracy(accuracy);
if (m === undefined) return undefined;
if (m < 1000) return `±${m} m`;
const km = m / 1000;
return `±${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
};
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gifPreviewBox } from './gifPreviewSize';
test('gifPreviewBox: a landscape GIF wider than the card fills its width', () => {
assert.deepEqual(gifPreviewBox(480, 270), { width: 400, height: 225 });
assert.deepEqual(gifPreviewBox(480, 200), { width: 400, height: 167 });
});
test('gifPreviewBox: square and portrait GIFs keep their shape instead of being cropped', () => {
assert.deepEqual(gifPreviewBox(300, 300), { width: 320, height: 320 });
assert.deepEqual(gifPreviewBox(480, 480), { width: 320, height: 320 });
assert.deepEqual(gifPreviewBox(240, 480), { width: 160, height: 320 });
});
test('gifPreviewBox: a small GIF is enlarged at most 2×', () => {
assert.deepEqual(gifPreviewBox(100, 80), { width: 200, height: 160 });
assert.deepEqual(gifPreviewBox(150, 150), { width: 300, height: 300 });
});
test('gifPreviewBox: extreme aspect ratios stay inside the box and never collapse to 0', () => {
assert.deepEqual(gifPreviewBox(2000, 50), { width: 400, height: 10 });
assert.deepEqual(gifPreviewBox(1, 10000), { width: 1, height: 320 });
});
test('gifPreviewBox: accepts numeric strings and rejects unknown sizes', () => {
assert.deepEqual(gifPreviewBox('480', '270'), { width: 400, height: 225 });
assert.equal(gifPreviewBox(undefined, 270), undefined);
assert.equal(gifPreviewBox(480, 0), undefined);
assert.equal(gifPreviewBox('wide', 270), undefined);
assert.equal(gifPreviewBox(Number.NaN, 270), undefined);
});
test('gifPreviewBox: custom limits', () => {
assert.deepEqual(gifPreviewBox(480, 270, 200, 200, 1), { width: 200, height: 113 });
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Display size for a Giphy / Tenor link-preview GIF. The card used to force
* every GIF into a full-width × 200px band with `object-fit: cover`, so square
* and portrait GIFs lost most of their height and small ones were blown up.
* Fit the GIF inside `maxWidth × maxHeight` keeping its aspect ratio, and
* enlarge a small GIF at most `maxUpscale`× so it stays sharp.
*/
/** The GIF card's width (UrlPreview is 25rem); a phone shrinks it further via CSS. */
export const GIF_PREVIEW_MAX_WIDTH = 400;
export const GIF_PREVIEW_MAX_HEIGHT = 320;
export const GIF_PREVIEW_MAX_UPSCALE = 2;
export type GifPreviewBox = { width: number; height: number };
// og:image:width / og:image:height are numbers from Synapse, but some sites
// publish them as strings; anything else (missing, 0, NaN) means "unknown".
const dimension = (value: unknown): number | undefined => {
const n = typeof value === 'string' ? Number(value) : value;
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
};
/** The box to render the GIF in, or `undefined` when its size isn't known. */
export const gifPreviewBox = (
rawWidth: unknown,
rawHeight: unknown,
maxWidth = GIF_PREVIEW_MAX_WIDTH,
maxHeight = GIF_PREVIEW_MAX_HEIGHT,
maxUpscale = GIF_PREVIEW_MAX_UPSCALE,
): GifPreviewBox | undefined => {
const width = dimension(rawWidth);
const height = dimension(rawHeight);
if (!width || !height) return undefined;
const scale = Math.min(maxWidth / width, maxHeight / height, maxUpscale);
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
};
+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)));
};
+81
View File
@@ -0,0 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
dismissNudge,
NUDGE_GRACE_MS,
NUDGE_SNOOZE_MS,
nudgeFor,
parseNudgeRecord,
SecurityState,
shouldShowNudge,
} from './securityNudge';
const healthy: SecurityState = {
crossSigning: true,
deviceVerified: true,
backupOnServer: true,
backupActive: true,
};
test('the #123 table: which nudge for which state', () => {
// E: healthy → nothing, ever.
assert.equal(nudgeFor(healthy), undefined);
// B: cross-signing, this device unverified, backup on server → verify first.
assert.equal(nudgeFor({ ...healthy, deviceVerified: false }), 'verify-device');
// C: cross-signing, unverified, no backup → still verify first.
assert.equal(
nudgeFor({ ...healthy, deviceVerified: false, backupOnServer: false }),
'verify-device',
);
// D: verified, no backup → #110's set-up nudge.
assert.equal(nudgeFor({ ...healthy, backupOnServer: false }), 'setup-backup');
// #110 state 2: backup exists, this device not using it.
assert.equal(nudgeFor({ ...healthy, backupActive: false }), 'connect-backup');
// A: never set up cross-signing, no backup → set up (the flow does both).
assert.equal(
nudgeFor({
crossSigning: false,
deviceVerified: false,
backupOnServer: false,
backupActive: false,
}),
'setup-backup',
);
});
test('nothing while crypto is still loading (F)', () => {
assert.equal(nudgeFor({ ...healthy, deviceVerified: undefined }), undefined);
assert.equal(nudgeFor({ ...healthy, backupOnServer: undefined }), undefined);
assert.equal(nudgeFor({ ...healthy, backupActive: undefined }), undefined);
// Unknown backup connection doesn't hide a known "no backup at all".
assert.equal(
nudgeFor({ ...healthy, backupOnServer: false, backupActive: undefined }),
'setup-backup',
);
});
test('timing: 24 h grace, 7-day snooze, stop after 3 dismissals', () => {
const t0 = 1_700_000_000_000;
let record = parseNudgeRecord(null, t0);
assert.equal(shouldShowNudge('verify-device', record, t0), false, 'first launch');
assert.equal(shouldShowNudge('verify-device', record, t0 + NUDGE_GRACE_MS - 1), false);
let now = t0 + NUDGE_GRACE_MS;
assert.equal(shouldShowNudge('verify-device', record, now), true, 'after 24 h');
for (let i = 1; i <= 3; i += 1) {
record = dismissNudge('verify-device', record, now);
assert.equal(shouldShowNudge('verify-device', record, now), false, `snoozed after #${i}`);
now += NUDGE_SNOOZE_MS;
assert.equal(shouldShowNudge('verify-device', record, now), i < 3, `after 7 days (#${i})`);
}
// Dismissing one nudge doesn't hide another.
assert.equal(shouldShowNudge('setup-backup', record, now), true);
});
test('stored record: round trip, and garbage starts a fresh grace period', () => {
const t0 = 1_700_000_000_000;
const r = dismissNudge('setup-backup', parseNudgeRecord(null, t0 - 5), t0);
assert.deepEqual(parseNudgeRecord(JSON.stringify(r), t0 + 1), r);
for (const bad of ['{', 'null', '"x"', '{"firstSeen":"yesterday"}', '{"firstSeen":1}']) {
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
}
});
+94
View File
@@ -0,0 +1,94 @@
/**
* [Gitea #110, #123] One "security" nudge for this device, in priority order:
*
* - verify-device: the account has cross-signing but THIS device isn't signed.
* It can't unlock backed-up history and shows as untrusted to others.
* Verifying with the recovery key also connects the backup, so it comes first.
* - connect-backup: a key backup exists on the server but this device isn't
* backing up to it (`getActiveSessionBackupVersion()` null).
* - setup-backup: no key backup at all (includes accounts that never set up
* cross-signing: the setup flow does both).
*
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
* dismissals of a nudge stop it for good; a healthy device never sees one.
*/
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
export type SecurityState = {
/** m.cross_signing.master account data present. */
crossSigning: boolean;
/** This device cross-signing-verified; undefined while crypto isn't ready. */
deviceVerified: boolean | undefined;
/** A key backup version on the server; undefined while loading. */
backupOnServer: boolean | undefined;
/** This device is backing up to it; undefined while loading. */
backupActive: boolean | undefined;
};
export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
if (s.crossSigning) {
if (s.deviceVerified === undefined) return undefined;
if (!s.deviceVerified) return 'verify-device';
}
if (s.backupOnServer === undefined) return undefined;
if (!s.backupOnServer) return 'setup-backup';
if (s.backupActive === undefined) return undefined;
return s.backupActive ? undefined : 'connect-backup';
};
export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000;
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
export const NUDGE_MAX_DISMISSALS = 3;
export type NudgeRecord = {
/** When this device was first seen by the nudge (ms). */
firstSeen: number;
dismissals: Partial<Record<NudgeKind, { count: number; last: number }>>;
};
export const shouldShowNudge = (kind: NudgeKind, record: NudgeRecord, now: number): boolean => {
if (now - record.firstSeen < NUDGE_GRACE_MS) return false;
const d = record.dismissals[kind];
if (!d) return true;
return d.count < NUDGE_MAX_DISMISSALS && now - d.last >= NUDGE_SNOOZE_MS;
};
export const dismissNudge = (kind: NudgeKind, record: NudgeRecord, now: number): NudgeRecord => ({
...record,
dismissals: {
...record.dismissals,
[kind]: { count: (record.dismissals[kind]?.count ?? 0) + 1, last: now },
},
});
export const NUDGE_KEY_PREFIX = 'lotus-security-nudge-';
/** Parse a stored record; anything unexpected starts a fresh one (grace period from now). */
export const parseNudgeRecord = (raw: string | null, now: number): NudgeRecord => {
try {
const v = JSON.parse(raw ?? '');
if (v && typeof v.firstSeen === 'number' && v.dismissals && typeof v.dismissals === 'object') {
return { firstSeen: v.firstSeen, dismissals: v.dismissals };
}
} catch {
/* fresh record below */
}
return { firstSeen: now, dismissals: {} };
};
export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
'verify-device': {
text: 'Verify this device so you can read your older encrypted messages and others see it as trusted.',
action: 'Verify',
},
'connect-backup': {
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
action: 'Connect',
},
'setup-backup': {
text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.",
action: 'Set up',
},
};
+33
View File
@@ -55,3 +55,36 @@ test('manual download link: Windows gets the installer, others the release page'
);
assert.equal(manualDownloadUrl('Mozilla/5.0 (X11; Linux x86_64)'), MANUAL_DOWNLOAD_URL.other);
});
test('install kinds: anything unknown keeps the in-app updater', async () => {
const { toInstallKind } = await import('./updateErrors');
assert.equal(toInstallKind('pacman'), 'pacman');
assert.equal(toInstallKind('deb'), 'deb');
assert.equal(toInstallKind('manual'), 'manual');
assert.equal(toInstallKind('in-app'), 'in-app');
assert.equal(toInstallKind(undefined), 'in-app', 'older desktop build without the command');
assert.equal(toInstallKind('rpm'), 'in-app');
});
test('package installs get their package manager’s command, not an Install button', async () => {
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
const pacman = packageUpdateHelp('pacman')!;
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
assert.equal(
pacman.command,
'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst',
);
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
const deb = packageUpdateHelp('deb')!;
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
assert.equal(packageUpdateHelp('manual')?.command, undefined);
assert.equal(packageUpdateHelp('in-app'), undefined);
assert.equal(
isPackageManagedError('package-managed (pacman): update Lotus Chat with your package manager'),
true,
);
assert.equal(
isPackageManagedError('Permission denied (os error 13) at path "/usr/bin/tauri_current_app"'),
false,
);
});
+43
View File
@@ -47,3 +47,46 @@ export const describeUpdateError = (phase: UpdatePhase, message: string): string
}
return 'The update downloaded but couldn’t be installed. Download the installer yourself and run it; your chats and settings are kept.';
};
/**
* How this desktop install gets updated (cinny-desktop `update_install_kind`).
* Linux package installs can't be replaced in place by the in-app updater
* (it tried to write into /usr/bin: "Permission denied (os error 13)"), so
* they get their package manager's command instead of an Install button.
*/
export type InstallKind = 'in-app' | 'pacman' | 'deb' | 'manual';
const RELEASE_DOWNLOAD =
'https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest';
export const toInstallKind = (value: unknown): InstallKind =>
value === 'pacman' || value === 'deb' || value === 'manual' ? value : 'in-app';
export type PackageUpdateHelp = { command?: string; url: string; download: string };
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
if (kind === 'pacman') {
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`;
// Download first, then install the local file: `pacman -U <url>` also
// fetches `<url>.sig` and fails without it (we don't sign packages),
// while a local file falls under LocalFileSigLevel (signature optional).
return {
command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`,
url,
download: 'Download package',
};
}
if (kind === 'deb') {
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`;
return {
command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`,
url,
download: 'Download package',
};
}
if (kind === 'manual') return { url: MANUAL_DOWNLOAD_URL.other, download: 'Open downloads' };
return undefined;
};
/** The native side refuses an in-app install on a package install. */
export const isPackageManagedError = (message: string): boolean => /package-managed/.test(message);
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { voiceChannelOccupancy } from './voiceChannelFull';
test('voiceChannelOccupancy: one user on two devices fills one slot, not two', () => {
assert.deepEqual(voiceChannelOccupancy(['@a:x', '@a:x'], '@b:x', 2), {
channelFull: false,
current: 1,
max: 2,
});
});
test('voiceChannelOccupancy: full when distinct users reach the limit', () => {
assert.deepEqual(voiceChannelOccupancy(['@a:x', '@c:x', '@a:x'], '@b:x', 2), {
channelFull: true,
current: 2,
max: 2,
});
});
test('voiceChannelOccupancy: someone already in the call can always join another device', () => {
assert.equal(voiceChannelOccupancy(['@a:x', '@b:x'], '@b:x', 2).channelFull, false);
});
test('voiceChannelOccupancy: no limit (0) is never full; unknown senders are ignored', () => {
assert.equal(voiceChannelOccupancy(['@a:x', '@c:x'], '@b:x', 0).channelFull, false);
assert.equal(voiceChannelOccupancy([undefined, '@a:x'], '@b:x', 2).current, 1);
});
+25
View File
@@ -0,0 +1,25 @@
/**
* [Gitea #342] Whether a voice channel with an `io.lotus.voice_limit` is full.
* Counts distinct PEOPLE, not call memberships: a MatrixRTC membership is per
* device, so one user on desktop + phone (or with a crashed tab's membership
* not yet expired) used to fill two slots. This matches the server-side guard
* (matrix `voice-limit-guard.py`, which reduces LiveKit identities to users).
*/
export type VoiceChannelOccupancy = {
channelFull: boolean;
/** Distinct users in the call. */
current: number;
max: number;
};
export const voiceChannelOccupancy = (
memberSenders: Array<string | undefined>,
myUserId: string,
maxUsers: number,
): VoiceChannelOccupancy => {
const users = new Set(memberSenders.filter((s): s is string => !!s));
// A user already in the call (another device, or rejoining) is never blocked.
const alreadyMember = users.has(myUserId);
const channelFull = maxUsers > 0 && !alreadyMember && users.size >= maxUsers;
return { channelFull, current: users.size, max: maxUsers };
};
+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