From 501d493ca46623767ff33365db77c15dbb5f1342 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 1 Jul 2026 22:39:10 -0400 Subject: [PATCH] feat(threads): Slack-style per-thread notifications (P4-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default = Participating: thread replies notify only when you've posted in the thread or are @mentioned; per-thread override to All / Mentions-only / Mute via a bell menu in the thread panel header. Modes sync across devices in io.lotus.thread_notifications account data (pruned on write: left rooms, >180d, cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed (+BellMute glyph), and their counts are subtracted from the room's sidebar badge (client-side; clamped ≥0). Also fixes the thread notification path itself: thread replies are now owned by exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and the unread binder are explicitly thread-guarded, eliminating the previously un-gated/double path. Room badges now also refresh live on RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag). Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may under-notify pre-decryption (same class as the existing path). Co-Authored-By: Claude Opus 4.8 --- .../ThreadNotificationModeSwitcher.tsx | 127 +++++++++ src/app/features/room/thread/ThreadPanel.tsx | 44 ++- .../features/room/thread/ThreadSummary.tsx | 4 +- src/app/hooks/useRoomsListener.ts | 64 +++++ src/app/hooks/useThreadNotifications.ts | 97 +++++++ src/app/hooks/useThreadSummary.ts | 14 +- src/app/pages/client/ClientNonUIFeatures.tsx | 190 +++++++++---- src/app/state/hooks/useBindAtoms.ts | 2 + src/app/state/room/roomToUnread.ts | 73 ++++- src/app/state/threadNotifications.ts | 36 +++ src/app/utils/room.test.ts | 57 ++++ src/app/utils/room.ts | 29 +- src/app/utils/threadNotifications.test.ts | 260 ++++++++++++++++++ src/app/utils/threadNotifications.ts | 196 +++++++++++++ src/types/matrix/accountData.ts | 4 + 15 files changed, 1129 insertions(+), 68 deletions(-) create mode 100644 src/app/components/ThreadNotificationModeSwitcher.tsx create mode 100644 src/app/hooks/useRoomsListener.ts create mode 100644 src/app/hooks/useThreadNotifications.ts create mode 100644 src/app/state/threadNotifications.ts create mode 100644 src/app/utils/threadNotifications.test.ts create mode 100644 src/app/utils/threadNotifications.ts diff --git a/src/app/components/ThreadNotificationModeSwitcher.tsx b/src/app/components/ThreadNotificationModeSwitcher.tsx new file mode 100644 index 000000000..d6be577e0 --- /dev/null +++ b/src/app/components/ThreadNotificationModeSwitcher.tsx @@ -0,0 +1,127 @@ +import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { stopPropagation } from '../utils/keyboard'; +import { ThreadNotificationMode } from '../utils/threadNotifications'; +import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications'; +import { AsyncStatus } from '../hooks/useAsyncCallback'; + +export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => { + if (mode === ThreadNotificationMode.Mute) return Icons.BellMute; + if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing; + if (mode === ThreadNotificationMode.All) return Icons.BellRing; + + return Icons.Bell; +}; + +const useThreadNotificationModes = (): ThreadNotificationMode[] => + useMemo( + () => [ + ThreadNotificationMode.Default, + ThreadNotificationMode.All, + ThreadNotificationMode.MentionsOnly, + ThreadNotificationMode.Mute, + ], + [], + ); + +const useThreadNotificationModeStr = (): Record => + useMemo( + () => ({ + [ThreadNotificationMode.Default]: 'Default (participating)', + [ThreadNotificationMode.All]: 'All replies', + [ThreadNotificationMode.MentionsOnly]: 'Mentions only', + [ThreadNotificationMode.Mute]: 'Mute', + }), + [], + ); + +type ThreadNotificationModeSwitcherProps = { + roomId: string; + threadId: string; + value?: ThreadNotificationMode; + children: ( + handleOpen: MouseEventHandler, + opened: boolean, + changing: boolean, + ) => ReactNode; +}; +export function ThreadNotificationModeSwitcher({ + roomId, + threadId, + value = ThreadNotificationMode.Default, + children, +}: ThreadNotificationModeSwitcherProps) { + const modes = useThreadNotificationModes(); + const modeToStr = useThreadNotificationModeStr(); + + const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId); + const changing = modeState.status === AsyncStatus.Loading; + + const [menuCords, setMenuCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleClose = () => { + setMenuCords(undefined); + }; + + const handleSelect = (mode: ThreadNotificationMode) => { + if (changing) return; + setMode(mode); + handleClose(); + }; + + return ( + + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + {modes.map((mode) => ( + handleSelect(mode)} + before={ + + } + > + + {mode === value ? {modeToStr[mode]} : modeToStr[mode]} + + + ))} + + + + } + > + {children(handleOpenMenu, !!menuCords, changing)} + + ); +} diff --git a/src/app/features/room/thread/ThreadPanel.tsx b/src/app/features/room/thread/ThreadPanel.tsx index 3fb8501b2..42ca666a9 100644 --- a/src/app/features/room/thread/ThreadPanel.tsx +++ b/src/app/features/room/thread/ThreadPanel.tsx @@ -23,12 +23,21 @@ import { useKeyDown } from '../../../hooks/useKeyDown'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { RoomInput } from '../RoomInput'; +import { + getThreadNotificationModeIcon, + ThreadNotificationModeSwitcher, +} from '../../../components/ThreadNotificationModeSwitcher'; +import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications'; +import { ThreadNotificationMode } from '../../../utils/threadNotifications'; type ThreadPanelHeaderProps = { room: Room; + threadId: string; requestClose: () => void; }; -function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) { +function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) { + const mode = useThreadNotificationMode(room.roomId, threadId); + return (
@@ -40,7 +49,36 @@ function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) { {room.name} - + + + {(handleOpen, opened) => ( + + Notifications + + } + > + {(triggerRef) => ( + + + + )} + + )} + - + {!thread ? ( diff --git a/src/app/features/room/thread/ThreadSummary.tsx b/src/app/features/room/thread/ThreadSummary.tsx index 43e17ea27..10bce29e6 100644 --- a/src/app/features/room/thread/ThreadSummary.tsx +++ b/src/app/features/room/thread/ThreadSummary.tsx @@ -5,6 +5,7 @@ import { useThreadSummary } from '../../../hooks/useThreadSummary'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time'; +import { ThreadNotificationMode } from '../../../utils/threadNotifications'; type ThreadSummaryProps = { rootEvent: MatrixEvent; @@ -12,7 +13,7 @@ type ThreadSummaryProps = { onOpen: (threadId: string) => void; }; export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) { - const { summary, unread } = useThreadSummary(rootEvent, room); + const { summary, unread, mode } = useThreadSummary(rootEvent, room); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); if (!summary || summary.count === 0) return null; @@ -43,6 +44,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) { {count === 1 ? '1 reply' : `${count} replies`} {latestStr ? ` · ${latestStr}` : ''} + {mode === ThreadNotificationMode.Mute && } ); diff --git a/src/app/hooks/useRoomsListener.ts b/src/app/hooks/useRoomsListener.ts new file mode 100644 index 000000000..33bb80f7c --- /dev/null +++ b/src/app/hooks/useRoomsListener.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react'; +import { + ClientEvent, + MatrixClient, + Room, + RoomEmittedEvents, + RoomEventHandlerMap, +} from 'matrix-js-sdk'; + +/** + * Attach `handler` for `event` on every joined/known room, including rooms + * created after mount (via `ClientEvent.Room`). All listeners are detached on + * unmount or when `mx`/`event` change. + * + * The handler is stored in a ref (mirroring `useTauriEvent`) so callers don't + * need to memoize it — changing the handler identity never re-attaches the + * per-room listeners. + * + * The emitting {@link Room} is appended as a FINAL extra argument after the + * event's own args: several room-level SDK events (e.g. + * `RoomEvent.UnreadNotifications`) don't include the room in their payload, + * which callers need for per-room updates. Handlers that don't care can simply + * ignore it. + */ +export function useRoomsListener( + mx: MatrixClient, + event: E, + handler: (...args: [...Parameters, Room]) => void, +): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + // Track attached rooms (and their per-room trampolines) so re-emitted + // `ClientEvent.Room` (e.g. on membership changes) never double-subscribes, + // and cleanup can detach exactly what was attached. + const attached = new Map void>(); + + const attach = (room: Room) => { + if (attached.has(room.roomId)) return; + // Per-room trampoline: forwards to the current ref value with the + // emitting room appended. + const roomHandler = (...args: unknown[]) => + (handlerRef.current as (...a: unknown[]) => void)(...args, room); + attached.set(room.roomId, roomHandler); + // `event`/`roomHandler` are correlated through E but TS can't prove it + // for the open generic, so we assert at the boundary. + room.on(event, roomHandler as any); + }; + + mx.getRooms().forEach(attach); + + const handleRoom = (room: Room) => attach(room); + mx.on(ClientEvent.Room, handleRoom); + + return () => { + mx.removeListener(ClientEvent.Room, handleRoom); + attached.forEach((roomHandler, roomId) => { + mx.getRoom(roomId)?.removeListener(event, roomHandler as any); + }); + attached.clear(); + }; + }, [mx, event]); +} diff --git a/src/app/hooks/useThreadNotifications.ts b/src/app/hooks/useThreadNotifications.ts new file mode 100644 index 000000000..77fbe050c --- /dev/null +++ b/src/app/hooks/useThreadNotifications.ts @@ -0,0 +1,97 @@ +import { useCallback } from 'react'; +import { useAtomValue } from 'jotai'; +import { MatrixClient } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { threadNotificationsAtom } from '../state/threadNotifications'; +import { + getThreadNotificationMode, + pruneThreadNotifications, + ThreadNotificationEntry, + ThreadNotificationMode, + ThreadNotificationsContent, +} from '../utils/threadNotifications'; +import { useMatrixClient } from './useMatrixClient'; +import { AsyncState, useAsyncCallback } from './useAsyncCallback'; + +/** Read the current notification mode for a thread from the bound atom. */ +export function useThreadNotificationMode( + roomId: string, + threadRootId: string, +): ThreadNotificationMode { + const content = useAtomValue(threadNotificationsAtom); + return getThreadNotificationMode(content, roomId, threadRootId); +} + +const readContent = (mx: MatrixClient): ThreadNotificationsContent => + ((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as + | ThreadNotificationsContent + | undefined) ?? {}; + +const getJoinedRoomIds = (mx: MatrixClient): Set => { + const joined = new Set(); + mx.getRooms().forEach((room) => { + if (room.getMyMembership() === 'join') { + joined.add(room.roomId); + } + }); + return joined; +}; + +const writeThreadNotificationMode = async ( + mx: MatrixClient, + roomId: string, + threadRootId: string, + mode: ThreadNotificationMode, +): Promise => { + const current = readContent(mx); + const now = Date.now(); + + // Work on a mutable clone; prune produces a fresh object so the mutations + // below never touch the atom's/account-data's current content. + const next: ThreadNotificationsContent = { + ...current, + rooms: Object.fromEntries( + Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]), + ), + }; + + const rooms = next.rooms as Record>; + + if (mode === ThreadNotificationMode.Default) { + if (rooms[roomId]) { + delete rooms[roomId][threadRootId]; + if (Object.keys(rooms[roomId]).length === 0) { + delete rooms[roomId]; + } + } + } else { + if (!rooms[roomId]) { + rooms[roomId] = {}; + } + rooms[roomId][threadRootId] = { mode, ts: now }; + } + + // ALWAYS prune before persisting to keep account data bounded. + const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now); + + await (mx as any).setAccountData(AccountDataEvent.LotusThreadNotifications, finalContent); +}; + +export function useSetThreadNotificationMode( + roomId: string, + threadRootId: string, +): { + modeState: AsyncState; + setMode: (mode: ThreadNotificationMode) => Promise; +} { + const mx = useMatrixClient(); + + const [modeState, setMode] = useAsyncCallback( + useCallback( + (mode: ThreadNotificationMode) => writeThreadNotificationMode(mx, roomId, threadRootId, mode), + [mx, roomId, threadRootId], + ), + ); + + return { modeState, setMode }; +} diff --git a/src/app/hooks/useThreadSummary.ts b/src/app/hooks/useThreadSummary.ts index 931e2255a..4018ec470 100644 --- a/src/app/hooks/useThreadSummary.ts +++ b/src/app/hooks/useThreadSummary.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { MatrixEvent, NotificationCountType, @@ -8,6 +9,8 @@ import { ThreadEvent, } from 'matrix-js-sdk'; import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary'; +import { threadNotificationsAtom } from '../state/threadNotifications'; +import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications'; /** * Reactive thread summary + unread count for a root event's "N replies" chip. @@ -18,9 +21,14 @@ import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/thr export const useThreadSummary = ( rootEvent: MatrixEvent, room: Room, -): { summary: ThreadSummaryData | undefined; unread: number } => { +): { summary: ThreadSummaryData | undefined; unread: number; mode: ThreadNotificationMode } => { const threadId = rootEvent.getId(); + const threadNotifications = useAtomValue(threadNotificationsAtom); + const mode = threadId + ? getThreadNotificationMode(threadNotifications, room.roomId, threadId) + : ThreadNotificationMode.Default; + const [summary, setSummary] = useState(() => getThreadSummary(rootEvent), ); @@ -53,5 +61,7 @@ export const useThreadSummary = ( }; }, [rootEvent, room, threadId]); - return { summary, unread }; + const muted = mode === ThreadNotificationMode.Mute; + + return { summary, unread: muted ? 0 : unread, mode }; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b0a1eba49..a86cfe66d 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 React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { + MatrixEvent, + Room, + RoomEvent, + RoomEventHandlerMap, + ThreadEvent, +} from 'matrix-js-sdk'; import { focusAssistActiveAtom } from '../../state/focusAssist'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import LogoSVG from '../../../../public/res/lotus.png'; @@ -35,6 +41,14 @@ import { toastQueueAtom } from '../../state/toast'; import { useReminders } from '../../hooks/useReminders'; import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; +import { useRoomsListener } from '../../hooks/useRoomsListener'; +import { threadNotificationsAtom } from '../../state/threadNotifications'; +import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; +import { + getThreadNotificationMode, + shouldNotifyThreadReply, + THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, +} from '../../utils/threadNotifications'; function isInQuietHours(start: string, end: string): boolean { const now = new Date(); @@ -212,6 +226,8 @@ function PresenceUpdater() { function MessageNotifications() { const audioRef = useRef(null); const unreadCacheRef = useRef>(new Map()); + // Per-thread dedupe: threadId -> last notified eventId. + const lastNotifiedThreadRef = useRef>(new Map()); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); @@ -242,6 +258,8 @@ function MessageNotifications() { const navigate = useNavigate(); const notificationSelected = useInboxNotificationsSelected(); const selectedRoomId = useSelectedRoom(); + const threadPrefs = useAtomValue(threadNotificationsAtom); + const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? '')); const notify = useCallback( ({ @@ -252,6 +270,7 @@ function MessageNotifications() { eventId, body, encrypted, + threadId, }: { roomName: string; roomAvatar?: string; @@ -260,6 +279,7 @@ function MessageNotifications() { eventId: string; body?: string; encrypted?: boolean; + threadId?: string; }) => { const roomPath = mDirects.has(roomId) ? getDirectRoomPath(roomId, eventId) @@ -294,7 +314,9 @@ function MessageNotifications() { silent: true, // Coalesce repeated notifications for the same room (replaces the old // manual notifRef.close() dedup, which a SW notification can't hold). - tag: roomId, + // For thread replies widen the tag to room:thread so each thread + // coalesces independently instead of clobbering the room's bucket. + tag: threadId ? `${roomId}:${threadId}` : roomId, data: { path: roomPath }, }, () => { @@ -326,6 +348,69 @@ function MessageNotifications() { audioElement?.play(); }, []); + // Shared delivery tail for both the main timeline and per-thread paths: + // room-level unread dedup → avatar resolution → OS/toast notify → sound, all + // behind the quiet-hours / focus-assist gate. `threadId` (when set) widens the + // OS coalescing tag so each thread notifies independently; the click path + // stays the room path (RoomTimeline deep-links thread events into the panel). + const deliverNotification = useCallback( + (room: Room, mEvent: MatrixEvent, threadId?: string) => { + const sender = mEvent.getSender(); + const eventId = mEvent.getId(); + if (!sender || !eventId) return; + + const unreadInfo = getUnreadInfo(room); + const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId); + unreadCacheRef.current.set(room.roomId, unreadInfo); + + if (unreadInfo.total === 0) return; + if ( + cachedUnreadInfo && + unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo)) + ) { + return; + } + + const quietActive = + focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd)); + if (quietActive) return; + + if (showNotifications && notificationPermission('granted')) { + const avatarMxc = + room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); + notify({ + roomName: room.name ?? 'Unknown', + roomAvatar: avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined, + username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, + roomId: room.roomId, + eventId, + body: (mEvent.getContent().body as string | undefined) ?? '', + encrypted: room.hasEncryptionStateEvent(), + threadId, + }); + } + + if (notificationSound && messageSoundId !== 'none') { + playSound(); + } + }, + [ + mx, + notify, + playSound, + showNotifications, + notificationSound, + useAuthentication, + quietHoursEnabled, + quietHoursStart, + quietHoursEnd, + focusAssistActive, + messageSoundId, + ], + ); + useEffect(() => { const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( mEvent, @@ -349,61 +434,64 @@ function MessageNotifications() { const sender = mEvent.getSender(); const eventId = mEvent.getId(); if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; - const unreadInfo = getUnreadInfo(room); - const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId); - unreadCacheRef.current.set(room.roomId, unreadInfo); + // Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply + // handler below (per-thread gating), so ignore them here — a reply notifies once. + if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return; - if (unreadInfo.total === 0) return; - if ( - cachedUnreadInfo && - unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo)) - ) { - return; - } - - const quietActive = - focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd)); - if (!quietActive) { - if (showNotifications && notificationPermission('granted')) { - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - notify({ - roomName: room.name ?? 'Unknown', - roomAvatar: avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) - : undefined, - username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, - roomId: room.roomId, - eventId, - body: (mEvent.getContent().body as string | undefined) ?? '', - encrypted: room.hasEncryptionStateEvent(), - }); - } - - if (notificationSound && messageSoundId !== 'none') { - playSound(); - } - } + deliverNotification(room, mEvent); }; mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [ - mx, - notificationSound, - notificationSelected, - showNotifications, - playSound, - notify, - selectedRoomId, - useAuthentication, - quietHoursEnabled, - quietHoursStart, - quietHoursEnd, - focusAssistActive, - messageSoundId, - ]); + }, [mx, notificationSelected, selectedRoomId, deliverNotification]); + + const handleNewReply = useCallback( + (thread, mEvent) => { + if (mx.getSyncState() !== 'SYNCING') return; + const room = mx.getRoom(thread.roomId); + if (!room || room.isSpaceRoom()) return; + if (!isNotificationEvent(mEvent) || mEvent.isSending()) return; + const sender = mEvent.getSender(); + if (!sender || sender === mx.getUserId()) return; + // Suppress when the user is actively looking at this thread (or the inbox). + if ( + document.hasFocus() && + (notificationSelected || + (selectedRoomId === thread.roomId && activeThreadId === thread.id)) + ) { + return; + } + + // Per-thread dedupe: a NewReply can re-fire for the same event as the + // thread (re)populates; notify at most once per (thread, event). + const eventId = mEvent.getId(); + if (eventId) { + if (lastNotifiedThreadRef.current.get(thread.id) === eventId) return; + lastNotifiedThreadRef.current.set(thread.id, eventId); + } + + const content = threadPrefs; + const mode = getThreadNotificationMode(content, room.roomId, thread.id); + const actions = mx.getPushActionsForEvent(mEvent); + const decision = shouldNotifyThreadReply({ + mode, + defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, + participated: thread.hasCurrentUserParticipated, + highlight: !!actions?.tweaks?.highlight, + notify: !!actions?.notify, + roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute, + }); + if (decision === 'none') return; + + // E2EE caveat: NewReply can fire before decryption, so MentionsOnly may + // under-notify in encrypted rooms (same class as the main timeline path). + // Plaintext body suppression for encrypted rooms is handled inside notify(). + deliverNotification(room, mEvent, thread.id); + }, + [mx, notificationSelected, selectedRoomId, activeThreadId, threadPrefs, deliverNotification], + ); + useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply); return (