import { test } from 'node:test'; import assert from 'node:assert/strict'; import { EventTimeline, JoinRule, MatrixEvent, NotificationCountType, Room, RoomMember, } from 'matrix-js-sdk'; import { getStateEvent, getStateEvents, getMDirects, isDirectInvite, isSpace, isRoom, isUnsupportedRoom, isValidChild, getAllParents, getSpaceChildren, mapParentWithChildren, getOrphanParents, isMutedRule, findMutedRule, isNotificationEvent, roomHaveNotification, getUnreadInfo, getRoomIconSrc, trimReplyFromBody, trimReplyFromFormattedBody, parseReplyBody, parseReplyFormattedBody, getMemberDisplayName, getMemberSearchStr, getMemberAvatarMxc, isMembershipChanged, getReactionContent, getLatestEdit, reactionOrEditEvent, getMentionContent, getAllVersionsRoomCreator, } from './room'; import { RoomType, StateEvent, RoomToParents } 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); }); // --- 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, ); }); // --- 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 }); }); 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'); });