diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml
index 0b8fcfa5c8..999f19c258 100644
--- a/.github/workflows/static_analysis.yaml
+++ b/.github/workflows/static_analysis.yaml
@@ -52,6 +52,8 @@ jobs:
- "--noImplicitAny"
steps:
- uses: actions/checkout@v3
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Install Deps
run: "scripts/ci/layered.sh"
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/location/ZoomButtons.tsx b/src/components/views/location/ZoomButtons.tsx
index 4c707979bc..461cdad3cd 100644
--- a/src/components/views/location/ZoomButtons.tsx
+++ b/src/components/views/location/ZoomButtons.tsx
@@ -39,7 +39,7 @@ const ZoomButtons: React.FC = ({ map }) => {
@@ -47,7 +47,7 @@ const ZoomButtons: React.FC = ({ map }) => {
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/effects/confetti/index.ts b/src/effects/confetti/index.ts
index 9900236a21..32b6073845 100644
--- a/src/effects/confetti/index.ts
+++ b/src/effects/confetti/index.ts
@@ -1,6 +1,7 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
+ 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.
@@ -86,7 +87,7 @@ export default class Confetti implements ICanvasEffect {
private particles: Array = [];
private waveAngle = 0;
- public isRunning: boolean;
+ public isRunning = false;
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise => {
if (!canvas) {
diff --git a/src/effects/fireworks/index.ts b/src/effects/fireworks/index.ts
index f37b7152f0..40a8e7593e 100644
--- a/src/effects/fireworks/index.ts
+++ b/src/effects/fireworks/index.ts
@@ -1,6 +1,7 @@
/*
Copyright 2020 Nurjin Jafar
Copyright 2020 Nordeck IT + Consulting GmbH.
+ 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.
@@ -69,7 +70,7 @@ export default class Fireworks implements ICanvasEffect {
private context: CanvasRenderingContext2D | null = null;
private supportsAnimationFrame = window.requestAnimationFrame;
private particles: Array = [];
- public isRunning: boolean;
+ public isRunning = false;
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise => {
if (!canvas) {
@@ -94,7 +95,7 @@ export default class Fireworks implements ICanvasEffect {
if (this.particles.length < this.options.maxCount && this.isRunning) {
this.createFirework();
}
- const alive = [];
+ const alive: FireworksParticle[] = [];
for (let i = 0; i < this.particles.length; i++) {
if (this.move(this.particles[i])) {
alive.push(this.particles[i]);
diff --git a/src/effects/hearts/index.ts b/src/effects/hearts/index.ts
index af4dca154f..3c082e3589 100644
--- a/src/effects/hearts/index.ts
+++ b/src/effects/hearts/index.ts
@@ -1,5 +1,5 @@
/*
- Copyright 2021 The Matrix.org Foundation C.I.C.
+ Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Arseny Uskov
Licensed under the Apache License, Version 2.0 (the "License");
@@ -65,7 +65,7 @@ export default class Hearts implements ICanvasEffect {
private context: CanvasRenderingContext2D | null = null;
private particles: Array = [];
- private lastAnimationTime: number;
+ private lastAnimationTime = 0;
private colours = [
"rgba(194,210,224,1)",
@@ -82,7 +82,7 @@ export default class Hearts implements ICanvasEffect {
"rgba(252,116,183,1)",
];
- public isRunning: boolean;
+ public isRunning = false;
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise => {
if (!canvas) {
diff --git a/src/effects/rainfall/index.ts b/src/effects/rainfall/index.ts
index c0b4d71c77..8b91ab19b9 100644
--- a/src/effects/rainfall/index.ts
+++ b/src/effects/rainfall/index.ts
@@ -1,5 +1,5 @@
/*
- Copyright 2020 The Matrix.org Foundation C.I.C.
+ Copyright 2020 - 2023 The Matrix.org Foundation C.I.C.
Copyright 2021 Josias Allestad
Licensed under the Apache License, Version 2.0 (the "License");
@@ -52,9 +52,9 @@ export default class Rainfall implements ICanvasEffect {
private context: CanvasRenderingContext2D | null = null;
private particles: Array = [];
- private lastAnimationTime: number;
+ private lastAnimationTime = 0;
- public isRunning: boolean;
+ public isRunning = false;
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise => {
if (!canvas) {
diff --git a/src/effects/snowfall/index.ts b/src/effects/snowfall/index.ts
index 9604f79647..8c51e8e22a 100644
--- a/src/effects/snowfall/index.ts
+++ b/src/effects/snowfall/index.ts
@@ -1,5 +1,5 @@
/*
- Copyright 2020 The Matrix.org Foundation C.I.C.
+ Copyright 2020 - 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.
@@ -57,9 +57,9 @@ export default class Snowfall implements ICanvasEffect {
private context: CanvasRenderingContext2D | null = null;
private particles: Array = [];
- private lastAnimationTime: number;
+ private lastAnimationTime = 0;
- public isRunning: boolean;
+ public isRunning = false;
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise => {
if (!canvas) {
diff --git a/src/effects/spaceinvaders/index.ts b/src/effects/spaceinvaders/index.ts
index 6d148ce16d..f375aee88b 100644
--- a/src/effects/spaceinvaders/index.ts
+++ b/src/effects/spaceinvaders/index.ts
@@ -1,5 +1,5 @@
/*
- Copyright 2021 The Matrix.org Foundation C.I.C.
+ Copyright 2021 - 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.
@@ -51,9 +51,9 @@ export default class SpaceInvaders implements ICanvasEffect {
private context: CanvasRenderingContext2D | null = null;
private particles: Array = [];
- private lastAnimationTime: number;
+ private lastAnimationTime = 0;
- public isRunning: boolean;
+ public isRunning = false;
public start = async (canvas: HTMLCanvasElement, timeout = 3000): Promise => {
if (!canvas) {
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/location/ZoomButtons-test.tsx b/test/components/views/location/ZoomButtons-test.tsx
index 831860bcd5..8fbe6710eb 100644
--- a/test/components/views/location/ZoomButtons-test.tsx
+++ b/test/components/views/location/ZoomButtons-test.tsx
@@ -1,5 +1,5 @@
/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
+Copyright 2022, 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.
@@ -15,13 +15,10 @@ limitations under the License.
*/
import React from "react";
-// eslint-disable-next-line deprecate/import
-import { mount } from "enzyme";
import * as maplibregl from "maplibre-gl";
-import { act } from "react-dom/test-utils";
+import { render, screen } from "@testing-library/react";
import ZoomButtons from "../../../../src/components/views/location/ZoomButtons";
-import { findByTestId } from "../../../test-utils";
describe("", () => {
const mapOptions = { container: {} as unknown as HTMLElement, style: "" };
@@ -29,7 +26,7 @@ describe("", () => {
const defaultProps = {
map: mockMap,
};
- const getComponent = (props = {}) => mount();
+ const getComponent = (props = {}) => render();
beforeEach(() => {
jest.clearAllMocks();
@@ -37,15 +34,12 @@ describe("", () => {
it("renders buttons", () => {
const component = getComponent();
- expect(component).toMatchSnapshot();
+ expect(component.asFragment()).toMatchSnapshot();
});
it("calls map zoom in on zoom in click", () => {
const component = getComponent();
-
- act(() => {
- findByTestId(component, "map-zoom-in-button").at(0).simulate("click");
- });
+ screen.getByTestId("map-zoom-in-button").click();
expect(mockMap.zoomIn).toHaveBeenCalled();
expect(component).toBeTruthy();
@@ -53,10 +47,7 @@ describe("", () => {
it("calls map zoom out on zoom out click", () => {
const component = getComponent();
-
- act(() => {
- findByTestId(component, "map-zoom-out-button").at(0).simulate("click");
- });
+ screen.getByTestId("map-zoom-out-button").click();
expect(mockMap.zoomOut).toHaveBeenCalled();
expect(component).toBeTruthy();
diff --git a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap
index 29d29e96dd..c88bced220 100644
--- a/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap
+++ b/test/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap
@@ -162,7 +162,7 @@ exports[` renders map correctly 1`] = `
>
renders map correctly 1`] = `
>
renders map correctly 1`] = `
renders map correctly 1`] = `
>
renders buttons 1`] = `
-
+
-
-
-
-
-
+
+
-
-
-
+ class="mx_ZoomButtons_icon"
+ />
+
-
+
`;
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`] = `
+