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:
@@ -5,12 +5,14 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
||||
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||
|
||||
export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindMDirectAtom(mx, mDirectAtom);
|
||||
useBindAllInvitesAtom(mx, allInvitesAtom);
|
||||
useBindAllRoomsAtom(mx, allRoomsAtom);
|
||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||
|
||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { produce } from 'immer';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
IRoomTimelineData,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
NotificationCount,
|
||||
Room,
|
||||
RoomEvent,
|
||||
SyncState,
|
||||
} from 'matrix-js-sdk';
|
||||
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Membership,
|
||||
NotificationType,
|
||||
@@ -29,6 +30,9 @@ import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||
import { threadNotificationsAtom } from '../threadNotifications';
|
||||
import { getMutedThreads } from '../../utils/threadNotifications';
|
||||
|
||||
export type RoomToUnreadAction =
|
||||
| {
|
||||
@@ -169,11 +173,17 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
||||
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
|
||||
const setUnreadAtom = useSetAtom(unreadAtom);
|
||||
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const threadNotifications = useAtomValue(threadNotificationsAtom);
|
||||
// Latest thread-notification prefs for the SDK-event handlers below, read via a
|
||||
// ref so changing prefs never re-attaches the (many) per-room listeners. The
|
||||
// dedicated reset effect keyed on `threadNotifications` handles mute/unmute.
|
||||
const threadNotificationsRef = useRef(threadNotifications);
|
||||
threadNotificationsRef.current = threadNotifications;
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
|
||||
});
|
||||
}, [mx, setUnreadAtom]);
|
||||
|
||||
@@ -187,7 +197,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
) {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -204,6 +214,10 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
data: IRoomTimelineData,
|
||||
) => {
|
||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||
// Single-owner rule: thread replies drive the room badge via
|
||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||
// never double-driven / mis-attributed to the main timeline.
|
||||
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||
setUnreadAtom({
|
||||
type: 'DELETE',
|
||||
@@ -213,7 +227,13 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
}
|
||||
|
||||
if (mEvent.getSender() === mx.getUserId()) return;
|
||||
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||
setUnreadAtom({
|
||||
type: 'PUT',
|
||||
unreadInfo: getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
),
|
||||
});
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
@@ -246,10 +266,51 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx),
|
||||
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
|
||||
});
|
||||
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
|
||||
|
||||
// Mute/unmute of a thread rewrites `threadNotificationsAtom`; recompute badges
|
||||
// immediately so muted-thread subtraction takes effect without waiting for the
|
||||
// next timeline / unread event (mirrors the notification-preferences reset).
|
||||
useEffect(() => {
|
||||
setUnreadAtom({
|
||||
type: 'RESET',
|
||||
unreadInfos: getUnreadInfos(mx, threadNotifications),
|
||||
});
|
||||
}, [mx, setUnreadAtom, threadNotifications]);
|
||||
|
||||
// RoomEvent.UnreadNotifications is emitted room-level only (never re-emitted
|
||||
// client-side), so the main Timeline pathway misses thread-count changes and
|
||||
// room badges lag. useRoomsListener appends the emitting Room as the final
|
||||
// arg, making this a surgical per-room PUT (not a full RESET per emit) with
|
||||
// muted-thread subtraction re-applied. Room-mute keeps its DELETE semantics.
|
||||
useRoomsListener(
|
||||
mx,
|
||||
RoomEvent.UnreadNotifications,
|
||||
useCallback(
|
||||
(
|
||||
_unreadNotifications: NotificationCount | undefined,
|
||||
_threadId: string | undefined,
|
||||
room: Room,
|
||||
) => {
|
||||
if (room.isSpaceRoom()) return;
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||
return;
|
||||
}
|
||||
setUnreadAtom({
|
||||
type: 'PUT',
|
||||
unreadInfo: getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
),
|
||||
});
|
||||
},
|
||||
[mx, setUnreadAtom],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMembershipChange = (room: Room, membership: string) => {
|
||||
if (membership !== Membership.Join) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useEffect } from 'react';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { ThreadNotificationsContent } from '../utils/threadNotifications';
|
||||
|
||||
// Holds the parsed `io.lotus.thread_notifications` account data. Seeded and
|
||||
// kept in sync by `useBindThreadNotificationsAtom`.
|
||||
export const threadNotificationsAtom = atom<ThreadNotificationsContent>({});
|
||||
|
||||
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
|
||||
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
|
||||
| ThreadNotificationsContent
|
||||
| undefined) ?? {};
|
||||
|
||||
export const useBindThreadNotificationsAtom = (
|
||||
mx: MatrixClient,
|
||||
threadNotifications: typeof threadNotificationsAtom,
|
||||
) => {
|
||||
const setContent = useSetAtom(threadNotifications);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(readContent(mx));
|
||||
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() === AccountDataEvent.LotusThreadNotifications) {
|
||||
setContent(event.getContent<ThreadNotificationsContent>() ?? {});
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [mx, setContent]);
|
||||
};
|
||||
Reference in New Issue
Block a user