/* Copyright 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ 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, }); }); }); });