Compare commits

...
Author SHA1 Message Date
jared 0c5cf4109a Merge pull request 'fix(retention): a tiny or brand-new retention value can't wipe a room (#298)' (#367) from fix/retention-floor-grace into lotus
CI / Build & Quality Checks (push) Successful in 3m55s
CI / Trigger Desktop Build (push) Waiting to run
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Waiting to run
CI / Secret scan (gitleaks) (push) Successful in 9s
2026-10-16 22:48:18 -04:00
jared 39f74d51e6 Merge pull request 'fix(startup): Firefox no longer hangs on "Heating up" after login (#303)' (#366) from fix/firefox-persist-hang into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 22:36:52 -04:00
jared 2d31312a73 Merge pull request 'fix(calls): a finished call no longer rings again after a reload (#312)' (#365) from fix/phantom-ring-after-reload into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 22:24:05 -04:00
jared 29f6a35bfd Merge pull request 'fix(rooms): "Rename for me" names in search results, inbox and threads (#336)' (#364) from fix/local-room-names-rest into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 22:12:55 -04:00
Lotus CIandClaude Opus 5.5 8b2322f751 fix(retention): a tiny or brand-new retention value can't wipe a room (#298)
CI / Build & Quality Checks (pull_request) Successful in 2m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m33s
m.room.retention can be set to anything by anyone with state power. A
fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus
user and, for members with "Enforce Message Retention" on, the sweeper
redacted all their messages within a minute. The RETENTION_MIN_MS floor
existed but was never applied.

- effectiveRetentionMs(): anything under 10 min counts as off; used by
  getRoomRetentionMs (sweeper) and the timeline's hiding.
- The sweeper never deletes under a policy less than 24 h old, so a
  mistake or a malicious change can be reverted before anything is lost.
  (Local hiding of expired messages still applies at once.)

Verified headless (the issue's repro, enforcement on): after
max_lifetime 1000, all 4 messages stay visible and none are redacted
after 45 s (before: timeline blank, all 4 redacted). Unit tests for the
floor and the grace window.

Fixes #298

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:37:16 -04:00
Lotus CIandClaude Opus 5.5 02950499ea fix(startup): Firefox no longer hangs on "Heating up" after login (#303)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m48s
initClient awaited navigator.storage.persist() before creating the client.
Chromium and WebKit answer at once, but Firefox shows a permission prompt
and leaves the promise pending until it's answered, so the app sat on
"Heating up" (no sync, no crypto, no IndexedDB) for every Firefox user who
didn't notice the prompt.

The request is still made (KE-1 only needs that), but startup waits at
most 1.5 s for the answer. The comment claiming persist() never prompts
is corrected.

Verified in Playwright Firefox with the prompt left unanswered
(persist() still pending): before, still "Heating up" after 20 s; now the
room list after 12 s.

Fixes #303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:31:55 -04:00
Lotus CIandClaude Opus 5.5 05d7e87d26 fix(calls): a finished call no longer rings again after a reload (#312)
CI / Build & Quality Checks (pull_request) Successful in 1m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m58s
After a reload/restart within a ring's lifetime, the catch-up sync replays
the ring together with the caller's hang-up and our own decline, and the
MatrixRTC session can keep the stale join. The client rang again for up to
90 s and showed "1 Live" (call screen instead of chat) for minutes.

- Before ringing, check the room STATE (utils/callRing.ts, unit-tested):
  no ring if we already declined that ring or are in the call; if the
  caller has no active membership yet, re-check after 4 s (a real call's
  membership can land just after its ring) and ring only if they joined.
- The "caller gone" dismissal also consults room state, not only the
  possibly stale session.
- When sync becomes live (startup or reconnect), recompute every active
  RTC session from the current state, clearing a stale "Live".

Verified headless (Bob's membership + ring in an invite-only room):
live ring rings; hang-up stops it; reload within the window: before
rang again + "1 Live", now no ring and no "Live"; a ring that arrives
just before the membership still rings.

Fixes #312

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:28:33 -04:00
Lotus CIandClaude Opus 5.5 bbb2034080 fix(rooms): "Rename for me" names in search results, inbox and threads (#336)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m5s
Follow-up to #359: the personal room name now also shows in message-search
result groups and the search room filter (whose room search matches it
too), Inbox → Notifications, the threads list and the thread panel header.

Verified headless with general renamed to "Zebra Lounge": a global message
search and Inbox → Notifications both show "Zebra Lounge".

Fixes #336

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:19:47 -04:00
jared 400ee76b63 Merge pull request 'fix(calls): no "Missed call" in voice rooms or space channels' (#363) from fix/missed-call-voice-rooms into lotus
CI / Build & Quality Checks (push) Successful in 1m44s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m58s
2026-10-16 21:06:57 -04:00
jared 47c1e435b7 Merge pull request 'perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)' (#361) from fix/animated-bg-compositor into lotus
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Canceled after 8s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 20:52:34 -04:00
Lotus CIandClaude Opus 5.5 4650796120 fix(calls): no "Missed call" in voice rooms or space channels
CI / Build & Quality Checks (pull_request) Successful in 1m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m48s
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.

utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.

Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 20:29:50 -04:00
jared d55a8ba8a5 Merge branch 'lotus' into fix/animated-bg-compositor
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m11s
2026-10-16 19:59:31 -04:00
jared 4b8c3219e9 Merge pull request 'fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)' (#360) from fix/link-safety-bypasses into lotus
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 12m20s
2026-10-16 19:59:19 -04:00
jared 47328d6fee Merge branch 'lotus' into fix/link-safety-bypasses
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m25s
2026-10-16 19:33:54 -04:00
jared b0eb3da830 Merge pull request 'test(e2e): allow the register probe's 403 now that sign-up is closed' (#362) from fix/e2e-register-403 into lotus
CI / Build & Quality Checks (push) Successful in 1m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 10m28s
2026-10-16 19:33:43 -04:00
Lotus CIandClaude Opus 5.5 f22f9f07a3 test(e2e): allow the register probe's 403 now that sign-up is closed
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 9m50s
matrix.lotusguild.org turned off open registration (2026-10-16; accounts
come from SSO/LLDAP), so the login page's registration probe
(POST /_matrix/client/v3/register) now answers 403 "Registration has been
disabled" instead of 401 + UIA flows. The boot smoke test treated that
console line as unexpected and failed on every branch (seen on #360).

collectConsole now skips a 403 only when its source URL is the register
endpoint; any other 403 still fails the test.

Verified: boot.spec passes locally in chromium, webkit and iphone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:12:15 -04:00
Lotus CIandClaude Opus 5.5 2700aad209 perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)
CI / Build & Quality Checks (pull_request) Successful in 1m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Failing after 9m56s
background-position can't run on the compositor, so every frame of the
animated chat backgrounds repainted the whole chat area: 51 to 72% main-thread
busy while idle in a room at 60 fps. The drifts are slow (3 to 22 px/s,
under a pixel per frame), so each animation now uses steps(): about 12
updates/s for fireflies, stars and aurora and about 24/s for rain and
pulse.

Measured headless (20 s idle in a room, CDP TaskDuration):
  none 0.7% → 0.9%; rain 50.7 → 5.9; stars 71.2 → 4.5;
  pulse 64.9 → 5.8; aurora 58.9 → 4.2; fireflies 71.7 → 3.8.

Aurora and pulse used ease-in-out; with steps() their motion is now
even-paced. Pause animations / reduced motion still strip the animation.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:05:52 -04:00
Lotus CIandClaude Opus 5.5 a8a7e639a9 fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)
CI / Build & Quality Checks (pull_request) Successful in 1m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Failing after 11m30s
Two easy bypasses of the "this link goes somewhere else" check:

- Any whitespace in the link text disabled it, so "Log in at
  chat.lotusguild.org" → evil.example wasn't flagged. hostsFromText now
  scans each word for host-shaped tokens; a link is flagged if any named
  host is a different site. Bare file names (main.py, report.pdf) and
  version numbers are skipped inside sentences so ordinary links aren't
  flagged.
- Shared-hosting suffixes counted as one site (alice.github.io vs
  evil.github.io, *.pages.dev, netlify.app, vercel.app, …).
  registrableDomain now treats a curated list of them as public suffixes
  (longest match, incl. s3.amazonaws.com). Curated rather than the full
  PSL to stay inside the bundle budget.

Verified with unit tests and headless on real messages: the four bypass
cases are flagged; the honest sentence, file/version text and "click
here" are not.

Fixes #335

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 18:56:01 -04:00
jared 508a123150 Merge pull request 'fix(rooms): "Rename for me" names in Ctrl+K, forward/share pickers and notifications (#336)' (#359) from fix/local-room-names-everywhere into lotus
CI / Build & Quality Checks (push) Successful in 3m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 15m26s
2026-10-12 16:44:08 -04:00
jared fe61352d88 Merge pull request 'fix(messages): collapsed long messages work with the keyboard and on any background (#343)' (#358) from fix/collapsible-messages into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-12 16:43:59 -04:00
jared 0bec28b651 Merge pull request 'fix(logout): clear the call-volume list and settings-sync stamp (#347)' (#357) from fix/logout-volume-residue into lotus
CI / Build & Quality Checks (push) Successful in 3m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 6s
2026-10-12 16:07:43 -04:00
jared 75089ecd3f Merge pull request 'fix(settings): "ENTER for Newline" stays per device (#348)' (#356) from fix/enter-newline-device-local into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-12 16:07:33 -04:00
Lotus CIandClaude Opus 5.5 a4a163df3d fix(rooms): "Rename for me" names in Ctrl+K, forward/share pickers and notifications (#336)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m29s
The personal room name (io.lotus.room_names) was only used by the
sidebar, header, intro and call bar; the quick switcher couldn't find a
room by it ("No match found") and showed the server name, as did the
forward/share pickers, toasts, OS notifications and reminders.

useRoomMeta gains useLocalRoomNames() / getLocalRoomName(); the quick
switcher matches both names and shows the personal one, the forward and
share pickers show and filter by it, and message/reminder notifications
use it for the room title.

Verified headless with general renamed to "Zebra Lounge": Ctrl+K "Zebra"
and "general" both list "Zebra Lounge"; the forward picker filtered by
"zebra" lists "Zebra Lounge".

Not covered here (follow-up): message-search result headers, the inbox
notifications page and the threads list still use the server name.

Refs #336

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:40:56 -04:00
Lotus CIandClaude Opus 5.5 70f1d640ed fix(messages): collapsed long messages work with the keyboard and on any background (#343)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m15s
- Tab into a link in the hidden part used to scroll the overflow:hidden box
  itself (top cut off, and it stayed that way); focus inside now expands
  the message, and collapsing resets the scroll.
- "Read more" / "Show less" is announced as a toggle (aria-expanded,
  aria-controls); the arrows are aria-hidden.
- The fade is a mask on the content instead of a gradient in a fixed
  surface colour, so there's no band in bubble layout, on highlights or
  over chat backgrounds.
- The cap is 20rem instead of 320px, so a larger font shows as many lines.

Verified headless on a 41-line message with a link on the last line:
aria-expanded false → focusing the link expands it (aria-expanded true,
no clipping); bubble layout fades into the bubble with no band.

Fixes #343

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:31:50 -04:00
Lotus CIandClaude Opus 5.5 85a2abc46a fix(logout): clear the call-volume list and settings-sync stamp (#347)
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m33s
Logout left lotus-per-user-volume (Element Call's per-person volume map:
whom the previous user called and turned up/down; same origin on the web)
and settings-sync-meta:<userId> (a key naming the account) in
localStorage. clearPlaintextCaches now removes both; device preferences
(`settings`) are kept as before.

Verified: after joining/leaving a call and logging out via Settings, both
keys are gone (unit test added).

Fixes #347

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:28:41 -04:00
Lotus CIandClaude Opus 5.5 5130dfc9c8 fix(settings): "ENTER for Newline" stays per device (#348)
CI / Build & Quality Checks (pull_request) Successful in 3m6s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m37s
Settings sync mirrored enterForNewline to io.lotus.settings, so turning it
on for a phone keyboard switched the desktop to Ctrl+Enter-to-send (and
vice versa). It is now in DEVICE_LOCAL_KEYS.

Verified with one account on a phone and a desktop profile: toggling it
on the phone leaves the desktop at Enter-to-send, and it is no longer in
the synced account data.

Fixes #348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-12 15:27:01 -04:00
jared e57af60466 Merge pull request 'fix(presence): leaving Do Not Disturb restores your status instead of "dnd" (#283)' (#355) from fix/dnd-restore-status into lotus
CI / Build & Quality Checks (push) Successful in 3m28s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 13m55s
2026-10-12 13:12:50 -04:00
jared 1f10866b60 Merge pull request 'fix(presence): Invisible, Hide Online Status, Idle and DND stick (#282)' (#354) from fix/presence-sticky into lotus
CI / Build & Quality Checks (push) Successful in 3m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 14s
2026-10-12 12:53:55 -04:00
31 changed files with 596 additions and 78 deletions
+12 -1
View File
@@ -42,7 +42,18 @@ export function collectConsole(page: Page): ConsoleCollector {
const errors: string[] = []; const errors: string[] = [];
const pageErrors: string[] = []; const pageErrors: string[] = [];
page.on('console', (msg) => { page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text()); if (msg.type() !== 'error') return;
// The login page's registration probe (POST /_matrix/client/v3/register)
// answers 403 "Registration has been disabled" on a homeserver with sign-up
// closed (matrix.lotusguild.org since 2026-10-16: accounts come from SSO).
// Allowed only for that URL so any other 403 still fails the test.
if (
/status of 403/.test(msg.text()) &&
/\/_matrix\/client\/v3\/register/.test(msg.location().url)
) {
return;
}
errors.push(msg.text());
}); });
page.on('pageerror', (err) => { page.on('pageerror', (err) => {
pageErrors.push(err.message); pageErrors.push(err.message);
+49 -10
View File
@@ -18,9 +18,10 @@ import {
toRem, toRem,
} from 'folds'; } from 'folds';
import { import {
ClientEvent,
EventTimelineSetHandlerMap, EventTimelineSetHandlerMap,
EventType, EventType,
JoinRule, SyncState,
RelationType, RelationType,
Room, Room,
RoomEvent, RoomEvent,
@@ -72,7 +73,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences'; import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room'; import { getStateEvent, getMemberName } from '../utils/room';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -581,12 +583,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// Only ring in rooms where the call button is visible: DMs or invite-only rooms // Only ring in rooms where the call button is visible: DMs or invite-only rooms
// with no space parent. Persistent voice rooms (call rooms), space channels, // with no space parent. Persistent voice rooms (call rooms), space channels,
// restricted rooms, and public rooms must never trigger ringing. // restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return; if (!isRingableRoom(room, directs.has(room.roomId))) return;
const isDirect = directs.has(room.roomId);
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
if (!isDirect && !isPrivateInviteGroup) return;
const info: IncomingCallInfo = { const info: IncomingCallInfo = {
room, room,
@@ -601,11 +598,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
refEventId, refEventId,
}; };
setCallInfo(info); // [Gitea #312] Don't ring for a call that is already over or handled: after
// a reload the first sync replays the ring together with the caller's
// hang-up and our own decline. Check the room STATE (not the possibly
// stale RTC session). The caller's membership can arrive just after the
// ring, so an absent one gets a short grace period before giving up.
const myUserId = mx.getSafeUserId();
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
return;
}
if (hasActiveCallMembership(room, sender)) {
setCallInfo(info);
return;
}
setTimeout(() => {
if (Date.now() >= senderTs + lifetime) return;
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
return;
}
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
}, 4000);
}, },
[mx, directs, callEmbed, setToast], [mx, directs, callEmbed, setToast],
); );
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
// call's join and leave in one batch, and the RTC session may keep the join
// (stale "1 Live", call screen instead of chat). Once sync is live again,
// recompute every active session from the room's current state.
useEffect(() => {
const onSync = (state: SyncState, prev: SyncState | null) => {
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
mx.getRooms().forEach((r) => {
mx.matrixRTC
.getActiveRoomSession(r)
?._onRTCSessionMemberUpdate()
.catch(() => undefined);
});
};
mx.on(ClientEvent.Sync, onSync);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
};
}, [mx]);
useEffect(() => { useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent); mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => { return () => {
@@ -634,7 +670,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const { memberships } = session; const { memberships } = session;
if (memberships.some((m) => m.sender === myUserId)) if (memberships.some((m) => m.sender === myUserId))
dismiss(); // answered elsewhere dismiss(); // answered elsewhere
else if (memberships.length === 0) dismiss(); // caller gone else if (memberships.length === 0)
dismiss(); // caller gone
// [Gitea #312] The session can be stale after a reload; the room state isn't.
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
}; };
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event, event,
+32 -19
View File
@@ -1,4 +1,4 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; import React, { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds'; import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent, MatrixEvent } from 'matrix-js-sdk'; import { IContent, MatrixEvent } from 'matrix-js-sdk';
@@ -41,7 +41,10 @@ import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines // ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
const COLLAPSE_MAX_HEIGHT_REM = 20;
const collapseMaxHeightPx = (): number =>
COLLAPSE_MAX_HEIGHT_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
type CollapsibleBodyProps = { type CollapsibleBodyProps = {
eventId?: string; eventId?: string;
@@ -79,7 +82,7 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
const el = bodyRef.current; const el = bodyRef.current;
if (!el) return undefined; if (!el) return undefined;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT); setNeedsCollapse(el.scrollHeight > collapseMaxHeightPx());
}); });
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
@@ -89,16 +92,36 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
// (Gitea #85); useReducedMotion subscribes to the change event instead. // (Gitea #85); useReducedMotion subscribes to the change event instead.
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const bodyId = useId();
const isClipped = needsCollapse && collapsed;
// [Gitea #343] The clipped part stays in the DOM, so Tab can reach a link in
// it; the browser would then scroll the overflow:hidden box itself (top cut
// off, and it stayed that way). Expand instead when focus lands inside.
const handleFocus = () => {
if (isClipped) setCollapsed(false);
};
useEffect(() => {
if (isClipped && bodyRef.current) bodyRef.current.scrollTop = 0;
}, [isClipped]);
// Fade the content itself rather than painting a fixed-colour gradient over
// it, so it blends on any background (bubbles, highlights, chat backgrounds).
const fadeMask = 'linear-gradient(#000 calc(100% - 3rem), transparent)';
return ( return (
<div> <div>
<div <div
ref={bodyRef} ref={bodyRef}
id={bodyId}
onFocus={handleFocus}
style={{ style={{
position: 'relative', position: 'relative',
...(needsCollapse && collapsed ...(isClipped
? { ? {
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`, maxHeight: `${COLLAPSE_MAX_HEIGHT_REM}rem`,
overflow: 'hidden', overflow: 'hidden',
maskImage: fadeMask,
WebkitMaskImage: fadeMask,
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease', transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
} }
: { : {
@@ -107,23 +130,12 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
}} }}
> >
{children} {children}
{needsCollapse && collapsed && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '3rem',
background: `linear-gradient(transparent, ${color.Surface.Container})`,
pointerEvents: 'none',
}}
/>
)}
</div> </div>
{needsCollapse && ( {needsCollapse && (
<button <button
type="button" type="button"
aria-expanded={!collapsed}
aria-controls={bodyId}
onClick={() => setCollapsed((c) => !c)} onClick={() => setCollapsed((c) => !c)}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
@@ -134,7 +146,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
}} }}
> >
<Text as="span" size="T200" style={{ color: color.Primary.Main }}> <Text as="span" size="T200" style={{ color: color.Primary.Main }}>
{collapsed ? 'Read more ↓' : 'Show less ↑'} {collapsed ? 'Read more' : 'Show less'}
<span aria-hidden="true">{collapsed ? ' ↓' : ' ↑'}</span>
</Text> </Text>
</button> </button>
)} )}
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%', backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%', backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`, animation: `${auroraFlow} 60s steps(180) infinite`,
}; };
const light: CSSProperties = { const light: CSSProperties = {
@@ -75,7 +75,7 @@ const light: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%', backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%', backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`, animation: `${auroraFlow} 60s steps(180) infinite`,
}; };
export const animAurora: ChatBgVariants = { dark, light }; export const animAurora: ChatBgVariants = { dark, light };
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static) '0 0', // wash A (static)
'0 0', // wash B (static) '0 0', // wash B (static)
].join(','), ].join(','),
animation: `${firefliesDrift} 44s linear infinite`, animation: `${firefliesDrift} 44s steps(528) infinite`,
}, },
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with // Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static) '0 0', // wash A (static)
'0 0', // wash B (static) '0 0', // wash B (static)
].join(','), ].join(','),
animation: `${firefliesDrift} 44s linear infinite`, animation: `${firefliesDrift} 44s steps(528) infinite`,
}, },
}; };
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash '0 0', // base wash
'0 0', // vignette '0 0', // vignette
].join(','), ].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`, animation: `${gridPulse} 22s steps(528) infinite`,
}, },
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe. // Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash '0 0', // base wash
'0 0', // vignette '0 0', // vignette
].join(','), ].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`, animation: `${gridPulse} 22s steps(528) infinite`,
}, },
}; };
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
].join(','), ].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','), backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','), backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`, animation: `${rainFall} 12s steps(288) infinite`,
}, },
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon. // Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
].join(','), ].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','), backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','), backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`, animation: `${rainFall} 12s steps(288) infinite`,
}, },
}; };
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
'0 0', // nebula A '0 0', // nebula A
'0 0', // nebula B '0 0', // nebula B
].join(','), ].join(','),
animation: `${starDrift} 90s linear infinite`, animation: `${starDrift} 90s steps(1080) infinite`,
}, },
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very // Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
'0 0', // wash A '0 0', // wash A
'0 0', // wash B '0 0', // wash B
].join(','), ].join(','),
animation: `${starDrift} 100s linear infinite`, animation: `${starDrift} 100s steps(1200) infinite`,
}, },
}; };
+5
View File
@@ -149,6 +149,11 @@ export const getChatBg = (
} }
// For animated backgrounds, promote the element to its own compositor layer so // For animated backgrounds, promote the element to its own compositor layer so
// background-position keyframes don't trigger repaints on descendant elements. // background-position keyframes don't trigger repaints on descendant elements.
// [Gitea #337] background-position can't run on the compositor, so every
// animation frame repaints the whole chat area (45 to 70% main-thread busy
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
// same, a fraction of the repaints.
if (style.animation) { if (style.animation) {
return { ...style, willChange: 'background-position', contain: 'paint' }; return { ...style, willChange: 'background-position', contain: 'paint' };
} }
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { MsgTypeFilter } from './useMessageSearch'; import { MsgTypeFilter } from './useMessageSearch';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [ const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo }, { msgType: 'm.image', label: 'Images', icon: Icons.Photo },
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
onChange: (rooms?: string[]) => void; onChange: (rooms?: string[]) => void;
}; };
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) { function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [localSelected, setLocalSelected] = useState(selectedRooms); const [localSelected, setLocalSelected] = useState(selectedRooms);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback( const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => mx.getRoom(rId)?.name ?? rId, (rId) => {
[mx], const name = mx.getRoom(rId)?.name ?? rId;
// [Gitea #336] match the personal "Rename for me" name too
return localNames[rId] ? [localNames[rId], name] : name;
},
[mx, localNames],
); );
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch( const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
} }
> >
<Text truncate size="T300"> <Text truncate size="T300">
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
</MenuItem> </MenuItem>
</VirtualTile> </VirtualTile>
@@ -701,6 +707,7 @@ export function SearchFilters({
pinnedOnly, pinnedOnly,
onTogglePinnedOnly, onTogglePinnedOnly,
}: SearchFiltersProps) { }: SearchFiltersProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
return ( return (
@@ -748,7 +755,7 @@ export function SearchFilters({
} }
after={<Icon size="50" src={Icons.Cross} />} after={<Icon size="50" src={Icons.Cross} />}
> >
<Text size="T200">{room.name}</Text> <Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
</Chip> </Chip>
); );
})} })}
@@ -50,6 +50,7 @@ import {
} from '../../hooks/useMemberPowerTag'; } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
type SearchResultGroupProps = { type SearchResultGroupProps = {
room: Room; room: Room;
@@ -73,6 +74,7 @@ export function SearchResultGroup({
hour24Clock, hour24Clock,
dateFormatString, dateFormatString,
}: SearchResultGroupProps) { }: SearchResultGroupProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]); const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
@@ -212,7 +214,7 @@ export function SearchResultGroup({
/> />
</Avatar> </Avatar>
<Text size="H4" truncate> <Text size="H4" truncate>
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
</Box> </Box>
</Header> </Header>
+9 -2
View File
@@ -121,7 +121,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, isExpired } from '../../utils/retention'; import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom'; import { useIsDirectRoom } from '../../hooks/useRoom';
import { isRingableRoom } from '../../utils/callRing';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
@@ -493,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// timeline (unless "show hidden events" is on). Reactive so a policy change // timeline (unless "show hidden events" is on). Reactive so a policy change
// re-renders. `undefined` = no policy. // re-renders. `undefined` = no policy.
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention); const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime; // [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
const retentionMs = effectiveRetentionMs(
retentionEvent?.getContent<RetentionContent>().max_lifetime,
);
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
@@ -2030,6 +2034,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[EventType.RTCNotification]: (mEventId, mEvent, item) => { [EventType.RTCNotification]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
if (!senderId || senderId === mx.getUserId()) return null; if (!senderId || senderId === mx.getUserId()) return null;
// Same rule as ringing: no "Missed call" in voice rooms or space
// channels, where starting a call is just joining the channel.
if (!isRingableRoom(room, direct)) return null;
const content = mEvent.getContent(); const content = mEvent.getContent();
const mention = const mention =
content['m.mentions']?.room || content['m.mentions']?.room ||
@@ -48,6 +48,7 @@ import {
addRecentForwardTarget, addRecentForwardTarget,
} from '../../../state/recentForwardTargets'; } from '../../../state/recentForwardTargets';
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent'; import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type RoomRowProps = { type RoomRowProps = {
room: Room; room: Room;
@@ -58,6 +59,7 @@ type RoomRowProps = {
sending: boolean; sending: boolean;
}; };
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) { function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl(); const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
@@ -97,7 +99,7 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
> >
<Box direction="Column"> <Box direction="Column">
<Text size="T300" truncate> <Text size="T300" truncate>
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
{dm && ( {dm && (
<Text size="T200" priority="300" truncate> <Text size="T200" priority="300" truncate>
@@ -212,6 +214,7 @@ function RecentChip({
onToggle: () => void; onToggle: () => void;
sending: boolean; sending: boolean;
}) { }) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl(); const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
@@ -240,7 +243,7 @@ function RecentChip({
} }
> >
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}> <Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
</Chip> </Chip>
); );
@@ -252,6 +255,7 @@ type Props = {
}; };
export function ForwardMessageDialog({ mEvent, onClose }: Props) { export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const modalStyle = useModalStyle(400); const modalStyle = useModalStyle(400);
const directs = useAtomValue(mDirectAtom); const directs = useAtomValue(mDirectAtom);
@@ -296,8 +300,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!query) return allRooms; if (!query) return allRooms;
const q = query.toLowerCase(); const q = query.toLowerCase();
return allRooms.filter((r) => r.name.toLowerCase().includes(q)); return allRooms.filter((r) =>
}, [allRooms, query]); [r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
);
}, [allRooms, query, localNames]);
// Resolve recent target ids to still-joined rooms (drop ones left/gone). // Resolve recent target ids to still-joined rooms (drop ones left/gone).
const recentRooms = useMemo( const recentRooms = useMemo(
+3 -1
View File
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { getMemberName } from '../../../utils/room'; import { getMemberName } from '../../../utils/room';
import { ThreadNotificationMode } from '../../../utils/threadNotifications'; import { ThreadNotificationMode } from '../../../utils/threadNotifications';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type ThreadPanelHeaderProps = { type ThreadPanelHeaderProps = {
room: Room; room: Room;
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
requestClose: () => void; requestClose: () => void;
}; };
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) { function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const localNames = useLocalRoomNames();
const mode = useThreadNotificationMode(room.roomId, threadId); const mode = useThreadNotificationMode(room.roomId, threadId);
const { format } = useTimestampFormatter(); const { format } = useTimestampFormatter();
// [Gitea #165] Who started the thread and when — the first thing you want // [Gitea #165] Who started the thread and when — the first thing you want
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
Thread Thread
</Text> </Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}> <Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name} {localNames[room.roomId] ?? room.name}
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`} {startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
</Text> </Text>
</Box> </Box>
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp'; import { formatRelativeAge } from '../../../utils/formatTimestamp';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads // Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render. // localStorage synchronously so the chosen filter/sort apply on first render.
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
onOpenThread: (threadId: string) => void; onOpenThread: (threadId: string) => void;
}; };
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) { export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const localNames = useLocalRoomNames();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const handleJump = useCallback( const handleJump = useCallback(
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
Threads Threads
</Text> </Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}> <Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
</Box> </Box>
{totalUnread > 0 && ( {totalUnread > 0 && (
+11 -5
View File
@@ -37,6 +37,7 @@ import {
useAsyncSearch, useAsyncSearch,
UseAsyncSearchOptions, UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch'; } from '../../hooks/useAsyncSearch';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { import {
@@ -157,17 +158,20 @@ export function Search({ requestClose }: SearchProps) {
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces); const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces); const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
const localNames = useLocalRoomNames();
const getTargetStr: SearchItemStrGetter<string> = useCallback( const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => { (roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId; const roomName = getRoom(roomId)?.name ?? roomId;
// [Gitea #336] Match the user's own "Rename for me" name as well.
const names = localNames[roomId] ? [localNames[roomId], roomName] : [roomName];
if (mDirects.has(roomId)) { if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId()); const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername]; if (targetUsername) return [...names, targetUsername];
} }
return roomName; return names.length === 1 ? names[0] : names;
}, },
[getRoom, mDirects, mx], [getRoom, mDirects, mx, localNames],
); );
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
@@ -388,8 +392,10 @@ export function Search({ requestClose }: SearchProps) {
<Box grow="Yes" alignItems="Center" gap="100"> <Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate> <Text size="T400" truncate>
{queryHighlighRegex {queryHighlighRegex
? highlightText(queryHighlighRegex, [room.name]) ? highlightText(queryHighlighRegex, [
: room.name} localNames[room.roomId] ?? room.name,
])
: (localNames[room.roomId] ?? room.name)}
</Text> </Text>
{dmUsername && ( {dmUsername && (
<Text as="span" size="T200" priority="300" truncate> <Text as="span" size="T200" priority="300" truncate>
+16
View File
@@ -102,6 +102,22 @@ export const useLocalRoomName = (room: Room): string => {
return localNames[room.roomId] ?? name; return localNames[room.roomId] ?? name;
}; };
/**
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
* (quick switcher, forward/share pickers) so they use the name the user sees in
* the sidebar, not the server name.
*/
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
const mx = useMatrixClient();
return roomNamesStore.useValue(mx);
};
/** Non-hook read for notification paths (toast / OS notification titles). */
export const getLocalRoomName = (
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
export const useHasLocalRoomName = (roomId: string): boolean => { export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const localNames = roomNamesStore.useValue(mx); const localNames = roomNamesStore.useValue(mx);
+13 -3
View File
@@ -14,6 +14,7 @@ import {
ThreadEvent, ThreadEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet'; import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
import { getLocalRoomName } from '../../hooks/useRoomMeta';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import NotificationSound from '../../../../public/sound/notification.ogg'; import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg'; import InviteSound from '../../../../public/sound/invite.ogg';
@@ -67,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { dismissToastAtom, toastQueueAtom } from '../../state/toast'; import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders'; import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention'; import {
getRoomRetentionMs,
getRoomRetentionSetAt,
isExpired,
retentionDeletesAllowed,
} from '../../utils/retention';
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater'; import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
import { settingsRequestAtom } from '../../state/settingsRequest'; import { settingsRequestAtom } from '../../state/settingsRequest';
import { isNetworkUpdateError } from '../../utils/updateErrors'; import { isNetworkUpdateError } from '../../utils/updateErrors';
@@ -639,7 +645,8 @@ function MessageNotifications() {
const avatarMxc = const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({ notify({
roomName: room.name ?? 'Unknown', // [Gitea #336] the user's own "Rename for me" name when they set one
roomName: getLocalRoomName(mx, room.roomId) ?? room.name ?? 'Unknown',
roomAvatar: avatarMxc roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined, : undefined,
@@ -808,7 +815,7 @@ function ReminderMonitor() {
if (!firedRef.current.has(key)) { if (!firedRef.current.has(key)) {
firedRef.current.add(key); firedRef.current.add(key);
const room = mx.getRoom(r.roomId); const room = mx.getRoom(r.roomId);
const roomName = room?.name ?? 'Unknown Room'; const roomName = getLocalRoomName(mx, r.roomId) ?? room?.name ?? 'Unknown Room';
// Reminders for E2EE rooms carry no text (account data is server- // Reminders for E2EE rooms carry no text (account data is server-
// readable), so resolve the body from the local timeline at fire time. // readable), so resolve the body from the local timeline at fire time.
const localBody = room?.findEventById(r.eventId)?.getContent()?.body; const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
@@ -908,6 +915,9 @@ function RetentionSweeper() {
mx.getRooms().forEach((room) => { mx.getRooms().forEach((room) => {
const maxLifetime = getRoomRetentionMs(room); const maxLifetime = getRoomRetentionMs(room);
if (!maxLifetime) return; if (!maxLifetime) return;
// [Gitea #298] Never delete under a policy that is less than a day old.
const setAt = getRoomRetentionSetAt(room);
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
room room
.getLiveTimeline() .getLiveTimeline()
.getEvents() .getEvents()
+3 -1
View File
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { PresenceRingAvatar } from '../../../components/presence'; import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration'; import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type RoomNotificationsGroup = { type RoomNotificationsGroup = {
roomId: string; roomId: string;
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
hour24Clock, hour24Clock,
dateFormatString, dateFormatString,
}: RoomNotificationsGroupProps) { }: RoomNotificationsGroupProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
/> />
</Avatar> </Avatar>
<Text size="H4" truncate> <Text size="H4" truncate>
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
+10 -3
View File
@@ -38,6 +38,7 @@ import {
import { BlockType } from '../../../components/editor'; import { BlockType } from '../../../components/editor';
import { bytesToSize } from '../../../utils/common'; import { bytesToSize } from '../../../utils/common';
import { getHomePath } from '../../pathUtils'; import { getHomePath } from '../../pathUtils';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type Shared = { payload: SharePayload; files: File[] }; type Shared = { payload: SharePayload; files: File[] };
@@ -47,6 +48,7 @@ type Shared = { payload: SharePayload; files: File[] };
* as the draft) — the user still presses Send. * as the draft) — the user still presses Send.
*/ */
export function Share() { export function Share() {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
@@ -69,8 +71,12 @@ export function Share() {
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom()) .filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)); .sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all; return q
}, [mx, query]); ? all.filter((r) =>
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
)
: all;
}, [mx, query, localNames]);
// Written straight into the room's draft atoms so its composer shows the // Written straight into the room's draft atoms so its composer shows the
// upload board + text as soon as it mounts. // upload board + text as soon as it mounts.
@@ -210,6 +216,7 @@ function ShareRoomRow({
disabled: boolean; disabled: boolean;
onPick: (room: Room) => void; onPick: (room: Room) => void;
}) { }) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient(); const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl(); const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
@@ -237,7 +244,7 @@ function ShareRoomRow({
> >
<Box direction="Column"> <Box direction="Column">
<Text size="T300" truncate> <Text size="T300" truncate>
{room.name} {localNames[room.roomId] ?? room.name}
</Text> </Text>
{dm && ( {dm && (
<Text size="T200" priority="300" truncate> <Text size="T200" priority="300" truncate>
+11
View File
@@ -83,3 +83,14 @@ test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern'); assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
assert.ok(store.has('cinny_session')); assert.ok(store.has('cinny_session'));
}); });
test('clearPlaintextCaches removes the call-volume list and the settings-sync stamp (#347)', () => {
store.clear();
store.set('lotus-per-user-volume', '{"@bob:server":0.3}');
store.set('settings-sync-meta:@me:server', '{"lastSyncedAt":1}');
store.set('settings', '{}');
clearPlaintextCaches('@me:server');
assert.equal(store.has('lotus-per-user-volume'), false);
assert.equal(store.has('settings-sync-meta:@me:server'), false);
assert.equal(store.has('settings'), true, 'device preferences are kept');
});
+21
View File
@@ -47,6 +47,8 @@ const clearMsgDrafts = (): void => {
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent * - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId) * - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
* - `lotus_outbox_v1` — decrypted content of messages not yet sent ([Gitea #112]) * - `lotus_outbox_v1` — decrypted content of messages not yet sent ([Gitea #112])
* - `lotus-per-user-volume`, `settings-sync-meta:<userId>` — whom you adjusted in
* calls, and a key naming the account ([Gitea #347])
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by * - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
* user — see [Gitea #41]; previously deliberately preserved across logout * user — see [Gitea #41]; previously deliberately preserved across logout
* (N98), which let the next account on this device see/send a prior user's * (N98), which let the next account on this device see/send a prior user's
@@ -92,6 +94,24 @@ const clearStatusMessage = (): void => {
}); });
}; };
/**
* [Gitea #347] Per-person residue that isn't message text but still says whom
* the previous user dealt with: Element Call's per-user volume memory (same
* origin on the web — element-call `lotusVolumeMemory.ts`) and the settings-sync
* stamp, whose key names the account.
*/
export const CALL_VOLUME_KEY = 'lotus-per-user-volume';
const clearCallVolumesAndSyncMeta = (userId?: string): void => {
const keys = [CALL_VOLUME_KEY, ...(userId ? [`settings-sync-meta:${userId}`] : [])];
keys.forEach((key) => {
try {
localStorage.removeItem(key);
} catch {
// best-effort
}
});
};
export const clearPlaintextCaches = (userId?: string): void => { export const clearPlaintextCaches = (userId?: string): void => {
clearTranslationCache(); clearTranslationCache();
clearScheduledMessages(); clearScheduledMessages();
@@ -103,5 +123,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearOutbox(); clearOutbox();
clearCallSession(); clearCallSession();
clearStatusMessage(); clearStatusMessage();
clearCallVolumesAndSyncMeta(userId);
if (userId) clearNavToActivePathStore(userId); if (userId) clearNavToActivePathStore(userId);
}; };
+69
View File
@@ -0,0 +1,69 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room } from 'matrix-js-sdk';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
({
isCallRoom: () => !!opts.callRoom,
getJoinRule: () => opts.joinRule ?? 'invite',
getLiveTimeline: () => ({
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
}),
}) as unknown as Room;
test('voice rooms never ring or show "missed call", even as DMs', () => {
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
});
test('DMs and invite-only groups outside a space ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
});
test('space channels, public and restricted rooms do not ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
});
const stateRoom = (
members: Array<{ sender: string; content: Record<string, unknown> }>,
timeline: unknown[] = [],
): Room =>
({
getLiveTimeline: () => ({
getState: () => ({
getStateEvents: () =>
members.map((m) => ({
getSender: () => m.sender,
getStateKey: () => `_${m.sender}_DEV_m.call`,
getContent: () => m.content,
})),
}),
getEvents: () => timeline,
}),
}) as unknown as Room;
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
const room = stateRoom([
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
{ sender: '@carol:x', content: {} },
]);
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
});
test('declinedRing finds our own decline of that ring only (#312)', () => {
const decline = (sender: string, rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => sender,
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
});
+52
View File
@@ -0,0 +1,52 @@
import { JoinRule, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvents } from './room';
/**
* Whether a call notification in this room may ring — and, by the same rule,
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
* call button is shown. Persistent voice rooms, space channels, restricted and
* public rooms never do: joining a voice channel alone is normal and isn't a
* "call" anyone missed.
*/
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
if (room.isCallRoom()) return false;
if (isDirect) return true;
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
};
/** A call-membership state event that is still "in the call" (left = empty content). */
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
!!content &&
(typeof content.application === 'string' ||
(Array.isArray(content.memberships) && content.memberships.length > 0));
/**
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
* current STATE. After a reload the MatrixRTC session can be stale (a join and
* leave replayed in one catch-up batch), but the state is right.
*/
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
(ev) =>
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
isActiveMembership(ev.getContent()),
);
/**
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
* the ring in the first sync after a restart, before any dismiss listener.
*/
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
room
.getLiveTimeline()
.getEvents()
.some(
(ev) =>
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
ev.getSender() === myUserId &&
ev.getRelation()?.event_id === refEventId,
);
+35
View File
@@ -38,3 +38,38 @@ test('hostFromText only accepts URL/host-shaped text; IDN becomes punycode', ()
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true); assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true);
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true); assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true);
}); });
test('a trusted name inside a sentence still exposes a different destination (#335)', () => {
const a = analyzeLink('Log in at chat.lotusguild.org', 'https://evil.example/');
assert.equal(a?.mismatch, true);
assert.equal(a?.shownHost, 'chat.lotusguild.org');
assert.equal(
analyzeLink('chat.lotusguild.org (official)', 'https://evil.example/')?.mismatch,
true,
);
assert.equal(
analyzeLink('Open https://paypal.com/login now', 'https://evil.example/')?.mismatch,
true,
);
// honest sentences are not flagged
assert.equal(
analyzeLink('Log in at chat.lotusguild.org', 'https://chat.lotusguild.org/login')?.mismatch,
false,
);
assert.equal(analyzeLink('read the docs', 'https://evil.example/')?.mismatch, false);
assert.equal(analyzeLink('see main.py and report.pdf', 'https://github.com/x')?.mismatch, false);
assert.equal(analyzeLink('release v1.2.3 notes', 'https://github.com/x')?.mismatch, false);
});
test('shared hosting suffixes are separate sites (#335)', () => {
assert.equal(registrableDomain('alice.github.io'), 'alice.github.io');
assert.equal(registrableDomain('www.alice.github.io'), 'alice.github.io');
assert.equal(registrableDomain('bucket.s3.amazonaws.com'), 'bucket.s3.amazonaws.com');
assert.equal(analyzeLink('alice.github.io', 'https://evil.github.io/')?.mismatch, true);
assert.equal(analyzeLink('lotusguild.pages.dev', 'https://evil.pages.dev/')?.mismatch, true);
assert.equal(
analyzeLink('alice.github.io/blog', 'https://alice.github.io/blog/1')?.mismatch,
false,
);
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
});
+131 -7
View File
@@ -48,11 +48,53 @@ const TWO_LABEL_SUFFIXES = new Set([
'com.tw', 'com.tw',
]); ]);
// [Gitea #335] Shared-hosting suffixes (the Public Suffix List's "private"
// section, the ones phishing actually uses): every customer gets their own
// subdomain, so `alice.github.io` and `evil.github.io` are different sites.
// A curated list rather than the full PSL keeps this out of the bundle budget.
const SHARED_HOSTING_SUFFIXES = new Set([
'github.io',
'gitlab.io',
'codeberg.page',
'pages.dev',
'workers.dev',
'trycloudflare.com',
'netlify.app',
'vercel.app',
'web.app',
'firebaseapp.com',
'appspot.com',
'herokuapp.com',
'onrender.com',
'fly.dev',
'glitch.me',
'replit.app',
'repl.co',
'surge.sh',
'blogspot.com',
'wordpress.com',
'wixsite.com',
'weebly.com',
'azurewebsites.net',
'cloudfront.net',
'ngrok.io',
'ngrok-free.app',
'duckdns.org',
's3.amazonaws.com',
]);
const isPublicSuffix = (suffix: string): boolean =>
TWO_LABEL_SUFFIXES.has(suffix) || SHARED_HOSTING_SUFFIXES.has(suffix);
export const registrableDomain = (host: string): string => { export const registrableDomain = (host: string): string => {
const labels = host.toLowerCase().replace(/\.$/, '').split('.'); const labels = host.toLowerCase().replace(/\.$/, '').split('.');
if (labels.length <= 2) return labels.join('.'); // Longest matching public suffix wins (3 labels, e.g. s3.amazonaws.com, then 2).
const lastTwo = labels.slice(-2).join('.'); for (const n of [3, 2]) {
return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo; if (labels.length > n && isPublicSuffix(labels.slice(-n).join('.'))) {
return labels.slice(-(n + 1)).join('.');
}
}
return labels.slice(-2).join('.');
}; };
const HOST_LIKE = const HOST_LIKE =
@@ -72,6 +114,85 @@ export const hostFromText = (text: string): string | null => {
} }
}; };
// Words that look like hosts but are usually files or versions in chat
// ("see main.py", "report.pdf", "v1.2.3"): skipped inside sentences unless
// written as a URL (scheme, www., or a path) so ordinary links aren't flagged.
const FILE_EXTENSIONS = new Set([
'py',
'sh',
'md',
'rs',
'js',
'ts',
'tsx',
'jsx',
'json',
'yaml',
'yml',
'toml',
'txt',
'log',
'csv',
'pdf',
'zip',
'gz',
'tar',
'png',
'jpg',
'jpeg',
'gif',
'webp',
'svg',
'mp3',
'mp4',
'mov',
'html',
'htm',
'css',
'exe',
'dll',
'lock',
'cfg',
'conf',
'ini',
'xml',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'apk',
'iso',
'jar',
'java',
'go',
'rb',
'php',
'c',
'h',
'cpp',
]);
const TRIM_PUNCT = /^[\s"'“”‘’«»([{<]+|[\s"'“”‘’«»)\]}>.,;:!?]+$/g;
/** Every host a sentence names (Gitea #335), e.g. "Log in at chat.lotusguild.org". */
export const hostsFromText = (text: string): string[] => {
const hosts: string[] = [];
text.split(/\s+/).forEach((raw) => {
const word = raw.replace(TRIM_PUNCT, '');
const host = word && hostFromText(word);
if (!host) return;
const tld = host.split('.').pop() ?? '';
if (!/[a-z]/i.test(tld)) return; // version numbers, IPs-as-words
const urlShaped =
/^[a-z][a-z0-9+.-]*:\/\//i.test(word) || /^www\./i.test(word) || word.includes('/');
if (!urlShaped && host.split('.').length === 2 && FILE_EXTENSIONS.has(tld.toLowerCase()))
return;
if (!hosts.includes(host)) hosts.push(host);
});
return hosts;
};
export type LinkSafety = { export type LinkSafety = {
/** The visible text names a different site than the destination. */ /** The visible text names a different site than the destination. */
mismatch: boolean; mismatch: boolean;
@@ -92,8 +213,11 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
if (url.hostname === 'matrix.to') return null; if (url.hostname === 'matrix.to') return null;
const realHost = url.hostname; const realHost = url.hostname;
const punycode = realHost.split('.').some((l) => l.startsWith('xn--')); const punycode = realHost.split('.').some((l) => l.startsWith('xn--'));
const shownHost = hostFromText(text) ?? undefined; // A single URL/host-shaped text, or (Gitea #335) host-shaped words inside a
const mismatch = // sentence: "Log in at chat.lotusguild.org" must not hide a different site.
shownHost !== undefined && registrableDomain(shownHost) !== registrableDomain(realHost); const single = hostFromText(text);
return { mismatch, shownHost, realHost, punycode }; const shown = single ? [single] : hostsFromText(text);
const realSite = registrableDomain(realHost);
const other = shown.find((h) => registrableDomain(h) !== realSite);
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
}; };
+27 -1
View File
@@ -1,6 +1,13 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention'; import {
effectiveRetentionMs,
isExpired,
RETENTION_DELETE_GRACE_MS,
RETENTION_MIN_MS,
RETENTION_PRESETS,
retentionDeletesAllowed,
} from './retention';
// MSC1763 retention: `isExpired` decides whether a message is past the room's // MSC1763 retention: `isExpired` decides whether a message is past the room's
// retention window. It must be strict (> window, not >=) and a disabled policy // retention window. It must be strict (> window, not >=) and a disabled policy
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
'all presets above the floor', 'all presets above the floor',
); );
}); });
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
assert.equal(effectiveRetentionMs(0), undefined);
assert.equal(effectiveRetentionMs(-5), undefined);
assert.equal(effectiveRetentionMs('86400000'), undefined);
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
});
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
const now = 1_000 * HOUR;
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
});
+27 -2
View File
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
{ label: '1 Month', ms: 30 * DAY_MS }, { label: '1 Month', ms: 30 * DAY_MS },
]; ];
/**
* [Gitea #298] A usable retention window, or `undefined`. Anything under
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
* from any client by anyone with state power) used to blank every timeline and
* get opted-in users' messages redacted within a minute.
*/
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
? maxLifetime
: undefined;
/** The room's active retention window in ms, or `undefined` when unset/disabled. */ /** The room's active retention window in ms, or `undefined` when unset/disabled. */
export const getRoomRetentionMs = (room: Room): number | undefined => { export const getRoomRetentionMs = (room: Room): number | undefined => {
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, ''); const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
const ms = event?.getContent<RetentionContent>()?.max_lifetime; return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
return typeof ms === 'number' && ms > 0 ? ms : undefined;
}; };
/**
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
* policy for this long after it was set, so a mistaken or malicious change can
* be reverted before anything is permanently redacted. (Hiding expired
* messages locally is not destructive and applies at once.)
*/
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
/** When the room's current retention policy was set (its state event's ts). */
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */ /** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean => export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs; maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
+6
View File
@@ -104,3 +104,9 @@ describe('buildSyncedContent', () => {
assert.deepEqual(c.settings, pickSyncable(base)); assert.deepEqual(c.settings, pickSyncable(base));
}); });
}); });
describe('device-local keys', () => {
it('keeps enterForNewline on each device (#348)', () => {
assert.equal(DEVICE_LOCAL_KEYS.has('enterForNewline'), true);
});
});
+3
View File
@@ -49,6 +49,9 @@ export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Sett
'ringtoneVolume', 'ringtoneVolume',
'soundboardVolume', 'soundboardVolume',
'cameraOnJoin', 'cameraOnJoin',
// [Gitea #348] Enter = new line suits a phone keyboard, Enter = send a desktop one;
// syncing it switched the desktop to Ctrl+Enter when set on the phone.
'enterForNewline',
]); ]);
/** The subset of `settings` that is synced (deep-copied so callers can't alias). */ /** The subset of `settings` that is synced (deep-copied so callers can't alias). */
+14 -3
View File
@@ -20,9 +20,12 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
* session. When that happens the device "resurrects" with a blank key store and the * session. When that happens the device "resurrects" with a blank key store and the
* client re-uploads a one-time key at an id Synapse already holds → a permanent * client re-uploads a one-time key at an id Synapse already holds → a permanent
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream, * `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA * undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
* install and shows no prompt; denial/absence is non-fatal. * (engagement / PWA install); Firefox shows a permission prompt and the promise
* stays pending until it's answered. Denial/absence is non-fatal.
*/ */
const PERSIST_WAIT_MS = 1500;
export const requestPersistentStorage = async (): Promise<boolean> => { export const requestPersistentStorage = async (): Promise<boolean> => {
try { try {
if (!navigator.storage?.persist) return false; if (!navigator.storage?.persist) return false;
@@ -39,7 +42,15 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
export const initClient = async (session: Session): Promise<MatrixClient> => { export const initClient = async (session: Session): Promise<MatrixClient> => {
// Protect the crypto store from eviction before we create and write to it. // Protect the crypto store from eviction before we create and write to it.
await requestPersistentStorage(); // [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
// the promise pending until the user responds, which froze the app on
// "Heating up". KE-1 only needs the request made; give it 1.5 s at most.
await Promise.race([
requestPersistentStorage(),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
}),
]);
const indexedDBStore = new IndexedDBStore({ const indexedDBStore = new IndexedDBStore({
indexedDB: globalThis.indexedDB, indexedDB: globalThis.indexedDB,