From 583050c8c0c0e13bd886c73049f237fdf96e7220 Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 8 Feb 2023 10:12:39 +1300 Subject: [PATCH] Render poll end events in timeline (#10027) * wip * remove dupe * use poll model relations in all cases * update mpollbody tests to use poll instance * update poll fetching login in pinned messages card * add pinned polls to room polls state * add spinner while relations are still loading * handle no poll in end poll dialog * strict errors * render a poll body that errors for poll end events * add fetching logic to pollend tile * extract poll testing utilities * test mpollend * strict fix * more strict fix * strict fix for forwardref * update poll test utils * implicit anys * tidy and add jsdoc --- res/css/_components.pcss | 1 + res/css/views/messages/_MPollEndBody.pcss | 22 ++ res/img/element-icons/room/composer/poll.svg | 6 +- .../views/messages/MPollEndBody.tsx | 109 ++++++++++ .../views/messages/MessageEvent.tsx | 5 +- src/events/EventTileFactory.tsx | 10 +- src/events/forward/getForwardableEvent.ts | 4 +- src/utils/EventRenderingUtils.ts | 3 +- src/utils/EventUtils.ts | 3 +- .../dialogs/polls/PollHistoryDialog-test.tsx | 8 +- .../views/messages/MPollBody-test.tsx | 108 ++++------ .../views/messages/MPollEndBody-test.tsx | 203 ++++++++++++++++++ .../__snapshots__/MPollEndBody-test.tsx.snap | 108 ++++++++++ test/test-utils/poll.ts | 71 +++++- 14 files changed, 572 insertions(+), 89 deletions(-) create mode 100644 res/css/views/messages/_MPollEndBody.pcss create mode 100644 src/components/views/messages/MPollEndBody.tsx create mode 100644 test/components/views/messages/MPollEndBody-test.tsx create mode 100644 test/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 195fc6cce7..541178b926 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -236,6 +236,7 @@ @import "./views/messages/_MLocationBody.pcss"; @import "./views/messages/_MNoticeBody.pcss"; @import "./views/messages/_MPollBody.pcss"; +@import "./views/messages/_MPollEndBody.pcss"; @import "./views/messages/_MStickerBody.pcss"; @import "./views/messages/_MTextBody.pcss"; @import "./views/messages/_MVideoBody.pcss"; diff --git a/res/css/views/messages/_MPollEndBody.pcss b/res/css/views/messages/_MPollEndBody.pcss new file mode 100644 index 0000000000..db30265504 --- /dev/null +++ b/res/css/views/messages/_MPollEndBody.pcss @@ -0,0 +1,22 @@ +/* +Copyright 2023 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. +*/ + +.mx_MPollEndBody_icon { + height: 14px; + margin-right: $spacing-8; + vertical-align: middle; + color: $secondary-content; +} diff --git a/res/img/element-icons/room/composer/poll.svg b/res/img/element-icons/room/composer/poll.svg index 063edab7ab..75e74fd60a 100644 --- a/res/img/element-icons/room/composer/poll.svg +++ b/res/img/element-icons/room/composer/poll.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/src/components/views/messages/MPollEndBody.tsx b/src/components/views/messages/MPollEndBody.tsx new file mode 100644 index 0000000000..2ae6a73e86 --- /dev/null +++ b/src/components/views/messages/MPollEndBody.tsx @@ -0,0 +1,109 @@ +/* +Copyright 2023 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, { useEffect, useState, useContext } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { Icon as PollIcon } from "../../../../res/img/element-icons/room/composer/poll.svg"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { textForEvent } from "../../../TextForEvent"; +import { IBodyProps } from "./IBodyProps"; +import MPollBody from "./MPollBody"; + +const getRelatedPollStartEventId = (event: MatrixEvent): string | undefined => { + const relation = event.getRelation(); + return relation?.event_id; +}; + +/** + * Attempt to retrieve the related poll start event for this end event + * If the event already exists in the rooms timeline, return it + * Otherwise try to fetch the event from the server + * @param event + * @returns + */ +const usePollStartEvent = (event: MatrixEvent): { pollStartEvent?: MatrixEvent; isLoadingPollStartEvent: boolean } => { + const matrixClient = useContext(MatrixClientContext); + const [pollStartEvent, setPollStartEvent] = useState(); + const [isLoadingPollStartEvent, setIsLoadingPollStartEvent] = useState(false); + + const pollStartEventId = getRelatedPollStartEventId(event); + + useEffect(() => { + const room = matrixClient.getRoom(event.getRoomId()); + const fetchPollStartEvent = async (roomId: string, pollStartEventId: string): Promise => { + setIsLoadingPollStartEvent(true); + try { + const startEventJson = await matrixClient.fetchRoomEvent(roomId, pollStartEventId); + const startEvent = new MatrixEvent(startEventJson); + // add the poll to the room polls state + room?.processPollEvents([startEvent, event]); + + // end event is not a valid end to the related start event + // if not sent by the same user + if (startEvent.getSender() === event.getSender()) { + setPollStartEvent(startEvent); + } + } catch (error) { + logger.error("Failed to fetch related poll start event", error); + } finally { + setIsLoadingPollStartEvent(false); + } + }; + + if (pollStartEvent || !room || !pollStartEventId) { + return; + } + + const timelineSet = room.getUnfilteredTimelineSet(); + const localEvent = timelineSet + ?.getTimelineForEvent(pollStartEventId) + ?.getEvents() + .find((e) => e.getId() === pollStartEventId); + + if (localEvent) { + // end event is not a valid end to the related start event + // if not sent by the same user + if (localEvent.getSender() === event.getSender()) { + setPollStartEvent(localEvent); + } + } else { + // pollStartEvent is not in the current timeline, + // fetch it + fetchPollStartEvent(room.roomId, pollStartEventId); + } + }, [event, pollStartEventId, pollStartEvent, matrixClient]); + + return { pollStartEvent, isLoadingPollStartEvent }; +}; + +export const MPollEndBody = React.forwardRef(({ mxEvent, ...props }, ref) => { + const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent); + + if (!pollStartEvent) { + const pollEndFallbackMessage = M_TEXT.findIn(mxEvent.getContent()) || textForEvent(mxEvent); + return ( + <> + + {!isLoadingPollStartEvent && pollEndFallbackMessage} + + ); + } + + return ; +}); diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 781afc9ad5..5b89f25673 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -18,7 +18,7 @@ import React, { createRef } from "react"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { M_LOCATION } from "matrix-js-sdk/src/@types/location"; -import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_END, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import SettingsStore from "../../../settings/SettingsStore"; @@ -37,6 +37,7 @@ import MVoiceOrAudioBody from "./MVoiceOrAudioBody"; import MVideoBody from "./MVideoBody"; import MStickerBody from "./MStickerBody"; import MPollBody from "./MPollBody"; +import { MPollEndBody } from "./MPollEndBody"; import MLocationBody from "./MLocationBody"; import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; @@ -73,6 +74,8 @@ const baseEvTypes = new Map>>([ [EventType.Sticker, MStickerBody], [M_POLL_START.name, MPollBody], [M_POLL_START.altName, MPollBody], + [M_POLL_END.name, MPollEndBody], + [M_POLL_END.altName, MPollEndBody], [M_BEACON_INFO.name, MBeaconBody], [M_BEACON_INFO.altName, MBeaconBody], ]); diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 7f1a31518a..8bb9c28c44 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -18,7 +18,7 @@ import React from "react"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; import { Optional } from "matrix-events-sdk"; -import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_END, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { GroupCallIntent } from "matrix-js-sdk/src/webrtc/groupCall"; @@ -99,6 +99,8 @@ const EVENT_TILE_TYPES = new Map([ [EventType.Sticker, MessageEventFactory], [M_POLL_START.name, MessageEventFactory], [M_POLL_START.altName, MessageEventFactory], + [M_POLL_END.name, MessageEventFactory], + [M_POLL_END.altName, MessageEventFactory], [EventType.KeyVerificationCancel, KeyVerificationConclFactory], [EventType.KeyVerificationDone, KeyVerificationConclFactory], [EventType.CallInvite, LegacyCallEventFactory], // note that this requires a special factory type @@ -412,7 +414,11 @@ export function renderReplyTile( // XXX: this'll eventually be dynamic based on the fields once we have extensible event types const messageTypes = [EventType.RoomMessage, EventType.Sticker]; export function isMessageEvent(ev: MatrixEvent): boolean { - return messageTypes.includes(ev.getType() as EventType) || M_POLL_START.matches(ev.getType()); + return ( + messageTypes.includes(ev.getType() as EventType) || + M_POLL_START.matches(ev.getType()) || + M_POLL_END.matches(ev.getType()) + ); } export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boolean): boolean { diff --git a/src/events/forward/getForwardableEvent.ts b/src/events/forward/getForwardableEvent.ts index 2ceaedc108..380c0a5f9a 100644 --- a/src/events/forward/getForwardableEvent.ts +++ b/src/events/forward/getForwardableEvent.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_END, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; @@ -26,7 +26,7 @@ import { VoiceBroadcastInfoEventType } from "../../voice-broadcast/types"; * If an event is not forwardable return null */ export const getForwardableEvent = (event: MatrixEvent, cli: MatrixClient): MatrixEvent | null => { - if (M_POLL_START.matches(event.getType())) { + if (M_POLL_START.matches(event.getType()) || M_POLL_END.matches(event.getType())) { return null; } diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index 3acd95e48a..ccb0c25c90 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -16,7 +16,7 @@ limitations under the License. import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_END, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { IContent } from "matrix-js-sdk/src/matrix"; @@ -41,6 +41,7 @@ const calcIsInfoMessage = ( eventType !== EventType.Sticker && eventType !== EventType.RoomCreate && !M_POLL_START.matches(eventType) && + !M_POLL_END.matches(eventType) && !M_BEACON_INFO.matches(eventType) && !(eventType === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) ); diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 5e650018a0..cb02de81e4 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -18,7 +18,7 @@ import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType, EVENT_VISIBILITY_CHANGE_TYPE, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; -import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_END, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { M_LOCATION } from "matrix-js-sdk/src/@types/location"; import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; @@ -57,6 +57,7 @@ export function isContentActionable(mxEvent: MatrixEvent): boolean { } else if ( mxEvent.getType() === "m.sticker" || M_POLL_START.matches(mxEvent.getType()) || + M_POLL_END.matches(mxEvent.getType()) || M_BEACON_INFO.matches(mxEvent.getType()) || (mxEvent.getType() === VoiceBroadcastInfoEventType && mxEvent.getContent()?.state === VoiceBroadcastInfoState.Started) diff --git a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx index 4557b145e7..3c937bdc68 100644 --- a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -69,10 +69,10 @@ describe("", () => { expect(getByText("There are no polls in this room")).toBeTruthy(); }); - it("renders a list of polls when there are polls in the timeline", () => { - const pollStart1 = makePollStartEvent("Question?", userId, undefined, 1675300825090, "$1"); - const pollStart2 = makePollStartEvent("Where?", userId, undefined, 1675300725090, "$2"); - const pollStart3 = makePollStartEvent("What?", userId, undefined, 1675200725090, "$3"); + it("renders a list of polls when there are polls in the timeline", async () => { + const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: 1675300825090, id: "$1" }); + const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: 1675300725090, id: "$2" }); + const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: 1675200725090, id: "$3" }); const message = new MatrixEvent({ type: "m.room.message", content: {}, diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index 574a552a0e..a24cc5de0c 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -16,10 +16,9 @@ limitations under the License. import React from "react"; import { fireEvent, render, RenderResult } from "@testing-library/react"; -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { - M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, M_POLL_RESPONSE, @@ -31,7 +30,13 @@ import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; import { allVotes, findTopAnswer, isPollEnded } from "../../../../src/components/views/messages/MPollBody"; import { IBodyProps } from "../../../../src/components/views/messages/IBodyProps"; -import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; +import { + flushPromises, + getMockClientWithEventEmitter, + makePollEndEvent, + mockClientMethodsUser, + setupRoomWithPollEvents, +} from "../../../test-utils"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import MPollBody from "../../../../src/components/views/messages/MPollBody"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; @@ -112,7 +117,7 @@ describe("MPollBody", () => { responseEvent("@catrd:example.com", "poutine"), responseEvent("@dune2:example.com", "wings"), ]; - const ends = [endEvent("@notallowed:example.com", 12)]; + const ends = [newPollEndEvent("@notallowed:example.com", 12)]; const renderResult = await newMPollBody(votes, ends); // Even though an end event was sent, we render the poll as unfinished @@ -222,7 +227,7 @@ describe("MPollBody", () => { content: newPollStart(undefined, undefined, true), }); const props = getMPollBodyPropsFromEvent(mxEvent); - const room = await setupRoomWithPollEvents(mxEvent, votes); + const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient); const renderResult = renderMPollBodyWithWrapper(props); // wait for /relations promise to resolve await flushPromises(); @@ -250,7 +255,7 @@ describe("MPollBody", () => { content: newPollStart(undefined, undefined, true), }); const props = getMPollBodyPropsFromEvent(mxEvent); - const room = await setupRoomWithPollEvents(mxEvent, votes); + const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient); const renderResult = renderMPollBodyWithWrapper(props); // wait for /relations promise to resolve await flushPromises(); @@ -422,7 +427,7 @@ describe("MPollBody", () => { responseEvent("@catrd:example.com", "poutine"), responseEvent("@dune2:example.com", "wings"), ]; - const ends = [endEvent("@me:example.com", 12)]; + const ends = [newPollEndEvent("@me:example.com", 12)]; const renderResult = await newMPollBody(votes, ends, undefined, false); expect(endedVotesCount(renderResult, "pizza")).toBe("3 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); @@ -471,7 +476,7 @@ describe("MPollBody", () => { }); it("sends no events when I click in an ended poll", async () => { - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const votes = [responseEvent("@uy:example.com", "wings", 15), responseEvent("@uy:example.com", "poutine", 15)]; const renderResult = await newMPollBody(votes, ends); clickOption(renderResult, "wings"); @@ -509,7 +514,7 @@ describe("MPollBody", () => { }); it("shows non-radio buttons if the poll is ended", async () => { - const events = [endEvent()]; + const events = [newPollEndEvent()]; const { container } = await newMPollBody([], events); expect(container.querySelector(".mx_StyledRadioButton")).not.toBeInTheDocument(); expect(container.querySelector('input[type="radio"]')).not.toBeInTheDocument(); @@ -523,7 +528,7 @@ describe("MPollBody", () => { responseEvent("@qbert:example.com", "poutine", 16), // latest qbert responseEvent("@qbert:example.com", "wings", 15), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("0 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); @@ -534,7 +539,7 @@ describe("MPollBody", () => { it("counts a single vote as normal if the poll is ended", async () => { const votes = [responseEvent("@qbert:example.com", "poutine", 16)]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("0 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); @@ -551,7 +556,7 @@ describe("MPollBody", () => { responseEvent("@fg:example.com", "pizza", 15), responseEvent("@hi:example.com", "pizza", 15), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(0); @@ -573,7 +578,7 @@ describe("MPollBody", () => { responseEvent("@wf:example.com", "pizza", 15), responseEvent("@ld:example.com", "pizza", 15), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); @@ -594,8 +599,8 @@ describe("MPollBody", () => { responseEvent("@ld:example.com", "pizza", 15), ]; const ends = [ - endEvent("@unauthorised:example.com", 5), // Should be ignored - endEvent("@me:example.com", 25), + newPollEndEvent("@unauthorised:example.com", 5), // Should be ignored + newPollEndEvent("@me:example.com", 25), ]; const renderResult = await newMPollBody(votes, ends); @@ -620,9 +625,9 @@ describe("MPollBody", () => { responseEvent("@ld:example.com", "pizza", 15), ]; const ends = [ - endEvent("@me:example.com", 65), - endEvent("@me:example.com", 25), - endEvent("@me:example.com", 75), + newPollEndEvent("@me:example.com", 65), + newPollEndEvent("@me:example.com", 25), + newPollEndEvent("@me:example.com", 75), ]; const renderResult = await newMPollBody(votes, ends); @@ -640,7 +645,7 @@ describe("MPollBody", () => { responseEvent("@qb:example.com", "wings", 14), responseEvent("@xy:example.com", "wings", 15), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); // Then the winner is highlighted @@ -658,7 +663,7 @@ describe("MPollBody", () => { responseEvent("@xy:example.com", "wings", 15), responseEvent("@fg:example.com", "poutine", 15), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(endedVoteChecked(renderResult, "pizza")).toBe(true); @@ -669,7 +674,7 @@ describe("MPollBody", () => { }); it("highlights nothing if poll has no votes", async () => { - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody([], ends); expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(0); }); @@ -681,7 +686,7 @@ describe("MPollBody", () => { }); it("says poll is ended if there is an end event", async () => { - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const result = await runIsPollEnded(ends); expect(result).toBe(true); }); @@ -693,9 +698,9 @@ describe("MPollBody", () => { room_id: "#myroom:example.com", content: newPollStart([]), }); - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; - await setupRoomWithPollEvents(pollEvent, [], ends); + await setupRoomWithPollEvents(pollEvent, [], ends, mockClient); const poll = mockClient.getRoom(pollEvent.getRoomId()!)!.polls.get(pollEvent.getId()!)!; // start fetching, dont await poll.getResponses(); @@ -793,7 +798,7 @@ describe("MPollBody", () => { }); it("renders a finished poll with no votes", async () => { - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const { container } = await newMPollBody([], ends); expect(container).toMatchSnapshot(); }); @@ -806,7 +811,7 @@ describe("MPollBody", () => { responseEvent("@yo:example.com", "wings", 15), responseEvent("@qr:example.com", "italian", 16), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const { container } = await newMPollBody(votes, ends); expect(container).toMatchSnapshot(); }); @@ -820,7 +825,7 @@ describe("MPollBody", () => { responseEvent("@th:example.com", "poutine", 13), responseEvent("@yh:example.com", "poutine", 14), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const { container } = await newMPollBody(votes, ends); expect(container).toMatchSnapshot(); }); @@ -848,7 +853,7 @@ describe("MPollBody", () => { responseEvent("@th:example.com", "poutine", 13), responseEvent("@yh:example.com", "poutine", 14), ]; - const ends = [endEvent("@me:example.com", 25)]; + const ends = [newPollEndEvent("@me:example.com", 25)]; const { container } = await newMPollBody(votes, ends, undefined, false); expect(container).toMatchSnapshot(); }); @@ -915,28 +920,11 @@ async function newMPollBodyFromEvent( ): Promise { const props = getMPollBodyPropsFromEvent(mxEvent); - await setupRoomWithPollEvents(mxEvent, relationEvents, endEvents); + await setupRoomWithPollEvents(mxEvent, relationEvents, endEvents, mockClient); return renderMPollBodyWithWrapper(props); } -async function setupRoomWithPollEvents( - mxEvent: MatrixEvent, - relationEvents: Array, - endEvents: Array = [], -): Promise { - const room = new Room(mxEvent.getRoomId()!, mockClient, userId); - room.processPollEvents([mxEvent, ...relationEvents, ...endEvents]); - setRedactionAllowedForMeOnly(room); - // wait for events to process on room - await flushPromises(); - mockClient.getRoom.mockReturnValue(room); - mockClient.relations.mockResolvedValue({ - events: [...relationEvents, ...endEvents], - }); - return room; -} - function clickOption({ getByTestId }: RenderResult, value: string) { fireEvent.click(getByTestId(`pollOption-${value}`)); } @@ -961,7 +949,7 @@ function endedVotesCount(renderResult: RenderResult, value: string): string { return votesCount(renderResult, value); } -function newPollStart(answers?: PollAnswer[], question?: string, disclosed = true): PollStartEventContent { +export function newPollStart(answers?: PollAnswer[], question?: string, disclosed = true): PollStartEventContent { if (!answers) { answers = [ { id: "pizza", [M_TEXT.name]: "Pizza" }, @@ -1036,22 +1024,8 @@ function expectedResponseEventCall(answer: string) { return [roomId, eventType, content]; } -function endEvent(sender = "@me:example.com", ts = 0): MatrixEvent { - return new MatrixEvent({ - event_id: nextId(), - room_id: "#myroom:example.com", - origin_server_ts: ts, - type: M_POLL_END.name, - sender: sender, - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$mypoll", - }, - [M_POLL_END.name]: {}, - [M_TEXT.name]: "The poll has ended. Something.", - }, - }); +export function newPollEndEvent(sender = "@me:example.com", ts = 0): MatrixEvent { + return makePollEndEvent("$mypoll", "#myroom:example.com", sender, ts); } async function runIsPollEnded(ends: MatrixEvent[]) { @@ -1062,7 +1036,7 @@ async function runIsPollEnded(ends: MatrixEvent[]) { content: newPollStart(), }); - await setupRoomWithPollEvents(pollEvent, [], ends); + await setupRoomWithPollEvents(pollEvent, [], ends, mockClient); return isPollEnded(pollEvent, mockClient); } @@ -1078,12 +1052,6 @@ function runFindTopAnswer(votes: MatrixEvent[]) { return findTopAnswer(pollEvent, newVoteRelations(votes)); } -function setRedactionAllowedForMeOnly(room: Room) { - jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation((_evt: MatrixEvent, id: string) => { - return id === userId; - }); -} - let EVENT_ID = 0; function nextId(): string { EVENT_ID++; diff --git a/test/components/views/messages/MPollEndBody-test.tsx b/test/components/views/messages/MPollEndBody-test.tsx new file mode 100644 index 0000000000..f34d2003db --- /dev/null +++ b/test/components/views/messages/MPollEndBody-test.tsx @@ -0,0 +1,203 @@ +/* +Copyright 2023 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 } from "@testing-library/react"; +import { EventTimeline, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; + +import { IBodyProps } from "../../../../src/components/views/messages/IBodyProps"; +import { MPollEndBody } from "../../../../src/components/views/messages/MPollEndBody"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; +import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; +import { + flushPromises, + getMockClientWithEventEmitter, + makePollEndEvent, + makePollStartEvent, + mockClientMethodsEvents, + mockClientMethodsUser, + setupRoomWithPollEvents, +} from "../../../test-utils"; + +describe("", () => { + const userId = "@alice:domain.org"; + const roomId = "!room:domain.org"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + ...mockClientMethodsEvents(), + getRoom: jest.fn(), + relations: jest.fn(), + fetchRoomEvent: jest.fn(), + }); + const pollStartEvent = makePollStartEvent("Question?", userId, undefined, { roomId }); + const pollEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123); + + const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise => { + if (pollStart) { + await setupRoomWithPollEvents(pollStart, [], [pollEnd], mockClient); + } + const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId); + + // end events validate against this + jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation( + (_evt: MatrixEvent, id: string) => { + return id === mockClient.getSafeUserId(); + }, + ); + + const timelineSet = room.getUnfilteredTimelineSet(); + const getTimelineForEventSpy = jest.spyOn(timelineSet, "getTimelineForEvent"); + // if we have a pollStart, mock the room timeline to include it + if (pollStart) { + const eventTimeline = { + getEvents: jest.fn().mockReturnValue([pollEnd, pollStart]), + } as unknown as EventTimeline; + getTimelineForEventSpy.mockReturnValue(eventTimeline); + } + mockClient.getRoom.mockReturnValue(room); + + return room; + }; + + const defaultProps = { + mxEvent: pollEndEvent, + highlightLink: "unused", + mediaEventHelper: {} as unknown as MediaEventHelper, + onHeightChanged: () => {}, + onMessageAllowed: () => {}, + permalinkCreator: {} as unknown as RoomPermalinkCreator, + ref: undefined as any, + }; + + const getComponent = (props: Partial = {}) => + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + beforeEach(() => { + mockClient.getRoom.mockReset(); + mockClient.relations.mockResolvedValue({ + events: [], + }); + mockClient.fetchRoomEvent.mockResolvedValue(pollStartEvent.toJSON()); + }); + + afterEach(() => { + jest.spyOn(logger, "error").mockRestore(); + }); + + describe("when poll start event exists in current timeline", () => { + it("renders an ended poll", async () => { + await setupRoomWithEventsTimeline(pollEndEvent, pollStartEvent); + const { container } = getComponent(); + + // ended poll rendered + expect(container).toMatchSnapshot(); + + // didnt try to fetch start event while it was already in timeline + expect(mockClient.fetchRoomEvent).not.toHaveBeenCalled(); + }); + + it("does not render a poll tile when end event is invalid", async () => { + // sender of end event does not match start event + const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123); + await setupRoomWithEventsTimeline(invalidEndEvent, pollStartEvent); + const { getByText } = getComponent({ mxEvent: invalidEndEvent }); + + // no poll tile rendered + expect(getByText("The poll has ended. Something.")).toBeTruthy(); + }); + }); + + describe("when poll start event does not exist in current timeline", () => { + it("fetches the related poll start event and displays a poll tile", async () => { + await setupRoomWithEventsTimeline(pollEndEvent); + const { container, getByTestId } = getComponent(); + + // while fetching event, only icon is shown + expect(container).toMatchSnapshot(); + + // flush the fetch event promise + await flushPromises(); + + expect(mockClient.fetchRoomEvent).toHaveBeenCalledWith(roomId, pollStartEvent.getId()); + + // quick check for poll tile + expect(getByTestId("pollQuestion").innerHTML).toEqual("Question?"); + expect(getByTestId("totalVotes").innerHTML).toEqual("Final result based on 0 votes"); + }); + + it("does not render a poll tile when end event is invalid", async () => { + // sender of end event does not match start event + const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123); + await setupRoomWithEventsTimeline(invalidEndEvent); + const { getByText } = getComponent({ mxEvent: invalidEndEvent }); + + // flush the fetch event promise + await flushPromises(); + + // no poll tile rendered + expect(getByText("The poll has ended. Something.")).toBeTruthy(); + }); + + it("logs an error and displays the text fallback when fetching the start event fails", async () => { + await setupRoomWithEventsTimeline(pollEndEvent); + mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 }); + const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {}); + const { getByText } = getComponent(); + + // flush the fetch event promise + await flushPromises(); + + // poll end event fallback text used + expect(getByText("The poll has ended. Something.")).toBeTruthy(); + expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 }); + }); + + it("logs an error and displays the extensible event text when fetching the start event fails", async () => { + await setupRoomWithEventsTimeline(pollEndEvent); + mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 }); + const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {}); + const { getByText } = getComponent(); + + // flush the fetch event promise + await flushPromises(); + + // poll end event fallback text used + expect(getByText("The poll has ended. Something.")).toBeTruthy(); + expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 }); + }); + + it("displays fallback text when the poll end event does not have text", async () => { + const endWithoutText = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123); + delete endWithoutText.getContent()[M_TEXT.name]; + await setupRoomWithEventsTimeline(endWithoutText); + mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 }); + const { getByText } = getComponent({ mxEvent: endWithoutText }); + + // flush the fetch event promise + await flushPromises(); + + // default fallback text used + expect(getByText("@alice:domain.org has ended a poll")).toBeTruthy(); + }); + }); +}); diff --git a/test/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap new file mode 100644 index 0000000000..dbb66c7151 --- /dev/null +++ b/test/components/views/messages/__snapshots__/MPollEndBody-test.tsx.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` when poll start event does not exist in current timeline fetches the related poll start event and displays a poll tile 1`] = ` +
+
+
+`; + +exports[` when poll start event exists in current timeline renders an ended poll 1`] = ` +
+
+

+ Question? +

+
+
+
+
+
+ Socks +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+
+
+ Shoes +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ Final result based on 0 votes +
+
+
+
+
+
+`; diff --git a/test/test-utils/poll.ts b/test/test-utils/poll.ts index ffb23ee609..9cb108b676 100644 --- a/test/test-utils/poll.ts +++ b/test/test-utils/poll.ts @@ -14,16 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { M_POLL_START, PollAnswer, M_POLL_KIND_DISCLOSED } from "matrix-js-sdk/src/@types/polls"; +import { Mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { M_POLL_START, PollAnswer, M_POLL_KIND_DISCLOSED, M_POLL_END } from "matrix-js-sdk/src/@types/polls"; import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; +import { uuid4 } from "@sentry/utils"; +import { flushPromises } from "./utilities"; + +type Options = { + roomId: string; + ts: number; + id: string; +}; export const makePollStartEvent = ( question: string, sender: string, answers?: PollAnswer[], - ts?: number, - id?: string, + { roomId, ts, id }: Partial = {}, ): MatrixEvent => { if (!answers) { answers = [ @@ -34,7 +43,7 @@ export const makePollStartEvent = ( return new MatrixEvent({ event_id: id || "$mypoll", - room_id: "#myroom:example.com", + room_id: roomId || "#myroom:example.com", sender: sender, type: M_POLL_START.name, content: { @@ -50,3 +59,55 @@ export const makePollStartEvent = ( origin_server_ts: ts || 0, }); }; + +export const makePollEndEvent = (pollStartEventId: string, roomId: string, sender: string, ts = 0): MatrixEvent => { + return new MatrixEvent({ + event_id: uuid4(), + room_id: roomId, + origin_server_ts: ts, + type: M_POLL_END.name, + sender: sender, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollStartEventId, + }, + [M_POLL_END.name]: {}, + [M_TEXT.name]: "The poll has ended. Something.", + }, + }); +}; + +/** + * Creates a room with attached poll events + * Returns room from mockClient + * mocks relations api + * @param mxEvent - poll start event + * @param relationEvents - returned by relations api + * @param endEvents - returned by relations api + * @param mockClient - client in use + * @returns + */ +export const setupRoomWithPollEvents = async ( + mxEvent: MatrixEvent, + relationEvents: Array, + endEvents: Array = [], + mockClient: Mocked, +): Promise => { + const room = new Room(mxEvent.getRoomId()!, mockClient, mockClient.getSafeUserId()); + room.processPollEvents([mxEvent, ...relationEvents, ...endEvents]); + + // set redaction allowed for current user only + // poll end events are validated against this + jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation((_evt: MatrixEvent, id: string) => { + return id === mockClient.getSafeUserId(); + }); + + // wait for events to process on room + await flushPromises(); + mockClient.getRoom.mockReturnValue(room); + mockClient.relations.mockResolvedValue({ + events: [...relationEvents, ...endEvents], + }); + return room; +};