From 976d2e8db1b3c51a7bd2e594f01b4c25f469994f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 16:34:06 -0400 Subject: [PATCH 1/2] fix: comprehensive notification improvements for sliding sync - Fix DM notifications using multiple detection signals (m.direct + member count heuristic) - Fix encrypted event handling with proper event type checking and decryption - Use fixupNotifications() to reconcile Sliding Sync notification count drift - Force DM notifications for all messages (excluding mute/mentions-only settings) - Implement robust stale count detection and correction for accurate badge numbers - Add timeline walking fallback for computing real counts when SDK counters are stale - Improve notification type detection for DMs with proper mute checks - Show in-app banner for background account notifications when app is visible - Add sound notifications for all accounts (active and background) - Always force reaction notifications to show for all room types - Improve background notification routing - build payload first, then route by visibility Known Issues: - Background account notifications: System/push notifications do not work when app is hidden - Mobile keyboard banner positioning: Banner not properly positioned when keyboard is open on iOS --- ...tion-and-badge-issues-with-sliding-sync.md | 5 + .../NotificationBanner.css.ts | 18 +- .../NotificationBanner.tsx | 56 ++++++- .../settings/notifications/AllMessages.tsx | 6 +- .../pages/client/BackgroundNotifications.tsx | 154 ++++++++++++++---- src/app/pages/client/ClientLayout.tsx | 2 - src/app/pages/client/ClientNonUIFeatures.tsx | 93 ++++++++--- src/app/pages/client/direct/Direct.tsx | 33 +++- .../client/sidebar/AccountSwitcherTab.tsx | 12 +- src/app/state/room/roomToUnread.ts | 69 +++++--- src/app/utils/notificationStyle.ts | 12 ++ src/app/utils/room.ts | 102 ++++++++++-- 12 files changed, 459 insertions(+), 103 deletions(-) create mode 100644 .changeset/fix-dm-notification-and-badge-issues-with-sliding-sync.md diff --git a/.changeset/fix-dm-notification-and-badge-issues-with-sliding-sync.md b/.changeset/fix-dm-notification-and-badge-issues-with-sliding-sync.md new file mode 100644 index 000000000..d328fbcdb --- /dev/null +++ b/.changeset/fix-dm-notification-and-badge-issues-with-sliding-sync.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix DM notifications, encrypted event notifications, and enable reaction notifications diff --git a/src/app/components/notification-banner/NotificationBanner.css.ts b/src/app/components/notification-banner/NotificationBanner.css.ts index 4a29d24d7..a60db281c 100644 --- a/src/app/components/notification-banner/NotificationBanner.css.ts +++ b/src/app/components/notification-banner/NotificationBanner.css.ts @@ -23,10 +23,14 @@ const slideOut = keyframes({ }, }); -// Floats at the top of the viewport, spanning full width on all platforms. +// Positions at the top of the viewport, spanning full width. +// Uses fixed positioning with safe-area-inset to handle iOS keyboard correctly. +// On iOS, the banner stays at the top of the visual viewport even when keyboard is open. export const BannerContainer = style({ position: 'fixed', - top: 0, + // Use env(safe-area-inset-top) to respect device-specific safe areas (notches, etc) + // This also helps position correctly on iOS when the keyboard is open + top: 'env(safe-area-inset-top, 0)', left: 0, right: 0, zIndex: 9999, @@ -36,6 +40,16 @@ export const BannerContainer = style({ padding: config.space.S400, pointerEvents: 'none', alignItems: 'stretch', + + // On iOS, when keyboard opens, ensure banner stays visible at top of visual viewport + '@supports': { + '(-webkit-touch-callout: none)': { + // iOS-specific: Position relative to the visible viewport when keyboard is open + position: 'fixed', + // Support both old and new safe area syntax + top: 'max(env(safe-area-inset-top, 0px), constant(safe-area-inset-top, 0px))', + }, + }, }); export const Banner = style({ diff --git a/src/app/components/notification-banner/NotificationBanner.tsx b/src/app/components/notification-banner/NotificationBanner.tsx index 666ec0777..b574e3585 100644 --- a/src/app/components/notification-banner/NotificationBanner.tsx +++ b/src/app/components/notification-banner/NotificationBanner.tsx @@ -1,9 +1,11 @@ import { useAtom } from 'jotai'; import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { Box, Icon, IconButton, Icons, Text } from 'folds'; +import { createLogger } from '$utils/debug'; import { inAppBannerAtom, InAppBannerNotification } from '$state/sessions'; import * as css from './NotificationBanner.css'; +const log = createLogger('NotificationBanner'); const BANNER_DURATION_MS = 5000; // Renders body text capped at a max height with a gradient fade when it overflows. @@ -173,15 +175,58 @@ export function NotificationBanner() { // We store an array locally so multiple rapid notifications stack briefly. const [banner, setBanner] = useAtom(inAppBannerAtom); const [queue, setQueue] = useState([]); + const containerRef = useRef(null); + + log.log('[Banner] Component render, queue length:', queue.length, 'banner:', banner); + + // Adjust banner position for iOS keyboard + useEffect(() => { + // Only apply on iOS/browsers that support visualViewport + if (!('visualViewport' in window)) return undefined; + + const updatePosition = () => { + const container = containerRef.current; + if (!container) return; + + const visualViewport = window.visualViewport!; + // Calculate how much of the screen is covered by the keyboard + // When keyboard opens, visualViewport.height shrinks + const keyboardHeight = window.innerHeight - visualViewport.height; + + // Position the banner down by the keyboard height so it appears at the top of the visible area + // This puts it "halfway down the page" when keyboard covers half the screen + if (keyboardHeight > 0) { + container.style.top = `${keyboardHeight}px`; + } else { + // Reset to CSS default (env(safe-area-inset-top)) + container.style.top = ''; + } + }; + + const visualViewport = window.visualViewport!; + visualViewport.addEventListener('resize', updatePosition); + visualViewport.addEventListener('scroll', updatePosition); + updatePosition(); // Initial position + + return () => { + visualViewport.removeEventListener('resize', updatePosition); + visualViewport.removeEventListener('scroll', updatePosition); + }; + }, []); // Push new notifications into the local queue. useEffect(() => { if (!banner) return; + log.log('[Banner] New banner from atom:', banner.id, banner.title); setQueue((prev) => { // De-duplicate by id - if (prev.some((n) => n.id === banner.id)) return prev; + if (prev.some((n) => n.id === banner.id)) { + log.log('[Banner] Duplicate banner, skipping:', banner.id); + return prev; + } // Keep at most 3 visible at once — drop the oldest if over limit. const next = [...prev, banner]; + log.log('[Banner] Adding to queue, new length:', next.length); return next.length > 3 ? next.slice(next.length - 3) : next; }); // Clear the atom so the same notification doesn't re-enqueue on re-render. @@ -189,13 +234,18 @@ export function NotificationBanner() { }, [banner, setBanner]); const handleDismiss = (id: string) => { + log.log('[Banner] Dismissing banner:', id); setQueue((prev) => prev.filter((n) => n.id !== id)); }; - if (queue.length === 0) return null; + if (queue.length === 0) { + log.log('[Banner] No banners in queue, returning null'); + return null; + } + log.log('[Banner] Rendering', queue.length, 'banners'); return ( -
+
{queue.map((n) => ( ))} diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx index 61ace27c4..5ee60a178 100644 --- a/src/app/features/settings/notifications/AllMessages.tsx +++ b/src/app/features/settings/notifications/AllMessages.tsx @@ -108,7 +108,8 @@ export function AllMessagesNotifications() { gap="400" > } /> @@ -119,7 +120,8 @@ export function AllMessagesNotifications() { gap="400" > { - if (!shouldRunBackgroundNotifications) return undefined; + if (!shouldRunBackgroundNotifications) { + return undefined; + } const { current } = clientsRef; const activeIds = new Set(inactiveSessions.map((s) => s.userId)); @@ -187,7 +192,6 @@ export function BackgroundNotifications() { current.forEach((mx, userId) => { if (!activeIds.has(userId)) { - log.log('stopping background client for', userId); stopClient(mx); current.delete(userId); // Clear the background unread badge when this session is no longer a background account. @@ -200,18 +204,53 @@ export function BackgroundNotifications() { }); inactiveSessions.forEach((session) => { - if (current.has(session.userId)) return; - - log.log('starting background client for', session.userId); + const alreadyRunning = current.has(session.userId); + if (alreadyRunning) return; startBackgroundClient(session, clientConfig.slidingSync) .then(async (mx) => { current.set(session.userId, mx); await waitForSync(mx); - log.log('background client synced for', session.userId); + + // Wait for m.direct account data to load. This is critical for DM detection. + // Without it, rooms in /direct/ won't be recognized as DMs, causing notifications to fail. + let mDirectsSet: Set | undefined; + const mDirectEvent = mx.getAccountData('m.direct' as any); + if (mDirectEvent) { + mDirectsSet = getMDirects(mDirectEvent); + } else { + // Account data not loaded yet; wait for it + await new Promise((resolve) => { + const handler = (event: MatrixEvent) => { + if (event.getType() === 'm.direct') { + mDirectsSet = getMDirects(event); + mx.off(ClientEvent.AccountData as any, handler); + resolve(); + } + }; + mx.on(ClientEvent.AccountData as any, handler); + // Timeout after 5s to avoid blocking forever if m.direct never arrives + setTimeout(() => { + mx.off(ClientEvent.AccountData as any, handler); + resolve(); + }, 5000); + }); + } const pushProcessor = new PushProcessor(mx); + // Keep mDirectsSet updated when m.direct account data changes + const handleAccountData = (event: MatrixEvent) => { + if (event.getType() === 'm.direct') { + mDirectsSet = getMDirects(event); + } + }; + mx.on(ClientEvent.AccountData as any, handleAccountData); + + // Track encrypted events that are being decrypted to avoid re-checking the + // encryption guard when the Decrypted callback fires. + const decryptingEvents = new Set(); + const handleTimeline = ( mEvent: MatrixEvent, room: Room | undefined, @@ -220,22 +259,83 @@ export function BackgroundNotifications() { data: { liveEvent: boolean } ) => { if (!isClientReadyForNotifications(mx.getSyncState())) return; - if (!room || !data?.liveEvent || room.isSpaceRoom()) return; - if (!isNotificationEvent(mEvent)) return; - - const notifType = getNotificationType(mx, room.roomId); - if (notifType === NotificationType.Mute) return; + if (!room || room.isSpaceRoom()) return; + // Allow recent events even if liveEvent is false (e.g., after decryption) + // Historical filter: event is old (>60s before start) AND already read const eventId = mEvent.getId(); if (!eventId) return; + + const eventType = mEvent.getType(); + const isEncryptedType = eventType === 'm.room.encrypted'; + + // For encrypted events that haven't been decrypted yet, wait for decryption + // before processing the notification. The SDK's Timeline re-emission after + // decryption comes with data.liveEvent=false which would wrongly block it. + // Check this BEFORE the liveEvent check so we can attach the listener early. + if ( + eventId && + !decryptingEvents.has(eventId) && + mEvent.isEncrypted() && + isEncryptedType + ) { + decryptingEvents.add(eventId); + const handleDecrypted = () => { + // After decryption, run the notification logic with the decrypted event. + // Force liveEvent=true since the SDK's re-emission sets it to false. + handleTimeline(mEvent, room, toStartOfTimeline, removed, { liveEvent: true }); + // Clean up the tracking flag + decryptingEvents.delete(eventId); + }; + mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); + return; + } + + // Trust the SDK's liveEvent flag for non-encrypted events. + // Encrypted events are handled above via the Decrypted listener. + if (!data?.liveEvent) { + return; + } + + if (!isNotificationEvent(mEvent)) { + return; + } + + const notificationType = getNotificationType(mx, room.roomId); + if (notificationType === NotificationType.Mute) { + return; + } + const dedupeId = `${session.userId}:${eventId}`; - if (notifiedEventsRef.current.has(dedupeId)) return; + if (notifiedEventsRef.current.has(dedupeId)) { + return; + } const sender = mEvent.getSender(); - if (!sender || sender === mx.getUserId()) return; + if (!sender || sender === mx.getUserId()) { + return; + } + + // Check if this is a DM using multiple signals for robustness + // Use the mDirectsSet that was loaded during initialization + const isDM = isDMRoom(room, mDirectsSet); const pushActions = pushProcessor.actionsForEvent(mEvent); - if (!pushActions?.notify) return; + // For DMs with "All Messages" or "Default" notification settings: + // Always notify even if push rules fail to match due to sliding sync limitations. + // For "Mention & Keywords": respect the push rule (only notify if it matches). + const shouldForceDMNotification = + isDM && notificationType !== NotificationType.MentionsAndKeywords; + // For reactions: Always notify regardless of push rules since the room is not muted + // (muted rooms are filtered out earlier). Reactions are part of room activity + // and users expect to see them. + const shouldForceReactionNotification = eventType === 'm.reaction'; + const shouldNotify = + pushActions?.notify || shouldForceDMNotification || shouldForceReactionNotification; + + if (!shouldNotify) { + return; + } const senderName = getMemberDisplayName(room, sender, nicknamesRef.current) ?? @@ -264,7 +364,9 @@ export function BackgroundNotifications() { }); // Silent-rule events: unread badge updated above; no OS notification or sound. - if (!loudByRule && !isHighlight) return; + if (!loudByRule && !isHighlight) { + return; + } const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption); @@ -275,15 +377,6 @@ export function BackgroundNotifications() { if (first) notifiedEventsRef.current.delete(first); } - // This component handles ONLY background (inactive) accounts. - // SW push covers the active account when the app is backgrounded. - // When the page is hidden, iOS suspends JS entirely — nothing to do here. - // Only show an in-app notification when the user is actively looking at the app. - if (document.visibilityState !== 'visible') return; - - // Respect in-app notification setting (read from ref to avoid stale closure) - if (!mobileOrTablet() || !showNotificationsRef.current) return; - const notificationPayload = buildRoomMessageNotification({ roomName: room.name ?? room.getCanonicalAlias() ?? room.roomId, roomAvatar, @@ -314,7 +407,13 @@ export function BackgroundNotifications() { setPending({ roomId: room.roomId, eventId, targetSessionId: session.userId }); }; - if (document.visibilityState === 'visible') { + // Show in-app banner when app is visible, mobile, and in-app notifications enabled + const canShowInAppBanner = + document.visibilityState === 'visible' && + mobileOrTablet() && + showNotificationsRef.current; + + if (canShowInAppBanner) { // App is in the foreground on a different account — show the themed in-app banner. setInAppBannerRef.current({ id: dedupeId, @@ -326,9 +425,8 @@ export function BackgroundNotifications() { onClick: notifOnClick, }); } else if (loudByRule) { - // App is backgrounded — fire an OS notification only for loud (sound-tweak) rules. - // Highlight-only events are silently counted in the badge; OS noise is left to the - // user's own push rules for that account. + // 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. sendNotification({ title: notificationPayload.title, icon: notificationPayload.options.icon, diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 9cb4a3f41..4bbd4068f 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,6 +1,5 @@ import { ReactNode } from 'react'; import { Box } from 'folds'; -import { NotificationBanner } from '$components/notification-banner'; type ClientLayoutProps = { nav: ReactNode; @@ -9,7 +8,6 @@ type ClientLayoutProps = { export function ClientLayout({ nav, children }: ClientLayoutProps) { return ( - {nav} {children} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9bf2162d1..f49dddbb1 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,7 +1,13 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { PushProcessor, RoomEvent, RoomEventHandlerMap, SetPresence } from '$types/matrix-sdk'; +import { + MatrixEventEvent, + PushProcessor, + RoomEvent, + RoomEventHandlerMap, + SetPresence, +} from '$types/matrix-sdk'; import parse from 'html-react-parser'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; import { sanitizeCustomHtml } from '$utils/sanitize'; @@ -23,6 +29,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + isDMRoom, isNotificationEvent, } from '$utils/room'; import { NotificationType, StateEvent } from '$types/matrix/room'; @@ -39,6 +46,7 @@ import { import { mobileOrTablet } from '$utils/user-agent'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; +import { NotificationBanner } from '$components/notification-banner'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -250,6 +258,11 @@ function MessageNotifications() { useEffect(() => { const pushProcessor = new PushProcessor(mx); + // Track encrypted events that should skip focus check when decrypted (because we + // already checked focus when the encrypted event arrived, and want to use that + // original state rather than re-checking after decryption completes). + const skipFocusCheckEvents = new Set(); + const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( mEvent, room, @@ -258,7 +271,13 @@ function MessageNotifications() { data ) => { if (mx.getSyncState() !== 'SYNCING') return; - if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; + + const eventId = mEvent.getId(); + const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId); + if (!shouldSkipFocusCheck) { + if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) + return; + } // Older sliding sync proxies (e.g. matrix-sliding-sync) omit num_live, // which causes every event to arrive with fromCache=true and therefore @@ -274,39 +293,60 @@ function MessageNotifications() { (mEvent.getTs() < clientStartTimeRef.current - 60 * 1000 || (!!room && room.hasUserReadEvent(mx.getSafeUserId(), mEvent.getId()!))); - // m.room.encrypted events haven't been decrypted yet; the SDK will - // re-emit the event after decryption with the real type and content. - // Without this guard we'd add the eventId to notifiedEventsRef here, - // causing the decrypted re-emission to be deduped — showing - // "Encrypted Message" instead of the actual content. - if (mEvent.getType() === 'm.room.encrypted') return; - - if ( - !room || - isHistoricalEvent || - room.isSpaceRoom() || - !isNotificationEvent(mEvent) || - getNotificationType(mx, room.roomId) === NotificationType.Mute - ) { + // For encrypted events that haven't been decrypted yet, wait for decryption + // before processing the notification. The SDK's Timeline re-emission after + // decryption comes with data.liveEvent=false which would wrongly block it. + if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { + if (eventId) { + // Mark this event to skip focus check when decrypted, so we use the focus + // state from when the encrypted event originally arrived, not when it decrypts. + skipFocusCheckEvents.add(eventId); + } + + const handleDecrypted = () => { + // After decryption, run the notification logic with the decrypted event + handleTimelineEvent(mEvent, room, undefined, removed, data); + // Clean up the skip-focus marker + if (eventId) { + skipFocusCheckEvents.delete(eventId); + } + }; + mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); + return; + } + + if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + return; + } + + const notificationType = getNotificationType(mx, room.roomId); + if (notificationType === NotificationType.Mute) { return; } const sender = mEvent.getSender(); - const eventId = mEvent.getId(); if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; // Deduplicate: don't show a second banner if this event fires twice // (e.g., decrypted events re-emitted by the SDK). if (notifiedEventsRef.current.has(eventId)) return; + // Check if this is a DM using multiple signals for robustness + const isDM = isDMRoom(room, mDirectsRef.current); const pushActions = pushProcessor.actionsForEvent(mEvent); - if (!pushActions?.notify) return; + + // For DMs with "All Messages" or "Default" notification settings: + // Always notify even if push rules fail to match due to sliding sync limitations. + // For "Mention & Keywords": respect the push rule (only notify if it matches). + const shouldForceDMNotification = + isDM && notificationType !== NotificationType.MentionsAndKeywords; + const shouldNotify = pushActions?.notify || shouldForceDMNotification; + + // If we shouldn't notify based on rules/settings, skip everything + if (!shouldNotify) return; + const loudByRule = Boolean(pushActions.tweaks?.sound); const isHighlightByRule = Boolean(pushActions.tweaks?.highlight); - const isDM = mDirectsRef.current.has(room.roomId); - - // If neither a loud nor a highlight rule matches, and it's not a DM, nothing to show. - if (!isHighlightByRule && !loudByRule && !isDM) return; // With sliding sync we only load m.room.member/$ME in required_state, so // PushProcessor cannot evaluate the room_member_count == 2 condition on @@ -371,7 +411,9 @@ function MessageNotifications() { if (document.visibilityState !== 'visible') return; // Page is visible — show the themed in-app notification banner. - if (showNotifications && (isHighlightByRule || isLoud)) { + // For non-DM rooms, only show banner for highlighted messages (mentions/keywords). + // For DMs, show banner for all messages. + if (showNotifications && (isHighlightByRule || isDM)) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const roomAvatar = avatarMxc @@ -438,8 +480,8 @@ function MessageNotifications() { }); } - // In-app audio: play whenever notification sounds are enabled. - if (notificationSound) { + // In-app audio: play when notification sounds are enabled AND this notification is loud. + if (notificationSound && isLoud) { playSound(); } }; @@ -602,6 +644,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index f5aa587c4..43ccb6be1 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; +import { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, @@ -18,6 +18,7 @@ import { import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; import { useNavigate } from 'react-router-dom'; +import { RoomEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { factoryRoomIdByActivity } from '$utils/sort'; import { @@ -183,6 +184,33 @@ export function Direct() { const noRoomToDisplay = directs.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + // Track timeline activity to trigger re-sorting when messages arrive. + // Without this, DMs only re-sort when you switch rooms because getLastActiveTimestamp() + // is internal SDK state not tracked by React dependencies. + const [activityCounter, setActivityCounter] = useState(0); + const directsSetRef = useRef(directs); + directsSetRef.current = directs; + + useEffect(() => { + const handleTimeline = () => { + // Increment counter to trigger re-sort when any timeline event happens + setActivityCounter((prev) => prev + 1); + }; + + // Listen to timeline events only for direct message rooms + directsSetRef.current.forEach((roomId) => { + const room = mx.getRoom(roomId); + room?.on(RoomEvent.Timeline, handleTimeline); + }); + + return () => { + directsSetRef.current.forEach((roomId) => { + const room = mx.getRoom(roomId); + room?.off(RoomEvent.Timeline, handleTimeline); + }); + }; + }, [mx, directs]); + const sortedDirects = useMemo(() => { const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); const hasUnread = (roomId: string) => { @@ -193,7 +221,8 @@ export function Direct() { return items.filter((rId) => hasUnread(rId) || rId === selectedRoomId); } return items; - }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mx, directs, closedCategories, roomToUnread, selectedRoomId, activityCounter]); const virtualizer = useVirtualizer({ count: sortedDirects.length, diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 61058d95c..7d2a3ca11 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -152,9 +152,10 @@ export function AccountSwitcherTab() { const totalBackgroundUnread = Object.entries(backgroundUnreads) .filter(([uid]) => uid !== (activeSessionId ?? sessions[0]?.userId)) .reduce((acc, [, u]) => acc + u.total, 0); - const anyBackgroundHighlight = Object.entries(backgroundUnreads) + const totalBackgroundHighlight = Object.entries(backgroundUnreads) .filter(([uid]) => uid !== (activeSessionId ?? sessions[0]?.userId)) - .some(([, u]) => u.highlight > 0); + .reduce((acc, [, u]) => acc + u.highlight, 0); + const anyBackgroundHighlight = totalBackgroundHighlight > 0; const [menuAnchor, setMenuAnchor] = useState(); const [busyUserIds, setBusyUserIds] = useState>(new Set()); @@ -274,9 +275,12 @@ export function AccountSwitcherTab() { )} - {totalBackgroundUnread > 0 && ( + {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( - + )} diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index cf0064380..e70271d2c 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -1,5 +1,5 @@ import { produce } from 'immer'; -import { atom, useSetAtom } from 'jotai'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; import { IRoomTimelineData, MatrixClient, @@ -32,6 +32,7 @@ import { useStateEventCallback } from '$hooks/useStateEventCallback'; import { useSyncState } from '$hooks/useSyncState'; import { useRoomsNotificationPreferencesContext } from '$hooks/useRoomsNotificationPreferences'; import { getClientSyncDiagnostics } from '$client/initMatrix'; +import { mDirectAtom } from '$state/mDirectList'; import { roomToParentsAtom } from './roomToParents'; export type RoomToUnreadAction = @@ -179,6 +180,7 @@ export const roomToUnreadAtom = atom { const setUnreadAtom = useSetAtom(unreadAtom); const roomsNotificationPreferences = useRoomsNotificationPreferencesContext(); + const mDirects = useAtomValue(mDirectAtom); const spaceChildResetTimerRef = useRef(null); const shouldApplyUnreadFixup = useCallback( () => getClientSyncDiagnostics(mx).transport === 'sliding', @@ -188,9 +190,9 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo useEffect(() => { setUnreadAtom({ type: 'RESET', - unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup() }), + unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), mDirects }), }); - }, [mx, setUnreadAtom, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); useSyncState( mx, @@ -202,11 +204,11 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo ) { setUnreadAtom({ type: 'RESET', - unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup() }), + unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), mDirects }), }); } }, - [mx, setUnreadAtom, shouldApplyUnreadFixup] + [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects] ) ); @@ -218,7 +220,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo removed: boolean, data: IRoomTimelineData ) => { - if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return; + if (!room || room.isSpaceRoom()) return; if (getNotificationType(mx, room.roomId) === NotificationType.Mute) { setUnreadAtom({ type: 'DELETE', @@ -227,17 +229,36 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return; } - if (mEvent.getSender() === mx.getUserId()) return; - setUnreadAtom({ - type: 'PUT', - unreadInfo: getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup() }), - }); + // Handle live events (new messages arriving in real-time) + if (data.liveEvent && isNotificationEvent(mEvent)) { + if (mEvent.getSender() === mx.getUserId()) return; + const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup(), mDirects }); + setUnreadAtom({ + type: 'PUT', + unreadInfo, + }); + return; + } + + // Handle non-live events (initial sync/sliding sync timeline population) + // For rooms without read receipts (unvisited in sliding sync), check if they need badges + const userId = mx.getUserId(); + if (!data.liveEvent && userId && !room.getEventReadUpTo(userId)) { + // Room has no read receipt - check if timeline activity warrants a badge + const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup(), mDirects }); + if (unreadInfo.total > 0 || unreadInfo.highlight > 0) { + setUnreadAtom({ + type: 'PUT', + unreadInfo, + }); + } + } }; mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleReceipt = (mEvent: MatrixEvent, room: Room) => { @@ -252,7 +273,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo ) ); if (isMyReceipt) { - const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup() }); + const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup(), mDirects }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); return; @@ -264,7 +285,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return () => { mx.removeListener(RoomEvent.Receipt, handleReceipt); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleUnreadNotifications = ( @@ -275,7 +296,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo if (!room || room.isSpaceRoom()) return; if (room.getMyMembership() !== Membership.Join) return; - const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup() }); + const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup(), mDirects }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); return; @@ -286,14 +307,14 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return () => { (mx as any).removeListener(RoomEvent.UnreadNotifications, handleUnreadNotifications); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleRoomAccountData = (mEvent: MatrixEvent, room: Room) => { if (room.isSpaceRoom()) return; if (mEvent.getType() !== EventType.FullyRead) return; - const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup() }); + const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup(), mDirects }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); return; @@ -304,14 +325,14 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return () => { mx.removeListener(RoomEvent.AccountData, handleRoomAccountData); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); useEffect(() => { setUnreadAtom({ type: 'RESET', - unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup() }), + unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), mDirects }), }); - }, [mx, setUnreadAtom, roomsNotificationPreferences, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, roomsNotificationPreferences, shouldApplyUnreadFixup, mDirects]); useEffect(() => { const handleMembershipChange = (room: Room, membership: string) => { @@ -336,7 +357,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo useEffect(() => { const handleRoomAdded = (room: Room) => { if (room.isSpaceRoom() || room.getMyMembership() !== Membership.Join) return; - const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup() }); + const unreadInfo = getUnreadInfo(room, { applyFixup: shouldApplyUnreadFixup(), mDirects }); if (unreadInfo.total > 0 || unreadInfo.highlight > 0) { setUnreadAtom({ type: 'PUT', unreadInfo }); } @@ -345,7 +366,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo return () => { mx.removeListener(ClientEvent.Room, handleRoomAdded as (room: Room) => void); }; - }, [mx, setUnreadAtom, shouldApplyUnreadFixup]); + }, [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects]); useEffect( () => () => { @@ -373,13 +394,13 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo spaceChildResetTimerRef.current = window.setTimeout(() => { setUnreadAtom({ type: 'RESET', - unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup() }), + unreadInfos: getUnreadInfos(mx, { applyFixup: shouldApplyUnreadFixup(), mDirects }), }); spaceChildResetTimerRef.current = null; }, 150); } }, - [mx, setUnreadAtom, shouldApplyUnreadFixup] + [mx, setUnreadAtom, shouldApplyUnreadFixup, mDirects] ) ); }; diff --git a/src/app/utils/notificationStyle.ts b/src/app/utils/notificationStyle.ts index 550a511f7..6a616b448 100644 --- a/src/app/utils/notificationStyle.ts +++ b/src/app/utils/notificationStyle.ts @@ -51,6 +51,18 @@ export const resolveNotificationPreviewText = ({ showMessageContent, showEncryptedMessageContent, }: NotificationPreviewInput): string => { + // Handle reactions specially - show the reaction emoji + if (eventType === 'm.reaction' && content && typeof content === 'object') { + const relatesTo = (content as Record)['m.relates_to']; + if (relatesTo && typeof relatesTo === 'object') { + const { key } = relatesTo as Record; + if (typeof key === 'string') { + return `Reacted with ${key}`; + } + } + return 'Added a reaction'; + } + const encryptedContext = isEncryptedRoom || eventType === 'm.room.encrypted'; if (!showMessageContent) { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e2b319391..cb30a1af9 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -94,6 +94,28 @@ export const isUnsupportedRoom = (room: Room | null): boolean => { return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space; }; +/** + * Detects if a room is a direct message room using multiple signals for robustness: + * 1. Primary: checks if room is in mDirects set (from m.direct account data) + * 2. Fallback: checks if room has exactly 2 joined members (classic DM heuristic) + * + * The fallback handles cases where m.direct account data is incomplete or outdated. + */ +export const isDMRoom = (room: Room, mDirects?: Set): boolean => { + // Primary signal: check m.direct account data + if (mDirects?.has(room.roomId)) { + return true; + } + + // Fallback: use member count heuristic for untagged DMs + // Only applies to non-space rooms with exactly 2 members (you + them) + if (!room.isSpaceRoom() && room.getJoinedMemberCount() === 2) { + return true; + } + + return false; +}; + export function isValidChild(mEvent: MatrixEvent): boolean { return ( mEvent.getType() === StateEvent.SpaceChild && @@ -190,6 +212,7 @@ const NOTIFICATION_EVENT_TYPES = [ 'm.room.encrypted', 'm.room.member', 'm.sticker', + 'm.reaction', ]; export const isNotificationEvent = (mEvent: MatrixEvent) => { const eType = mEvent.getType(); @@ -213,24 +236,34 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => { const userId = mx.getUserId(); if (!userId) return false; const readUpToId = room.getEventReadUpTo(userId); - if (!readUpToId) return false; const liveEvents = room.getLiveTimeline().getEvents(); - if (liveEvents[liveEvents.length - 1]?.getSender() === userId) { + if (!readUpToId) { + return false; + } + + const latestEvent = liveEvents[liveEvents.length - 1]; + + if (latestEvent?.getSender() === userId) { return false; } for (let i = liveEvents.length - 1; i >= 0; i -= 1) { const event = liveEvents[i]; if (!event) return false; - if (event.getId() === readUpToId) return false; - if (isNotificationEvent(event)) return true; + if (event.getId() === readUpToId) { + return false; + } + if (isNotificationEvent(event)) { + return true; + } } return false; }; type UnreadInfoOptions = { applyFixup?: boolean; + mDirects?: Set; }; export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadInfo => { @@ -243,6 +276,14 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn let total = room.getUnreadNotificationCount(NotificationCountType.Total); const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); + // Check if this is a DM and what notification type it has (using multiple signals for robustness) + const isDM = isDMRoom(room, options?.mDirects); + const notificationType = isDM ? getNotificationType(room.client, room.roomId) : undefined; + const shouldForceDMHighlight = + isDM && + notificationType !== NotificationType.Mute && + notificationType !== NotificationType.MentionsAndKeywords; + // If our latest main-timeline notification event is confirmed read, clamp its stale count. // Only apply to the room (non-thread) portion so thread reply counts are preserved. // Guard: only clamp when the room has NO receipt-confirmed unread events; if roomHaveUnread @@ -291,6 +332,43 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } + // Sliding sync limitation: unvisited rooms don't have read receipt data, but may have + // timeline activity. Check for notification events from others in the timeline to show a + // badge even when SDK counts are 0 (or unreliable without receipts). + if (userId) { + const readUpToId = room.getEventReadUpTo(userId); + + // If we have no read receipt, SDK counts may be unreliable. Always check timeline. + if (!readUpToId) { + const liveEvents = room.getLiveTimeline().getEvents(); + + const hasActivity = liveEvents.some( + (event) => event.getSender() !== userId && isNotificationEvent(event) + ); + + if (hasActivity) { + // If SDK already has counts, use those. Otherwise show dot badge (count=1). + if (total === 0 && highlight === 0) { + return { roomId: room.roomId, highlight: 0, total: 1 }; + } + // SDK has counts but no receipt - trust the counts and show them + return { roomId: room.roomId, highlight, total }; + } + } + } + + // For DMs with Default or AllMessages notification type: if there are unread messages, + // ensure we show a notification badge (treat as highlight for badge color purposes). + // This handles cases where push rules don't properly match (e.g., classic sync with + // member_count condition failures, or sliding sync with limited required_state). + if (shouldForceDMHighlight && total > 0 && highlight === 0) { + return { + roomId: room.roomId, + highlight: total, // Treat all unread messages as highlights for DMs + total, + }; + } + return { roomId: room.roomId, highlight, @@ -298,22 +376,24 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn }; }; -export const getUnreadInfos = (mx: MatrixClient, options?: UnreadInfoOptions): UnreadInfo[] => - mx.getRooms().reduce((unread, room) => { +export const getUnreadInfos = (mx: MatrixClient, options?: UnreadInfoOptions): UnreadInfo[] => { + const unreadInfos = mx.getRooms().reduce((unread, room) => { if (room.isSpaceRoom()) return unread; if (room.getMyMembership() !== 'join') return unread; if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread; - if (roomHaveNotification(room) || roomHaveUnread(mx, room)) { - const unreadInfo = getUnreadInfo(room, options); - if (unreadInfo.total > 0 || unreadInfo.highlight > 0) { - unread.push(unreadInfo); - } + // Always call getUnreadInfo - it has fallback logic for sliding sync rooms without receipts + const unreadInfo = getUnreadInfo(room, options); + if (unreadInfo.total > 0 || unreadInfo.highlight > 0) { + unread.push(unreadInfo); } return unread; }, []); + return unreadInfos; +}; + export const getRoomIconSrc = ( icons: Record, roomType?: string, From 8476116ed830b0ff2c33816101c10250654ac8f3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 20:50:37 -0400 Subject: [PATCH 2/2] Fix: Only send reaction notifications for reactions to your own messages --- src/app/pages/client/BackgroundNotifications.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 167e4f2f0..9ff5ab57f 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -326,10 +326,18 @@ export function BackgroundNotifications() { // For "Mention & Keywords": respect the push rule (only notify if it matches). const shouldForceDMNotification = isDM && notificationType !== NotificationType.MentionsAndKeywords; - // For reactions: Always notify regardless of push rules since the room is not muted - // (muted rooms are filtered out earlier). Reactions are part of room activity - // and users expect to see them. - const shouldForceReactionNotification = eventType === 'm.reaction'; + // For reactions: Only notify if someone reacted to your own message + let shouldForceReactionNotification = false; + if (eventType === 'm.reaction') { + const relatesTo = mEvent.getContent()['m.relates_to']; + const reactedToEventId = relatesTo?.event_id; + if (reactedToEventId) { + const reactedToEvent = room.findEventById(reactedToEventId); + if (reactedToEvent && reactedToEvent.getSender() === mx.getUserId()) { + shouldForceReactionNotification = true; + } + } + } const shouldNotify = pushActions?.notify || shouldForceDMNotification || shouldForceReactionNotification;