// 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; ts: number; }; export type ThreadNotificationsContent = { default?: ThreadDefaultBehavior; rooms?: Record>; }; // 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 = new Set([ ThreadNotificationMode.All, ThreadNotificationMode.MentionsOnly, ThreadNotificationMode.Mute, ]); const isStoredMode = ( value: unknown, ): value is Exclude => 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 { const muted = new Set(); 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, 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> = {}; 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 = {}; capped.forEach(([threadRootId, entry]) => { nextRoom[threadRootId] = entry; }); prunedRooms[roomId] = nextRoom; }); if (Object.keys(prunedRooms).length > 0) { pruned.rooms = prunedRooms; } return pruned; }