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
+41 -3
View File
@@ -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 (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
@@ -40,7 +49,36 @@ function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
{room.name}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<Box shrink="No" alignItems="Center" gap="100">
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
{(handleOpen, opened) => (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Notifications</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Thread notifications"
aria-pressed={opened}
onClick={handleOpen}
>
<Icon
src={getThreadNotificationModeIcon(mode)}
filled={mode !== ThreadNotificationMode.Default}
/>
</IconButton>
)}
</TooltipProvider>
)}
</ThreadNotificationModeSwitcher>
<TooltipProvider
position="Bottom"
align="End"
@@ -137,7 +175,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
shrink="No"
direction="Column"
>
<ThreadPanelHeader room={room} requestClose={requestClose} />
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
{!thread ? (
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Spinner size="400" variant="Secondary" />
@@ -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}` : ''}
</Text>
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
</Chip>
</Box>
);