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:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user