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', () => {
+24 -5
View File
@@ -29,6 +29,7 @@ import {
StateEvent,
UnreadInfo,
} from '../../types/matrix/room';
import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications';
export const getStateEvent = (
room: Room,
@@ -233,9 +234,23 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
export const getUnreadInfo = (room: Room): UnreadInfo => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
// Server room totals INCLUDE per-thread notification counts, so subtract any
// explicitly muted thread's counts back out (clamped at zero) to keep muted
// threads from contributing to the room badge (P4-1).
if (mutedThreads && mutedThreads.size > 0) {
mutedThreads.forEach((threadId) => {
total -= room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0;
highlight -=
room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) ?? 0;
});
if (total < 0) total = 0;
if (highlight < 0) highlight = 0;
}
return {
roomId: room.roomId,
highlight,
@@ -243,14 +258,18 @@ export const getUnreadInfo = (room: Room): UnreadInfo => {
};
};
export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
export const getUnreadInfos = (
mx: MatrixClient,
content?: ThreadNotificationsContent,
): UnreadInfo[] => {
const unreadInfos = mx.getRooms().reduce<UnreadInfo[]>((unread, room) => {
if (room.isSpaceRoom()) return unread;
if (room.getMyMembership() !== 'join') return unread;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
unread.push(getUnreadInfo(room));
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
unread.push(getUnreadInfo(room, mutedThreads));
}
return unread;
+260
View File
@@ -0,0 +1,260 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
getMutedThreads,
getThreadNotificationMode,
pruneThreadNotifications,
shouldNotifyThreadReply,
ThreadDefaultBehavior,
ThreadNotificationMode,
ThreadNotificationsContent,
ThreadNotifyDecision,
} from './threadNotifications';
const DAY = 24 * 60 * 60 * 1000;
const decide = (
overrides: Partial<Parameters<typeof shouldNotifyThreadReply>[0]>,
): ThreadNotifyDecision =>
shouldNotifyThreadReply({
mode: ThreadNotificationMode.Default,
defaultBehavior: 'participating',
participated: false,
highlight: false,
notify: false,
roomMuted: false,
...overrides,
});
describe('shouldNotifyThreadReply', () => {
it('roomMuted trumps everything, even mode All + highlight', () => {
assert.equal(
decide({ roomMuted: true, mode: ThreadNotificationMode.All, highlight: true }),
'none',
);
});
it('roomMuted trumps Default + participating + participated', () => {
assert.equal(decide({ roomMuted: true, participated: true }), 'none');
});
it('mode Mute is none regardless of highlight/participation', () => {
assert.equal(
decide({ mode: ThreadNotificationMode.Mute, highlight: true, participated: true }),
'none',
);
});
it('mode All + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: true }), 'loud');
});
it('mode All + no highlight => notify', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify');
});
it('mode MentionsOnly + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud');
});
it('mode MentionsOnly + no highlight => none (even if participated)', () => {
assert.equal(
decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: false, participated: true }),
'none',
);
});
it('Default + behavior all + highlight => loud', () => {
assert.equal(decide({ defaultBehavior: 'all', highlight: true }), 'loud');
});
it('Default + behavior all + no highlight => notify', () => {
assert.equal(decide({ defaultBehavior: 'all', highlight: false }), 'notify');
});
it('Default + participating + highlight => loud (even if not participated)', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: true, participated: false }),
'loud',
);
});
it('Default + participating + no highlight + participated => notify', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: false, participated: true }),
'notify',
);
});
it('Default + participating + no highlight + not participated => none', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: false, participated: false }),
'none',
);
});
it('ignores the `notify` input entirely in v1', () => {
// notify=true must not upgrade a "none" decision.
assert.equal(
decide({ defaultBehavior: 'participating', participated: false, notify: true }),
'none',
);
// notify=false must not downgrade an "all" mode notify.
assert.equal(decide({ mode: ThreadNotificationMode.All, notify: false }), 'notify');
});
});
describe('getThreadNotificationMode', () => {
it('returns Default for undefined content', () => {
assert.equal(getThreadNotificationMode(undefined, '!r', '$t'), ThreadNotificationMode.Default);
});
it('returns Default when room or thread is absent', () => {
const content: ThreadNotificationsContent = {
rooms: { '!r': { $other: { mode: ThreadNotificationMode.All, ts: 1 } } },
};
assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(content, '!x', '$t'), ThreadNotificationMode.Default);
});
it('returns the stored mode', () => {
const content: ThreadNotificationsContent = {
rooms: { '!r': { $t: { mode: ThreadNotificationMode.Mute, ts: 1 } } },
};
assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Mute);
});
it('is safe against malformed entries', () => {
const bad = {
rooms: {
'!r': {
$badMode: { mode: 'nonsense', ts: 1 },
$noTs: { mode: ThreadNotificationMode.All },
$notObj: 'oops',
$nullEntry: null,
},
},
} as unknown as ThreadNotificationsContent;
assert.equal(getThreadNotificationMode(bad, '!r', '$badMode'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(bad, '!r', '$noTs'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(bad, '!r', '$notObj'), ThreadNotificationMode.Default);
assert.equal(
getThreadNotificationMode(bad, '!r', '$nullEntry'),
ThreadNotificationMode.Default,
);
});
it('is safe when rooms is not an object', () => {
const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent;
assert.equal(getThreadNotificationMode(bad, '!r', '$t'), ThreadNotificationMode.Default);
});
});
describe('getMutedThreads', () => {
it('returns empty set for undefined/absent room', () => {
assert.deepEqual(getMutedThreads(undefined, '!r'), new Set());
assert.deepEqual(getMutedThreads({ rooms: {} }, '!r'), new Set());
});
it('collects only Mute entries', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': {
$a: { mode: ThreadNotificationMode.Mute, ts: 1 },
$b: { mode: ThreadNotificationMode.All, ts: 1 },
$c: { mode: ThreadNotificationMode.Mute, ts: 1 },
},
},
};
assert.deepEqual(getMutedThreads(content, '!r'), new Set(['$a', '$c']));
});
it('ignores malformed entries', () => {
const bad = {
rooms: { '!r': { $a: { mode: 'mute-ish', ts: 1 }, $b: null } },
} as unknown as ThreadNotificationsContent;
assert.deepEqual(getMutedThreads(bad, '!r'), new Set());
});
});
describe('pruneThreadNotifications', () => {
const now = 1_000_000_000_000;
it('drops rooms not in joinedRoomIds', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!keep': { $t: { mode: ThreadNotificationMode.All, ts: now } },
'!left': { $t: { mode: ThreadNotificationMode.All, ts: now } },
},
};
const out = pruneThreadNotifications(content, new Set(['!keep']), now);
assert.deepEqual(Object.keys(out.rooms ?? {}), ['!keep']);
});
it('drops entries older than 180 days', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': {
$fresh: { mode: ThreadNotificationMode.All, ts: now - 179 * DAY },
$old: { mode: ThreadNotificationMode.All, ts: now - 181 * DAY },
},
},
};
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.deepEqual(Object.keys(out.rooms?.['!r'] ?? {}), ['$fresh']);
});
it('caps a room at 200 entries, evicting oldest ts first', () => {
const entries: Record<string, { mode: ThreadNotificationMode.All; ts: number }> = {};
// 205 entries, ts ascending with the id index.
for (let i = 0; i < 205; i += 1) {
entries[`$t${i}`] = { mode: ThreadNotificationMode.All, ts: now - (205 - i) * 1000 };
}
const content: ThreadNotificationsContent = { rooms: { '!r': entries } };
const out = pruneThreadNotifications(content, new Set(['!r']), now);
const kept = out.rooms?.['!r'] ?? {};
assert.equal(Object.keys(kept).length, 200);
// Oldest 5 ($t0..$t4) evicted; newest retained.
assert.equal(kept.$t0, undefined);
assert.equal(kept.$t4, undefined);
assert.notEqual(kept.$t5, undefined);
assert.notEqual(kept.$t204, undefined);
});
it('drops rooms left with no entries', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': { $old: { mode: ThreadNotificationMode.All, ts: now - 200 * DAY } },
},
};
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.equal(out.rooms, undefined);
});
it('preserves the default behavior field', () => {
const behavior: ThreadDefaultBehavior = 'all';
const content: ThreadNotificationsContent = { default: behavior, rooms: {} };
const out = pruneThreadNotifications(content, new Set(), now);
assert.equal(out.default, 'all');
});
it('never mutates the input', () => {
const content: ThreadNotificationsContent = {
default: 'participating',
rooms: {
'!r': { $t: { mode: ThreadNotificationMode.All, ts: now } },
'!left': { $t: { mode: ThreadNotificationMode.All, ts: now } },
},
};
const snapshot = JSON.parse(JSON.stringify(content));
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.deepEqual(content, snapshot);
// Output room objects are fresh, not shared references with the input.
assert.notEqual(out.rooms?.['!r'], content.rooms?.['!r']);
});
it('handles malformed rooms container safely', () => {
const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent;
assert.deepEqual(pruneThreadNotifications(bad, new Set(['!r']), now), {});
});
});
+196
View File
@@ -0,0 +1,196 @@
// Per-thread notification modes (P4-1). Stored in the
// `io.lotus.thread_notifications` account data event. The functions in this
// module are PURE — they never touch React or matrix-js-sdk objects so they
// can be unit-tested in isolation and reused by the pipeline/UI agents.
export enum ThreadNotificationMode {
Default = 'default',
All = 'all',
MentionsOnly = 'mentions',
Mute = 'mute',
}
export type ThreadDefaultBehavior = 'all' | 'participating';
export type ThreadNotifyDecision = 'loud' | 'notify' | 'none';
export type ThreadNotificationEntry = {
mode: Exclude<ThreadNotificationMode, ThreadNotificationMode.Default>;
ts: number;
};
export type ThreadNotificationsContent = {
default?: ThreadDefaultBehavior;
rooms?: Record<string, Record<string, ThreadNotificationEntry>>;
};
// DEFAULT behavior when the user has not chosen a global default. Fixed to
// 'participating': notify only if the current user participated in the thread
// or the reply mentions them.
export const THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR: ThreadDefaultBehavior = 'participating';
// Entries older than this are pruned on write to keep account data bounded.
const PRUNE_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;
// Maximum stored entries per room; oldest are evicted first.
const PRUNE_MAX_ENTRIES_PER_ROOM = 200;
const STORED_MODES: ReadonlySet<string> = new Set([
ThreadNotificationMode.All,
ThreadNotificationMode.MentionsOnly,
ThreadNotificationMode.Mute,
]);
const isStoredMode = (
value: unknown,
): value is Exclude<ThreadNotificationMode, ThreadNotificationMode.Default> =>
typeof value === 'string' && STORED_MODES.has(value);
const readEntry = (
content: ThreadNotificationsContent | undefined,
roomId: string,
threadRootId: string,
): ThreadNotificationEntry | undefined => {
const entry = content?.rooms?.[roomId]?.[threadRootId];
if (!entry || typeof entry !== 'object') return undefined;
if (!isStoredMode(entry.mode) || typeof entry.ts !== 'number') return undefined;
return entry;
};
/**
* Resolve the stored notification mode for a thread. Absent or malformed
* content resolves to `ThreadNotificationMode.Default`.
*/
export function getThreadNotificationMode(
content: ThreadNotificationsContent | undefined,
roomId: string,
threadRootId: string,
): ThreadNotificationMode {
const entry = readEntry(content, roomId, threadRootId);
return entry ? entry.mode : ThreadNotificationMode.Default;
}
/**
* All thread root ids explicitly muted within a room. Malformed content yields
* an empty set.
*/
export function getMutedThreads(
content: ThreadNotificationsContent | undefined,
roomId: string,
): Set<string> {
const muted = new Set<string>();
const roomEntries = content?.rooms?.[roomId];
if (!roomEntries || typeof roomEntries !== 'object') return muted;
Object.keys(roomEntries).forEach((threadRootId) => {
const entry = roomEntries[threadRootId];
if (entry && isStoredMode(entry.mode) && entry.mode === ThreadNotificationMode.Mute) {
muted.add(threadRootId);
}
});
return muted;
}
/**
* Decide whether a thread reply should notify.
*
* NOTE: the `notify` input reflects the base matrix push rule outcome and is
* accepted for forward-compatibility, but is intentionally IGNORED in v1: the
* per-thread mode fully determines the decision, so honoring `notify` would let
* server push rules silently override an explicit "All" thread override. Kept
* in the signature so the pipeline can start plumbing it without a later break.
*/
export function shouldNotifyThreadReply(input: {
mode: ThreadNotificationMode;
defaultBehavior: ThreadDefaultBehavior;
participated: boolean;
highlight: boolean;
notify: boolean;
roomMuted: boolean;
}): ThreadNotifyDecision {
const { mode, defaultBehavior, participated, highlight, roomMuted } = input;
if (roomMuted) return 'none';
if (mode === ThreadNotificationMode.Mute) return 'none';
if (mode === ThreadNotificationMode.All) {
return highlight ? 'loud' : 'notify';
}
if (mode === ThreadNotificationMode.MentionsOnly) {
return highlight ? 'loud' : 'none';
}
// ThreadNotificationMode.Default
if (defaultBehavior === 'all') {
return highlight ? 'loud' : 'notify';
}
// defaultBehavior === 'participating'
if (highlight) return 'loud';
return participated ? 'notify' : 'none';
}
/**
* Return a NEW content object with stale/oversized data removed. Never mutates
* the input.
*
* (1) drop rooms not in `joinedRoomIds`
* (2) drop entries older than 180 days (`ts < now - PRUNE_MAX_AGE_MS`)
* (3) cap each room at 200 entries, evicting the oldest `ts` first
* (4) drop rooms left with no entries
*/
export function pruneThreadNotifications(
content: ThreadNotificationsContent,
joinedRoomIds: Set<string>,
now: number,
): ThreadNotificationsContent {
const minTs = now - PRUNE_MAX_AGE_MS;
const pruned: ThreadNotificationsContent = {};
if (content.default !== undefined) {
pruned.default = content.default;
}
const rooms = content.rooms;
if (!rooms || typeof rooms !== 'object') {
return pruned;
}
const prunedRooms: Record<string, Record<string, ThreadNotificationEntry>> = {};
Object.keys(rooms).forEach((roomId) => {
if (!joinedRoomIds.has(roomId)) return;
const roomEntries = rooms[roomId];
if (!roomEntries || typeof roomEntries !== 'object') return;
// Keep only well-formed, non-expired entries.
const kept: Array<[string, ThreadNotificationEntry]> = [];
Object.keys(roomEntries).forEach((threadRootId) => {
const entry = roomEntries[threadRootId];
if (!entry || !isStoredMode(entry.mode) || typeof entry.ts !== 'number') return;
if (entry.ts < minTs) return;
kept.push([threadRootId, { mode: entry.mode, ts: entry.ts }]);
});
if (kept.length === 0) return;
// Cap per room, evicting oldest first (ascending ts sort, keep the tail).
let capped = kept;
if (kept.length > PRUNE_MAX_ENTRIES_PER_ROOM) {
capped = [...kept]
.sort((a, b) => a[1].ts - b[1].ts)
.slice(kept.length - PRUNE_MAX_ENTRIES_PER_ROOM);
}
const nextRoom: Record<string, ThreadNotificationEntry> = {};
capped.forEach(([threadRootId, entry]) => {
nextRoom[threadRootId] = entry;
});
prunedRooms[roomId] = nextRoom;
});
if (Object.keys(prunedRooms).length > 0) {
pruned.rooms = prunedRooms;
}
return pruned;
}