2026-06-30 14:28:32 -04:00
|
|
|
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<Record<string, unknown>> = {}): MatrixEvent => {
|
|
|
|
|
const base: Record<string, () => 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<string, MatrixEvent[]>,
|
|
|
|
|
rest: Record<string, unknown> = {},
|
|
|
|
|
): 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 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-01 22:39:10 -04:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-30 14:28:32 -04:00
|
|
|
// --- 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 = '<mx-reply><blockquote>x</blockquote></mx-reply>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('<mx-reply><blockquote>'));
|
|
|
|
|
assert.ok(result.endsWith('hi</blockquote></mx-reply>'));
|
|
|
|
|
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');
|
|
|
|
|
});
|