397 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			397 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
| /*
 | |
| 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 React from "react";
 | |
| import { render, act, RenderResult, waitForElementToBeRemoved, screen, waitFor } from "jest-matrix-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("<PinnedMessagesCard />", () => {
 | |
|     let cli: MockedObject<MatrixClient>;
 | |
|     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<RenderResult> {
 | |
|         const renderResult = render(
 | |
|             <MatrixClientContext.Provider value={cli}>
 | |
|                 <PinnedMessagesCard
 | |
|                     room={room}
 | |
|                     onClose={jest.fn()}
 | |
|                     permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
 | |
|                 />
 | |
|             </MatrixClientContext.Provider>,
 | |
|         );
 | |
|         // 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(
 | |
|             <MatrixClientContext.Provider value={cli}>
 | |
|                 <PinnedMessagesCard
 | |
|                     room={room}
 | |
|                     onClose={jest.fn()}
 | |
|                     permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
 | |
|                 />
 | |
|             </MatrixClientContext.Provider>,
 | |
|         );
 | |
| 
 | |
|         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 { asFragment } = await initPinnedMessagesCard([pin1], [pin2]);
 | |
| 
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(2));
 | |
|         expect(asFragment()).toMatchSnapshot();
 | |
|     });
 | |
| 
 | |
|     it("should not show more than 100 messages", async () => {
 | |
|         const events = Array.from({ length: 120 }, (_, i) =>
 | |
|             mkMessage({
 | |
|                 event: true,
 | |
|                 room: "!room:example.org",
 | |
|                 user: "@alice:example.org",
 | |
|                 msg: `The message ${i}`,
 | |
|                 ts: i,
 | |
|             }),
 | |
|         );
 | |
|         await initPinnedMessagesCard(events, []);
 | |
| 
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(100));
 | |
|     }, 15000);
 | |
| 
 | |
|     it("should updates when messages are pinned", async () => {
 | |
|         // Start with nothing pinned
 | |
|         const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []);
 | |
| 
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(0));
 | |
| 
 | |
|         // Pin the first message
 | |
|         await addLocalPinEvent(pin1);
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(1));
 | |
| 
 | |
|         // Pin the second message
 | |
|         await addNonLocalPinEvent(pin2);
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(2));
 | |
|     });
 | |
| 
 | |
|     it("should updates when messages are unpinned", async () => {
 | |
|         // Start with two pins
 | |
|         const { removeLastLocalPinEvent, removeLastNonLocalPinEvent } = await initPinnedMessagesCard([pin1], [pin2]);
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(2));
 | |
| 
 | |
|         // Unpin the first message
 | |
|         await removeLastLocalPinEvent();
 | |
|         await waitFor(() => expect(screen.queryAllByRole("listitem")).toHaveLength(1));
 | |
| 
 | |
|         // Unpin the second message
 | |
|         await removeLastNonLocalPinEvent();
 | |
|         await waitFor(() => 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(
 | |
|                 <MatrixClientContext.Provider value={cli}>
 | |
|                     <PinnedMessagesCard
 | |
|                         room={room}
 | |
|                         onClose={jest.fn()}
 | |
|                         permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
 | |
|                     />
 | |
|                 </MatrixClientContext.Provider>,
 | |
|             );
 | |
| 
 | |
|             // 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();
 | |
|     });
 | |
| });
 |