From 5ce552917062d257cef0ac445d2b3e3ed132de57 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 15 May 2026 08:06:32 -0400 Subject: [PATCH 01/13] fix(notifications): correct room routing and preserve event context on notification tap Two race conditions fixed: 1. NotificationJumper used roomToParentsAtom which is populated by a sibling useEffect in the parent component. React runs child effects before parent effects, so when SyncState.Syncing fires and performJump() reads the atom, it can still hold stale/empty data. getOrphanParents() then returns [] for space rooms, routing them to the home path where HomeRouteRoomProvider shows JoinBeforeNavigate instead of the actual room. Fix: call getRoomToParents(mx) directly inside performJump() to read fresh space-membership data from the SDK at navigation time. roomToParentsAtom is no longer read in this hook. 2. When a sliding sync gap fires RoomEvent.TimelineReset or TimelineRefresh (e.g. after the mobile PWA comes to the foreground), useLiveTimelineRefresh reset the timeline to getInitialTimeline(room), discarding the loaded event context. The user would then see the live timeline at the bottom instead of the notification event. Fix: if an eventId is active, reload that event's context via loadEventTimeline(eventId) instead of resetting to live." --- src/app/hooks/timeline/useTimelineSync.ts | 10 +++++++++- src/app/hooks/useNotificationJumper.ts | 8 +++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c10b762b8..613a59713 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -523,13 +523,21 @@ export function useTimelineSync({ useLiveTimelineRefresh( room, useCallback(() => { + // If the user arrived via a notification event link, reload that event's + // context rather than dropping back to the live timeline — otherwise a + // sync gap (TimelineReset / TimelineRefresh) would silently discard the + // loaded context and the notification event would no longer be visible. + if (eventId) { + void loadEventTimeline(eventId); + return; + } const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { scrollToBottom('instant'); } - }, [room, isAtBottomRef, scrollToBottom]) + }, [eventId, loadEventTimeline, room, isAtBottomRef, scrollToBottom]) ); useRelationUpdate( diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index a03342fc5..531079405 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -8,15 +8,13 @@ import { useSyncState } from './useSyncState'; import { useMatrixClient } from './useMatrixClient'; import { getCanonicalAliasOrRoomId } from '../utils/matrix'; import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../pages/pathUtils'; -import { getOrphanParents, guessPerfectParent } from '../utils/room'; -import { roomToParentsAtom } from '../state/room/roomToParents'; +import { getOrphanParents, getRoomToParents, guessPerfectParent } from '../utils/room'; import { createLogger } from '../utils/debug'; export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); const mDirects = useAtomValue(mDirectAtom); - const roomToParents = useAtomValue(roomToParentsAtom); const mx = useMatrixClient(); const navigate = useNavigate(); const log = createLogger('NotificationJumper'); @@ -66,7 +64,7 @@ export function NotificationJumper() { // Use getOrphanParents + guessPerfectParent (same as useRoomNavigate) so // we always navigate to a root-level space, not a subspace — subspace // paths are not recognised by the router and land on JoinBeforeNavigate. - const orphanParents = getOrphanParents(roomToParents, pending.roomId); + const orphanParents = getOrphanParents(getRoomToParents(mx), pending.roomId); if (orphanParents.length > 0) { const parentSpace = guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0]; @@ -90,7 +88,7 @@ export function NotificationJumper() { membership: room?.getMyMembership(), }); } - }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); + }, [pending, activeSessionId, mx, mDirects, navigate, setPending, log]); // Reset the guard only when pending is replaced (new notification or cleared). useEffect(() => { From 05d64a8ec5c90be7f09076d3b88e9429e53d6b51 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 10:40:26 -0400 Subject: [PATCH 02/13] docs: document notification and service worker issues Tracks Issues #2, #5, #6 with root cause analysis and proposed fixes. Covers SW connection, media auth, and unread badge logic. --- docs/NOTIFICATIONS_FIXES.md | 80 +++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/NOTIFICATIONS_FIXES.md diff --git a/docs/NOTIFICATIONS_FIXES.md b/docs/NOTIFICATIONS_FIXES.md new file mode 100644 index 000000000..4ceef1270 --- /dev/null +++ b/docs/NOTIFICATIONS_FIXES.md @@ -0,0 +1,80 @@ +# Notifications & Service Worker Fixes + +This document tracks notification and service worker issues that need to be addressed in `feat/notifications`. + +## Issue #2: SW connection dropout after idle period + +**Problem**: The service worker doesn't seem to stay connected after a period of time, causing notifications to fail. + +**Root Cause**: +- Service worker becomes inactive after idle period +- Push subscription may expire or lose connection +- Background sync registration not persisting +- SW messaging channel disconnected after tab becomes inactive + +**Proposed Fix**: +- Implement periodic SW keepalive ping from active tabs +- Re-establish push subscription on SW activation +- Add SW connection health monitoring +- Implement exponential backoff reconnection logic +- Persist notification state in IndexedDB for SW access + +**Implementation Notes**: +- Add `postMessage` keepalive every 30s from active tab +- Listen for SW `activate` event to restore connections +- Use `navigator.serviceWorker.ready` to ensure registration +- Test with Chrome DevTools → Application → Service Workers → "Update on reload" disabled + +## Issue #5: Media loading failures until hard reset + +**Problem**: Loading media (either media I just sent, or other media), including URL previews, fails until the app is hard reset. + +**Root Cause**: +- Media authentication tokens not being refreshed/passed correctly +- CORS issues with media URLs after session changes +- Service worker caching stale auth headers +- Blob URL revocation before media loads + +**Proposed Fix**: +- Implement media auth token refresh mechanism +- Clear SW media cache on auth token changes +- Add retry logic with fresh auth for media requests +- Use stable blob URLs with reference counting +- Add authentication headers to media fetch requests in SW + +**Implementation Notes**: +- Store media auth tokens in SW cache with expiry +- Listen for Matrix client auth token changes +- Invalidate SW media cache on token refresh +- Add `Authorization` header to fetch requests in SW +- Test with private media enabled homeserver + +## Issue #6: Phantom unread favicon badges/dots + +**Problem**: There are phantom unread favicon badges/dots that don't correspond to actual unread messages. + +**Root Cause**: +- Unread count calculation includes muted/low-priority rooms +- Favicon update race condition with sync state +- Notification count not clearing after room visit +- Thread notifications counting separately from main timeline + +**Proposed Fix**: +- Recalculate unread count from room notification state only +- Exclude muted rooms and low-priority notifications +- Clear favicon badge immediately on room focus +- Consolidate thread + main timeline counts correctly +- Add debouncing to favicon updates during rapid sync + +**Implementation Notes**: +- Use `room.getUnreadNotificationCount()` with proper filters +- Check `room.notificationCounts` and respect notification level +- Update favicon only when total unread count actually changes +- Test with various notification settings (All, Mentions, Muted) + +**Related Files**: +- Service worker message handling +- Push notification registration +- Favicon update logic +- Media authentication +- Notification badge counting From 94d557981976eecf2f81de1a94d8e469d351acf3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 15:46:33 -0400 Subject: [PATCH 03/13] fix(notifications): play audio when tab hidden; show banner for loud rooms Two notification fixes: 1. Audio when tab hidden: move playSound() before the visibilityState guard so notification sounds play even when the browser tab is in the background. Only in-app UI elements (the banner) need page visibility. 2. Loud room banner: extend the in-app banner condition from (isHighlightByRule || isDM) to (isHighlightByRule || isDM || isLoud) so rooms configured with a loud push rule (e.g. "All messages" notification level) also show an in-app banner, not just an unread dot. --- src/app/pages/client/ClientNonUIFeatures.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..0a026bc59 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -452,13 +452,17 @@ function MessageNotifications() { } } - // Everything below requires the page to be visible (in-app UI + audio). + // In-app audio plays regardless of tab visibility — sound doesn't require the page to be visible. + if (notificationSound && isLoud) { + playSound(); + } + + // Everything below requires the page to be visible (in-app UI only). if (document.visibilityState !== 'visible') return; // Page is visible — show the themed in-app notification banner. - // For non-DM rooms, only show banner for highlighted messages (mentions/keywords). - // For DMs, show banner for all messages. - if (showNotifications && (isHighlightByRule || isDM)) { + // Show for DMs, highlighted messages (mentions/keywords), or any loud push-rule match. + if (showNotifications && (isHighlightByRule || isDM || isLoud)) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const roomAvatar = avatarMxc @@ -530,10 +534,7 @@ function MessageNotifications() { }); } - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { - playSound(); - } + // (Audio is handled above the visibility gate.) }; mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { From d43e271ae370df8fc19f95839294a4487e384576 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 18:16:11 -0400 Subject: [PATCH 04/13] fix(notifications): remove pre-init roomInitialSync/getLatestTimeline from loadEventTimeline These calls fired TimelineReset mid-jump, resetting the scroll position to the live bottom instead of the target event. --- src/app/hooks/timeline/useTimelineSync.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 613a59713..3d1a247d4 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -63,16 +63,6 @@ const useEventTimelineLoader = ( Sentry.startSpan({ name: 'timeline.jump_load', op: 'matrix.timeline' }, async () => { const jumpLoadStart = performance.now(); - if (!room.getUnfilteredTimelineSet().getTimelineForEvent(eventId)) { - await withTimeout( - mx.roomInitialSync(room.roomId, PAGINATION_LIMIT), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - await withTimeout( - mx.getLatestTimeline(room.getUnfilteredTimelineSet()), - EVENT_TIMELINE_LOAD_TIMEOUT_MS - ); - } const [err, replyEvtTimeline] = await to( withTimeout( mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId), From 0cd0d80ec189c25e8edfb99caa86664c4ad9d193 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 19:34:03 -0400 Subject: [PATCH 05/13] fix(notifications): show in-app banners for rooms set to All Messages Rooms where the user has explicitly set the notification preference to All Messages now trigger in-app notification banners and notification sounds, mirroring the existing DM force-notify behaviour. Two changes: - shouldForceRoomLoudNotification bypasses push-rule evaluation for All-Messages rooms, so a room-specific rule written by another client (without a sound tweak) or a silent push-rule eval failure no longer silently drops the notification. - isLoud now includes shouldForceRoomLoudNotification so that in-app audio and the banner visibility condition both respect this. --- src/app/pages/client/ClientNonUIFeatures.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0a026bc59..2cc4e651f 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -381,7 +381,13 @@ function MessageNotifications() { // For "Mention & Keywords": respect the push rule (only notify if it matches). const shouldForceDMNotification = isDM && notificationType !== NotificationType.MentionsAndKeywords; - const shouldNotify = pushActions?.notify || shouldForceDMNotification; + // For rooms explicitly set to "All Messages": also force-notify, mirroring the DM + // bypass above. Push-rule evaluation can silently return notify=false when the + // room-specific rule was written by another client with a different action format. + const shouldForceRoomLoudNotification = + !isDM && notificationType === NotificationType.AllMessages; + const shouldNotify = + pushActions?.notify || shouldForceDMNotification || shouldForceRoomLoudNotification; // If we shouldn't notify based on rules/settings, skip everything if (!shouldNotify) return; @@ -395,7 +401,10 @@ function MessageNotifications() { // messages fall through to .m.rule.message which carries no sound tweak — // leaving loudByRule=false. Treat known DMs as inherently loud so that // the OS notification and badge are consistent with the DM context. - const isLoud = loudByRule || isDM; + // Similarly, rooms explicitly set to "All Messages" are treated as loud + // even when the room-specific push rule was written by another client + // without a sound tweak, or when push-rule evaluation fails silently. + const isLoud = loudByRule || isDM || shouldForceRoomLoudNotification; // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events). notifiedEventsRef.current.add(eventId); From 4dcd1a6efc54ba618e24d539fa5f305b3abae56f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 19:34:58 -0400 Subject: [PATCH 06/13] style: format NOTIFICATIONS_FIXES.md --- docs/NOTIFICATIONS_FIXES.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/NOTIFICATIONS_FIXES.md b/docs/NOTIFICATIONS_FIXES.md index 4ceef1270..0ed5bdf38 100644 --- a/docs/NOTIFICATIONS_FIXES.md +++ b/docs/NOTIFICATIONS_FIXES.md @@ -7,12 +7,14 @@ This document tracks notification and service worker issues that need to be addr **Problem**: The service worker doesn't seem to stay connected after a period of time, causing notifications to fail. **Root Cause**: + - Service worker becomes inactive after idle period - Push subscription may expire or lose connection - Background sync registration not persisting - SW messaging channel disconnected after tab becomes inactive **Proposed Fix**: + - Implement periodic SW keepalive ping from active tabs - Re-establish push subscription on SW activation - Add SW connection health monitoring @@ -20,6 +22,7 @@ This document tracks notification and service worker issues that need to be addr - Persist notification state in IndexedDB for SW access **Implementation Notes**: + - Add `postMessage` keepalive every 30s from active tab - Listen for SW `activate` event to restore connections - Use `navigator.serviceWorker.ready` to ensure registration @@ -30,12 +33,14 @@ This document tracks notification and service worker issues that need to be addr **Problem**: Loading media (either media I just sent, or other media), including URL previews, fails until the app is hard reset. **Root Cause**: + - Media authentication tokens not being refreshed/passed correctly - CORS issues with media URLs after session changes - Service worker caching stale auth headers - Blob URL revocation before media loads **Proposed Fix**: + - Implement media auth token refresh mechanism - Clear SW media cache on auth token changes - Add retry logic with fresh auth for media requests @@ -43,6 +48,7 @@ This document tracks notification and service worker issues that need to be addr - Add authentication headers to media fetch requests in SW **Implementation Notes**: + - Store media auth tokens in SW cache with expiry - Listen for Matrix client auth token changes - Invalidate SW media cache on token refresh @@ -54,12 +60,14 @@ This document tracks notification and service worker issues that need to be addr **Problem**: There are phantom unread favicon badges/dots that don't correspond to actual unread messages. **Root Cause**: + - Unread count calculation includes muted/low-priority rooms - Favicon update race condition with sync state - Notification count not clearing after room visit - Thread notifications counting separately from main timeline **Proposed Fix**: + - Recalculate unread count from room notification state only - Exclude muted rooms and low-priority notifications - Clear favicon badge immediately on room focus @@ -67,14 +75,16 @@ This document tracks notification and service worker issues that need to be addr - Add debouncing to favicon updates during rapid sync **Implementation Notes**: + - Use `room.getUnreadNotificationCount()` with proper filters - Check `room.notificationCounts` and respect notification level - Update favicon only when total unread count actually changes - Test with various notification settings (All, Mentions, Muted) **Related Files**: + - Service worker message handling - Push notification registration -- Favicon update logic +- Favicon update logic - Media authentication - Notification badge counting From 7c9e01e8168c32b0a55bd37aa3a74fcfaf33012a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 20:45:19 -0400 Subject: [PATCH 07/13] fix(notifications): send background notifications for loud non-DM rooms --- .../pages/client/BackgroundNotifications.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index e7fdff6d7..22b1f8486 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -381,7 +381,11 @@ export function BackgroundNotifications() { // For "Mention & Keywords": respect the push rule (only notify if it matches). const shouldForceDMNotification = isDM && notificationType !== NotificationType.MentionsAndKeywords; - const shouldNotify = pushActions?.notify || shouldForceDMNotification; + // For rooms explicitly set to "All Messages": force-notify, mirroring the DM bypass. + const shouldForceRoomLoudNotification = + !isDM && notificationType === NotificationType.AllMessages; + const shouldNotify = + pushActions?.notify || shouldForceDMNotification || shouldForceRoomLoudNotification; if (!shouldNotify) { debugLog.debug('notification', 'Event filtered - no push action match', { @@ -395,6 +399,9 @@ export function BackgroundNotifications() { const loudByRule = Boolean(pushActions.tweaks?.sound); const isHighlight = Boolean(pushActions.tweaks?.highlight); + // Treat DMs and "All Messages" rooms as inherently loud when the push rule lacks a + // sound tweak (common with sliding sync where room_member_count conditions fail). + const isLoud = loudByRule || isDM || shouldForceRoomLoudNotification; debugLog.info('notification', 'Processing notification event', { eventId, @@ -402,7 +409,7 @@ export function BackgroundNotifications() { eventType, isDM, isHighlight, - loud: loudByRule, + loud: isLoud, }); const senderName = @@ -429,7 +436,7 @@ export function BackgroundNotifications() { }); // Silent-rule events: unread badge updated above; no OS notification or sound. - if (!loudByRule && !isHighlight) { + if (!isLoud && !isHighlight) { debugLog.debug('notification', 'Silent notification - badge updated only', { eventId, roomId: room.roomId, @@ -458,8 +465,8 @@ export function BackgroundNotifications() { showMessageContent: showMessageContentRef.current, showEncryptedMessageContent: showEncryptedMessageContentRef.current, }), - // Play sound only if the push rule requests it and the user has sounds enabled. - silent: !notificationSoundRef.current || !loudByRule, + // Play sound only if the event is loud and the user has sounds enabled. + silent: !notificationSoundRef.current || !isLoud, eventId, data: { type: mEvent.getType(), @@ -502,9 +509,9 @@ export function BackgroundNotifications() { icon: notificationPayload.options.icon, onClick: notifOnClick, }); - } else if (loudByRule) { - // App is backgrounded or in-app notifications disabled — fire an OS notification. - // Only send for loud (sound-tweak) rules; highlight-only events are silently counted. + } else if (isLoud) { + // App is backgrounded or in-app notifications disabled — fire an OS notification. + // Send for loud events (includes DMs and rooms set to "All Messages"). debugLog.info('notification', 'Sending OS notification', { eventId, roomId: room.roomId, From cbde5229f325e89a8797b60be70c69fd89ef9833 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 20:45:26 -0400 Subject: [PATCH 08/13] fix(unread): suppress phantom badges when user sent the latest message --- src/app/utils/room.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e15630c79..2fa63f83c 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -312,6 +312,18 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } + // If the user's own message is the most recent event in the live timeline they + // implicitly read everything before it when they composed that reply. Return zero + // to suppress phantom unread badges that arise from stale SDK counters in sliding + // sync when no explicit read receipt is present. + if (userId && !room.getEventReadUpTo(userId)) { + const liveEvents = room.getLiveTimeline().getEvents(); + const latestEvent = liveEvents[liveEvents.length - 1]; + if (latestEvent && !latestEvent.isSending() && latestEvent.getSender() === userId) { + return { roomId: room.roomId, highlight: 0, total: 0 }; + } + } + let total = room.getUnreadNotificationCount(NotificationCountType.Total); const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); From 9dc560737948df014b907336d11121ecd4100954 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 20:45:31 -0400 Subject: [PATCH 09/13] chore(lint): use proper type imports in ThreadBrowser; remove unused import in useNotificationJumper --- src/app/features/room/ThreadBrowser.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 766889032..7334d8ebe 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -41,6 +41,8 @@ import { RenderMessageContent } from '$components/RenderMessageContent'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import type { GetContentCallback } from '$types/matrix/room'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import type { HTMLReactParserOptions } from 'html-react-parser'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import { From 04d08aa5fe879e1f35670104ecb97f8aa34da316 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:08 -0400 Subject: [PATCH 10/13] chore: add changeset --- .changeset/notifications.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/notifications.md diff --git a/.changeset/notifications.md b/.changeset/notifications.md new file mode 100644 index 000000000..13b1d7f7a --- /dev/null +++ b/.changeset/notifications.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix notification routing, in-app banners for All Messages rooms, and background audio for loud non-DM rooms. From 7b3a4093ca9284598e8729fc6de1a80dc4f55d0a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 15:40:27 -0400 Subject: [PATCH 11/13] fix(notifications): remove duplicate HTMLReactParserOptions and LinkifyOpts imports --- src/app/features/room/ThreadBrowser.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 7334d8ebe..766889032 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -41,8 +41,6 @@ import { RenderMessageContent } from '$components/RenderMessageContent'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import type { GetContentCallback } from '$types/matrix/room'; -import type { Opts as LinkifyOpts } from 'linkifyjs'; -import type { HTMLReactParserOptions } from 'html-react-parser'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import { From 894f4ce46bb116f7f291a3f123a853769651a4eb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:06:05 -0400 Subject: [PATCH 12/13] fix(notifications): address Copilot review feedback - Make shouldForceRoomLoudNotification order-independent: check all push rule actions with .some() instead of only actions[0] - Catch audio.play() promise rejection in both playSound callbacks (both invite and message notification paths) - Guard phantom-unread suppression with isNotificationEvent() so that non-message events (state, reactions, etc.) don't reset the badge - Add test: TimelineReset with eventId reloads event context, not live timeline --- .../hooks/timeline/useTimelineSync.test.tsx | 35 +++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 4 +-- src/app/utils/room.ts | 9 +++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..06a1597ad 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -75,6 +75,41 @@ function createRoom( } describe('useTimelineSync', () => { + it('reloads event context on TimelineReset when eventId is set', async () => { + const { room, timelineSet } = createRoom(); + const scrollToBottom = vi.fn<() => void>(); + + // mx.getEventTimeline is intentionally absent — loadEventTimeline will + // reject silently (void), leaving the timeline state unchanged. + const { result } = renderHook(() => + useTimelineSync({ + room: room as Room, + mx: { getUserId: () => '@alice:test' } as never, + eventId: '$linked:event', + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }) + ); + + const timelineBefore = result.current.timeline.linkedTimelines; + + await act(async () => { + timelineSet.emit(RoomEvent.TimelineReset); + await Promise.resolve(); + }); + + // The live timeline should NOT have replaced the event-context timeline — + // the eventId branch in useLiveTimelineRefresh returns early after calling + // loadEventTimeline and never calls setTimeline with the live timeline. + expect(result.current.timeline.linkedTimelines).toBe(timelineBefore); + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + it('does not snap a non-bottom user to latest after TimelineReset', async () => { const { room, timelineSet, events } = createRoom(); const scrollToBottom = vi.fn<() => void>(); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 2cc4e651f..0838ca447 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -197,7 +197,7 @@ function InviteNotifications() { const playSound = useCallback(() => { const audioElement = audioRef.current; - audioElement?.play(); + audioElement?.play().catch(() => {}); clearMediaSessionQuickly(); }, []); @@ -271,7 +271,7 @@ function MessageNotifications() { const playSound = useCallback(() => { const audioElement = audioRef.current; - audioElement?.play(); + audioElement?.play().catch(() => {}); clearMediaSessionQuickly(); }, []); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 2fa63f83c..7bf20a3a0 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -209,7 +209,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat return NotificationType.Default; } - if ((roomPushRule.actions[0] as string) === 'notify') return NotificationType.AllMessages; + if ((roomPushRule.actions as string[]).some((a) => a === 'notify')) return NotificationType.AllMessages; return NotificationType.MentionsAndKeywords; }; @@ -319,7 +319,12 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn if (userId && !room.getEventReadUpTo(userId)) { const liveEvents = room.getLiveTimeline().getEvents(); const latestEvent = liveEvents[liveEvents.length - 1]; - if (latestEvent && !latestEvent.isSending() && latestEvent.getSender() === userId) { + if ( + latestEvent && + !latestEvent.isSending() && + latestEvent.getSender() === userId && + isNotificationEvent(latestEvent) + ) { return { roomId: room.roomId, highlight: 0, total: 0 }; } } From ad731e4bbe6e2f676477b38accbe565fe4acc35c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 13:28:08 -0400 Subject: [PATCH 13/13] style: fix formatting --- src/app/utils/room.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 7bf20a3a0..17a61eeef 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -209,7 +209,8 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat return NotificationType.Default; } - if ((roomPushRule.actions as string[]).some((a) => a === 'notify')) return NotificationType.AllMessages; + if ((roomPushRule.actions as string[]).some((a) => a === 'notify')) + return NotificationType.AllMessages; return NotificationType.MentionsAndKeywords; };