From 5b5fbdb7b70936c539c4d92c0bd175c2cdcf8954 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 21:42:47 -0400 Subject: [PATCH 01/37] fix(timeline): media gallery / activity log no longer make the timeline drop messages (#259) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 4 +++- src/app/features/room/RoomTimeline.tsx | 4 ++++ .../features/room/widgets/RoomWidgetView.tsx | 2 ++ src/app/pages/client/ClientNonUIFeatures.tsx | 3 +++ src/app/state/room/roomToUnread.ts | 2 ++ src/app/utils/filteredTimeline.test.ts | 20 +++++++++++++++++++ src/app/utils/room.ts | 12 +++++++++++ 7 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/app/utils/filteredTimeline.test.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 0660d27e8..1019be8d5 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -71,7 +71,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; import { useSetting } from '../state/hooks/settings'; import { useCallPreferences } from '../state/hooks/callPreferences'; import { settingsAtom } from '../state/settings'; -import { getStateEvent, getMemberName } from '../utils/room'; +import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room'; import { isRingableRoom } from '../utils/callRing'; import { StateEvent } from '../../types/matrix/room'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; @@ -494,6 +494,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( async (event, room, toStartOfTimeline, removed, data) => { + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] // only process rtc notification reference events. // we do not want to wait to decrypt all events. if (event.getRelation()?.rel_type !== RelationType.Reference) return; @@ -639,6 +640,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) data, ) => { if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) { const related = event.getRelation()?.event_id; if (!related || related === refEventId) dismiss(); // declined elsewhere diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fd69479c4..49cd21fc1 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -90,6 +90,7 @@ import { isMembershipChanged, reactionOrEditEvent, sendRoomEvent, + isFromFilteredTimelineSet, } from '../../utils/room'; import { getLastEditDiff } from '../../utils/editDiff'; import { tick } from '../../utils/haptics'; @@ -410,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) data, ) => { 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); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => { diff --git a/src/app/features/room/widgets/RoomWidgetView.tsx b/src/app/features/room/widgets/RoomWidgetView.tsx index 47a16508a..99c460cd8 100644 --- a/src/app/features/room/widgets/RoomWidgetView.tsx +++ b/src/app/features/room/widgets/RoomWidgetView.tsx @@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver'; import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils'; import { restrictWidgetMessages } from '../../../plugins/widgetTransport'; import { WidgetPermissionPrompt } from './WidgetPermissionPrompt'; +import { isFromFilteredTimelineSet } from '../../../utils/room'; type PendingAsk = { requests: WidgetPermissionRequest[]; @@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) { }; const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => { if (toStart || removed || !data?.liveEvent) return; + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed); else feed(ev); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 84e3e9f8f..0dfdfc829 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -49,6 +49,7 @@ import { getNotificationType, getUnreadInfo, isNotificationEvent, + isFromFilteredTimelineSet, } from '../../utils/room'; import { NotificationType } from '../../../types/matrix/room'; import { mxcUrlToHttp } from '../../utils/matrix'; @@ -679,6 +680,7 @@ function MessageNotifications() { data, ) => { if (mx.getSyncState() !== 'SYNCING') return; + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; if ( !room || @@ -1118,6 +1120,7 @@ function ClockSkewFeature() { data, ) => { if (!data.liveEvent) return; + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] // Only events our homeserver stamped: a federated event's // origin_server_ts is the other server's clock. const senderServer = mEvent.getSender()?.split(':').slice(1).join(':'); diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 535574af8..c3b31c5d9 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -24,6 +24,7 @@ import { getUnreadInfo, getUnreadInfos, isNotificationEvent, + isFromFilteredTimelineSet, } from '../../utils/room'; import { roomToParentsAtom } from './roomToParents'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; @@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo data: IRoomTimelineData, ) => { 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 // RoomEvent.UnreadNotifications below — ignore them here so the count is // never double-driven / mis-attributed to the main timeline. diff --git a/src/app/utils/filteredTimeline.test.ts b/src/app/utils/filteredTimeline.test.ts new file mode 100644 index 000000000..12405c508 --- /dev/null +++ b/src/app/utils/filteredTimeline.test.ts @@ -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, + ); +}); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index a86a2bf10..41071fe52 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -20,6 +20,7 @@ import { Room, RoomMember, TimelineEvents, + IRoomTimelineData, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events'; @@ -847,3 +848,14 @@ export const guessPerfectParent = ( 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(); From a425aa3572e9507bb2234671c7e2612f3c530e54 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 18:01:06 -0400 Subject: [PATCH 02/37] fix(calls): Reject sends the decline to the ring notification (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 26 +++++++++++++++++++----- src/app/features/room/RoomTimeline.tsx | 5 ++++- src/app/utils/callRing.test.ts | 15 ++++++++++++++ src/app/utils/callRing.ts | 15 ++++++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 17e054da5..4d3b614bf 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -111,6 +111,8 @@ type IncomingCallInfo = { intent?: string; notificationType: RTCNotificationType; refEventId: string; + /** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */ + notificationEventId?: string; }; type IncomingCallProps = { dm: boolean; @@ -283,7 +285,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr fill="Soft" size="400" radii="400" - onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} + onClick={() => + dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore() + } before={} > @@ -455,7 +459,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming size="300" radii="300" outlined - onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} + onClick={() => + dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore() + } before={} > @@ -596,6 +602,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) : undefined, notificationType, refEventId, + notificationEventId: event.getId(), }; // [Gitea #312] Don't ring for a call that is already over or handled: after @@ -604,7 +611,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) // stale RTC session). The caller's membership can arrive just after the // ring, so an absent one gets a short grace period before giving up. const myUserId = mx.getSafeUserId(); - if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { + if ( + declinedRing(room, [refEventId, event.getId()], myUserId) || + hasActiveCallMembership(room, myUserId) + ) { return; } if (hasActiveCallMembership(room, sender)) { @@ -613,7 +623,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) } setTimeout(() => { 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; } if (hasActiveCallMembership(room, sender)) setCallInfo(info); @@ -685,7 +698,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) { 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); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2500feda6..9512232e1 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -138,7 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useIsDirectRoom } from '../../hooks/useRoom'; -import { isRingableRoom } from '../../utils/callRing'; +import { declinedRing, isRingableRoom } from '../../utils/callRing'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -2037,6 +2037,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Same rule as ringing: no "Missed call" in voice rooms or space // channels, where starting a call is just joining the channel. if (!isRingableRoom(room, direct)) return null; + // [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 mention = content['m.mentions']?.room || diff --git a/src/app/utils/callRing.test.ts b/src/app/utils/callRing.test.ts index ac96ae3ee..bf94215a9 100644 --- a/src/app/utils/callRing.test.ts +++ b/src/app/utils/callRing.test.ts @@ -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, '$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, + ); +}); diff --git a/src/app/utils/callRing.ts b/src/app/utils/callRing.ts index adbdd5e42..370ca1bcb 100644 --- a/src/app/utils/callRing.ts +++ b/src/app/utils/callRing.ts @@ -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 * 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, + 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, myUserId: string): boolean => room .getLiveTimeline() .getEvents() @@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string): (ev) => ev.getType() === 'org.matrix.msc4310.rtc.decline' && ev.getSender() === myUserId && - ev.getRelation()?.event_id === refEventId, + ids.has(ev.getRelation()?.event_id), ); From 46d6570df3f6685430dc746586b08211b87e9dfd Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 18:57:22 -0400 Subject: [PATCH 03/37] fix(calls): lift CallControl's listener limit so ended calls can be freed (#316) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/plugins/call/CallControl.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index da734785c..c37efc26b 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -91,6 +91,13 @@ export class CallControl extends EventEmitter implements CallControlState { constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { 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.call = call; From e8fd0a1112a4d94b25073749ee4b9b3c8f48e786 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:29:01 -0400 Subject: [PATCH 04/37] fix(status): show a strip when the homeserver hangs, not 110 s later (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../server-status/ServerStatusBanner.tsx | 8 ++- src/app/hooks/useHomeserverStall.test.ts | 16 ++++++ src/app/hooks/useHomeserverStall.ts | 57 +++++++++++++++++++ src/app/pages/client/SyncStatus.tsx | 38 ++++++++++--- 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 src/app/hooks/useHomeserverStall.test.ts create mode 100644 src/app/hooks/useHomeserverStall.ts diff --git a/src/app/features/server-status/ServerStatusBanner.tsx b/src/app/features/server-status/ServerStatusBanner.tsx index b59e45fde..f98b62fe1 100644 --- a/src/app/features/server-status/ServerStatusBanner.tsx +++ b/src/app/features/server-status/ServerStatusBanner.tsx @@ -9,6 +9,7 @@ import { useServerStatus } from '../../hooks/useServerStatus'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { serverStatusAtom } from '../../state/serverStatus'; import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus'; +import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall'; const DISMISS_KEY = 'lotus-status-banner-dismissed'; const DISMISS_MAX = 50; @@ -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 setAtom = useSetAtom(serverStatusAtom); useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]); diff --git a/src/app/hooks/useHomeserverStall.test.ts b/src/app/hooks/useHomeserverStall.test.ts new file mode 100644 index 000000000..4440f1c3c --- /dev/null +++ b/src/app/hooks/useHomeserverStall.test.ts @@ -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); +}); diff --git a/src/app/hooks/useHomeserverStall.ts b/src/app/hooks/useHomeserverStall.ts new file mode 100644 index 000000000..458bd1560 --- /dev/null +++ b/src/app/hooks/useHomeserverStall.ts @@ -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; +} diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 25d1042af..a6ae01056 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { serverStatusAtom } from '../../state/serverStatus'; import { serverConfirmedUpSince } from '../../utils/kumaStatus'; +import { homeserverStalledAtom } from '../../hooks/useHomeserverStall'; type StateData = { current: SyncState | null; @@ -17,6 +18,7 @@ type SyncStatusProps = { }; export function SyncStatus({ mx }: SyncStatusProps) { const serverStatus = useAtomValue(serverStatusAtom); + const stalled = useAtomValue(homeserverStalledAtom); const [stateData, setStateData] = useState({ current: null, 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 ( + + + + Lotus Chat's server isn't responding. Messages will send when it's back. + Still trying… + + + + + ); + } + if ( (stateData.current === SyncState.Prepared || 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) { return ( From 4ddcda122ab4c92b429a3f72d7c169c510779ef5 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:35:06 -0400 Subject: [PATCH 05/37] fix(calls): one delayed-event restart in flight per id, with a timeout (#318) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/plugins/call/CallWidgetDriver.ts | 15 ++++++++++- src/app/utils/inFlight.test.ts | 32 ++++++++++++++++++++++++ src/app/utils/inFlight.ts | 14 +++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/app/utils/inFlight.test.ts create mode 100644 src/app/utils/inFlight.ts diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index f0d6e5a2f..3600d6dc8 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -28,7 +28,10 @@ import { } from 'matrix-js-sdk'; import { getCallCapabilities } from './utils'; 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 { private allowedCapabilities: Set; @@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver { 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(); + public async restartScheduledDelayedEvent(delayId: string): Promise { - 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 { diff --git a/src/app/utils/inFlight.test.ts b/src/app/utils/inFlight.test.ts new file mode 100644 index 000000000..832366613 --- /dev/null +++ b/src/app/utils/inFlight.test.ts @@ -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(); + let calls = 0; + let release!: () => void; + const gate = new Promise((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(); + await assert.rejects(once('k', () => Promise.reject(new Error('boom')))); + await once('k', () => Promise.resolve()); +}); diff --git a/src/app/utils/inFlight.ts b/src/app/utils/inFlight.ts new file mode 100644 index 000000000..a1fd10080 --- /dev/null +++ b/src/app/utils/inFlight.ts @@ -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(): (key: string, run: () => Promise) => Promise { + const pending = new Map>(); + 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; + }; +} From 8bbb436ca7d2375c2ad718a091f268e800fd3762 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:40:03 -0400 Subject: [PATCH 06/37] fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/LogoutDialog.tsx | 68 +++++++++++++++---- .../features/room/ScheduledMessagesTray.tsx | 27 +++++++- src/app/state/scheduledMessages.ts | 4 +- src/app/utils/scheduledMessages.test.ts | 60 ++++++++++++++++ src/app/utils/scheduledMessages.ts | 59 ++++++++++++++++ 5 files changed, 201 insertions(+), 17 deletions(-) diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index 921e595ec..0dc15ae98 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -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 { useAtom } from 'jotai'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; @@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient'; import { useModalStyle } from '../hooks/useModalStyle'; import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { InfoCard } from './info-card'; +import { ScheduledMessage } from '../state/scheduledMessages'; +import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages'; import { useDeviceVerificationStatus, VerificationStatus, @@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef( const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom); - const [logoutState, logout] = useAsyncCallback( - useCallback(async () => { - // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC - // membership (expires: 4 h) stays behind as a ghost participant and - // everyone else sees you "in call" until it times out. - if (callEmbed && callEmbed.joined && !callEmbed.disposed) { - await hangupCallAndWait(mx, callEmbed); - setCallEmbed(undefined); - } - await logoutClient(mx); - }, [mx, callEmbed, setCallEmbed]), + // [Gitea #322] Scheduled messages live on the server and still send after + // logout, where no client would show them. Say so, and offer to cancel. + const [scheduled, setScheduled] = useState([]); + useEffect(() => { + let alive = true; + fetchScheduledMessages(mx) + .then((list) => alive && setScheduled(list)) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [mx]); + + const [logoutState, logout] = useAsyncCallback( + 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 + // membership (expires: 4 h) stays behind as a ghost participant and + // everyone else sees you "in call" until it times out. + if (callEmbed && callEmbed.joined && !callEmbed.disposed) { + await hangupCallAndWait(mx, callEmbed); + setCallEmbed(undefined); + } + await logoutClient(mx); + }, + [mx, callEmbed, setCallEmbed, scheduled], + ), ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; @@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef( description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages." /> ))} + {scheduled.length > 0 && ( + + )} You’re about to log out. Are you sure? {logoutState.status === AsyncStatus.Error && ( @@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef( + {scheduled.length > 0 && ( + + )} diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index 46866bd22..c66563a1b 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk'; import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; 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 { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { formatFriendlyDateTime } from '../../utils/datetimeInput'; @@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { 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 useEffect(() => { if (messages.length === 0) return undefined; diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts index ddceb704b..6eaf0eb5b 100644 --- a/src/app/state/scheduledMessages.ts +++ b/src/app/state/scheduledMessages.ts @@ -21,8 +21,8 @@ const internalAtom = atomWithStorage>( /** * Global atom: Map * Stores all locally-tracked scheduled messages across rooms. - * MSC4140 has no list endpoint, so we track them ourselves. - * Backed by localStorage so scheduled messages survive page refreshes. + * Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts + * (Gitea #322); localStorage is a cache for the first paint. */ export const scheduledMessagesAtom = atom( (get): Map => new Map(Object.entries(get(internalAtom))), diff --git a/src/app/utils/scheduledMessages.test.ts b/src/app/utils/scheduledMessages.test.ts index 69869ba31..c3328fd5f 100644 --- a/src/app/utils/scheduledMessages.test.ts +++ b/src/app/utils/scheduledMessages.test.ts @@ -6,6 +6,8 @@ import { scheduleMessage, cancelScheduledMessage, restartScheduledMessage, + parseDelayedEvents, + groupScheduledByRoom, } from './scheduledMessages'; // 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'); 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); +}); diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index 3f157ab1a..a8acde312 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -1,4 +1,5 @@ import { IContent, MatrixClient, Method } from 'matrix-js-sdk'; +import type { ScheduledMessage } from '../state/scheduledMessages'; /** * 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' }, ); } + +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 { + 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 { + const map = new Map(); + 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; +} From f9172cc33911e66ff3901c38082a7ca6b5d90985 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:54:01 -0400 Subject: [PATCH 07/37] fix(polls): you can vote on a poll you just created (#284) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/local-homeserver.spec.ts | 46 +++++++++++++++ .../message/content/PollContent.tsx | 58 +++++++++++++------ 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/e2e/local-homeserver.spec.ts b/e2e/local-homeserver.spec.ts index 0df5c0d1a..7dcbd6f63 100644 --- a/e2e/local-homeserver.spec.ts +++ b/e2e/local-homeserver.spec.ts @@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => { 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 }) => { const room = await createRoom(alice, 'Scroll Room'); await Array.from({ length: 40 }).reduce>( diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 87a194970..819ecc19e 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -1,7 +1,7 @@ import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds'; 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 { getMemberName, sendRoomEvent } from '../../../utils/room'; import { @@ -99,7 +99,21 @@ export function PollContent({ }) { const mx = useMatrixClient(); 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 parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]); @@ -192,8 +206,9 @@ export function PollContent({ const isMultiple = maxSelections > 1; const showResults = resultsVisible(isUndisclosed, isEnded); - const canVote = !!roomId && !!eventId && !isEnded; - const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact); + const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded; + const canEnd = + !!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact); const myVotes = pending ?? tally.myVotes; const { counts, total, voters } = tally; @@ -219,25 +234,34 @@ export function PollContent({ setPending(next); // Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader // accepts both namespaces). - sendRoomEvent(mx, roomId, null, 'm.poll.response', { - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.selections': Array.from(next), - }).catch(() => setPending(null)); + // The SDK can throw synchronously (e.g. a relation to a pending event). + try { + sendRoomEvent(mx, roomId, null, 'm.poll.response', { + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.selections': Array.from(next), + }).catch(() => setPending(null)); + } catch { + setPending(null); + } }; const handleEndPoll = () => { if (!roomId || !eventId || ending) return; setEnding(true); - sendRoomEvent(mx, roomId, null, 'm.poll.end', { - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.poll.end': {}, - 'm.text': 'The poll has ended.', - }) - .then(() => { - setConfirmEnd(false); - setEnding(false); + try { + sendRoomEvent(mx, roomId, null, 'm.poll.end', { + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.poll.end': {}, + 'm.text': 'The poll has ended.', }) - .catch(() => setEnding(false)); + .then(() => { + setConfirmEnd(false); + setEnding(false); + }) + .catch(() => setEnding(false)); + } catch { + setEnding(false); + } }; // Radiogroup keyboard model (single-choice only): arrows move focus + selection. From e16c923bea588b24313c3419e5ae018ae3f7dc53 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:59:57 -0400 Subject: [PATCH 08/37] fix(share): room share and invite links open Lotus Chat, not matrix.to (#285) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../invite-user-prompt/InviteUserPrompt.tsx | 9 +++++++-- .../general/RoomShareInvite.tsx | Bin 5485 -> 5897 bytes 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index 4e110be41..a0b76d10f 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useAlive } from '../../hooks/useAlive'; import { copyToClipboard } from '../../utils/dom'; -import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getViaServers } from '../../plugins/via-servers'; import { useModalStyle } from '../../hooks/useModalStyle'; +import { useLotusShareBase } from '../../hooks/useLotusLinkBase'; +import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { const [linkCopied, setLinkCopied] = 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 roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); 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 = () => { diff --git a/src/app/features/common-settings/general/RoomShareInvite.tsx b/src/app/features/common-settings/general/RoomShareInvite.tsx index f37bdf100314db4f9bdfb78a85f633f22915b9ce..f3d0c3aa9ba41452c7b8827ede9539cab883c8d7 100644 GIT binary patch delta 469 zcmZ{gF-rqM5QRxZ0uj+VHcu>ak@EtAAci0qP=pj>uo8>qZp|&;?Jm0$Q7lr6<^2b1 zA-}}V+TP!wy9+rGv6bX zeH1YG*I>yoG0h1=I(N#2MaJj4P>&^-lsJ!6NlOTC`m`AS?Ih|AYEZ~~_xP159B5n% ziLfEHZzSqD3^@L$k7#*hg~zBqTHC{bAJzD;R%_b9;gBEaS&W&?_^pt9@!5J={{a#R BrTPE> delta 94 zcmeCwd#knKKcl{HVo6bEMM!>7etxb(twLH6kW$yv(}$pfoYM5nykh-auqxe>{K+2} ft2YZV^RiC9%poL@o?3#eUt_WYr{d Date: Sat, 17 Oct 2026 20:03:40 -0400 Subject: [PATCH 09/37] fix(calls): the call-failed screen gets a Try again button (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/call/CallView.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index f4e21a5ab..5973d9bf6 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -6,12 +6,13 @@ import { useCallJoined, useCallEmbedPlacementSync, useCallLoadError, + useCallStart, } from '../../hooks/useCallEmbed'; import { callEmbedAtom } from '../../state/callEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; -import { useRoom } from '../../hooks/useRoom'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -160,6 +161,16 @@ function CallLoadErrorMessage() { // prescreen, where they can choose to join again. 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 ( + From f7bdd2855affd51ecb3784880fce9db0fde35d45 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:06:41 -0400 Subject: [PATCH 10/37] fix(composer): Escape closes the emoji and sticker boards (#304) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/emoji-board/EmojiBoard.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 91252a70f..da56c7fea 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji'; import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupIcons } from './useEmojiGroupIcons'; -import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; +import { preventScrollWithArrowKey } from '../../utils/keyboard'; import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../hooks/useRecentEmoji'; @@ -612,7 +612,13 @@ export function EmojiBoard({ !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), isKeyBackward: (evt: KeyboardEvent) => !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; + }, }} > Date: Sat, 17 Oct 2026 20:08:56 -0400 Subject: [PATCH 11/37] fix(sidebar): the room filter hides empty sections and says when nothing matches (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/pages/client/home/Home.tsx | 319 +++++++++++++++++------------ 1 file changed, 185 insertions(+), 134 deletions(-) diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 4a3a0e0df..b08f7dc77 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -255,6 +255,8 @@ function HomeCustomSection({ return (localName ?? matrixName).toLowerCase().includes(query); }); }, [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 // virtualizer per section (a late-mounted one never measured its scroller). return ( @@ -443,6 +445,26 @@ export function Home() { }); }, [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({ count: filteredFavoriteRooms.length, getScrollElement: () => scrollRef.current, @@ -580,7 +602,7 @@ export function Home() { /> - {favoriteRooms.length > 0 && ( + {favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && ( ))} - - - + - Rooms - - {!closedCategories.has(DEFAULT_CATEGORY_ID) && ( - <> - ) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setSortMenuAnchor((current) => (current ? undefined : cords)); - }} - aria-label="Sort rooms" - > - - - setSortMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - { - setHomeRoomSort('recent'); - setSortMenuAnchor(undefined); - }} - size="300" - after={ - homeRoomSort === 'recent' ? ( - - ) : undefined - } - radii="300" + + No rooms match “{filterQuery.trim()}” + + + + + )} + {(!filtering || filteredRooms.length > 0) && ( + + + + Rooms + + {!closedCategories.has(DEFAULT_CATEGORY_ID) && ( + <> + ) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setSortMenuAnchor((current) => (current ? undefined : cords)); + }} + aria-label="Sort rooms" + > + + + setSortMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + - - Recent Activity - - - { - setHomeRoomSort('alpha'); - setSortMenuAnchor(undefined); - }} - size="300" - after={ - homeRoomSort === 'alpha' ? ( - - ) : undefined - } - radii="300" - > - - A → Z - - - { - setHomeRoomSort('unread'); - setSortMenuAnchor(undefined); - }} - size="300" - after={ - homeRoomSort === 'unread' ? ( - - ) : undefined - } - radii="300" - > - - Unread First - - - - - - } - /> - - )} - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = filteredRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - { + setHomeRoomSort('recent'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'recent' ? ( + + ) : undefined + } + radii="300" + > + + Recent Activity + + + { + setHomeRoomSort('alpha'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'alpha' ? ( + + ) : undefined + } + radii="300" + > + + A → Z + + + { + setHomeRoomSort('unread'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'unread' ? ( + + ) : undefined + } + radii="300" + > + + Unread First + + + +
+ + } /> - - ); - })} - -
- {lowPriorityRooms.length > 0 && ( + + )} +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const roomId = filteredRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; + + return ( + + + + ); + })} +
+
+ )} + {lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && ( Date: Sat, 17 Oct 2026 20:14:09 -0400 Subject: [PATCH 12/37] fix(search): an edited message shows once, with its latest text (#288) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../message-search/useMessageSearch.test.ts | 45 +++++++++++++++++ .../message-search/useMessageSearch.ts | 50 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/app/features/message-search/useMessageSearch.test.ts b/src/app/features/message-search/useMessageSearch.test.ts index c57b6a484..2b02b7245 100644 --- a/src/app/features/message-search/useMessageSearch.test.ts +++ b/src/app/features/message-search/useMessageSearch.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + foldEdits, filterGroupsByDateRange, filterGroupsByMsgType, filterGroupsByPinned, @@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => { ['$2'], ); }); + +const sr = (eventId: string, ts: number, content: Record) => + ({ + rank: 1, + result: { + event_id: eventId, + room_id: '!r:x', + origin_server_ts: ts, + type: 'm.room.message', + content, + }, + }) as unknown as Parameters[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'); +}); diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index dbf5cc545..2ce6cfb5b 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -96,10 +96,58 @@ export const filterGroupsByDateRange = ( .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(); + const latestEdit = new Map(); + 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 groups: ResultGroup[] = []; - results.forEach((item) => { + foldEdits(results).forEach((item) => { const roomId = item.result.room_id; const resultItem: ResultItem = { rank: item.rank, From 0a2f18e3d54113ad8e74282b1b72625230055c47 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:20:12 -0400 Subject: [PATCH 13/37] fix(calls): DM-aware rejoin toast, and no rejoining a DM call that ended (#302) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/hooks/useCallRejoin.ts | 26 +++++++++++++++++++++++++- src/app/utils/callRejoin.test.ts | 26 +++++++++++++++++++++++++- src/app/utils/callRejoin.ts | 15 +++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useCallRejoin.ts b/src/app/hooks/useCallRejoin.ts index f69bf0e99..e1d908ca9 100644 --- a/src/app/hooks/useCallRejoin.ts +++ b/src/app/hooks/useCallRejoin.ts @@ -17,6 +17,8 @@ import { CALL_SESSION_HEARTBEAT_MS, clearCallSession, decideRejoin, + othersInCall, + rejoinToastBody, readCallSession, writeCallSession, } from '../utils/callRejoin'; @@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo }; dismissToast(TOAST_ID); 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 { (l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref); } catch { @@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo .filter((m) => m.userId === mx.getUserId()) .map((m) => m.deviceId), memberCount: memberships.length, + dm: latest.current.directs.has(room.roomId), }); // The RTC session fills in a moment after sync; keep waiting while it is // 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, iconSrc: Icons.Phone, 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 ?? '', roomId: room.roomId, sticky: true, diff --git a/src/app/utils/callRejoin.test.ts b/src/app/utils/callRejoin.test.ts index 9f5eb62fd..ad887d65d 100644 --- a/src/app/utils/callRejoin.test.ts +++ b/src/app/utils/callRejoin.test.ts @@ -1,6 +1,12 @@ import { describe, it } from 'node:test'; 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 = { 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); +}); diff --git a/src/app/utils/callRejoin.ts b/src/app/utils/callRejoin.ts index 93cfb365b..fc53cd6fc 100644 --- a/src/app/utils/callRejoin.ts +++ b/src/app/utils/callRejoin.ts @@ -67,6 +67,8 @@ export type RejoinContext = { ownMemberDevices: string[]; /** Number of call memberships in the room, ours included. */ memberCount: number; + /** A DM call: rejoining one may ring the other person. */ + dm?: boolean; }; 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). const others = ctx.memberCount - ctx.ownMemberDevices.length; 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 }; } + +/** 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 ". */ +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.`; From f30a65eb742b64677eacedce9d89c04fa11707b9 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:22:12 -0400 Subject: [PATCH 14/37] fix(status): banner Details strips Markdown syntax, not every #, _, >, * (#308) plain() deleted those characters everywhere, so "join #lotus-general" lost its #, snake_case and @some_user lost underscores, "->" became "-", "5 * 3" became "5 3", and "" became " Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/utils/kumaStatus.test.ts | 25 +++++++++++++++++++++++++ src/app/utils/kumaStatus.ts | 19 ++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/app/utils/kumaStatus.test.ts b/src/app/utils/kumaStatus.test.ts index ad08f850c..93088a411 100644 --- a/src/app/utils/kumaStatus.test.ts +++ b/src/app/utils/kumaStatus.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + plain, DEFAULT_GROUPS, kumaBeatTime, kumaUrls, @@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve 'server-down', ); }); + +test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => { + assert.equal( + plain('join #lotus-general or #announcements:lotusguild.org'), + 'join #lotus-general or #announcements:lotusguild.org', + ); + assert.equal( + plain('ping @some_user about snake_case and C#'), + 'ping @some_user about snake_case and C#', + ); + assert.equal( + plain('calls -> fixed, > 5 people, 5 * 3 = 15'), + 'calls -> fixed, > 5 people, 5 * 3 = 15', + ); + assert.equal( + plain('# Heading\n* item one\n- item two\n> quoted'), + 'Heading\nitem one\nitem two\nquoted', + ); + assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get'); + assert.equal( + plain('Read the guide [docs](https://x.y)'), + 'Read the guide docs', + ); +}); diff --git a/src/app/utils/kumaStatus.ts b/src/app/utils/kumaStatus.ts index 4b4b71837..7e23274c2 100644 --- a/src/app/utils/kumaStatus.ts +++ b/src/app/utils/kumaStatus.ts @@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => { }; /** Markdown-ish incident text as plain text for the Details line. */ -const plain = (s: string): string => +/** + * [Gitea #308] Markdown → plain text for the Details line. Strips only the + * syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case", + * "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway). + */ +export const plain = (s: string): string => s .replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') - .replace(/[*_`#>]/g, '') - .replace(/\s+\n/g, '\n') + .replace(/<\/?[a-z][^>]*>/gi, '') + .replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '') + .replace(/^[ \t]*>[ \t]?/gm, '') + .replace(/^[ \t]*[*+-][ \t]+/gm, '') + .replace(/`([^`\n]+)`/g, '$1') + .replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1') + .replace(/__(?=\S)([^_\n]*?\S)__/g, '$1') + .replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2') + .replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2') + .replace(/[ \t]+\n/g, '\n') .trim(); /** From 8e7127a350780540685ea25edb240c0a65d215ca Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:26:54 -0400 Subject: [PATCH 15/37] fix(security): a dismissed status announcement no longer hides the security nudge (#307) SecurityBanner asked pickBanner whether another strip would show without the dismissed set, so a dismissed Kuma announcement (often pinned for weeks) still counted and suppressed the verify / backup nudge on every device that had dismissed it. The dismissed set is now a shared jotai atom (still persisted to localStorage) used by both banners. Adds a Playwright regression. Fixes #307 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/security-nudge.spec.ts | 42 +++++++++++++++++++ .../security-nudge/SecurityBanner.tsx | 9 +++- .../server-status/ServerStatusBanner.tsx | 28 ++----------- src/app/state/serverStatus.ts | 32 ++++++++++++++ 4 files changed, 85 insertions(+), 26 deletions(-) diff --git a/e2e/security-nudge.spec.ts b/e2e/security-nudge.spec.ts index f9dcd73c9..e664eb46b 100644 --- a/e2e/security-nudge.spec.ts +++ b/e2e/security-nudge.spec.ts @@ -29,6 +29,32 @@ async function settled(page: Page) { await page.waitForTimeout(2000); } +/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */ +async function mockAnnouncement(page: Page) { + await page.route(/\/config\.json(\?.*)?$/, async (route) => { + const res = await route.fetch(); + const cfg = await res.json(); + cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } }; + await route.fulfill({ response: res, json: cfg }); + }); + await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => { + const cors = { 'access-control-allow-origin': '*' }; + if (route.request().url().includes('/heartbeat/')) { + await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } }); + return; + } + await route.fulfill({ + headers: cors, + json: { + config: { slug: 'matrix' }, + incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }], + maintenanceList: [], + publicGroupList: [], + }, + }); + }); +} + test.describe('security nudge (#110, #123)', () => { let user: TestUser; @@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => { await expect(page.getByText('Device Verification').first()).toBeVisible(); }); + test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => { + test.setTimeout(200_000); + await mockAnnouncement(page); + await loginUI(page, user); + await pastGrace(page); + await settled(page); + // The announcement wins the one strip slot until it's dismissed. + const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' }); + await expect(announcement).toBeVisible(); + await expect(strip(page)).toHaveCount(0); + await announcement.getByRole('button', { name: 'Dismiss' }).click(); + await page.reload(); + await settled(page); + await expect(strip(page)).toContainText("Your encryption keys aren't backed up"); + }); + test('"Not now" snoozes it across a reload', async ({ page }) => { test.setTimeout(200_000); await loginUI(page, user); diff --git a/src/app/features/security-nudge/SecurityBanner.tsx b/src/app/features/security-nudge/SecurityBanner.tsx index bfe7f23d4..8ad82bb82 100644 --- a/src/app/features/security-nudge/SecurityBanner.tsx +++ b/src/app/features/security-nudge/SecurityBanner.tsx @@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa import { useAlive } from '../../hooks/useAlive'; import { useSyncState } from '../../hooks/useSyncState'; import { settingsRequestAtom } from '../../state/settingsRequest'; -import { serverStatusAtom } from '../../state/serverStatus'; +import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus'; import { pickBanner } from '../../utils/kumaStatus'; import { dismissNudge, @@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) { const backupInfo = useKeyBackupInfo(crypto); const backupActive = useBackupActive(crypto); const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom); + const statusDismissed = useAtomValue(statusDismissedAtom); const requestSettings = useSetAtom(settingsRequestAtom); // Not urgent: wait until sync has settled (two SYNCING in a row), so it // never stacks under the "Connecting…" strip at startup. @@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) { }); // One strip at a time: outages, maintenance and a lost connection win. - const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' }); + // [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't + // on screen and must not keep the nudge hidden for as long as it's posted. + const otherStrip = + syncLost || + !!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) }); if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now())) return null; diff --git a/src/app/features/server-status/ServerStatusBanner.tsx b/src/app/features/server-status/ServerStatusBanner.tsx index b59e45fde..f33a2108e 100644 --- a/src/app/features/server-status/ServerStatusBanner.tsx +++ b/src/app/features/server-status/ServerStatusBanner.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { MatrixClient, SyncState } from 'matrix-js-sdk'; import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds'; import { ContainerColor } from '../../styles/ContainerColor.css'; @@ -7,21 +7,9 @@ import { useClientConfig } from '../../hooks/useClientConfig'; import { useSyncState } from '../../hooks/useSyncState'; import { useServerStatus } from '../../hooks/useServerStatus'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { serverStatusAtom } from '../../state/serverStatus'; +import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus'; import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus'; -const DISMISS_KEY = 'lotus-status-banner-dismissed'; -const DISMISS_MAX = 50; - -const readDismissed = (): string[] => { - try { - const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]'); - return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : []; - } catch { - return []; - } -}; - const bannerIcon = (b: StripContent): IconSrc => { if (b.kind === 'maintenance') return Icons.Setting; if (b.tone === 'Primary') return Icons.Info; @@ -173,22 +161,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) { /** The logged-in app's status strip. */ export function ServerStatusBanner() { const { status, syncLost } = useAtomValue(serverStatusAtom); - const [dismissed, setDismissed] = useState(readDismissed); + const [dismissed, dismissKey] = useAtom(statusDismissedAtom); const banner = pickBanner(status, { syncLost, where: 'client', dismissed: new Set(dismissed), }); if (!banner) return null; - const dismiss = () => { - const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX); - try { - localStorage.setItem(DISMISS_KEY, JSON.stringify(next)); - } catch { - // storage unavailable — dismissed for this session only - } - setDismissed(next); - }; + const dismiss = () => dismissKey(banner.key); return ; } diff --git a/src/app/state/serverStatus.ts b/src/app/state/serverStatus.ts index 9d4e94250..cd29358e2 100644 --- a/src/app/state/serverStatus.ts +++ b/src/app/state/serverStatus.ts @@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{ syncLost: false, lostAt: null, }); + +const DISMISS_KEY = 'lotus-status-banner-dismissed'; +const DISMISS_MAX = 50; + +const readDismissed = (): string[] => { + try { + const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]'); + return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : []; + } catch { + return []; + } +}; + +const baseDismissedAtom = atom(readDismissed()); + +/** + * [Gitea #307] Status strips the user dismissed (by banner key), shared so + * the security nudge knows a dismissed announcement isn't on screen. + * Persisted to localStorage; session-only if storage is unavailable. + */ +export const statusDismissedAtom = atom( + (get) => get(baseDismissedAtom), + (get, set, key: string) => { + const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX); + try { + localStorage.setItem(DISMISS_KEY, JSON.stringify(next)); + } catch { + // storage unavailable — dismissed for this session only + } + set(baseDismissedAtom, next); + }, +); From 15f2fcc4d11c4bf8ec42d3d44c11c2075f94039a Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:28:34 -0400 Subject: [PATCH 16/37] fix(moderation): policy viewer lists a stable rule and its legacy twin once (#321) dedupePolicyEntries keyed on the raw recommendation, so m.ban and org.matrix.mjolnir.ban for the same entity never matched and the rule showed twice (both labelled "Ban"). Key on the normalised value; move the pure helpers to policyEntries.ts with unit tests. Fixes #321 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../room-settings/PolicyListViewer.tsx | 32 +---------------- .../room-settings/policyEntries.test.ts | 32 +++++++++++++++++ .../features/room-settings/policyEntries.ts | 35 +++++++++++++++++++ 3 files changed, 68 insertions(+), 31 deletions(-) create mode 100644 src/app/features/room-settings/policyEntries.test.ts create mode 100644 src/app/features/room-settings/policyEntries.ts diff --git a/src/app/features/room-settings/PolicyListViewer.tsx b/src/app/features/room-settings/PolicyListViewer.tsx index 5a120c8a3..453016a5c 100644 --- a/src/app/features/room-settings/PolicyListViewer.tsx +++ b/src/app/features/room-settings/PolicyListViewer.tsx @@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SequenceCard } from '../../components/sequence-card'; import { SequenceCardStyle } from '../common-settings/styles.css'; +import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries'; // ── Policy event types ──────────────────────────────────────────────────────── @@ -38,13 +39,6 @@ type PolicyRuleContent = { recommendation?: string; }; -type PolicyEntry = { - entity: string; - reason: string; - recommendation: string; - stateKey: string; -}; - type PolicyTab = 'users' | 'rooms' | 'servers'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -53,13 +47,6 @@ function isGlob(entity: string): boolean { return entity.includes('*') || entity.includes('?'); } -function recommendationLabel(rec: string): string { - // `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy - // (pre-stabilization) recommendation still emitted by older bots. - if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban'; - return rec; -} - /** * Fetch all state events of a given type from a room's live forward state. * Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type. @@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] { .filter((entry) => entry.entity !== ''); } -/** - * Merge policy entries from the stable and legacy event types for a rule - * kind, de-duplicating by entity+recommendation so a room that emits both a - * stable and a legacy rule for the same target isn't double-listed. - */ -export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] { - const seen = new Set(); - const result: PolicyEntry[] = []; - entries.forEach((entry) => { - const key = `${entry.entity} ${entry.recommendation}`; - if (seen.has(key)) return; - seen.add(key); - result.push(entry); - }); - return result; -} - // ── Entry row ───────────────────────────────────────────────────────────────── function PolicyEntryRow({ entry }: { entry: PolicyEntry }) { diff --git a/src/app/features/room-settings/policyEntries.test.ts b/src/app/features/room-settings/policyEntries.test.ts new file mode 100644 index 000000000..0e88eba30 --- /dev/null +++ b/src/app/features/room-settings/policyEntries.test.ts @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dedupePolicyEntries, recommendationLabel } from './policyEntries'; + +const e = (entity: string, recommendation: string, stateKey = entity) => ({ + entity, + reason: 'spam waves', + recommendation, + stateKey, +}); + +test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => { + const out = dedupePolicyEntries([ + e('@spammer:evil.example', 'm.ban'), + e('@spammer:evil.example', 'org.matrix.mjolnir.ban'), + e('@*:spam.example', 'm.ban'), + ]); + assert.deepEqual( + out.map((x) => x.entity), + ['@spammer:evil.example', '@*:spam.example'], + ); +}); + +test('dedupePolicyEntries: different recommendations stay separate', () => { + assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2); +}); + +test('recommendationLabel', () => { + assert.equal(recommendationLabel('m.ban'), 'Ban'); + assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban'); + assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute'); +}); diff --git a/src/app/features/room-settings/policyEntries.ts b/src/app/features/room-settings/policyEntries.ts new file mode 100644 index 000000000..3954731b6 --- /dev/null +++ b/src/app/features/room-settings/policyEntries.ts @@ -0,0 +1,35 @@ +export type PolicyEntry = { + entity: string; + reason: string; + recommendation: string; + stateKey: string; +}; + +/** + * `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy + * (pre-stabilization) recommendation still emitted by older bots. + */ +export const normalizeRecommendation = (rec: string): string => + rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec; + +export function recommendationLabel(rec: string): string { + if (normalizeRecommendation(rec) === 'm.ban') return 'Ban'; + return rec; +} + +/** + * Merge stable and legacy rule lists, one entry per entity + recommendation. + * [Gitea #321] Keyed on the normalised recommendation: a bot publishing both + * `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice. + */ +export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] { + const seen = new Set(); + const result: PolicyEntry[] = []; + entries.forEach((entry) => { + const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`; + if (seen.has(key)) return; + seen.add(key); + result.push(entry); + }); + return result; +} From 69e05bbf8de96587f57c1b1597c7640a912f9b22 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:33:32 -0400 Subject: [PATCH 17/37] fix(notifications): timed mutes are lifted by any Lotus device, not just the one that set them (#333) A timed mute writes a server-side push rule (muted everywhere), but the unmute timer lived in one browser's localStorage: close that laptop and the room stayed muted on the phone until it was reopened, and forever after a logout there. Timers now live in account data (io.lotus.mute_timers: { timers: { [roomId]: unmuteAt } }). Every client schedules from it, reschedules when it changes, and the first one online after expiry unmutes. An indefinite mute clears a pending timer. Local timers from before are migrated once. Fixes #333 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/room-nav/RoomNavItem.tsx | 9 +- src/app/features/room-nav/muteTimers.test.ts | 15 +++- src/app/features/room-nav/muteTimers.ts | 93 ++++++++++++++++---- src/app/pages/client/ClientNonUIFeatures.tsx | 48 +++++----- src/app/state/plaintextCaches.ts | 7 +- src/types/matrix/accountData.ts | 4 + 6 files changed, 131 insertions(+), 45 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 711dd0122..a3f4da929 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -80,7 +80,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; -import { scheduleMuteTimer, unmuteRoom } from './muteTimers'; +import { clearMuteTimer, scheduleMuteTimer } from './muteTimers'; import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; @@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef( prevMode, ).catch(() => {}); if (durationMs !== null) { - scheduleMuteTimer(room.roomId, durationMs, () => { - unmuteRoom(mx, room.roomId); - }); + await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {}); + } else { + // An indefinite mute replaces any timed one. + await clearMuteTimer(mx, room.roomId); } requestClose(); }, diff --git a/src/app/features/room-nav/muteTimers.test.ts b/src/app/features/room-nav/muteTimers.test.ts index 809ee152c..094c9b01c 100644 --- a/src/app/features/room-nav/muteTimers.test.ts +++ b/src/app/features/room-nav/muteTimers.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences'; -import { shouldResetMuteOnUnmute } from './muteTimers'; +import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers'; test('resets to Unset when the room is still Mute at expiry', () => { assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true); @@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute test('does not reset when the mode is already Unset', () => { assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false); }); + +test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => { + const entries = [ + { roomId: '!a:x', unmuteAt: 1000 }, + { roomId: '!b:x', unmuteAt: 2000 }, + ]; + assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries); + assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [ + { roomId: '!b:x', unmuteAt: 5 }, + ]); + assert.deepEqual(parseMuteTimers(undefined), []); + assert.deepEqual(parseMuteTimers({ timers: null }), []); +}); diff --git a/src/app/features/room-nav/muteTimers.ts b/src/app/features/room-nav/muteTimers.ts index d1eb3e66f..06286e64d 100644 --- a/src/app/features/room-nav/muteTimers.ts +++ b/src/app/features/room-nav/muteTimers.ts @@ -7,7 +7,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; -// localStorage key for timed mute timers +// Legacy localStorage key (before #333); migrated into account data once. export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; // setTimeout's delay is a signed 32-bit int; larger values overflow and fire @@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647; export type MuteTimerEntry = { roomId: string; unmuteAt: number }; -export function loadMuteTimers(): MuteTimerEntry[] { - try { - const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } +/** Pure: the timers in an `io.lotus.mute_timers` account data content. */ +export function parseMuteTimers(content: unknown): MuteTimerEntry[] { + const timers = (content as { timers?: unknown } | undefined)?.timers; + if (!timers || typeof timers !== 'object') return []; + return Object.entries(timers as Record) + .filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1])) + .map(([roomId, unmuteAt]) => ({ roomId, unmuteAt })); } -export function saveMuteTimers(timers: MuteTimerEntry[]): void { - localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); +/** Pure: account data content for `entries`. */ +export const muteTimersContent = (entries: MuteTimerEntry[]) => ({ + timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])), +}); + +/** + * [Gitea #333] Timed mutes live in account data: the mute itself is a + * server-side push rule on every device, so the timer must be too — kept in one + * browser's localStorage, only that device ever unmuted (and never after a + * logout there). + */ +export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] { + return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent()); +} + +async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise { + await mx.setAccountData( + AccountDataEvent.LotusMuteTimers as never, + muteTimersContent(entries) as never, + ); +} + +/** Move timers kept in this browser's localStorage (pre-#333) into account data. */ +export async function migrateLocalMuteTimers(mx: MatrixClient): Promise { + let legacy: MuteTimerEntry[] = []; + try { + const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); + legacy = Array.isArray(parsed) + ? parsed.filter( + (e): e is MuteTimerEntry => + !!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number', + ) + : []; + } catch { + legacy = []; + } + if (legacy.length === 0) return; + const current = loadMuteTimers(mx); + const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))]; + await saveMuteTimers(mx, merged); + try { + localStorage.removeItem(MUTE_TIMERS_KEY); + } catch { + /* ignore */ + } } // Pure decision for the unmute guard: a timed mute should only be reset back to @@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise {}); } - saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId)); + await clearMuteTimer(mx, roomId); } -export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { - const unmuteAt = Date.now() + durationMs; - const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); - saveMuteTimers([...existing, { roomId, unmuteAt }]); - setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS)); +/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */ +export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise { + const timers = loadMuteTimers(mx); + if (!timers.some((e) => e.roomId === roomId)) return; + await saveMuteTimers( + mx, + timers.filter((e) => e.roomId !== roomId), + ).catch(() => {}); +} + +/** + * Record when a timed mute ends. MuteTimerRestore (on every Lotus device) + * schedules the unmute from account data, so whichever is online at expiry + * lifts it. + */ +export async function scheduleMuteTimer( + mx: MatrixClient, + roomId: string, + durationMs: number, +): Promise { + const unmuteAt = Date.now() + durationMs; + const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId); + await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]); } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6bf40cbe2..aaeaf6812 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -58,8 +58,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { MAX_MUTE_TIMEOUT_MS, - MuteTimerEntry, loadMuteTimers, + migrateLocalMuteTimers, unmuteRoom, } from '../../features/room-nav/muteTimers'; import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; @@ -91,6 +91,7 @@ import { shouldNotifyThreadReply, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, } from '../../utils/threadNotifications'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; // Grace period after the initial sync settles before invite notifications arm, so // the async invite-atom population lands first and isn't mistaken for new invites. @@ -369,30 +370,35 @@ function PresenceUpdater() { function MuteTimerRestore() { const mx = useMatrixClient(); + // [Gitea #333] Timers are in account data, so a mute set on another device + // is lifted here too, and changes there (a new mute, a manual unmute) + // reschedule. Moves any timers this browser kept locally (pre-#333) first. useEffect(() => { - const timers = loadMuteTimers(); - if (timers.length === 0) return undefined; - - const now = Date.now(); - const pastDue: MuteTimerEntry[] = []; - const future: MuteTimerEntry[] = []; - timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry)); - - pastDue.forEach((entry) => { - unmuteRoom(mx, entry.roomId); - }); - - const handles = future.map((entry) => - setTimeout( - () => { - unmuteRoom(mx, entry.roomId); - }, - Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS), - ), - ); + migrateLocalMuteTimers(mx).catch(() => {}); + }, [mx]); + useEffect(() => { + let handles: ReturnType[] = []; + const schedule = () => { + handles.forEach(clearTimeout); + const now = Date.now(); + handles = loadMuteTimers(mx).map((entry) => + setTimeout( + () => { + unmuteRoom(mx, entry.roomId); + }, + Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)), + ), + ); + }; + const onAccountData = (event: MatrixEvent) => { + if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule(); + }; + schedule(); + mx.on(ClientEvent.AccountData, onAccountData); return () => { handles.forEach(clearTimeout); + mx.removeListener(ClientEvent.AccountData, onAccountData); }; }, [mx]); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index 6e66bc864..ca4c9e8a0 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => { * `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are * swept with the rest — the server-side presence status survives, so a * re-login loses nothing) - * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed - * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as - * preferences, not swept here + * - low-sensitivity UI/metadata residue (collapsed nav/space categories, + * `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here. + * Timed mutes moved to account data in [Gitea #333]; a leftover local + * `io.lotus.mute_timers` is migrated there on the next start. */ /** * [Gitea #204] The local mirror of the user's status message (+ its expiry) is diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 472d0bd40..74e8dee4a 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -26,6 +26,10 @@ export enum AccountDataEvent { // [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync). LotusSettings = 'io.lotus.settings', + // [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus + // device can lift a mute set on another. + LotusMuteTimers = 'io.lotus.mute_timers', + SecretStorageDefaultKey = 'm.secret_storage.default_key', CrossSigningMaster = 'm.cross_signing.master', From 3bf040f17a31bdf9f4de0f1af23e9132d8551e71 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:41:48 -0400 Subject: [PATCH 18/37] fix(notifications): in-app toasts no longer need browser notification permission (#324) notify() shows an in-page toast while the window is focused and only falls back to an OS notification otherwise, but both message and invite notifications were gated on Notification.permission === granted. Anyone who declined (or was never asked) heard the ping with nothing on screen. Gate only the OS path on the permission. Fixes #324 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/pages/client/ClientNonUIFeatures.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6bf40cbe2..e8e6bccc9 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -334,7 +334,9 @@ function InviteNotifications() { if (quietActive) return; - if (showNotifications && notificationPermission('granted')) { + // [Gitea #324] The in-app toast (focused window) needs no browser + // permission; only the OS notification does. + if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) { notify(newCount); } if (notificationSound && inviteSoundId !== 'none') { @@ -641,7 +643,11 @@ function MessageNotifications() { if (quietActive) return; - if (showNotifications && notificationPermission('granted')) { + // [Gitea #324] notify() shows an in-app toast while the window is focused, + // which needs no browser permission; only its OS-notification path does. + // Gating both on the permission left people who declined it (most web + // users) with a ping sound and nothing on screen. + if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); notify({ From 33cc0de97c52812199354bb28d712b04fd73019a Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:47:34 -0400 Subject: [PATCH 19/37] fix(calls): the call PiP no longer opens on top of the composer (#323) The first-use PiP position was bottom-right, 72px from the bottom: on desktop over Send, on phones over Send, Emoji and the message box, so the first thing you typed or tapped in the text room hit the call. Default to top-right under the room header instead. A dragged position is still remembered. Refs #323 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 17e054da5..17fdfc1f8 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -89,6 +89,8 @@ import { zIndices } from '../styles/zIndex'; const PIP_MIN_W = 200; const PIP_MIN_H = 112; +/** Default PiP top: just below the 64px-ish room header. */ +const PIP_DEFAULT_TOP = 72; type Corner = 'se' | 'sw' | 'ne' | 'nw'; @@ -1046,8 +1048,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`; el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`; } else { - el.style.left = `${window.innerWidth - 280 - 16}px`; - el.style.top = `${window.innerHeight - 158 - 72}px`; + // [Gitea #323] First use: top-right under the room header, over the + // timeline. Bottom-right sat on the composer (Send / Emoji, and on + // phones the whole message box), so typing landed on the call. + el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`; + el.style.top = `${PIP_DEFAULT_TOP}px`; } el.style.width = '280px'; el.style.height = '158px'; From 005fc9e7688fba2f10893edc33a026c1a9e0d7c2 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:52:12 -0400 Subject: [PATCH 20/37] fix(notifications): notification replies re-check encryption at send time and use the right account (#299) The service worker posts inline notification replies in the clear (it cannot encrypt). The Reply action was only withheld for rooms that were encrypted when the notification was shown, so a reply typed hours later into a room that has since turned on encryption went out unencrypted. It also posted with the newest session the worker knew, not the notification's account. The worker now GETs the room's m.room.encryption state first and only sends on a definite 404; notifications carry the user id and the worker uses that account's session (or refuses). "Reply not sent" says why. Fixes #299 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/pages/client/ClientNonUIFeatures.tsx | 5 +- src/index.tsx | 2 +- src/sw-session.ts | 3 +- src/sw.ts | 28 ++++-- src/swReply.test.ts | 90 +++++++++++++------- src/swReply.ts | 59 ++++++++++--- 6 files changed, 133 insertions(+), 54 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6bf40cbe2..1241c3849 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -571,7 +571,8 @@ function MessageNotifications() { // NotificationOptions typing. actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }], }), - data: { path: roomPath, roomId, threadId: threadId || undefined }, + // userId: [Gitea #299] the SW replies as this account only. + data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() }, }, () => { window.focus(); @@ -579,7 +580,7 @@ function MessageNotifications() { }, ); }, - [navigate, setToast, mDirects], + [navigate, setToast, mDirects, mx], ); // N105: when a service-worker-owned notification is clicked, the SW focuses diff --git a/src/index.tsx b/src/index.tsx index 528fd59c9..a2c853b44 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -43,7 +43,7 @@ if ('serviceWorker' in navigator && swProtocolOk) { const sendSessionToSW = () => { const session = getFallbackSession(); - pushSessionToSW(session?.baseUrl, session?.accessToken); + pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId); }; // The dev worker is an ES module (it imports workbox), so it must be diff --git a/src/sw-session.ts b/src/sw-session.ts index 4b2ec055d..e4d2672da 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,4 +1,4 @@ -export function pushSessionToSW(baseUrl?: string, accessToken?: string) { +export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { if (!('serviceWorker' in navigator)) return; if (!navigator.serviceWorker.controller) return; @@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) { type: 'setSession', accessToken, baseUrl, + userId, }); } diff --git a/src/sw.ts b/src/sw.ts index 158773965..33d9a8e64 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -2,7 +2,7 @@ import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; import { SHARE_TARGET_PATH, stashSharedForm } from './swShare'; -import { sendNotificationReply } from './swReply'; +import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply'; export type {}; declare const self: ServiceWorkerGlobalScope & { @@ -40,6 +40,8 @@ precacheAndRoute(self.__WB_MANIFEST); type SessionInfo = { accessToken: string; baseUrl: string; + /** [Gitea #299] whose session this is, so a notification reply uses its own account */ + userId?: string; }; /** @@ -63,9 +65,13 @@ async function cleanupDeadClients() { }); } -function setSession(clientId: string, accessToken: string, baseUrl: string) { +function setSession(clientId: string, accessToken: string, baseUrl: string, userId?: unknown) { if (typeof accessToken === 'string' && typeof baseUrl === 'string') { - sessions.set(clientId, { accessToken, baseUrl }); + sessions.set(clientId, { + accessToken, + baseUrl, + userId: typeof userId === 'string' ? userId : undefined, + }); } else { // Logout or invalid session sessions.delete(clientId); @@ -130,10 +136,10 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const client = event.source as Client | null; if (!client) return; - const { type, accessToken, baseUrl } = event.data || {}; + const { type, accessToken, baseUrl, userId } = event.data || {}; if (type === 'setSession') { - setSession(client.id, accessToken, baseUrl); + setSession(client.id, accessToken, baseUrl, userId); cleanupDeadClients(); } }); @@ -155,15 +161,19 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { if (text) { event.waitUntil( sendNotificationReply( - Array.from(sessions.values()).pop(), + // [Gitea #299] The notification's own account, not the newest session. + pickReplySession( + Array.from(sessions.values()), + typeof data.userId === 'string' ? data.userId : undefined, + ), data.roomId, data.threadId, text, - ).then((ok) => { - if (!ok) { + ).then((result) => { + if (result !== 'sent') { // Let the user know it didn't go out, and keep their text reachable. return self.registration.showNotification('Reply not sent', { - body: `Couldn't send "${text.slice(0, 60)}" — open the app to retry.`, + body: replyFailureText(result, text), tag: `swreply-fail-${data.roomId}`, data: { path }, }); diff --git a/src/swReply.test.ts b/src/swReply.test.ts index c7763f5a7..66c7f2ffd 100644 --- a/src/swReply.test.ts +++ b/src/swReply.test.ts @@ -1,6 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { buildReplyContent, replySendUrl, sendNotificationReply } from './swReply'; +import { + buildReplyContent, + pickReplySession, + replyFailureText, + replySendUrl, + sendNotificationReply, +} from './swReply'; test('thread replies carry the m.thread relation, room replies do not', () => { assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} }); @@ -19,40 +25,64 @@ test('send URL encodes the room id against the session base URL', () => { ); }); -test('sendNotificationReply PUTs with the bearer token and reports ok / failure', async () => { +test('sendNotificationReply checks encryption, then PUTs with the bearer token (#203, #299)', async () => { const calls: { url: string; init: RequestInit }[] = []; - const fetchOk = (async (url: string, init: RequestInit) => { - calls.push({ url, init }); - return { ok: true } as Response; - }) as unknown as typeof fetch; - assert.equal( - await sendNotificationReply( - { accessToken: 'tok', baseUrl: 'https://hs' }, - '!r:hs', - '$t', - 'yo', - fetchOk, - ), - true, - ); - assert.equal(calls[0].init.method, 'PUT'); - assert.equal((calls[0].init.headers as Record).Authorization, 'Bearer tok'); + const fetchFor = (encStatus: number, sendOk = true) => + (async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url.includes('/state/m.room.encryption/')) { + return { ok: encStatus === 200, status: encStatus } as Response; + } + return { ok: sendOk, status: sendOk ? 200 : 500 } as Response; + }) as unknown as typeof fetch; + const session = { accessToken: 'tok', baseUrl: 'https://hs' }; + + assert.equal(await sendNotificationReply(session, '!r:hs', '$t', 'yo', fetchFor(404)), 'sent'); + assert.match(calls[0].url, /\/rooms\/!r%3Ahs\/state\/m\.room\.encryption\/$/); + const put = calls[1]; + assert.equal(put.init.method, 'PUT'); + assert.equal((put.init.headers as Record).Authorization, 'Bearer tok'); assert.match( - calls[0].url, + put.url, /^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/, ); - assert.equal(JSON.parse(calls[0].init.body as string)['m.relates_to'].event_id, '$t'); + assert.equal(JSON.parse(put.init.body as string)['m.relates_to'].event_id, '$t'); - const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch; + // Encryption turned on since the notification was shown: nothing is sent. + calls.length = 0; assert.equal( - await sendNotificationReply( - { accessToken: 'tok', baseUrl: 'https://hs' }, - '!r:hs', - undefined, - 'yo', - fetchFail, - ), - false, + await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(200)), + 'encrypted', + ); + assert.equal(calls.length, 1); + // Can't tell (server error): fail safe, nothing is sent. + calls.length = 0; + assert.equal( + await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(500)), + 'failed', + ); + assert.equal(calls.length, 1); + assert.equal( + await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(404, false)), + 'failed', + ); + assert.equal( + await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchFor(404)), + 'no-session', ); - assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false); +}); + +test('pickReplySession: the notification account only (#299)', () => { + const a = { accessToken: 'a', baseUrl: 'https://hs', userId: '@a:hs' }; + const b = { accessToken: 'b', baseUrl: 'https://hs', userId: '@b:hs' }; + assert.equal(pickReplySession([a, b], '@a:hs'), a); + assert.equal(pickReplySession([b], '@a:hs'), undefined, 'logged out of @a here'); + assert.equal(pickReplySession([a], undefined), a, 'old notification, one session'); + assert.equal(pickReplySession([a, b], undefined), undefined, 'old notification, ambiguous'); +}); + +test('replyFailureText says why', () => { + assert.match(replyFailureText('encrypted', 'hi'), /now encrypted/); + assert.match(replyFailureText('no-session', 'hi'), /no longer signed in/); + assert.match(replyFailureText('failed', 'hi'), /retry/); }); diff --git a/src/swReply.ts b/src/swReply.ts index 968918567..987aab4a3 100644 --- a/src/swReply.ts +++ b/src/swReply.ts @@ -1,9 +1,42 @@ /** * [Gitea #203] Build and send an inline reply typed into a notification's * text action. Lives outside sw.ts so it can be unit-tested; the SW passes in - * its newest known session and the global fetch. + * the notification account's session and the global fetch. */ -export type SwReplySession = { accessToken: string; baseUrl: string }; +export type SwReplySession = { accessToken: string; baseUrl: string; userId?: string }; + +export type SwReplyResult = 'sent' | 'failed' | 'encrypted' | 'no-session'; + +/** + * [Gitea #299] The session to reply with: the notification's own account. A + * notification without a user id (shown before this change) may only use the + * session if it is the only one, so a reply never posts as another account. + */ +export const pickReplySession = ( + sessions: SwReplySession[], + userId: string | undefined, +): SwReplySession | undefined => { + if (userId) return sessions.filter((x) => x.userId === userId).pop(); + return sessions.length === 1 ? sessions[0] : undefined; +}; + +/** What the "Reply not sent" notification says for each failure. */ +export const replyFailureText = (result: SwReplyResult, text: string): string => { + const quoted = `"${text.slice(0, 60)}"`; + if (result === 'encrypted') { + return `This room is now encrypted, so ${quoted} wasn't sent. Open the app to reply.`; + } + if (result === 'no-session') { + return `You're no longer signed in to that account here, so ${quoted} wasn't sent.`; + } + return `Couldn't send ${quoted}. Open the app to retry.`; +}; + +export const encryptionStateUrl = (baseUrl: string, roomId: string): string => + new URL( + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`, + baseUrl, + ).href; export const buildReplyContent = (text: string, threadId?: string): Record => { const content: Record = { msgtype: 'm.text', body: text, 'm.mentions': {} }; @@ -30,20 +63,24 @@ export async function sendNotificationReply( threadId: string | undefined, text: string, fetchFn: typeof fetch = fetch, -): Promise { - if (!session?.accessToken || !session.baseUrl) return false; - const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +): Promise { + if (!session?.accessToken || !session.baseUrl) return 'no-session'; + const headers = { Authorization: `Bearer ${session.accessToken}` }; try { + // [Gitea #299] The SW can't encrypt, and the reply action was offered when + // the notification was shown — possibly hours ago. Re-check now: only a + // definite "no encryption state" (404) lets the plain reply go out. + const enc = await fetchFn(encryptionStateUrl(session.baseUrl, roomId), { headers }); + if (enc.ok) return 'encrypted'; + if (enc.status !== 404) return 'failed'; + const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), { method: 'PUT', - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'Content-Type': 'application/json', - }, + headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(buildReplyContent(text, threadId)), }); - return res.ok; + return res.ok ? 'sent' : 'failed'; } catch { - return false; + return 'failed'; } } From 7fc42b32d403a4fc6c6dacd0b89a19ff20e741fd Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:55:05 -0400 Subject: [PATCH 21/37] fix(calls): no "the shared window was closed" toast when an admin turns sharing off (#297) When a room admin turned off Allow Screen Sharing, the sharer got two toasts: the correct "turned off by a room admin" and the fork's "ended" notice, "the shared window was closed", which was wrong. Skip the ended notice while the room policy forbids sharing. Fixes #297 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/hooks/useScreenshareNotices.test.ts | 10 ++++++++++ src/app/hooks/useScreenshareNotices.ts | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 src/app/hooks/useScreenshareNotices.test.ts diff --git a/src/app/hooks/useScreenshareNotices.test.ts b/src/app/hooks/useScreenshareNotices.test.ts new file mode 100644 index 000000000..e5bb2aeda --- /dev/null +++ b/src/app/hooks/useScreenshareNotices.test.ts @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { shouldShowScreenshareNotice } from './useScreenshareNotices'; + +test('shouldShowScreenshareNotice: no "window was closed" when an admin turned sharing off (#297)', () => { + assert.equal(shouldShowScreenshareNotice('ended', false), false); + assert.equal(shouldShowScreenshareNotice('ended', true), true); + assert.equal(shouldShowScreenshareNotice('no-frames', false), true); + assert.equal(shouldShowScreenshareNotice('alone', true), true); +}); diff --git a/src/app/hooks/useScreenshareNotices.ts b/src/app/hooks/useScreenshareNotices.ts index 40b52338b..4a8398a74 100644 --- a/src/app/hooks/useScreenshareNotices.ts +++ b/src/app/hooks/useScreenshareNotices.ts @@ -1,8 +1,9 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useSetAtom } from 'jotai'; import { Icons } from 'folds'; import { CallEmbed } from '../plugins/call'; import { toastQueueAtom } from '../state/toast'; +import { useRoomCallPolicy } from './useRoomCallPolicy'; export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone'; @@ -19,18 +20,32 @@ export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string | } }; +/** Pure: a policy-forced stop is explained by its own toast (#297). */ +export const shouldShowScreenshareNotice = ( + kind: ScreenshareNoticeKind, + allowScreenshare: boolean, +): boolean => !(kind === 'ended' && !allowScreenshare); + /** * [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window * closed, no frames for a while, or a long share with nobody watching. */ export function useScreenshareNotices(embed: CallEmbed): void { const setToast = useSetAtom(toastQueueAtom); + // [Gitea #297] When a room admin turns screen sharing off, the share ends + // and the fork reports `ended` ("the shared window was closed"), which is + // wrong; useCallPolicyRevokedToast already says what happened. + const { allowScreenshare } = useRoomCallPolicy(embed.room); + const allowRef = useRef(allowScreenshare); + allowRef.current = allowScreenshare; useEffect( () => embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>( 'io.lotus.screenshare_notice', (evt) => { - const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind); + const kind = evt.detail?.data?.kind as ScreenshareNoticeKind; + if (!shouldShowScreenshareNotice(kind, allowRef.current)) return; + const body = describeScreenshareNotice(kind); if (!body) return; setToast({ id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`, From 3a74699fef9c9c2bb9a61cc38e1b956469e0bf90 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:57:33 -0400 Subject: [PATCH 22/37] fix(media): "Compress image" no longer blackens transparent images or freezes animations (#331) Compression re-encodes through a canvas to JPEG. Only PNG was skipped, so a transparent WebP came out on a black background, and an animated GIF or WebP became one still frame. GIF and APNG are no longer offered; an animated WebP (VP8X animation flag) or any image with a transparent pixel is kept as the original. Fixes #331 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/utils/imageCompression.test.ts | 33 ++++++++++++++++-- src/app/utils/imageCompression.ts | 48 +++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/app/utils/imageCompression.test.ts b/src/app/utils/imageCompression.test.ts index 4bcf11415..a41c1c1a9 100644 --- a/src/app/utils/imageCompression.test.ts +++ b/src/app/utils/imageCompression.test.ts @@ -1,6 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { formatFileSize, isCompressible } from './imageCompression'; +import { + formatFileSize, + hasTransparency, + isAnimatedWebp, + isCompressible, +} from './imageCompression'; test('formatFileSize formats bytes below 1KB as B', () => { assert.equal(formatFileSize(0), '0 B'); @@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => { test('isCompressible accepts raster image types', () => { assert.equal(isCompressible({ type: 'image/png' } as Blob), true); assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true); - assert.equal(isCompressible({ type: 'image/gif' } as Blob), true); assert.equal(isCompressible({ type: 'image/webp' } as Blob), true); }); test('isCompressible rejects svg, empty type, and non-images', () => { assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false); + // [Gitea #331] usually animated; a canvas keeps one frame + assert.equal(isCompressible({ type: 'image/gif' } as Blob), false); + assert.equal(isCompressible({ type: 'image/apng' } as Blob), false); assert.equal(isCompressible({ type: '' } as Blob), false); assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false); assert.equal(isCompressible({ type: 'text/plain' } as Blob), false); assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false); }); + +const webpHeader = (chunk: string, flags = 0) => { + const b = new Uint8Array(32); + const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at); + put(0, 'RIFF'); + put(8, 'WEBP'); + put(12, chunk); + b[20] = flags; + return b; +}; + +test('isAnimatedWebp: VP8X with the animation flag (#331)', () => { + assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true); + assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only'); + assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy'); + assert.equal(isAnimatedWebp(new Uint8Array(8)), false); +}); + +test('hasTransparency (#331)', () => { + assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false); + assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true); +}); diff --git a/src/app/utils/imageCompression.ts b/src/app/utils/imageCompression.ts index 63b277036..6eb5e14ad 100644 --- a/src/app/utils/imageCompression.ts +++ b/src/app/utils/imageCompression.ts @@ -8,11 +8,32 @@ export type CompressionResult = { height: number; }; -// Any raster image the browser can render to canvas can be compressed to JPEG. -// SVG is vector and must stay as-is; GIF animation is lost on canvas but static -// frames compress fine. Empty type string (undetected MIME) is excluded. +// Raster images the browser can render to canvas can be compressed to JPEG. +// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually +// animated, and a canvas keeps only the first frame, so they aren't offered. +// Empty type string (undetected MIME) is excluded. +const NOT_COMPRESSIBLE = new Set(['image/svg+xml', 'image/gif', 'image/apng']); const isCompressibleType = (type: string): boolean => - type.startsWith('image/') && type !== 'image/svg+xml' && type !== ''; + type.startsWith('image/') && !NOT_COMPRESSIBLE.has(type); + +/** + * Pure: whether a WebP file is animated. An animated WebP is a VP8X file + * whose header has the animation flag (byte 20, bit 0x02) and an ANIM chunk. + */ +export function isAnimatedWebp(bytes: Uint8Array): boolean { + const ascii = (at: number, len: number) => String.fromCharCode(...bytes.subarray(at, at + len)); + if (bytes.length < 21 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WEBP') return false; + // Bit 0x02 of the VP8X flags byte (no-bitwise: test it arithmetically). + return ascii(12, 4) === 'VP8X' && Math.floor(bytes[20] / 2) % 2 === 1; +} + +/** Pure: whether RGBA pixel data has any pixel that isn't fully opaque. */ +export function hasTransparency(rgba: Uint8ClampedArray): boolean { + for (let i = 3; i < rgba.length; i += 4) { + if (rgba[i] !== 255) return true; + } + return false; +} /** Returns true if this file can be compressed via canvas (any raster image type). */ export function isCompressible(file: File | Blob): boolean { @@ -28,7 +49,8 @@ const JPEG_OUTPUT_TYPE = 'image/jpeg'; * * PNG is skipped entirely: it may carry an alpha channel, and re-encoding to * JPEG composites transparency onto an opaque (black) background, corrupting the - * image. Returning null makes callers fall back to uploading the lossless + * image. Any other image with transparent pixels, and animated WebP, are left + * alone for the same reason (#331). Returning null makes callers fall back to uploading the lossless * original. The image is decoded with `imageOrientation: 'from-image'` so any * EXIF orientation is baked into the pixels instead of being silently dropped. */ @@ -39,6 +61,15 @@ export async function compressImage( if (!isCompressibleType(file.type)) return null; // Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency. if (file.type === 'image/png') return null; + // [Gitea #331] An animated WebP would become a single still frame. + if (file.type === 'image/webp') { + try { + const head = new Uint8Array(await file.slice(0, 32).arrayBuffer()); + if (isAnimatedWebp(head)) return null; + } catch { + return null; + } + } let bitmap: ImageBitmap; try { @@ -60,6 +91,13 @@ export async function compressImage( } ctx.drawImage(bitmap, 0, 0); bitmap.close(); + // [Gitea #331] JPEG has no alpha: a transparent WebP (sticker, logo) came out + // with a black background. Keep any image with transparency as the original. + try { + if (hasTransparency(ctx.getImageData(0, 0, width, height).data)) return null; + } catch { + return null; + } return new Promise((resolve) => { canvas.toBlob( From be71931a815f39c0eb390b946a79917d27671a49 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 23:40:58 -0400 Subject: [PATCH 23/37] fix(forward): warn when forwarding out of an encrypted room into one that is not (#330) Forwarding from an encrypted room into an unencrypted one posts the text (and a re-uploaded plaintext attachment) where the server can read it, with the original author attached, and the dialog said nothing. Show a note above Send naming the unencrypted destination(s). Refs #330 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../room/message/ForwardMessageDialog.tsx | 22 +++++++++++++++++-- .../room/message/forwardContent.test.ts | 15 ++++++++++++- .../features/room/message/forwardContent.ts | 20 +++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index ef6f745e2..892a92ec2 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -47,8 +47,12 @@ import { recentForwardTargetsAtom, addRecentForwardTarget, } from '../../../state/recentForwardTargets'; -import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent'; -import { useLocalRoomNames } from '../../../hooks/useRoomMeta'; +import { + buildForwardContent, + buildPlaintextAttachmentContent, + forwardEncryptionWarning, +} from './forwardContent'; +import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta'; type RoomRowProps = { room: Room; @@ -275,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { // Selection persists across query changes: a room selected then filtered out // of the rendered slice stays selected. const [selectedRoomIds, setSelectedRoomIds] = useState>(new Set()); + const encryptionWarning = forwardEncryptionWarning( + !!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(), + [...selectedRoomIds] + .map((id) => mx.getRoom(id)) + .filter((r): r is Room => !!r && !r.hasEncryptionStateEvent()) + // The name the user sees (their own "Rename for me" name, if set). + .map((r) => getLocalRoomName(mx, r.roomId) ?? r.name), + ); const toggleRoom = useCallback((roomId: string) => { setSelectedRoomIds((prev) => { @@ -590,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { shrink="No" direction="Column" style={{ padding: `${config.space.S200} ${config.space.S400}` }} + gap="200" > + {encryptionWarning && ( + + {encryptionWarning} + + )}