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
+57
View File
@@ -388,6 +388,63 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
const mockRoomWithThreadCounts = (
total: number,
highlight: number,
threadCounts: Record<string, { total: number; highlight: number }>,
): Room =>
({
roomId: '!r:x',
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? total : highlight,
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
type === NotificationCountType.Total
? (threadCounts[threadId]?.total ?? 0)
: (threadCounts[threadId]?.highlight ?? 0),
}) as unknown as Room;
test('getUnreadInfo subtracts muted thread counts from room totals', () => {
const room = mockRoomWithThreadCounts(5, 2, { $t1: { total: 3, highlight: 1 } });
assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
roomId: '!r:x',
highlight: 1,
total: 2,
});
});
test('getUnreadInfo subtracts multiple muted threads', () => {
const room = mockRoomWithThreadCounts(9, 3, {
$t1: { total: 3, highlight: 1 },
$t2: { total: 2, highlight: 1 },
});
assert.deepEqual(getUnreadInfo(room, new Set(['$t1', '$t2'])), {
roomId: '!r:x',
highlight: 1,
total: 4,
});
});
test('getUnreadInfo clamps subtracted counts at zero', () => {
const room = mockRoomWithThreadCounts(2, 1, { $t1: { total: 5, highlight: 4 } });
assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
roomId: '!r:x',
highlight: 0,
total: 0,
});
});
test('getUnreadInfo leaves counts untouched without muted threads', () => {
const room = mockRoomWithThreadCounts(4, 1, { $t1: { total: 3, highlight: 1 } });
// undefined muted set (backward compat)
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 1, total: 4 });
// empty muted set is a no-op too
assert.deepEqual(getUnreadInfo(room, new Set<string>()), {
roomId: '!r:x',
highlight: 1,
total: 4,
});
});
// --- getRoomIconSrc -------------------------------------------------------
test('getRoomIconSrc selects icon by room type and join rule', () => {