/* 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 { M_LOCATION, EventStatus, EventType, IEvent, MatrixClient, MatrixEvent, MsgType, PendingEventOrdering, RelationType, Room, Thread, } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { canCancel, canEditContent, canEditOwnEvent, fetchInitialEvent, findEditableEvent, highlightEvent, isContentActionable, isLocationEvent, isVoiceMessage, } from "../../src/utils/EventUtils"; import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils"; import dis from "../../src/dispatcher/dispatcher"; import { Action } from "../../src/dispatcher/actions"; import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; import { VoiceBroadcastInfoState } from "../../src/voice-broadcast/types"; jest.mock("../../src/dispatcher/dispatcher"); describe("EventUtils", () => { const userId = "@user:server"; const roomId = "!room:server"; const mockClient = getMockClientWithEventEmitter({ getUserId: jest.fn().mockReturnValue(userId), }); beforeEach(() => { mockClient.getUserId.mockClear().mockReturnValue(userId); }); afterAll(() => { jest.spyOn(MatrixClientPeg, "get").mockRestore(); }); // setup events const unsentEvent = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, }); unsentEvent.status = EventStatus.ENCRYPTING; const redactedEvent = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, }); redactedEvent.makeRedacted( redactedEvent, new Room(redactedEvent.getRoomId()!, mockClient, mockClient.getUserId()!), ); const stateEvent = new MatrixEvent({ type: EventType.RoomTopic, state_key: "", }); const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId); const roomMemberEvent = new MatrixEvent({ type: EventType.RoomMember, sender: userId, }); const stickerEvent = new MatrixEvent({ type: EventType.Sticker, sender: userId, }); const pollStartEvent = makePollStartEvent("What?", userId); const notDecryptedEvent = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: "m.bad.encrypted", }, }); const noMsgType = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: undefined, }, }); const noContentBody = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.Image, }, }); const emptyContentBody = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.Text, body: "", }, }); const objectContentBody = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.File, body: {}, }, }); const niceTextMessage = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.Text, body: "Hello", }, }); const bobsTextMessage = new MatrixEvent({ type: EventType.RoomMessage, sender: "@bob:server", content: { msgtype: MsgType.Text, body: "Hello from Bob", }, }); const voiceBroadcastStart = mkVoiceBroadcastInfoStateEvent( "!room:example.com", VoiceBroadcastInfoState.Started, "@user:example.com", "ABC123", ); const voiceBroadcastStop = mkVoiceBroadcastInfoStateEvent( "!room:example.com", VoiceBroadcastInfoState.Stopped, "@user:example.com", "ABC123", ); describe("isContentActionable()", () => { type TestCase = [string, MatrixEvent]; it.each([ ["unsent event", unsentEvent], ["redacted event", redactedEvent], ["state event", stateEvent], ["undecrypted event", notDecryptedEvent], ["room member event", roomMemberEvent], ["event without msgtype", noMsgType], ["event without content body property", noContentBody], ["broadcast stop event", voiceBroadcastStop], ])("returns false for %s", (_description, event) => { expect(isContentActionable(event)).toBe(false); }); it.each([ ["sticker event", stickerEvent], ["poll start event", pollStartEvent], ["event with empty content body", emptyContentBody], ["event with a content body", niceTextMessage], ["beacon_info event", beaconInfoEvent], ["broadcast start event", voiceBroadcastStart], ])("returns true for %s", (_description, event) => { expect(isContentActionable(event)).toBe(true); }); }); describe("editable content helpers", () => { const replaceRelationEvent = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.Text, body: "Hello", ["m.relates_to"]: { rel_type: RelationType.Replace, event_id: "1", }, }, }); const referenceRelationEvent = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.Text, body: "Hello", ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: "1", }, }, }); const emoteEvent = new MatrixEvent({ type: EventType.RoomMessage, sender: userId, content: { msgtype: MsgType.Emote, body: "🧪", }, }); type TestCase = [string, MatrixEvent]; const uneditableCases: TestCase[] = [ ["redacted event", redactedEvent], ["state event", stateEvent], ["event that is not room message", roomMemberEvent], ["event without msgtype", noMsgType], ["event without content body property", noContentBody], ["event with empty content body property", emptyContentBody], ["event with non-string body", objectContentBody], ["event not sent by current user", bobsTextMessage], ["event with a replace relation", replaceRelationEvent], ]; const editableCases: TestCase[] = [ ["event with reference relation", referenceRelationEvent], ["emote event", emoteEvent], ["poll start event", pollStartEvent], ["event with a content body", niceTextMessage], ]; describe("canEditContent()", () => { it.each(uneditableCases)("returns false for %s", (_description, event) => { expect(canEditContent(mockClient, event)).toBe(false); }); it.each(editableCases)("returns true for %s", (_description, event) => { expect(canEditContent(mockClient, event)).toBe(true); }); }); describe("canEditOwnContent()", () => { it.each(uneditableCases)("returns false for %s", (_description, event) => { expect(canEditOwnEvent(mockClient, event)).toBe(false); }); it.each(editableCases)("returns true for %s", (_description, event) => { expect(canEditOwnEvent(mockClient, event)).toBe(true); }); }); }); describe("isVoiceMessage()", () => { it("returns true for an event with msc2516.voice content", () => { const event = new MatrixEvent({ type: EventType.RoomMessage, content: { ["org.matrix.msc2516.voice"]: {}, }, }); expect(isVoiceMessage(event)).toBe(true); }); it("returns true for an event with msc3245.voice content", () => { const event = new MatrixEvent({ type: EventType.RoomMessage, content: { ["org.matrix.msc3245.voice"]: {}, }, }); expect(isVoiceMessage(event)).toBe(true); }); it("returns false for an event with voice content", () => { const event = new MatrixEvent({ type: EventType.RoomMessage, content: { body: "hello", }, }); expect(isVoiceMessage(event)).toBe(false); }); }); describe("isLocationEvent()", () => { it("returns true for an event with m.location stable type", () => { const event = new MatrixEvent({ type: M_LOCATION.altName, }); expect(isLocationEvent(event)).toBe(true); }); it("returns true for an event with m.location unstable prefixed type", () => { const event = new MatrixEvent({ type: M_LOCATION.name, }); expect(isLocationEvent(event)).toBe(true); }); it("returns true for a room message with stable m.location msgtype", () => { const event = new MatrixEvent({ type: EventType.RoomMessage, content: { msgtype: M_LOCATION.altName, }, }); expect(isLocationEvent(event)).toBe(true); }); it("returns true for a room message with unstable m.location msgtype", () => { const event = new MatrixEvent({ type: EventType.RoomMessage, content: { msgtype: M_LOCATION.name, }, }); expect(isLocationEvent(event)).toBe(true); }); it("returns false for a non location event", () => { const event = new MatrixEvent({ type: EventType.RoomMessage, content: { body: "Hello", }, }); expect(isLocationEvent(event)).toBe(false); }); }); describe("canCancel()", () => { it.each([[EventStatus.QUEUED], [EventStatus.NOT_SENT], [EventStatus.ENCRYPTING]])( "return true for status %s", (status) => { expect(canCancel(status)).toBe(true); }, ); it.each([ [EventStatus.SENDING], [EventStatus.CANCELLED], [EventStatus.SENT], ["invalid-status" as unknown as EventStatus], ])("return false for status %s", (status) => { expect(canCancel(status)).toBe(false); }); }); describe("fetchInitialEvent", () => { const ROOM_ID = "!roomId:example.org"; let room: Room; let client: MatrixClient; const NORMAL_EVENT = "$normalEvent"; const THREAD_ROOT = "$threadRoot"; const THREAD_REPLY = "$threadReply"; const events: Record> = { [NORMAL_EVENT]: { event_id: NORMAL_EVENT, type: EventType.RoomMessage, content: { body: "Classic event", msgtype: MsgType.Text, }, }, [THREAD_ROOT]: { event_id: THREAD_ROOT, type: EventType.RoomMessage, content: { body: "Thread root", msgtype: "m.text", }, unsigned: { "m.relations": { [RelationType.Thread]: { latest_event: { event_id: THREAD_REPLY, type: EventType.RoomMessage, content: { "body": "Thread reply", "msgtype": MsgType.Text, "m.relates_to": { event_id: "$threadRoot", rel_type: RelationType.Thread, }, }, }, count: 1, current_user_participated: false, }, }, }, }, [THREAD_REPLY]: { event_id: THREAD_REPLY, type: EventType.RoomMessage, content: { "body": "Thread reply", "msgtype": MsgType.Text, "m.relates_to": { event_id: THREAD_ROOT, rel_type: RelationType.Thread, }, }, }, }; beforeEach(() => { jest.clearAllMocks(); stubClient(); client = MatrixClientPeg.safeGet(); room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, }); jest.spyOn(client, "supportsThreads").mockReturnValue(true); jest.spyOn(client, "getRoom").mockReturnValue(room); jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => { return events[eventId] ?? Promise.reject(); }); }); it("returns null for unknown events", async () => { expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull(); expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent); }); it("creates a thread when needed", async () => { await fetchInitialEvent(client, room.roomId, THREAD_REPLY); expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread); }); }); describe("findEditableEvent", () => { it("should not explode when given empty events array", () => { expect( findEditableEvent({ events: [], isForward: true, matrixClient: mockClient, }), ).toBeUndefined(); }); }); describe("highlightEvent", () => { const eventId = "$zLg9jResFQmMO_UKFeWpgLgOgyWrL8qIgLgZ5VywrCQ"; it("should dispatch an action to view the event", () => { highlightEvent(roomId, eventId); expect(dis.dispatch).toHaveBeenCalledWith({ action: Action.ViewRoom, event_id: eventId, highlighted: true, room_id: roomId, metricsTrigger: undefined, }); }); }); });