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
+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;
}