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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user