/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { EventType, HistoryVisibility, JoinRule, MatrixClient, MatrixEvent, Room, RoomMember, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { render } from "jest-matrix-react"; import { ReactElement } from "react"; import { Mocked, mocked } from "jest-mock"; import { textForEvent } from "../src/TextForEvent"; import SettingsStore from "../src/settings/SettingsStore"; import { createTestClient, stubClient } from "./test-utils"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; import UserIdentifierCustomisations from "../src/customisations/UserIdentifier"; import { ElementCall } from "../src/models/Call"; import { getSenderName } from "../src/utils/event/getSenderName"; jest.mock("../src/settings/SettingsStore"); jest.mock("../src/customisations/UserIdentifier", () => ({ getDisplayUserIdentifier: jest.fn().mockImplementation((userId) => userId), })); function mockPinnedEvent(pinnedMessageIds?: string[], prevPinnedMessageIds?: string[]): MatrixEvent { return new MatrixEvent({ type: "m.room.pinned_events", state_key: "", sender: "@foo:example.com", content: { pinned: pinnedMessageIds, }, unsigned: { prev_content: { pinned: prevPinnedMessageIds, }, }, }); } describe("TextForEvent", () => { const mockClient = createTestClient(); describe("getSenderName()", () => { it("Prefers sender.name", () => { expect(getSenderName({ sender: { name: "Alice" } } as MatrixEvent)).toBe("Alice"); }); it("Handles missing sender", () => { expect(getSenderName({ getSender: () => "Alice" } as MatrixEvent)).toBe("Alice"); }); it("Handles missing sender and get sender", () => { expect(getSenderName({ getSender: () => undefined } as MatrixEvent)).toBe("Someone"); }); }); describe("TextForPinnedEvent", () => { it("mentions message when a single message was pinned, with no previously pinned messages", () => { const event = mockPinnedEvent(["message-1"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com pinned a message to this room. See all pinned messages."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); it("mentions message when a single message was pinned, with multiple previously pinned messages", () => { const event = mockPinnedEvent(["message-1", "message-2", "message-3"], ["message-1", "message-2"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com pinned a message to this room. See all pinned messages."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); it("mentions message when a single message was unpinned, with a single message previously pinned", () => { const event = mockPinnedEvent([], ["message-1"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com unpinned a message from this room. See all pinned messages."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); it("mentions message when a single message was unpinned, with multiple previously pinned messages", () => { const event = mockPinnedEvent(["message-2"], ["message-1", "message-2"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com unpinned a message from this room. See all pinned messages."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); it("shows generic text when multiple messages were pinned", () => { const event = mockPinnedEvent(["message-1", "message-2", "message-3"], ["message-1"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com changed the pinned messages for the room."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); it("shows generic text when multiple messages were unpinned", () => { const event = mockPinnedEvent(["message-3"], ["message-1", "message-2", "message-3"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com changed the pinned messages for the room."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); it("shows generic text when one message was pinned, and another unpinned", () => { const event = mockPinnedEvent(["message-2"], ["message-1"]); const plainText = textForEvent(event, mockClient); const component = render(textForEvent(event, mockClient, true) as ReactElement); const expectedText = "@foo:example.com changed the pinned messages for the room."; expect(plainText).toBe(expectedText); expect(component.container).toHaveTextContent(expectedText); }); }); describe("textForPowerEvent()", () => { let mockClient: Mocked; const mockRoom = { getMember: jest.fn(), } as unknown as Mocked; const userA = { userId: "@a", name: "Alice", rawDisplayName: "Alice", } as RoomMember; const userB = { userId: "@b", name: "Bob (@b)", rawDisplayName: "Bob", } as RoomMember; const userC = { userId: "@c", name: "Bob (@c)", rawDisplayName: "Bob", } as RoomMember; interface PowerEventProps { usersDefault?: number; prevDefault?: number; users: Record; prevUsers: Record; } const mockPowerEvent = ({ usersDefault, prevDefault, users, prevUsers }: PowerEventProps): MatrixEvent => { const mxEvent = new MatrixEvent({ type: EventType.RoomPowerLevels, sender: userA.userId, state_key: "", content: { users_default: usersDefault, users, }, unsigned: { prev_content: { users: prevUsers, users_default: prevDefault, }, }, }); mxEvent.sender = { name: userA.name } as RoomMember; return mxEvent; }; beforeAll(() => { mockClient = createTestClient() as Mocked; MatrixClientPeg.get = () => mockClient; MatrixClientPeg.safeGet = () => mockClient; mockClient.getRoom.mockClear().mockReturnValue(mockRoom); mockRoom.getMember .mockClear() .mockImplementation((userId) => [userA, userB, userC].find((u) => u.userId === userId) || null); (SettingsStore.getValue as jest.Mock).mockReturnValue(true); }); beforeEach(() => { (UserIdentifierCustomisations.getDisplayUserIdentifier as jest.Mock) .mockClear() .mockImplementation((userId) => userId); }); it("returns falsy when no users have changed power level", () => { const event = mockPowerEvent({ users: { [userA.userId]: 100, }, prevUsers: { [userA.userId]: 100, }, }); expect(textForEvent(event, mockClient)).toBeFalsy(); }); it("returns false when users power levels have been changed by default settings", () => { const event = mockPowerEvent({ usersDefault: 100, prevDefault: 50, users: { [userA.userId]: 100, }, prevUsers: { [userA.userId]: 50, }, }); expect(textForEvent(event, mockClient)).toBeFalsy(); }); it("returns correct message for a single user with changed power level", () => { const event = mockPowerEvent({ users: { [userB.userId]: 100, }, prevUsers: { [userB.userId]: 50, }, }); const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin."; expect(textForEvent(event, mockClient)).toEqual(expectedText); }); it("returns correct message for a single user with power level changed to the default", () => { const event = mockPowerEvent({ usersDefault: 20, prevDefault: 101, users: { [userB.userId]: 20, }, prevUsers: { [userB.userId]: 50, }, }); const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Default."; expect(textForEvent(event, mockClient)).toEqual(expectedText); }); it("returns correct message for a single user with power level changed to a custom level", () => { const event = mockPowerEvent({ users: { [userB.userId]: -1, }, prevUsers: { [userB.userId]: 50, }, }); const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Custom (-1)."; expect(textForEvent(event, mockClient)).toEqual(expectedText); }); it("returns correct message for a multiple power level changes", () => { const event = mockPowerEvent({ users: { [userB.userId]: 100, [userC.userId]: 50, }, prevUsers: { [userB.userId]: 50, [userC.userId]: 101, }, }); const expectedText = "Alice changed the power level of Bob (@b) from Moderator to Admin," + " Bob (@c) from Custom (101) to Moderator."; expect(textForEvent(event, mockClient)).toEqual(expectedText); }); }); describe("textForCanonicalAliasEvent()", () => { const userA = { userId: "@a", name: "Alice", }; interface AliasEventProps { alias?: string; prevAlias?: string; altAliases?: string[]; prevAltAliases?: string[]; } const mockEvent = ({ alias, prevAlias, altAliases, prevAltAliases }: AliasEventProps): MatrixEvent => new MatrixEvent({ type: EventType.RoomCanonicalAlias, sender: userA.userId, state_key: "", content: { alias, alt_aliases: altAliases, }, unsigned: { prev_content: { alias: prevAlias, alt_aliases: prevAltAliases, }, }, }); type TestCase = [string, AliasEventProps & { result: string }]; const testCases: TestCase[] = [ [ "room alias didn't change", { result: "@a changed the addresses for this room.", }, ], [ "room alias changed", { alias: "banana", prevAlias: "apple", result: "@a set the main address for this room to banana.", }, ], [ "room alias was added", { alias: "banana", result: "@a set the main address for this room to banana.", }, ], [ "room alias was removed", { prevAlias: "apple", result: "@a removed the main address for this room.", }, ], [ "added an alt alias", { altAliases: ["canteloupe"], result: "@a added alternative address canteloupe for this room.", }, ], [ "added multiple alt aliases", { altAliases: ["canteloupe", "date"], result: "@a added the alternative addresses canteloupe, date for this room.", }, ], [ "removed an alt alias", { altAliases: ["canteloupe"], prevAltAliases: ["canteloupe", "date"], result: "@a removed alternative address date for this room.", }, ], [ "added and removed an alt aliases", { altAliases: ["canteloupe", "elderberry"], prevAltAliases: ["canteloupe", "date"], result: "@a changed the alternative addresses for this room.", }, ], [ "changed alias and added alt alias", { alias: "banana", prevAlias: "apple", altAliases: ["canteloupe"], result: "@a changed the main and alternative addresses for this room.", }, ], ]; it.each(testCases)("returns correct message when %s", (_d, { result, ...eventProps }) => { const event = mockEvent(eventProps); expect(textForEvent(event, mockClient)).toEqual(result); }); }); describe("textForPollStartEvent()", () => { let pollEvent: MatrixEvent; beforeEach(() => { pollEvent = new MatrixEvent({ type: "org.matrix.msc3381.poll.start", sender: "@a", content: { "org.matrix.msc3381.poll.start": { answers: [{ "org.matrix.msc1767.text": "option1" }, { "org.matrix.msc1767.text": "option2" }], question: { "body": "Test poll name", "msgtype": "m.text", "org.matrix.msc1767.text": "Test poll name", }, }, }, }); }); it("returns correct message for redacted poll start", () => { pollEvent.makeRedacted(pollEvent, new Room(pollEvent.getRoomId()!, mockClient, mockClient.getSafeUserId())); expect(textForEvent(pollEvent, mockClient)).toEqual("@a: Message deleted"); }); it("returns correct message for normal poll start", () => { expect(textForEvent(pollEvent, mockClient)).toEqual("@a has started a poll - "); }); }); describe("textForMessageEvent()", () => { let messageEvent: MatrixEvent; beforeEach(() => { messageEvent = new MatrixEvent({ type: "m.room.message", sender: "@a", content: { "body": "test message", "msgtype": "m.text", "org.matrix.msc1767.text": "test message", }, }); }); it("returns correct message for redacted message", () => { messageEvent.makeRedacted( messageEvent, new Room(messageEvent.getRoomId()!, mockClient, mockClient.getSafeUserId()), ); expect(textForEvent(messageEvent, mockClient)).toEqual("@a: Message deleted"); }); it("returns correct message for normal message", () => { expect(textForEvent(messageEvent, mockClient)).toEqual("@a: test message"); }); }); describe("textForCallEvent()", () => { let mockClient: MatrixClient; let callEvent: MatrixEvent; beforeEach(() => { stubClient(); mockClient = MatrixClientPeg.safeGet(); mocked(mockClient.getRoom).mockReturnValue({ name: "Test room", } as unknown as Room); callEvent = { getRoomId: jest.fn(), getType: jest.fn(), isState: jest.fn().mockReturnValue(true), } as unknown as MatrixEvent; }); describe.each(ElementCall.CALL_EVENT_TYPE.names)("eventType=%s", (eventType: string) => { beforeEach(() => { mocked(callEvent).getType.mockReturnValue(eventType); }); it("returns correct message for call event when supported", () => { expect(textForEvent(callEvent, mockClient)).toEqual("Video call started in Test room."); }); it("returns correct message for call event when not supported", () => { mocked(mockClient).supportsVoip.mockReturnValue(false); expect(textForEvent(callEvent, mockClient)).toEqual( "Video call started in Test room. (not supported by this browser)", ); }); }); }); describe("textForMemberEvent()", () => { beforeEach(() => { stubClient(); }); it("should handle both displayname and avatar changing in one event", () => { expect( textForEvent( new MatrixEvent({ type: "m.room.member", sender: "@a:foo", content: { membership: KnownMembership.Join, avatar_url: "b", displayname: "Bob", }, unsigned: { prev_content: { membership: KnownMembership.Join, avatar_url: "a", displayname: "Andy", }, }, state_key: "@a:foo", }), mockClient, ), ).toMatchInlineSnapshot(`"Andy changed their display name and profile picture"`); }); }); describe("textForJoinRulesEvent()", () => { type TestCase = [string, { result: string }]; const testCases: TestCase[] = [ [JoinRule.Public, { result: "@a made the room public to whoever knows the link." }], [JoinRule.Invite, { result: "@a made the room invite only." }], [JoinRule.Knock, { result: "@a changed the join rule to ask to join." }], [JoinRule.Restricted, { result: "@a changed who can join this room." }], ]; it.each(testCases)("returns correct message when room join rule changed to %s", (joinRule, { result }) => { expect( textForEvent( new MatrixEvent({ type: "m.room.join_rules", sender: "@a", content: { join_rule: joinRule, }, state_key: "", }), mockClient, ), ).toEqual(result); }); it(`returns correct JSX message when room join rule changed to ${JoinRule.Restricted}`, () => { expect( textForEvent( new MatrixEvent({ type: "m.room.join_rules", sender: "@a", content: { join_rule: JoinRule.Restricted, }, state_key: "", }), mockClient, true, ), ).toMatchSnapshot(); }); it("returns correct default message", () => { expect( textForEvent( new MatrixEvent({ type: "m.room.join_rules", sender: "@a", content: { join_rule: "a not implemented one", }, state_key: "", }), mockClient, ), ).toEqual("@a changed the join rule to a not implemented one"); }); }); describe("textForHistoryVisibilityEvent()", () => { type TestCase = [string, { result: string }]; const testCases: TestCase[] = [ [ HistoryVisibility.Invited, { result: "@a made future room history visible to all room members, from the point they are invited." }, ], [ HistoryVisibility.Joined, { result: "@a made future room history visible to all room members, from the point they joined." }, ], [HistoryVisibility.Shared, { result: "@a made future room history visible to all room members." }], [HistoryVisibility.WorldReadable, { result: "@a made future room history visible to anyone." }], ]; it.each(testCases)( "returns correct message when room join rule changed to %s", (historyVisibility, { result }) => { expect( textForEvent( new MatrixEvent({ type: "m.room.history_visibility", sender: "@a", content: { history_visibility: historyVisibility, }, state_key: "", }), mockClient, ), ).toEqual(result); }, ); }); });