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', () => {
|
||||
|
||||
+24
-5
@@ -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;
|
||||
|
||||
@@ -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), {});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user