From 3d80eff65bb93399dc30f1088ddb75a21b423c73 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 21 Aug 2024 10:50:00 +0200 Subject: [PATCH] Add Pin/Unpin action in quick access of the message action bar (#12897) * Add Pin/Unpin action in quick access of the message action bar * Add tests for `MessageActionBar` * Add tests for `PinningUtils` * Fix `MessageContextMenu-test` * Add e2e test to pin/unpin from message action bar --- playwright/e2e/pinned-messages/index.ts | 26 +- .../pinned-messages/pinned-messages.spec.ts | 11 + .../context_menus/_MessageContextMenu.pcss | 4 +- .../context_menus/MessageContextMenu.tsx | 64 ++--- .../views/messages/MessageActionBar.tsx | 30 +++ src/utils/PinningUtils.ts | 81 +++++- .../context_menus/MessageContextMenu-test.tsx | 104 ++++---- .../views/messages/MessageActionBar-test.tsx | 36 ++- test/utils/PinningUtils-test.ts | 252 ++++++++++++++++++ 9 files changed, 503 insertions(+), 105 deletions(-) create mode 100644 test/utils/PinningUtils-test.ts diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index a67df09d86..5e61b11e85 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -100,13 +100,35 @@ export class Helpers { } /** - * Pin the given message + * Pin the given message from the quick actions + * @param message + * @param unpin + */ + async pinMessageFromQuickActions(message: string, unpin = false) { + const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message }); + await timelineMessage.hover(); + await this.page.getByRole("button", { name: unpin ? "Unpin" : "Pin", exact: true }).click(); + } + + /** + * Pin the given messages from the quick actions + * @param messages + * @param unpin + */ + async pinMessagesFromQuickActions(messages: string[], unpin = false) { + for (const message of messages) { + await this.pinMessageFromQuickActions(message, unpin); + } + } + + /** + * Pin the given message from the contextual menu * @param message */ async pinMessage(message: string) { const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message }); await timelineMessage.click({ button: "right" }); - await this.page.getByRole("menuitem", { name: "Pin" }).click(); + await this.page.getByRole("menuitem", { name: "Pin", exact: true }).click(); } /** diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index be1c92223f..53f657ea7f 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -76,4 +76,15 @@ test.describe("Pinned messages", () => { await util.backPinnedMessagesList(); await util.assertPinnedCountInRoomInfo(0); }); + + test("should be able to pin and unpin from the quick actions", async ({ page, app, room1, util }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]); + await util.pinMessagesFromQuickActions(["Msg1"]); + await util.openRoomInfo(); + await util.assertPinnedCountInRoomInfo(1); + + await util.pinMessagesFromQuickActions(["Msg1"], true); + await util.assertPinnedCountInRoomInfo(0); + }); }); diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss index be113c770f..28529eabf9 100644 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ b/res/css/views/context_menus/_MessageContextMenu.pcss @@ -81,11 +81,11 @@ limitations under the License. } .mx_MessageContextMenu_iconPin::before { - mask-image: url("$(res)/img/element-icons/room/pin-upright.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/pin.svg"); } .mx_MessageContextMenu_iconUnpin::before { - mask-image: url("$(res)/img/element-icons/room/pin.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/unpin.svg"); } .mx_MessageContextMenu_iconCopy::before { diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 801ab0b023..2d5a81a89b 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -36,9 +36,8 @@ import Modal from "../../../Modal"; import Resend from "../../../Resend"; import SettingsStore from "../../../settings/SettingsStore"; import { isUrlPermitted } from "../../../HtmlUtils"; -import { canEditContent, canPinEvent, editEvent, isContentActionable } from "../../../utils/EventUtils"; +import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; -import { ReadPinsEventId } from "../right_panel/types"; import { Action } from "../../../dispatcher/actions"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { ButtonEvent } from "../elements/AccessibleButton"; @@ -60,6 +59,7 @@ import { getForwardableEvent } from "../../../events/forward/getForwardableEvent import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { CardContext } from "../right_panel/context"; +import PinningUtils from "../../../utils/PinningUtils"; interface IReplyInThreadButton { mxEvent: MatrixEvent; @@ -177,24 +177,11 @@ export default class MessageContextMenu extends React.Component this.props.mxEvent.getType() !== EventType.RoomServerAcl && this.props.mxEvent.getType() !== EventType.RoomEncryption; - let canPin = - !!room?.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli) && - canPinEvent(this.props.mxEvent); - - // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality - if (!SettingsStore.getValue("feature_pinning")) canPin = false; + const canPin = PinningUtils.canPinOrUnpin(cli, this.props.mxEvent); this.setState({ canRedact, canPin }); }; - private isPinned(): boolean { - const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - const pinnedEvent = room?.currentState.getStateEvents(EventType.RoomPinnedEvents, ""); - if (!pinnedEvent) return false; - const content = pinnedEvent.getContent(); - return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); - } - private canEndPoll(mxEvent: MatrixEvent): boolean { return ( M_POLL_START.matches(mxEvent.getType()) && @@ -257,22 +244,8 @@ export default class MessageContextMenu extends React.Component }; private onPinClick = (): void => { - const cli = MatrixClientPeg.safeGet(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - if (!room) return; - const eventId = this.props.mxEvent.getId(); - - const pinnedIds = room.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || []; - - if (pinnedIds.includes(eventId)) { - pinnedIds.splice(pinnedIds.indexOf(eventId), 1); - } else { - pinnedIds.push(eventId); - cli.setRoomAccountData(room.roomId, ReadPinsEventId, { - event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId], - }); - } - cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""); + // Pin or unpin in background + PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent); this.closeMenu(); }; @@ -452,17 +425,6 @@ export default class MessageContextMenu extends React.Component ); } - let pinButton: JSX.Element | undefined; - if (contentActionable && this.state.canPin) { - pinButton = ( - - ); - } - // This is specifically not behind the developerMode flag to give people insight into the Matrix const viewSourceButton = ( ); } + let pinButton: JSX.Element | undefined; + if (rightClick && this.state.canPin) { + const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); + pinButton = ( + + ); + } + let viewInRoomButton: JSX.Element | undefined; if (isThreadRootEvent) { viewInRoomButton = ( @@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component } let quickItemsList: JSX.Element | undefined; - if (editButton || replyButton || reactButton) { + if (editButton || replyButton || reactButton || pinButton) { quickItemsList = ( {reactButton} {replyButton} {replyInThreadButton} {editButton} + {pinButton} ); } @@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component {openInMapSiteButton} {endPollButton} {forwardButton} - {pinButton} {permalinkButton} {reportEventButton} {externalURLButton} diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 25547c7836..eebcb96b1e 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -26,6 +26,8 @@ import { M_BEACON_INFO, } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; +import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin.svg"; +import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg"; import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg"; import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg"; @@ -61,6 +63,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa import { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile"; import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types"; import { ButtonEvent } from "../elements/AccessibleButton"; +import PinningUtils from "../../../utils/PinningUtils"; interface IOptionsButtonProps { mxEvent: MatrixEvent; @@ -384,6 +387,17 @@ export default class MessageActionBar extends React.PureComponent => { + // Don't open the regular browser or our context menu on right-click + event.preventDefault(); + event.stopPropagation(); + + await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent); + }; + public render(): React.ReactNode { const toolbarOpts: JSX.Element[] = []; if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) { @@ -401,6 +415,22 @@ export default class MessageActionBar extends React.PureComponent + {isPinned ? : } + , + ); + } + const cancelSendingButton = ( { + const room = matrixClient.getRoom(mxEvent.getRoomId()); + if (!room) return; + + const eventId = mxEvent.getId(); + if (!eventId) return; + + // Get the current pinned events of the room + const pinnedIds: Array = + room + .getLiveTimeline() + .getState(EventTimeline.FORWARDS) + ?.getStateEvents(EventType.RoomPinnedEvents, "") + ?.getContent().pinned || []; + + // If the event is already pinned, unpin it + if (pinnedIds.includes(eventId)) { + pinnedIds.splice(pinnedIds.indexOf(eventId), 1); + } else { + // Otherwise, pin it + pinnedIds.push(eventId); + await matrixClient.setRoomAccountData(room.roomId, ReadPinsEventId, { + event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId], + }); + } + await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, ""); + } } diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index 2be71e39cb..7be05452c8 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { fireEvent, render, RenderResult } from "@testing-library/react"; +import { fireEvent, render, RenderResult, screen, waitFor } from "@testing-library/react"; import { EventStatus, MatrixEvent, @@ -28,9 +28,11 @@ import { FeatureSupport, Thread, M_POLL_KIND_DISCLOSED, + EventTimeline, } from "matrix-js-sdk/src/matrix"; import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; @@ -83,8 +85,16 @@ describe("MessageContextMenu", () => { }); describe("message pinning", () => { + let room: Room; + beforeEach(() => { + room = makeDefaultRoom(); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(true); }); afterAll(() => { @@ -95,25 +105,23 @@ describe("MessageContextMenu", () => { const eventContent = createMessageEventContent("hello"); const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); - const room = makeDefaultRoom(); // mock permission to disallow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false); + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(false); - createMenu(event, {}, {}, undefined, room); + createMenu(event, { rightClick: true }, {}, undefined, room); - expect(document.querySelector('li[aria-label="Pin"]')).toBeFalsy(); + expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); }); it("does not show pin option for beacon_info event", () => { const deadBeaconEvent = makeBeaconInfoEvent("@alice:server.org", roomId, { isLive: false }); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + createMenu(deadBeaconEvent, { rightClick: true }, {}, undefined, room); - createMenu(deadBeaconEvent, {}, {}, undefined, room); - - expect(document.querySelector('li[aria-label="Pin"]')).toBeFalsy(); + expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); }); it("does not show pin option when pinning feature is disabled", () => { @@ -124,15 +132,12 @@ describe("MessageContextMenu", () => { room_id: roomId, }); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); // disable pinning feature jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); - createMenu(pinnableEvent, {}, {}, undefined, room); + createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room); - expect(document.querySelector('li[aria-label="Pin"]')).toBeFalsy(); + expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); }); it("shows pin option when pinning feature is enabled", () => { @@ -143,16 +148,12 @@ describe("MessageContextMenu", () => { room_id: roomId, }); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room); - createMenu(pinnableEvent, {}, {}, undefined, room); - - expect(document.querySelector('li[aria-label="Pin"]')).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Pin" })).toBeTruthy(); }); - it("pins event on pin option click", () => { + it("pins event on pin option click", async () => { const onFinished = jest.fn(); const eventContent = createMessageEventContent("hello"); const pinnableEvent = new MatrixEvent({ @@ -162,43 +163,48 @@ describe("MessageContextMenu", () => { }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.safeGet(); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue({ + // @ts-ignore + getContent: () => ({ pinned: ["!1", "!2"] }), + }); // mock read pins account data const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } }); jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData); - createMenu(pinnableEvent, { onFinished }, {}, undefined, room); + createMenu(pinnableEvent, { onFinished, rightClick: true }, {}, undefined, room); - fireEvent.click(document.querySelector('li[aria-label="Pin"]')!); + await userEvent.click(screen.getByRole("menuitem", { name: "Pin" })); // added to account data - expect(client.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, { - event_ids: [ - // from account data - "!1", - "!2", - pinnableEvent.getId(), - ], - }); + await waitFor(() => + expect(client.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, { + event_ids: [ + // from account data + "!1", + "!2", + pinnableEvent.getId(), + ], + }), + ); // add to room's pins - expect(client.sendStateEvent).toHaveBeenCalledWith( - roomId, - EventType.RoomPinnedEvents, - { - pinned: [pinnableEvent.getId()], - }, - "", + await waitFor(() => + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + EventType.RoomPinnedEvents, + { + pinned: ["!1", "!2", pinnableEvent.getId()], + }, + "", + ), ); expect(onFinished).toHaveBeenCalled(); }); - it("unpins event on pin option click when event is pinned", () => { + it("unpins event on pin option click when event is pinned", async () => { const eventContent = createMessageEventContent("hello"); const pinnableEvent = new MatrixEvent({ type: EventType.RoomMessage, @@ -207,7 +213,6 @@ describe("MessageContextMenu", () => { }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.safeGet(); - const room = makeDefaultRoom(); // make the event already pinned in the room const pinEvent = new MatrixEvent({ @@ -216,18 +221,15 @@ describe("MessageContextMenu", () => { state_key: "", content: { pinned: [pinnableEvent.getId(), "!another-event"] }, }); - room.currentState.setStateEvents([pinEvent]); - - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!.setStateEvents([pinEvent]); // mock read pins account data const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } }); jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData); - createMenu(pinnableEvent, {}, {}, undefined, room); + createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room); - fireEvent.click(document.querySelector('li[aria-label="Unpin"]')!); + await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" })); expect(client.setRoomAccountData).not.toHaveBeenCalled(); diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index 33d1f0d8ee..ad184314a2 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -25,6 +25,7 @@ import { Room, FeatureSupport, Thread, + EventTimeline, } from "matrix-js-sdk/src/matrix"; import MessageActionBar from "../../../../src/components/views/messages/MessageActionBar"; @@ -51,6 +52,8 @@ describe("", () => { ...mockClientMethodsUser(userId), ...mockClientMethodsEvents(), getRoom: jest.fn(), + setRoomAccountData: jest.fn(), + sendStateEvent: jest.fn(), }); const room = new Room(roomId, client, userId); @@ -442,10 +445,10 @@ describe("", () => { }); }); - it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"]])( + it.each([["React"], ["Reply"], ["Reply in thread"], ["Edit"], ["Pin"]])( "does not show context menu when right-clicking", (buttonLabel: string) => { - // For favourite button + // For favourite and pin buttons jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); const event = new MouseEvent("contextmenu", { @@ -468,4 +471,33 @@ describe("", () => { fireEvent.contextMenu(queryByLabelText("Options")!); expect(queryByTestId("mx_MessageContextMenu")).toBeTruthy(); }); + + describe("pin button", () => { + beforeEach(() => { + // enable pin button + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + }); + + afterEach(() => { + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockRestore(); + }); + + it("should not render pin button when user can't send state event", () => { + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(false); + + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText("Pin")).toBeFalsy(); + }); + + it("should render pin button", () => { + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText("Pin")).toBeTruthy(); + }); + }); }); diff --git a/test/utils/PinningUtils-test.ts b/test/utils/PinningUtils-test.ts new file mode 100644 index 0000000000..07b26adb8a --- /dev/null +++ b/test/utils/PinningUtils-test.ts @@ -0,0 +1,252 @@ +/* + * Copyright 2024 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 { EventTimeline, EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import { createTestClient } from "../test-utils"; +import PinningUtils from "../../src/utils/PinningUtils"; +import SettingsStore from "../../src/settings/SettingsStore"; +import { canPinEvent, isContentActionable } from "../../src/utils/EventUtils"; +import { ReadPinsEventId } from "../../src/components/views/right_panel/types"; + +jest.mock("../../src/utils/EventUtils", () => { + return { + isContentActionable: jest.fn(), + canPinEvent: jest.fn(), + }; +}); + +describe("PinningUtils", () => { + const roomId = "!room:example.org"; + const userId = "@alice:example.org"; + + const mockedIsContentActionable = mocked(isContentActionable); + const mockedCanPinEvent = mocked(canPinEvent); + + let matrixClient: MatrixClient; + let room: Room; + + /** + * Create a pinned event with the given content. + * @param content + */ + function makePinEvent(content?: Partial) { + return new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + content: { + body: "First pinned message", + msgtype: "m.text", + }, + room_id: roomId, + origin_server_ts: 0, + event_id: "$eventId", + ...content, + }); + } + + beforeEach(() => { + // Enable feature pinning + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + mockedIsContentActionable.mockImplementation(() => true); + mockedCanPinEvent.mockImplementation(() => true); + + matrixClient = createTestClient(); + room = new Room(roomId, matrixClient, userId); + matrixClient.getRoom = jest.fn().mockReturnValue(room); + + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(true); + }); + + describe("isPinnable", () => { + test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => { + const event = makePinEvent({ type: eventType }); + expect(PinningUtils.isPinnable(event)).toBe(true); + }); + + test("should return false for a non pinnable event type", () => { + const event = makePinEvent({ type: EventType.RoomCreate }); + expect(PinningUtils.isPinnable(event)).toBe(false); + }); + + test("should return false for a redacted event", () => { + const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } }); + expect(PinningUtils.isPinnable(event)).toBe(false); + }); + }); + + describe("isPinned", () => { + test("should return false if no room", () => { + matrixClient.getRoom = jest.fn().mockReturnValue(undefined); + const event = makePinEvent(); + + expect(PinningUtils.isPinned(matrixClient, event)).toBe(false); + }); + + test("should return false if no pinned event", () => { + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "getStateEvents", + ).mockReturnValue(null); + + const event = makePinEvent(); + expect(PinningUtils.isPinned(matrixClient, event)).toBe(false); + }); + + test("should return false if pinned events do not contain the event id", () => { + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "getStateEvents", + ).mockReturnValue({ + // @ts-ignore + getContent: () => ({ pinned: ["$otherEventId"] }), + }); + + const event = makePinEvent(); + expect(PinningUtils.isPinned(matrixClient, event)).toBe(false); + }); + + test("should return true if pinned events contains the event id", () => { + const event = makePinEvent(); + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "getStateEvents", + ).mockReturnValue({ + // @ts-ignore + getContent: () => ({ pinned: [event.getId()] }), + }); + + expect(PinningUtils.isPinned(matrixClient, event)).toBe(true); + }); + }); + + describe("canPinOrUnpin", () => { + test("should return false if pinning is disabled", () => { + // Disable feature pinning + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + const event = makePinEvent(); + + expect(PinningUtils.canPinOrUnpin(matrixClient, event)).toBe(false); + }); + + test("should return false if event is not actionable", () => { + mockedIsContentActionable.mockImplementation(() => false); + const event = makePinEvent(); + + expect(PinningUtils.canPinOrUnpin(matrixClient, event)).toBe(false); + }); + + test("should return false if no room", () => { + matrixClient.getRoom = jest.fn().mockReturnValue(undefined); + const event = makePinEvent(); + + expect(PinningUtils.canPinOrUnpin(matrixClient, event)).toBe(false); + }); + + test("should return false if client cannot send state event", () => { + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(false); + const event = makePinEvent(); + + expect(PinningUtils.canPinOrUnpin(matrixClient, event)).toBe(false); + }); + + test("should return false if event is not pinnable", () => { + mockedCanPinEvent.mockReturnValue(false); + const event = makePinEvent(); + + expect(PinningUtils.canPinOrUnpin(matrixClient, event)).toBe(false); + }); + + test("should return true if all conditions are met", () => { + const event = makePinEvent(); + + expect(PinningUtils.canPinOrUnpin(matrixClient, event)).toBe(true); + }); + }); + + describe("pinOrUnpinEvent", () => { + test("should do nothing if no room", async () => { + matrixClient.getRoom = jest.fn().mockReturnValue(undefined); + const event = makePinEvent(); + + await PinningUtils.pinOrUnpinEvent(matrixClient, event); + expect(matrixClient.sendStateEvent).not.toHaveBeenCalled(); + }); + + test("should do nothing if no event id", async () => { + const event = makePinEvent({ event_id: undefined }); + + await PinningUtils.pinOrUnpinEvent(matrixClient, event); + expect(matrixClient.sendStateEvent).not.toHaveBeenCalled(); + }); + + test("should pin the event if not pinned", async () => { + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "getStateEvents", + ).mockReturnValue({ + // @ts-ignore + getContent: () => ({ pinned: ["$otherEventId"] }), + }); + + jest.spyOn(room, "getAccountData").mockReturnValue({ + getContent: jest.fn().mockReturnValue({ + event_ids: ["$otherEventId"], + }), + } as unknown as MatrixEvent); + + const event = makePinEvent(); + await PinningUtils.pinOrUnpinEvent(matrixClient, event); + + expect(matrixClient.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, { + event_ids: ["$otherEventId", event.getId()], + }); + expect(matrixClient.sendStateEvent).toHaveBeenCalledWith( + roomId, + EventType.RoomPinnedEvents, + { pinned: ["$otherEventId", event.getId()] }, + "", + ); + }); + + test("should unpin the event if already pinned", async () => { + const event = makePinEvent(); + + jest.spyOn( + matrixClient.getRoom(roomId)!.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "getStateEvents", + ).mockReturnValue({ + // @ts-ignore + getContent: () => ({ pinned: [event.getId(), "$otherEventId"] }), + }); + + await PinningUtils.pinOrUnpinEvent(matrixClient, event); + expect(matrixClient.sendStateEvent).toHaveBeenCalledWith( + roomId, + EventType.RoomPinnedEvents, + { pinned: ["$otherEventId"] }, + "", + ); + }); + }); +});