Compare commits

..
Author SHA1 Message Date
Lotus CI 0b940a6df7 Merge remote-tracking branch 'origin/lotus' into perf/lazy-settings
CI / Build & Quality Checks (pull_request) Successful in 2m0s
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 11m52s
# Conflicts:
#	src/app/features/settings/account/Profile.tsx
2026-10-19 01:07:39 -04:00
jared 46da76c21b Merge pull request 'feat(calls): minimise the call PiP to a small bar; phones start minimised (#323)' (#414) from fix/pip-minimise 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-19 01:00:16 -04:00
jared a20accd446 Merge pull request 'fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)' (#409) from fix/translate-unavailable-copy 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-19 00:47:26 -04:00
jared 080831d4ea Merge pull request 'fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)' (#394) from fix/status-expiry-account-data into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 00:35:05 -04:00
Lotus CIandClaude Opus 5.5 8b6464c8b9 perf: load the Settings dialog on demand; entry chunk 351.8 → 277.1 kB gzip
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
Settings (every page, pickers, the denoise tester) was in the entry
chunk though it only renders when opened. Load it lazily behind a
focusable spinner (it opens inside a FocusTrap), and warm the chunk
when the app goes idle. SettingsPages and the status localStorage keys
move to tiny modules so callers don't pull the dialog back in.
sanitize-html (+postcss, htmlparser2) and chroma-js get their own vendor
chunks; with Settings gone the chunk graph had dropped them into the
entry chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:00:19 -04:00
jared 7ef24c14e0 Merge branch 'lotus' into fix/pip-minimise
CI / Build & Quality Checks (pull_request) Successful in 2m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 1m11s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m54s
2026-10-18 23:40:47 -04:00
jared ca284a75e3 Merge branch 'lotus' into fix/translate-unavailable-copy
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m19s
2026-10-18 23:40:46 -04:00
jared 179be38e2c Merge branch 'lotus' into fix/status-expiry-account-data
CI / Build & Quality Checks (pull_request) Successful in 1m43s
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 12m4s
2026-10-18 23:40:44 -04:00
Lotus CI 708e5c2c14 Merge remote-tracking branch 'origin/lotus' into fix/pip-minimise
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
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-18 23:40:18 -04:00
Lotus CI b07eb35159 Merge remote-tracking branch 'origin/lotus' into fix/translate-unavailable-copy
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
# Conflicts:
#	src/app/utils/translation/langUtils.ts
2026-10-18 23:37:54 -04:00
jared 04605f2680 Merge pull request 'ci: re-seed the bundle budget after the October audit fix batch' (#419) from ci/reseed-bundle-budget into lotus
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 12m3s
2026-10-18 23:28:45 -04:00
Lotus CI e4b2405a19 Merge remote-tracking branch 'origin/lotus' into fix/status-expiry-account-data
CI / Build & Quality Checks (pull_request) Failing after 1m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
# Conflicts:
#	src/app/pages/client/ClientNonUIFeatures.tsx
#	src/types/matrix/accountData.ts
2026-10-18 23:20:44 -04:00
Lotus CIandClaude Opus 5.5 bc7458ac99 ci: re-seed the bundle budget after the October audit fix batch
CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m29s
The entry chunk on lotus (d7bc6d94) is 360238 bytes gzip, past the
358317 budget seeded in September, so every PR now fails Build &
Quality Checks. The growth is ~40 merged fixes (call-state, status,
notification, search and settings changes), not one regression.
Re-seed per the file's rule: measured sizes +10% (total 1666894 →
1833583, largest 360238 → 396262).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 23:14:50 -04:00
jared d7bc6d94bb Merge pull request 'fix(calls): explain when the soundboard is not allowed in a voice channel (EC #47)' (#415) from fix/soundboard-not-permitted into lotus
CI / Build & Quality Checks (push) Successful in 3m2s
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 14m28s
2026-10-18 11:24:49 -04:00
jared 9c3fdfff39 Merge pull request 'fix(calls): say when ML noise suppression fails or falls back; drop the spoofable listener (#260)' (#413) from fix/denoise-state-notice into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 10:53:39 -04:00
jared 6191690389 Merge pull request 'docs: document the 55 features missing from LOTUS_FEATURES.md; refresh the Synapse re-check (#294)' (#412) from docs/features-catchup into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 10:34:23 -04:00
jared 3845d2e4a8 Merge pull request 'fix(settings): desktop-only settings get their own Desktop section (#290)' (#411) from fix/desktop-settings-section into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 10:18:09 -04:00
jared 4bfb51c5fe Merge pull request 'fix(dev): ML noise suppression works on npm start; no lotusTheme warning per call (#293)' (#410) from fix/dev-denoise-theme into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 09:59:52 -04:00
jared 72db4c110e Merge pull request 'fix(pwa): the installed app can rotate, and "New Message" opens a new DM (#292)' (#408) from fix/pwa-manifest into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 09:23:21 -04:00
jared a02eda05cb Merge pull request 'fix(translate): say why Translate did nothing (#287)' (#407) from fix/translate-feedback into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 08:59:30 -04:00
jared 8a2d37b5a1 Merge pull request 'fix(desktop): without a tray, the first-close dialog defaults to Quit (cinny-desktop #37)' (#416) from fix/close-no-tray into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 08:18:30 -04:00
jared 3f94d73c7a Merge pull request 'fix(media): "Compress image" no longer blackens transparent images or freezes animations (#331)' (#399) from fix/compress-alpha-anim into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 07:23:09 -04:00
jared 1b887de81d Merge pull request 'fix(consent): a retry that fails again reopens the terms prompt (#311)' (#406) from fix/consent-retry-snooze into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 06:51:24 -04:00
jared 1037d458f4 Merge pull request 'fix(privacy): "Remove Photo Metadata" also covers emoji/sticker packs and badges (#332)' (#405) from fix/strip-metadata-packs into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 06:31:31 -04:00
jared 8a3b2eeb61 Merge pull request 'fix(previews): no genuine preview card for a site a deceptive link only pretends to be (#286)' (#404) from fix/preview-deceptive-links into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 06:15:46 -04:00
jared 099ada5d18 Merge pull request 'fix(previews): a page can no longer pose as a YouTube (etc.) player via its og:url (#326)' (#403) from fix/embed-ogurl-provider into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 05:54:22 -04:00
jared 80ef1179c9 Merge pull request 'fix(settings): valid HTML in setting tiles; start uploads in an effect (#291)' (#402) from fix/settingtile-nesting into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 05:37:06 -04:00
jared 200ef4993c Merge pull request 'fix(a11y): the keyboard shortcuts dialog lists every shortcut (#334)' (#401) from fix/shortcuts-dialog into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 05:21:22 -04:00
jared 0ebf7f0806 Merge pull request 'fix(forward): warn when forwarding out of an encrypted room into one that is not (#330)' (#400) from fix/forward-encryption-warning into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 05:05:17 -04:00
jared 397eb7da81 Merge pull request 'fix(calls): no "the shared window was closed" toast when an admin turns sharing off (#297)' (#398) from fix/screenshare-double-toast into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 04:23:18 -04:00
jared a20e68596a Merge pull request 'fix(notifications): notification replies re-check encryption at send time and use the right account (#299)' (#397) from fix/sw-reply-checks into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 03:55:01 -04:00
jared df9591c44c Merge pull request 'fix(calls): the call PiP no longer opens on top of the composer (#323)' (#396) from fix/pip-default-position into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 03:24:13 -04:00
jared ed24d7f78e Merge pull request 'fix(notifications): in-app toasts no longer need browser notification permission (#324)' (#395) from fix/inapp-toast-permission into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 03:06:12 -04:00
jared 667925733e Merge pull request 'fix(notifications): timed mutes are lifted by any Lotus device, not just the one that set them (#333)' (#393) from fix/mute-timers-account-data into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 02:22:21 -04:00
jared ea5a0d4906 Merge pull request 'fix(moderation): policy viewer lists a stable rule and its legacy twin once (#321)' (#392) from fix/policy-dedupe into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 02:05:12 -04:00
jared a6e90c7bc4 Merge pull request 'fix(security): a dismissed status announcement no longer hides the security nudge (#307)' (#391) from fix/nudge-dismissed-strip into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 01:49:48 -04:00
jared c87fa4f6d5 Merge pull request 'fix(status): banner Details strips Markdown syntax, not every #, _, >, * (#308)' (#390) from fix/kuma-plain-text into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-18 01:33:17 -04:00
Lotus CIandClaude Opus 5.5 f8efbfe38b fix(calls): explain when the soundboard is not allowed in a voice channel (EC #47)
CI / Build & Quality Checks (pull_request) Successful in 2m47s
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 15m47s
The fork now refuses a clip it cannot publish (audio-only rooms grant
only the microphone source) with reason "not_permitted". Say "The
soundboard isn't available in this voice channel" instead of the
generic error. Older forks are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:22:50 -04:00
Lotus CIandClaude Opus 5.5 c4c54dad7a feat(calls): minimise the call PiP to a small bar; phones start minimised (#323)
CI / Build & Quality Checks (pull_request) Successful in 2m51s
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 15m22s
The PiP had no way to get out of the way short of dragging it. Add a
minimise / expand button: minimised it is a 220×40 bar with the call
room's name (still draggable, tap to return to the call). The choice is
remembered; with no saved choice, phones start minimised.

Fixes #323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:08:36 -04:00
Lotus CIandClaude Opus 5.5 acc17f422b fix(calls): say when ML noise suppression fails or falls back; drop the spoofable listener (#260)
CI / Build & Quality Checks (pull_request) Successful in 2m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m56s
The fork reports what actually runs as io.lotus.denoise_state, but the
host never listened ("unsupported action"), so a failed ML denoiser
silently published the raw mic. Handle it: toast when ML did not start
or fell back to another model. Remove LotusDenoiseFeature, which waited
for a postMessage nothing sends any more and accepted it from any frame.

Refs #260

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:48:46 -04:00
Lotus CIandClaude Opus 5.5 5feb2184be docs: document the 55 features missing from LOTUS_FEATURES.md; refresh the Synapse re-check (#294)
CI / Build & Quality Checks (pull_request) Successful in 3m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m11s
LOTUS_FEATURES.md had not been updated since September and was missing
55 shipped features. Add a "June–October 2026 Additions" section,
grouped by area, one line per feature with its commit and issue.
LOTUS_REFERENCE.md's blocked-features note is re-checked against
production Synapse 1.162.0.

Fixes #294

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:29:54 -04:00
Lotus CIandClaude Opus 5.5 86b77ac9f3 fix(settings): desktop-only settings get their own Desktop section (#290)
CI / Build & Quality Checks (pull_request) Successful in 2m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 19s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m11s
Custom Window Chrome, Launch on login / When I close the window and the
keychain status sat inside Appearance, between Monochrome Mode and
Twitter Emoji. Move them to a Desktop section (above App Updates),
rendered only in the desktop app.

Fixes #290

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:28:12 -04:00
Lotus CIandClaude Opus 5.5 cd760ebc3c fix(dev): ML noise suppression works on npm start; no lotusTheme warning per call (#293)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m16s
The denoise worklets/wasm/models were only copied into dist/ by the
lotus-denoise closeBundle hook, so on the dev server the call frame got
the SPA fallback for them and silently used the raw mic. The asset list
is now shared (denoiseSources) between that copy and a dev-server
middleware serving the same paths. Also send lotusTheme=true with
lotusTransparent, which the fork warned about in every call.

Fixes #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:24:03 -04:00
Lotus CIandClaude Opus 5.5 f41de57893 fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)
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 16s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m15s
Where the on-device Translator/LanguageDetector APIs are missing,
Settings said to use Chrome/Edge "or the Lotus desktop app", but the
Linux and macOS apps (WebKit) lack them too, and showed the advice to
people already in the app. Word it by platform. (The per-message
Translate action was already hidden where unsupported.)

Fixes #349

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:19:48 -04:00
Lotus CIandClaude Opus 5.5 1511cb0b97 fix(pwa): the installed app can rotate, and "New Message" opens a new DM (#292)
CI / Build & Quality Checks (pull_request) Successful in 2m55s
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 23m55s
manifest.json locked the installed app to portrait (no landscape video,
screenshare or call view on Android / tablets) and its "New Message"
shortcut pointed at /, the home page. Drop the orientation lock and
point the shortcut at ./direct/create/ (Create Chat).

Fixes #292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:18:23 -04:00
Lotus CIandClaude Opus 5.5 3e11a6fc0b fix(translate): say why Translate did nothing (#287)
CI / Build & Quality Checks (pull_request) Successful in 3m5s
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 13m42s
When a message was already in the target language, or its language
could not be detected, Translate switched itself back off with no chip,
no "Translating…" and no error. Show a short toast instead: "This
message is already in English." or "Couldn't tell what language this
message is in, so it wasn't translated."

Fixes #287

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:17:04 -04:00
Lotus CIandClaude Opus 5.5 5ce4692a7b fix(consent): a retry that fails again reopens the terms prompt (#311)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 19m50s
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
Closing the dialog via "I've accepted — retry" also deactivated its focus
trap, whose onDeactivate is "Later": every close set the 10 s snooze, so
a retry made before the terms were really accepted failed silently.
Retry now closes without snoozing (and clears any earlier snooze).

Fixes #311

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

Fixes #332

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

Fixes #286

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

Fixes #326

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

Refs #291

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

Fixes #334

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

Refs #330

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 23:40:58 -04:00
Lotus CIandClaude Opus 5.5 3a74699fef fix(media): "Compress image" no longer blackens transparent images or freezes animations (#331)
CI / Build & Quality Checks (pull_request) Successful in 3m13s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m29s
Compression re-encodes through a canvas to JPEG. Only PNG was skipped,
so a transparent WebP came out on a black background, and an animated
GIF or WebP became one still frame. GIF and APNG are no longer offered;
an animated WebP (VP8X animation flag) or any image with a transparent
pixel is kept as the original.

Fixes #331

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:57:33 -04:00
Lotus CIandClaude Opus 5.5 7fc42b32d4 fix(calls): no "the shared window was closed" toast when an admin turns sharing off (#297)
CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m14s
When a room admin turned off Allow Screen Sharing, the sharer got two
toasts: the correct "turned off by a room admin" and the fork's
"ended" notice, "the shared window was closed", which was wrong. Skip
the ended notice while the room policy forbids sharing.

Fixes #297

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

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

Fixes #299

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

Refs #323

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

Fixes #324

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:41:48 -04:00
Lotus CIandClaude Opus 5.5 c8576cbb1f fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)
CI / Build & Quality Checks (pull_request) Successful in 3m2s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 25m12s
"Clear after 1 hour" lived in that device's localStorage, so a status
set on a laptop that was then closed stayed on the server until the
laptop opened Lotus again. The expiry is now also kept in account data
(io.lotus.status_expiry: { status_msg, expires_at }). Every device's
StatusExpiryMonitor clears the status when due, but only if it is still
the one that was set to expire (otherwise it just forgets the expiry).

Fixes #300

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

Fixes #333

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

Fixes #321

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

Fixes #307

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

Fixes #308

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:22:12 -04:00
67 changed files with 1533 additions and 331 deletions
+95 -2
View File
@@ -1,7 +1,7 @@
# Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: July 2026.
Last updated: October 2026.
---
@@ -28,7 +28,8 @@ Last updated: July 2026.
19. [Infrastructure](#infrastructure)
20. [Localization](#localization)
21. [Desktop App Features](#desktop-app-features)
22. [Key Custom Files](#key-custom-files)
22. [June–October 2026 Additions](#juneoctober-2026-additions)
23. [Key Custom Files](#key-custom-files)
---
@@ -1522,6 +1523,98 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
---
## June–October 2026 Additions
Features shipped since the sections above were last updated (Gitea #294). Each
line names the commit that introduced it.
### Calls
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
- **Mic level meter** on the mute button (`3449338b`, #146).
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
- **Live-call dot on the space rail** (`4fe9c870`, #148).
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
### Messaging & composer
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
### Search & navigation
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
### Rooms & threads
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
### Media viewer
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
- **Two-finger pinch zoom** (`39589b40`, #164).
### Profile, privacy & safety
- **Report User** (MSC4260) (`26f90087`).
- **Active now / Last active** from presence (`dea1f7af`, #150).
- **Delete all my messages** in a room (`edb46247`, #169).
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
### Appearance
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
### Mobile & PWA
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
### Notifications
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
### Desktop app
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
---
## Key Custom Files
| File | Purpose |
+1 -1
View File
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
+42
View File
@@ -29,6 +29,32 @@ async function settled(page: Page) {
await page.waitForTimeout(2000);
}
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
async function mockAnnouncement(page: Page) {
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const cors = { 'access-control-allow-origin': '*' };
if (route.request().url().includes('/heartbeat/')) {
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
maintenanceList: [],
publicGroupList: [],
},
});
});
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
test.setTimeout(200_000);
await mockAnnouncement(page);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// The announcement wins the one strip slot until it's dismissed.
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
await expect(announcement).toBeVisible();
await expect(strip(page)).toHaveCount(0);
await announcement.getByRole('button', { name: 'Dismiss' }).click();
await page.reload();
await settled(page);
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
+1 -2
View File
@@ -5,7 +5,6 @@
"dir": "auto",
"lang": "en-US",
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"background_color": "#0a0a0a",
"theme_color": "#980000",
@@ -90,7 +89,7 @@
"name": "New Message",
"short_name": "DM",
"description": "Open a new direct message",
"url": "/",
"url": "./direct/create/",
"icons": [
{
"src": "public/res/android/android-chrome-96x96.png",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
"totalGzipBytes": 1731120,
"largestChunkGzipBytes": 358317
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin. Re-seeded 2026-10-18 from lotus d7bc6d94 (total 1666894, largest 360238 bytes gzip) after the October audit fix batch (~40 PRs) grew the entry chunk past the old budget.",
"totalGzipBytes": 1833583,
"largestChunkGzipBytes": 396262
}
+101 -9
View File
@@ -86,9 +86,15 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
/** [Gitea #323] Minimised PiP bar size. */
const PIP_MINI_W = 220;
const PIP_MINI_H = 40;
/** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72;
type Corner = 'se' | 'sw' | 'ne' | 'nw';
@@ -811,6 +817,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
useDenoiseNotices(embed);
// [Gitea #43] The in-frame screenshare button follows the same room policy
// as the host bar's (hidden where the server would refuse the share).
const { allowScreenshare } = useRoomCallPolicy(embed.room);
@@ -999,6 +1006,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
);
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
// instead of the video. Remembered; phones start minimised.
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
try {
const v = localStorage.getItem('pip-minimized');
if (v !== null) return v === '1';
} catch {
/* storage unavailable */
}
return window.innerWidth < 600;
});
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
const togglePipMinimized = useCallback(() => {
setPipMinimized((m) => {
try {
localStorage.setItem('pip-minimized', m ? '0' : '1');
} catch {
/* storage unavailable */
}
return !m;
});
}, []);
useEffect(() => {
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
@@ -1064,8 +1093,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
} else {
el.style.left = `${window.innerWidth - 280 - 16}px`;
el.style.top = `${window.innerHeight - 158 - 72}px`;
// [Gitea #323] First use: top-right under the room header, over the
// timeline. Bottom-right sat on the composer (Send / Emoji, and on
// phones the whole message box), so typing landed on the call.
el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`;
el.style.top = `${PIP_DEFAULT_TOP}px`;
}
el.style.width = '280px';
el.style.height = '158px';
@@ -1096,6 +1128,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
}
}, [pipMode, callVisible]);
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
// below has positioned and sized it). Keeps the bar on screen.
React.useEffect(() => {
const el = callEmbedRef.current;
if (!el || !pipMode) return;
if (pipMinimized) {
if (el.offsetHeight > PIP_MINI_H) {
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
}
el.style.width = `${PIP_MINI_W}px`;
el.style.height = `${PIP_MINI_H}px`;
} else {
el.style.width = `${pipFullSizeRef.current.width}px`;
el.style.height = `${pipFullSizeRef.current.height}px`;
}
const rect = el.getBoundingClientRect();
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
}, [pipMode, pipMinimized, callEmbedRef]);
React.useEffect(() => {
if (!pipMode) return;
const onPipWindowResize = (): void => {
@@ -1414,19 +1466,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
// Minimised: cover the frame so only the bar shows.
background: pipMinimized ? color.Surface.Container : 'transparent',
cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
alignItems: pipMinimized ? 'center' : 'flex-start',
padding: '6px',
}}
>
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<div
style={{
display: 'flex',
gap: config.space.S100,
alignItems: 'center',
...(pipMinimized ? { width: '100%' } : {}),
}}
>
{/* Leftmost: the top-right corner is a resize handle. */}
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
aria-pressed={pipMinimized}
onClick={(e) => {
e.stopPropagation();
togglePipMinimized();
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
color: '#fff',
}}
>
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
</IconButton>
{!pipMinimized && document.fullscreenEnabled && (
<IconButton
type="button"
size="300"
@@ -1458,14 +1541,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
...(pipMinimized
? {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
: {}),
}}
>
↗ Return to call
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
</div>
</div>
</div>
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
+12 -1
View File
@@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() {
const [opened, setOpened] = useState(false);
const [blockedCount, setBlockedCount] = useState(0);
const snoozedUntil = useRef(0);
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
const closingForRetry = useRef(false);
const blocked = useRef(new Map<MatrixEvent, Room>());
const modalStyle = useModalStyle(440);
const server = mx.getDomain() ?? 'Your homeserver';
@@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() {
}, [consentUri]);
const retry = useCallback(() => {
closingForRetry.current = true;
snoozedUntil.current = 0;
setOpen(false);
setOpened(false);
const pending = Array.from(blocked.current.entries());
@@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() {
focusTrapOptions={{
initialFocus: '#consent-review',
fallbackFocus: '#consent-later',
onDeactivate: later,
onDeactivate: () => {
if (closingForRetry.current) {
closingForRetry.current = false;
return;
}
later();
},
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
@@ -16,7 +16,7 @@ import { SettingTile } from '../setting-tile';
import { UsageSwitcher } from './UsageSwitcher';
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload';
import { getImageInfo, TUploadContent } from '../../utils/matrix';
@@ -60,7 +60,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
[imagePack, savedImages, uploadedImages],
);
const pickFiles = useFilePicker(
const pickFiles = useStrippedFilePicker(
useCallback(
(pickedFiles: File[]) => {
const uniqueFiles = pickedFiles.map((file) => {
@@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common';
import { BreakWord } from '../../styles/Text.css';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card';
@@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const pickFile = useStrippedFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
import { previewUrlsFor } from '../../utils/linkSafety';
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
const COLLAPSE_MAX_HEIGHT_REM = 20;
@@ -343,7 +344,13 @@ export function MText({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -401,7 +408,13 @@ export function MEmote({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -458,7 +471,13 @@ export function MNotice({
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
const urls = urlsMatch
? previewUrlsFor(
[...new Set(urlsMatch)],
typeof customBody === 'string' ? customBody : undefined,
)
: undefined;
return (
<>
@@ -43,7 +43,9 @@ export function SettingTile({ title, description, before, after, children }: Set
</Text>
)}
{description && (
<Text className={BreakWord} size="T200" priority="300">
// [Gitea #291] A <div>, not a <p>: many tiles pass blocks (badges,
// <Box>, <Text>) as the description, which is invalid inside a <p>.
<Text as="div" className={BreakWord} size="T200" priority="300">
{description}
</Text>
)}
@@ -310,9 +310,13 @@ export function UploadCardRenderer({
const { file } = upload;
const fileSizeExceeded = file.size >= allowSize;
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
startUpload();
}
// [Gitea #291] Start in an effect, not during render: the desktop taskbar
// progress hook subscribes to the upload atoms, so a render-time start
// updated another component mid-render (React warning on every image send).
const idle = upload.status === UploadStatus.Idle;
useEffect(() => {
if (idle && !fileSizeExceeded) startUpload();
}, [idle, fileSizeExceeded, startUpload]);
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
@@ -54,6 +54,7 @@ import {
GIF_PREVIEW_MAX_WIDTH,
gifPreviewBox,
} from '../../utils/gifPreviewSize';
import { sameProvider } from './embedProvider';
const linkStyles = { color: color.Success.Main };
@@ -2497,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
if (embed) return embed;
const ogUrl = prev['og:url'];
return typeof ogUrl === 'string' && ogUrl !== url
// [Gitea #326] …but only from an og:url on the posted link's own
// provider: the page sets og:url, so any site could otherwise show up
// as a playable YouTube (etc.) card that links to itself.
return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl)
? parseMediaEmbed(ogUrl, window.location.hostname)
: null;
};
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sameProvider } from './embedProvider';
test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => {
const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
assert.equal(sameProvider('https://evil.example/free-gift', yt), false);
assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false);
assert.equal(sameProvider('https://youtu.be/abc', yt), true);
assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true);
assert.equal(
sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'),
true,
);
assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true);
assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true);
assert.equal(
sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'),
true,
);
assert.equal(sameProvider('not a url', yt), false);
});
@@ -0,0 +1,36 @@
/**
* [Gitea #326] A page's og:url is chosen by the page itself, so a phishing
* link could serve `og:url = youtube.com/watch?v=…` and render as a playable
* YouTube card linking to the attacker. Only trust an og:url on the same
* provider as the link actually posted (including the provider's own
* short-link domains).
*/
const PROVIDER_ALIASES: Record<string, string> = {
'youtu.be': 'youtube.com',
'youtube-nocookie.com': 'youtube.com',
'spotify.link': 'spotify.com',
'redd.it': 'reddit.com',
'x.com': 'twitter.com',
'twitch.tv': 'twitch.tv',
'apple.co': 'apple.com',
};
/**
* Pure: the provider a host belongs to (last two labels, aliases folded). Hosts
* under two-part public suffixes (`*.co.uk`) share a value, which is harmless
* here: no embeddable provider lives under one.
*/
export const providerOf = (host: string): string => {
const parts = host.toLowerCase().replace(/\.$/, '').split('.');
const base = parts.slice(-2).join('.');
return PROVIDER_ALIASES[base] ?? base;
};
/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */
export function sameProvider(url: string, ogUrl: string): boolean {
try {
return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname);
} catch {
return false;
}
}
+11 -6
View File
@@ -50,6 +50,14 @@ type FlatClip = {
* A management toggle reveals the pack editors (personal + this room, if
* permitted). Space-wide packs are managed from Space settings.
*/
/** [EC#13, EC#47] Why the fork refused to play a clip, for the user. */
export const soundboardRefusalText = (reason: string | undefined): string => {
if (reason === 'muted') return 'Unmute your microphone to play a soundboard clip.';
if (reason === 'not_permitted')
return "The soundboard isn't available in this voice channel (only microphone audio is allowed here).";
return 'Could not play that clip.';
};
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
const mx = useMatrixClient();
const { room } = callEmbed;
@@ -122,13 +130,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
.catch(() => undefined);
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
if (!result.played) {
// [EC#13] Refused fork-side (only reason today: local mic muted) —
// [EC#13, EC#47] Refused fork-side (mic muted, or the room's token
// only allows the microphone source, as in audio-only rooms) —
// don't play it locally either, or the user would think it went out.
setError(
result.reason === 'muted'
? 'Unmute your microphone to play a soundboard clip.'
: 'Could not play that clip.',
);
setError(soundboardRefusalText(result.reason));
done();
return;
}
@@ -40,7 +40,7 @@ import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useStrippedFilePicker } from '../../../hooks/useStrippedFilePicker';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -68,7 +68,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
const [iconFile, setIconFile] = useState<File>();
const pickFile = useFilePicker(setIconFile, false);
const pickFile = useStrippedFilePicker(setIconFile, false);
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
+5 -4
View File
@@ -80,7 +80,7 @@ import {
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
@@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
prevMode,
).catch(() => {});
if (durationMs !== null) {
scheduleMuteTimer(room.roomId, durationMs, () => {
unmuteRoom(mx, room.roomId);
});
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
} else {
// An indefinite mute replaces any timed one.
await clearMuteTimer(mx, room.roomId);
}
requestClose();
},
+14 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
@@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => {
const entries = [
{ roomId: '!a:x', unmuteAt: 1000 },
{ roomId: '!b:x', unmuteAt: 2000 },
];
assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries);
assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [
{ roomId: '!b:x', unmuteAt: 5 },
]);
assert.deepEqual(parseMuteTimers(undefined), []);
assert.deepEqual(parseMuteTimers({ timers: null }), []);
});
+77 -16
View File
@@ -7,7 +7,7 @@ import {
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
// Legacy localStorage key (before #333); migrated into account data once.
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
@@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
const timers = (content as { timers?: unknown } | undefined)?.timers;
if (!timers || typeof timers !== 'object') return [];
return Object.entries(timers as Record<string, unknown>)
.filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]))
.map(([roomId, unmuteAt]) => ({ roomId, unmuteAt }));
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
/** Pure: account data content for `entries`. */
export const muteTimersContent = (entries: MuteTimerEntry[]) => ({
timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])),
});
/**
* [Gitea #333] Timed mutes live in account data: the mute itself is a
* server-side push rule on every device, so the timer must be too — kept in one
* browser's localStorage, only that device ever unmuted (and never after a
* logout there).
*/
export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] {
return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent());
}
async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise<void> {
await mx.setAccountData(
AccountDataEvent.LotusMuteTimers as never,
muteTimersContent(entries) as never,
);
}
/** Move timers kept in this browser's localStorage (pre-#333) into account data. */
export async function migrateLocalMuteTimers(mx: MatrixClient): Promise<void> {
let legacy: MuteTimerEntry[] = [];
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
legacy = Array.isArray(parsed)
? parsed.filter(
(e): e is MuteTimerEntry =>
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
)
: [];
} catch {
legacy = [];
}
if (legacy.length === 0) return;
const current = loadMuteTimers(mx);
const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))];
await saveMuteTimers(mx, merged);
try {
localStorage.removeItem(MUTE_TIMERS_KEY);
} catch {
/* ignore */
}
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
await clearMuteTimer(mx, roomId);
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
const timers = loadMuteTimers(mx);
if (!timers.some((e) => e.roomId === roomId)) return;
await saveMuteTimers(
mx,
timers.filter((e) => e.roomId !== roomId),
).catch(() => {});
}
/**
* Record when a timed mute ends. MuteTimerRestore (on every Lotus device)
* schedules the unmute from account data, so whichever is online at expiry
* lifts it.
*/
export async function scheduleMuteTimer(
mx: MatrixClient,
roomId: string,
durationMs: number,
): Promise<void> {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
}
@@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
// ── Policy event types ────────────────────────────────────────────────────────
@@ -38,13 +39,6 @@ type PolicyRuleContent = {
recommendation?: string;
};
type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
type PolicyTab = 'users' | 'rooms' | 'servers';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -53,13 +47,6 @@ function isGlob(entity: string): boolean {
return entity.includes('*') || entity.includes('?');
}
function recommendationLabel(rec: string): string {
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
// (pre-stabilization) recommendation still emitted by older bots.
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
return rec;
}
/**
* Fetch all state events of a given type from a room's live forward state.
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
@@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== '');
}
/**
* Merge policy entries from the stable and legacy event types for a rule
* kind, de-duplicating by entity+recommendation so a room that emits both a
* stable and a legacy rule for the same target isn't double-listed.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${entry.recommendation}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupePolicyEntries, recommendationLabel } from './policyEntries';
const e = (entity: string, recommendation: string, stateKey = entity) => ({
entity,
reason: 'spam waves',
recommendation,
stateKey,
});
test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => {
const out = dedupePolicyEntries([
e('@spammer:evil.example', 'm.ban'),
e('@spammer:evil.example', 'org.matrix.mjolnir.ban'),
e('@*:spam.example', 'm.ban'),
]);
assert.deepEqual(
out.map((x) => x.entity),
['@spammer:evil.example', '@*:spam.example'],
);
});
test('dedupePolicyEntries: different recommendations stay separate', () => {
assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2);
});
test('recommendationLabel', () => {
assert.equal(recommendationLabel('m.ban'), 'Ban');
assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban');
assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute');
});
@@ -0,0 +1,35 @@
export type PolicyEntry = {
entity: string;
reason: string;
recommendation: string;
stateKey: string;
};
/**
* `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
* (pre-stabilization) recommendation still emitted by older bots.
*/
export const normalizeRecommendation = (rec: string): string =>
rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec;
export function recommendationLabel(rec: string): string {
if (normalizeRecommendation(rec) === 'm.ban') return 'Ban';
return rec;
}
/**
* Merge stable and legacy rule lists, one entry per entity + recommendation.
* [Gitea #321] Keyed on the normalised recommendation: a bot publishing both
* `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
@@ -47,8 +47,12 @@ import {
recentForwardTargetsAtom,
addRecentForwardTarget,
} from '../../../state/recentForwardTargets';
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
import {
buildForwardContent,
buildPlaintextAttachmentContent,
forwardEncryptionWarning,
} from './forwardContent';
import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta';
type RoomRowProps = {
room: Room;
@@ -275,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
// Selection persists across query changes: a room selected then filtered out
// of the rendered slice stays selected.
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
const encryptionWarning = forwardEncryptionWarning(
!!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(),
[...selectedRoomIds]
.map((id) => mx.getRoom(id))
.filter((r): r is Room => !!r && !r.hasEncryptionStateEvent())
// The name the user sees (their own "Rename for me" name, if set).
.map((r) => getLocalRoomName(mx, r.roomId) ?? r.name),
);
const toggleRoom = useCallback((roomId: string) => {
setSelectedRoomIds((prev) => {
@@ -590,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
shrink="No"
direction="Column"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
gap="200"
>
{encryptionWarning && (
<Text size="T200" role="note" style={{ color: color.Warning.Main }}>
{encryptionWarning}
</Text>
)}
<Button
variant="Primary"
size="400"
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { buildForwardContent, getForwardedMeta } from './forwardContent';
import { buildForwardContent, forwardEncryptionWarning, getForwardedMeta } from './forwardContent';
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
@@ -171,3 +171,16 @@ test('getForwardedMeta rejects malformed metadata', () => {
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
assert.equal(getForwardedMeta({}), undefined);
});
test('forwardEncryptionWarning: only from an encrypted room into unencrypted ones (#330)', () => {
assert.equal(forwardEncryptionWarning(false, ['general']), undefined);
assert.equal(forwardEncryptionWarning(true, []), undefined);
assert.match(
forwardEncryptionWarning(true, ['general']) ?? '',
/^general isn't encrypted\..*that room, along with who originally wrote it\.$/,
);
assert.match(
forwardEncryptionWarning(true, ['a', 'b']) ?? '',
/^2 of the rooms you picked aren't encrypted\..*those rooms/,
);
});
@@ -122,3 +122,23 @@ export async function buildPlaintextAttachmentContent(
return plainContent;
}
/**
* [Gitea #330] The note shown when forwarding out of an encrypted room into
* rooms that aren't: the text (and a re-uploaded attachment) becomes readable
* by the server, and the original author is shown with it. Undefined when
* there's nothing to warn about.
*/
export function forwardEncryptionWarning(
sourceEncrypted: boolean,
unencryptedDestNames: string[],
): string | undefined {
if (!sourceEncrypted || unencryptedDestNames.length === 0) return undefined;
const where =
unencryptedDestNames.length === 1
? `${unencryptedDestNames[0]} isn't encrypted.`
: `${unencryptedDestNames.length} of the rooms you picked aren't encrypted.`;
return `${where} The forwarded message (and any attachment) will be readable by the server and everyone in ${
unencryptedDestNames.length === 1 ? 'that room' : 'those rooms'
}, along with who originally wrote it.`;
}
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
const statusDismissed = useAtomValue(statusDismissedAtom);
const requestSettings = useSetAtom(settingsRequestAtom);
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
// never stacks under the "Connecting…" strip at startup.
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
});
// One strip at a time: outages, maintenance and a lost connection win.
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
// on screen and must not keep the nudge hidden for as long as it's posted.
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -7,22 +7,10 @@ import { useClientConfig } from '../../hooks/useClientConfig';
import { useSyncState } from '../../hooks/useSyncState';
import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info;
@@ -179,22 +167,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
/** The logged-in app's status strip. */
export function ServerStatusBanner() {
const { status, syncLost } = useAtomValue(serverStatusAtom);
const [dismissed, setDismissed] = useState(readDismissed);
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
const banner = pickBanner(status, {
syncLost,
where: 'client',
dismissed: new Set(dismissed),
});
if (!banner) return null;
const dismiss = () => {
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
setDismissed(next);
};
const dismiss = () => dismissKey(banner.key);
return <StatusStrip banner={banner} onDismiss={dismiss} />;
}
+2 -9
View File
@@ -33,16 +33,9 @@ import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { SettingsPages } from './pages';
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
export { SettingsPages };
type SettingsMenuItem = {
page: SettingsPages;
@@ -0,0 +1,43 @@
import React, { lazy, Suspense } from 'react';
import { Box, Spinner, config } from 'folds';
import type { SettingsPages } from './pages';
// Settings is a large, rarely-open dialog (all its pages, pickers, the
// denoise tester…). Load it on first open instead of in the entry chunk, and
// warm the chunk once the app is idle so opening it rarely waits.
const loadSettings = () => import('./Settings');
const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings })));
if (typeof window !== 'undefined') {
const warm = () => {
loadSettings().catch(() => undefined);
};
if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 });
else setTimeout(warm, 5_000);
}
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
};
export function Settings(props: SettingsProps) {
return (
<Suspense
fallback={
// Settings opens inside a FocusTrap, which needs a tabbable node.
<Box
tabIndex={0}
role="status"
aria-label="Loading settings"
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S700, outline: 'none' }}
>
<Spinner variant="Secondary" size="400" />
</Box>
}
>
<SettingsDialog {...props} />
</Suspense>
);
}
+10 -4
View File
@@ -67,6 +67,8 @@ import {
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
import { writeStatusExpiry } from '../../../utils/statusExpiry';
type ProfileProps = {
profile: UserProfile;
@@ -365,8 +367,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
);
}
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' },
@@ -508,12 +509,16 @@ function ProfileStatus() {
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
const expiresAt = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt));
// [Gitea #300] …and on every other Lotus device, via account data.
writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined);
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, msg).catch(() => undefined);
}
},
[saveStatus, userId],
[saveStatus, userId, mx],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -553,6 +558,7 @@ function ProfileStatus() {
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, '').catch(() => undefined);
// Preserve the user's chosen presence when clearing the status message.
mx.setPresence({
presence: presenceStateFromSetting(presenceStatus, hidePresence),
+20 -5
View File
@@ -108,6 +108,7 @@ import {
TRANSLATE_TARGET_LANGUAGES,
normalizeLang,
isSupportedTargetLang,
translationUnavailableText,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
@@ -596,10 +597,6 @@ function Appearance() {
/>
</SequenceCard>
<DesktopChromeSetting />
<AutostartSetting />
<KeychainMirrorSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
@@ -2760,7 +2757,7 @@ function Messages() {
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.'
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
: translationUnavailableText(isTauriEnv())
}
after={
<SettingsSelect
@@ -2856,6 +2853,23 @@ function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUp
);
}
/**
* [Gitea #290] Desktop-only settings in their own section (they used to sit
* inside Appearance, between Monochrome Mode and Twitter Emoji). Renders
* nothing outside the desktop app.
*/
function DesktopSettings() {
if (!isTauriEnv()) return null;
return (
<Box direction="Column" gap="100">
<Text size="L400">Desktop</Text>
<AutostartSetting />
<KeychainMirrorSetting />
<DesktopChromeSetting />
</Box>
);
}
function AppUpdates() {
const { isTauri, status, check, install, installKind } = useTauriUpdater();
if (!isTauri) return null;
@@ -2964,6 +2978,7 @@ export function General({ requestClose }: GeneralProps) {
<StorageUsage />
<SettingsSyncSection />
<Calls />
<DesktopSettings />
<AppUpdates />
</Box>
</PageContent>
+2 -1
View File
@@ -1 +1,2 @@
export * from './Settings';
export { SettingsPages } from './pages';
export { Settings } from './SettingsLazy';
+11
View File
@@ -0,0 +1,11 @@
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
* page without pulling the dialog into the entry chunk. */
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
@@ -25,6 +25,9 @@ import { settingsAtom } from '../../state/settings';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import * as css from './KeyboardShortcutsDialog.css';
import { BLOCK_HOTKEYS, INLINE_HOTKEYS } from '../../components/editor/keyboard';
import { MarkType } from '../../components/editor/types';
import { BLOCK_LABELS, MARK_LABELS, hotkeyKeys, keyCodeLabel } from './shortcutLabels';
/** Global open-state for the keyboard shortcuts help dialog. */
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
@@ -98,6 +101,9 @@ export function KeyboardShortcutsDialog() {
const { open, closeDialog } = useKeyboardShortcutsDialog();
const modalStyle = useModalStyle(480);
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
@@ -109,6 +115,8 @@ export function KeyboardShortcutsDialog() {
rows: [
{ description: 'Show keyboard shortcuts', keys: ['?'] },
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
// [Gitea #334] the quick switcher / search was missing
{ description: 'Quick switcher: jump to a room or person', keys: [modKey, 'K'] },
],
},
{
@@ -124,6 +132,26 @@ export function KeyboardShortcutsDialog() {
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
},
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
{ description: 'Edit your last message (empty composer)', keys: ['↑'] },
],
},
{
// [Gitea #334] generated from the editor's own bindings so they can't drift
title: 'Composer formatting',
rows: [
...Object.entries(INLINE_HOTKEYS).map(([hotkey, mark]) => ({
description:
mark === MarkType.StrikeThrough
? `${MARK_LABELS[mark]} (not the browser's Save)`
: MARK_LABELS[mark],
keys: hotkeyKeys(hotkey, modKey),
})),
{ description: 'Heading 1 / 2 / 3', keys: [modKey, '1 / 2 / 3'] },
...Object.entries(BLOCK_HOTKEYS).map(([hotkey, block]) => ({
description: BLOCK_LABELS[block] ?? block,
keys: hotkeyKeys(hotkey, modKey),
})),
{ description: 'Clear formatting', keys: [modKey, 'E'] },
],
},
{
@@ -132,6 +160,19 @@ export function KeyboardShortcutsDialog() {
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
],
},
{
// [Gitea #334] the user's own bindings (Settings → Calls)
title: 'Calls',
rows: [
{
description: pttMode
? 'Push to talk (hold)'
: 'Push to talk (hold; turn on in Settings → Calls)',
keys: [keyCodeLabel(pttKey)],
},
{ description: 'Deafen / undeafen', keys: [keyCodeLabel(deafenKey)] },
],
},
];
return (
@@ -0,0 +1,16 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hotkeyKeys, keyCodeLabel } from './shortcutLabels';
test('hotkeyKeys (#334)', () => {
assert.deepEqual(hotkeyKeys('mod+b', 'Ctrl'), ['Ctrl', 'B']);
assert.deepEqual(hotkeyKeys("mod+'", '⌘'), ['⌘', "'"]);
assert.deepEqual(hotkeyKeys('mod+[', 'Ctrl'), ['Ctrl', '[']);
});
test('keyCodeLabel (#334)', () => {
assert.equal(keyCodeLabel('KeyM'), 'M');
assert.equal(keyCodeLabel('Digit1'), '1');
assert.equal(keyCodeLabel('Space'), 'Space');
assert.equal(keyCodeLabel(''), 'Not set');
});
@@ -0,0 +1,29 @@
import { BlockType, MarkType } from '../../components/editor/types';
/** Pure: an is-hotkey string ('mod+b', "mod+'") as keycap labels. */
export const hotkeyKeys = (hotkey: string, modKey: string): string[] =>
hotkey.split('+').map((k) => (k === 'mod' ? modKey : k.length === 1 ? k.toUpperCase() : k));
/** Pure: a KeyboardEvent.code ('KeyM', 'Digit1', 'Space') as a keycap label. */
export const keyCodeLabel = (code: string | undefined): string => {
if (!code) return 'Not set';
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit\d$/.test(code)) return code.slice(5);
return code;
};
export const MARK_LABELS: Record<MarkType, string> = {
[MarkType.Bold]: 'Bold',
[MarkType.Italic]: 'Italic',
[MarkType.Underline]: 'Underline',
[MarkType.StrikeThrough]: 'Strikethrough',
[MarkType.Code]: 'Inline code',
[MarkType.Spoiler]: 'Spoiler',
};
export const BLOCK_LABELS: Partial<Record<BlockType, string>> = {
[BlockType.OrderedList]: 'Numbered list',
[BlockType.UnorderedList]: 'Bulleted list',
[BlockType.BlockQuote]: 'Quote',
[BlockType.CodeBlock]: 'Code block',
};
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { CallEmbed } from '../plugins/call';
import { toastQueueAtom } from '../state/toast';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { denoiseNotice, DenoiseStateReport } from '../utils/denoiseNotice';
/**
* [Gitea #260] Tell the user when ML noise suppression didn't attach (raw mic)
* or fell back to another model. The fork's `io.lotus.denoise_state` used to
* go unanswered ("unsupported action"), so failures were silent.
*/
export function useDenoiseNotices(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
const [mode] = useSetting(settingsAtom, 'callNoiseSuppression');
const [model] = useSetting(settingsAtom, 'callDenoiseModel');
const latest = useRef({ mode, model });
latest.current = { mode, model };
useEffect(
() =>
embed.listenAction<{ data?: DenoiseStateReport }>('io.lotus.denoise_state', (evt) => {
if (latest.current.mode !== 'ml') return;
const body = denoiseNotice(latest.current.model, evt.detail?.data);
if (!body) return;
setToast({
id: `denoise-state-${Date.now()}`,
iconSrc: Icons.Mic,
displayName: 'Noise suppression',
body,
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
});
}),
[embed, setToast],
);
}
+14 -2
View File
@@ -12,7 +12,8 @@ import {
markAutoTranslateTried,
} from '../state/translation';
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
import { sameLanguage } from '../utils/translation/langUtils';
import { sameLanguage, translateSkipMessage } from '../utils/translation/langUtils';
import { toastQueueAtom } from '../state/toast';
export type MsgTranslationStatus =
| 'idle' // not translating (original shown)
@@ -52,6 +53,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
// Write-only: reading the whole cache array here (via useAtom) would
// resubscribe this message to every other message's writes. [Gitea #39]
const setCache = useSetAtom(translationCacheAtom);
const setToast = useSetAtom(toastQueueAtom);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale
@@ -124,6 +126,16 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
if (!from || sameLanguage(from, targetLang)) {
setStatus('skipped');
setActive(false);
// [Gitea #287] Say why nothing happened instead of silently undoing
// the click (no chip shows once translation is switched back off).
setToast({
id: `translate-skipped-${eventId}-${Date.now()}`,
displayName: 'Translate',
body: translateSkipMessage(from, targetLang),
roomName: '',
roomId: '',
onClick: () => undefined,
});
return;
}
setFromLang(from);
@@ -154,7 +166,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
// Invalidate this run's writes if inputs change / unmount.
runIdRef.current += 1;
};
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
}, [active, cached, supported, text, targetLang, key, setActive, setCache, setToast, eventId]);
// When switched off, drop back to idle (keep the cached translation around so
// toggling back on is instant).
@@ -0,0 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shouldShowScreenshareNotice } from './useScreenshareNotices';
test('shouldShowScreenshareNotice: no "window was closed" when an admin turned sharing off (#297)', () => {
assert.equal(shouldShowScreenshareNotice('ended', false), false);
assert.equal(shouldShowScreenshareNotice('ended', true), true);
assert.equal(shouldShowScreenshareNotice('no-frames', false), true);
assert.equal(shouldShowScreenshareNotice('alone', true), true);
});
+17 -2
View File
@@ -1,8 +1,9 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { CallEmbed } from '../plugins/call';
import { toastQueueAtom } from '../state/toast';
import { useRoomCallPolicy } from './useRoomCallPolicy';
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
@@ -19,18 +20,32 @@ export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string |
}
};
/** Pure: a policy-forced stop is explained by its own toast (#297). */
export const shouldShowScreenshareNotice = (
kind: ScreenshareNoticeKind,
allowScreenshare: boolean,
): boolean => !(kind === 'ended' && !allowScreenshare);
/**
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
* closed, no frames for a while, or a long share with nobody watching.
*/
export function useScreenshareNotices(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
// [Gitea #297] When a room admin turns screen sharing off, the share ends
// and the fork reports `ended` ("the shared window was closed"), which is
// wrong; useCallPolicyRevokedToast already says what happened.
const { allowScreenshare } = useRoomCallPolicy(embed.room);
const allowRef = useRef(allowScreenshare);
allowRef.current = allowScreenshare;
useEffect(
() =>
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
'io.lotus.screenshare_notice',
(evt) => {
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
const kind = evt.detail?.data?.kind as ScreenshareNoticeKind;
if (!shouldShowScreenshareNotice(kind, allowRef.current)) return;
const body = describeScreenshareNotice(kind);
if (!body) return;
setToast({
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
+36
View File
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { useFilePicker } from './useFilePicker';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { stripImageMetadata } from '../utils/stripImageMetadata';
/**
* [Gitea #332] `useFilePicker` for images that get published (emoji / sticker
* packs, pack avatars, power-level badges): applies "Remove Photo Metadata"
* like message attachments and avatars, so a phone photo's GPS location and
* timestamp don't go to every member of the room or space.
*/
export const useStrippedFilePicker = <M extends boolean | undefined = undefined>(
onSelect: (file: M extends true ? File[] : File) => void,
multiple?: M,
) => {
const [strip] = useSetting(settingsAtom, 'stripImageMetadata');
const handle = useCallback(
(picked: File | File[]) => {
if (!strip) {
onSelect(picked as M extends true ? File[] : File);
return;
}
const clean = (f: File) => stripImageMetadata(f).then((r) => r.file);
if (Array.isArray(picked)) {
Promise.all(picked.map(clean)).then((files) =>
onSelect(files as M extends true ? File[] : File),
);
} else {
clean(picked).then((file) => onSelect(file as M extends true ? File[] : File));
}
},
[strip, onSelect],
);
return useFilePicker(handle as (file: M extends true ? File[] : File) => void, multiple);
};
+60 -59
View File
@@ -59,11 +59,11 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import {
MAX_MUTE_TIMEOUT_MS,
MuteTimerEntry,
loadMuteTimers,
migrateLocalMuteTimers,
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
@@ -92,6 +92,8 @@ import {
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
import { AccountDataEvent } from '../../../types/matrix/accountData';
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
@@ -335,7 +337,9 @@ function InviteNotifications() {
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
// [Gitea #324] The in-app toast (focused window) needs no browser
// permission; only the OS notification does.
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
notify(newCount);
}
if (notificationSound && inviteSoundId !== 'none') {
@@ -370,30 +374,35 @@ function PresenceUpdater() {
function MuteTimerRestore() {
const mx = useMatrixClient();
// [Gitea #333] Timers are in account data, so a mute set on another device
// is lifted here too, and changes there (a new mute, a manual unmute)
// reschedule. Moves any timers this browser kept locally (pre-#333) first.
useEffect(() => {
const timers = loadMuteTimers();
if (timers.length === 0) return undefined;
const now = Date.now();
const pastDue: MuteTimerEntry[] = [];
const future: MuteTimerEntry[] = [];
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
pastDue.forEach((entry) => {
unmuteRoom(mx, entry.roomId);
});
const handles = future.map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
),
);
migrateLocalMuteTimers(mx).catch(() => {});
}, [mx]);
useEffect(() => {
let handles: ReturnType<typeof setTimeout>[] = [];
const schedule = () => {
handles.forEach(clearTimeout);
const now = Date.now();
handles = loadMuteTimers(mx).map((entry) =>
setTimeout(
() => {
unmuteRoom(mx, entry.roomId);
},
Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)),
),
);
};
const onAccountData = (event: MatrixEvent) => {
if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule();
};
schedule();
mx.on(ClientEvent.AccountData, onAccountData);
return () => {
handles.forEach(clearTimeout);
mx.removeListener(ClientEvent.AccountData, onAccountData);
};
}, [mx]);
@@ -428,12 +437,7 @@ function StatusExpiryMonitor() {
// permanently and the status stayed set forever. Retry the write (honouring
// Retry-After), and only forget the status once the server has taken it.
let clearing = false;
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
const clearStatus = () => {
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
@@ -443,12 +447,31 @@ function StatusExpiryMonitor() {
noteLocalStatusWrite();
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
writeStatusExpiry(mx, '').catch(() => undefined);
})
.catch(() => undefined)
.finally(() => {
clearing = false;
});
};
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
const ts = stored ? parseInt(stored, 10) : 0;
if (ts && Date.now() >= ts) {
clearStatus();
return;
}
// [Gitea #300] An expiry set on another device (account data): clear the
// status if it is still that one, otherwise just forget the expiry.
const action = statusExpiryAction(
readStatusExpiry(mx),
mx.getUser(userId)?.presenceStatusMsg,
Date.now(),
);
if (action === 'clear') clearStatus();
else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined);
};
check();
// Poll often enough that a rate-limited retry lands promptly, but not so
@@ -572,7 +595,8 @@ function MessageNotifications() {
// NotificationOptions typing.
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
}),
data: { path: roomPath, roomId, threadId: threadId || undefined },
// userId: [Gitea #299] the SW replies as this account only.
data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() },
},
() => {
window.focus();
@@ -580,7 +604,7 @@ function MessageNotifications() {
},
);
},
[navigate, setToast, mDirects],
[navigate, setToast, mDirects, mx],
);
// N105: when a service-worker-owned notification is clicked, the SW focuses
@@ -642,7 +666,11 @@ function MessageNotifications() {
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
// [Gitea #324] notify() shows an in-app toast while the window is focused,
// which needs no browser permission; only its OS-notification path does.
// Gating both on the permission left people who declined it (most web
// users) with a ping sound and nothing on screen.
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
@@ -1062,32 +1090,6 @@ function TauriUpdateFeature() {
return null;
}
function LotusDenoiseFeature() {
const setToast = useSetAtom(toastQueueAtom);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'lotus-denoise-status') {
const { active, error } = event.data;
if (!active) {
setToast({
id: `denoise-fail-${Date.now()}`,
displayName: 'Audio Quality',
body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`,
roomName: 'System',
roomId: '',
});
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setToast]);
return null;
}
// Registers the global `?` shortcut (ignored while typing) and renders the
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
function KeyboardShortcutsFeature() {
@@ -1170,7 +1172,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<CloseBehaviorPrompt />
<ConsentRequiredPrompt />
<OutboxFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
{children}
+3
View File
@@ -218,6 +218,9 @@ export class CallEmbed {
// - transparent background so the room wallpaper shows through natively
lotusCallState: 'true',
lotusTransparent: 'true',
// [Gitea #293] the fork applies the theme with transparency anyway, and
// warned in every call that lotusTheme wasn't set
lotusTheme: 'true',
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
// sets its root color-scheme from the theme, instead of us injecting CSS.
lotusHostControls: 'true',
+4 -3
View File
@@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => {
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
* swept with the rest — the server-side presence status survives, so a
* re-login loses nothing)
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
* preferences, not swept here
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
* Timed mutes moved to account data in [Gitea #333]; a leftover local
* `io.lotus.mute_timers` is migrated there on the next start.
*/
/**
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
+32
View File
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
syncLost: false,
lostAt: null,
});
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const baseDismissedAtom = atom<string[]>(readDismissed());
/**
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
* the security nudge knows a dismissed announcement isn't on screen.
* Persisted to localStorage; session-only if storage is unavailable.
*/
export const statusDismissedAtom = atom(
(get) => get(baseDismissedAtom),
(get, set, key: string) => {
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
set(baseDismissedAtom, next);
},
);
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { denoiseNotice } from './denoiseNotice';
test('denoiseNotice: says when ML is off or fell back (#260)', () => {
assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined);
assert.match(
denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '',
/^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/,
);
assert.match(
denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '',
/unfiltered audio/,
);
assert.equal(denoiseNotice('rnnoise', undefined), undefined);
assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined);
});
+29
View File
@@ -0,0 +1,29 @@
/**
* [Gitea #260] The fork reports what noise suppression actually runs as
* `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for
* a report, or undefined when what runs is what the user picked.
*/
export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown };
const MODEL_NAMES: Record<string, string> = {
rnnoise: 'RNNoise',
speex: 'Speex',
dtln: 'DTLN',
deepfilternet: 'DeepFilterNet 3',
};
const modelName = (m: unknown): string =>
typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model';
export function denoiseNotice(
requestedModel: string,
report: DenoiseStateReport | undefined,
): string | undefined {
if (!report || typeof report.active !== 'boolean') return undefined;
if (!report.active) {
return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`;
}
if (typeof report.model === 'string' && report.model !== requestedModel) {
return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`;
}
return undefined;
}
+31 -2
View File
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatFileSize, isCompressible } from './imageCompression';
import {
formatFileSize,
hasTransparency,
isAnimatedWebp,
isCompressible,
} from './imageCompression';
test('formatFileSize formats bytes below 1KB as B', () => {
assert.equal(formatFileSize(0), '0 B');
@@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => {
test('isCompressible accepts raster image types', () => {
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
});
test('isCompressible rejects svg, empty type, and non-images', () => {
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
// [Gitea #331] usually animated; a canvas keeps one frame
assert.equal(isCompressible({ type: 'image/gif' } as Blob), false);
assert.equal(isCompressible({ type: 'image/apng' } as Blob), false);
assert.equal(isCompressible({ type: '' } as Blob), false);
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
});
const webpHeader = (chunk: string, flags = 0) => {
const b = new Uint8Array(32);
const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at);
put(0, 'RIFF');
put(8, 'WEBP');
put(12, chunk);
b[20] = flags;
return b;
};
test('isAnimatedWebp: VP8X with the animation flag (#331)', () => {
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true);
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only');
assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy');
assert.equal(isAnimatedWebp(new Uint8Array(8)), false);
});
test('hasTransparency (#331)', () => {
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false);
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true);
});
+43 -5
View File
@@ -8,11 +8,32 @@ export type CompressionResult = {
height: number;
};
// Any raster image the browser can render to canvas can be compressed to JPEG.
// SVG is vector and must stay as-is; GIF animation is lost on canvas but static
// frames compress fine. Empty type string (undetected MIME) is excluded.
// Raster images the browser can render to canvas can be compressed to JPEG.
// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually
// animated, and a canvas keeps only the first frame, so they aren't offered.
// Empty type string (undetected MIME) is excluded.
const NOT_COMPRESSIBLE = new Set(['image/svg+xml', 'image/gif', 'image/apng']);
const isCompressibleType = (type: string): boolean =>
type.startsWith('image/') && type !== 'image/svg+xml' && type !== '';
type.startsWith('image/') && !NOT_COMPRESSIBLE.has(type);
/**
* Pure: whether a WebP file is animated. An animated WebP is a VP8X file
* whose header has the animation flag (byte 20, bit 0x02) and an ANIM chunk.
*/
export function isAnimatedWebp(bytes: Uint8Array): boolean {
const ascii = (at: number, len: number) => String.fromCharCode(...bytes.subarray(at, at + len));
if (bytes.length < 21 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WEBP') return false;
// Bit 0x02 of the VP8X flags byte (no-bitwise: test it arithmetically).
return ascii(12, 4) === 'VP8X' && Math.floor(bytes[20] / 2) % 2 === 1;
}
/** Pure: whether RGBA pixel data has any pixel that isn't fully opaque. */
export function hasTransparency(rgba: Uint8ClampedArray): boolean {
for (let i = 3; i < rgba.length; i += 4) {
if (rgba[i] !== 255) return true;
}
return false;
}
/** Returns true if this file can be compressed via canvas (any raster image type). */
export function isCompressible(file: File | Blob): boolean {
@@ -28,7 +49,8 @@ const JPEG_OUTPUT_TYPE = 'image/jpeg';
*
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
* JPEG composites transparency onto an opaque (black) background, corrupting the
* image. Returning null makes callers fall back to uploading the lossless
* image. Any other image with transparent pixels, and animated WebP, are left
* alone for the same reason (#331). Returning null makes callers fall back to uploading the lossless
* original. The image is decoded with `imageOrientation: 'from-image'` so any
* EXIF orientation is baked into the pixels instead of being silently dropped.
*/
@@ -39,6 +61,15 @@ export async function compressImage(
if (!isCompressibleType(file.type)) return null;
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
if (file.type === 'image/png') return null;
// [Gitea #331] An animated WebP would become a single still frame.
if (file.type === 'image/webp') {
try {
const head = new Uint8Array(await file.slice(0, 32).arrayBuffer());
if (isAnimatedWebp(head)) return null;
} catch {
return null;
}
}
let bitmap: ImageBitmap;
try {
@@ -60,6 +91,13 @@ export async function compressImage(
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
// [Gitea #331] JPEG has no alpha: a transparent WebP (sticker, logo) came out
// with a black background. Keep any image with transparency as the original.
try {
if (hasTransparency(ctx.getImageData(0, 0, width, height).data)) return null;
} catch {
return null;
}
return new Promise((resolve) => {
canvas.toBlob(
+25
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
plain,
DEFAULT_GROUPS,
kumaBeatTime,
kumaUrls,
@@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
'server-down',
);
});
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
assert.equal(
plain('join #lotus-general or #announcements:lotusguild.org'),
'join #lotus-general or #announcements:lotusguild.org',
);
assert.equal(
plain('ping @some_user about snake_case and C#'),
'ping @some_user about snake_case and C#',
);
assert.equal(
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
'calls -> fixed, > 5 people, 5 * 3 = 15',
);
assert.equal(
plain('# Heading\n* item one\n- item two\n> quoted'),
'Heading\nitem one\nitem two\nquoted',
);
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
assert.equal(
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
'Read the guide docs',
);
});
+16 -3
View File
@@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => {
};
/** Markdown-ish incident text as plain text for the Details line. */
const plain = (s: string): string =>
/**
* [Gitea #308] Markdown → plain text for the Details line. Strips only the
* syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case",
* "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway).
*/
export const plain = (s: string): string =>
s
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`#>]/g, '')
.replace(/\s+\n/g, '\n')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/^[ \t]*[*+-][ \t]+/gm, '')
.replace(/`([^`\n]+)`/g, '$1')
.replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1')
.replace(/__(?=\S)([^_\n]*?\S)__/g, '$1')
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2')
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
.replace(/[ \t]+\n/g, '\n')
.trim();
/**
+23 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { analyzeLink, hostFromText, registrableDomain } from './linkSafety';
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
test('visible text that names another site is a mismatch', () => {
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
@@ -73,3 +73,25 @@ test('shared hosting suffixes are separate sites (#335)', () => {
);
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
});
test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => {
const urls = ['https://paypal.com'];
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://evil.example/login">https://paypal.com</a>'),
[],
);
// An honest link keeps its preview.
assert.deepEqual(
previewUrlsFor(urls, '<a href="https://www.paypal.com/x">https://paypal.com</a>'),
urls,
);
assert.deepEqual(previewUrlsFor(urls, undefined), urls);
// Other links in the message keep theirs.
assert.deepEqual(
previewUrlsFor(
['https://paypal.com', 'https://lotusguild.org'],
'see <a href="https://evil.example">paypal.com</a> and https://lotusguild.org',
),
['https://lotusguild.org'],
);
});
+34
View File
@@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
const other = shown.find((h) => registrableDomain(h) !== realSite);
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
};
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
const decodeEntities = (s: string): string =>
s
.replace(/<[^>]+>/g, '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
/**
* [Gitea #286] The URLs to build link previews for. Previews come from the
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
* controls both: text reading "https://paypal.com" linked to evil.example got
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
* deceptive link's text.
*/
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
if (!formattedBody || urls.length === 0) return urls;
const claimed = new Set<string>();
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
});
if (claimed.size === 0) return urls;
return urls.filter((u) => {
try {
return !claimed.has(registrableDomain(new URL(u).hostname));
} catch {
return true;
}
});
};
+22
View File
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseStatusExpiry, statusExpiryAction } from './statusExpiry';
test('parseStatusExpiry (#300)', () => {
assert.deepEqual(parseStatusExpiry({ status_msg: '🍔 Lunch', expires_at: 5 }), {
statusMsg: '🍔 Lunch',
expiresAt: 5,
});
assert.equal(parseStatusExpiry({}), undefined);
assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined);
assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined);
});
test('statusExpiryAction: clears only the status that was set to expire (#300)', () => {
const e = { statusMsg: '🍔 Lunch', expiresAt: 1000 };
assert.equal(statusExpiryAction(e, '🍔 Lunch', 999), 'none');
assert.equal(statusExpiryAction(e, '🍔 Lunch', 1000), 'clear');
assert.equal(statusExpiryAction(e, '🎮 Gaming', 2000), 'forget');
assert.equal(statusExpiryAction(e, '', 2000), 'forget');
assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none');
});
+46
View File
@@ -0,0 +1,46 @@
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData } from './room';
/**
* [Gitea #300] The status message's auto-clear time, in account data so every
* Lotus device knows it. Before, only the device that set "clear after 1 hour"
* (localStorage) ever cleared it, and only while it was open.
*/
export type StatusExpiry = { statusMsg: string; expiresAt: number };
/** Pure: parse `io.lotus.status_expiry` content. */
export function parseStatusExpiry(content: unknown): StatusExpiry | undefined {
const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined;
if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined;
if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined;
return { statusMsg: c.status_msg, expiresAt: c.expires_at };
}
/**
* Pure: what to do with a due expiry. Clear the server status only if it is
* still the one that was set to expire; if it has since changed (e.g. on
* another device), just forget the expiry.
*/
export function statusExpiryAction(
entry: StatusExpiry | undefined,
currentStatusMsg: string | undefined,
now: number,
): 'none' | 'clear' | 'forget' {
if (!entry || now < entry.expiresAt) return 'none';
return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget';
}
export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined =>
parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent());
/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */
export async function writeStatusExpiry(
mx: MatrixClient,
statusMsg: string,
expiresAt?: number,
): Promise<void> {
const content =
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
}
+3
View File
@@ -0,0 +1,3 @@
/** localStorage keys for this device's copy of the status message and its expiry. */
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
translationUnavailableText,
normalizeLang,
sameLanguage,
languageName,
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
assert.equal(new Set(codes).size, codes.length);
assert.ok(codes.includes('en'));
});
test('translationUnavailableText: no "use the desktop app" inside the desktop app (#349)', () => {
assert.doesNotMatch(translationUnavailableText(true), /desktop app\.$|Lotus desktop app/);
assert.match(translationUnavailableText(true), /this version of the desktop app/);
assert.match(translationUnavailableText(false), /this browser/);
});
+16
View File
@@ -85,3 +85,19 @@ export function languageName(code: string, uiLocale = 'en'): string {
export function isSupportedTargetLang(code: string): boolean {
return CURATED_NAME.has(normalizeLang(code));
}
/**
* [Gitea #349] Why translation is unavailable, by platform. The desktop app
* on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send
* people to "the Lotus desktop app" they're already using.
*/
export const translationUnavailableText = (desktopApp: boolean): string =>
desktopApp
? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.'
: 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.';
/** Pure: why a manual Translate did nothing (#287). */
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
from
? `This message is already in ${languageName(targetLang) || targetLang}.`
: "Couldn't tell what language this message is in, so it wasn't translated.";
@@ -0,0 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { translateSkipMessage } from './langUtils';
test('translateSkipMessage explains a Translate that did nothing (#287)', () => {
assert.equal(translateSkipMessage('en', 'en'), 'This message is already in English.');
assert.match(translateSkipMessage(undefined, 'en'), /^Couldn't tell what language/);
});
+1 -1
View File
@@ -43,7 +43,7 @@ if ('serviceWorker' in navigator && swProtocolOk) {
const sendSessionToSW = () => {
const session = getFallbackSession();
pushSessionToSW(session?.baseUrl, session?.accessToken);
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
};
// The dev worker is an ES module (it imports workbox), so it must be
+2 -1
View File
@@ -1,4 +1,4 @@
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) {
if (!('serviceWorker' in navigator)) return;
if (!navigator.serviceWorker.controller) return;
@@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
type: 'setSession',
accessToken,
baseUrl,
userId,
});
}
+19 -9
View File
@@ -2,7 +2,7 @@
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { sendNotificationReply } from './swReply';
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
export type {};
declare const self: ServiceWorkerGlobalScope & {
@@ -40,6 +40,8 @@ precacheAndRoute(self.__WB_MANIFEST);
type SessionInfo = {
accessToken: string;
baseUrl: string;
/** [Gitea #299] whose session this is, so a notification reply uses its own account */
userId?: string;
};
/**
@@ -63,9 +65,13 @@ async function cleanupDeadClients() {
});
}
function setSession(clientId: string, accessToken: string, baseUrl: string) {
function setSession(clientId: string, accessToken: string, baseUrl: string, userId?: unknown) {
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(clientId, { accessToken, baseUrl });
sessions.set(clientId, {
accessToken,
baseUrl,
userId: typeof userId === 'string' ? userId : undefined,
});
} else {
// Logout or invalid session
sessions.delete(clientId);
@@ -130,10 +136,10 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null;
if (!client) return;
const { type, accessToken, baseUrl } = event.data || {};
const { type, accessToken, baseUrl, userId } = event.data || {};
if (type === 'setSession') {
setSession(client.id, accessToken, baseUrl);
setSession(client.id, accessToken, baseUrl, userId);
cleanupDeadClients();
}
});
@@ -155,15 +161,19 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
if (text) {
event.waitUntil(
sendNotificationReply(
Array.from(sessions.values()).pop(),
// [Gitea #299] The notification's own account, not the newest session.
pickReplySession(
Array.from(sessions.values()),
typeof data.userId === 'string' ? data.userId : undefined,
),
data.roomId,
data.threadId,
text,
).then((ok) => {
if (!ok) {
).then((result) => {
if (result !== 'sent') {
// Let the user know it didn't go out, and keep their text reachable.
return self.registration.showNotification('Reply not sent', {
body: `Couldn't send "${text.slice(0, 60)}" — open the app to retry.`,
body: replyFailureText(result, text),
tag: `swreply-fail-${data.roomId}`,
data: { path },
});
+60 -30
View File
@@ -1,6 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildReplyContent, replySendUrl, sendNotificationReply } from './swReply';
import {
buildReplyContent,
pickReplySession,
replyFailureText,
replySendUrl,
sendNotificationReply,
} from './swReply';
test('thread replies carry the m.thread relation, room replies do not', () => {
assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} });
@@ -19,40 +25,64 @@ test('send URL encodes the room id against the session base URL', () => {
);
});
test('sendNotificationReply PUTs with the bearer token and reports ok / failure', async () => {
test('sendNotificationReply checks encryption, then PUTs with the bearer token (#203, #299)', async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchOk = (async (url: string, init: RequestInit) => {
calls.push({ url, init });
return { ok: true } as Response;
}) as unknown as typeof fetch;
assert.equal(
await sendNotificationReply(
{ accessToken: 'tok', baseUrl: 'https://hs' },
'!r:hs',
'$t',
'yo',
fetchOk,
),
true,
);
assert.equal(calls[0].init.method, 'PUT');
assert.equal((calls[0].init.headers as Record<string, string>).Authorization, 'Bearer tok');
const fetchFor = (encStatus: number, sendOk = true) =>
(async (url: string, init: RequestInit = {}) => {
calls.push({ url, init });
if (url.includes('/state/m.room.encryption/')) {
return { ok: encStatus === 200, status: encStatus } as Response;
}
return { ok: sendOk, status: sendOk ? 200 : 500 } as Response;
}) as unknown as typeof fetch;
const session = { accessToken: 'tok', baseUrl: 'https://hs' };
assert.equal(await sendNotificationReply(session, '!r:hs', '$t', 'yo', fetchFor(404)), 'sent');
assert.match(calls[0].url, /\/rooms\/!r%3Ahs\/state\/m\.room\.encryption\/$/);
const put = calls[1];
assert.equal(put.init.method, 'PUT');
assert.equal((put.init.headers as Record<string, string>).Authorization, 'Bearer tok');
assert.match(
calls[0].url,
put.url,
/^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/,
);
assert.equal(JSON.parse(calls[0].init.body as string)['m.relates_to'].event_id, '$t');
assert.equal(JSON.parse(put.init.body as string)['m.relates_to'].event_id, '$t');
const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch;
// Encryption turned on since the notification was shown: nothing is sent.
calls.length = 0;
assert.equal(
await sendNotificationReply(
{ accessToken: 'tok', baseUrl: 'https://hs' },
'!r:hs',
undefined,
'yo',
fetchFail,
),
false,
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(200)),
'encrypted',
);
assert.equal(calls.length, 1);
// Can't tell (server error): fail safe, nothing is sent.
calls.length = 0;
assert.equal(
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(500)),
'failed',
);
assert.equal(calls.length, 1);
assert.equal(
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(404, false)),
'failed',
);
assert.equal(
await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchFor(404)),
'no-session',
);
assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false);
});
test('pickReplySession: the notification account only (#299)', () => {
const a = { accessToken: 'a', baseUrl: 'https://hs', userId: '@a:hs' };
const b = { accessToken: 'b', baseUrl: 'https://hs', userId: '@b:hs' };
assert.equal(pickReplySession([a, b], '@a:hs'), a);
assert.equal(pickReplySession([b], '@a:hs'), undefined, 'logged out of @a here');
assert.equal(pickReplySession([a], undefined), a, 'old notification, one session');
assert.equal(pickReplySession([a, b], undefined), undefined, 'old notification, ambiguous');
});
test('replyFailureText says why', () => {
assert.match(replyFailureText('encrypted', 'hi'), /now encrypted/);
assert.match(replyFailureText('no-session', 'hi'), /no longer signed in/);
assert.match(replyFailureText('failed', 'hi'), /retry/);
});
+48 -11
View File
@@ -1,9 +1,42 @@
/**
* [Gitea #203] Build and send an inline reply typed into a notification's
* text action. Lives outside sw.ts so it can be unit-tested; the SW passes in
* its newest known session and the global fetch.
* the notification account's session and the global fetch.
*/
export type SwReplySession = { accessToken: string; baseUrl: string };
export type SwReplySession = { accessToken: string; baseUrl: string; userId?: string };
export type SwReplyResult = 'sent' | 'failed' | 'encrypted' | 'no-session';
/**
* [Gitea #299] The session to reply with: the notification's own account. A
* notification without a user id (shown before this change) may only use the
* session if it is the only one, so a reply never posts as another account.
*/
export const pickReplySession = (
sessions: SwReplySession[],
userId: string | undefined,
): SwReplySession | undefined => {
if (userId) return sessions.filter((x) => x.userId === userId).pop();
return sessions.length === 1 ? sessions[0] : undefined;
};
/** What the "Reply not sent" notification says for each failure. */
export const replyFailureText = (result: SwReplyResult, text: string): string => {
const quoted = `"${text.slice(0, 60)}"`;
if (result === 'encrypted') {
return `This room is now encrypted, so ${quoted} wasn't sent. Open the app to reply.`;
}
if (result === 'no-session') {
return `You're no longer signed in to that account here, so ${quoted} wasn't sent.`;
}
return `Couldn't send ${quoted}. Open the app to retry.`;
};
export const encryptionStateUrl = (baseUrl: string, roomId: string): string =>
new URL(
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
baseUrl,
).href;
export const buildReplyContent = (text: string, threadId?: string): Record<string, unknown> => {
const content: Record<string, unknown> = { msgtype: 'm.text', body: text, 'm.mentions': {} };
@@ -30,20 +63,24 @@ export async function sendNotificationReply(
threadId: string | undefined,
text: string,
fetchFn: typeof fetch = fetch,
): Promise<boolean> {
if (!session?.accessToken || !session.baseUrl) return false;
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
): Promise<SwReplyResult> {
if (!session?.accessToken || !session.baseUrl) return 'no-session';
const headers = { Authorization: `Bearer ${session.accessToken}` };
try {
// [Gitea #299] The SW can't encrypt, and the reply action was offered when
// the notification was shown — possibly hours ago. Re-check now: only a
// definite "no encryption state" (404) lets the plain reply go out.
const enc = await fetchFn(encryptionStateUrl(session.baseUrl, roomId), { headers });
if (enc.ok) return 'encrypted';
if (enc.status !== 404) return 'failed';
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
method: 'PUT',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(buildReplyContent(text, threadId)),
});
return res.ok;
return res.ok ? 'sent' : 'failed';
} catch {
return false;
return 'failed';
}
}
+7
View File
@@ -26,6 +26,13 @@ export enum AccountDataEvent {
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
LotusSettings = 'io.lotus.settings',
// [Gitea #300] When the status message auto-clears: { status_msg, expires_at },
// so any Lotus device can clear it (not only the one that set it).
LotusStatusExpiry = 'io.lotus.status_expiry',
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
// device can lift a mute set on another.
LotusMuteTimers = 'io.lotus.mute_timers',
SecretStorageDefaultKey = 'm.secret_storage.default_key',
CrossSigningMaster = 'm.cross_signing.master',
+92 -64
View File
@@ -87,97 +87,106 @@ function copyPdfWorker() {
};
}
// Lotus ML noise suppression: ship the RNNoise worklet/wasm + our getUserMedia
// shim alongside the vendored Element Call bundle, and inject the shim <script>
// into EC's index.html so it runs before EC captures the mic. Runs after
// viteStaticCopy has populated dist/public/element-call (closeBundle order).
// The denoise assets the fork loads from ./denoise/ at runtime, as
// [source, path under denoise/] pairs; `dir: true` entries are whole trees.
// Shared by the build copy and the dev server (Gitea #293).
function denoiseSources() {
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
return {
// All bundled denoise assets are REQUIRED: every entry backs a model the
// UI can select (RNNoise, Speex) or the optional noise gate.
sapphi: [
[path.join(sapphi, 'rnnoise/workletProcessor.js'), 'rnnoiseWorklet.js'],
[path.join(sapphi, 'rnnoise.wasm'), 'rnnoise.wasm'],
[path.join(sapphi, 'rnnoise_simd.wasm'), 'rnnoise_simd.wasm'],
[path.join(sapphi, 'speex/workletProcessor.js'), 'speexWorklet.js'],
[path.join(sapphi, 'speex.wasm'), 'speex.wasm'],
[path.join(sapphi, 'noiseGate/workletProcessor.js'), 'noiseGateWorklet.js'],
],
// DTLN (@workadventure/noise-suppression): a self-contained ES module that
// resolves its own worklet, LiteRT WASM runtime and TFLite models via
// `import.meta.url`, so its whole dist/ tree is served intact.
dtln: [path.resolve('node_modules/@workadventure/noise-suppression/dist'), 'workadventure'],
// DeepFilterNet 3: the ESM (worklet inlined) plus the vendored wasm and ONNX
// model (CDN-free / Tauri-CSP safe), in the package's expected v2/ layout.
dfn: [
[
path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js'),
'deepfilternet/index.esm.js',
],
[path.join(dfnVendor, 'v2/pkg/df_bg.wasm'), 'deepfilternet/v2/pkg/df_bg.wasm'],
[
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
],
],
};
}
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
// serves the same files at the same URLs, so `npm start` can test denoise.
function lotusDenoise() {
const DEV_PREFIX = '/public/element-call/denoise/';
return {
name: 'lotus-denoise',
configureServer(server) {
const { sapphi, dtln, dfn } = denoiseSources();
const files = new Map([...sapphi, ...dfn].map(([src, rel]) => [rel, src]));
const types = { '.js': 'text/javascript', '.wasm': 'application/wasm' };
server.middlewares.use((req, res, next) => {
const url = (req.url ?? '').split('?')[0];
if (!url.startsWith(DEV_PREFIX)) return next();
const rel = decodeURIComponent(url.slice(DEV_PREFIX.length));
let file = files.get(rel);
if (!file && rel.startsWith(`${dtln[1]}/`)) {
const candidate = path.resolve(dtln[0], rel.slice(dtln[1].length + 1));
if (candidate.startsWith(dtln[0] + path.sep)) file = candidate;
}
if (!file || !fs.existsSync(file)) return next();
res.setHeader('Content-Type', types[path.extname(file)] ?? 'application/octet-stream');
fs.createReadStream(file).pipe(res);
return undefined;
});
},
closeBundle() {
const ecDir = path.resolve('dist/public/element-call');
if (!fs.existsSync(ecDir)) return;
const denoiseDir = path.join(ecDir, 'denoise');
fs.mkdirSync(denoiseDir, { recursive: true });
const { sapphi, dtln, dfn } = denoiseSources();
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
// All bundled denoise assets are REQUIRED: every entry backs a model the
// UI can select (RNNoise, Speex) or the optional noise gate. A missing
// source means a partial/changed install would otherwise silently ship a
// broken ML feature (worklet 404 -> raw mic), so we fail the build instead.
const assets = [
[
path.join(sapphi, 'rnnoise/workletProcessor.js'),
path.join(denoiseDir, 'rnnoiseWorklet.js'),
],
[path.join(sapphi, 'rnnoise.wasm'), path.join(denoiseDir, 'rnnoise.wasm')],
[path.join(sapphi, 'rnnoise_simd.wasm'), path.join(denoiseDir, 'rnnoise_simd.wasm')],
[path.join(sapphi, 'speex/workletProcessor.js'), path.join(denoiseDir, 'speexWorklet.js')],
[path.join(sapphi, 'speex.wasm'), path.join(denoiseDir, 'speex.wasm')],
[
path.join(sapphi, 'noiseGate/workletProcessor.js'),
path.join(denoiseDir, 'noiseGateWorklet.js'),
],
];
const missing = assets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
// A missing source means a partial/changed install would otherwise
// silently ship a broken ML feature (worklet 404 -> raw mic), so we fail
// the build instead.
const missing = sapphi.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (missing.length > 0) {
throw new Error(
`[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`,
);
}
assets.forEach(([s, d]) => fs.copyFileSync(s, d));
sapphi.forEach(([s, rel]) => fs.copyFileSync(s, path.join(denoiseDir, rel)));
// DTLN (@workadventure/noise-suppression): unlike the flat sapphi
// worklets, this package is a self-contained ES module that resolves its
// own AudioWorklet processor, LiteRT WASM runtime and TFLite models via
// `import.meta.url`. So we copy its whole dist/ tree intact to
// denoise/workadventure/ (preserving assets/ + vendor/) and let the shim
// dynamic-import denoise/workadventure/audio-worklet.js at runtime. The
// entry module is required — fail the build if the install is broken.
const dtlnSrc = path.resolve('node_modules/@workadventure/noise-suppression/dist');
const dtlnEntry = path.join(dtlnSrc, 'audio-worklet.js');
const dtlnEntry = path.join(dtln[0], 'audio-worklet.js');
if (!fs.existsSync(dtlnEntry)) {
throw new Error(
`[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` +
'Run `npm ci` to install @workadventure/noise-suppression.',
);
}
fs.cpSync(dtlnSrc, path.join(denoiseDir, 'workadventure'), { recursive: true });
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
// DeepFilterNet 3 (deepfilternet3-noise-filter): the npm package ships only
// its ESM (index.esm.js) with the AudioWorklet processor + wasm-bindgen glue
// INLINED as a string (loaded via a Blob URL, no CDN for the worklet). The
// only runtime CDN fetches are its single-threaded `df_bg.wasm` and the
// ONNX `DeepFilterNet3_onnx.tar.gz` model — which we VENDOR locally (under
// build/denoise-vendor/deepfilternet/) and serve, overriding the package's
// cdnUrl to our self-hosted base. This keeps the feature CDN-free / Tauri-CSP
// safe. We copy the ESM (the shim dynamic-imports it, mirroring DTLN) plus
// the vendored assets, preserving the package's expected v2/... layout. All
// are required — a missing entry means a broken install, so fail the build.
const dfnEsm = path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js');
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
const dfnAssets = [
[dfnEsm, path.join(denoiseDir, 'deepfilternet/index.esm.js')],
[
path.join(dfnVendor, 'v2/pkg/df_bg.wasm'),
path.join(denoiseDir, 'deepfilternet/v2/pkg/df_bg.wasm'),
],
[
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
path.join(denoiseDir, 'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz'),
],
];
const dfnMissing = dfnAssets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
if (dfnMissing.length > 0) {
throw new Error(
'[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid ' +
'shipping a broken ML feature:\n ' +
dfnMissing.join('\n ') +
'\n(Run `npm ci`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)',
`[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${dfnMissing.join('\n ')}\n(Run \`npm ci\`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)`,
);
}
dfnAssets.forEach(([s, d]) => {
dfn.forEach(([s, rel]) => {
const d = path.join(denoiseDir, rel);
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
});
@@ -242,6 +251,25 @@ const vendorChunks = (id) => {
if (id.includes('node_modules/immer')) return 'immer';
if (id.includes('node_modules/folds')) return 'folds';
if (id.includes('node_modules/emojibase')) return 'emojibase';
// HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour
// maths are needed at startup but are big, stable libraries: keep them in
// their own cacheable chunks instead of wherever the chunk graph drops them
// (once Settings became lazy they landed in the entry chunk).
if (
id.includes('node_modules/sanitize-html') ||
id.includes('node_modules/postcss') ||
id.includes('node_modules/htmlparser2') ||
id.includes('node_modules/domhandler') ||
id.includes('node_modules/domutils') ||
id.includes('node_modules/dom-serializer') ||
id.includes('node_modules/entities') ||
id.includes('node_modules/parse-srcset') ||
id.includes('node_modules/nanoid') ||
id.includes('node_modules/picocolors') ||
id.includes('node_modules/source-map-js')
)
return 'sanitize-html';
if (id.includes('node_modules/chroma-js')) return 'chroma';
};
export default defineConfig({