501d493ca4
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>
197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
// 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;
|
|
}
|