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
This commit is contained in:
Kerry 2023-02-08 10:12:39 +13:00 committed by GitHub
parent 013fd0a343
commit 583050c8c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 572 additions and 89 deletions

View file

@ -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";

View file

@ -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;
}

View file

@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 9.5C3 9.22386 3.22386 9 3.5 9H6.5C6.77614 9 7 9.22386 7 9.5V22H3V9.5Z" fill="#C1C6CD"/>
<path d="M17 13.5C17 13.2239 17.2239 13 17.5 13H20.5C20.7761 13 21 13.2239 21 13.5V22H17V13.5Z" fill="#C1C6CD"/>
<path d="M10 2.5C10 2.22386 10.2239 2 10.5 2H13.5C13.7761 2 14 2.22386 14 2.5V22H10V2.5Z" fill="#C1C6CD"/>
<path d="M3 9.5C3 9.22386 3.22386 9 3.5 9H6.5C6.77614 9 7 9.22386 7 9.5V22H3V9.5Z" fill="currentColor"/>
<path d="M17 13.5C17 13.2239 17.2239 13 17.5 13H20.5C20.7761 13 21 13.2239 21 13.5V22H17V13.5Z" fill="currentColor"/>
<path d="M10 2.5C10 2.22386 10.2239 2 10.5 2H13.5C13.7761 2 14 2.22386 14 2.5V22H10V2.5Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 423 B

After

Width:  |  Height:  |  Size: 438 B

View file

@ -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<MatrixEvent>();
const [isLoadingPollStartEvent, setIsLoadingPollStartEvent] = useState(false);
const pollStartEventId = getRelatedPollStartEventId(event);
useEffect(() => {
const room = matrixClient.getRoom(event.getRoomId());
const fetchPollStartEvent = async (roomId: string, pollStartEventId: string): Promise<void> => {
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<any, IBodyProps>(({ mxEvent, ...props }, ref) => {
const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent);
if (!pollStartEvent) {
const pollEndFallbackMessage = M_TEXT.findIn(mxEvent.getContent()) || textForEvent(mxEvent);
return (
<>
<PollIcon className="mx_MPollEndBody_icon" />
{!isLoadingPollStartEvent && pollEndFallbackMessage}
</>
);
}
return <MPollBody mxEvent={pollStartEvent} {...props} />;
});

View file

@ -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<string, React.ComponentType<Partial<IBodyProps>>>([
[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],
]);

View file

@ -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<string, Factory>([
[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 {

View file

@ -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;
}

View file

@ -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)
);

View file

@ -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)

View file

@ -69,10 +69,10 @@ describe("<PollHistoryDialog />", () => {
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: {},

View file

@ -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<RenderResult> {
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<MatrixEvent>,
endEvents: Array<MatrixEvent> = [],
): Promise<Room> {
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++;

View file

@ -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("<MPollEndBody />", () => {
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<Room> => {
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<IBodyProps> = {}) =>
render(<MPollEndBody {...defaultProps} {...props} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
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();
});
});
});

View file

@ -0,0 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MPollEndBody /> when poll start event does not exist in current timeline fetches the related poll start event and displays a poll tile 1`] = `
<div>
<div
class="mx_MPollEndBody_icon"
/>
</div>
`;
exports[`<MPollEndBody /> when poll start event exists in current timeline renders an ended poll 1`] = `
<div>
<div
class="mx_MPollBody"
>
<h2
data-testid="pollQuestion"
>
Question?
</h2>
<div
class="mx_MPollBody_allOptions"
>
<div
class="mx_MPollBody_option mx_MPollBody_option_ended"
data-testid="pollOption-socks"
>
<div
class="mx_MPollBody_endedOption"
data-value="socks"
>
<div
class="mx_MPollBody_optionDescription"
>
<div
class="mx_MPollBody_optionText"
>
Socks
</div>
<div
class="mx_MPollBody_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_MPollBody_popularityBackground"
>
<div
class="mx_MPollBody_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
<div
class="mx_MPollBody_option mx_MPollBody_option_ended"
data-testid="pollOption-shoes"
>
<div
class="mx_MPollBody_endedOption"
data-value="shoes"
>
<div
class="mx_MPollBody_optionDescription"
>
<div
class="mx_MPollBody_optionText"
>
Shoes
</div>
<div
class="mx_MPollBody_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_MPollBody_popularityBackground"
>
<div
class="mx_MPollBody_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
</div>
<div
class="mx_MPollBody_totalVotes"
data-testid="totalVotes"
>
Final result based on 0 votes
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 16px; height: 16px;"
/>
</div>
</div>
</div>
</div>
`;

View file

@ -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<Options> = {},
): 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<MatrixEvent>,
endEvents: Array<MatrixEvent> = [],
mockClient: Mocked<MatrixClient>,
): Promise<Room> => {
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;
};