From 6b585d6596a9313a4e9e4eb4effb7bed7d627903 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:10:26 -0400 Subject: [PATCH 1/5] fix(notifications): pass room and userId context to reaction notification filter --- .changeset/reaction-notification-context.md | 5 +++++ src/app/pages/client/BackgroundNotifications.tsx | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/reaction-notification-context.md diff --git a/.changeset/reaction-notification-context.md b/.changeset/reaction-notification-context.md new file mode 100644 index 000000000..18e22446b --- /dev/null +++ b/.changeset/reaction-notification-context.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix reaction notifications not being delivered by passing room and user context to the notification event filter diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 395718223..0fa0c2d3a 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -323,7 +323,7 @@ export function BackgroundNotifications() { return; } - if (!isNotificationEvent(mEvent)) { + if (!isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..65bf1825a 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -336,7 +336,7 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } From 5a4a324db73f6c1435588e8832c465a760a6c82c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:22:39 -0400 Subject: [PATCH 2/5] fix: wrap long if condition for prettier compliance --- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 65bf1825a..8a464cf82 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -336,7 +336,12 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { + if ( + !room || + isHistoricalEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined) + ) { return; } From 90e2e1e736f5ed062b853319e1b01a79a7c35620 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:54:36 -0400 Subject: [PATCH 3/5] fix(notifications): open joined rooms at live timeline on notification click --- src/app/hooks/useNotificationJumper.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 43c358317..1c785d079 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -52,13 +52,17 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - log.log('jumping to:', pending.roomId, pending.eventId); + // Always open joined rooms at the live timeline for notification clicks. + // Event-scoped navigation can create a sparse historical context where the + // room appears to contain only the notification event. + const targetEventId = undefined; + log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -74,11 +78,11 @@ export function NotificationJumper() { getSpaceRoomPath( getCanonicalAliasOrRoomId(mx, parentSpace), roomIdOrAlias, - pending.eventId + targetEventId ) ); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); From 11fe16cffd98d8b54f193b3e1e304aa1516662ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:58:37 -0400 Subject: [PATCH 4/5] fix(notifications): prefer live timeline before event-scoped jump --- src/app/hooks/useNotificationJumper.ts | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 1c785d079..8a9f6aec2 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; -import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import { SyncState, ClientEvent, RoomEvent, Room, MatrixEvent } from '$types/matrix-sdk'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -52,10 +52,22 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - // Always open joined rooms at the live timeline for notification clicks. - // Event-scoped navigation can create a sparse historical context where the - // room appears to contain only the notification event. - const targetEventId = undefined; + const liveEvents = + room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? []; + const eventInLive = pending.eventId + ? liveEvents.some((event) => event.getId() === pending.eventId) + : false; + + // If the live timeline is empty the room data is not ready yet. + // Defer and retry on RoomEvent.Timeline so we can decide with real data. + if (!eventInLive && liveEvents.length === 0) { + log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); + return; + } + + // Keep event targeting when needed, but avoid event-scoped navigation for + // events already in the live timeline to prevent sparse historical context. + const targetEventId = eventInLive ? undefined : pending.eventId; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which @@ -121,11 +133,16 @@ export function NotificationJumper() { if (!pending) return undefined; const onRoom = () => performJumpRef.current(); + const onTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => { + if (eventRoom?.roomId === pending.roomId) performJumpRef.current(); + }; mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.Timeline, onTimeline); performJumpRef.current(); return () => { mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.Timeline, onTimeline); }; }, [pending, mx]); // performJump intentionally omitted — use ref above From 4f1bed31018cc5964904342c04a28d0028bf5184 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 03:21:33 -0400 Subject: [PATCH 5/5] fix(notifications): defer event-scoped jump until event appears in live timeline --- src/app/hooks/useNotificationJumper.ts | 43 ++++++++++++++++++++------ 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 8a9f6aec2..90df74293 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -12,6 +12,10 @@ import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; +// How long to wait for the notification event to appear in the live timeline +// before falling back to opening the room at the live bottom. +const JUMP_TIMEOUT_MS = 15_000; + export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -27,6 +31,9 @@ export function NotificationJumper() { // churn re-calls performJump (from the ClientEvent.Room listener or effect // re-runs) before React has committed the null, causing repeated navigation. const jumpingRef = useRef(false); + // Tracks when we first started waiting for the target event to appear in the + // live timeline. Reset whenever `pending` changes. + const jumpStartTimeRef = useRef(null); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -58,16 +65,33 @@ export function NotificationJumper() { ? liveEvents.some((event) => event.getId() === pending.eventId) : false; - // If the live timeline is empty the room data is not ready yet. - // Defer and retry on RoomEvent.Timeline so we can decide with real data. - if (!eventInLive && liveEvents.length === 0) { - log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); - return; + // Defer while the target event hasn't arrived in the live timeline yet. + // Navigating with an eventId not in the live timeline triggers a sparse + // historical context load — the room appears empty or shows only one message. + // Retry on each RoomEvent.Timeline until the event appears, then navigate + // with the eventId so the room scrolls to and highlights it in full context. + // After JUMP_TIMEOUT_MS fall back to opening the room at the live bottom. + if (pending.eventId && !eventInLive) { + if (jumpStartTimeRef.current === null) { + jumpStartTimeRef.current = Date.now(); + } + if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) { + log.log('event not yet in live timeline, deferring jump...', { + roomId: pending.roomId, + eventId: pending.eventId, + }); + return; + } + log.log('timed out waiting for event in live; falling back to live bottom', { + roomId: pending.roomId, + eventId: pending.eventId, + }); } - // Keep event targeting when needed, but avoid event-scoped navigation for - // events already in the live timeline to prevent sparse historical context. - const targetEventId = eventInLive ? undefined : pending.eventId; + // Pass eventId only when confirmed in the live timeline — scrolls to and + // highlights the event in full room context without a sparse historical load. + // Falls back to undefined (live bottom) when the event never appears in live. + const targetEventId = eventInLive ? pending.eventId : undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which @@ -108,9 +132,10 @@ export function NotificationJumper() { } }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); - // Reset the guard only when pending is replaced (new notification or cleared). + // Reset guards only when pending is replaced (new notification or cleared). useEffect(() => { jumpingRef.current = false; + jumpStartTimeRef.current = null; }, [pending]); // Keep a stable ref to the latest performJump so that the listeners below