import { test } from 'node:test'; import assert from 'node:assert/strict'; import { EventTimeline, JoinRule, MatrixClient, MatrixEvent, NotificationCountType, Room, RoomMember, } from 'matrix-js-sdk'; import { getStateEvent, getStateEvents, getMDirects, isDirectInvite, isSpace, isRoom, isUnsupportedRoom, isValidChild, getAllParents, getSpaceChildren, mapParentWithChildren, getOrphanParents, isMutedRule, findMutedRule, getNotificationType, isNotificationEvent, isVerificationFlowEvent, unreadIsOnlyVerification, readReceiptCoversTail, roomHasUnreadThread, roomHaveNotification, getUnreadInfo, getRoomIconSrc, trimReplyFromBody, trimReplyFromFormattedBody, parseReplyBody, parseReplyFormattedBody, getMemberDisplayName, getMemberSearchStr, getMemberAvatarMxc, isMembershipChanged, getReactionContent, getLatestEdit, reactionOrEditEvent, getMentionContent, getAllVersionsRoomCreator, } from './room'; import { RoomType, StateEvent, RoomToParents, NotificationType } from '../../types/matrix/room'; // --- Mock helpers --------------------------------------------------------- const mockEvent = (overrides: Partial> = {}): MatrixEvent => { const base: Record unknown> = { getType: () => undefined, getContent: () => ({}), getPrevContent: () => ({}), getStateKey: () => '', getSender: () => undefined, getTs: () => 0, getId: () => undefined, getRelation: () => null, isRedacted: () => false, }; return { ...base, ...overrides } as unknown as MatrixEvent; }; // Build a Room whose live-timeline forward state returns the given state events. const mockRoomWithState = ( stateEventsByType: Record, rest: Record = {}, ): Room => { const state = { getStateEvents: (eventType: string, stateKey?: string) => { const events = stateEventsByType[eventType] ?? []; if (stateKey !== undefined) { return events[0]; // single-event form } return events; // array form }, }; return { getLiveTimeline: () => ({ getState: () => state, }), ...rest, } as unknown as Room; }; // --- getStateEvent / getStateEvents -------------------------------------- test('getStateEvent returns the matching state event or undefined', () => { const ev = mockEvent({ getType: () => StateEvent.RoomName }); const room = mockRoomWithState({ [StateEvent.RoomName]: [ev] }); assert.equal(getStateEvent(room, StateEvent.RoomName), ev); const empty = mockRoomWithState({}); assert.equal(getStateEvent(empty, StateEvent.RoomName), undefined); }); test('getStateEvent returns undefined when there is no forward state', () => { const room = { getLiveTimeline: () => ({ getState: () => undefined }), } as unknown as Room; assert.equal(getStateEvent(room, StateEvent.RoomName), undefined); }); test('getStateEvents returns an array, empty when none', () => { const ev = mockEvent(); const room = mockRoomWithState({ [StateEvent.SpaceChild]: [ev] }); assert.deepEqual(getStateEvents(room, StateEvent.SpaceChild), [ev]); const empty = mockRoomWithState({}); assert.deepEqual(getStateEvents(empty, StateEvent.SpaceChild), []); }); // --- getMDirects ---------------------------------------------------------- test('getMDirects collects all direct room ids across users', () => { const ev = mockEvent({ getContent: () => ({ '@a:x': ['!room1', '!room2'], '@b:x': ['!room3'], }), }); const result = getMDirects(ev); assert.deepEqual([...result].sort(), ['!room1', '!room2', '!room3']); }); test('getMDirects ignores non-array and non-string entries', () => { const ev = mockEvent({ getContent: () => ({ '@a:x': 'not-an-array', '@b:x': ['!ok', 42, null], }), }); const result = getMDirects(ev); assert.deepEqual([...result], ['!ok']); }); test('getMDirects returns empty set when content is undefined', () => { const ev = mockEvent({ getContent: () => undefined }); assert.equal(getMDirects(ev).size, 0); }); // --- isDirectInvite ------------------------------------------------------- test('isDirectInvite returns false for null room or null user', () => { assert.equal(isDirectInvite(null, '@me:x'), false); assert.equal(isDirectInvite({} as unknown as Room, null), false); }); test('isDirectInvite reads is_direct from the member event content', () => { const room = { getMember: () => ({ events: { member: { getContent: () => ({ is_direct: true }) } }, }), } as unknown as Room; assert.equal(isDirectInvite(room, '@me:x'), true); const roomNotDirect = { getMember: () => ({ events: { member: { getContent: () => ({}) } }, }), } as unknown as Room; assert.equal(isDirectInvite(roomNotDirect, '@me:x'), false); }); test('isDirectInvite returns false when member is missing', () => { const room = { getMember: () => null } as unknown as Room; assert.equal(isDirectInvite(room, '@me:x'), false); }); // --- isSpace / isRoom / isUnsupportedRoom -------------------------------- test('isSpace detects space rooms via create event type', () => { const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) }); assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), true); const normalEv = mockEvent({ getContent: () => ({}) }); assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), false); assert.equal(isSpace(null), false); assert.equal(isSpace(mockRoomWithState({})), false); // no create event }); test('isRoom is true for normal rooms and rooms with no create event', () => { const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) }); assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false); const normalEv = mockEvent({ getContent: () => ({}) }); assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), true); assert.equal(isRoom(mockRoomWithState({})), true); // no create event -> treated as room assert.equal(isRoom(null), false); }); test('isUnsupportedRoom is true when type is set and not a space', () => { const callEv = mockEvent({ getContent: () => ({ type: RoomType.Call }) }); assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [callEv] })), true); const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) }); assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false); const normalEv = mockEvent({ getContent: () => ({}) }); assert.equal( isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), false, ); assert.equal(isUnsupportedRoom(mockRoomWithState({})), true); // missing create event assert.equal(isUnsupportedRoom(null), false); }); // --- isValidChild / getSpaceChildren ------------------------------------- test('isValidChild requires SpaceChild type with via array', () => { const valid = mockEvent({ getType: () => StateEvent.SpaceChild, getContent: () => ({ via: ['server'] }), }); assert.equal(isValidChild(valid), true); const noVia = mockEvent({ getType: () => StateEvent.SpaceChild, getContent: () => ({}), }); assert.equal(isValidChild(noVia), false); const wrongType = mockEvent({ getType: () => StateEvent.RoomName, getContent: () => ({ via: ['server'] }), }); assert.equal(isValidChild(wrongType), false); }); test('getSpaceChildren returns state keys of valid child events', () => { const valid = mockEvent({ getType: () => StateEvent.SpaceChild, getStateKey: () => '!child:x', getContent: () => ({ via: ['s'] }), }); const invalid = mockEvent({ getType: () => StateEvent.SpaceChild, getStateKey: () => '!bad:x', getContent: () => ({}), }); const noKey = mockEvent({ getType: () => StateEvent.SpaceChild, getStateKey: () => '', getContent: () => ({ via: ['s'] }), }); const room = mockRoomWithState({ [StateEvent.SpaceChild]: [valid, invalid, noKey], }); assert.deepEqual(getSpaceChildren(room), ['!child:x']); }); // --- getAllParents / mapParentWithChildren / getOrphanParents ------------ test('getAllParents walks the parent graph excluding the start room', () => { const map: RoomToParents = new Map([ ['child', new Set(['mid'])], ['mid', new Set(['root'])], ]); assert.deepEqual([...getAllParents(map, 'child')].sort(), ['mid', 'root']); assert.deepEqual([...getAllParents(map, 'root')], []); }); test('getAllParents handles cycles without infinite recursion', () => { const map: RoomToParents = new Map([ ['a', new Set(['b'])], ['b', new Set(['a'])], ]); assert.deepEqual([...getAllParents(map, 'a')], ['b']); }); test('mapParentWithChildren registers parent for each child', () => { const map: RoomToParents = new Map(); mapParentWithChildren(map, 'space1', ['c1', 'c2']); assert.deepEqual([...(map.get('c1') ?? [])], ['space1']); assert.deepEqual([...(map.get('c2') ?? [])], ['space1']); }); test('mapParentWithChildren skips children that would create a cycle', () => { const map: RoomToParents = new Map([['space1', new Set(['ancestor'])]]); mapParentWithChildren(map, 'space1', ['ancestor', 'newchild']); // ancestor is an existing parent -> cycle -> skipped assert.equal(map.has('ancestor'), false); assert.deepEqual([...(map.get('newchild') ?? [])], ['space1']); }); test('getOrphanParents returns parents not present as keys in the map', () => { const map: RoomToParents = new Map([ ['child', new Set(['mid'])], ['mid', new Set(['root'])], ]); // root is a parent but has no entry of its own -> orphan assert.deepEqual(getOrphanParents(map, 'child'), ['root']); }); // --- isMutedRule / findMutedRule ----------------------------------------- test('isMutedRule matches empty actions or dont_notify with event_match', () => { assert.equal( isMutedRule({ actions: [], conditions: [{ kind: 'event_match' }], } as never), true, ); assert.equal( isMutedRule({ actions: ['dont_notify'], conditions: [{ kind: 'event_match' }], } as never), true, ); assert.equal( isMutedRule({ actions: ['notify'], conditions: [{ kind: 'event_match' }], } as never), false, ); assert.ok( !isMutedRule({ actions: [], conditions: [{ kind: 'other' }], } as never), ); }); test('findMutedRule finds rule by id that is also muted', () => { const rules = [ { rule_id: '!a', actions: [], conditions: [{ kind: 'event_match' }] }, { rule_id: '!b', actions: ['notify'], conditions: [{ kind: 'event_match' }] }, ] as never[]; assert.equal(findMutedRule(rules, '!a'), rules[0]); assert.equal(findMutedRule(rules, '!b'), undefined); assert.equal(findMutedRule(rules, '!missing'), undefined); }); // --- getNotificationType (memoization, #77) ------------------------------- test('getNotificationType memoizes per push-rules content object and room id', () => { let scanCalls = 0; const overrideRules = [{ rule_id: '!muted', actions: [], conditions: [{ kind: 'event_match' }] }]; const pushRulesContent = { global: { override: overrideRules } }; const mx = { getRoomPushRule: () => { scanCalls += 1; return undefined; }, getAccountData: () => ({ getContent: () => pushRulesContent }), } as never; assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute); assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute); assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute); // Repeated calls with the same (content object, roomId) pair must hit the // cache instead of re-running getRoomPushRule + the override scan. assert.equal(scanCalls, 1); // A different room id under the same push-rules content is a separate cache // entry and still gets computed. assert.equal(getNotificationType(mx, '!other'), NotificationType.Default); assert.equal(scanCalls, 2); // A new push-rules content object (simulating an `m.push_rules` account-data // update) invalidates the cache and forces a recompute. const newContent = { global: { override: overrideRules } }; const mx2 = { getRoomPushRule: mx.getRoomPushRule, getAccountData: () => ({ getContent: () => newContent }), } as never; assert.equal(getNotificationType(mx2, '!muted'), NotificationType.Mute); assert.equal(scanCalls, 3); }); // --- isNotificationEvent -------------------------------------------------- test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => { assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.message' })), true); assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.sticker' })), true); assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.member' })), false); assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.topic' })), false); assert.equal( isNotificationEvent(mockEvent({ getType: () => 'm.room.message', isRedacted: () => true })), false, ); assert.equal( isNotificationEvent( mockEvent({ getType: () => 'm.room.message', getRelation: () => ({ rel_type: 'm.replace' }), }), ), false, ); // Device-verification requests (m.room.message + this msgtype) are crypto // control messages — must NOT count as unread/notify (stale-request nag fix). assert.equal( isNotificationEvent( mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.key.verification.request' }), }), ), false, ); // Regression guard: a normal text message still notifies. assert.equal( isNotificationEvent( mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text', body: 'hi' }), }), ), true, ); }); // --- roomHaveNotification / getUnreadInfo -------------------------------- test('roomHaveNotification true when total or highlight count > 0', () => { const make = (total: number, highlight: number) => ({ getUnreadNotificationCount: (type: NotificationCountType) => type === NotificationCountType.Total ? total : highlight, }) as unknown as Room; assert.equal(roomHaveNotification(make(0, 0)), false); assert.equal(roomHaveNotification(make(3, 0)), true); assert.equal(roomHaveNotification(make(0, 1)), true); }); test('getUnreadInfo uses highlight when it exceeds total', () => { const room = { roomId: '!r:x', getUnreadNotificationCount: (type: NotificationCountType) => type === NotificationCountType.Total ? 2 : 5, } as unknown as Room; assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 5, total: 5 }); const room2 = { roomId: '!r:y', getUnreadNotificationCount: (type: NotificationCountType) => type === NotificationCountType.Total ? 7 : 1, } as unknown as Room; assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 }); }); // --- verification-flow unread suppression -------------------------------- test('isVerificationFlowEvent', () => { // the in-room request (m.room.message + verification msgtype) assert.equal( isVerificationFlowEvent( mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.key.verification.request' }), }), ), true, ); // the handshake events (their own m.key.verification.* types) ['ready', 'start', 'accept', 'key', 'mac', 'done', 'cancel'].forEach((phase) => { assert.equal( isVerificationFlowEvent(mockEvent({ getType: () => `m.key.verification.${phase}` })), true, ); }); // a normal message is not verification flow assert.equal( isVerificationFlowEvent( mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text' }) }), ), false, ); // a still-encrypted event can't be classified → false (conservative) assert.equal(isVerificationFlowEvent(mockEvent({ getType: () => 'm.room.encrypted' })), false); }); const mockUnreadRoom = ( events: MatrixEvent[], readUpToId: string | null, counts: { total: number; highlight: number } = { total: 0, highlight: 0 }, threadCounts: Record = {}, ): Room => ({ roomId: '!r:x', getEventReadUpTo: () => readUpToId, getLiveTimeline: () => ({ getEvents: () => events }), getUnreadNotificationCount: (type: NotificationCountType) => type === NotificationCountType.Total ? counts.total : counts.highlight, getThreads: () => Object.keys(threadCounts).map((id) => ({ id })), getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) => type === NotificationCountType.Total ? (threadCounts[threadId]?.total ?? 0) : (threadCounts[threadId]?.highlight ?? 0), }) as unknown as Room; const mx = { getUserId: () => '@me:x' } as unknown as MatrixClient; const verifRequest = (id: string) => mockEvent({ getId: () => id, getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.key.verification.request' }), }); const verifPhase = (id: string, phase: string) => mockEvent({ getId: () => id, getType: () => `m.key.verification.${phase}` }); const textMsg = (id: string) => mockEvent({ getId: () => id, getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text', body: 'hi' }), }); const reactionEv = (id: string) => mockEvent({ getId: () => id, getType: () => 'm.reaction', getContent: () => ({}) }); const encryptedEv = (id: string) => mockEvent({ getId: () => id, getType: () => 'm.room.encrypted', getContent: () => ({}) }); const pollEv = (id: string) => mockEvent({ getId: () => id, getType: () => 'm.poll.start', getContent: () => ({}) }); test('unreadIsOnlyVerification: verification-only unread tail → true', () => { // timeline oldest→newest: [read msg] then the verification handshake at the tail const events = [textMsg('$read'), verifPhase('$done', 'done'), verifRequest('$req')]; assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), true); }); test('unreadIsOnlyVerification: a real unread message in the span → false', () => { // an unread text message sits between the read marker and the verification tail const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')]; assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false); }); test('unreadIsOnlyVerification: read marker off-window → false (conservative)', () => { // the read marker isn't in the loaded timeline const events = [verifPhase('$done', 'done'), verifRequest('$req')]; assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$offwindow'), '@me:x'), false); }); test('unreadIsOnlyVerification: still-encrypted tail → false (conservative)', () => { const encryptedTail = mockEvent({ getId: () => '$enc', getType: () => 'm.room.encrypted' }); const events = [textMsg('$read'), encryptedTail]; assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false); }); test('unreadIsOnlyVerification: no userId → false', () => { const events = [textMsg('$read'), verifRequest('$req')]; assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), null), false); }); test('unreadIsOnlyVerification: verification-only main tail but a real unread THREAD → false', () => { // markAsRead clears every thread, so a verification-only main timeline must NOT // count as "only verification" when a thread still has a genuine unread reply. const events = [textMsg('$read'), verifRequest('$req')]; const room = mockUnreadRoom( events, '$read', { total: 2, highlight: 0 }, { $thread: { total: 1, highlight: 0 }, }, ); assert.equal(unreadIsOnlyVerification(room, '@me:x'), false); }); test('getUnreadInfo suppresses a verification-only room to {0,0} when mx is passed', () => { const events = [textMsg('$read'), verifRequest('$req')]; const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 }); // Without mx, the raw count is trusted (backward compatible). assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 }); // With mx, the verification-only count is suppressed. assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 }); }); test('getUnreadInfo does NOT suppress a highlight (real mention) even if the tail is a verification', () => { const events = [textMsg('$read'), verifRequest('$req')]; const room = mockUnreadRoom(events, '$read', { total: 2, highlight: 1 }); assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 1, total: 2 }); }); test('getUnreadInfo does NOT suppress when a real message is unread alongside a verification', () => { const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')]; const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 }); assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 }); }); // --- readReceiptCoversTail (UTD / spurious-count suppression) -------------- test('readReceiptCoversTail: receipt on the tail (a reaction) → true', () => { // The Cool Kids case: a corrupt/undecryptable event sits BEFORE the read // receipt, and the receipt itself landed on the trailing reaction. const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), '@me:x'), true); }); test('readReceiptCoversTail: only non-notifiable events after the receipt → true', () => { const events = [textMsg('$read'), reactionEv('$r1'), verifRequest('$v')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), true); }); test('readReceiptCoversTail: a real unread message after the receipt → false', () => { const events = [textMsg('$read'), reactionEv('$r'), textMsg('$new')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false); }); test('readReceiptCoversTail: an unread poll after the receipt → false (polls are content)', () => { const events = [textMsg('$read'), pollEv('$poll')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false); }); test('isNotificationEvent recognizes polls (MSC3381)', () => { assert.equal(isNotificationEvent(pollEv('$p')), true); }); test('readReceiptCoversTail: a still-encrypted message after the receipt → false (conservative)', () => { const events = [textMsg('$read'), encryptedEv('$enc')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false); }); test('readReceiptCoversTail: receipt off-window → false', () => { const events = [reactionEv('$r'), textMsg('$new')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$gone'), '@me:x'), false); }); test('readReceiptCoversTail: no receipt / null user → false', () => { const events = [textMsg('$read'), reactionEv('$tail')]; assert.equal(readReceiptCoversTail(mockUnreadRoom(events, null), '@me:x'), false); assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), null), false); }); test('readReceiptCoversTail: a genuine unread THREAD blocks suppression → false', () => { const events = [textMsg('$read'), reactionEv('$tail')]; const room = mockUnreadRoom( events, '$tail', { total: 2, highlight: 0 }, { $thread: { total: 1, highlight: 0 }, }, ); assert.equal(readReceiptCoversTail(room, '@me:x'), false); }); test('roomHasUnreadThread reflects per-thread counts', () => { const events = [textMsg('$read')]; assert.equal(roomHasUnreadThread(mockUnreadRoom(events, '$read')), false); assert.equal( roomHasUnreadThread( mockUnreadRoom( events, '$read', { total: 1, highlight: 0 }, { $t: { total: 1, highlight: 0 } }, ), ), true, ); }); test('getUnreadInfo suppresses a spurious count when the read receipt covers the tail', () => { // Inflated Total=1 (undecryptable event) but the receipt is on the trailing // reaction → the room is genuinely read; suppress to {0,0}. Without mx the raw // count is trusted (backward compatible). const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')]; const room = mockUnreadRoom(events, '$tail', { total: 1, highlight: 0 }); assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 }); assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 }); }); test('getUnreadInfo does NOT suppress a spurious count when a real message is unread past the receipt', () => { const events = [textMsg('$read'), textMsg('$new')]; const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 }); assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 }); }); const mockRoomWithThreadCounts = ( total: number, highlight: number, threadCounts: Record, ): 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()), { roomId: '!r:x', highlight: 1, total: 4, }); }); // --- getRoomIconSrc ------------------------------------------------------- test('getRoomIconSrc selects icon by room type and join rule', () => { const icons = { Warning: 'warning', SpaceGlobe: 'space-globe', SpaceLock: 'space-lock', Space: 'space', VolumeHighGlobe: 'vol-globe', VolumeHighLock: 'vol-lock', VolumeHigh: 'vol', HashGlobe: 'hash-globe', HashLock: 'hash-lock', Hash: 'hash', } as never; assert.equal(getRoomIconSrc(icons, 'm.server_notice'), 'warning'); assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Public), 'space-globe'); assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Invite), 'space-lock'); assert.equal(getRoomIconSrc(icons, RoomType.Space), 'space'); assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Public), 'vol-globe'); assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Knock), 'vol-lock'); assert.equal(getRoomIconSrc(icons, RoomType.Call), 'vol'); assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Public), 'hash-globe'); assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Invite), 'hash-lock'); assert.equal(getRoomIconSrc(icons), 'hash'); }); // --- reply parsing helpers ----------------------------------------------- test('trimReplyFromBody strips a matrix reply fallback prefix', () => { const body = '> <@a:x> hello\n> more\n\nactual message'; assert.equal(trimReplyFromBody(body), 'actual message'); // no fallback -> unchanged assert.equal(trimReplyFromBody('plain text'), 'plain text'); }); test('trimReplyFromFormattedBody strips up to and including mx-reply', () => { const fb = '
x
real'; assert.equal(trimReplyFromFormattedBody(fb), 'real'); assert.equal(trimReplyFromFormattedBody('no reply here'), 'no reply here'); }); test('parseReplyBody builds a quoted fallback with prefixed newlines', () => { assert.equal(parseReplyBody('@a:x', 'line1\nline2'), '> <@a:x> line1\n> line2\n\n'); }); test('parseReplyFormattedBody builds an mx-reply block with encoded links', () => { const result = parseReplyFormattedBody('!r:x', '@a:x', '$e:x', 'hi'); assert.ok(result.startsWith('
')); assert.ok(result.endsWith('hi
')); assert.ok(result.includes(encodeURIComponent('!r:x'))); assert.ok(result.includes(encodeURIComponent('$e:x'))); assert.ok(result.includes(encodeURIComponent('@a:x'))); }); // --- member helpers ------------------------------------------------------- test('getMemberDisplayName returns name unless it equals the userId', () => { const room = { getMember: (id: string) => id === '@named:x' ? { rawDisplayName: 'Alice' } : { rawDisplayName: '@plain:x' }, } as unknown as Room; assert.equal(getMemberDisplayName(room, '@named:x'), 'Alice'); assert.equal(getMemberDisplayName(room, '@plain:x'), undefined); const noMember = { getMember: () => null } as unknown as Room; assert.equal(getMemberDisplayName(noMember, '@x:x'), undefined); }); test('getMemberSearchStr produces display and id variants', () => { const member = { rawDisplayName: 'Alice', userId: '@alice:x', } as unknown as RoomMember; const mxIdToName = (id: string) => `name(${id})`; // query without @ or : -> uses mapped name for second entry assert.deepEqual(getMemberSearchStr(member, 'al', mxIdToName), ['Alice', 'name(@alice:x)']); // query with @ -> uses raw userId for second entry assert.deepEqual(getMemberSearchStr(member, '@al', mxIdToName), ['Alice', '@alice:x']); // when display name equals userId, first entry falls back to mapped name const unnamed = { rawDisplayName: '@bob:x', userId: '@bob:x' } as unknown as RoomMember; assert.deepEqual(getMemberSearchStr(unnamed, 'bob', mxIdToName), [ 'name(@bob:x)', 'name(@bob:x)', ]); }); test('getMemberAvatarMxc returns member mxc or undefined', () => { const room = { getMember: (id: string) => (id === '@a:x' ? { getMxcAvatarUrl: () => 'mxc://a' } : null), } as unknown as Room; assert.equal(getMemberAvatarMxc(room, '@a:x'), 'mxc://a'); assert.equal(getMemberAvatarMxc(room, '@b:x'), undefined); }); // --- isMembershipChanged -------------------------------------------------- test('isMembershipChanged compares membership and reason against prev content', () => { const changedMembership = mockEvent({ getContent: () => ({ membership: 'join' }), getPrevContent: () => ({ membership: 'invite' }), }); assert.equal(isMembershipChanged(changedMembership), true); const changedReason = mockEvent({ getContent: () => ({ membership: 'leave', reason: 'spam' }), getPrevContent: () => ({ membership: 'leave' }), }); assert.equal(isMembershipChanged(changedReason), true); const unchanged = mockEvent({ getContent: () => ({ membership: 'join', reason: 'x' }), getPrevContent: () => ({ membership: 'join', reason: 'x' }), }); assert.equal(isMembershipChanged(unchanged), false); }); // --- getReactionContent / getMentionContent ------------------------------ test('getReactionContent builds an annotation relation', () => { assert.deepEqual(getReactionContent('$e:x', '👍', 'thumbsup'), { 'm.relates_to': { event_id: '$e:x', key: '👍', rel_type: 'm.annotation', }, shortcode: 'thumbsup', }); }); test('getMentionContent includes user_ids and room only when relevant', () => { assert.deepEqual(getMentionContent(['@a:x'], true), { user_ids: ['@a:x'], room: true, }); assert.deepEqual(getMentionContent([], false), {}); assert.deepEqual(getMentionContent(['@a:x'], false), { user_ids: ['@a:x'] }); assert.deepEqual(getMentionContent([], true), { room: true }); }); // --- getLatestEdit / reactionOrEditEvent --------------------------------- test('getLatestEdit returns newest edit from the same sender', () => { const target = mockEvent({ getSender: () => '@a:x' }); const oldEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 100 }); const newEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 200 }); const otherSender = mockEvent({ getSender: () => '@b:x', getTs: () => 300 }); assert.equal(getLatestEdit(target, [oldEdit, newEdit, otherSender]), newEdit); assert.equal(getLatestEdit(target, [otherSender]), undefined); }); test('reactionOrEditEvent detects annotation and replace relations', () => { assert.equal( reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.annotation' }) })), true, ); assert.equal( reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.replace' }) })), true, ); assert.equal( reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.thread' }) })), false, ); assert.equal(reactionOrEditEvent(mockEvent({ getRelation: () => null })), false); }); // --- getAllVersionsRoomCreator ------------------------------------------- test('getAllVersionsRoomCreator collects sender and additional_creators', () => { const createEv = mockEvent({ getSender: () => '@creator:x', getContent: () => ({ additional_creators: ['@co1:x', '@co2:x', 5] }), }); const room = mockRoomWithState({ [StateEvent.RoomCreate]: [createEv] }); const creators = getAllVersionsRoomCreator(room); assert.deepEqual([...creators].sort(), ['@co1:x', '@co2:x', '@creator:x']); }); test('getAllVersionsRoomCreator returns empty set when no create event', () => { const room = mockRoomWithState({}); assert.equal(getAllVersionsRoomCreator(room).size, 0); }); // Reference EventTimeline so the import is used even though state mocks bypass it. test('EventTimeline.FORWARDS constant is available', () => { assert.equal(typeof EventTimeline.FORWARDS, 'string'); });