feat(threads): Slack-style per-thread notifications (P4-1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:39:10 -04:00
parent ffb934fce6
commit 501d493ca4
15 changed files with 1129 additions and 68 deletions
+139 -51
View File
@@ -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<HTMLAudioElement>(null);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
// Per-thread dedupe: threadId -> last notified eventId.
const lastNotifiedThreadRef = useRef<Map<string, string>>(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<RoomEventHandlerMap[ThreadEvent.NewReply]>(
(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 (
<audio ref={audioRef} style={{ display: 'none' }}>