Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 a7495fc509 chore(deps): sanitize-html 2.18.1, i18next-http-backend 4.0.3 [security]
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m10s
Matches upstream Cinny 4.12.8. npm audit flagged our sanitize-html
2.17.7 with three advisories, two of them XSS bypasses (SMIL animation
namespace-prefix javascript: URIs; meta refresh javascript:/data: URLs;
noscript content rendering after an implied close). sanitize-html cleans
formatted message HTML. After the bump: 0 production advisories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 11:55:28 -04:00
jared 299da879fb Merge pull request 'feat(settings): quick wins from the settings audit (#453 phase 1)' (#464) from feat/settings-quick-wins into lotus
CI / Build & Quality Checks (push) Successful in 4m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 22s
CI / Trigger Desktop Build (push) Successful in 14s
CI / Playwright smoke (e2e) (push) Successful in 21m43s
2026-10-25 02:48:55 -04:00
jared f8fa1ca5ed Merge pull request 'feat(settings): export / import your data (#114)' (#454) from feat/data-export into lotus
CI / Build & Quality Checks (push) Successful in 3m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-25 02:16:03 -04:00
Lotus CIandClaude Opus 5.5 5d15013a27 feat(settings): quick wins from the settings audit (#453)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m56s
- #456 Copy pass: sentence case for every setting title and section,
  plainer names ("Hide joins and leaves", "Link previews", "Emoji
  style: Twemoji", "Rejoin calls after a restart", "Also use the
  browser's noise suppression", "Push-to-talk key"...) and grammar fixes
  ("when messages arrive", "Play a sound when a new message arrives",
  blocked users "Enter their Matrix ID"). Labels only: no setting is
  renamed in storage.
- #455 One theme control: Theme: System / Light / Dark. System shows the
  light and dark variant pickers, Light or Dark one picker of that kind.
  Replaces the System switch plus a Theme dropdown that was greyed out
  whenever the switch was on. Uses the existing settings (no migration);
  picking a variant also remembers it for System.
- #457 Remove the "Block Messages" stub from Notifications; the GIF
  picker row only appears in builds with a GIF key.
- #458 (part 1) "Export/Import Messages Data" -> "Export/Import
  encryption keys", with a sentence on when you need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 01:37:37 -04:00
Lotus CIandClaude Opus 5.5 378b40fabe feat(settings): export / import your data (#114)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m38s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 22m30s
Settings -> General -> "Your data": Export writes one JSON file of the
account's Lotus data (synced settings, bookmarks, private notes, room names
and sections, reminders, status presets, thread notification settings,
timed mutes, soundboard (clips stay on the server), emoji packs, avatar
decoration, and this device's settings). Not messages or encryption keys:
they have their own exports, and keys are secret.

Import merges: new items are added; things that differ are listed per
category and kept unless the user picks "Use the file's". Only our own
account-data types are read from a file. Files from a newer Lotus Chat
are refused with a plain message; the version stamp leaves room to
migrate older ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 01:08:28 -04:00
jared 0d4f2e4a87 Merge pull request 'feat(security): nudge to sign out sessions unused for 3 months (#113)' (#452) from feat/stale-sessions into lotus
CI / Build & Quality Checks (push) Successful in 4m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 21s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 23m25s
2026-10-25 00:43:14 -04:00
Lotus CIandClaude Opus 5.5 c907b0ba99 feat(security): nudge to sign out sessions unused for 3 months (#113)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m20s
Sessions of this account not seen for 90+ days (never the current one; a
session with no last-seen time is left alone) get a nudge in the existing
security strip, after the device-security ones and with the same rules
("Not now" snoozes 7 days, gone after 3 dismissals; a snoozed security
nudge no longer hides it): "You have 3 sessions you haven't used in 3
months. Signing them out keeps your account safe if an old device is
lost." [Review]

Review opens Settings -> Devices with those sessions ticked for sign-out
(one confirm, the usual re-auth incl. SSO); they are listed first and
marked "Inactive for 3+ months". OIDC accounts sign out on the
provider's dashboard, so nothing is ticked there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 23:52:16 -04:00
jared 02c852e194 Merge pull request 'feat(call): full-screen call when a phone is turned sideways (#126)' (#451) from feat/landscape-call into lotus
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 20m32s
2026-10-24 23:13:16 -04:00
Lotus CIandClaude Opus 5.5 6e6213a43b feat(call): full-screen call when a phone is turned sideways (#126)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m31s
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 18m48s
Landscape, at most 500px tall, touch input (a sideways phone is ~840px
wide, the Tablet size class, so the trigger is the screen shape), in this
room's joined call, chat closed: the call takes the whole screen. No room
header, sidebar or call-status bar; safe-area insets respected.

- Taps inside the call frame never reach the app, so while the controls
  are hidden a transparent layer covers the call: the first tap shows the
  call bar plus Chat / Exit full screen, which hide again after 4 s
  without a touch; with them up the call underneath stays usable.
- In an Android browser tab the first tap also enters the browser's own
  full screen (hides the address bar); left again on rotate back, hang
  up or exit. No effect on iPhone or the installed app.
- Exit full screen lasts until the phone is upright again.
- The frame is transparent (room wallpaper), so in full screen it gets an
  opaque base colour under the wallpaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 22:50:01 -04:00
jared 5379eb046d Merge pull request 'feat(call): ask before joining when your usual mic / headphones are missing (#121)' (#450) from feat/expected-devices into lotus
CI / Build & Quality Checks (push) Successful in 3m18s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 19m44s
2026-10-24 21:30:20 -04:00
Lotus CIandClaude Opus 5.5 17ebddb159 chore(deps): Element Call 0.28.0-lotus.2 (expected-device check, #121)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m25s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 21s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 19:58:56 -04:00
Lotus CIandClaude Opus 5.5 7bd1c14a3c feat(call): ask before joining when your usual mic / headphones are missing (#121)
The fork now checks, before joining (lotusDeviceCheck=1), that the mic /
output the user last picked are plugged in. When one is missing it asks
us (io.lotus.device_missing): "Your usual microphone isn't connected.
Arctis 7 isn't connected. Join with Laptop Mic instead?" [Join anyway]
[Cancel]. Join anyway isn't asked again this session for the same
device(s); Cancel closes the call before it starts. A device lost during
a call is a toast ("Arctis 7 disconnected — now using Laptop Mic").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 19:49:50 -04:00
jared a58684958c Merge pull request 'feat(auth): offer native OAuth sign-in on servers like matrix.org' (#449) from feat/oauth-detect into lotus
CI / Build & Quality Checks (push) Successful in 3m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 16m31s
2026-10-24 16:36:37 -04:00
Lotus CIandClaude Opus 5.5 3c3bb2e41c feat(auth): offer native OAuth sign-in on servers like matrix.org
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m41s
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 16m45s
The login page looked for an OAuth issuer in .well-known
(m.authentication), which matrix.org no longer publishes, so its users
got the password / SSO compatibility forms. Detect native OAuth the way
the SDK finds the auth server for refresh: load /auth_metadata next to
the login flows. When present:
- Login shows "Continue with <server>" only (as Element does); a legacy
  SSO loginToken coming back from a redirect still completes.
- Register opens the auth server's sign-up page (prompt=create, where
  prompt_values_supported says so) instead of the legacy forms.
Servers without it (Lotus) are unchanged.

Checked against the live servers: matrix.org login and register show
"Continue with matrix.org" and no password form; matrix.lotusguild.org
keeps its password form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 16:15:24 -04:00
jared 88446e8953 Merge pull request 'chore(deps): Element Call 0.28.0-lotus.1 (#201)' (#448) from chore/ec-0.28 into lotus
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 21m51s
2026-10-24 03:02:12 -04:00
Lotus CIandClaude Opus 5.5 f6bad9eb58 test(e2e): #212 waits for the timeline to settle before scrolling up
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m8s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m44s
The wheel ran as soon as the composer appeared; when it landed during the
room's initial jump-to-bottom it was snapped back and "Jump to Latest"
never showed. Seen ~1 in 5 runs since matrix-js-sdk 44 shifted the load
timing (failed the post-merge e2e run of #447 on lotus). Wait for the
newest message, then retry the scroll until the button appears.
10/10 locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 02:35:58 -04:00
Lotus CIandClaude Opus 5.5 93c7dcd749 chore(deps): Element Call 0.25.0-lotus.29 -> 0.28.0-lotus.1 (#201)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
The fork synced to upstream v0.28.0 (element-call #61): matrix-js-sdk
develop with the SDK 44 MatrixRTC changes, delayed-leave delegation.

v0.28 renamed the delayed-leave config keys and its embedded build ships
no config.json (defaults: 18 s). Ship the Lotus values with the build
(build/element-call/config.json -> public/element-call/config.json) so
the desktop app keeps the 60 s grace from element-call lotus #46. The web
deploy keeps the server's own config.json (rsync excludes it); that file
already carries both the old and new keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 02:06:43 -04:00
jared 43e166b879 Merge pull request 'chore(deps): matrix-js-sdk 41.7 → 44.0 (#201)' (#447) from chore/sdk-44 into lotus
CI / Build & Quality Checks (push) Successful in 3m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Failing after 18m55s
2026-10-24 01:31:40 -04:00
Lotus CIandClaude Opus 5.5 70206d6033 chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m50s
Picks up three releases of MatrixRTC / delayed-event fixes and
matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0.

SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so
the native-OAuth sign-in is ported:
- login discovers the auth server via /auth_metadata, registers with
  OAuth2.registerClient, and keeps the PKCE verifier + device in its own
  sessionStorage entry keyed by `state` (single use; CSRF check);
- refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16
  cross-tab protection is kept by a narrow guard on refresh-token grants
  (the SDK calls the global fetch for OAuth, not fetchFn), which
  serialises them with a Web Lock and adopts a sibling tab's tokens;
- logout and forced logout revoke via mx.logout() (the SDK revokes at
  the auth server for OAuth sessions);
- the callback page parsed its params on every render, re-running the
  single-use code exchange in a loop; parsed once now.

Also: notification_type is deprecated in MSC4075 (ring in DMs, notify
elsewhere when omitted), _onRTCSessionMemberUpdate ->
ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the
7.8 MB crypto wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 00:32:13 -04:00
jared bfed37bf50 Merge pull request 'feat(e2ee): tell the user when their device's key uploads are stuck (#201 KE-1)' (#446) from feat/key-upload-recovery into lotus
CI / Build & Quality Checks (push) Successful in 3m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
2026-10-24 00:11:28 -04:00
Lotus CIandClaude Opus 5.5 c2c43eda99 feat(e2ee): tell the user when their device's key uploads are stuck (#201 KE-1)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m41s
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 15m54s
When the server keeps rejecting this device's /keys/upload with 400 "One
time key ... already exists", the device's encryption store has diverged
and the SDK retries forever (matrix-rust-sdk#5200, still open). Synapse
saw 474k of these from May 30 to Jul 8 2026. The fix is a fresh login, so:

- keyUploadWatch observes /keys/upload responses through the client's
  fetchFn (the response the SDK gets is untouched; a clone is read).
  Three conflicts within 15 min = stuck; any successful upload clears it.
- KeyUploadBanner (critical strip, same slot as the sync/clock banners):
  "Encryption on this device is out of sync" with Details and
  "Fix: sign out…", which opens the normal logout dialog (it already
  warns when the device isn't verified / backed up).

Server side, Grafana now alerts on the same signal (E2EE Key Upload
Conflicts) and on delayed-event 5xx (Call Delayed-Event Errors).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-23 23:51:15 -04:00
jared 45c3b16694 Merge pull request 'feat(desktop): keep the login tokens only in the OS keychain (#105 step 2)' (#445) from feat/keychain-session into lotus
CI / Build & Quality Checks (push) Successful in 3m13s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 16m28s
2026-10-23 20:59:25 -04:00
Lotus CIandClaude Opus 5.5 5f3c5d370a feat(desktop): keep the login tokens only in the OS keychain (#105 step 2)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m50s
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 20m30s
On the desktop app with a working keychain (Windows Credential Manager),
the tokens leave localStorage: it keeps a token-less shell blob
(tokensIn: keychain) and the full session lives in memory, read from the
keychain at boot before anything reads the session, so getFallbackSession
stays synchronous. Web, Linux and older desktop builds are unchanged.

- A new session (login, token refresh) is written to localStorage in full
  first; the mirror verifies the keychain copy and only then strips the
  tokens, so one durable store always holds the newest tokens.
- Existing installs migrate on first boot the same way; a failing keychain
  write keeps the tokens in localStorage.
- If a shell is there but the keychain entry is missing or unreadable, the
  app shows Retry / Sign in again instead of logging out, and the mirror
  is not started (it would clear the entry).

Fixes #105

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-23 20:02:35 -04:00
jared 4120ccdbcc Merge pull request 'feat(embeds): Vimeo live events play inline (#200)' (#444) from feat/vimeo-events into lotus
CI / Build & Quality Checks (push) Successful in 4m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 16s
CI / Playwright smoke (e2e) (push) Successful in 21m22s
2026-10-21 00:14:21 -04:00
Lotus CIandClaude Opus 5.5 ed5bf997b4 feat(embeds): Vimeo live events play inline (#200)
CI / Build & Quality Checks (pull_request) Successful in 4m38s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m27s
vimeo.com/event/{id} (and /event/{id}/{hash} for unlisted events) now get
the same click-to-play card as Vimeo videos, embedding Vimeo's live-event
player (vimeo.com/event/{id}/embed[/{hash}], dnt=1). The issue assumed
this needed vimeo.com in the CSP, but frame-src is already https: on web
and desktop.

Refs #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 23:31:13 -04:00
jared 878a34712a Merge pull request 'feat(widgets): keep a widget on screen while you navigate away (#205)' (#443) from feat/widget-always-on-screen into lotus
CI / Build & Quality Checks (push) Successful in 3m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 42s
CI / Trigger Desktop Build (push) Successful in 18s
CI / Playwright smoke (e2e) (push) Successful in 19m38s
2026-10-20 22:21:48 -04:00
jared 62672787a0 Merge pull request 'feat(call): Details card after a call, with who/why/numbers/activity (#253)' (#442) from feat/call-summary-details into lotus
CI / Build & Quality Checks (push) Canceled after 18s
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-20 21:37:26 -04:00
Lotus CIandClaude Opus 5.5 0746e80941 feat(widgets): keep a widget on screen while you navigate away (#205)
CI / Build & Quality Checks (pull_request) Successful in 3m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 18s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m40s
MSC2931: a widget with m.always_on_screen (auto-granted) can send
set_always_on_screen; the host now answers it, and a widget that asked
keeps running in a small floating window (title, Back to room, close)
when the Widgets panel closes or you switch rooms. Back to room returns
to it with the panel open on that widget, without reloading it.

Moving an iframe reloads it, so widget frames now live in an app-level
layer from the start (widgetSessions.ts) and are positioned over the
panel's placeholder while docked. A widget that did not ask is torn
down as before. One floating widget at a time; it also goes when the
widget withdraws, is removed from the room, or on logout. A permission
request while floating grants nothing extra.

Refs #205

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 21:05:31 -04:00
jared 6300e4170e Merge pull request 'feat(widgets): open a widget in its own window (#205)' (#441) from feat/widget-popout into lotus
CI / Build & Quality Checks (push) Successful in 4m5s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-20 21:05:12 -04:00
jared 7250322977 Merge pull request 'fix(touch): harden long-press for iOS (#199)' (#440) from fix/ios-long-press 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-20 20:47:19 -04:00
Lotus CIandClaude Opus 5.5 ac1a1c9a48 chore(deps): pin @lotusguild/element-call-embedded 0.25.0-lotus.29 (#253)
CI / Build & Quality Checks (pull_request) Successful in 3m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m13s
lotus.29 adds the call summary `details` (element-call#60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:33:13 -04:00
Lotus CIandClaude Opus 5.5 8645a55769 feat(call): Details card for the call-ended toast (#253)
The call-ended toast gets a Details button (and stays 10 s) that opens a
card with who was in the call and the peak, connection verdict and
reconnects, how long your / each person's / everyone's connection was
poor (likely the server when everyone), RTT, packet loss and jitter, and
your talk, mute, deafen and screenshare time, in technical wording.
Deafened time is tracked by CallEmbed; the rest comes from the fork's new
io.lotus.call_summary `details` (lotus.29). Settings → Calls gets
"Summary After Calls" (on by default).

Fixes #253

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:33:07 -04:00
Lotus CIandClaude Opus 5.5 c3f897b6d1 feat(widgets): open a widget in its own window (#205)
CI / Build & Quality Checks (pull_request) Successful in 3m34s
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 17m37s
Each widget in the Widgets panel (and the open widget's header) gets an
"Open in a new window" button, so a pad or board can sit beside the chat.
It opens the same filled-in URL the frame uses (widgetCompleteUrl, now
shared) with noopener,noreferrer, standalone as in Element. Only
cross-origin https URLs get the button: room state can carry anything,
e.g. a script URL from another client.

Refs #205

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:17:52 -04:00
jared 8784a8158c Merge pull request 'chore(types): fewer any casts; eslint ratchet 36 → 29 (#210)' (#439) from chore/any-cleanup into lotus
CI / Build & Quality Checks (push) Successful in 3m13s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Canceled after 14s
2026-10-20 20:17:01 -04:00
Lotus CIandClaude Opus 5.5 42040b6412 chore(types): fewer any casts; eslint warning ratchet 36 -> 29 (#210)
CI / Build & Quality Checks (pull_request) Successful in 3m23s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m6s
- Developer tools (send event, state event editor, room account data) and
  setMarkedUnread go through the typed helpers in utils/room and
  utils/accountData (new setRoomAccountData) instead of casting at the
  call site; sendStateEvent accepts a developer-chosen type string.
- SecretStorage passes the key description as the SDK's
  SecretStorageKeyDescription, CallEmbed replies with an IWidgetApiRequest,
  and fillMissingPowers writes through a Record instead of `as any`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 19:49:15 -04:00
103 changed files with 4536 additions and 714 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"matrix_rtc_session": {
"delayed_leave": { "delay_ms": 60000, "restart_ms": 15000 }
}
}
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #201 KE-1] When the server keeps rejecting this device's key uploads
// with "One time key … already exists", the app says so and offers the fix.
const CONFLICT = {
errcode: 'M_UNKNOWN',
error: 'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {}; new key: {}',
};
test.describe('key upload recovery (#201)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('a diverged device sees the banner, and Fix opens the logout dialog', async ({ page }) => {
test.setTimeout(240_000);
const user = await ensureUser(uniq('kup'));
let rejected = 0;
await page.route(/\/_matrix\/client\/v3\/keys\/upload/, async (route) => {
rejected += 1;
await route.fulfill({ status: 400, json: CONFLICT });
});
await loginUI(page, user);
const banner = page
.getByRole('alert')
.filter({ hasText: /Encryption on this device is out of sync/ });
await expect(banner).toBeVisible({ timeout: 200_000 });
console.log(`key uploads rejected: ${rejected}`);
expect(rejected).toBeGreaterThanOrEqual(3);
await banner.getByRole('button', { name: 'Details' }).click();
await expect(banner).toContainText('Settings → Devices');
await expect(banner).toContainText('already exists');
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
await expect(page.getByRole('dialog').getByText('Logout').first()).toBeVisible();
});
test('a healthy device never sees it', async ({ page }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('kok'));
await loginUI(page, user);
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
await page.waitForTimeout(5000);
await expect(page.getByText(/Encryption on this device is out of sync/)).toHaveCount(0);
});
});
+10 -2
View File
@@ -109,9 +109,17 @@ test.describe('local homeserver regression', () => {
);
await loginUI(page, alice);
await openRoom(page, room);
// Wait for the timeline to settle at the newest message first: a wheel
// during the initial jump-to-bottom gets snapped back (test race, seen
// ~1 in 5 runs once matrix-js-sdk 44 changed the load timing).
await expect(page.getByText('filler 39')).toBeInViewport();
await page.mouse.move(600, 350);
await page.mouse.wheel(0, -600);
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible();
await expect(async () => {
await page.mouse.wheel(0, -600);
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible({
timeout: 2000,
});
}).toPass({ timeout: 20_000 });
await page.locator('[data-slate-editor]').first().click();
await page.keyboard.type('sent while scrolled up');
await page.keyboard.press('Enter');
+260
View File
@@ -0,0 +1,260 @@
import { createHash } from 'node:crypto';
import { test, expect, Page, Route, BrowserContext } from '@playwright/test';
import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs';
// Native OAuth sign-in on matrix-js-sdk 44 (Gitea #201 SDK upgrade): login,
// refresh on an expired token, the #16 two-tab refresh race, and logout
// revocation. The homeserver is the real local Synapse; the auth server is
// faked here and hands out REAL Synapse tokens for the device the client asked
// for, so sync works after every step. "Expiry" is simulated by answering
// M_UNKNOWN_TOKEN for a token we mark expired.
const AUTH = 'https://auth.fake.test';
type Fake = {
prompts: (string | null)[];
registered: number;
tokenCalls: { grant: string; refresh?: string }[];
revoked: string[];
expired: Set<string>;
issuedAccess: string[];
};
const b64url = (buf: Buffer) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function synapseToken(localpart: string, deviceId: string): Promise<string> {
const res = await api<{ access_token: string }>('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: localpart },
password: PASSWORD,
device_id: deviceId,
});
return res.access_token;
}
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
const fake: Fake = {
prompts: [],
registered: 0,
tokenCalls: [],
revoked: [],
expired: new Set(),
issuedAccess: [],
};
const codes = new Map<string, { challenge: string; deviceId: string }>();
const refreshes = new Map<string, string>(); // live refresh token -> deviceId (rotating)
let n = 0;
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
const issue = async (deviceId: string) => {
n += 1;
const access = await synapseToken(localpart, deviceId);
const refresh = `r${n}`;
refreshes.set(refresh, deviceId);
fake.issuedAccess.push(access);
return { token_type: 'Bearer', access_token: access, refresh_token: refresh, expires_in: 300 };
};
// Homeserver: advertise the issuer, serve auth metadata, and "expire" tokens.
await context.route(`${HS}/.well-known/matrix/client`, (route) =>
route.fulfill({
headers: cors,
json: { 'm.homeserver': { base_url: HS }, 'm.authentication': { issuer: `${AUTH}/` } },
}),
);
await context.route(`${HS}/_matrix/client/v1/auth_metadata`, (route) =>
route.fulfill({
headers: cors,
json: {
issuer: `${AUTH}/`,
authorization_endpoint: `${AUTH}/authorize`,
token_endpoint: `${AUTH}/token`,
registration_endpoint: `${AUTH}/register`,
revocation_endpoint: `${AUTH}/revoke`,
response_types_supported: ['code'],
response_modes_supported: ['query', 'fragment'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
prompt_values_supported: ['create'],
},
}),
);
await context.route(`${HS}/_matrix/**`, async (route: Route) => {
const auth = route.request().headers().authorization ?? '';
const token = auth.replace(/^Bearer /, '');
if (token && fake.expired.has(token)) {
await route.fulfill({
status: 401,
headers: cors,
json: { errcode: 'M_UNKNOWN_TOKEN', error: 'Token expired', soft_logout: true },
});
return;
}
await route.fallback();
});
// Auth server.
await context.route(`${AUTH}/**`, async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
if (req.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: cors });
return;
}
if (url.pathname === '/register') {
fake.registered += 1;
await route.fulfill({ headers: cors, json: { client_id: 'fake-client' } });
return;
}
if (url.pathname === '/authorize') {
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
url.searchParams.get('scope') ?? '',
)?.[1];
fake.prompts.push(url.searchParams.get('prompt'));
expect(url.searchParams.get('response_mode')).toBe('query');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
const code = `code-${Date.now()}`;
codes.set(code, { challenge: url.searchParams.get('code_challenge')!, deviceId: deviceId! });
const back = new URL(url.searchParams.get('redirect_uri')!);
back.searchParams.set('code', code);
back.searchParams.set('state', url.searchParams.get('state')!);
await route.fulfill({ status: 302, headers: { location: back.href } });
return;
}
const form = new URLSearchParams(req.postData() ?? '');
if (url.pathname === '/token') {
const grant = form.get('grant_type') ?? '';
fake.tokenCalls.push({ grant, refresh: form.get('refresh_token') ?? undefined });
if (grant === 'authorization_code') {
const pending = codes.get(form.get('code') ?? '');
const verifier = form.get('code_verifier') ?? '';
if (
!pending ||
b64url(createHash('sha256').update(verifier).digest()) !== pending.challenge
) {
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
return;
}
codes.delete(form.get('code')!);
await route.fulfill({ headers: cors, json: await issue(pending.deviceId) });
return;
}
if (grant === 'refresh_token') {
const deviceId = refreshes.get(form.get('refresh_token') ?? '');
if (!deviceId) {
// rotating refresh tokens: a used one is dead
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
return;
}
refreshes.delete(form.get('refresh_token')!);
await route.fulfill({ headers: cors, json: await issue(deviceId) });
return;
}
}
if (url.pathname === '/revoke') {
fake.revoked.push(form.get('token') ?? '');
await route.fulfill({ status: 200, headers: cors, body: '' });
return;
}
await route.fulfill({ status: 404, headers: cors, body: '' });
});
return fake;
}
const storedSession = (page: Page) =>
page.evaluate(() => JSON.parse(localStorage.getItem('cinny_session_v1') ?? 'null'));
async function oauthLogin(page: Page) {
await page.goto(`/login/${enc(HS)}/`);
await page.getByRole('button', { name: /Continue with/ }).click();
await page.waitForURL(/\/home/, { timeout: 60_000 });
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
}
test.describe('OAuth sign-in on SDK 44', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('login, refresh on expiry, two-tab race, logout revokes', async ({ context }) => {
test.setTimeout(240_000);
const user = await ensureUser(uniq('oa'));
const fake = await fakeOAuth(context, user.localpart);
const page = await context.newPage();
// 1. Login: registration, PKCE-checked code exchange, session persisted.
await oauthLogin(page);
expect(fake.registered).toBe(1);
expect(fake.tokenCalls.map((c) => c.grant)).toEqual(['authorization_code']);
let s = await storedSession(page);
expect(s.userId).toBe(user.userId);
expect(s.refreshToken).toBe('r1');
expect(s.oidc.clientId).toBe('fake-client');
const t1 = s.accessToken;
// 2. Second tab on the same session.
const page2 = await context.newPage();
await page2.goto('/home/');
await page2.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
// 3. Token "expires": both tabs get M_UNKNOWN_TOKEN on their next request
// and refresh at once. Exactly one refresh may reach the auth server
// (r1 is single-use); the other tab must adopt its tokens.
// Sync long-polls continuously, so both tabs hit it within ~30 s.
fake.expired.add(t1);
await expect
.poll(async () => (await storedSession(page)).accessToken, { timeout: 90_000 })
.not.toBe(t1);
await page.waitForTimeout(5000);
const refreshCalls = fake.tokenCalls.filter((c) => c.grant === 'refresh_token');
expect(refreshCalls.map((c) => c.refresh)).toEqual(['r1']);
s = await storedSession(page);
expect(s.refreshToken).toBe('r2');
await Promise.all(
[page, page2].map((p) =>
expect(p.getByText(/Username or email|Sign-in failed|logged out/i)).toHaveCount(0),
),
);
// 4. Logout through the UI revokes both tokens at the auth server
// (the SDK does it for OAuth sessions instead of calling /logout).
await page2.close();
const latest = await storedSession(page);
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Logout' }).first().click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Logout', exact: true })
.last()
.click();
await expect(
page.getByLabel('Username or email').or(page.getByRole('button', { name: /Continue with/ })),
).toBeVisible({
timeout: 60_000,
});
expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
expect(await storedSession(page)).toBeNull();
});
test('Register opens the auth server sign-up page (prompt=create)', async ({ context }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('or'));
const fake = await fakeOAuth(context, user.localpart);
const page = await context.newPage();
await page.goto(`/register/${enc(HS)}/`);
// Native-OAuth server: no legacy registration form, just the auth server.
await expect(page.getByLabel('Username', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: /Continue with/ }).click();
await page.waitForURL(/\/home/, { timeout: 60_000 });
expect(fake.prompts).toEqual(['create']);
});
test('Login sends no prompt', async ({ context }) => {
test.setTimeout(120_000);
const user = await ensureUser(uniq('ol'));
const fake = await fakeOAuth(context, user.localpart);
const page = await context.newPage();
await oauthLogin(page);
expect(fake.prompts).toEqual([null]);
});
});
+1 -1
View File
@@ -83,7 +83,7 @@ test.describe('security nudge (#110, #123)', () => {
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();
await expect(page.getByText('Device verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, Page } from '@playwright/test';
import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security
// one), and Review opens Settings → Devices with them ticked for sign-out.
// Synapse won't backdate last-seen, so the session list is served with one
// real extra session made 120 days old.
const DAY = 86_400_000;
const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text });
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();
}
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 });
}
test.describe('stale sessions (#113)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('nudge after the security one, Review ticks the old session, sign-out works', async ({
page,
}) => {
test.setTimeout(180_000);
const user = await ensureUser(uniq('ss'));
// Two extra sessions: one we'll make look 4 months old, one recent.
const login = (device: string) =>
api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: user.localpart },
password: PASSWORD,
device_id: device,
initial_device_display_name: device,
});
await login('OLDLAPTOP');
await login('RECENTPHONE');
await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
for (const d of body.devices ?? []) {
if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY;
else d.last_seen_ts = Date.now() - DAY;
}
await route.fulfill({ response: res, json: body });
});
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// A fresh account has no key backup: that nudge comes first.
const security = strip(page, /encryption keys/);
await expect(security).toBeVisible({ timeout: 30_000 });
await security.getByRole('button', { name: 'Not now' }).click();
const stale = strip(page, /a session you haven't used in 3 months/);
await expect(stale).toBeVisible({ timeout: 30_000 });
await stale.getByRole('button', { name: 'Review' }).click();
const dialog = page.getByRole('dialog').first();
await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1);
await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible();
// The sticky "Logout from selected devices" bar comes last.
await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click();
await page.locator('input[name="passwordInput"]').fill(PASSWORD);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, {
timeout: 30_000,
});
const { devices } = await api<{ devices: { device_id: string }[] }>(
'GET',
'/_matrix/client/v3/devices',
user.token,
);
const ids = devices.map((d) => d.device_id);
expect(ids).not.toContain('OLDLAPTOP');
expect(ids).toContain('RECENTPHONE');
expect(HS).toBeTruthy();
});
});
+98
View File
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import { test, expect } from '@playwright/test';
import { api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
// [Gitea #114] Export / import "your data": a round trip through the file, the
// merge summary, and the keep-mine / use-the-file's choice.
const enc = encodeURIComponent;
test.describe('your data (#114)', () => {
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'local homeserver not running');
});
test('export, change things, import merges with the chosen conflict rule', async ({ page }) => {
test.setTimeout(150_000);
const user = await ensureUser(uniq('yd'));
const put = (type: string, content: unknown) =>
api(
'PUT',
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
user.token,
content,
);
const get = (type: string) =>
api<Record<string, unknown>>(
'GET',
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
user.token,
);
const bm = (eventId: string) => ({ roomId: '!r:localhost', eventId, savedAt: 1 });
await put('io.lotus.bookmarks', { bookmarks: [bm('$one'), bm('$two')] });
await put('io.lotus.user_notes', { '@friend:localhost': 'met at the LAN party' });
await loginUI(page, user);
await page
.getByText('Connecting...')
.first()
.waitFor({ state: 'detached', timeout: 90_000 })
.catch(() => undefined);
const openGeneral = async () => {
await page.getByRole('button', { name: 'User Settings' }).click();
await page.getByRole('dialog').getByText('General', { exact: true }).first().click();
await page
.getByRole('dialog')
.getByText('Your data', { exact: true })
.scrollIntoViewIfNeeded();
};
await openGeneral();
// Export.
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('dialog').getByRole('button', { name: 'Export', exact: true }).click(),
]);
const path = await download.path();
const file = JSON.parse(fs.readFileSync(path!, 'utf8'));
expect(file.format).toBe('lotus-chat-data');
expect(file.accountData['io.lotus.bookmarks'].bookmarks).toHaveLength(2);
expect(file.local.settings).toBeTruthy();
// Lose a bookmark, change the note.
await put('io.lotus.bookmarks', { bookmarks: [bm('$one')] });
await put('io.lotus.user_notes', { '@friend:localhost': 'changed my mind' });
await page.waitForTimeout(3000); // let sync deliver the new account data
const importFile = async () => {
await page
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
.setInputFiles(path!);
await expect(page.getByRole('region', { name: 'Import summary' })).toBeVisible();
};
await importFile();
const summary = page.getByRole('region', { name: 'Import summary' });
await expect(summary).toContainText('Bookmarks: 1 new');
await expect(summary).toContainText('Private notes about people: 1 differ');
// Keep mine: the bookmark comes back, the note stays as it is now.
await summary.getByRole('button', { name: 'Import', exact: true }).click();
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
expect(((await get('io.lotus.bookmarks')).bookmarks as unknown[]).length).toBe(2);
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('changed my mind');
// Again, using the file's: the note goes back.
await page.waitForTimeout(2000);
await importFile();
await summary.getByText("Use the file's").click();
await summary.getByRole('button', { name: 'Import', exact: true }).click();
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('met at the LAN party');
// Wrong files are refused with a plain message.
fs.writeFileSync(`${path}.bad`, '{"format":"something-else"}');
await page
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
.setInputFiles(`${path}.bad`);
await expect(page.getByText("This isn't a Lotus Chat data file.")).toBeVisible();
});
});
+61 -61
View File
@@ -45,7 +45,7 @@
"html-react-parser": "6.1.2",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.2",
"i18next-http-backend": "4.0.3",
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
@@ -53,7 +53,7 @@
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
"matrix-js-sdk": "44.0.0",
"matrix-widget-api": "1.18.0",
"millify": "6.1.0",
"pdfjs-dist": "6.3.289",
@@ -70,7 +70,7 @@
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.18.3",
"sanitize-html": "2.17.7",
"sanitize-html": "2.18.1",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,15 +2695,15 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.28",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.28/element-call-embedded-0.25.0-lotus.28.tgz",
"integrity": "sha512-IFBHHSVw0CzrrLgx3oIIcJmBB4YMb8at4oVuSKXifZ7MmyHydxg9U3sTlZGgLUjMVrrAXj4gFzCksGHet4LSQw==",
"version": "0.28.0-lotus.2",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
"version": "18.9.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.9.0.tgz",
"integrity": "sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
@@ -5580,12 +5580,12 @@
"dev": true
},
"node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz",
"integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==",
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=22"
},
"funding": {
"type": "opencollective",
@@ -6057,6 +6057,7 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8168,9 +8169,9 @@
}
},
"node_modules/i18next-http-backend": {
"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==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.3.tgz",
"integrity": "sha512-B5GJysCsJwqiDQRpUcikWsut82Vmekix58WIyiCpYtxaZ/wY8kwJCicMRvVcMoIa1x84siiRl5vBt6iCBVO6zg==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -8704,9 +8705,10 @@
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -9152,15 +9154,6 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/katex": {
"version": "0.18.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
@@ -9216,14 +9209,20 @@
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
"dayjs": "^1.11.23"
}
},
"node_modules/launder/node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -10037,21 +10036,19 @@
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "41.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"version": "44.0.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-44.0.0.tgz",
"integrity": "sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
"@babel/runtime": "^8.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.9.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^2.0.0",
"jwt-decode": "^4.0.0",
"content-type": "^3.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"matrix-widget-api": "^1.19.0",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6"
@@ -10060,6 +10057,22 @@
"node": ">=22.0.0"
}
},
"node_modules/matrix-js-sdk/node_modules/@babel/runtime": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.8.tgz",
"integrity": "sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==",
"license": "MIT"
},
"node_modules/matrix-js-sdk/node_modules/matrix-widget-api": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz",
"integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
}
},
"node_modules/matrix-widget-api": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
@@ -10405,18 +10418,6 @@
],
"license": "MIT"
},
"node_modules/oidc-client-ts": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -11713,18 +11714,17 @@
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.7",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"is-plain-object": "^5.1.0",
"launder": "^1.7.2",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
"postcss": "^8.5.27"
},
"engines": {
"node": ">=22.12.0"
+5 -5
View File
@@ -12,7 +12,7 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 36",
"check:eslint": "eslint src/* --max-warnings 29",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
@@ -72,7 +72,7 @@
"html-react-parser": "6.1.2",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.2",
"i18next-http-backend": "4.0.3",
"immer": "11.1.8",
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
@@ -80,7 +80,7 @@
"katex": "0.18.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
"matrix-js-sdk": "44.0.0",
"matrix-widget-api": "1.18.0",
"millify": "6.1.0",
"pdfjs-dist": "6.3.289",
@@ -97,7 +97,7 @@
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.18.3",
"sanitize-html": "2.17.7",
"sanitize-html": "2.18.1",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+7 -1
View File
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
const [state, load] = useAsyncCallback(
useCallback(async () => {
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
const result = await Promise.allSettled([
mx.loginFlows(),
mx.registerRequest({}),
// Rejects when the server has no native OAuth: normal for most servers.
mx.getAuthMetadata(),
]);
const loginFlows = promiseFulfilledResult(result[0]);
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
const authFlows: AuthFlows = {
loginFlows,
registerFlows,
authMetadata: promiseFulfilledResult(result[2]),
};
return authFlows;
+24 -5
View File
@@ -37,7 +37,7 @@ import {
useCallThemeSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { callChatAtom, callEmbedAtom, immersiveCallAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
@@ -52,6 +52,7 @@ import { useCallEndedToast } from '../hooks/useCallEndedToast';
import { useCallRejoin } from '../hooks/useCallRejoin';
import { usePttHaptics } from '../hooks/usePttHaptics';
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
import { ExpectedDevicesPrompt } from '../features/call/ExpectedDevicesPrompt';
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
import { callAnnouncementAtom } from '../state/callAnnouncement';
@@ -571,7 +572,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
? content.sender_ts
: event.getTs();
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type;
// `notification_type` is deprecated in MSC4075 (matrix-js-sdk 43+): newer
// senders may omit it and leave the presentation to us. Then ring in a
// direct chat and notify quietly elsewhere, like Element Web.
const notificationType =
content.notification_type ?? (directs.has(room.roomId) ? 'ring' : 'notification');
const relation =
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
const refEventId = relation?.event_id;
@@ -653,7 +658,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
mx.getRooms().forEach((r) => {
mx.matrixRTC
.getActiveRoomSession(r)
?._onRTCSessionMemberUpdate()
?.ensureRecalculateSessionMembers()
.catch(() => undefined);
});
};
@@ -854,7 +859,14 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
};
}, [joined]);
return null;
return (
<ExpectedDevicesPrompt
embed={embed}
onCancel={() => {
setCallEmbed(undefined);
}}
/>
);
}
/**
@@ -942,6 +954,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const immersiveCall = useAtomValue(immersiveCallAtom);
const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate();
@@ -1446,7 +1459,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
...(pipMode
? { background: color.Surface.Container }
: callVisible
? wallpaperStyle
? {
// [Gitea #126] Full screen: the frame is transparent, so give
// it an opaque base under the wallpaper or the room's
// sidebar / call bar show through.
...(immersiveCall ? { backgroundColor: color.Background.Container } : {}),
...wallpaperStyle,
}
: {}),
}}
ref={callEmbedRef}
+9 -2
View File
@@ -1,6 +1,7 @@
import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage';
import { PasswordInput } from './password-input';
import {
SecretStorageKeyContent,
@@ -39,7 +40,10 @@ export function SecretStorageRecoveryPassphrase({
bits,
);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
const match = await mx.secretStorage.checkKey(
decodedRecoveryKey,
keyContent as SecretStorageKeyDescription,
);
if (!match) {
throw new Error('Invalid recovery passphrase.');
@@ -132,7 +136,10 @@ export function SecretStorageRecoveryKey({
async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
const match = await mx.secretStorage.checkKey(
decodedRecoveryKey,
keyContent as SecretStorageKeyDescription,
);
if (!match) {
throw new Error('Invalid recovery key.');
+4 -13
View File
@@ -1,5 +1,5 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { Capabilities, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
@@ -28,23 +28,14 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
// getAuthMetadata validates (and rejects when the server has no native
// OAuth, which is normal for servers using traditional SSO).
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
// Only validate if the server returned metadata — a rejected promise means no native
// Matrix OIDC (MSC3861/MAS), which is normal for servers using traditional SSO.
if (authMetadata !== undefined) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
}
return {
capabilities,
mediaConfig,
authMetadata: validatedAuthMetadata,
authMetadata,
};
}, [mx]),
);
@@ -0,0 +1,84 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { useAtom } from 'jotai';
import { callSummaryViewAtom } from '../../state/callSummary';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard';
/**
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
* shows who was in the call, connection quality and whose side it was on,
* network numbers and your own activity (technical wording).
*/
export function CallSummaryDialog() {
const [view, setView] = useAtom(callSummaryViewAtom);
const modalStyle = useModalStyle(400);
if (!view) return null;
const close = () => setView(undefined);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="call-summary-title"
style={modalStyle}
>
<Header
variant="Surface"
size="500"
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Phone} size="100" />
<Text id="call-summary-title" size="H4" truncate>
Call summary · {view.roomName}
</Text>
</Box>
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{view.sections.map((section) => (
<Box key={section.title} direction="Column" gap="100">
<Text size="L400">{section.title}</Text>
{section.lines.map((line) => (
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
{line}
</Text>
))}
</Box>
))}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
+37 -2
View File
@@ -23,6 +23,7 @@ import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { ImmersiveCallOverlay } from './ImmersiveCallOverlay';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { CallsUnavailableMessage } from './CallsUnavailable';
import { webRTCSupported } from '../../utils/rtc';
@@ -198,10 +199,24 @@ function CallLoadErrorMessage() {
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
/** [Gitea #126] Full screen: controls float over the call instead. */
onExitImmersive?: () => void;
};
function CallJoined({ joined, containerRef }: CallJoinedProps) {
function CallJoined({ joined, containerRef, onExitImmersive }: CallJoinedProps) {
const callEmbed = useCallEmbed();
if (onExitImmersive) {
return (
<Box grow="Yes" direction="Column" style={{ position: 'relative' }}>
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && (
<ImmersiveCallOverlay callEmbed={callEmbed} onExit={onExitImmersive} />
)}
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
</Box>
);
}
return (
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
@@ -212,7 +227,7 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
);
}
export function CallView() {
export function CallView({ onExitImmersive }: { onExitImmersive?: () => void } = {}) {
const room = useRoom();
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useCallEmbedPlacementSync(callContainerRef);
@@ -227,6 +242,26 @@ export function CallView() {
// made it into the call (a hung iframe / blank spinner otherwise).
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
if (onExitImmersive && currentJoined) {
// [Gitea #126] Cover the screen with a transparent layer above the call
// frame: the frame follows the placeholder (useCallEmbedPlacementSync), so
// it goes full screen too; the layer only takes touches on its own
// tap-catcher / controls, so the call underneath stays usable.
return (
<Box
style={{
position: 'fixed',
inset: 0,
zIndex: 10,
pointerEvents: 'none',
paddingTop: 'env(safe-area-inset-top)',
}}
>
<CallJoined joined containerRef={callContainerRef} onExitImmersive={onExitImmersive} />
</Box>
);
}
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { useSetAtom } from 'jotai';
import {
Box,
Button,
Dialog,
Header,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
config,
} from 'folds';
import { CallEmbed } from '../../plugins/call';
import {
MissingDevice,
acceptForSession,
describeLost,
describeMissing,
isAcceptedThisSession,
parseDeviceLost,
parseDeviceMissing,
} from '../../plugins/call/expectedDevices';
import { toastQueueAtom } from '../../state/toast';
import { useModalStyle } from '../../hooks/useModalStyle';
/**
* [Gitea #121] "Your usual headset isn't connected": the fork holds the join
* until we answer. Join anyway (not asked again this session for the same
* device) or Cancel (closes the call before it starts). A device lost mid-call
* is only a toast.
*/
export function ExpectedDevicesPrompt({
embed,
onCancel,
}: {
embed: CallEmbed;
onCancel: () => void;
}) {
const [missing, setMissing] = useState<MissingDevice[]>([]);
const setToast = useSetAtom(toastQueueAtom);
const modalStyle = useModalStyle(440);
useEffect(() => {
const offMissing = embed.listenAction<{ data?: unknown }>('io.lotus.device_missing', (evt) => {
const list = parseDeviceMissing(evt.detail?.data);
if (list.length === 0) return;
if (isAcceptedThisSession(list)) {
embed.control.sendDeviceDecision(true);
return;
}
setMissing(list);
});
const offLost = embed.listenAction<{ data?: unknown }>('io.lotus.device_lost', (evt) => {
const lost = parseDeviceLost(evt.detail?.data);
if (!lost) return;
setToast({
id: `device-lost-${lost.kind}-${Date.now()}`,
iconSrc: lost.kind === 'audioinput' ? Icons.Mic : Icons.VolumeHigh,
displayName: 'Audio device',
body: describeLost(lost),
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
});
});
return () => {
offMissing();
offLost();
};
}, [embed, setToast]);
if (missing.length === 0) return null;
const joinAnyway = () => {
acceptForSession(missing);
embed.control.sendDeviceDecision(true);
setMissing([]);
};
const cancel = () => {
embed.control.sendDeviceDecision(false);
setMissing([]);
onCancel();
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
<Dialog
variant="Surface"
role="alertdialog"
aria-labelledby="expected-devices-title"
style={modalStyle}
>
<Header
variant="Surface"
size="500"
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Text id="expected-devices-title" as="h2" size="H4">
{missing.length === 1
? describeMissing(missing[0]).title
: "Your usual audio devices aren't connected"}
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{missing.map((d) => {
const t = describeMissing(d);
return (
<Text key={d.kind} size="T300">
<b>{t.expected}</b> isn&apos;t connected. Join with <b>{t.fallback}</b> instead?
</Text>
);
})}
<Text size="T200" priority="300">
Plug it in and choose Cancel to start over with it.
</Text>
<Box direction="Column" gap="200">
<Button variant="Primary" onClick={joinAnyway}>
<Text size="B400">Join anyway</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={cancel}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -0,0 +1,129 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { CallEmbed } from '../../plugins/call';
import { CallControls } from './CallControls';
import { callChatAtom } from '../../state/callEmbed';
import { CONTROLS_AUTOHIDE_MS, wantsBrowserFullscreen } from './landscapeCall';
const isStandalone = (): boolean =>
window.matchMedia?.('(display-mode: standalone)').matches ||
(navigator as { standalone?: boolean }).standalone === true;
/**
* [Gitea #126] Full-screen call controls. Taps inside the call frame never
* reach us, so while the controls are hidden a transparent layer covers the
* call: the first tap shows them (and is otherwise swallowed, as in native
* video apps); with them up the layer is gone, so the call stays usable. They
* hide again after a few seconds without a touch. The first tap in an Android
* browser tab also enters the browser's own full screen (hides the address
* bar); it is left again when this unmounts (rotate back, hang up, exit).
*/
export function ImmersiveCallOverlay({
callEmbed,
onExit,
}: {
callEmbed: CallEmbed;
onExit: () => void;
}) {
const [shown, setShown] = useState(false);
const timer = useRef<number | undefined>(undefined);
const enteredFullscreen = useRef(false);
const setChat = useSetAtom(callChatAtom);
const armHide = useCallback(() => {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setShown(false), CONTROLS_AUTOHIDE_MS);
}, []);
useEffect(
() => () => {
window.clearTimeout(timer.current);
if (enteredFullscreen.current && document.fullscreenElement === document.documentElement) {
document.exitFullscreen().catch(() => undefined);
}
},
[],
);
const reveal = () => {
if (
wantsBrowserFullscreen({
userAgent: navigator.userAgent,
standalone: isStandalone(),
fullscreenEnabled: !!document.fullscreenEnabled,
alreadyFullscreen: !!document.fullscreenElement,
})
) {
document.documentElement
.requestFullscreen({ navigationUI: 'hide' })
.then(() => {
enteredFullscreen.current = true;
})
.catch(() => undefined);
}
setShown(true);
armHide();
};
if (!shown) {
return (
<div
role="button"
tabIndex={0}
aria-label="Show call controls"
onClick={reveal}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && reveal()}
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
/>
);
}
return (
<Box
direction="Column"
// Any touch on the controls keeps them up a little longer.
onPointerDown={armHide}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'auto',
paddingLeft: 'env(safe-area-inset-left)',
paddingRight: 'env(safe-area-inset-right)',
paddingBottom: 'env(safe-area-inset-bottom)',
background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0))',
}}
>
<Box
justifyContent="End"
gap="200"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="100" src={Icons.Message} />}
onClick={() => setChat(true)}
>
<Text size="B300">Chat</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="100" src={Icons.Cross} />}
onClick={onExit}
style={{ minWidth: toRem(40) }}
>
<Text size="B300">Exit full screen</Text>
</Button>
</Box>
<CallControls callEmbed={callEmbed} />
</Box>
);
}
@@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldGoImmersive, wantsBrowserFullscreen } from './landscapeCall';
const base = { landscapePhone: true, joinedHere: true, chatOpen: false, dismissed: false };
test('[#126] full screen only for a joined call on a sideways phone', () => {
assert.equal(shouldGoImmersive(base), true);
assert.equal(shouldGoImmersive({ ...base, landscapePhone: false }), false, 'upright / tablet');
assert.equal(shouldGoImmersive({ ...base, joinedHere: false }), false, 'prescreen / other room');
assert.equal(shouldGoImmersive({ ...base, chatOpen: true }), false, 'chat open');
assert.equal(shouldGoImmersive({ ...base, dismissed: true }), false, 'user exited');
});
test('[#126] browser full screen only in an Android browser tab', () => {
const env = {
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/140 Mobile',
standalone: false,
fullscreenEnabled: true,
alreadyFullscreen: false,
};
assert.equal(wantsBrowserFullscreen(env), true);
assert.equal(wantsBrowserFullscreen({ ...env, standalone: true }), false, 'installed app');
assert.equal(
wantsBrowserFullscreen({
...env,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari',
}),
false,
'iPhone',
);
assert.equal(wantsBrowserFullscreen({ ...env, alreadyFullscreen: true }), false);
assert.equal(wantsBrowserFullscreen({ ...env, fullscreenEnabled: false }), false);
});
+41
View File
@@ -0,0 +1,41 @@
/**
* [Gitea #126] Phone turned sideways in a call: the call takes the whole
* screen. A phone in landscape is ~840px wide (the "Tablet" size class), so
* the trigger is the shape of the screen, not the size class: landscape, at
* most 500px tall, touch input. Tablets and desktops never match.
*/
export const LANDSCAPE_PHONE_QUERY =
'(orientation: landscape) and (max-height: 500px) and (pointer: coarse)';
/** Controls hide again after this long without a touch on them. */
export const CONTROLS_AUTOHIDE_MS = 4000;
export type ImmersiveInputs = {
landscapePhone: boolean;
/** This room's call is the one we are in, and we have joined it. */
joinedHere: boolean;
/** The in-call chat is open (it replaces the call on small screens). */
chatOpen: boolean;
/** "Exit full screen" was pressed since the phone was last upright. */
dismissed: boolean;
};
/** Pure: whether the call should be full screen. */
export const shouldGoImmersive = (i: ImmersiveInputs): boolean =>
i.landscapePhone && i.joinedHere && !i.chatOpen && !i.dismissed;
/**
* Pure: use the browser's own full-screen mode too? Only where an address bar
* takes room: an Android browser tab (not the installed app / iOS, which have
* none or don't support it), when the page isn't already full screen.
*/
export const wantsBrowserFullscreen = (env: {
userAgent: string;
standalone: boolean;
fullscreenEnabled: boolean;
alreadyFullscreen: boolean;
}): boolean =>
/Android/i.test(env.userAgent) &&
!env.standalone &&
env.fullscreenEnabled &&
!env.alreadyFullscreen;
@@ -30,6 +30,7 @@ import {
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { setRoomAccountData } from '../../../utils/accountData';
type DeveloperToolsProps = {
requestClose: () => void;
@@ -58,8 +59,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.setRoomAccountData(room.roomId, type as any, content);
await setRoomAccountData(mx, room.roomId, type, content);
},
[mx, room.roomId],
);
@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { IContent, MatrixError } from 'matrix-js-sdk';
import {
Box,
Chip,
@@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { Cursor } from '../../../plugins/text-area';
import { sendRoomEvent, sendStateEvent } from '../../../utils/room';
const EDITOR_INTENT_SPACE_COUNT = 2;
@@ -51,9 +52,9 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
useCallback(
(evtType, evtStateKey, evtContent) => {
if (typeof evtStateKey === 'string') {
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
return sendStateEvent(mx, room.roomId, evtType, evtContent, evtStateKey);
}
return mx.sendEvent(room.roomId, evtType as any, evtContent);
return sendRoomEvent(mx, room.roomId, null, evtType, evtContent as IContent);
},
[mx, room],
),
@@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { sendStateEvent } from '../../../utils/room';
const EDITOR_INTENT_SPACE_COUNT = 2;
@@ -59,7 +60,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
useCallback(
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
(c) => sendStateEvent(mx, room.roomId, type, c, stateKey),
[mx, room, type, stateKey],
),
);
+26 -5
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -11,7 +11,6 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
@@ -21,10 +20,12 @@ import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView';
import { LANDSCAPE_PHONE_QUERY, shouldGoImmersive } from '../call/landscapeCall';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { callChatAtom, immersiveCallAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallEmbed, useCallJoined } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { threadsListAtom } from '../../state/threadsList';
@@ -57,6 +58,25 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
// [Gitea #126] Phone turned sideways in this room's call: full screen.
const landscapePhone = useMediaQuery(LANDSCAPE_PHONE_QUERY);
const joinedAnywhere = useCallJoined(callEmbed);
const [immersiveDismissed, setImmersiveDismissed] = useState(false);
useEffect(() => {
// "Exit full screen" lasts until the phone is upright again.
if (!landscapePhone) setImmersiveDismissed(false);
}, [landscapePhone]);
const setImmersiveCall = useSetAtom(immersiveCallAtom);
const immersive = shouldGoImmersive({
landscapePhone,
joinedHere: joinedAnywhere && callEmbed?.roomId === room.roomId,
chatOpen: chat,
dismissed: immersiveDismissed,
});
useEffect(() => {
setImmersiveCall(immersive);
return () => setImmersiveCall(false);
}, [immersive, setImmersiveCall]);
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
// rendered as siblings of RoomView, not descendants — share the same computed
// positions instead of falling back to the context default (Gitea #38).
@@ -170,7 +190,8 @@ export function Room() {
<PowerLevelsContextProvider value={powerLevels}>
<ReadPositionsContext.Provider value={readPositions}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
{immersive && <CallView onExitImmersive={() => setImmersiveDismissed(true)} />}
{!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
<Box grow="Yes">
+1 -1
View File
@@ -128,7 +128,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
const editor = useEditor();
const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
// "Hide typing and read receipts" must also make thread receipts private (matches markAsRead).
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
@@ -0,0 +1,76 @@
import React, { useEffect, useSyncExternalStore } from 'react';
import { Box, color, config, Icon, IconButton, Icons, Text } from 'folds';
import { useSetAtom } from 'jotai';
import {
destroyAllWidgetSessions,
destroyWidgetSession,
FLOATING_WIDGET,
floatingWidgetSessions,
subscribeWidgetSessions,
widgetSessionsVersion,
} from './widgetSessions';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { widgetViewingAtom, widgetsPanelAtom } from '../../../state/widgetsPanel';
import { zIndices } from '../../../styles/zIndex';
/**
* [Gitea #205] Title bar for a widget kept on screen (MSC2931) after its room
* or the Widgets panel was left. The widget's own frame is positioned by
* widgetSessions.ts right below this bar. Mounted once for the signed-in
* client; unmounting (logout) stops every widget.
*/
export function FloatingWidget() {
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const { navigateRoom } = useRoomNavigate();
const setPanel = useSetAtom(widgetsPanelAtom);
const setViewing = useSetAtom(widgetViewingAtom);
useEffect(() => () => destroyAllWidgetSessions(), []);
const session = floatingWidgetSessions()[0];
if (!session) return null;
const f = FLOATING_WIDGET;
const back = () => {
setViewing((prev) => ({ ...prev, [session.roomId]: session.widgetId }));
setPanel(true);
navigateRoom(session.roomId);
};
return (
<Box
role="region"
aria-label={`${session.name} (kept on screen)`}
alignItems="Center"
gap="100"
style={{
position: 'fixed',
right: f.right,
bottom: f.bottom + f.height,
width: f.width,
height: f.bar,
zIndex: zIndices.floatingWidget,
padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
borderRadius: '8px 8px 0 0',
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
}}
>
<Icon size="100" src={Icons.Category} />
<Text size="T300" truncate style={{ flexGrow: 1 }}>
{session.name}
</Text>
<IconButton size="300" radii="300" variant="Surface" aria-label="Back to room" onClick={back}>
<Icon size="100" src={Icons.ArrowGoLeft} />
</IconButton>
<IconButton
size="300"
radii="300"
variant="Surface"
aria-label={`Close ${session.name}`}
onClick={() => destroyWidgetSession(session.key)}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
);
}
+34 -124
View File
@@ -1,138 +1,48 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useSyncExternalStore } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import {
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
import { Room } from 'matrix-js-sdk';
import { Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import { isFromFilteredTimelineSet } from '../../../utils/room';
type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
import {
attachWidgetSession,
detachWidgetSession,
getWidgetSession,
subscribeWidgetSessions,
widgetSessionKey,
widgetSessionsVersion,
} from './widgetSessions';
type RoomWidgetViewProps = {
room: Room;
widget: Widget;
};
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
// that wait on the client handshake load). Display-only access is granted
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
// and, once granted, the room's live events are fed to the widget.
// Re-mounts only when the widget id or its (template) URL changes — not on every
// unrelated room-state update — so viewing a widget doesn't reload constantly.
// Shows one room widget in the Widgets panel. The widget itself runs in a
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
// layer and is positioned over this placeholder, so a widget that asked to
// stay on screen can keep running in a floating window after the panel closes.
// Display-only access is granted silently; reading/sending in this room is
// asked for (WidgetPermissionPrompt) and, once granted, the room's live events
// are fed to the widget. Re-attaches only when the widget id or its (template)
// URL changes, so viewing a widget doesn't reload it on unrelated state updates.
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const mx = useMatrixClient();
const containerRef = useRef<HTMLDivElement>(null);
const slotRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef(widget);
widgetRef.current = widget;
const [blocked, setBlocked] = useState(false);
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
const key = widgetSessionKey(room.roomId, widget.id);
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const session = getWidgetSession(key);
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
const current = widgetRef.current;
const slot = slotRef.current;
if (!slot) return undefined;
attachWidgetSession(mx, room, widgetRef.current, slot);
return () => detachWidgetSession(key, slot);
}, [mx, room, key, widget.templateUrl]);
const completeUrl = current.getCompleteUrl({
currentUserId: mx.getSafeUserId(),
widgetRoomId: room.roomId,
deviceId: mx.getDeviceId() ?? undefined,
baseUrl: mx.baseUrl,
});
// Security: never render a same-origin widget with allow-same-origin (a
// same-origin frame could break out of the sandbox against our own origin).
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
setBlocked(true);
return undefined;
}
setBlocked(false);
const iframe = document.createElement('iframe');
iframe.title = current.name || 'Widget';
iframe.sandbox.value =
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
iframe.allow = 'autoplay; clipboard-write;';
iframe.src = completeUrl;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
container.append(iframe);
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: current.id, url: current.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
setPendingAsk(undefined);
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
setPendingAsk({ requests, origin, resolve: done });
}),
);
const clientApi = new ClientWidgetApi(current, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, current.origin);
clientApi.setViewedRoomId(room.roomId);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
cancelAsk?.();
clientApi.stop();
iframe.remove();
};
}, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) {
if (session?.blocked) {
return (
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
@@ -149,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
return (
<>
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{pendingAsk && (
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{session?.pendingAsk && (
<WidgetPermissionPrompt
widgetName={widget.name || 'Widget'}
origin={pendingAsk.origin}
origin={session.pendingAsk.origin}
addedBy={addedBy}
requests={pendingAsk.requests}
onDone={pendingAsk.resolve}
requests={session.pendingAsk.requests}
onDone={session.pendingAsk.resolve}
/>
)}
</>
+50 -3
View File
@@ -16,7 +16,9 @@ import {
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { widgetViewingAtom } from '../../../state/widgetsPanel';
import * as css from './WidgetsPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { RoomWidgetView } from './RoomWidgetView';
@@ -27,7 +29,13 @@ import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
import {
generateWidgetId,
validateWidgetUrl,
widgetCompleteUrl,
widgetPopoutUrl,
WidgetUrlError,
} from './widgetUtils';
const urlErrorMessage = (err: WidgetUrlError): string => {
switch (err) {
@@ -54,12 +62,23 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const permissions = useRoomPermissions(creators, powerLevels);
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
const [viewingId, setViewingId] = useState<string | null>(null);
const [viewingByRoom, setViewingByRoom] = useAtom(widgetViewingAtom);
const viewingId = viewingByRoom[room.roomId] ?? null;
const setViewingId = (id: string | null) =>
setViewingByRoom((prev) => ({ ...prev, [room.roomId]: id }));
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
// [Gitea #205] Open a widget in its own window, beside the chat. It runs
// standalone there (no widget API), as in Element's popout.
const popoutUrl = (widget: (typeof widgets)[number]) =>
widgetPopoutUrl(widgetCompleteUrl(widget, mx, room.roomId), window.location.origin);
const popout = (widget: (typeof widgets)[number]) => {
const url = popoutUrl(widget);
if (url) window.open(url, '_blank', 'noopener,noreferrer');
};
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
@@ -144,7 +163,13 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
<Box grow="Yes" className={css.WidgetsPanelContent}>
{viewing ? (
<Box grow="Yes" direction="Column">
<Box shrink="No" style={{ padding: config.space.S200 }}>
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{ padding: config.space.S200 }}
>
<Button
variant="Secondary"
fill="Soft"
@@ -157,6 +182,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
{viewing.name || 'Widget'}
</Text>
</Button>
{popoutUrl(viewing) && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Open ${viewing.name || 'widget'} in a new window`}
onClick={() => popout(viewing)}
>
<Icon size="100" src={Icons.External} />
</IconButton>
)}
</Box>
<RoomWidgetView room={room} widget={viewing} />
</Box>
@@ -184,6 +220,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
{widget.name || widget.templateUrl}
</Text>
</Box>
{popoutUrl(widget) && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Open ${widget.name || 'widget'} in a new window`}
onClick={() => popout(widget)}
>
<Icon size="100" src={Icons.External} />
</IconButton>
)}
{canModify && (
<IconButton
size="300"
@@ -0,0 +1,341 @@
import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import {
Capability,
ClientWidgetApi,
IRoomEvent,
IWidgetApiRequest,
MatrixCapabilities,
Widget,
WidgetApiFromWidgetAction,
} from 'matrix-widget-api';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { isFromFilteredTimelineSet } from '../../../utils/room';
import { zIndices } from '../../../styles/zIndex';
/**
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
* Widgets panel or switching rooms.
*
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
* start in one app-level fixed layer. While the panel shows the widget, the
* frame is positioned over the panel's placeholder (`attach`), so it looks and
* behaves as before. When the placeholder goes away (`detach`) the widget is
* torn down, unless it is always-on-screen: then the same live frame moves to
* a small floating window (see FloatingWidget) until it is closed, the widget
* says it no longer needs to be on screen, or it is removed from the room.
*/
export type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
export type WidgetSession = {
readonly key: string;
readonly roomId: string;
readonly widgetId: string;
readonly name: string;
readonly templateUrl: string;
/** The URL is on this app's own origin: never rendered. */
readonly blocked: boolean;
alwaysOnScreen: boolean;
floating: boolean;
pendingAsk?: PendingAsk;
};
type Internal = WidgetSession & {
frame: HTMLDivElement;
slot: HTMLElement | null;
raf: number | undefined;
stop: () => void;
};
/** The floating window's geometry (FloatingWidget draws its title bar above). */
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
const sessions = new Map<string, Internal>();
const listeners = new Set<() => void>();
let version = 0;
const notify = () => {
version += 1;
listeners.forEach((l) => l());
};
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
export const widgetSessionsVersion = (): number => version;
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
`${roomId}|${widgetId}`;
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
export const floatingWidgetSessions = (): WidgetSession[] =>
[...sessions.values()].filter((s) => s.floating);
let layerEl: HTMLDivElement | undefined;
const layer = (): HTMLDivElement => {
if (!layerEl || !layerEl.isConnected) {
layerEl = document.createElement('div');
layerEl.id = 'lotus-widget-layer';
// The layer itself is click-through; only the frames inside take input.
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
document.body.append(layerEl);
}
return layerEl;
};
const hideFrame = (s: Internal) => {
s.frame.style.visibility = 'hidden';
};
const placeDocked = (s: Internal) => {
const slot = s.slot;
if (!slot) return;
const r = slot.getBoundingClientRect();
if (r.width === 0 || r.height === 0) {
hideFrame(s);
} else {
Object.assign(s.frame.style, {
visibility: 'visible',
left: `${r.left}px`,
top: `${r.top}px`,
width: `${r.width}px`,
height: `${r.height}px`,
right: '',
bottom: '',
borderRadius: '0',
boxShadow: 'none',
zIndex: String(DOCKED_Z),
});
}
s.raf = requestAnimationFrame(() => placeDocked(s));
};
const placeFloating = (s: Internal) => {
const f = FLOATING_WIDGET;
Object.assign(s.frame.style, {
visibility: 'visible',
left: '',
top: '',
right: `${f.right}px`,
bottom: `${f.bottom}px`,
width: `${f.width}px`,
height: `${f.height}px`,
borderRadius: '0 0 8px 8px',
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
zIndex: String(zIndices.floatingWidget),
});
};
const stopPlacing = (s: Internal) => {
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
s.raf = undefined;
};
export const destroyWidgetSession = (key: string): void => {
const s = sessions.get(key);
if (!s) return;
sessions.delete(key);
stopPlacing(s);
s.stop();
s.frame.remove();
notify();
};
export const destroyAllWidgetSessions = (): void => {
[...sessions.keys()].forEach(destroyWidgetSession);
};
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
export function attachWidgetSession(
mx: MatrixClient,
room: Room,
widget: Widget,
slot: HTMLElement,
): WidgetSession {
const key = widgetSessionKey(room.roomId, widget.id);
let s = sessions.get(key);
if (s && s.templateUrl !== widget.templateUrl) {
destroyWidgetSession(key);
s = undefined;
}
if (!s) s = startSession(mx, room, widget, key);
stopPlacing(s);
s.slot = slot;
s.floating = false;
if (!s.blocked) placeDocked(s);
notify();
return s;
}
/** The panel stopped showing the widget: float it, or tear it down. */
export function detachWidgetSession(key: string, slot: HTMLElement): void {
const s = sessions.get(key);
if (!s || s.slot !== slot) return;
s.slot = null;
stopPlacing(s);
if (!s.alwaysOnScreen || s.blocked) {
destroyWidgetSession(key);
return;
}
// One floating widget at a time: a newer one replaces an older one.
floatingWidgetSessions()
.filter((o) => o.key !== key)
.forEach((o) => destroyWidgetSession(o.key));
// Nobody is there to answer a permission prompt any more.
s.pendingAsk?.resolve(new Set(), false);
s.floating = true;
placeFloating(s);
notify();
}
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
const frame = document.createElement('div');
Object.assign(frame.style, {
position: 'fixed',
pointerEvents: 'auto',
overflow: 'hidden',
visibility: 'hidden',
});
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
const s: Internal = {
key,
roomId: room.roomId,
widgetId: widget.id,
name: widget.name || 'Widget',
templateUrl: widget.templateUrl,
blocked,
alwaysOnScreen: false,
floating: false,
frame,
slot: null,
raf: undefined,
stop: () => undefined,
};
sessions.set(key, s);
if (blocked) return s;
// Security: never render a same-origin widget with allow-same-origin (a
// same-origin frame could break out of the sandbox against our own origin).
const iframe = document.createElement('iframe');
iframe.title = widget.name || 'Widget';
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
iframe.allow = 'autoplay; clipboard-write;';
iframe.src = completeUrl;
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
frame.append(iframe);
layer().append(frame);
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: widget.id, url: widget.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
s.pendingAsk = undefined;
notify();
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
// A floating widget has nobody to ask: grant nothing extra.
if (s.floating) {
done(new Set(), false);
return;
}
s.pendingAsk = { requests, origin, resolve: done };
notify();
}),
);
const clientApi = new ClientWidgetApi(widget, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, widget.origin);
clientApi.setViewedRoomId(room.roomId);
// MSC2931: the widget asks to stay visible when the user navigates away.
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
clientApi.transport.reply(ev.detail, {
error: { message: 'Missing capability' },
});
return;
}
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
s.alwaysOnScreen = value;
clientApi.transport.reply(ev.detail, {});
// Floating only because it asked: once it no longer needs to be, go.
if (!value && s.floating) destroyWidgetSession(key);
else notify();
};
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
// The widget was removed from the room while it was floating.
if (
s.floating &&
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
ev.getStateKey() === widget.id &&
!ev.getContent()?.url
) {
destroyWidgetSession(key);
return;
}
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
s.stop = () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
cancelAsk?.();
clientApi.stop();
};
return s;
}
@@ -8,6 +8,7 @@ import {
generateWidgetId,
classifyWidgetCapabilities,
loadWidgetConsent,
widgetPopoutUrl,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
@@ -122,3 +123,17 @@ test('[#266] space structure, call membership and Lotus room policy can never be
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
// Built at runtime so the no-script-url lint rule doesn't flag the test data.
const scriptUrl = ['javascript', 'alert(1)'].join(':');
assert.equal(
widgetPopoutUrl('https://pad.example.org/p/room', APP),
'https://pad.example.org/p/room',
);
assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin');
assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https');
assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL');
assert.equal(widgetPopoutUrl('data:text/html,<b>x</b>', APP), null, 'data:');
assert.equal(widgetPopoutUrl('not a url', APP), null);
});
@@ -1,8 +1,10 @@
import type { MatrixClient } from 'matrix-js-sdk';
import {
Capability,
EventDirection,
EventKind,
MatrixCapabilities,
type Widget,
WidgetEventCapability,
} from 'matrix-widget-api';
@@ -48,6 +50,29 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
}
};
/** The widget's URL with its template variables filled in for this viewer. */
export const widgetCompleteUrl = (widget: Widget, mx: MatrixClient, roomId: string): string =>
widget.getCompleteUrl({
currentUserId: mx.getSafeUserId(),
widgetRoomId: roomId,
deviceId: mx.getDeviceId() ?? undefined,
baseUrl: mx.baseUrl,
});
/**
* [Gitea #205] The URL to open a widget in its own window, or null when it
* mustn't be opened: only cross-origin https (room state can carry anything,
* e.g. a `javascript:` URL from another client, whose origin is "null").
*/
export const widgetPopoutUrl = (completeUrl: string, appOrigin: string): string | null => {
try {
const u = new URL(completeUrl);
return u.protocol === 'https:' && u.origin !== appOrigin ? u.href : null;
} catch {
return null;
}
};
export const generateWidgetId = (): string =>
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
@@ -23,8 +23,11 @@ import {
nudgeFor,
NudgeRecord,
parseNudgeRecord,
shouldShowNudge,
pickNudge,
staleSessions,
staleSessionsText,
} from '../../utils/securityNudge';
import { preselectSignOutAtom } from '../../state/staleSessions';
import { StatusStrip } from '../server-status/ServerStatusBanner';
/** This device's backup connection: undefined until known, then true/false. */
@@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
return active;
};
/**
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
* once sync has settled, then hourly; [] until known or on error.
*/
const useStaleSessionIds = (enabled: boolean): string[] => {
const mx = useMatrixClient();
const alive = useAlive();
const [ids, setIds] = useState<string[]>([]);
useEffect(() => {
if (!enabled) return undefined;
const load = () =>
mx
.getDevices()
.then(({ devices }) => {
if (alive())
setIds(
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
(d) => d.device_id,
),
);
})
.catch(() => undefined);
load();
const t = window.setInterval(load, 60 * 60 * 1000);
return () => window.clearInterval(t);
}, [mx, enabled, alive]);
return ids;
};
const readRecord = (key: string): NudgeRecord => {
let raw: string | null = null;
try {
@@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
let deviceVerified: boolean | undefined;
if (status === VerificationStatus.Verified) deviceVerified = true;
else if (status === VerificationStatus.Unverified) deviceVerified = false;
const kind = nudgeFor({
const securityKind = nudgeFor({
crossSigning,
deviceVerified,
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
backupActive,
});
const staleIds = useStaleSessionIds(settled);
const setPreselect = useSetAtom(preselectSignOutAtom);
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
// One strip at a time: outages, maintenance and a lost connection win.
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
@@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
if (!settled || !deviceId || !kind || otherStrip) return null;
const copy = NUDGE_COPY[kind];
const copy =
kind === 'stale-sessions'
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
: NUDGE_COPY[kind];
const dismiss = () => {
const next = dismissNudge(kind, record, Date.now());
try {
@@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
return (
<StatusStrip
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
icon={Icons.ShieldUser}
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
action={{
label: copy.action,
onClick: () => {
// Review: the Devices page opens with these ticked for sign-out.
if (kind === 'stale-sessions') setPreselect(staleIds);
requestSettings('devices');
},
}}
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
dismissLabel="Not now"
onDismiss={dismiss}
/>
+1 -1
View File
@@ -205,7 +205,7 @@ export function About({ requestClose }: AboutProps) {
gap="400"
>
<SettingTile
title="Clear Cache & Reload"
title="Clear cache and reload"
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
after={
<Button
@@ -22,14 +22,14 @@ export function ContactInformation() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact Information</Text>
<Text size="L400">Contact information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email Address" description="Email address attached to your account.">
<SettingTile title="Email address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
@@ -136,7 +136,7 @@ export function IgnoredUserList() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Blocked Users</Text>
<Text size="L400">Blocked users</Text>
</Box>
<SequenceCard
className={SequenceCardStyle}
@@ -145,8 +145,8 @@ export function IgnoredUserList() {
gap="400"
>
<SettingTile
title="Select User"
description="Prevent receiving messages or invites from user by adding their userId."
title="Block someone"
description="Stop someone's messages and invites reaching you. Enter their Matrix ID, e.g. @name:server."
>
<Box direction="Column" gap="300">
<IgnoreUserInput userList={ignoredUsers} />
@@ -31,7 +31,7 @@ export function OidcManageAccount() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Management</Text>
<Text size="L400">Account management</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -72,7 +72,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
gap="400"
>
<SettingTile
title="Enable Developer Tools"
title="Enable developer tools"
after={
<Switch
variant="Primary"
@@ -90,7 +90,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
gap="400"
>
<SettingTile
title="Access Token"
title="Access token"
description="Copy access token to clipboard."
after={
<Button
@@ -35,7 +35,7 @@ export function CryptoDiagnostics() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Crypto Diagnostics</Text>
<Text size="L400">Crypto diagnostics</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -43,8 +43,8 @@ export function CryptoDiagnostics() {
gap="400"
>
<SettingTile
title="Crypto Diagnostics — captures E2EE error signatures this session"
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
title="Crypto diagnostics"
description="Captures encryption error signatures from this session. Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
after={
<Box alignItems="Center" gap="200" shrink="No">
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
@@ -93,8 +93,8 @@ export function Devices({ requestClose }: DevicesProps) {
gap="400"
>
<SettingTile
title="Device Verification"
description="To verify device identity and grant access to encrypted messages."
title="Device verification"
description="Verify this device so it can read your encrypted messages and others can trust it."
after={
<>
<EnableVerification visible={!crossSigningActive} />
@@ -130,8 +130,8 @@ function ExportKeysTile() {
return (
<>
<SettingTile
title="Export Messages Data"
description="Save password protected copy of encryption data on your device to decrypt messages later."
title="Export encryption keys"
description="Save a password-protected file of your encryption keys. Useful when moving to a device without key backup, or as an offline copy."
after={
<Box>
<Button
@@ -260,8 +260,8 @@ function ImportKeysTile() {
return (
<>
<SettingTile
title="Import Messages Data"
description="Load password protected copy of encryption data from device to decrypt your messages."
title="Import encryption keys"
description="Load a password-protected encryption key file to read older encrypted messages on this device."
after={
<Box>
{file ? (
@@ -307,7 +307,7 @@ function ImportKeysTile() {
export function LocalBackup() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Local Backup</Text>
<Text size="L400">Encryption key backup file</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
@@ -15,6 +16,8 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { SettingTile } from '../../../components/setting-tile';
import { preselectSignOutAtom } from '../../../state/staleSessions';
import { staleSessions } from '../../../utils/securityNudge';
type OtherDevicesProps = {
devices: IMyDevice[];
@@ -29,6 +32,23 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
const [deleted, setDeleted] = useState<Set<string>>(new Set());
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
// sign-out (one confirm below). OIDC accounts sign out on the provider's
// dashboard instead, so nothing is ticked there.
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
useEffect(() => {
if (preselect.length === 0) return;
if (!authMetadata) {
const present = new Set(devices.map((d) => d.device_id));
setDeleted(new Set(preselect.filter((id) => present.has(id))));
}
setPreselect([]);
}, [preselect, setPreselect, devices, authMetadata]);
const staleIds = new Set(
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
);
const handleDashboardOIDC = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
@@ -115,7 +135,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
gap="400"
>
<SettingTile
title="Device Dashboard"
title="Device dashboard"
description="Manage your devices on OIDC dashboard."
after={
<Button
@@ -132,8 +152,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
/>
</SequenceCard>
)}
{devices
{staleIds.size > 0 && (
<Text size="T200" priority="300">
{staleIds.size === 1
? "1 session hasn't been used in 3 months or more (marked Inactive)."
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
Sign out the ones you no longer use.
</Text>
)}
{[...devices]
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
.sort((d1, d2) => {
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
if (s1 !== s2) return s1 - s2;
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
})
@@ -145,6 +177,11 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
direction="Column"
gap="400"
>
{staleIds.has(device.device_id) && (
<Text size="L400" style={{ color: 'inherit' }}>
Inactive for 3+ months
</Text>
)}
<DeviceTile
device={device}
deleted={deleted.has(device.device_id)}
@@ -423,7 +423,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
return (
<>
<Box direction="Column" gap="100">
<Text size="L400">Favorite Packs</Text>
<Text size="L400">Favourite packs</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
@@ -431,7 +431,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
gap="400"
>
<SettingTile
title="Select Pack"
title="Add a pack"
description="Pick emojis and stickers pack from rooms to use in all rooms."
after={
<>
@@ -31,7 +31,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
return (
<Box direction="Column" gap="100">
<Text size="L400">Default Pack</Text>
<Text size="L400">Default pack</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
+161 -100
View File
@@ -61,6 +61,7 @@ import {
} from '../../../utils/lotusDenoiseUtils';
import { useSetting } from '../../../state/hooks/settings';
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
import { YourDataSection } from './YourData';
import {
CallAudioBitrate,
ChatBackground,
@@ -155,7 +156,7 @@ function DesktopChromeSetting() {
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Window Chrome (Beta)"
title="Custom window chrome (beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
/>
@@ -263,7 +264,7 @@ function KeychainMirrorSetting() {
}
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Login in the system keychain" description={description} />
<SettingTile title="Keep your sign-in in the system keychain" description={description} />
</SequenceCard>
);
}
@@ -294,12 +295,18 @@ const ThemeSelector = as<'div', ThemeSelectorProps>(
),
);
function SelectTheme({ disabled }: { disabled?: boolean }) {
const themes = useThemes();
function SelectTheme({ disabled, kind }: { disabled?: boolean; kind?: ThemeKind }) {
const allThemes = useThemes();
const themes = kind ? allThemes.filter((theme) => theme.kind === kind) : allThemes;
const themeNames = useThemeNames();
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
const selectedTheme =
themes.find((theme) => theme.id === themeId) ??
themes[0] ??
(kind === ThemeKind.Dark ? DarkTheme : LightTheme);
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
@@ -307,6 +314,10 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
const handleThemeSelect = (theme: Theme) => {
setThemeId(theme.id);
// [Gitea #455] Also remember it as this kind's variant, so switching to
// System later keeps the user's pick.
if (theme.kind === ThemeKind.Light) setLightThemeId(theme.id);
else setDarkThemeId(theme.id);
setMenuCords(undefined);
};
@@ -391,7 +402,7 @@ function SystemThemePreferences() {
return (
<Box wrap="Wrap" gap="400">
<SettingTile
title="Light Theme:"
title="Light theme:"
after={
<Chip
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
@@ -432,7 +443,7 @@ function SystemThemePreferences() {
}
/>
<SettingTile
title="Dark Theme:"
title="Dark theme:"
after={
<Chip
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
@@ -534,6 +545,26 @@ function PageZoomInput() {
function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const allThemes = useThemes();
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
const currentKind = allThemes.find((t) => t.id === themeId)?.kind ?? ThemeKind.Light;
let themeMode: 'system' | 'light' | 'dark' = 'system';
if (!systemTheme) themeMode = currentKind === ThemeKind.Dark ? 'dark' : 'light';
const chooseThemeMode = (mode: 'system' | 'light' | 'dark') => {
if (mode === 'system') {
setSystemTheme(true);
return;
}
setSystemTheme(false);
// Use the variant already chosen for that kind (or the default one).
const kind = mode === 'dark' ? ThemeKind.Dark : ThemeKind.Light;
if (allThemes.find((t) => t.id === themeId)?.kind === kind) return;
const preferred = mode === 'dark' ? darkThemeId : lightThemeId;
const fallback = mode === 'dark' ? DarkTheme.id : LightTheme.id;
setThemeId(allThemes.find((t) => t.id === preferred && t.kind === kind)?.id ?? fallback);
};
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
@@ -575,38 +606,64 @@ function Appearance() {
direction="Column"
gap="400"
>
<SettingTile
title="System Theme"
description="Choose between light and dark theme based on system preference."
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
/>
{systemTheme && <SystemThemePreferences />}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
{/* [Gitea #455] One control instead of a System switch plus a Theme
dropdown that was greyed out whenever the switch was on. */}
<SettingTile
title="Theme"
description="Theme to use when system theme is not enabled."
after={<SelectTheme disabled={systemTheme} />}
description={
themeMode === 'system'
? "Follows your device's light or dark setting."
: 'Always use this theme, whatever your device is set to.'
}
after={
<Box gap="100" role="radiogroup" aria-label="Theme" shrink="No" wrap="Wrap">
{(
[
['system', 'System'],
['light', 'Light'],
['dark', 'Dark'],
] as const
).map(([mode, label]) => (
<Chip
key={mode}
role="radio"
aria-checked={themeMode === mode}
variant={themeMode === mode ? 'Primary' : 'Secondary'}
outlined={themeMode === mode}
radii="Pill"
onClick={() => chooseThemeMode(mode)}
>
<Text size="B300">{label}</Text>
</Chip>
))}
</Box>
}
/>
{themeMode === 'system' && <SystemThemePreferences />}
{themeMode !== 'system' && (
<SettingTile
title={themeMode === 'light' ? 'Light theme:' : 'Dark theme:'}
after={<SelectTheme kind={themeMode === 'light' ? ThemeKind.Light : ThemeKind.Dark} />}
/>
)}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Monochrome Mode"
title="Monochrome mode"
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
title="Emoji style: Twemoji"
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
<SettingTile title="Page zoom" after={<PageZoomInput />} />
</SequenceCard>
<SequenceCard
@@ -616,7 +673,7 @@ function Appearance() {
gap="200"
>
<SettingTile
title="Chat Background"
title="Chat background"
description="Pattern applied behind the message timeline."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
@@ -626,7 +683,7 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Pause Background Animations"
title="Pause background animations"
description="Stop background animation to reduce motion or improve performance."
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
/>
@@ -639,7 +696,7 @@ function Appearance() {
gap="200"
>
<SettingTile
title="Seasonal Theme"
title="Seasonal theme"
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
@@ -659,7 +716,7 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Profile on Every Message"
title="Show name and avatar on every message"
description="Display avatar and name on each message instead of grouping consecutive messages."
after={
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
@@ -673,7 +730,7 @@ function Appearance() {
gap="200"
>
<SettingTile
title="Night Light"
title="Night light"
description="Reduce blue light with a warm orange tint overlay."
after={
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
@@ -753,7 +810,7 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Glassmorphism Sidebar"
title="Glass sidebar"
description="Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
after={
<Switch
@@ -767,8 +824,8 @@ function Appearance() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Lotus Terminal Mode"
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
title="Lotus Terminal mode"
description="A retro terminal look: orange and cyan on a dot grid, CRT scanlines and a boot animation (press Esc to skip)."
after={
<Box direction="Row" gap="200" alignItems="Center">
{lotusTerminal && (
@@ -802,7 +859,7 @@ function Appearance() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="UI Font"
title="Interface font"
description="Font used throughout the interface."
after={
<SettingsSelect
@@ -822,7 +879,7 @@ function Appearance() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="@Mention Highlight Color"
title="@mention highlight colour"
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
after={
<HexColorPickerPopOut
@@ -866,7 +923,7 @@ function Appearance() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Accent Color"
title="Custom accent colour"
description={
lotusTerminal
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
@@ -1240,7 +1297,7 @@ function SelectDateFormat() {
return (
<>
<SettingTile
title="Date Format"
title="Date format"
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
/>
@@ -1256,10 +1313,10 @@ function DateAndTime() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Date & Time</Text>
<Text size="L400">Date and time</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="24-Hour Time Format"
title="24-hour time"
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
/>
</SequenceCard>
@@ -1471,7 +1528,7 @@ function Editor() {
<Text size="L400">Editor</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="ENTER for Newline"
title="Enter for a new line"
description={`Use ${
isMacOS() ? KeySymbol.Command : 'Ctrl'
} + ENTER to send message and ENTER for newline.`}
@@ -1480,20 +1537,20 @@ function Editor() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Markdown Formatting"
title="Markdown formatting"
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Offer to Format Pasted Code"
title="Offer to format pasted code"
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Formatting Toolbar"
title="Formatting toolbar"
description="Show bold, italic, code and other formatting buttons above the message input."
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
/>
@@ -1505,7 +1562,7 @@ function Editor() {
gap="300"
>
<SettingTile
title="Composer Toolbar"
title="Composer toolbar"
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
/>
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
@@ -1540,28 +1597,28 @@ function Privacy() {
<Text size="L400">Privacy</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Strip Tracking Parameters from Links"
title="Strip tracking parameters from links"
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Remove Photo Metadata Before Sending"
title="Remove photo metadata before sending"
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Typing & Read Receipts"
title="Hide typing and read receipts"
description="Turn off both typing status and read receipts to keep your activity private."
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Private Read Receipts"
title="Private read receipts"
description="Send read receipts only to you and the server — other users won't see when you've read messages."
after={
<Switch
@@ -1574,7 +1631,7 @@ function Privacy() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Online Status"
title="Hide online status"
description="Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
/>
@@ -1656,7 +1713,7 @@ function SettingsSyncSection() {
<Text size="L400">Sync</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Sync Settings Across Devices"
title="Sync settings across devices"
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
/>
@@ -1664,7 +1721,7 @@ function SettingsSyncSection() {
{settingsSync && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Use This Device's Settings Everywhere"
title="Use this device's settings everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
// [Gitea #273] On phones the two buttons squeezed the text into a
// narrow column; put them under the description there.
@@ -1749,6 +1806,7 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [callSummaryToast, setCallSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
@@ -1798,14 +1856,14 @@ function Calls() {
<Text size="L400">Calls</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join with Camera On"
title="Join with camera on"
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Noise Suppression"
title="Noise suppression"
description={
<Box direction="Column" gap="200">
{/* [Gitea #271] T200 like every other setting description. */}
@@ -1862,7 +1920,7 @@ function Calls() {
<Box direction="Column" gap="200">
<Text size="L400">Model</Text>
<SettingTile
title="ML Model"
title="ML model"
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
after={
<SettingsSelect<DenoiseModelId>
@@ -1978,7 +2036,7 @@ function Calls() {
>
<Text size="L400">Enhancements</Text>
<SettingTile
title="Series Suppression"
title="Also use the browser's noise suppression"
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
after={
<Switch
@@ -1990,7 +2048,7 @@ function Calls() {
/>
<SettingTile
title="Noise Gate"
title="Noise gate"
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
after={
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
@@ -2026,7 +2084,7 @@ function Calls() {
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="L400">Test &amp; calibrate</Text>
<Text size="L400">Test and calibrate</Text>
<Text size="T200" priority="300">
Audition the selected model and tune the gate without joining a call. Changes to the
settings above apply the next time you start a monitor or play a clip.
@@ -2048,7 +2106,7 @@ function Calls() {
gap="400"
>
<SettingTile
title="After a Restart"
title="Rejoin calls after a restart"
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
after={
<SettingsSelect<'ask' | 'auto' | 'off'>
@@ -2062,21 +2120,28 @@ function Calls() {
/>
}
/>
<SettingTile
title="Summary after calls"
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
after={
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
}
/>
{hapticsSupported() && (
<SettingTile
title="Haptic Feedback"
title="Haptic feedback"
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
/>
)}
<SettingTile
title="Push to Talk"
title="Push to talk"
description="Mute your microphone by default. Hold the PTT key to speak."
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
/>
{pttMode && (
<SettingTile
title="PTT Key"
title="Push-to-talk key"
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
after={
<Button
@@ -2094,7 +2159,7 @@ function Calls() {
/>
)}
<SettingTile
title="Push to Deafen"
title="Push to deafen"
description={
deafenBind.error ??
(deafenHotkey
@@ -2135,7 +2200,7 @@ function Calls() {
/>
{isTauriEnv() && (
<SettingTile
title="Hotkeys Work Outside the Window"
title="Hotkeys work outside the window"
description="Keep Push to Talk and Push to Deafen working while a game or another app has focus. The keys are only watched during a call and are never blocked from other apps. Windows only; some anti-cheat systems may prevent it."
after={
<Switch variant="Primary" value={globalCallHotkeys} onChange={setGlobalCallHotkeys} />
@@ -2157,13 +2222,13 @@ function Calls() {
}
/>
<SettingTile
title="AFK Auto-Mute"
title="AFK auto-mute"
description="Automatically mute your microphone when you have been silent in a call."
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
/>
{afkAutoMute && (
<SettingTile
title="Idle Timeout"
title="Idle timeout"
description="How long to wait before auto-muting."
after={
<SettingsSelect
@@ -2195,7 +2260,7 @@ function Calls() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
<SettingTile title="Ringtone volume" description="Volume of the incoming call ringtone." />
<Box
direction="Row"
alignItems="Center"
@@ -2219,7 +2284,7 @@ function Calls() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join & Leave Sounds"
title="Join and leave sounds"
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
after={
<SettingsSelect
@@ -2238,7 +2303,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Microphone Bitrate"
title="Microphone bitrate"
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
after={
<SettingsSelect<CallAudioBitrate>
@@ -2252,7 +2317,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screenshare Bitrate"
title="Screen share bitrate"
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
after={
<SettingsSelect<ScreenshareBitrate>
@@ -2266,7 +2331,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Screenshare Framerate"
title="Screen share frame rate"
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
after={
<SettingsSelect<ScreenshareFramerate>
@@ -2292,7 +2357,7 @@ function Calls() {
/>
{soundboardEnabled && (
<>
<SettingTile title="Soundboard Volume" />
<SettingTile title="Soundboard volume" />
<Box
alignItems="Center"
gap="200"
@@ -2637,14 +2702,14 @@ function Messages() {
<Box direction="Column" gap="100">
<Text size="L400">Messages</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
<SettingTile title="Message layout" after={<SelectMessageLayout />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
<SettingTile title="Message spacing" after={<SelectMessageSpacing />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Legacy Username Color"
title="Legacy username colour"
after={
<Switch
variant="Primary"
@@ -2656,7 +2721,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Membership Change"
title="Hide joins and leaves"
after={
<Switch
variant="Primary"
@@ -2668,7 +2733,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Profile Change"
title="Hide name and avatar changes"
after={
<Switch
variant="Primary"
@@ -2680,7 +2745,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Disable Media Auto Load"
title="Don't load media automatically"
after={
<Switch
variant="Primary"
@@ -2692,14 +2757,14 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="URL Preview"
title="Link previews"
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="URL Preview in Encrypted Rooms"
title="Link previews in encrypted rooms"
description={
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
@@ -2723,34 +2788,29 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Inline Media Players"
title="Inline media players"
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
after={
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
}
/>
</SequenceCard>
{/* [Gitea #457] Only where this build has a GIF key: a disabled switch
with a paragraph about missing configuration helps nobody. */}
{gifAvailable && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="GIF picker"
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
after={
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
}
/>
</SequenceCard>
)}
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="GIF Picker"
description={
gifAvailable
? 'Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default.'
: "GIF search isn't set up for this version of the app (no Giphy key in its configuration), so there is no GIF button even when this is on."
}
after={
<Switch
variant="Primary"
value={gifPickerEnabled && gifAvailable}
onChange={setGifPickerEnabled}
disabled={!gifAvailable}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Hidden Events"
title="Show hidden events"
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
@@ -2758,7 +2818,7 @@ function Messages() {
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Enforce Message Retention"
title="Enforce message retention"
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
after={
<Switch
@@ -2775,7 +2835,7 @@ function Messages() {
direction="Column"
>
<SettingTile
title="Translate Messages Into"
title="Translate messages into"
description={
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
@@ -2793,7 +2853,7 @@ function Messages() {
/>
{translationSupported && (
<SettingTile
title="Auto-translate Incoming Messages"
title="Auto-translate incoming messages"
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
/>
@@ -2954,9 +3014,9 @@ function AppUpdates() {
return (
<Box direction="Column" gap="100">
<Text size="L400">App Updates</Text>
<Text size="L400">App updates</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Check for Updates" description={description} after={after} />
<SettingTile title="Check for updates" description={description} after={after} />
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
{status.state === 'error' && !packageUpdate && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
@@ -2999,6 +3059,7 @@ export function General({ requestClose }: GeneralProps) {
<Privacy />
<StorageUsage />
<SettingsSyncSection />
<YourDataSection />
<Calls />
<DesktopSettings />
<AppUpdates />
@@ -0,0 +1,285 @@
import React, { useCallback, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { Method } from 'matrix-js-sdk';
import { Box, Button, Text, color } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { Settings, settingsAtom } from '../../../state/settings';
import {
buildExport,
EXPORT_ACCOUNT_DATA,
ExportFile,
ImportPlanItem,
mergeData,
parseExport,
planImport,
} from '../../../utils/dataExport';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const DECORATION_FIELD = 'io.lotus.avatar_decoration';
type Current = {
accountData: Record<string, unknown>;
decoration: string | null;
localSettings: Record<string, unknown> | null;
};
/**
* [Gitea #114] "Your data": export the account's Lotus data (settings,
* bookmarks, notes, sections, ...) to a file and import it here or on another
* account. Import merges: new things are added; for things that differ the
* user picks "keep mine" (default) or "use the file's". Message history and
* encryption keys have their own exports.
*/
export function YourDataSection() {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const [settings, setSettings] = useAtom(settingsAtom);
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<{ file: ExportFile; plan: ImportPlanItem[] }>();
const [preferFile, setPreferFile] = useState(false);
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string }>();
const [busy, setBusy] = useState(false);
const readCurrent = useCallback(async (): Promise<Current> => {
const accountData: Record<string, unknown> = {};
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
const c = mx.getAccountData(type as never)?.getContent();
if (c && Object.keys(c).length > 0) accountData[type] = c;
});
let decoration: string | null = null;
try {
const profile = await mx.http.authedRequest<Record<string, unknown>>(
Method.Get,
`/profile/${encodeURIComponent(userId)}`,
);
const v = profile[DECORATION_FIELD];
decoration = typeof v === 'string' && v ? v : null;
} catch {
/* no decoration */
}
return {
accountData,
decoration,
localSettings: settings as unknown as Record<string, unknown>,
};
}, [mx, userId, settings]);
const exportData = async () => {
setBusy(true);
setMessage(undefined);
try {
const cur = await readCurrent();
const file = buildExport({ userId, ...cur });
const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lotus-chat-data-${userId.replace(/[^a-z0-9]+/gi, '-')}-${file.exportedAt.slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
setMessage({ tone: 'ok', text: 'Exported. Keep the file somewhere safe.' });
} catch {
setMessage({ tone: 'error', text: "Couldn't export your data. Please try again." });
} finally {
setBusy(false);
}
};
const chooseFile = async (f: File | undefined) => {
if (!f) return;
setMessage(undefined);
const parsed = parseExport(await f.text());
if (!parsed.ok) {
setMessage({ tone: 'error', text: parsed.error });
return;
}
const plan = planImport(parsed.file, await readCurrent());
setPreferFile(false);
setPending({ file: parsed.file, plan });
};
const applyImport = async () => {
if (!pending) return;
setBusy(true);
try {
const cur = await readCurrent();
const { file } = pending;
await Object.entries(file.accountData).reduce<Promise<void>>(
async (prev, [type, incoming]) => {
await prev;
const r = mergeData(cur.accountData[type], incoming, preferFile);
if (r.added > 0 || (preferFile && r.conflicts > 0)) {
await mx.setAccountData(type as never, r.value as never);
}
},
Promise.resolve(),
);
const deco = file.profile.decoration;
if (deco && deco !== cur.decoration && (!cur.decoration || preferFile)) {
await mx.http.authedRequest(
Method.Put,
`/profile/${encodeURIComponent(userId)}/${DECORATION_FIELD}`,
undefined,
{ [DECORATION_FIELD]: deco },
);
invalidateDecorationCache(userId);
}
if (file.local.settings) {
const r = mergeData(cur.localSettings ?? {}, file.local.settings, preferFile);
setSettings({ ...settings, ...(r.value as Partial<Settings>) });
}
setPending(undefined);
setMessage({ tone: 'ok', text: 'Imported.' });
} catch {
setMessage({ tone: 'error', text: "Couldn't finish the import. Nothing else was changed." });
} finally {
setBusy(false);
}
};
const totals = pending?.plan.reduce(
(t, p) => ({ added: t.added + p.added, conflicts: t.conflicts + p.conflicts }),
{ added: 0, conflicts: 0 },
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Your data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Export your data"
description="Settings, bookmarks, private notes, room sections and names, reminders, status presets, soundboard and emoji, as one file. Not your messages or encryption keys (they have their own exports)."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={busy}
onClick={exportData}
>
<Text size="B300">Export</Text>
</Button>
}
/>
<SettingTile
title="Import data"
description="Load a Lotus Chat data file into this account. New items are added; you choose what happens to things that differ."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={busy}
onClick={() => fileInput.current?.click()}
>
<Text size="B300">Import…</Text>
</Button>
}
/>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
hidden
aria-label="Lotus Chat data file"
onChange={(e) => {
chooseFile(e.target.files?.[0]).catch(() => undefined);
e.target.value = '';
}}
/>
{pending && totals && (
<Box direction="Column" gap="200" role="region" aria-label="Import summary">
{pending.file.userId && pending.file.userId !== userId && (
<Text size="T200">
This file is from <b>{pending.file.userId}</b>.
</Text>
)}
{pending.plan.map((p) => (
<Text key={p.key} size="T200">
{p.label}:{' '}
{p.added === 0 && p.conflicts === 0
? 'already up to date'
: [p.added > 0 && `${p.added} new`, p.conflicts > 0 && `${p.conflicts} differ`]
.filter(Boolean)
.join(', ')}
</Text>
))}
{totals.conflicts > 0 && (
<Box direction="Column" gap="100" role="radiogroup" aria-label="Things that differ">
<Text size="T200">
<b>{totals.conflicts}</b>{' '}
{totals.conflicts === 1 ? 'thing differs' : 'things differ'} from what&apos;s
here:
</Text>
<label>
<input
type="radio"
name="your-data-conflicts"
checked={!preferFile}
onChange={() => setPreferFile(false)}
/>{' '}
<Text as="span" size="T200">
Keep mine
</Text>
</label>
<label>
<input
type="radio"
name="your-data-conflicts"
checked={preferFile}
onChange={() => setPreferFile(true)}
/>{' '}
<Text as="span" size="T200">
Use the file&apos;s
</Text>
</label>
</Box>
)}
<Box gap="200">
<Button
size="300"
variant="Primary"
radii="300"
disabled={busy || (totals.added === 0 && !(preferFile && totals.conflicts > 0))}
onClick={applyImport}
>
<Text size="B300">Import</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
disabled={busy}
onClick={() => setPending(undefined)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
)}
{message && (
<Text
size="T200"
role="status"
style={{ color: message.tone === 'error' ? color.Critical.Main : undefined }}
>
{message.text}
</Text>
)}
</SequenceCard>
</Box>
);
}
@@ -82,7 +82,7 @@ export function AllMessagesNotifications() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">All Messages</Text>
<Text size="L400">All messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Secondary" fill="Solid">
@@ -97,7 +97,7 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="1-to-1 Chats"
title="1-to-1 chats"
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
/>
</SequenceCard>
@@ -108,7 +108,7 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="1-to-1 Chats (Encrypted)"
title="1-to-1 chats (encrypted)"
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
@@ -137,7 +137,7 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="Rooms (Encrypted)"
title="Rooms (encrypted)"
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
@@ -171,7 +171,7 @@ export function KeywordMessagesNotifications() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Keyword Messages</Text>
<Text size="L400">Keyword messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
@@ -186,7 +186,7 @@ export function KeywordMessagesNotifications() {
gap="400"
>
<SettingTile
title="Select Keyword"
title="Add a keyword"
description="Set a notification preference for message containing given keyword."
>
<KeywordInput />
@@ -9,7 +9,6 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
import { PushRuleEditor } from './PushRuleEditor';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { settingsAtom, Settings } from '../../../state/settings';
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
@@ -81,7 +80,7 @@ function NotificationPresets() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Quick Presets</Text>
<Text size="L400">Quick presets</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
{PRESETS.map((preset) => (
@@ -150,17 +149,6 @@ export function Notifications({ requestClose }: NotificationsProps) {
<SpecialMessagesNotifications />
<KeywordMessagesNotifications />
<PushRuleEditor />
<Box direction="Column" gap="100">
<Text size="L400">Block Messages</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile description='This option has been moved to "Account > Block Users" section.' />
</SequenceCard>
</Box>
</Box>
</PageContent>
</Scroll>
@@ -333,10 +333,10 @@ export function PushRuleEditor() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Advanced Push Rules</Text>
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
<Text size="L400">Advanced push rules</Text>
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
<RuleSection
title="Room Rules"
title="Room rules"
kind={PushRuleKind.RoomSpecific}
rules={roomRules}
addForm={
@@ -348,7 +348,7 @@ export function PushRuleEditor() {
}
/>
<RuleSection
title="Sender Rules"
title="Sender rules"
kind={PushRuleKind.SenderSpecific}
rules={senderRules}
addForm={
@@ -359,7 +359,7 @@ export function PushRuleEditor() {
/>
}
/>
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
<RuleSection title="Underride rules" kind={PushRuleKind.Underride} rules={underrideRules} />
</Box>
);
}
@@ -124,7 +124,7 @@ export function SpecialMessagesNotifications() {
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Special Messages</Text>
<Text size="L400">Special messages</Text>
<Box gap="100">
<Text size="T200">Badge: </Text>
<Badge radii="300" variant="Success" fill="Solid">
@@ -63,7 +63,7 @@ function EmailNotification() {
return (
<SettingTile
title="Email Notification"
title="Email notifications"
description={
<>
{result && !result.email && (
@@ -161,7 +161,7 @@ function PauseNotifications() {
return (
<SettingTile
title="Pause Notifications"
title="Pause notifications"
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
after={
active ? (
@@ -221,7 +221,7 @@ export function SystemNotification() {
gap="400"
>
<SettingTile
title="Desktop Notifications"
title="Desktop notifications"
description={
notifPermission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
@@ -230,7 +230,7 @@ export function SystemNotification() {
: 'Notifications are not supported by the system.'}
</Text>
) : (
<span>Show desktop notifications when message arrive.</span>
<span>Show desktop notifications when messages arrive.</span>
)
}
after={
@@ -263,8 +263,8 @@ export function SystemNotification() {
gap="400"
>
<SettingTile
title="Notification Sound"
description="Play sound when new message arrive."
title="Notification sound"
description="Play a sound when a new message arrives."
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
{isNotificationSounds && (
@@ -353,7 +353,7 @@ export function SystemNotification() {
gap="400"
>
<SettingTile
title="Quiet Hours"
title="Quiet hours"
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
/>
+27 -5
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds';
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
useEffect(() => {
if (toast.sticky) return;
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
timerRef.current = setTimeout(
() => {
dismiss(toast.id);
},
// [Gitea #253] A toast with a button stays long enough to use it.
toast.actionLabel ? 10_000 : 4000,
);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id, toast.sticky]);
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
const handleCardClick = () => {
if (toast.onClick) {
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
</div>
<div style={bodyStyle}>{toast.body}</div>
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
{toast.actionLabel && toast.onAction && (
<div style={{ marginTop: config.space.S200 }}>
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={(e) => {
e.stopPropagation();
toast.onAction?.();
dismiss(toast.id);
}}
>
<Text size="B300">{toast.actionLabel}</Text>
</Button>
</div>
)}
</div>
);
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react';
import { IAuthData, MatrixError } from 'matrix-js-sdk';
import { IAuthData, MatrixError, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
export enum RegisterFlowStatus {
@@ -44,6 +44,13 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes
export type AuthFlows = {
loginFlows: ILoginFlowsResponse;
registerFlows: RegisterFlowsResponse;
/**
* The homeserver's OAuth 2.0 metadata (`/_matrix/client/v1/auth_metadata`),
* when it uses native OAuth (MSC3861, e.g. matrix.org via MAS). The same
* source the SDK uses to refresh tokens, so sign-in is only offered when
* refresh will work too.
*/
authMetadata?: ValidatedAuthMetadata;
};
const AuthFlowsContext = createContext<AuthFlows | null>(null);
+18 -2
View File
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useCallHangupEvent } from './useCallEmbed';
import { toastQueueAtom } from '../state/toast';
import { describeCallEnd } from '../utils/callSummary';
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
import { callSummaryViewAtom } from '../state/callSummary';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
// before Element Call's hangup echo; give a late one this long before toasting.
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
*/
export function useCallEndedToast(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
const setSummaryView = useSetAtom(callSummaryViewAtom);
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
const fired = useRef(false);
useEffect(() => {
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
useCallHangupEvent(embed, () => {
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
fired.current = true;
if (!callSummaryToast) return;
const durationMs = Date.now() - embed.joinedAt;
const deafenedMs = embed.deafenedMs();
const roomName = embed.room.name ?? 'Voice call';
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
const toast = () =>
setToast({
id: `call-ended-${Date.now()}`,
displayName: 'Lotus Chat',
body: describeCallEnd(durationMs, embed.lastSummary),
roomName: embed.room.name ?? 'Voice call',
roomName,
roomId: embed.roomId,
// [Gitea #253] The full breakdown, snapshotted at hangup.
actionLabel: 'Details',
onAction: () =>
setSummaryView({
roomName,
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
}),
});
if (embed.lastSummary) toast();
else setTimeout(toast, SUMMARY_GRACE_MS);
+1 -1
View File
@@ -46,7 +46,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
keys.forEach((key) => {
if (draftPl[key] === undefined) {
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
(draftPl as Record<string, unknown>)[key] = DEFAULT_POWER_LEVELS[key];
}
});
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
+2
View File
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
@@ -248,6 +249,7 @@ function App() {
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
<CallSummaryDialog />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
+5 -1
View File
@@ -1,4 +1,6 @@
import React from 'react';
import { useAtomValue } from 'jotai';
import { immersiveCallAtom } from '../state/callEmbed';
import { useCallEmbed } from '../hooks/useCallEmbed';
import { CallStatus } from '../features/call-status';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
@@ -9,8 +11,10 @@ export function CallStatusRenderer() {
const selectedRoom = useSelectedRoom();
const screenSize = useScreenSizeContext();
// [Gitea #126] The full-screen call has its own controls.
const immersive = useAtomValue(immersiveCallAtom);
if (!callEmbed) return null;
if (!callEmbed || immersive) return null;
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
+69
View File
@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Box, Button, Text, config } from 'folds';
import { removeFallbackSession } from '../state/sessions';
import type { KeychainBootResult } from '../state/keychainSession';
import type { KeychainInvoke } from '../state/keychainMirror';
/**
* [Gitea #105] Shown instead of the app when the desktop app's sign-in is kept
* in the OS keychain but couldn't be read this time. Never a silent logout:
* Retry boots again; signing in again is an explicit choice.
*/
export function KeychainUnavailable({
boot,
invoke,
}: {
boot: KeychainBootResult;
invoke: KeychainInvoke | undefined;
}) {
const [busy, setBusy] = useState(false);
const missing = boot.mode === 'missing';
const signInAgain = async () => {
setBusy(true);
removeFallbackSession();
await invoke?.('secure_session_clear').catch(() => undefined);
window.location.reload();
};
return (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ minHeight: '100vh', padding: config.space.S700, textAlign: 'center' }}
>
<Text size="H3">Couldn&apos;t read your sign-in</Text>
<Text size="T300" style={{ maxWidth: 460 }}>
{missing
? 'Lotus Chat keeps your sign-in in Windows Credential Manager, but the entry is missing. If you removed it, sign in again.'
: 'Lotus Chat keeps your sign-in in Windows Credential Manager and couldn’t read it just now. This is usually temporary.'}
</Text>
{boot.mode === 'error' && (
<Text size="T200" priority="300">
{boot.error}
</Text>
)}
<Box gap="200">
<Button
variant="Primary"
size="400"
radii="300"
disabled={busy}
onClick={() => window.location.reload()}
>
<Text size="B400">Retry</Text>
</Button>
<Button
variant="Secondary"
fill="Soft"
size="400"
radii="300"
disabled={busy}
onClick={signInAgain}
>
<Text size="B400">Sign in again</Text>
</Button>
</Box>
</Box>
);
}
+2
View File
@@ -68,6 +68,7 @@ import { SHARE_PAGE_PATH } from '../../swShare';
import { RouteError } from './RouteError';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
@@ -197,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Outlet />
</ClientLayout>
<CallStatusRenderer />
<FloatingWidget />
</CallEmbedProvider>
<React.Suspense fallback={null}>
<SearchModalRenderer />
+9 -12
View File
@@ -10,7 +10,6 @@ import { SSOLogin } from '../SSOLogin';
import { TokenLogin } from './TokenLogin';
import { OidcLogin } from './OidcLogin';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { getOidcIssuer } from '../../../cs-api';
import { OrDivider } from '../OrDivider';
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
@@ -41,7 +40,7 @@ export function Login() {
const server = useAuthServer();
const discovery = useAutoDiscoveryInfo();
const { hashRouter } = useClientConfig();
const { loginFlows } = useAuthFlows();
const { loginFlows, authMetadata } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
@@ -58,21 +57,19 @@ export function Login() {
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
// Next-gen auth (MSC3861/MSC2965): when the server advertises an OIDC issuer in
// its `.well-known` discovery, use the native OIDC flow and suppress the
// password / legacy-SSO forms (those servers, e.g. MAS, reject `m.login.password`).
const oidc = getOidcIssuer(discovery);
if (oidc.issuer) {
// Next-gen auth (MSC3861): when the homeserver serves OAuth metadata
// (`/auth_metadata`, e.g. matrix.org via MAS), use the native OAuth flow and
// suppress the password / legacy-SSO forms, as Element does. Detected the way
// the SDK finds the auth server for refresh; the old `.well-known`
// `m.authentication` key is gone from matrix.org. A legacy SSO `loginToken`
// coming back from a redirect still completes below.
if (authMetadata && !loginSearchParams.loginToken) {
return (
<Box direction="Column" gap="500">
<Text as="h1" size="H2" priority="400">
Login
</Text>
<OidcLogin
issuer={oidc.issuer}
homeserverBaseUrl={discovery['m.homeserver'].base_url}
serverName={server}
/>
<OidcLogin homeserverBaseUrl={discovery['m.homeserver'].base_url} serverName={server} />
<span data-spacing-node />
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
+7 -4
View File
@@ -4,7 +4,8 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { startOidcLogin } from '../oidc/oidcLoginUtil';
type OidcLoginProps = {
issuer: string;
/** Register: open the auth server's sign-up page. */
register?: boolean;
homeserverBaseUrl: string;
serverName: string;
};
@@ -13,7 +14,7 @@ type OidcLoginProps = {
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
* single-button styling.
*/
export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) {
export function OidcLogin({ homeserverBaseUrl, serverName, register }: OidcLoginProps) {
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
useCallback(startOidcLogin, []),
);
@@ -26,7 +27,9 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
<Box direction="Column" gap="300">
{state.status === AsyncStatus.Error && (
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
Failed to start single sign-on. Please try again.
{register
? 'Failed to open sign-up. Please try again.'
: 'Failed to start single sign-on. Please try again.'}
</Text>
)}
<Button
@@ -35,7 +38,7 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
variant="Secondary"
fill="Soft"
outlined
onClick={() => startLogin(issuer, homeserverBaseUrl)}
onClick={() => startLogin(homeserverBaseUrl, register ? 'create' : undefined)}
disabled={ongoing}
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
>
+24 -17
View File
@@ -1,43 +1,45 @@
import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { createClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { setFallbackSession } from '../../../state/sessions';
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
import { getOidcCallbackUrl } from './oidcConfig';
import { completeOidcCodeGrant } from './oidcLoginUtil';
import {
invalidateCachedClient,
isStaleClientError,
parseOidcCallbackParams,
readStoredOidcIssuer,
takePendingOidcLogin,
} from './oidcState';
/**
* Exchange the authorization code for a Matrix session and persist it. The SDK
* (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage
* before the redirect and validates `state` (CSRF). `redirectUri` is the
* deterministic callback URL, recomputed here.
* Exchange the authorization code for a Matrix session and persist it. The
* pending sign-in saved before the redirect must exist for this `state`
* (CSRF check) and is consumed here.
*/
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } =
await completeAuthorizationCodeGrant(code, state);
const pending = takePendingOidcLogin(state);
if (!pending) throw new Error('No pending sign-in for this state.');
const tokenResponse = await completeOidcCodeGrant(code, pending);
// Derive Matrix user_id + device_id from the freshly-issued access token (the
// device is owned by the authentication service under MSC3861).
const tmp = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token });
const tmp = createClient({
baseUrl: pending.homeserverUrl,
accessToken: tokenResponse.access_token,
});
const { user_id: userId, device_id: deviceId } = await tmp.whoami();
if (!userId || !deviceId) {
throw new Error('Homeserver did not return a user/device for the OIDC session.');
}
setFallbackSession(tokenResponse.access_token, deviceId, userId, homeserverUrl, {
setFallbackSession(tokenResponse.access_token, deviceId, userId, pending.homeserverUrl, {
refreshToken: tokenResponse.refresh_token,
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
oidc: {
issuer: oidcClientSettings.issuer,
clientId: oidcClientSettings.clientId,
redirectUri: getOidcCallbackUrl(),
idTokenClaims: idTokenClaims as unknown as Record<string, unknown>,
issuer: pending.issuer,
clientId: pending.clientId,
redirectUri: pending.redirectUri,
},
});
};
@@ -71,14 +73,19 @@ function CallbackError({ message }: { message: string }) {
* router, since the redirect_uri must be a real non-hash path).
*/
export function OidcCallback() {
const params = parseOidcCallbackParams(window.location.search);
// Parsed once: a fresh object per render would re-run the effects below on
// every state change, and the code (and its pending sign-in) is single-use.
const params = useMemo(() => parseOidcCallbackParams(window.location.search), []);
const redeemed = useRef(false);
const [state, complete] = useAsyncCallback<void, unknown, [string, string]>(
useCallback(completeOidcLogin, []),
);
useEffect(() => {
if (params.kind === 'success') complete(params.code, params.state);
if (params.kind !== 'success' || redeemed.current) return;
redeemed.current = true;
complete(params.code, params.state);
}, [params, complete]);
useEffect(() => {
+12 -13
View File
@@ -1,4 +1,4 @@
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import type { OAuthRegistrationRequest } from 'matrix-js-sdk';
import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
@@ -17,22 +17,21 @@ export const getOidcCallbackUrl = (): string =>
withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH);
/**
* Client metadata sent during MSC2966 dynamic client registration.
* Client metadata sent during dynamic client registration.
*
* `registerOidcClient` drops any URI that doesn't share `clientUri` as a common
* base, so every URI here lives under our origin base.
* The SDK drops any URI that doesn't share `client_uri` as a common base, so
* every URI here lives under our origin base.
*/
export const getOidcClientMetadata = (): OidcRegistrationClientMetadata => {
export const getOidcClientMetadata = (): OAuthRegistrationRequest => {
// `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs.
const clientUri = getOriginBaseUrl();
return {
clientName: 'Lotus Chat',
clientUri,
logoUri: new URL(LotusLogo, window.location.origin).href,
applicationType: 'web',
contacts: ['support@lotusguild.org'],
tosUri: clientUri,
policyUri: clientUri,
redirectUris: [getOidcCallbackUrl()],
client_name: 'Lotus Chat',
client_uri: clientUri,
logo_uri: new URL(LotusLogo, window.location.origin).href,
application_type: 'web',
tos_uri: clientUri,
policy_uri: clientUri,
redirect_uris: [getOidcCallbackUrl()],
};
};
+58 -27
View File
@@ -1,57 +1,88 @@
import {
completeAuthorizationCodeGrant,
discoverAndValidateOIDCIssuerWellKnown,
generateOidcAuthorizationUrl,
OidcClientConfig,
registerOidcClient,
} from 'matrix-js-sdk';
import { createClient, OAuth2, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
import {
cacheClientId,
getCachedClientId,
invalidateCachedClient,
isStaleClientError,
PendingOidcLogin,
savePendingOidcLogin,
} from './oidcState';
export { completeAuthorizationCodeGrant };
/**
* The homeserver's OAuth 2.0 metadata (`GET /_matrix/client/v1/auth_metadata`).
* This is also how the SDK finds the auth server for token refresh and logout,
* so login uses the same source.
*/
export const discoverAuthMetadata = (homeserverBaseUrl: string): Promise<ValidatedAuthMetadata> =>
createClient({ baseUrl: homeserverBaseUrl }).getAuthMetadata();
export const getOrRegisterClientId = async (
config: OidcClientConfig,
issuer: string,
metadata: ValidatedAuthMetadata,
redirectUri: string,
): Promise<string> => {
const cached = getCachedClientId(issuer, redirectUri);
const cached = getCachedClientId(metadata.issuer, redirectUri);
if (cached) return cached;
const clientId = await registerOidcClient(config, getOidcClientMetadata());
cacheClientId(issuer, clientId, redirectUri);
const clientId = await OAuth2.registerClient(metadata, getOidcClientMetadata());
cacheClientId(metadata.issuer, clientId, redirectUri);
return clientId;
};
/**
* Begin the OIDC authorization-code flow: discover + validate the issuer,
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
* persists the transient code_verifier/state/nonce/homeserverUrl into
* sessionStorage for the callback), then redirect.
* Begin the OAuth authorization-code flow: discover the auth server through
* the homeserver, (re)register a client, remember the PKCE verifier + device
* under a fresh `state` (oidcState), then redirect. `prompt: 'create'` opens
* the auth server's sign-up page instead, where supported. The callback reads the
* result from the query string (`response_mode=query`).
*/
export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string): Promise<void> => {
export const startOidcLogin = async (
homeserverBaseUrl: string,
prompt?: 'create',
): Promise<void> => {
const redirectUri = getOidcCallbackUrl();
let issuer: string | undefined;
try {
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
const url = await generateOidcAuthorizationUrl({
metadata: config,
redirectUri,
const metadata = await discoverAuthMetadata(homeserverBaseUrl);
issuer = metadata.issuer;
const clientId = await getOrRegisterClientId(metadata, redirectUri);
const oauth = new OAuth2(metadata, { clientId });
const state = crypto.randomUUID();
const pending: PendingOidcLogin = {
issuer,
clientId,
deviceId: oauth.context.deviceId,
codeVerifier: oauth.context.codeVerifier,
homeserverUrl: homeserverBaseUrl,
nonce: crypto.randomUUID(),
});
window.location.assign(url);
redirectUri,
};
savePendingOidcLogin(state, pending);
// `prompt=create` asks the auth server for its sign-up page (Register),
// only where it says it supports that.
const usePrompt =
prompt && metadata.prompt_values_supported?.includes(prompt) ? prompt : undefined;
window.location.assign(
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query', usePrompt),
);
} catch (e) {
// #67 — drop the cached client so the next attempt re-registers, but ONLY
// when the failure says the client id itself was rejected. Network and
// discovery failures keep the cache, otherwise each retry would register
// yet another throwaway dynamic client at the provider.
if (isStaleClientError(e)) invalidateCachedClient(issuer);
if (issuer && isStaleClientError(e)) invalidateCachedClient(issuer);
throw e;
}
};
/**
* Exchange the authorization code using the pending sign-in saved before the
* redirect. The metadata is fetched again (it isn't stored).
*/
export const completeOidcCodeGrant = async (code: string, pending: PendingOidcLogin) => {
const metadata = await discoverAuthMetadata(pending.homeserverUrl);
const oauth = new OAuth2(metadata, {
clientId: pending.clientId,
deviceId: pending.deviceId,
codeVerifier: pending.codeVerifier,
});
return oauth.completeAuthorizationCodeGrant(code, pending.redirectUri);
};
+21 -11
View File
@@ -7,6 +7,8 @@ import {
parseOidcCallbackParams,
isStaleClientError,
readStoredOidcIssuer,
savePendingOidcLogin,
takePendingOidcLogin,
} from './oidcState';
const installStorage = (): Map<string, string> => {
@@ -131,21 +133,29 @@ test('isStaleClientError: also recognises the OidcCallback error-redirect shape
);
});
test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => {
const store = installSessionStorage();
const pending = {
issuer: 'https://issuer.example/',
clientId: 'client1',
deviceId: 'DEVICEABCD',
codeVerifier: 'v'.repeat(96),
homeserverUrl: 'https://hs.example',
redirectUri: 'https://app.example/oidc/callback',
};
test('pending sign-in: saved by state, read for the issuer, consumed once', () => {
installSessionStorage();
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
store.set(
'mx_oidc_xyz',
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
);
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
savePendingOidcLogin('xyz', pending);
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/');
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
assert.deepEqual(takePendingOidcLogin('xyz'), pending);
assert.equal(takePendingOidcLogin('xyz'), undefined, 'single use');
});
test('readStoredOidcIssuer tolerates corrupt or missing storage', () => {
test('pending sign-in tolerates corrupt or incomplete storage', () => {
const store = installSessionStorage();
store.set('mx_oidc_bad', '{ not json');
store.set('lotus_oidc_pending_bad', '{ not json');
assert.equal(readStoredOidcIssuer('bad'), undefined);
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
assert.equal(readStoredOidcIssuer('noauth'), undefined);
store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' }));
assert.equal(takePendingOidcLogin('partial'), undefined);
});
+50 -13
View File
@@ -97,23 +97,60 @@ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
};
/**
* #102 — the issuer for a pending sign-in isn't available on the callback's
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
* persists the pending sign-in's `SigninState` (including `authority` and
* `client_id`) into sessionStorage before redirecting, keyed by
* `mx_oidc_<state>` (WebStorageStateStore prefix `mx_oidc_`, keyed by the same
* `state` value the provider echoes back on redirect). Read it directly so a
* genuinely stale cached client id (`invalid_client`) can be evicted even when
* the callback errors before a token exchange is attempted.
* [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's
* OAuth2 class no longer persists anything). Saved in sessionStorage right
* before the redirect, keyed by the `state` we send, which the provider echoes
* back: a callback whose state has no entry is rejected (CSRF), and an entry is
* used once. Holds the PKCE code verifier, so it never goes to localStorage.
*/
export const readStoredOidcIssuer = (state: string): string | undefined => {
export type PendingOidcLogin = {
issuer: string;
clientId: string;
deviceId: string;
codeVerifier: string;
homeserverUrl: string;
redirectUri: string;
};
const PENDING_PREFIX = 'lotus_oidc_pending_';
const isPending = (v: unknown): v is PendingOidcLogin => {
if (!v || typeof v !== 'object') return false;
const p = v as Record<string, unknown>;
return ['issuer', 'clientId', 'deviceId', 'codeVerifier', 'homeserverUrl', 'redirectUri'].every(
(k) => typeof p[k] === 'string' && (p[k] as string).length > 0,
);
};
export const savePendingOidcLogin = (state: string, pending: PendingOidcLogin): void => {
sessionStorage.setItem(`${PENDING_PREFIX}${state}`, JSON.stringify(pending));
};
/** Read the pending sign-in for `state` without consuming it. */
export const readPendingOidcLogin = (state: string): PendingOidcLogin | undefined => {
try {
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`);
if (!raw) return undefined;
const { authority } = JSON.parse(raw) as { authority?: unknown };
return typeof authority === 'string' ? authority : undefined;
const parsed: unknown = JSON.parse(raw);
return isPending(parsed) ? parsed : undefined;
} catch {
return undefined;
}
};
/** Read and remove: an authorization code can only be redeemed once anyway. */
export const takePendingOidcLogin = (state: string): PendingOidcLogin | undefined => {
const pending = readPendingOidcLogin(state);
try {
sessionStorage.removeItem(`${PENDING_PREFIX}${state}`);
} catch {
// storage unavailable — nothing to clean up
}
return pending;
};
/**
* #102 — on the callback's error branch, the pending sign-in's issuer tells
* us whose cached client id to evict when the provider rejected it.
*/
export const readStoredOidcIssuer = (state: string): string | undefined =>
readPendingOidcLogin(state)?.issuer;
+25 -1
View File
@@ -12,6 +12,8 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo
import { getLoginPath } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { RegisterPathSearchParams } from '../../paths';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { OidcLogin } from '../login/OidcLogin';
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
useMemo(
@@ -25,7 +27,8 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea
export function Register() {
const server = useAuthServer();
const { loginFlows, registerFlows } = useAuthFlows();
const { loginFlows, registerFlows, authMetadata } = useAuthFlows();
const discovery = useAutoDiscoveryInfo();
const [searchParams] = useSearchParams();
const registerSearchParams = useRegisterSearchParams(searchParams);
const { sso } = useParsedLoginFlows(loginFlows.flows);
@@ -33,6 +36,27 @@ export function Register() {
// redirect to /login because only that path handle m.login.token
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
// Native OAuth servers (e.g. matrix.org via MAS) create accounts at the auth
// server; /register here would only offer the legacy forms. Mirrors Login.
if (authMetadata) {
return (
<Box direction="Column" gap="500">
<Text as="h2" size="H2" priority="400">
Register
</Text>
<OidcLogin
register
homeserverBaseUrl={discovery['m.homeserver'].base_url}
serverName={server}
/>
<span data-spacing-node />
<Text align="Center">
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
</Text>
</Box>
);
}
return (
<Box direction="Column" gap="500">
<Text as="h2" size="H2" priority="400">
+2 -2
View File
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
dismissToast(UPDATE_PROGRESS_TOAST);
if (!failure) return;
// Short on purpose; the full explanation and a Download installer button
// are in Settings → General → App Updates.
// are in Settings → General → App updates.
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
setToast({
id: UPDATE_FAILED_TOAST,
iconSrc: Icons.Warning,
displayName: 'Update didn’t install',
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App Updates.`,
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App updates.`,
roomName: 'System',
roomId: '',
sticky: true,
+12 -6
View File
@@ -46,6 +46,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { ClockSkewBanner } from './ClockSkewBanner';
import { KeyUploadBanner } from './KeyUploadBanner';
import {
ServerStatusBanner,
ServerStatusFeature,
@@ -54,7 +55,6 @@ import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
import { revokeOidcTokens } from '../../../client/oidcLogout';
import { useSessionSync } from '../../hooks/useSessionSync';
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
import { AutoDiscovery } from './AutoDiscovery';
@@ -158,11 +158,11 @@ const useLogoutListener = (mx?: MatrixClient) => {
pushSessionToSW();
mx?.stopClient();
// Best-effort issuer revocation for OIDC sessions (the token is already
// server-revoked here, but revoke the refresh token too). Before we drop
// the stored session below.
const loggedOutSession = getFallbackSession();
if (loggedOutSession?.oidc) {
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
// server-revoked here, but revoke the refresh token too). For an OAuth
// session the SDK's logout() only revokes at the auth server (no /logout
// call). Before we drop the stored session below.
if (getFallbackSession()?.oidc) {
await mx?.logout().catch(() => undefined);
}
await mx?.clearStores();
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
@@ -260,6 +260,12 @@ export function ClientRoot({ children }: ClientRootProps) {
{mx && !syncError && <ServerStatusBanner />}
{mx && !syncError && <SyncStatus mx={mx} />}
{mx && !syncError && <ClockSkewBanner />}
{mx && !syncError && (
// The fix opens LogoutDialog, which reads the client from context.
<MatrixClientProvider value={mx}>
<KeyUploadBanner />
</MatrixClientProvider>
)}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
+104
View File
@@ -0,0 +1,104 @@
import React, { useState, useSyncExternalStore } from 'react';
import FocusTrap from 'focus-trap-react';
import { Box, Button, config, Line, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { getKeyUploadWatch, subscribeKeyUploadWatch } from '../../utils/keyUploadWatch';
import { LogoutDialog } from '../../components/LogoutDialog';
import { stopPropagation } from '../../utils/keyboard';
/**
* [Gitea #201 KE-1] "Encryption on this device is out of sync." Shown while
* the server keeps rejecting this device's key uploads (keyUploadWatch). The
* fix is a fresh login, so the action opens the normal logout dialog, which
* already warns if the device isn't verified / backed up. Hideable for this
* session only: the problem doesn't go away by itself.
*/
export function KeyUploadBanner() {
const { stuck, lastError } = useSyncExternalStore(subscribeKeyUploadWatch, getKeyUploadWatch);
const [hidden, setHidden] = useState(false);
const [details, setDetails] = useState(false);
const [logout, setLogout] = useState(false);
if (!stuck || hidden) return null;
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Critical' })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
alignItems="Center"
gap="100"
role="alert"
>
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
<Text size="L400" align="Center">
Encryption on this device is out of sync with the server. New encrypted messages and
calls may fail until you sign out and back in.
</Text>
<Button
size="300"
variant="Critical"
fill="Solid"
radii="300"
onClick={() => setLogout(true)}
>
<Text size="B300">Fix: sign out…</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="Soft"
radii="300"
onClick={() => setDetails((v) => !v)}
aria-expanded={details}
>
<Text size="B300">Details</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
onClick={() => setHidden(true)}
>
<Text size="B300">Hide</Text>
</Button>
</Box>
{details && (
<Box direction="Column" gap="100" style={{ maxWidth: 640 }}>
<Text size="T200" align="Center">
The server already has encryption keys this device thinks it hasn&apos;t sent, so it
rejects every new upload and other people can&apos;t start fresh encrypted sessions
with you. This usually happens after the browser cleared this site&apos;s storage
while you stayed signed in. Before signing out, make sure this device is verified or
key backup is on (Settings → Devices), so your message history comes back when you
sign in again.
</Text>
{lastError && (
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
Server said: {lastError}
</Text>
)}
</Box>
)}
</Box>
<Line variant="Critical" size="300" />
{logout && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setLogout(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={() => setLogout(false)} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Box>
);
}
+5
View File
@@ -261,6 +261,11 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emit(CALL_ERROR_CHANGED);
}
/** [Gitea #121] Answer the fork's expected-device check: join or not. */
public sendDeviceDecision(join: boolean): void {
this.call.transport.send('io.lotus.device_decision', { join }).catch(() => undefined);
}
/** Press the fork's Reconnect / Try again. */
public reconnect(): void {
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
+32 -2
View File
@@ -15,6 +15,7 @@ import {
WidgetApiFromWidgetAction,
WidgetApiToWidgetAction,
WidgetDriver,
IWidgetApiRequest,
} from 'matrix-widget-api';
import { CallWidgetDriver } from './CallWidgetDriver';
import { trimTrailingSlash } from '../../utils/common';
@@ -25,13 +26,14 @@ import {
ElementMediaStateDetail,
ElementWidgetActions,
} from './types';
import { CallControl } from './CallControl';
import { CallControl, CallControlEvent } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -58,6 +60,8 @@ export interface LotusCallSummary {
reconnects: number;
poorMs: number;
verdict: 'good' | 'fair' | 'poor' | 'unknown';
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
details?: LotusCallSummaryDetails;
}
export class CallEmbed {
@@ -77,6 +81,25 @@ export class CallEmbed {
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
public lastSummary: LotusCallSummary | undefined;
// [Gitea #253] Time spent deafened (sound off), for the post-call Details.
private deafenedSince: number | undefined;
private deafenedTotal = 0;
public deafenedMs(now = Date.now()): number {
return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
}
private trackDeafened = (): void => {
if (!this.joined) return;
const deaf = !this.control.sound;
if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
if (!deaf && this.deafenedSince !== undefined) {
this.deafenedTotal += Date.now() - this.deafenedSince;
this.deafenedSince = undefined;
}
};
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
@@ -234,6 +257,9 @@ export class CallEmbed {
// sends io.lotus.inject_audio (only on an explicit user click), so
// arming it for every call is safe.
lotusAudioInject: 'true',
// [Gitea #121] Before joining, the fork checks that the mic / output the
// user last picked are plugged in, and asks us (ExpectedDevicesPrompt).
lotusDeviceCheck: 'true',
});
if (denoiseMode === 'ml') {
@@ -338,6 +364,8 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
this.initialState = controlState;
this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
// If the iframe document itself fails to load, fail fast.
iframe.onerror = () => {
this.settleLoad('iframe');
@@ -433,6 +461,7 @@ export class CallEmbed {
reconnects: data.reconnects ?? 0,
poorMs: data.poorMs ?? 0,
verdict: data.verdict ?? 'unknown',
details: parseCallSummaryDetails((data as { details?: unknown }).details),
};
}
}),
@@ -622,6 +651,7 @@ export class CallEmbed {
}
this.joined = true;
this.joinedAt = Date.now();
this.trackDeafened();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
@@ -796,7 +826,7 @@ export class CallEmbed {
const wrapped = (ev: CustomEvent<T>) => {
ev.preventDefault();
// Reply with success so the widget transport doesn't time out waiting for a response
this.call.transport.reply(ev.detail as any, {});
this.call.transport.reply(ev.detail as unknown as IWidgetApiRequest, {});
callback(ev);
};
return this.listenEvent(`action:${type}`, wrapped);
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const store = new Map<string, string>();
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
};
const m = await import('./expectedDevices');
test('[#121] parses the fork messages defensively', () => {
assert.deepEqual(
m.parseDeviceMissing({
missing: [
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
{ kind: 'videoinput', expected: 'Cam' },
{ kind: 'audioinput' },
],
}),
[
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
],
);
assert.deepEqual(m.parseDeviceMissing(undefined), []);
assert.deepEqual(m.parseDeviceMissing({ missing: 'x' }), []);
assert.equal(m.parseDeviceLost({ kind: 'nope' }), undefined);
});
test('[#121] wording', () => {
assert.deepEqual(
m.describeMissing({ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' }),
{
title: "Your usual microphone isn't connected",
expected: 'Arctis 7',
fallback: 'Laptop Mic',
},
);
assert.equal(
m.describeLost({ kind: 'audiooutput', expected: 'Arctis 7', fallback: null }),
'Arctis 7 disconnected — now using the default output.',
);
});
test('[#121] "Join anyway" is remembered for the session, per device set', () => {
store.clear();
const a = [{ kind: 'audioinput' as const, expected: 'Arctis 7', fallback: 'Laptop Mic' }];
const b = [{ kind: 'audiooutput' as const, expected: 'Arctis 7', fallback: null }];
assert.equal(m.isAcceptedThisSession(a), false);
m.acceptForSession(a);
assert.equal(m.isAcceptedThisSession(a), true);
assert.equal(m.isAcceptedThisSession(b), false, 'a different device still asks');
assert.equal(m.isAcceptedThisSession([...a, ...b]), false, 'more missing still asks');
});
+74
View File
@@ -0,0 +1,74 @@
/**
* [Gitea #121] The fork's expected-device check. Before joining, EC reports
* the user's usual mic / output (the one they last picked) as missing; we ask
* "Join anyway" or "Cancel". In a call, losing it is a toast. Pure helpers.
*/
export type DeviceKind = 'audioinput' | 'audiooutput';
export type MissingDevice = { kind: DeviceKind; expected: string; fallback: string | null };
const isKind = (k: unknown): k is DeviceKind => k === 'audioinput' || k === 'audiooutput';
const parseOne = (v: unknown): MissingDevice | undefined => {
if (!v || typeof v !== 'object') return undefined;
const { kind, expected, fallback } = v as Record<string, unknown>;
if (!isKind(kind) || typeof expected !== 'string' || !expected) return undefined;
return { kind, expected, fallback: typeof fallback === 'string' && fallback ? fallback : null };
};
/** `io.lotus.device_missing` → the missing devices (empty if malformed). */
export const parseDeviceMissing = (data: unknown): MissingDevice[] => {
const list = (data as { missing?: unknown } | undefined)?.missing;
if (!Array.isArray(list)) return [];
return list.map(parseOne).filter((d): d is MissingDevice => !!d);
};
/** `io.lotus.device_lost` → the device that went away. */
export const parseDeviceLost = (data: unknown): MissingDevice | undefined => parseOne(data);
const what = (kind: DeviceKind): string => (kind === 'audioinput' ? 'microphone' : 'audio output');
/** "Your usual microphone **Arctis 7** isn't connected." lines + the fallback. */
export const describeMissing = (
d: MissingDevice,
): { title: string; expected: string; fallback: string } => ({
title: `Your usual ${what(d.kind)} isn't connected`,
expected: d.expected,
fallback:
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output'),
});
export const describeLost = (d: MissingDevice): string =>
`${d.expected} disconnected — now using ${
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output')
}.`;
// "Join anyway" holds for the rest of the session for the same device(s).
const ACCEPTED_KEY = 'lotus-device-check-accepted';
const keyOf = (missing: MissingDevice[]): string =>
missing
.map((d) => `${d.kind}:${d.expected}`)
.sort()
.join('|');
const readAccepted = (): string[] => {
try {
const v: unknown = JSON.parse(sessionStorage.getItem(ACCEPTED_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
};
export const isAcceptedThisSession = (missing: MissingDevice[]): boolean =>
readAccepted().includes(keyOf(missing));
export const acceptForSession = (missing: MissingDevice[]): void => {
try {
const list = readAccepted();
const k = keyOf(missing);
if (!list.includes(k)) sessionStorage.setItem(ACCEPTED_KEY, JSON.stringify([...list, k]));
} catch {
// storage unavailable: ask again next time
}
};
+3
View File
@@ -18,3 +18,6 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
);
export const callChatAtom = atom<boolean>(false);
/** [Gitea #126] The call is full screen (phone turned sideways). */
export const immersiveCallAtom = atom<boolean>(false);
+6
View File
@@ -0,0 +1,6 @@
import { atom } from 'jotai';
import type { CallSummarySection } from '../utils/callSummary';
/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
export const callSummaryViewAtom = atom<CallSummaryView | undefined>(undefined);
+19 -8
View File
@@ -1,14 +1,19 @@
import { useEffect, useState } from 'react';
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
import {
getFallbackSession,
onSessionPersisted,
Session,
stripTokensFromStorage,
} from './sessions';
/**
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
* [Gitea #105] Desktop: keep the login tokens in 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.
* Step 1 mirrored them here and verified by reading back. Step 2 (sessions.ts,
* keychainSession.ts) reads them from here at boot; once a write is verified,
* the tokens are dropped from localStorage. Nothing here can log anyone out:
* a failure keeps the tokens in localStorage and 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.
@@ -122,7 +127,13 @@ export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void =>
started = true;
let chain: Promise<unknown> = Promise.resolve();
const enqueue = (session: Session | null) => {
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
chain = chain.then(async () => {
const result = await syncKeychain(invoke, session);
setStatus(result);
// [Gitea #105] step 2: the keychain verifiably holds these tokens, so
// localStorage can drop them (only if they're still the current ones).
if (result.state === 'ok' && session) stripTokensFromStorage(tokensOf(session));
});
};
enqueue(getFallbackSession() ?? null);
onSessionPersisted((session) => enqueue(session));
+183
View File
@@ -0,0 +1,183 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// localStorage stand-in (these modules read it at call time).
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
};
const sessions = await import('./sessions');
const { bootKeychainSession } = await import('./keychainSession');
const { syncKeychain, tokensOf } = await import('./keychainMirror');
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
const keychain = (opts: { getThrows?: boolean; setThrows?: boolean } = {}) => {
let stored: unknown = null;
const invoke: Invoke = async (cmd, args) => {
if (cmd === 'secure_session_supported') return true;
if (cmd === 'secure_session_get') {
if (opts.getThrows) throw new Error('Element not found.');
return stored;
}
if (cmd === 'secure_session_set') {
if (opts.setThrows) throw new Error('Access is denied.');
stored = args?.tokens;
return null;
}
if (cmd === 'secure_session_clear') {
stored = null;
return null;
}
throw new Error(cmd);
};
return {
invoke,
get: () => stored,
set: (v: unknown) => {
stored = v;
},
};
};
const blob = () => JSON.parse(store.get('cinny_session_v1') ?? 'null');
const tokensOnDisk = () =>
[...store.entries()].some(([, v]) => v.includes('syt_') || v.includes('mar_'));
const login = (access: string, refresh?: string) =>
sessions.setFallbackSession(access, 'DEV1', '@alice:example.org', 'https://hs.example', {
refreshToken: refresh,
expiresInMs: 300_000,
});
/** A fresh page load: drop the in-memory state, keep storage and keychain. */
const reload = () => sessions.leaveKeychainMode();
/** What the mirror does after a write: verify in the keychain, then strip. */
const mirror = async (invoke: Invoke) => {
const s = sessions.getFallbackSession() ?? null;
const r = await syncKeychain(invoke, s);
if (r.state === 'ok' && s) sessions.stripTokensFromStorage(tokensOf(s));
return r;
};
test('[#105] web / no keychain: localStorage stays authoritative', async () => {
store.clear();
reload();
login('syt_web', 'mar_web');
assert.deepEqual(await bootKeychainSession(undefined), { mode: 'off' });
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_web');
assert.equal(blob().accessToken, 'syt_web');
});
test('[#105] migration: existing install moves its tokens to the keychain, then off disk', async () => {
store.clear();
reload();
login('syt_old', 'mar_old'); // a pre-step-2 install
const kc = keychain();
const boot = await bootKeychainSession(kc.invoke);
assert.equal(boot.mode, 'keychain');
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old');
assert.equal(tokensOnDisk(), true, 'not stripped before the keychain is verified');
assert.equal((await mirror(kc.invoke)).state, 'ok');
assert.equal(tokensOnDisk(), false, 'tokens gone from localStorage');
assert.equal(blob().tokensIn, 'keychain');
assert.equal(store.get('cinny_access_token'), undefined);
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old', 'still signed in');
// Next launch: shell + keychain -> the same session.
reload();
const again = await bootKeychainSession(kc.invoke);
assert.equal(again.mode, 'keychain');
const s = sessions.getFallbackSession();
assert.equal(s?.accessToken, 'syt_old');
assert.equal(s?.refreshToken, 'mar_old');
assert.equal(s?.baseUrl, 'https://hs.example');
assert.ok((s?.expiresInMs ?? 0) > 0, 'expiry kept in the shell');
});
test('[#105] token refresh: written in full first, stripped once the keychain has it', async () => {
// continues from the previous state: keychain mode, shell on disk
const kc = keychain();
kc.set({
userId: '@alice:example.org',
deviceId: 'DEV1',
accessToken: 'syt_old',
refreshToken: 'mar_old',
});
login('syt_new', 'mar_new');
assert.equal(blob().accessToken, 'syt_new', 'durable on disk until the keychain confirms');
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_new');
await mirror(kc.invoke);
assert.equal(tokensOnDisk(), false);
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
});
test('[#105] a failing keychain write keeps the tokens in localStorage', async () => {
store.clear();
reload();
login('syt_keep', 'mar_keep');
const kc = keychain({ setThrows: true });
await bootKeychainSession(kc.invoke);
assert.equal((await mirror(kc.invoke)).state, 'error');
assert.equal(blob().accessToken, 'syt_keep', 'nothing stripped');
reload();
await bootKeychainSession(kc.invoke);
assert.equal(
sessions.getFallbackSession()?.accessToken,
'syt_keep',
'still signed in after a restart',
);
});
test('[#105] shell but no keychain entry: "missing", never a silent logout', async () => {
store.clear();
reload();
login('syt_x');
const kc = keychain();
await bootKeychainSession(kc.invoke);
await mirror(kc.invoke);
kc.set(null); // someone deleted the Credential Manager entry
reload();
const boot = await bootKeychainSession(kc.invoke);
assert.deepEqual(boot, { mode: 'missing', userId: '@alice:example.org' });
assert.equal(blob().tokensIn, 'keychain', 'shell kept for Retry');
});
test('[#105] shell but the keychain read fails: "error", shell kept', async () => {
// continues: shell on disk
reload();
const boot = await bootKeychainSession(keychain({ getThrows: true }).invoke);
assert.equal(boot.mode, 'error');
assert.equal(blob().tokensIn, 'keychain');
});
test('[#105] a shell for another device never pairs with the keychain', async () => {
store.clear();
reload();
login('syt_a');
const kc = keychain();
await bootKeychainSession(kc.invoke);
await mirror(kc.invoke);
kc.set({ userId: '@alice:example.org', deviceId: 'OTHER', accessToken: 'syt_b' });
reload();
assert.equal((await bootKeychainSession(kc.invoke)).mode, 'missing');
});
test('[#105] logout clears memory, disk and (via the mirror) the keychain', async () => {
store.clear();
reload();
login('syt_bye', 'mar_bye');
const kc = keychain();
await bootKeychainSession(kc.invoke);
await mirror(kc.invoke);
sessions.removeFallbackSession();
assert.equal(sessions.getFallbackSession(), undefined);
assert.equal(store.size, 0);
await mirror(kc.invoke);
assert.equal(kc.get(), null);
reload();
assert.deepEqual(await bootKeychainSession(kc.invoke), { mode: 'keychain', session: undefined });
});
+76
View File
@@ -0,0 +1,76 @@
import {
enterKeychainMode,
hasKeychainShell,
KeychainBoot,
KeychainSessionTokens,
} from './sessions';
import { KEYCHAIN_TIMEOUT_MS, KeychainInvoke } from './keychainMirror';
/**
* [Gitea #105] Desktop, step 2: at boot, before the app renders, read the login
* tokens from the OS keychain into memory (see sessions.ts). Returns:
* - `off`: not the desktop app, or no keychain here (Linux, older builds) —
* localStorage stays authoritative, exactly as before;
* - a KeychainBoot from enterKeychainMode: `keychain` (go on), or `missing`
* (localStorage says the tokens are in the keychain but they can't be read;
* the caller shows Retry / Sign in again and must NOT start the mirror,
* which would clear the keychain entry it couldn't read).
*/
export type KeychainBootResult = { mode: 'off' } | KeychainBoot | { mode: 'error'; error: string };
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 asTokens = (v: unknown): KeychainSessionTokens | null => {
if (!v || typeof v !== 'object') return null;
const t = v as Partial<KeychainSessionTokens>;
if (typeof t.userId !== 'string' || typeof t.deviceId !== 'string') return null;
if (typeof t.accessToken !== 'string') return null;
return {
userId: t.userId,
deviceId: t.deviceId,
accessToken: t.accessToken,
...(typeof t.refreshToken === 'string' ? { refreshToken: t.refreshToken } : {}),
};
};
export async function bootKeychainSession(
invoke: KeychainInvoke | undefined,
timeoutMs = KEYCHAIN_TIMEOUT_MS,
): Promise<KeychainBootResult> {
if (!invoke) return { mode: 'off' };
let supported: unknown;
try {
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
} catch {
supported = false;
}
if (supported !== true) {
// A shell without a keychain to read from can only mean the store went
// away (e.g. a downgrade of the desktop build): say so, don't log out.
return hasKeychainShell()
? { mode: 'error', error: 'keychain not available' }
: { mode: 'off' };
}
let stored: unknown;
try {
stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
} catch (e) {
if (hasKeychainShell())
return { mode: 'error', error: (e instanceof Error ? e.message : String(e)).slice(0, 200) };
return { mode: 'off' };
}
return enterKeychainMode(asTokens(stored));
}
+3 -4
View File
@@ -2,6 +2,7 @@ import { atom, useSetAtom } from 'jotai';
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { setRoomAccountData } from '../../utils/accountData';
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
@@ -25,11 +26,9 @@ export const setMarkedUnread = (
unread: boolean,
): Promise<unknown> =>
Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
setRoomAccountData(mx, roomId, AccountDataEvent.MarkedUnread, { unread }),
// Best-effort mirror for older servers; never fail the primary write on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
setRoomAccountData(mx, roomId, UNSTABLE_MARKED_UNREAD, { unread }).catch(() => undefined),
]);
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
+142 -3
View File
@@ -5,9 +5,9 @@
// setLocalStorageItem,
// } from './utils/atomWithLocalStorage';
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept
// as a generic claims object so this core module stays decoupled from
// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims).
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Since
// matrix-js-sdk 43 only `clientId` is used (the SDK finds the auth server via
// /auth_metadata); `idTokenClaims` is kept for sessions saved by older builds.
export type OidcSessionMeta = {
issuer: string;
clientId: string;
@@ -51,6 +51,21 @@ const OIDC_KEYS = {
// `_v1` suffix.
const SESSION_BLOB_KEY = 'cinny_session_v1';
// [Gitea #105] Desktop, step 2: the login tokens live in the OS keychain.
// localStorage then holds a SHELL blob (everything but the tokens, marked
// `tokensIn: 'keychain'`), and the full session is kept in memory, filled at
// boot (bootKeychainSession) before the app renders, so every reader of
// getFallbackSession stays synchronous. A new session is written to
// localStorage in full first; the tokens are stripped only once the keychain
// write was read back (stripTokensFromStorage), so at every instant one durable
// store holds the newest tokens (a token refresh invalidates the old ones).
let keychainMode = false;
let memorySession: PersistedSession | undefined;
type ShellSession = Omit<PersistedSession, 'accessToken' | 'refreshToken'> & {
tokensIn: 'keychain';
};
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
@@ -266,6 +281,7 @@ export function setFallbackSession(
extra?: FallbackSessionExtra,
) {
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
if (keychainMode) memorySession = persisted;
// ONE setItem — the blob can never be observed half-written.
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
// Dual-write the legacy keys (removal of this half is a future release).
@@ -276,6 +292,7 @@ export function setFallbackSession(
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
// or legacy-fallback) can resurrect a logged-out session.
export const removeFallbackSession = () => {
memorySession = undefined;
localStorage.removeItem(SESSION_BLOB_KEY);
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
@@ -286,7 +303,23 @@ export const removeFallbackSession = () => {
// corrupt/partial we transparently assemble from the legacy keys (migration);
// the next setFallbackSession then persists the blob. When both exist the blob
// wins by construction.
// The stored session with its tokens, choosing blob vs legacy as below.
const readStoredPersisted = (): PersistedSession | undefined => {
const blob = readSessionBlob();
const legacy = readLegacyKeys();
if (
blob &&
legacy &&
typeof legacy.expiresAt === 'number' &&
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
) {
return legacy;
}
return blob ?? legacy;
};
export const getFallbackSession = (): Session | undefined => {
if (keychainMode) return memorySession ? sessionFromPersisted(memorySession) : undefined;
const blob = readSessionBlob();
const legacy = readLegacyKeys();
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
@@ -305,6 +338,112 @@ export const getFallbackSession = (): Session | undefined => {
if (!persisted) return undefined;
return sessionFromPersisted(persisted);
};
// --- [Gitea #105] desktop keychain session -----------------------------------
export type KeychainSessionTokens = {
userId: string;
deviceId: string;
accessToken: string;
refreshToken?: string;
};
/** The shell blob written while the tokens are in the keychain, if any. */
const readShell = (): Omit<ShellSession, 'tokensIn'> | undefined => {
const raw = localStorage.getItem(SESSION_BLOB_KEY);
if (!raw) return undefined;
try {
const p = JSON.parse(raw) as Partial<ShellSession>;
if (
p?.tokensIn !== 'keychain' ||
typeof p.deviceId !== 'string' ||
typeof p.userId !== 'string' ||
typeof p.baseUrl !== 'string'
)
return undefined;
const { tokensIn: _drop, ...rest } = p as ShellSession;
return rest;
} catch {
return undefined;
}
};
/** A session in localStorage that still carries its tokens (blob or legacy). */
export const readStoredSessionWithTokens = (): Session | undefined => {
const persisted = readStoredPersisted();
return persisted ? sessionFromPersisted(persisted) : undefined;
};
/** Whether localStorage holds a token-less shell (tokens are in the keychain). */
export const hasKeychainShell = (): boolean => readShell() !== undefined;
export type KeychainBoot =
/** Use the keychain: memory holds `session` (or none = signed out). */
| { mode: 'keychain'; session: Session | undefined }
/** A shell says the tokens are in the keychain, but they can't be read. */
| { mode: 'missing'; userId: string };
/**
* Decide the boot session from the keychain's tokens and localStorage, and
* switch this tab to keychain mode. `tokens` is what the keychain returned
* (null = no entry). Pure apart from reading localStorage and setting the
* in-memory state.
*/
export const enterKeychainMode = (tokens: KeychainSessionTokens | null): KeychainBoot => {
const withTokens = readStoredPersisted();
const shell = readShell();
keychainMode = true;
if (withTokens) {
// Pre-step-2 install, or a write not yet confirmed: localStorage has the
// full session and stays authoritative until the mirror verifies it.
memorySession = withTokens;
return { mode: 'keychain', session: sessionFromPersisted(withTokens) };
}
if (shell) {
if (tokens && tokens.userId === shell.userId && tokens.deviceId === shell.deviceId) {
memorySession = {
...shell,
accessToken: tokens.accessToken,
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
};
return { mode: 'keychain', session: sessionFromPersisted(memorySession) };
}
// Never treat this as a logout: the shell stays, the caller offers Retry.
memorySession = undefined;
return { mode: 'missing', userId: shell.userId };
}
memorySession = undefined;
return { mode: 'keychain', session: undefined };
};
/** Leave keychain mode (the keychain failed): localStorage is authoritative again. */
export const leaveKeychainMode = (): void => {
keychainMode = false;
memorySession = undefined;
};
/**
* The keychain now verifiably holds `tokens`: if they're still the current
* session's, drop the tokens from localStorage (shell blob, no legacy keys).
*/
export const stripTokensFromStorage = (tokens: KeychainSessionTokens): boolean => {
const m = memorySession;
if (
!keychainMode ||
!m ||
m.userId !== tokens.userId ||
m.deviceId !== tokens.deviceId ||
m.accessToken !== tokens.accessToken ||
(m.refreshToken ?? undefined) !== (tokens.refreshToken ?? undefined)
)
return false;
const { accessToken: _a, refreshToken: _r, ...rest } = m;
const shell: ShellSession = { ...rest, tokensIn: 'keychain' };
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(shell));
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
return true;
};
/**
* End of migration code for old session
*/
+3
View File
@@ -310,6 +310,8 @@ export interface Settings {
screenshareFramerate: ScreenshareFramerate;
// [P5-15] In-call soundboard
soundboardEnabled: boolean;
// [Gitea #253] The call-ended toast (with its Details card).
callSummaryToast: boolean;
soundboardVolume: number; // 0–100
seasonalThemeOverride: SeasonalThemeOverride;
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
screenshareBitrate: 'auto',
screenshareFramerate: 'auto',
soundboardEnabled: true,
callSummaryToast: true,
soundboardVolume: 80,
seasonalThemeOverride: 'auto',
+7
View File
@@ -0,0 +1,7 @@
import { atom } from 'jotai';
/**
* [Gitea #113] Session ids the stale-sessions nudge's Review asked Settings →
* Devices to tick for sign-out. Consumed (and cleared) by OtherDevices.
*/
export const preselectSignOutAtom = atom<string[]>([]);
+2
View File
@@ -13,6 +13,8 @@ export type ToastNotif = {
onClick?: () => void; // custom click handler; skips hash navigation when set
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
onAction?: () => void;
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
};
+4
View File
@@ -2,3 +2,7 @@ import { atom } from 'jotai';
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
export const widgetsPanelAtom = atom<boolean>(false);
// [Gitea #205] Which widget the panel is showing, per room, so a floating
// widget's "Back to room" can open the panel on it.
export const widgetViewingAtom = atom<Record<string, string | null>>({});
+2
View File
@@ -5,6 +5,8 @@
* z-index values and is intentionally not listed here.
*/
export const zIndices = {
/** [Gitea #205] A room widget kept on screen after leaving its room. */
floatingWidget: 9980,
/** In-call incoming-call banner — below seasonal/night-light/modals. */
inCallBanner: 9990,
/** Seasonal particle effect — below the night-light tint so particles tint. */
+11
View File
@@ -18,6 +18,17 @@ export function getAccountData<T>(
return event?.getContent() as T | undefined;
}
/** Room account data under a custom or developer-chosen type (same cast). */
export function setRoomAccountData<T>(
mx: MatrixClient,
roomId: string,
eventType: string,
content: T,
): Promise<unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (mx as any).setRoomAccountData(roomId, eventType, content);
}
export function setAccountData<T>(
mx: MatrixClient,
eventType: AccountDataEvent | string,
+100 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { describeCallDuration, describeCallEnd } from './callSummary';
import {
describeCallDuration,
describeCallEnd,
describeCallDetails,
parseCallSummaryDetails,
} from './callSummary';
describe('describeCallDuration', () => {
it('picks the unit', () => {
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
);
});
});
it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
const d = parseCallSummaryDetails({
participants: ['@bob:x', 7, '@carol:x'],
peakParticipants: 3,
quality: {
youPoorMs: 1000,
othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
everyonePoorMs: 0,
},
network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
});
assert.deepEqual(d, {
participants: ['@bob:x', '@carol:x'],
peakParticipants: 3,
quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
network: { rttMedianMs: 38, inboundLossPct: 0.5 },
activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
});
assert.equal(parseCallSummaryDetails(undefined), undefined);
assert.equal(parseCallSummaryDetails('details'), undefined);
});
it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
const names: Record<string, string> = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
const sections = describeCallDetails(
{
durationMs: 2_460_000,
reconnects: 2,
poorMs: 120_000,
verdict: 'fair',
details: {
participants: ['@bob:x', '@carol:x'],
peakParticipants: 3,
quality: {
youPoorMs: 120_000,
othersPoorMs: { '@carol:x': 30_000 },
everyonePoorMs: 10_000,
},
network: {
rttMedianMs: 38,
rttP95Ms: 310,
outboundLossPct: 4.1,
inboundLossPct: 0.5,
jitterMs: 6,
},
activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
},
},
2_460_000,
90_000,
(u) => names[u] ?? u,
);
assert.deepEqual(sections, [
{ title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
{
title: 'Connection',
lines: [
'Verdict: fair · 2 reconnects',
'Your connection poor/lost: 2 min',
'Carol poor/lost: 30 s',
'Everyone poor at once: 10 s (likely server or SFU side)',
],
},
{
title: 'Network',
lines: [
'RTT median 38 ms · p95 310 ms',
'Outbound packet loss 4.1%',
'Inbound packet loss 0.5%',
'Jitter 6 ms',
],
},
{
title: 'You',
lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
},
]);
});
it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
const sections = describeCallDetails(
{ durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
60_000,
0,
(u) => u,
);
assert.deepEqual(
sections.map((s) => s.title),
['Call', 'Connection'],
);
assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
});
+120
View File
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
}
return parts.join(' · ');
}
/** [Gitea #253] The fork's `details` in io.lotus.call_summary (lotus.29+). */
export type LotusCallSummaryDetails = {
participants: string[];
peakParticipants: number;
quality: { youPoorMs: number; othersPoorMs: Record<string, number>; everyonePoorMs: number };
network: {
rttMedianMs?: number;
rttP95Ms?: number;
outboundLossPct?: number;
inboundLossPct?: number;
jitterMs?: number;
};
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
};
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
const obj = (v: unknown): Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
/** Defensive: the frame is another origin; take only well-formed fields. */
export function parseCallSummaryDetails(raw: unknown): LotusCallSummaryDetails | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = obj(raw);
const quality = obj(r.quality);
const network = obj(r.network);
const activity = obj(r.activity);
const othersPoorMs: Record<string, number> = {};
Object.entries(obj(quality.othersPoorMs)).forEach(([user, ms]) => {
const n = num(ms);
if (n !== undefined && user.startsWith('@')) othersPoorMs[user] = n;
});
const net: LotusCallSummaryDetails['network'] = {};
(['rttMedianMs', 'rttP95Ms', 'outboundLossPct', 'inboundLossPct', 'jitterMs'] as const).forEach(
(k) => {
const n = num(network[k]);
if (n !== undefined) net[k] = n;
},
);
return {
participants: Array.isArray(r.participants)
? r.participants.filter((p): p is string => typeof p === 'string').slice(0, 200)
: [],
peakParticipants: num(r.peakParticipants) ?? 1,
quality: {
youPoorMs: num(quality.youPoorMs) ?? 0,
othersPoorMs,
everyonePoorMs: num(quality.everyonePoorMs) ?? 0,
},
network: net,
activity: {
speakingMs: num(activity.speakingMs) ?? 0,
mutedMs: num(activity.mutedMs) ?? 0,
screenshareMs: num(activity.screenshareMs) ?? 0,
},
};
}
export type CallSummarySection = { title: string; lines: string[] };
/**
* [Gitea #253] The Details card, in technical wording (the owner's choice):
* who was there, connection quality and whose side it was on, network
* numbers, and your own activity. `nameOf` turns a user id into a display name.
*/
export function describeCallDetails(
summary: LotusCallSummary | undefined,
durationMs: number,
deafenedMs: number,
nameOf: (userId: string) => string,
): CallSummarySection[] {
const sections: CallSummarySection[] = [];
const d = summary?.details;
const dur = describeCallDuration;
const people = d?.participants ?? [];
sections.push({
title: 'Call',
lines: [
`Duration ${dur(durationMs)}${d ? ` · peak ${d.peakParticipants} in the call` : ''}`,
people.length
? `With ${people.map(nameOf).join(', ')}`
: d
? 'Nobody else joined'
: 'Participants unknown',
],
});
if (summary) {
const quality: string[] = [
`Verdict: ${summary.verdict} · ${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`,
];
if (d) {
quality.push(`Your connection poor/lost: ${dur(d.quality.youPoorMs)}`);
Object.entries(d.quality.othersPoorMs)
.sort((a, b) => b[1] - a[1])
.forEach(([user, ms]) => quality.push(`${nameOf(user)} poor/lost: ${dur(ms)}`));
if (d.quality.everyonePoorMs > 0)
quality.push(
`Everyone poor at once: ${dur(d.quality.everyonePoorMs)} (likely server or SFU side)`,
);
} else quality.push(`Your connection poor/lost: ${dur(summary.poorMs)}`);
sections.push({ title: 'Connection', lines: quality });
}
if (d) {
const n = d.network;
const net: string[] = [];
if (n.rttMedianMs !== undefined)
net.push(`RTT median ${n.rttMedianMs} ms · p95 ${n.rttP95Ms ?? n.rttMedianMs} ms`);
if (n.outboundLossPct !== undefined) net.push(`Outbound packet loss ${n.outboundLossPct}%`);
if (n.inboundLossPct !== undefined) net.push(`Inbound packet loss ${n.inboundLossPct}%`);
if (n.jitterMs !== undefined) net.push(`Jitter ${n.jitterMs} ms`);
if (net.length) sections.push({ title: 'Network', lines: net });
const act = [
`Talking ${dur(d.activity.speakingMs)} · muted ${dur(d.activity.mutedMs)} · deafened ${dur(deafenedMs)}`,
];
if (d.activity.screenshareMs > 0) act.push(`Screensharing ${dur(d.activity.screenshareMs)}`);
sections.push({ title: 'You', lines: act });
}
return sections;
}
+76
View File
@@ -0,0 +1,76 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildExport, mergeData, parseExport, planImport } from './dataExport';
const file = buildExport({
userId: '@a:hs',
accountData: {
'io.lotus.bookmarks': { bookmarks: [{ id: 'b1', note: 'x' }] },
'io.lotus.user_notes': { notes: { '@b:hs': 'met at LAN party' } },
'm.direct': { secret: true },
'io.lotus.settings': { theme: 'dark', fontSize: 16 },
},
decoration: 'halo',
localSettings: { callNoiseSuppression: 'ml' },
now: new Date('2026-10-25T00:00:00Z'),
});
test('[#114] export keeps only our own types', () => {
assert.equal(file.format, 'lotus-chat-data');
assert.equal(file.version, 1);
assert.deepEqual(Object.keys(file.accountData).sort(), [
'io.lotus.bookmarks',
'io.lotus.settings',
'io.lotus.user_notes',
]);
assert.equal(file.profile.decoration, 'halo');
});
test('[#114] parse: round trip, wrong files, newer versions, foreign types', () => {
const r = parseExport(JSON.stringify(file));
assert.ok(r.ok);
assert.equal(parseExport('nope').ok, false);
assert.equal(parseExport('{"format":"other"}').ok, false);
const newer = parseExport(JSON.stringify({ ...file, version: 2 }));
assert.ok(!newer.ok && /newer Lotus Chat/.test(newer.error));
const sneaky = parseExport(
JSON.stringify({ ...file, accountData: { 'm.push_rules': {}, 'io.lotus.bookmarks': {} } }),
);
assert.ok(sneaky.ok);
assert.deepEqual(Object.keys(sneaky.file.accountData), ['io.lotus.bookmarks']);
});
test('[#114] merge: lists by id, maps by key, conflicts kept unless preferred', () => {
const mine = { bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }] };
const theirs = { bookmarks: [{ id: 'b1', note: 'theirs' }, { id: 'b3' }] };
const keep = mergeData(mine, theirs);
assert.equal(keep.added, 1);
assert.equal(keep.conflicts, 1);
assert.deepEqual(keep.value, {
bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }, { id: 'b3' }],
});
const take = mergeData(mine, theirs, true);
assert.deepEqual((take.value as typeof mine).bookmarks[0], { id: 'b1', note: 'theirs' });
const notes = mergeData({ a: 1, b: 2 }, { b: 3, c: 4 });
assert.deepEqual(notes, { value: { a: 1, b: 2, c: 4 }, added: 1, conflicts: 1 });
assert.deepEqual(mergeData(undefined, { x: 1 }), { value: { x: 1 }, added: 1, conflicts: 0 });
assert.deepEqual(mergeData(['a'], ['a', 'b']).value, ['a', 'b']);
});
test('[#114] plan summarises per category', () => {
const plan = planImport(file, {
accountData: { 'io.lotus.settings': { theme: 'light' } },
decoration: null,
localSettings: { callNoiseSuppression: 'ml' },
});
const by = Object.fromEntries(plan.map((p) => [p.key, p]));
assert.deepEqual(
[by['io.lotus.settings'].added, by['io.lotus.settings'].conflicts],
[1, 1],
'fontSize new, theme differs',
);
assert.equal(by['io.lotus.bookmarks'].added, 1);
assert.equal(by['profile.decoration'].added, 1);
assert.deepEqual([by['local.settings'].added, by['local.settings'].conflicts], [0, 0]);
});
+219
View File
@@ -0,0 +1,219 @@
/**
* [Gitea #114] Export / import "your data": the Lotus-side personal data of
* an account (not message history, not encryption keys: those have their own
* exports, and keys are secret). One JSON file:
*
* { format: 'lotus-chat-data', version: 1, exportedAt, userId,
* accountData: { [type]: content }, profile: { decoration }, local: { settings } }
*
* Import MERGES: anything missing here is added; a value that differs is a
* conflict, kept as it is unless the user chooses the file's. Pure helpers.
*/
export const EXPORT_FORMAT = 'lotus-chat-data';
export const EXPORT_VERSION = 1;
/** The account-data types that are the user's own Lotus data, with labels. */
export const EXPORT_ACCOUNT_DATA: { type: string; label: string }[] = [
{ type: 'io.lotus.settings', label: 'Synced settings' },
{ type: 'io.lotus.bookmarks', label: 'Bookmarks' },
{ type: 'io.lotus.user_notes', label: 'Private notes about people' },
{ type: 'io.lotus.room_names', label: 'Your names for rooms' },
{ type: 'in.cinny.spaces', label: 'Room sections and sidebar' },
{ type: 'io.lotus.reminders', label: 'Reminders' },
{ type: 'io.lotus.status_presets', label: 'Status presets' },
{ type: 'io.lotus.thread_notifications', label: 'Thread notification settings' },
{ type: 'io.lotus.mute_timers', label: 'Timed mutes' },
{ type: 'io.lotus.soundboard', label: 'Soundboard (clips stay on the server)' },
{ type: 'io.lotus.soundboard_rooms', label: 'Soundboards enabled everywhere' },
{ type: 'im.ponies.user_emotes', label: 'Your emoji and stickers' },
{ type: 'im.ponies.emote_rooms', label: 'Emoji packs enabled everywhere' },
];
export type ExportFile = {
format: typeof EXPORT_FORMAT;
version: number;
exportedAt: string;
userId: string;
accountData: Record<string, unknown>;
profile: { decoration: string | null };
local: { settings: Record<string, unknown> | null };
};
export const buildExport = (input: {
userId: string;
accountData: Record<string, unknown>;
decoration: string | null;
localSettings: Record<string, unknown> | null;
now?: Date;
}): ExportFile => {
const accountData: Record<string, unknown> = {};
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
const c = input.accountData[type];
if (c !== undefined && c !== null) accountData[type] = c;
});
return {
format: EXPORT_FORMAT,
version: EXPORT_VERSION,
exportedAt: (input.now ?? new Date()).toISOString(),
userId: input.userId,
accountData,
profile: { decoration: input.decoration },
local: { settings: input.localSettings },
};
};
const isObject = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v);
export type ParseResult = { ok: true; file: ExportFile } | { ok: false; error: string };
/** Validate an imported file; older versions would be migrated here. */
export const parseExport = (text: string): ParseResult => {
let v: unknown;
try {
v = JSON.parse(text);
} catch {
return { ok: false, error: "This isn't a Lotus Chat data file (it isn't valid JSON)." };
}
if (!isObject(v) || v.format !== EXPORT_FORMAT)
return { ok: false, error: "This isn't a Lotus Chat data file." };
if (typeof v.version !== 'number' || v.version < 1)
return { ok: false, error: 'This data file is damaged (no version).' };
if (v.version > EXPORT_VERSION)
return {
ok: false,
error: 'This file was made by a newer Lotus Chat. Update the app, then import it again.',
};
const accountData = isObject(v.accountData) ? v.accountData : {};
const known = new Set(EXPORT_ACCOUNT_DATA.map((t) => t.type));
const filtered: Record<string, unknown> = {};
Object.entries(accountData).forEach(([type, content]) => {
// Only our own types: a file can't be used to write arbitrary account data.
if (known.has(type) && content !== null && content !== undefined) filtered[type] = content;
});
const profile = isObject(v.profile) ? v.profile : {};
const local = isObject(v.local) ? v.local : {};
return {
ok: true,
file: {
format: EXPORT_FORMAT,
version: v.version,
exportedAt: typeof v.exportedAt === 'string' ? v.exportedAt : '',
userId: typeof v.userId === 'string' ? v.userId : '',
accountData: filtered,
profile: {
decoration: typeof profile.decoration === 'string' ? profile.decoration : null,
},
local: { settings: isObject(local.settings) ? local.settings : null },
},
};
};
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
const itemKey = (v: unknown): string => {
if (isObject(v)) {
const id = v.id ?? v.eventId ?? v.event_id ?? v.key ?? v.roomId ?? v.room_id ?? v.mxc;
if (typeof id === 'string' || typeof id === 'number') return `id:${String(id)}`;
}
return `json:${JSON.stringify(v)}`;
};
export type MergeResult = { value: unknown; added: number; conflicts: number };
/**
* Pure: merge `incoming` into `current`. Lists: add items that are missing
* (matched by id, else by content). Objects: add missing keys, recurse into
* shared ones. A differing leaf is a conflict: `preferIncoming` decides.
*/
export const mergeData = (
current: unknown,
incoming: unknown,
preferIncoming = false,
): MergeResult => {
if (current === undefined || current === null) {
return { value: incoming, added: incoming === undefined ? 0 : 1, conflicts: 0 };
}
if (Array.isArray(current) && Array.isArray(incoming)) {
const have = new Map(current.map((v, i) => [itemKey(v), i]));
const out = [...current];
let added = 0;
let conflicts = 0;
incoming.forEach((item) => {
const k = itemKey(item);
const at = have.get(k);
if (at === undefined) {
out.push(item);
have.set(k, out.length - 1);
added += 1;
} else if (!same(out[at], item)) {
conflicts += 1;
if (preferIncoming) out[at] = item;
}
});
return { value: out, added, conflicts };
}
if (isObject(current) && isObject(incoming)) {
const out: Record<string, unknown> = { ...current };
let added = 0;
let conflicts = 0;
Object.entries(incoming).forEach(([k, v]) => {
if (!(k in current)) {
out[k] = v;
added += 1;
return;
}
const r = mergeData(current[k], v, preferIncoming);
out[k] = r.value;
added += r.added;
conflicts += r.conflicts;
});
return { value: out, added, conflicts };
}
if (same(current, incoming)) return { value: current, added: 0, conflicts: 0 };
return { value: preferIncoming ? incoming : current, added: 0, conflicts: 1 };
};
export type ImportPlanItem = {
key: string;
label: string;
added: number;
conflicts: number;
};
/** What importing `file` into the current data would change, per category. */
export const planImport = (
file: ExportFile,
current: {
accountData: Record<string, unknown>;
decoration: string | null;
localSettings: Record<string, unknown> | null;
},
): ImportPlanItem[] => {
const items: ImportPlanItem[] = [];
EXPORT_ACCOUNT_DATA.forEach(({ type, label }) => {
if (!(type in file.accountData)) return;
const r = mergeData(current.accountData[type], file.accountData[type]);
items.push({ key: type, label, added: r.added, conflicts: r.conflicts });
});
if (file.profile.decoration !== null) {
const differs = current.decoration !== file.profile.decoration;
items.push({
key: 'profile.decoration',
label: 'Avatar decoration',
added: !current.decoration && differs ? 1 : 0,
conflicts: current.decoration && differs ? 1 : 0,
});
}
if (file.local.settings) {
const r = mergeData(current.localSettings ?? {}, file.local.settings);
items.push({
key: 'local.settings',
label: "This device's settings",
added: r.added,
conflicts: r.conflicts,
});
}
return items;
};
+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getKeyUploadWatch,
isKeyUploadUrl,
isOtkConflict,
recordKeyUpload,
resetKeyUploadWatch,
STUCK_AFTER,
subscribeKeyUploadWatch,
WINDOW_MS,
watchKeyUploads,
} from './keyUploadWatch';
const CONFLICT = JSON.stringify({
errcode: 'M_UNKNOWN',
error:
'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {...}; new key: {...}',
});
test('[#201] recognises the upload endpoint and the conflict body', () => {
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload'), true);
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload/'), true);
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/query'), false);
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/room_keys/keys'), false);
assert.equal(isOtkConflict(400, CONFLICT), true);
assert.equal(isOtkConflict(400, '{"errcode":"M_BAD_JSON"}'), false);
assert.equal(isOtkConflict(500, CONFLICT), false);
});
test('[#201] stuck only after repeated conflicts inside the window', () => {
resetKeyUploadWatch();
const t0 = 1_000_000;
for (let i = 0; i < STUCK_AFTER - 1; i += 1) recordKeyUpload(400, CONFLICT, t0 + i * 1000);
assert.equal(getKeyUploadWatch().stuck, false, 'a couple of races is not divergence');
recordKeyUpload(400, CONFLICT, t0 + 5000);
assert.equal(getKeyUploadWatch().stuck, true);
assert.match(getKeyUploadWatch().lastError ?? '', /already exists/);
});
test('[#201] old conflicts age out; other 400s are ignored', () => {
resetKeyUploadWatch();
recordKeyUpload(400, CONFLICT, 0);
recordKeyUpload(400, CONFLICT, 1000);
recordKeyUpload(400, CONFLICT, WINDOW_MS + 60_000);
assert.equal(getKeyUploadWatch().stuck, false);
recordKeyUpload(400, '{"errcode":"M_BAD_JSON"}', WINDOW_MS + 61_000);
assert.equal(getKeyUploadWatch().conflicts, 1);
});
test('[#201] a successful upload clears it and notifies', () => {
resetKeyUploadWatch();
let calls = 0;
const off = subscribeKeyUploadWatch(() => {
calls += 1;
});
for (let i = 0; i < STUCK_AFTER; i += 1) recordKeyUpload(400, CONFLICT, i);
assert.equal(getKeyUploadWatch().stuck, true);
recordKeyUpload(200, '');
assert.equal(getKeyUploadWatch().stuck, false);
assert.ok(calls >= 2);
off();
});
test('[#201] the fetch wrapper observes without changing the response', async () => {
resetKeyUploadWatch();
const fake: typeof fetch = async (input) =>
String(input).endsWith('/keys/upload')
? new Response(CONFLICT, { status: 400 })
: new Response('{}', { status: 200 });
const f = watchKeyUploads(fake);
const responses = await Promise.all(
Array.from({ length: STUCK_AFTER }, () =>
f('https://hs.example/_matrix/client/v3/keys/upload', { method: 'POST' }),
),
);
const bodies = await Promise.all(responses.map((res) => res.text()));
responses.forEach((res) => assert.equal(res.status, 400));
bodies.forEach((b) => assert.match(b, /already exists/, 'the SDK can still read the body'));
await new Promise((r) => {
setTimeout(r, 10);
});
assert.equal(getKeyUploadWatch().stuck, true);
await f('https://hs.example/_matrix/client/v3/sync');
assert.equal(getKeyUploadWatch().stuck, true, 'unrelated requests change nothing');
});
+102
View File
@@ -0,0 +1,102 @@
/**
* [Gitea #201 KE-1] Notice when this device's encryption store has diverged
* from the server: every POST /keys/upload answers 400 "One time key ...
* already exists", and the SDK retries forever (matrix-rust-sdk#5200, open),
* so new encrypted messages and calls quietly degrade. The server saw 474k of
* these between May 30 and Jul 8 2026. The only fix is a fresh login on that
* device, so we tell the user (KeyUploadBanner) instead of looping silently.
*
* Watched at the fetch layer (MatrixClient `fetchFn`), so it doesn't depend on
* SDK log wording. Pure state + a tiny subscribe API; no network I/O of its own.
*/
/** Conflicts needed before we call the device stuck. The SDK retries within
* seconds, so a real divergence crosses this quickly; a one-off race doesn't. */
export const STUCK_AFTER = 3;
/** Conflicts older than this don't count (a long-ago blip isn't "now"). */
export const WINDOW_MS = 15 * 60 * 1000;
export type KeyUploadWatchState = { stuck: boolean; conflicts: number; lastError?: string };
let conflictTimes: number[] = [];
let lastError: string | undefined;
let state: KeyUploadWatchState = { stuck: false, conflicts: 0 };
const listeners = new Set<() => void>();
const publish = (next: KeyUploadWatchState) => {
if (next.stuck === state.stuck && next.conflicts === state.conflicts) return;
state = next;
listeners.forEach((l) => l());
};
export const isKeyUploadUrl = (url: string): boolean => {
try {
return /\/_matrix\/client\/[^/]+\/keys\/upload\/?$/.test(new URL(url, 'http://x').pathname);
} catch {
return false;
}
};
/** The 400 body that marks a diverged one-time-key store. */
export const isOtkConflict = (status: number, body: string): boolean =>
status === 400 && /one time key .*already exists/i.test(body);
/** Feed one /keys/upload response. `now` is injectable for tests. */
export const recordKeyUpload = (status: number, body: string, now = Date.now()): void => {
if (status >= 200 && status < 300) {
// A successful upload means the stores agree again.
conflictTimes = [];
lastError = undefined;
publish({ stuck: false, conflicts: 0 });
return;
}
if (!isOtkConflict(status, body)) return;
conflictTimes = [...conflictTimes, now].filter((t) => now - t <= WINDOW_MS);
lastError = body.slice(0, 300);
publish({
stuck: conflictTimes.length >= STUCK_AFTER,
conflicts: conflictTimes.length,
lastError,
});
};
export const getKeyUploadWatch = (): KeyUploadWatchState => state;
export const subscribeKeyUploadWatch = (cb: () => void): (() => void) => {
listeners.add(cb);
return () => listeners.delete(cb);
};
/** Test/logout helper: forget everything (a new session starts clean). */
export const resetKeyUploadWatch = (): void => {
conflictTimes = [];
lastError = undefined;
publish({ stuck: false, conflicts: 0 });
};
const urlOf = (input: RequestInfo | URL): string =>
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
/**
* Wrap a fetch so /keys/upload responses are observed. The response the SDK
* gets is untouched; a clone is read in the background, never awaited.
*/
export const watchKeyUploads =
(inner: typeof fetch): typeof fetch =>
async (input, init) => {
const res = await inner(input, init);
try {
if (isKeyUploadUrl(urlOf(input))) {
if (res.ok) recordKeyUpload(res.status, '');
else if (res.status === 400)
res
.clone()
.text()
.then((body) => recordKeyUpload(res.status, body))
.catch(() => undefined);
}
} catch {
// observation must never break the request
}
return res;
};
+1 -1
View File
@@ -60,7 +60,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
export function sendStateEvent<T extends object>(
mx: MatrixClient,
roomId: string,
eventType: StateEvent,
eventType: StateEvent | string,
content: T,
stateKey = '',
): Promise<ISendEventResponse> {
+45
View File
@@ -8,6 +8,9 @@ import {
parseNudgeRecord,
SecurityState,
shouldShowNudge,
pickNudge,
staleSessions,
staleSessionsText,
} from './securityNudge';
const healthy: SecurityState = {
@@ -79,3 +82,45 @@ test('stored record: round trip, and garbage starts a fresh grace period', () =>
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
}
});
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
const now = Date.UTC(2026, 9, 25);
const day = 86_400_000;
const devices = [
{ device_id: 'ME', last_seen_ts: now - 400 * day },
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
{ device_id: 'UNKNOWN' },
{ device_id: 'NULL', last_seen_ts: null },
];
assert.deepEqual(
staleSessions(devices, 'ME', now).map((d) => d.device_id),
['OLD', 'EDGE'],
);
});
test('[#113] wording follows the count', () => {
assert.match(
staleSessionsText(1),
/^You have a session you haven't used in 3 months\. Signing it out/,
);
assert.match(
staleSessionsText(3),
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
);
});
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
const now = 10 * 86_400_000;
const fresh = { firstSeen: 0, dismissals: {} };
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
const snoozed = dismissNudge('verify-device', fresh, now);
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
// inside the 24 h grace period nothing shows
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
});
+54 -1
View File
@@ -9,12 +9,15 @@
* - setup-backup: no key backup at all (includes accounts that never set up
* cross-signing: the setup flow does both).
*
* - stale-sessions (#113, lowest priority): sessions of this account not used
* for 90+ days; Review opens Settings → Devices with them ticked to sign out.
*
* 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 NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup' | 'stale-sessions';
export type SecurityState = {
/** m.cross_signing.master account data present. */
@@ -38,6 +41,51 @@ export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
return s.backupActive ? undefined : 'connect-backup';
};
/** [Gitea #113] A session counts as stale after this long unused. */
export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000;
type SessionLike = { device_id: string; last_seen_ts?: number | null };
/**
* Pure: other sessions of this account not seen for STALE_SESSION_MS. Never
* the current session; a session with no last-seen time is left alone (we
* can't tell it's unused).
*/
export const staleSessions = <T extends SessionLike>(
devices: T[],
currentDeviceId: string | undefined,
now: number,
): T[] =>
devices.filter(
(d) =>
d.device_id !== currentDeviceId &&
typeof d.last_seen_ts === 'number' &&
now - d.last_seen_ts >= STALE_SESSION_MS,
);
/** "1 session" / "3 sessions you haven't used in 3 months". */
export const staleSessionsText = (count: number): string =>
`You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${
count === 1 ? 'it' : 'them'
} out keeps your account safe if an old device is lost.`;
/**
* The nudge to show: the device-security one first, then stale sessions, each
* only if its own snooze allows (so a snoozed security nudge doesn't hide the
* stale-sessions one, and vice versa).
*/
export const pickNudge = (
security: NudgeKind | undefined,
staleCount: number,
record: NudgeRecord,
now: number,
): NudgeKind | undefined => {
const candidates: NudgeKind[] = [];
if (security) candidates.push(security);
if (staleCount > 0) candidates.push('stale-sessions');
return candidates.find((k) => shouldShowNudge(k, record, now));
};
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;
@@ -87,6 +135,11 @@ export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
action: 'Connect',
},
'stale-sessions': {
// The text depends on the count: see staleSessionsText.
text: '',
action: 'Review',
},
'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',
+32
View File
@@ -33,6 +33,7 @@ import {
spotifyEmbedHeight,
parseMediaEmbed,
isTwitterNoResults,
getVimeoEventParts,
} from './videoEmbed';
const HOST = 'chat.lotusguild.org';
@@ -429,3 +430,34 @@ test('isTwitterNoResults: ignores normal embed traffic and junk', () => {
assert.equal(isTwitterNoResults(null), false);
assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false);
});
test('[#200] Vimeo live events embed from vimeo.com/event/{id}/embed', () => {
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567'), {
id: '4321567',
hash: undefined,
});
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567/a1b2c3d4e5'), {
id: '4321567',
hash: 'a1b2c3d4e5',
});
assert.equal(
getVimeoEventParts('https://vimeo.com/event/4321567/embed/a1b2c3d4e5')?.hash,
'a1b2c3d4e5',
);
assert.equal(getVimeoEventParts('https://vimeo.com/event/4321567/chat'), null);
assert.equal(getVimeoEventParts('https://vimeo.com/123456789'), null);
assert.equal(getVimeoEventParts('https://evil.example/event/4321567'), null);
assert.equal(
parseMediaEmbed('https://vimeo.com/event/4321567', 'h')?.embedUrl,
'https://vimeo.com/event/4321567/embed?autoplay=1&dnt=1',
);
assert.equal(
parseMediaEmbed('https://vimeo.com/event/4321567/a1b2c3d4e5', 'h')?.embedUrl,
'https://vimeo.com/event/4321567/embed/a1b2c3d4e5?autoplay=1&dnt=1',
);
// An ordinary video still goes to the player.
assert.match(
parseMediaEmbed('https://vimeo.com/123456789', 'h')?.embedUrl ?? '',
/^https:\/\/player\.vimeo\.com\/video\/123456789/,
);
});
+27
View File
@@ -80,6 +80,25 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
}
}
/**
* [Gitea #200] Vimeo live events: vimeo.com/event/{id}, or /event/{id}/{hash}
* when unlisted. Embedded from vimeo.com itself (not player.vimeo.com); the
* web and desktop CSP `frame-src https:` already allow it.
*/
export function getVimeoEventParts(url: string): { id: string; hash?: string } | null {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
const m = pathname.match(/^\/event\/(\d+)(?:\/(?:embed\/)?([0-9a-f]{6,}))?\/?$/);
return m ? { id: m[1], hash: m[2] } : null;
} catch {
return null;
}
}
export const buildVimeoEventEmbedUrl = (id: string, hash?: string): string =>
`https://vimeo.com/event/${enc(id)}/embed${hash ? `/${enc(hash)}` : ''}?autoplay=1&dnt=1`;
export function getVimeoVideoId(url: string): string | null {
return getVimeoParts(url)?.id ?? null;
}
@@ -713,6 +732,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
embedUrl: buildVideoEmbedUrl('youtube', ytId),
};
const vimeoEvent = getVimeoEventParts(url);
if (vimeoEvent)
return {
provider: 'vimeo',
kind: 'landscape',
embedUrl: buildVimeoEventEmbedUrl(vimeoEvent.id, vimeoEvent.hash),
};
const vimeo = getVimeoParts(url);
if (vimeo)
return {
+21 -13
View File
@@ -3,11 +3,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
import { revokeOidcTokens } from './oidcLogout';
import { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher';
import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { watchKeyUploads } from '../app/utils/keyUploadWatch';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
import { clearSharedPayload } from '../swShare';
@@ -61,12 +61,19 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
// OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh
// (the client calls this reactively on a 401) and persist rotated tokens.
const oidcRefresher =
// OIDC/next-gen-auth sessions carry a refresh token. Since SDK 43 the client
// refreshes them itself; we persist the rotated tokens and serialise
// refreshes across tabs (oidcTokenRefresher).
const oidcIdentity: OidcIdentity | undefined =
session.refreshToken && session.oidc
? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl)
? {
userId: session.userId,
deviceId: session.deviceId,
baseUrl: session.baseUrl,
oidc: session.oidc,
}
: undefined;
if (oidcIdentity) installRefreshGuard(oidcIdentity);
const mx = createClient({
baseUrl: session.baseUrl,
@@ -76,12 +83,16 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
store: indexedDBStore,
cryptoStore: legacyCryptoStore,
deviceId: session.deviceId,
// [Gitea #201] Observe /keys/upload so a diverged OTK store is reported to
// the user (KeyUploadBanner) instead of retrying silently forever.
fetchFn: watchKeyUploads((input, init) => globalThis.fetch(input, init)),
timelineSupport: true,
cryptoCallbacks: cryptoCallbacks as any,
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
tokenRefreshFunction: oidcRefresher
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
oauthClientId: oidcIdentity?.oidc.clientId,
onTokenRefresh: oidcIdentity
? (tokens) => persistRefreshedTokens(oidcIdentity, tokens)
: undefined,
});
@@ -126,11 +137,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
export const logoutClient = async (mx: MatrixClient) => {
pushSessionToSW();
mx.stopClient();
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
const session = getFallbackSession();
if (session?.oidc) {
await revokeOidcTokens(session);
}
// For OIDC sessions the SDK revokes the tokens at the auth server instead of
// calling /logout (matrix-js-sdk 43+); best-effort either way.
try {
await mx.logout();
} catch {
-29
View File
@@ -1,29 +0,0 @@
import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk';
import { Session } from '../app/state/sessions';
/**
* Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation
* endpoint during logout. Tolerant of any failure — logout proceeds regardless
* (the local session is cleared by the caller either way).
*/
export const revokeOidcTokens = async (session: Session): Promise<void> => {
if (!session.oidc) return;
try {
const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer);
const endpoint = config.revocation_endpoint;
if (!endpoint) return;
const { clientId } = session.oidc;
const revoke = (token: string, hint: string): Promise<Response> =>
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }),
});
const requests: Promise<Response>[] = [];
if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token'));
if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token'));
await Promise.allSettled(requests);
} catch {
/* issuer unreachable / no revocation endpoint — ignore */
}
};
+118
View File
@@ -0,0 +1,118 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
};
const sessions = await import('../app/state/sessions');
const { guardRefreshFetch, refreshGrantToken } = await import('./oidcTokenRefresher');
const id = {
userId: '@a:hs.example',
deviceId: 'DEV1',
baseUrl: 'https://hs.example',
oidc: { issuer: 'https://auth.example/', clientId: 'c1', redirectUri: 'https://app/cb' },
};
const grant = (refresh: string): RequestInit => {
const body = new URLSearchParams();
body.append('grant_type', 'refresh_token');
body.append('client_id', 'c1');
body.append('refresh_token', refresh);
return { method: 'POST', body };
};
const persist = (access: string, refresh: string) =>
sessions.setFallbackSession(access, id.deviceId, id.userId, id.baseUrl, {
refreshToken: refresh,
oidc: id.oidc,
expiresInMs: 300_000,
});
test('[#16] recognises only refresh-token grants', () => {
assert.equal(refreshGrantToken(grant('r1')), 'r1');
assert.equal(
refreshGrantToken({ method: 'POST', body: 'grant_type=refresh_token&refresh_token=r2' }),
'r2',
);
assert.equal(
refreshGrantToken({ method: 'POST', body: 'grant_type=authorization_code&code=x' }),
undefined,
);
assert.equal(refreshGrantToken({ method: 'GET' }), undefined);
assert.equal(refreshGrantToken(undefined), undefined);
});
test('[#16] a real refresh is persisted before the lock is released', async () => {
store.clear();
persist('at1', 'r1');
let calls = 0;
const inner: typeof fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({
token_type: 'Bearer',
access_token: 'at2',
refresh_token: 'r2',
expires_in: 300,
}),
{ status: 200 },
);
};
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
assert.equal(calls, 1);
assert.equal((await res.json()).access_token, 'at2', 'the SDK still reads the body');
const s = sessions.getFallbackSession();
assert.equal(s?.accessToken, 'at2');
assert.equal(s?.refreshToken, 'r2');
assert.ok((s?.expiresInMs ?? 0) > 290_000);
});
test('[#16] the losing tab adopts the winner instead of spending a used token', async () => {
store.clear();
persist('at-winner', 'r-winner'); // a sibling tab already rotated r-old
let calls = 0;
const inner: typeof fetch = async () => {
calls += 1;
return new Response('{"error":"invalid_grant"}', { status: 400 });
};
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r-old'));
assert.equal(calls, 0, 'never hits the issuer');
const body = await res.json();
assert.equal(res.status, 200);
assert.equal(body.token_type, 'Bearer');
assert.equal(body.access_token, 'at-winner');
assert.equal(body.refresh_token, 'r-winner');
});
test('[#16] another account in storage is never adopted', async () => {
store.clear();
sessions.setFallbackSession('at-other', 'DEV9', '@b:hs.example', id.baseUrl, {
refreshToken: 'r-other',
});
let calls = 0;
const inner: typeof fetch = async () => {
calls += 1;
return new Response(JSON.stringify({ token_type: 'Bearer', access_token: 'at2' }), {
status: 200,
});
};
await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
assert.equal(calls, 1);
});
test('[#16] other requests pass straight through', async () => {
let seen = '';
const inner: typeof fetch = async (input) => {
seen = String(input);
return new Response('{}');
};
await guardRefreshFetch(inner, id)('https://hs.example/_matrix/client/v3/sync');
assert.equal(seen, 'https://hs.example/_matrix/client/v3/sync');
});

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