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();