From 5b5fbdb7b70936c539c4d92c0bd175c2cdcf8954 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 21:42:47 -0400 Subject: [PATCH] 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();