/* 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 React from "react"; import { render, act, RenderResult, waitForElementToBeRemoved, screen } from "@testing-library/react"; import { mocked, MockedObject } from "jest-mock"; import { MatrixEvent, RoomStateEvent, Room, IMinimalEvent, EventType, RelationType, MsgType, M_POLL_KIND_DISCLOSED, EventTimeline, MatrixClient, } from "matrix-js-sdk/src/matrix"; import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent"; import { sleep } from "matrix-js-sdk/src/utils"; import userEvent from "@testing-library/user-event"; import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { PinnedMessagesCard } from "../../../../src/components/views/right_panel/PinnedMessagesCard"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import Modal from "../../../../src/Modal"; import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog"; describe("", () => { let cli: MockedObject; beforeEach(() => { stubClient(); cli = mocked(MatrixClientPeg.safeGet()); cli.getUserId.mockReturnValue("@alice:example.org"); cli.setRoomAccountData.mockResolvedValue({}); cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] }); }); const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => { const room = new Room("!room:example.org", cli, "@me:example.org"); // Deferred since we may be adding or removing pins later const pins = () => [...localPins, ...nonLocalPins]; // Insert pin IDs into room state jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockImplementation( (): any => mkEvent({ event: true, type: EventType.RoomPinnedEvents, content: { pinned: pins().map((e) => e.getId()), }, user: "@user:example.org", room: "!room:example.org", }), ); jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue( true, ); // poll end event validates against this jest.spyOn( room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "maySendRedactionForEvent", ).mockReturnValue(true); // Return all pins over fetchRoomEvent cli.fetchRoomEvent.mockImplementation((roomId, eventId) => { const event = pins().find((e) => e.getId() === eventId)?.event; return Promise.resolve(event as IMinimalEvent); }); cli.getRoom.mockReturnValue(room); return room; }; async function renderMessagePinList(room: Room): Promise { const renderResult = render( , ); // Wait a tick for state updates await act(() => sleep(0)); return renderResult; } /** * * @param room */ async function emitPinUpdate(room: Room) { await act(async () => { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; roomState.emit( RoomStateEvent.Events, new MatrixEvent({ type: EventType.RoomPinnedEvents }), roomState, null, ); }); } /** * Initialize the pinned messages card with the given pinned messages. * Return the room, testing library helpers and functions to add and remove pinned messages. * @param localPins * @param nonLocalPins */ async function initPinnedMessagesCard(localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]) { const room = mkRoom(localPins, nonLocalPins); const addLocalPinEvent = async (event: MatrixEvent) => { localPins.push(event); await emitPinUpdate(room); }; const removeLastLocalPinEvent = async () => { localPins.pop(); await emitPinUpdate(room); }; const addNonLocalPinEvent = async (event: MatrixEvent) => { nonLocalPins.push(event); await emitPinUpdate(room); }; const removeLastNonLocalPinEvent = async () => { nonLocalPins.pop(); await emitPinUpdate(room); }; const renderResult = await renderMessagePinList(room); return { ...renderResult, addLocalPinEvent, removeLastLocalPinEvent, addNonLocalPinEvent, removeLastNonLocalPinEvent, room, }; } const pin1 = mkMessage({ event: true, room: "!room:example.org", user: "@alice:example.org", msg: "First pinned message", ts: 2, }); const pin2 = mkMessage({ event: true, room: "!room:example.org", user: "@alice:example.org", msg: "The second one", ts: 1, }); it("should show spinner whilst loading", async () => { const room = mkRoom([], [pin1]); render( , ); await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar")); }); it("should show the empty state when there are no pins", async () => { const { asFragment } = await initPinnedMessagesCard([], []); expect(screen.getByText("Pin important messages so that they can be easily discovered")).toBeInTheDocument(); expect(asFragment()).toMatchSnapshot(); }); it("should show two pinned messages", async () => { //const room = mkRoom([pin1], [pin2]); const { asFragment } = await initPinnedMessagesCard([pin1], [pin2]); expect(screen.queryAllByRole("listitem")).toHaveLength(2); expect(asFragment()).toMatchSnapshot(); }); it("should updates when messages are pinned", async () => { // Start with nothing pinned const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []); expect(screen.queryAllByRole("listitem")).toHaveLength(0); // Pin the first message await addLocalPinEvent(pin1); expect(screen.getAllByRole("listitem")).toHaveLength(1); // Pin the second message await addNonLocalPinEvent(pin2); expect(screen.getAllByRole("listitem")).toHaveLength(2); }); it("should updates when messages are unpinned", async () => { // Start with two pins const { removeLastLocalPinEvent, removeLastNonLocalPinEvent } = await initPinnedMessagesCard([pin1], [pin2]); expect(screen.getAllByRole("listitem")).toHaveLength(2); // Unpin the first message await removeLastLocalPinEvent(); expect(screen.getAllByRole("listitem")).toHaveLength(1); // Unpin the second message await removeLastNonLocalPinEvent(); expect(screen.queryAllByRole("listitem")).toHaveLength(0); }); it("should display an edited pinned event", async () => { const messageEvent = mkEvent({ event: true, type: EventType.RoomMessage, room: "!room:example.org", user: "@alice:example.org", content: { "msgtype": MsgType.Text, "body": " * First pinned message, edited", "m.new_content": { msgtype: MsgType.Text, body: "First pinned message, edited", }, "m.relates_to": { rel_type: RelationType.Replace, event_id: pin1.getId(), }, }, }); cli.relations.mockResolvedValue({ originalEvent: pin1, events: [messageEvent], }); await initPinnedMessagesCard([], [pin1]); expect(screen.getByText("First pinned message, edited")).toBeInTheDocument(); }); describe("unpinnable event", () => { it("should hide unpinnable events found in local timeline", async () => { // Redacted messages are unpinnable const pin = mkEvent({ event: true, type: EventType.RoomCreate, content: {}, room: "!room:example.org", user: "@alice:example.org", }); await initPinnedMessagesCard([pin], []); expect(screen.queryAllByRole("listitem")).toHaveLength(0); }); it("hides unpinnable events not found in local timeline", async () => { // Redacted messages are unpinnable const pin = mkEvent({ event: true, type: EventType.RoomCreate, content: {}, room: "!room:example.org", user: "@alice:example.org", }); await initPinnedMessagesCard([], [pin]); expect(screen.queryAllByRole("listitem")).toHaveLength(0); }); }); describe("unpin all", () => { it("should not allow to unpinall", async () => { const room = mkRoom([pin1], [pin2]); jest.spyOn( room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent", ).mockReturnValue(false); const { asFragment } = render( , ); // Wait a tick for state updates await act(() => sleep(0)); expect(screen.queryByText("Unpin all messages")).toBeNull(); expect(asFragment()).toMatchSnapshot(); }); it("should allow unpinning all messages", async () => { jest.spyOn(Modal, "createDialog"); const { room } = await initPinnedMessagesCard([pin1], [pin2]); expect(screen.getByText("Unpin all messages")).toBeInTheDocument(); await userEvent.click(screen.getByText("Unpin all messages")); // Should open the UnpinAllDialog dialog expect(Modal.createDialog).toHaveBeenCalledWith(UnpinAllDialog, { roomId: room.roomId, matrixClient: cli }); }); }); it("should displays votes on polls not found in local timeline", async () => { const poll = mkEvent({ ...PollStartEvent.from("A poll", ["Option 1", "Option 2"], M_POLL_KIND_DISCLOSED).serialize(), event: true, room: "!room:example.org", user: "@alice:example.org", }); const answers = (poll.unstableExtensibleEvent as PollStartEvent).answers; const responses = [ ["@alice:example.org", 0] as [string, number], ["@bob:example.org", 0] as [string, number], ["@eve:example.org", 1] as [string, number], ].map(([user, option], i) => mkEvent({ ...PollResponseEvent.from([answers[option as number].id], poll.getId()!).serialize(), event: true, room: "!room:example.org", user, }), ); const end = mkEvent({ ...PollEndEvent.from(poll.getId()!, "Closing the poll").serialize(), event: true, room: "!room:example.org", user: "@alice:example.org", }); // Make the responses available cli.relations.mockImplementation(async (roomId, eventId, relationType, eventType, opts) => { if (eventId === poll.getId() && relationType === RelationType.Reference) { // Paginate the results, for added challenge return opts?.from === "page2" ? { originalEvent: poll, events: responses.slice(2) } : { originalEvent: poll, events: [...responses.slice(0, 2), end], nextBatch: "page2" }; } // type does not allow originalEvent to be falsy // but code seems to // so still test that return { originalEvent: undefined as unknown as MatrixEvent, events: [] }; }); const { room } = await initPinnedMessagesCard([], [poll]); // two pages of results await flushPromises(); await flushPromises(); const pollInstance = room.polls.get(poll.getId()!); expect(pollInstance).toBeTruthy(); expect(screen.getByText("A poll")).toBeInTheDocument(); expect(screen.getByText("Option 1")).toBeInTheDocument(); expect(screen.getByText("2 votes")).toBeInTheDocument(); expect(screen.getByText("Option 2")).toBeInTheDocument(); expect(screen.getByText("1 vote")).toBeInTheDocument(); }); });