Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 37b3ae1ad7 fix(desktop): without a tray, the first-close dialog defaults to Quit (cinny-desktop #37)
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 18m14s
On desktops with no tray host (stock GNOME), "Keep running in the tray"
hid the window with no icon to bring it back. Ask the desktop app
(has_tray_host); without a tray, say so, focus "Quit when I close the
window", and offer "Keep running in the background" as the secondary
choice. Older desktop builds keep the current dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 02:41:04 -04:00
jared 5ecf4132f5 Merge pull request 'fix(calls): DM-aware rejoin toast, and no rejoining a DM call that ended (#302)' (#389) from fix/rejoin-dm 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:16:23 -04:00
jared d412f9c56c Merge pull request 'fix(search): an edited message shows once, with its latest text (#288)' (#388) from fix/search-edit-dupes 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 00:59:46 -04:00
jared ad5631819d Merge pull request 'fix(sidebar): the room filter hides empty sections and says when nothing matches (#289)' (#387) from fix/room-filter-empty 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 00:45:28 -04:00
jared 83933fb5ac Merge pull request 'fix(composer): Escape closes the emoji and sticker boards (#304)' (#386) from fix/emoji-board-escape 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 00:27:07 -04:00
jared a7c9d15824 Merge pull request 'fix(calls): the call-failed screen gets a Try again button (#301)' (#385) from fix/call-failed-retry 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 00:08:25 -04:00
jared e6e52cdc37 Merge pull request 'fix(share): room share and invite links open Lotus Chat, not matrix.to (#285)' (#384) from fix/share-links-lotus 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-17 23:50:18 -04:00
jared 4ab5a8fd06 Merge pull request 'fix(polls): you can vote on a poll you just created (#284)' (#383) from fix/poll-vote-local-echo 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-17 23:26:31 -04:00
jared 4e30ae763e Merge pull request 'fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322)' (#382) from fix/scheduled-from-server 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-17 23:05:38 -04:00
jared f9265d2d90 Merge pull request 'fix(calls): one delayed-event restart in flight per id, with a timeout (#318)' (#381) from fix/delayed-restart-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-17 22:37:01 -04:00
jared 82c5728a96 Merge pull request 'fix(calls): Reject sends the decline to the ring notification (#313)' (#375) from fix/decline-references-notification 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-17 22:13:55 -04:00
jared dd6cf156ba Merge pull request 'fix(status): show a strip when the homeserver hangs, not 110 s later (#317)' (#380) from fix/stall-watchdog 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-17 21:58:23 -04:00
jared e94cdfe5dc Merge pull request 'fix(timeline): media gallery / activity log no longer make the timeline drop messages (#259)' (#368) from fix/filtered-timeline-double-count 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-17 21:37:08 -04:00
jared bb2ace82eb Merge pull request 'fix(calls): lift CallControl's listener limit so ended calls can be freed (#316)' (#379) from fix/call-listener-limit 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-17 21:19:12 -04:00
Lotus CIandClaude Opus 5.5 0a2f18e3d5 fix(calls): DM-aware rejoin toast, and no rejoining a DM call that ended (#302)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 19s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m35s
After a reload during a DM call the toast said "You were in the call in
Bob Builder"; it now says "You were in a call with Bob Builder". DM
calls are never auto-rejoined (it could ring the other person), and if
they have left by the time you tap, the toast says "Call ended" instead
of starting a new, ringing call.

Fixes #302

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:20:12 -04:00
Lotus CIandClaude Opus 5.5 0d08ba0a55 fix(search): an edited message shows once, with its latest text (#288)
CI / Build & Quality Checks (pull_request) Successful in 2m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m21s
Server search also returns the m.replace events, so one message edited
twice came back as three rows. Fold edits into their message: one row
at the best-ranked position, showing the latest m.new_content and
pointing at the original event so Open jumps to the real message.

Fixes #288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:14:09 -04:00
Lotus CIandClaude Opus 5.5 6ae4d7adc0 fix(sidebar): the room filter hides empty sections and says when nothing matches (#289)
CI / Build & Quality Checks (pull_request) Successful in 3m7s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m7s
A filter matching nothing left Favorites / Projects / Rooms / Low
Priority headers with nothing under them. While filtering, sections
without a match are hidden, and with no match at all the list shows
"No rooms match “…”" with a Clear filter button.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:08:56 -04:00
Lotus CIandClaude Opus 5.5 f7bdd2855a fix(composer): Escape closes the emoji and sticker boards (#304)
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 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m19s
The board opens with focus in its search field, and its FocusTrap used
the shared stopPropagation helper, which ignores Escape while an input
is focused, so Escape never closed it (Chromium, Firefox, WebKit).
Always close on Escape here, still stopping the key from closing the
room or modal underneath. The GIF picker already uses the default.

Fixes #304

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:06:41 -04:00
Lotus CIandClaude Opus 5.5 26cf1204b5 fix(calls): the call-failed screen gets a Try again button (#301)
CI / Build & Quality Checks (pull_request) Successful in 2m44s
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 14m57s
"The call failed to load… try again" only offered Back, so the user had
to back out and press Join again. Try again starts a fresh embed for
the same room with the same mic/camera/sound (which disposes the hung
one); Back stays as the secondary action.

Fixes #301

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:03:40 -04:00
Lotus CIandClaude Opus 5.5 e16c923bea fix(share): room share and invite links open Lotus Chat, not matrix.to (#285)
CI / Build & Quality Checks (pull_request) Successful in 2m49s
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 17m6s
matrix.to can't send anyone to this deployment (its Cinny adapter is
app.cinny.in, web-instance[] is Element-only), so a friend scanning a
room QR or clicking an invite link was offered Element or app.cinny.in.
Share Room (link + QR) and the Invite dialog (link + QR) now use the
Lotus permalink (correct on desktop via webAppUrl). The space menus
keep both "Copy Link" (matrix.to, for other clients) and "Copy Lotus Link".

Fixes #285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:59:57 -04:00
Lotus CIandClaude Opus 5.5 f9172cc339 fix(polls): you can vote on a poll you just created (#284)
CI / Build & Quality Checks (pull_request) Successful in 2m53s
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 14m12s
PollContent read mEvent.getId() once at render. For your own new poll
that is the local echo id (~!room:txn), and nothing re-rendered it when
the SDK swapped in the real $id, so the vote related to the local id
and the SDK threw synchronously (getPendingEvents under chronological
ordering): no vote was ever sent. Track the id via LocalEventIdReplaced
and Status, block voting while it is still local, and catch synchronous
throws. Adds the Playwright regression the issue asks for.

Fixes #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:54:01 -04:00
Lotus CIandClaude Opus 5.5 8bbb436ca7 fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322)
CI / Build & Quality Checks (pull_request) Successful in 2m56s
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 15m43s
The tray only knew what this browser had scheduled (localStorage), but
Synapse lists every pending one via MSC4140 GET /delayed_events. Load it
when the tray mounts and on window focus (localStorage stays as a first-
paint cache), keeping only non-state m.room.message events. Logout now
says "N scheduled messages still send after you log out" and offers to
cancel them.

Fixes #322

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:40:03 -04:00
Lotus CIandClaude Opus 5.5 4ddcda122a fix(calls): one delayed-event restart in flight per id, with a timeout (#318)
CI / Build & Quality Checks (pull_request) Successful in 3m12s
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 15m31s
During a homeserver stall Element Call retries update_delayed_event every
2 s, and the host opened a new HTTP request each time: 12 pending in a
60 s stall, all landing together on recovery (404 x5, 429 x7). Share the
in-flight restart per delay id and abort it after 5 s.

Refs #318

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:35:06 -04:00
jared 9c433d9d77 Merge branch 'lotus' into fix/decline-references-notification
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 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m45s
2026-10-17 19:29:23 -04:00
Lotus CIandClaude Opus 5.5 e8fd0a1112 fix(status): show a strip when the homeserver hangs, not 110 s later (#317)
CI / Build & Quality Checks (pull_request) Successful in 2m40s
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 17m50s
A hung Synapse (accepts connections, never answers) kept sync SYNCING
for up to 110 s (pollTimeout 30 s + the SDK's 80 s buffer), so no strip
showed and Kuma was never asked. A watchdog now notices a /sync overdue
by 45 s, confirms with a 5 s GET /_matrix/client/versions, then shows
"Lotus Chat's server isn't responding…" and polls Kuma at once.

Fixes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:29:01 -04:00
jared 00e27a24e6 Merge branch 'lotus' into fix/filtered-timeline-double-count
CI / Build & Quality Checks (pull_request) Successful in 2m44s
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 18m21s
2026-10-17 19:08:38 -04:00
Lotus CIandClaude Opus 5.5 46d6570df3 fix(calls): lift CallControl's listener limit so ended calls can be freed (#316)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m30s
More than 10 call features subscribe to CallControl's state_update at once
(call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC). At
the default limit Node's events logged a MaxListenersExceededWarning, and
the console kept the warning's emitter (this CallControl, and through it
the whole Element Call document) alive: ~11 MB per call per the audit.
setMaxListeners(100); these are real, unsubscribed-on-cleanup listeners.

Not reproduced in this environment (headless web logs no warning: fewer
subscribers without the desktop hooks); this is the fix the issue
measured (swallowing the warning freed all CallControls).

Fixes #316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:57:22 -04:00
Lotus CIandClaude Opus 5.5 a425aa3572 fix(calls): Reject sends the decline to the ring notification (#313)
CI / Build & Quality Checks (pull_request) Successful in 3m8s
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 18m4s
handleReject related the MSC4310 decline to the caller's call.member event
(the notification's reference), but MSC4310 and Element Call match a
decline against the rtc.notification event itself, so the caller kept
"Calling…" for the full 90 s after being told "Declined", and declines
from other clients (correctly referencing the notification) didn't stop
Lotus ringing on other devices.

- The incoming call keeps the notification's own event id; Reject relates
  the decline to it.
- The "declined on another device" dismissal and declinedRing accept
  either id (older Lotus builds referenced the membership).
- The missed-call line is skipped for a call we declined.

Verified headless (DM, Bob rings, Reject): the decline used to reference
the membership; it now references the notification. Unit test added.

Fixes #313

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:01:06 -04:00
Lotus CI d1270956ea Merge remote-tracking branch 'origin/lotus' into fix/filtered-timeline-double-count
CI / Secret scan (gitleaks) (pull_request) Successful in 29s
CI / Build & Quality Checks (pull_request) Failing after 11m33s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-17 17:52:35 -04:00
Lotus CIandClaude Opus 5.5 5b5fbdb7b7 fix(timeline): media gallery / activity log no longer make the timeline drop messages (#259)
CI / Build & Quality Checks (pull_request) Successful in 2m22s
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 13m16s
The media gallery and activity log register filtered timeline sets on the
room (getOrCreateFilteredTimelineSet); the room re-emits their events as
live RoomEvent.Timeline, so every matching event reached "new event in
the room" listeners twice for the rest of the session. The timeline
window shifted by an extra message per event (loaded messages vanished),
and notifications, unread counts, widget feeds and ring handling saw
duplicates.

isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered
set; the room timeline, roomToUnread, message notifications, clock-skew
sampling, call ring/decline handling and widget event feeds ignore them.

Verified headless (40 messages, every 5th a file; gallery opened; Bob
sends 3 files + 1 text): before, the first rendered message drifted from
the room start to "file15"; now it stays put.

Fixes #259

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:42:47 -04:00
35 changed files with 995 additions and 204 deletions
+46
View File
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
expect(console_.unexpected()).toEqual([]); expect(console_.unexpected()).toEqual([]);
}); });
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Poll Room');
await loginUI(page, alice);
await openRoom(page, room);
const question = uniq('Lunch? ');
await page.getByRole('button', { name: 'Create poll' }).first().click();
await page.getByPlaceholder('Ask a question…').fill(question);
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
await page.getByRole('button', { name: 'Create Poll' }).last().click();
const poll = page.locator('[data-message-id]', { hasText: question }).last();
await expect(poll).toBeVisible();
type Ev = {
type: string;
event_id: string;
content: { 'm.relates_to'?: { event_id?: string } };
};
const events = async () =>
(
await api<{ chunk: Ev[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
alice.token,
)
).chunk;
// Vote once the poll has reached the server (its row may still carry the local id).
await expect
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
.toBe(true);
await poll.getByText('Tacos', { exact: true }).first().click();
await expect
.poll(async () => {
const chunk = await events();
const start = chunk.find((e) => /poll\.start/.test(e.type));
return chunk.some(
(e) =>
/poll\.response/.test(e.type) &&
e.content['m.relates_to']?.event_id === start?.event_id &&
start?.event_id.startsWith('$'),
);
})
.toBe(true);
expect(console_.unexpected()).toEqual([]);
});
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => { test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
const room = await createRoom(alice, 'Scroll Room'); const room = await createRoom(alice, 'Scroll Room');
await Array.from({ length: 40 }).reduce<Promise<unknown>>( await Array.from({ length: 40 }).reduce<Promise<unknown>>(
+24 -6
View File
@@ -73,7 +73,7 @@ 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, getMemberName } from '../utils/room'; import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing'; 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';
@@ -111,6 +111,8 @@ type IncomingCallInfo = {
intent?: string; intent?: string;
notificationType: RTCNotificationType; notificationType: RTCNotificationType;
refEventId: string; refEventId: string;
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
notificationEventId?: string;
}; };
type IncomingCallProps = { type IncomingCallProps = {
dm: boolean; dm: boolean;
@@ -283,7 +285,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
fill="Soft" fill="Soft"
size="400" size="400"
radii="400" radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} onClick={() =>
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
}
before={<Icon size="200" src={Icons.Cross} filled />} before={<Icon size="200" src={Icons.Cross} filled />}
> >
<Text as="span" size="B400"> <Text as="span" size="B400">
@@ -455,7 +459,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
size="300" size="300"
radii="300" radii="300"
outlined outlined
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} onClick={() =>
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
}
before={<Icon size="100" src={Icons.Cross} filled />} before={<Icon size="100" src={Icons.Cross} filled />}
> >
<Text as="span" size="B300"> <Text as="span" size="B300">
@@ -496,6 +502,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => { async (event, room, toStartOfTimeline, removed, data) => {
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// only process rtc notification reference events. // only process rtc notification reference events.
// we do not want to wait to decrypt all events. // we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return; if (event.getRelation()?.rel_type !== RelationType.Reference) return;
@@ -596,6 +603,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
: undefined, : undefined,
notificationType, notificationType,
refEventId, refEventId,
notificationEventId: event.getId(),
}; };
// [Gitea #312] Don't ring for a call that is already over or handled: after // [Gitea #312] Don't ring for a call that is already over or handled: after
@@ -604,7 +612,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// stale RTC session). The caller's membership can arrive just after the // 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. // ring, so an absent one gets a short grace period before giving up.
const myUserId = mx.getSafeUserId(); const myUserId = mx.getSafeUserId();
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { if (
declinedRing(room, [refEventId, event.getId()], myUserId) ||
hasActiveCallMembership(room, myUserId)
) {
return; return;
} }
if (hasActiveCallMembership(room, sender)) { if (hasActiveCallMembership(room, sender)) {
@@ -613,7 +624,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
} }
setTimeout(() => { setTimeout(() => {
if (Date.now() >= senderTs + lifetime) return; if (Date.now() >= senderTs + lifetime) return;
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { if (
declinedRing(room, [refEventId, event.getId()], myUserId) ||
hasActiveCallMembership(room, myUserId)
) {
return; return;
} }
if (hasActiveCallMembership(room, sender)) setCallInfo(info); if (hasActiveCallMembership(room, sender)) setCallInfo(info);
@@ -683,9 +697,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
data, data,
) => { ) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) { if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
const related = event.getRelation()?.event_id; const related = event.getRelation()?.event_id;
if (!related || related === refEventId) dismiss(); // declined elsewhere // [Gitea #313] Correct clients reference the notification; older Lotus
// builds referenced the call membership. Accept both.
if (!related || related === refEventId || related === callInfo.notificationEventId)
dismiss(); // declined elsewhere
} }
}; };
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships); session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
+52 -6
View File
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
export function CloseBehaviorPrompt() { export function CloseBehaviorPrompt() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [launchOnLogin, setLaunchOnLogin] = useState(false); const [launchOnLogin, setLaunchOnLogin] = useState(false);
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
// tray" would hide the window with no icon to bring it back. Older desktop
// builds lack the command; assume a tray then (the old behaviour).
const [hasTray, setHasTray] = useState(true);
const modalStyle = useModalStyle(420); const modalStyle = useModalStyle(420);
// Tells the native side we're listening (see get_close_behavior). // Tells the native side we're listening (see get_close_behavior).
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
tauriInvoke()?.('get_close_behavior').catch(() => undefined); tauriInvoke()?.('get_close_behavior').catch(() => undefined);
}, []); }, []);
useTauriEvent('lotus-close-requested', () => setOpen(true)); useTauriEvent('lotus-close-requested', () => {
const ask = tauriInvoke()?.('has_tray_host');
if (!ask) {
setOpen(true);
return;
}
ask
.then((v) => setHasTray(v !== false))
.catch(() => setHasTray(true))
.finally(() => setOpen(true));
});
const choose = useCallback( const choose = useCallback(
(value: 'tray' | 'quit') => { (value: 'tray' | 'quit') => {
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
<OverlayCenter> <OverlayCenter>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: '#close-behavior-keep', initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
// Dismissing without choosing keeps the window open; the next close // Dismissing without choosing keeps the window open; the next close
// asks again. // asks again.
onDeactivate: () => setOpen(false), onDeactivate: () => setOpen(false),
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
</Header> </Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" id="close-behavior-body"> <Text priority="400" id="close-behavior-body">
Lotus Chat can keep running in the system tray when you close this window, so {hasTray
messages, notifications and calls still reach you. Open it again from the tray icon. ? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.'
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
</Text> </Text>
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}> <Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
<Checkbox <Checkbox
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text> <Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
</Box> </Box>
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}> {hasTray ? (
<>
<Button
id="close-behavior-keep"
variant="Primary"
onClick={() => choose('tray')}
>
<Text size="B400">Keep running in the tray</Text> <Text size="B400">Keep running in the tray</Text>
</Button> </Button>
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}> <Button
id="close-behavior-quit"
variant="Secondary"
fill="Soft"
onClick={() => choose('quit')}
>
<Text size="B400">Quit when I close the window</Text> <Text size="B400">Quit when I close the window</Text>
</Button> </Button>
</>
) : (
<>
<Button
id="close-behavior-quit"
variant="Primary"
onClick={() => choose('quit')}
>
<Text size="B400">Quit when I close the window</Text>
</Button>
<Button
id="close-behavior-keep"
variant="Secondary"
fill="Soft"
onClick={() => choose('tray')}
>
<Text size="B400">Keep running in the background</Text>
</Button>
</>
)}
</Box> </Box>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
You can change this in Settings → General. You can change this in Settings → General.
+46 -6
View File
@@ -1,4 +1,4 @@
import React, { forwardRef, useCallback } from 'react'; import React, { forwardRef, useCallback, useEffect, useState } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card'; import { InfoCard } from './info-card';
import { ScheduledMessage } from '../state/scheduledMessages';
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
import { import {
useDeviceVerificationStatus, useDeviceVerificationStatus,
VerificationStatus, VerificationStatus,
@@ -36,8 +38,25 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom); const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
const [logoutState, logout] = useAsyncCallback<void, Error, []>( // [Gitea #322] Scheduled messages live on the server and still send after
useCallback(async () => { // logout, where no client would show them. Say so, and offer to cancel.
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
useEffect(() => {
let alive = true;
fetchScheduledMessages(mx)
.then((list) => alive && setScheduled(list))
.catch(() => undefined);
return () => {
alive = false;
};
}, [mx]);
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
useCallback(
async (cancelScheduled?: boolean) => {
if (cancelScheduled) {
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
}
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
// membership (expires: 4 h) stays behind as a ghost participant and // membership (expires: 4 h) stays behind as a ghost participant and
// everyone else sees you "in call" until it times out. // everyone else sees you "in call" until it times out.
@@ -46,7 +65,9 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
setCallEmbed(undefined); setCallEmbed(undefined);
} }
await logoutClient(mx); await logoutClient(mx);
}, [mx, callEmbed, setCallEmbed]), },
[mx, callEmbed, setCallEmbed, scheduled],
),
); );
const ongoingLogout = logoutState.status === AsyncStatus.Loading; const ongoingLogout = logoutState.status === AsyncStatus.Loading;
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages." description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
/> />
))} ))}
{scheduled.length > 0 && (
<InfoCard
variant="Warning"
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
/>
)}
<Text priority="400">You’re about to log out. Are you sure?</Text> <Text priority="400">You’re about to log out. Are you sure?</Text>
{logoutState.status === AsyncStatus.Error && ( {logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300"> <Text style={{ color: color.Critical.Main }} size="T300">
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Button <Button
variant="Critical" variant="Critical"
onClick={logout} onClick={() => logout()}
disabled={ongoingLogout} disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />} before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
> >
<Text size="B400">Logout</Text> <Text size="B400">
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
</Text>
</Button> </Button>
{scheduled.length > 0 && (
<Button
variant="Critical"
fill="Soft"
onClick={() => logout(true)}
disabled={ongoingLogout}
>
<Text size="B400">Cancel scheduled messages and logout</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}> <Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">Cancel</Text> <Text size="B400">Cancel</Text>
</Button> </Button>
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji'; import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../hooks/useRecentEmoji';
@@ -612,7 +612,13 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt), !editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
escapeDeactivates: stopPropagation, // [Gitea #304] The board opens with focus in its search field, where
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
}} }}
> >
<EmojiBoardLayout <EmojiBoardLayout
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useModalStyle } from '../../hooks/useModalStyle'; import { useModalStyle } from '../../hooks/useModalStyle';
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [showQr, setShowQr] = useState(false); const [showQr, setShowQr] = useState(false);
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
const lotusBase = useLotusShareBase();
const inviteUrl = (() => { const inviteUrl = (() => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
return getMatrixToRoom(roomIdOrAlias, viaServers); return room.isSpaceRoom()
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
})(); })();
const handleCopyLink = () => { const handleCopyLink = () => {
@@ -1,7 +1,7 @@
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds'; import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName, sendRoomEvent } from '../../../utils/room'; import { getMemberName, sendRoomEvent } from '../../../utils/room';
import { import {
@@ -99,7 +99,21 @@ export function PollContent({
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const roomId = room.roomId; const roomId = room.roomId;
const eventId = mEvent.getId(); // [Gitea #284] A poll you just created starts with its local-echo id
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
// doesn't re-render us. Track the id so votes and the tally use the real one.
const [eventId, setEventId] = useState(() => mEvent.getId());
useEffect(() => {
const sync = () => setEventId(mEvent.getId());
sync();
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.on(MatrixEventEvent.Status, sync);
return () => {
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.off(MatrixEventEvent.Status, sync);
};
}, [mEvent]);
const isLocalEcho = !!eventId?.startsWith('~');
const senderId = mEvent.getSender(); const senderId = mEvent.getSender();
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]); const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
@@ -192,8 +206,9 @@ export function PollContent({
const isMultiple = maxSelections > 1; const isMultiple = maxSelections > 1;
const showResults = resultsVisible(isUndisclosed, isEnded); const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isEnded; const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact); const canEnd =
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes; const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally; const { counts, total, voters } = tally;
@@ -219,15 +234,21 @@ export function PollContent({
setPending(next); setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader // Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces). // accepts both namespaces).
// The SDK can throw synchronously (e.g. a relation to a pending event).
try {
sendRoomEvent(mx, roomId, null, 'm.poll.response', { sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, 'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next), 'm.selections': Array.from(next),
}).catch(() => setPending(null)); }).catch(() => setPending(null));
} catch {
setPending(null);
}
}; };
const handleEndPoll = () => { const handleEndPoll = () => {
if (!roomId || !eventId || ending) return; if (!roomId || !eventId || ending) return;
setEnding(true); setEnding(true);
try {
sendRoomEvent(mx, roomId, null, 'm.poll.end', { sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, 'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {}, 'm.poll.end': {},
@@ -238,6 +259,9 @@ export function PollContent({
setEnding(false); setEnding(false);
}) })
.catch(() => setEnding(false)); .catch(() => setEnding(false));
} catch {
setEnding(false);
}
}; };
// Radiogroup keyboard model (single-choice only): arrows move focus + selection. // Radiogroup keyboard model (single-choice only): arrows move focus + selection.
+15 -1
View File
@@ -6,12 +6,13 @@ import {
useCallJoined, useCallJoined,
useCallEmbedPlacementSync, useCallEmbedPlacementSync,
useCallLoadError, useCallLoadError,
useCallStart,
} from '../../hooks/useCallEmbed'; } from '../../hooks/useCallEmbed';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls'; import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -160,6 +161,16 @@ function CallLoadErrorMessage() {
// prescreen, where they can choose to join again. // prescreen, where they can choose to join again.
const dismiss = () => setCallEmbed(undefined); const dismiss = () => setCallEmbed(undefined);
// [Gitea #301] Try again: start a fresh embed for the same room with the
// same mic/camera/sound choices (starting one disposes the hung one).
const callEmbed = useCallEmbed();
const startCall = useCallStart(useIsDirectRoom());
const retry = () => {
if (!callEmbed) return;
const { room, control } = callEmbed;
startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound });
};
return ( return (
<Box <Box
className={css.CallViewContent} className={css.CallViewContent}
@@ -173,6 +184,9 @@ function CallLoadErrorMessage() {
The call failed to load. Check your connection and try again. The call failed to load. Check your connection and try again.
</Text> </Text>
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
<Button variant="Primary" size="300" radii="400" onClick={retry} disabled={!callEmbed}>
<Text size="B400">Try again</Text>
</Button>
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}> <Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Back</Text> <Text size="B400">Back</Text>
</Button> </Button>
@@ -1,6 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
foldEdits,
filterGroupsByDateRange, filterGroupsByDateRange,
filterGroupsByMsgType, filterGroupsByMsgType,
filterGroupsByPinned, filterGroupsByPinned,
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
['$2'], ['$2'],
); );
}); });
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
({
rank: 1,
result: {
event_id: eventId,
room_id: '!r:x',
origin_server_ts: ts,
type: 'm.room.message',
content,
},
}) as unknown as Parameters<typeof foldEdits>[0][number];
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
const edit = (id: string, ts: number, body: string) =>
sr(id, ts, {
body: `* ${body}`,
'm.new_content': { body },
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
});
const out = foldEdits([
edit('$e2', 30, 'Tuesday 11am room B'),
sr('$orig', 10, { body: 'Monday 10am room A' }),
edit('$e1', 20, 'Tuesday 10am room A'),
sr('$other', 5, { body: 'meeting notes' }),
]);
assert.deepEqual(
out.map((r) => r.result.event_id),
['$orig', '$other'],
);
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
assert.equal(out[0].result.content.body, 'Monday 10am room A');
});
test('foldEdits: an edit whose original did not match stands in for it', () => {
const out = foldEdits([
sr('$e1', 20, {
'm.new_content': { body: 'x' },
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
}),
]);
assert.equal(out.length, 1);
assert.equal(out[0].result.event_id, '$orig');
});
@@ -96,10 +96,58 @@ export const filterGroupsByDateRange = (
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
}; };
/**
* [Gitea #288] Server search also returns `m.replace` events, so an edited
* message showed once per version. Fold each edit into its message: one row,
* at the first position any version ranked, showing the latest text and
* pointing at the original event (so "jump" lands on the real message).
*/
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
const targetOf = (r: ISearchResult): string => {
const rel = r.result.content?.['m.relates_to'];
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
? rel.event_id
: r.result.event_id;
};
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
const order: string[] = [];
const originals = new Map<string, ISearchResult>();
const latestEdit = new Map<string, ISearchResult>();
results.forEach((r) => {
const target = targetOf(r);
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
if (!isEdit(r)) {
originals.set(target, r);
return;
}
const prev = latestEdit.get(target);
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
latestEdit.set(target, r);
});
return order.map((target) => {
const original = originals.get(target);
const edit = latestEdit.get(target);
const newContent = edit?.result.content?.['m.new_content'];
if (original && !newContent) return original;
if (original) {
return {
...original,
result: {
...original.result,
content: { ...original.result.content, 'm.new_content': newContent },
},
};
}
// Only an edit matched: show it as its message.
const only = edit as ISearchResult;
return { ...only, result: { ...only.result, event_id: target } };
});
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
results.forEach((item) => { foldEdits(results).forEach((item) => {
const roomId = item.result.room_id; const roomId = item.result.room_id;
const resultItem: ResultItem = { const resultItem: ResultItem = {
rank: item.rank, rank: item.rank,
+8 -1
View File
@@ -90,6 +90,7 @@ import {
isMembershipChanged, isMembershipChanged,
reactionOrEditEvent, reactionOrEditEvent,
sendRoomEvent, sendRoomEvent,
isFromFilteredTimelineSet,
} from '../../utils/room'; } from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff'; import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics'; import { tick } from '../../utils/haptics';
@@ -138,7 +139,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 { declinedRing, 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';
@@ -410,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
data, data,
) => { ) => {
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
// [Gitea #259] The media gallery / activity log register filtered timeline
// sets whose events the room re-emits as live: don't count them twice.
if (isFromFilteredTimelineSet(data)) return;
onArrive(mEvent); onArrive(mEvent);
}; };
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => { const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
@@ -2037,6 +2041,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Same rule as ringing: no "Missed call" in voice rooms or space // Same rule as ringing: no "Missed call" in voice rooms or space
// channels, where starting a call is just joining the channel. // channels, where starting a call is just joining the channel.
if (!isRingableRoom(room, direct)) return null; if (!isRingableRoom(room, direct)) return null;
// [Gitea #313] A call we declined isn't a missed call.
const ringRef = mEvent.getRelation()?.event_id;
if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null;
const content = mEvent.getContent(); const content = mEvent.getContent();
const mention = const mention =
content['m.mentions']?.room || content['m.mentions']?.room ||
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds'; import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages'; import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages'; import {
cancelScheduledMessage,
fetchScheduledMessages,
groupScheduledByRoom,
sendScheduledMessageNow,
} from '../../utils/scheduledMessages';
import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduleMessageModal } from './ScheduleMessageModal';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { formatFriendlyDateTime } from '../../utils/datetimeInput'; import { formatFriendlyDateTime } from '../../utils/datetimeInput';
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
// [Gitea #322] The server is the source of truth: messages scheduled on
// another device (or before a logout) show up here and can be cancelled.
// localStorage is only a cache for the first paint; on error we keep it.
useEffect(() => {
let alive = true;
const load = () => {
fetchScheduledMessages(mx)
.then((list) => {
if (alive) setScheduledMessages(groupScheduledByRoom(list));
})
.catch(() => undefined);
};
load();
window.addEventListener('focus', load);
return () => {
alive = false;
window.removeEventListener('focus', load);
};
}, [mx, roomId, setScheduledMessages]);
// Remove scheduled messages whose time has passed // Remove scheduled messages whose time has passed
useEffect(() => { useEffect(() => {
if (messages.length === 0) return undefined; if (messages.length === 0) return undefined;
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils'; import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport'; import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt'; import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import { isFromFilteredTimelineSet } from '../../../utils/room';
type PendingAsk = { type PendingAsk = {
requests: WidgetPermissionRequest[]; requests: WidgetPermissionRequest[];
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
}; };
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => { const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return; if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed); if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev); else feed(ev);
}; };
@@ -9,6 +9,7 @@ import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus'; import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus'; import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
const DISMISS_KEY = 'lotus-status-banner-dismissed'; const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50; const DISMISS_MAX = 50;
@@ -163,7 +164,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
}); });
}, []), }, []),
); );
const syncLost = lostAt !== null; // [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
// watchdog flags it sooner, and Kuma is asked at once.
const stalled = useHomeserverStall(mx);
const setStalled = useSetAtom(homeserverStalledAtom);
useEffect(() => setStalled(stalled), [setStalled, stalled]);
const syncLost = lostAt !== null || stalled;
const status = useServerStatus(page, syncLost); const status = useServerStatus(page, syncLost);
const setAtom = useSetAtom(serverStatusAtom); const setAtom = useSetAtom(serverStatusAtom);
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]); useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
+25 -1
View File
@@ -17,6 +17,8 @@ import {
CALL_SESSION_HEARTBEAT_MS, CALL_SESSION_HEARTBEAT_MS,
clearCallSession, clearCallSession,
decideRejoin, decideRejoin,
othersInCall,
rejoinToastBody,
readCallSession, readCallSession,
writeCallSession, writeCallSession,
} from '../utils/callRejoin'; } from '../utils/callRejoin';
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
}; };
dismissToast(TOAST_ID); dismissToast(TOAST_ID);
clearCallSession(); clearCallSession();
// [Gitea #302] A DM call the other person has since left is over;
// starting it again would ring them, so just say so.
const dm = l.directs.has(room.roomId);
if (
dm &&
!othersInCall(
session.memberships.map((m) => m.userId ?? ''),
mx.getUserId() ?? '',
)
) {
// Own id: clicking a toast removes it by id after its onClick runs.
setToast({
id: `${TOAST_ID}-ended`,
iconSrc: Icons.Phone,
displayName: 'Call ended',
body: `${room.name ?? 'They'} already left the call.`,
roomName: room.name ?? '',
roomId: room.roomId,
});
return;
}
try { try {
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref); (l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
} catch { } catch {
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
.filter((m) => m.userId === mx.getUserId()) .filter((m) => m.userId === mx.getUserId())
.map((m) => m.deviceId), .map((m) => m.deviceId),
memberCount: memberships.length, memberCount: memberships.length,
dm: latest.current.directs.has(room.roomId),
}); });
// The RTC session fills in a moment after sync; keep waiting while it is // The RTC session fills in a moment after sync; keep waiting while it is
// empty unless this is the final (timed-out) look. // empty unless this is the final (timed-out) look.
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
id: TOAST_ID, id: TOAST_ID,
iconSrc: Icons.Phone, iconSrc: Icons.Phone,
displayName: 'Rejoin voice?', displayName: 'Rejoin voice?',
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`, body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
roomName: room.name ?? '', roomName: room.name ?? '',
roomId: room.roomId, roomId: room.roomId,
sticky: true, sticky: true,
+16
View File
@@ -0,0 +1,16 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SyncState } from 'matrix-js-sdk';
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
const now = 1_000_000;
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
assert.equal(
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
false,
'SyncStatus already shows that',
);
assert.equal(syncOverdue(now - 120_000, now, null), false);
});
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { atom } from 'jotai';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
/**
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
* /sync that's overdue and confirms with a short probe of /versions.
*/
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
const CHECK_EVERY_MS = 10_000;
const PROBE_TIMEOUT_MS = 5_000;
export const homeserverStalledAtom = atom(false);
/** Pure: should we probe, given the last sync response time? */
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
export function useHomeserverStall(mx: MatrixClient): boolean {
const [stalled, setStalled] = useState(false);
useEffect(() => {
let lastSyncAt = Date.now();
let probing = false;
const onSync = () => {
lastSyncAt = Date.now();
setStalled(false);
};
const check = async () => {
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
probing = true;
let answered = false;
try {
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
cache: 'no-store',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
answered = res.ok;
} catch {
answered = false;
}
probing = false;
// Still overdue and the server didn't answer a trivial request: stalled.
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
if (answered) setStalled(false);
};
mx.on(ClientEvent.Sync, onSync);
const id = setInterval(check, CHECK_EVERY_MS);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
clearInterval(id);
};
}, [mx]);
return stalled;
}
@@ -49,6 +49,7 @@ import {
getNotificationType, getNotificationType,
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room'; import { NotificationType } from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
@@ -684,6 +685,7 @@ function MessageNotifications() {
data, data,
) => { ) => {
if (mx.getSyncState() !== 'SYNCING') return; if (mx.getSyncState() !== 'SYNCING') return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
if ( if (
!room || !room ||
@@ -1126,6 +1128,7 @@ function ClockSkewFeature() {
data, data,
) => { ) => {
if (!data.liveEvent) return; if (!data.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// Only events our homeserver stamped: a federated event's // Only events our homeserver stamped: a federated event's
// origin_server_ts is the other server's clock. // origin_server_ts is the other server's clock.
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':'); const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
+31 -7
View File
@@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { serverStatusAtom } from '../../state/serverStatus'; import { serverStatusAtom } from '../../state/serverStatus';
import { serverConfirmedUpSince } from '../../utils/kumaStatus'; import { serverConfirmedUpSince } from '../../utils/kumaStatus';
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
type StateData = { type StateData = {
current: SyncState | null; current: SyncState | null;
@@ -17,6 +18,7 @@ type SyncStatusProps = {
}; };
export function SyncStatus({ mx }: SyncStatusProps) { export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom); const serverStatus = useAtomValue(serverStatusAtom);
const stalled = useAtomValue(homeserverStalledAtom);
const [stateData, setStateData] = useState<StateData>({ const [stateData, setStateData] = useState<StateData>({
current: null, current: null,
previous: undefined, previous: undefined,
@@ -34,6 +36,35 @@ export function SyncStatus({ mx }: SyncStatusProps) {
}, []), }, []),
); );
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
role="status"
aria-live="polite"
>
<Text size="L400">
Lotus Chat&apos;s server isn&apos;t responding. Messages will send when it&apos;s back.
Still trying…
</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if ( if (
(stateData.current === SyncState.Prepared || (stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing || stateData.current === SyncState.Syncing ||
@@ -57,13 +88,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
); );
} }
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
if (stateData.current === SyncState.Reconnecting && !serverDown) { if (stateData.current === SyncState.Reconnecting && !serverDown) {
return ( return (
<Box direction="Column" shrink="No"> <Box direction="Column" shrink="No">
+54 -3
View File
@@ -255,6 +255,8 @@ function HomeCustomSection({
return (localName ?? matrixName).toLowerCase().includes(query); return (localName ?? matrixName).toLowerCase().includes(query);
}); });
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]); }, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// [Gitea #289] While filtering, a section with no matches is hidden.
if (filterQuery.trim() && items.length === 0) return null;
// Sections are user-curated and small, so they render plainly — no // Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller). // virtualizer per section (a late-mounted one never measured its scroller).
return ( return (
@@ -443,6 +445,26 @@ export function Home() {
}); });
}, [mx, sortedRooms, filterQuery]); }, [mx, sortedRooms, filterQuery]);
// [Gitea #289] While filtering, hide empty sections; with no match at all,
// say so instead of leaving bare headers.
const filtering = filterQuery.trim().length > 0;
const customSectionMatch = useMemo(() => {
if (!filtering) return true;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return customSections.some((section) =>
section.rooms.some((rId) =>
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
),
);
}, [mx, customSections, filtering, filterQuery]);
const noFilterMatch =
filtering &&
!customSectionMatch &&
filteredFavoriteRooms.length === 0 &&
filteredRooms.length === 0 &&
filteredLowPriorityRooms.length === 0;
const favVirtualizer = useVirtualizer({ const favVirtualizer = useVirtualizer({
count: filteredFavoriteRooms.length, count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
@@ -580,7 +602,7 @@ export function Home() {
/> />
</Box> </Box>
</NavCategory> </NavCategory>
{favoriteRooms.length > 0 && ( {favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
@@ -629,6 +651,30 @@ export function Home() {
notificationPreferences={notificationPreferences} notificationPreferences={notificationPreferences}
/> />
))} ))}
{noFilterMatch && (
<NavCategory>
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
>
<Text size="T300" priority="300" align="Center">
No rooms match &ldquo;{filterQuery.trim()}&rdquo;
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFilterQuery('')}
>
<Text size="B300">Clear filter</Text>
</Button>
</Box>
</NavCategory>
)}
{(!filtering || filteredRooms.length > 0) && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
@@ -749,7 +795,11 @@ export function Home() {
const selected = selectedRoomId === roomId; const selected = selectedRoomId === roomId;
return ( return (
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}> <VirtualTile
virtualItem={vItem}
key={roomId}
ref={virtualizer.measureElement}
>
<RoomNavItem <RoomNavItem
room={room} room={room}
selected={selected} selected={selected}
@@ -764,7 +814,8 @@ export function Home() {
})} })}
</div> </div>
</NavCategory> </NavCategory>
{lowPriorityRooms.length > 0 && ( )}
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
<NavCategory> <NavCategory>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
+7
View File
@@ -91,6 +91,13 @@ export class CallControl extends EventEmitter implements CallControlState {
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super(); super();
// [Gitea #316] More than 10 call features subscribe to state_update at once
// (call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
// announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC …).
// Hitting the default limit logged a MaxListenersExceededWarning, and the
// console then kept this CallControl — and through it the whole Element Call
// document — alive: ~11 MB leaked per call. These are not leaks, so lift it.
this.setMaxListeners(100);
this.state = state; this.state = state;
this.call = call; this.call = call;
+14 -1
View File
@@ -28,7 +28,10 @@ import {
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { getCallCapabilities } from './utils'; import { getCallCapabilities } from './utils';
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
import { dedupeInFlight } from '../../utils/inFlight';
/** A little longer than Element Call's own 2 s timeout for a restart. */
const RESTART_TIMEOUT_MS = 5000;
export class CallWidgetDriver extends WidgetDriver { export class CallWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>; private allowedCapabilities: Set<Capability>;
@@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver {
await this.mx._unstable_cancelScheduledDelayedEvent(delayId); await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
} }
// [Gitea #318] While the homeserver is stalled, Element Call times out each
// restart locally after 2 s and asks again, so requests for the same delay
// id piled up and hit the server together on recovery (404s, then 429s).
// Share one in-flight restart per id, and give it a timeout of its own.
private readonly restartOnce = dedupeInFlight<unknown>();
public async restartScheduledDelayedEvent(delayId: string): Promise<void> { public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
await this.mx._unstable_restartScheduledDelayedEvent(delayId); await this.restartOnce(delayId, () =>
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
localTimeoutMs: RESTART_TIMEOUT_MS,
}),
);
} }
public async sendScheduledDelayedEvent(delayId: string): Promise<void> { public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
+2
View File
@@ -24,6 +24,7 @@ import {
getUnreadInfo, getUnreadInfo,
getUnreadInfos, getUnreadInfos,
isNotificationEvent, isNotificationEvent,
isFromFilteredTimelineSet,
} from '../../utils/room'; } from '../../utils/room';
import { roomToParentsAtom } from './roomToParents'; import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
data: IRoomTimelineData, data: IRoomTimelineData,
) => { ) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return; if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
// Single-owner rule: thread replies drive the room badge via // Single-owner rule: thread replies drive the room badge via
// RoomEvent.UnreadNotifications below — ignore them here so the count is // RoomEvent.UnreadNotifications below — ignore them here so the count is
// never double-driven / mis-attributed to the main timeline. // never double-driven / mis-attributed to the main timeline.
+2 -2
View File
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
/** /**
* Global atom: Map<roomId, ScheduledMessage[]> * Global atom: Map<roomId, ScheduledMessage[]>
* Stores all locally-tracked scheduled messages across rooms. * Stores all locally-tracked scheduled messages across rooms.
* MSC4140 has no list endpoint, so we track them ourselves. * Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts
* Backed by localStorage so scheduled messages survive page refreshes. * (Gitea #322); localStorage is a cache for the first paint.
*/ */
export const scheduledMessagesAtom = atom( export const scheduledMessagesAtom = atom(
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))), (get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
+25 -1
View File
@@ -1,6 +1,12 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin'; import {
CALL_REJOIN_MAX_AGE_MS,
decideRejoin,
othersInCall,
rejoinToastBody,
RejoinContext,
} from './callRejoin';
const base: RejoinContext = { const base: RejoinContext = {
record: { record: {
@@ -54,3 +60,21 @@ describe('decideRejoin', () => {
}); });
}); });
}); });
it('decideRejoin: DM calls are never auto-rejoined, only offered (#302)', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'auto', dm: true }), {
action: 'ask',
roomId: '!r',
});
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('rejoinToastBody and othersInCall (#302)', () => {
assert.equal(
rejoinToastBody('Bob Builder', true),
'You were in a call with Bob Builder before the restart. Tap to rejoin.',
);
assert.match(rejoinToastBody('Voice Lounge', false), /in the call in Voice Lounge/);
assert.equal(othersInCall(['@me:x'], '@me:x'), false);
assert.equal(othersInCall(['@me:x', '@bob:x'], '@me:x'), true);
});
+15
View File
@@ -67,6 +67,8 @@ export type RejoinContext = {
ownMemberDevices: string[]; ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */ /** Number of call memberships in the room, ours included. */
memberCount: number; memberCount: number;
/** A DM call: rejoining one may ring the other person. */
dm?: boolean;
}; };
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string }; export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
@@ -83,5 +85,18 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
// Nobody there any more (our own stale membership doesn't count). // Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length; const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' }; if (others <= 0) return { action: 'none' };
// [Gitea #302] Never rejoin a DM call on our own: if the other person has
// just left, starting it again would ring them. Ask instead.
if (ctx.dm && mode === 'auto') return { action: 'ask', roomId: record.roomId };
return { action: mode, roomId: record.roomId }; return { action: mode, roomId: record.roomId };
} }
/** Pure: whether anyone but us is still in the call (by call membership). */
export const othersInCall = (memberUserIds: string[], myUserId: string): boolean =>
memberUserIds.some((u) => u !== myUserId);
/** The rejoin toast's text; DMs name the person rather than "the call in <room>". */
export const rejoinToastBody = (roomName: string | undefined, dm: boolean): string =>
dm
? `You were in a call with ${roomName ?? 'them'} before the restart. Tap to rejoin.`
: `You were in the call in ${roomName ?? 'a room'} before the restart. Tap to rejoin.`;
+15
View File
@@ -67,3 +67,18 @@ test('declinedRing finds our own decline of that ring only (#312)', () => {
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline"); assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
assert.equal(declinedRing(room, '$ring3', '@me:x'), false); assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
}); });
test('declinedRing matches a decline of the notification or of the membership (#313)', () => {
const decline = (rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => '@me:x',
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('$notification')]);
assert.equal(declinedRing(room, ['$membership', '$notification'], '@me:x'), true);
assert.equal(declinedRing(room, ['$membership'], '@me:x'), false);
assert.equal(
declinedRing(stateRoom([], [decline('$membership')]), ['$membership', undefined], '@me:x'),
true,
);
});
+13 -2
View File
@@ -40,7 +40,18 @@ export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
* declined it (our own RTCDecline relates to it) — e.g. replayed together with * 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. * the ring in the first sync after a restart, before any dismiss listener.
*/ */
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean => export const declinedRing = (
room: Room,
ringIds: string | Array<string | undefined>,
myUserId: string,
): boolean => {
// [Gitea #313] A decline references the notification event (MSC4310); older
// Lotus builds referenced the call membership instead. Match either.
const ids = new Set((Array.isArray(ringIds) ? ringIds : [ringIds]).filter(Boolean));
return declinedAny(room, ids, myUserId);
};
const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string): boolean =>
room room
.getLiveTimeline() .getLiveTimeline()
.getEvents() .getEvents()
@@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string):
(ev) => (ev) =>
ev.getType() === 'org.matrix.msc4310.rtc.decline' && ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
ev.getSender() === myUserId && ev.getSender() === myUserId &&
ev.getRelation()?.event_id === refEventId, ids.has(ev.getRelation()?.event_id),
); );
+20
View File
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { IRoomTimelineData } from 'matrix-js-sdk';
import { isFromFilteredTimelineSet } from './room';
const data = (filter: unknown): IRoomTimelineData =>
({
liveEvent: true,
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
}) as unknown as IRoomTimelineData;
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
assert.equal(isFromFilteredTimelineSet(undefined), false);
assert.equal(
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
false,
);
});
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dedupeInFlight } from './inFlight';
test('dedupeInFlight: one request per key while pending, a new one after (#318)', async () => {
const once = dedupeInFlight<number>();
let calls = 0;
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
const run = async () => {
calls += 1;
await gate;
return calls;
};
const a = once('d1', run);
const b = once('d1', run);
const c = once('d2', run);
assert.equal(a, b);
release();
assert.deepEqual(await Promise.all([a, b, c]), [2, 2, 2]);
assert.equal(calls, 2);
await once('d1', run);
assert.equal(calls, 3, 'settled requests are not reused');
});
test('dedupeInFlight: a rejection clears the key', async () => {
const once = dedupeInFlight<void>();
await assert.rejects(once('k', () => Promise.reject(new Error('boom'))));
await once('k', () => Promise.resolve());
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Share one in-flight promise per key: callers that ask again while a request
* for the same key is still pending get that request instead of a new one.
*/
export function dedupeInFlight<T>(): (key: string, run: () => Promise<T>) => Promise<T> {
const pending = new Map<string, Promise<T>>();
return (key, run) => {
const existing = pending.get(key);
if (existing) return existing;
const p = run().finally(() => pending.delete(key));
pending.set(key, p);
return p;
};
}
+12
View File
@@ -20,6 +20,7 @@ import {
Room, Room,
RoomMember, RoomMember,
TimelineEvents, TimelineEvents,
IRoomTimelineData,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
@@ -847,3 +848,14 @@ export const guessPerfectParent = (
return perfectParent; return perfectParent;
}; };
/**
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
* set. The media gallery and activity log register filtered sets on the room
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
* liveEvent: true, so listeners that mean "a new event in the room" saw each
* matching event twice (the timeline window drifted, notifications doubled).
* The room's main and thread sets have no filter.
*/
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
!!data?.timeline?.getTimelineSet()?.getFilter();
+60
View File
@@ -6,6 +6,8 @@ import {
scheduleMessage, scheduleMessage,
cancelScheduledMessage, cancelScheduledMessage,
restartScheduledMessage, restartScheduledMessage,
parseDelayedEvents,
groupScheduledByRoom,
} from './scheduledMessages'; } from './scheduledMessages';
// Minimal MatrixClient stub: records every authedRequest call and returns a // Minimal MatrixClient stub: records every authedRequest call and returns a
@@ -119,3 +121,61 @@ test('cancel/restart url-encode the delay id', async () => {
await cancelScheduledMessage(mx, 'a/b c'); await cancelScheduledMessage(mx, 'a/b c');
assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c'); assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c');
}); });
test('parseDelayedEvents: Synapse shape, messages only, sendAt from running_since + delay (#322)', () => {
const res = {
delayed_events: [
{
delay_id: 'syd_a',
room_id: '!r:x',
type: 'm.room.message',
content: { msgtype: 'm.text', body: 'hi' },
delay: 60000,
running_since: 1000,
},
{
delay_id: 'syd_call',
room_id: '!r:x',
type: 'org.matrix.msc3401.call.member',
state_key: '_@a:x_D',
content: {},
delay: 8000,
running_since: 1000,
},
{ delay_id: 'syd_bad', room_id: '!r:x', type: 'm.room.message', content: {} },
null,
],
};
assert.deepEqual(parseDelayedEvents(res), [
{ delayId: 'syd_a', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' }, sendAt: 61000 },
]);
});
test('parseDelayedEvents: MSC `scheduled` shape and garbage', () => {
const item = {
delay_id: 'd',
room_id: '!r:x',
type: 'm.room.message',
content: { body: 'b' },
delay_ms: 5,
delayed_since_ts: 10,
};
assert.equal(parseDelayedEvents({ scheduled: [item] })[0].sendAt, 15);
assert.deepEqual(parseDelayedEvents(undefined), []);
assert.deepEqual(parseDelayedEvents({ delayed_events: 'nope' }), []);
});
test('groupScheduledByRoom: groups and sorts by sendAt', () => {
const m = (delayId: string, roomId: string, sendAt: number) => ({
delayId,
roomId,
content: {},
sendAt,
});
const g = groupScheduledByRoom([m('b', '!1', 20), m('a', '!1', 10), m('c', '!2', 5)]);
assert.deepEqual(
g.get('!1')?.map((x) => x.delayId),
['a', 'b'],
);
assert.equal(g.get('!2')?.length, 1);
});
+59
View File
@@ -1,4 +1,5 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk'; import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/** /**
* Schedule a message via MSC4140 (delayed messages). * Schedule a message via MSC4140 (delayed messages).
@@ -76,3 +77,61 @@ export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string)
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, { prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
); );
} }
type DelayedEventItem = {
delay_id?: unknown;
room_id?: unknown;
type?: unknown;
state_key?: unknown;
content?: unknown;
delay?: unknown;
delay_ms?: unknown;
running_since?: unknown;
delayed_since_ts?: unknown;
};
/**
* [Gitea #322] Pure: the user's pending scheduled messages from an MSC4140
* `GET /delayed_events` response. Synapse answers `{ delayed_events: [...] }`;
* the current MSC shape is `{ scheduled: [...] }`. Call-membership delayed
* events share the endpoint, so only non-state `m.room.message`s are kept.
*/
export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
if (!res || typeof res !== 'object') return [];
const r = res as { delayed_events?: unknown; scheduled?: unknown };
const list = Array.isArray(r.delayed_events) ? r.delayed_events : r.scheduled;
if (!Array.isArray(list)) return [];
const out: ScheduledMessage[] = [];
list.forEach((raw: DelayedEventItem) => {
if (!raw || typeof raw !== 'object') return;
if (raw.type !== 'm.room.message' || raw.state_key !== undefined) return;
if (typeof raw.delay_id !== 'string' || typeof raw.room_id !== 'string') return;
if (!raw.content || typeof raw.content !== 'object') return;
const delay = Number(raw.delay ?? raw.delay_ms);
const since = Number(raw.running_since ?? raw.delayed_since_ts);
if (!Number.isFinite(delay) || !Number.isFinite(since)) return;
out.push({
delayId: raw.delay_id,
roomId: raw.room_id,
content: raw.content as IContent,
sendAt: since + delay,
});
});
return out;
}
/** The user's pending scheduled messages, as the server has them. */
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
});
return parseDelayedEvents(res);
}
/** Group by room, the shape of `scheduledMessagesAtom`. */
export function groupScheduledByRoom(msgs: ScheduledMessage[]): Map<string, ScheduledMessage[]> {
const map = new Map<string, ScheduledMessage[]>();
msgs.forEach((m) => map.set(m.roomId, [...(map.get(m.roomId) ?? []), m]));
map.forEach((list) => list.sort((a, b) => a.sendAt - b.sendAt));
return map;
}