diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 51d169d61bd..f7e06e18f29 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -54,12 +54,12 @@ describe("Polls", () => { }; const getPollOption = (pollId: string, optionText: string): Chainable => { - return getPollTile(pollId).contains(".mx_MPollBody_option .mx_StyledRadioButton", optionText); + return getPollTile(pollId).contains(".mx_PollOption .mx_StyledRadioButton", optionText); }; const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => { getPollOption(pollId, optionText).within(() => { - cy.get(".mx_MPollBody_optionVoteCount").should("contain", `${votes} vote`); + cy.get(".mx_PollOption_optionVoteCount").should("contain", `${votes} vote`); }); }; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index c13cd4a4349..7c97e3b68d9 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -33,6 +33,7 @@ @import "./components/views/messages/_MBeaconBody.pcss"; @import "./components/views/messages/shared/_MediaProcessingError.pcss"; @import "./components/views/pips/_WidgetPip.pcss"; +@import "./components/views/polls/_PollOption.pcss"; @import "./components/views/settings/devices/_CurrentDeviceSection.pcss"; @import "./components/views/settings/devices/_DeviceDetailHeading.pcss"; @import "./components/views/settings/devices/_DeviceDetails.pcss"; diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss new file mode 100644 index 00000000000..e5a97b7f2b7 --- /dev/null +++ b/res/css/components/views/polls/_PollOption.pcss @@ -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. +*/ + +.mx_PollOption { + border: 1px solid $quinary-content; + border-radius: 8px; + padding: 6px 12px; + max-width: 550px; + background-color: $background; + + .mx_StyledRadioButton_content, + .mx_PollOption_endedOption { + padding-top: 2px; + margin-right: 0px; + } + + .mx_StyledRadioButton_spacer { + display: none; + } +} + +.mx_PollOption, +/* label has cursor: default in user-agent stylesheet */ +/* override */ +.mx_PollOption_live-option { + cursor: pointer; +} + +.mx_PollOption_content { + display: flex; + justify-content: space-between; +} + +.mx_PollOption_optionVoteCount { + color: $secondary-content; + font-size: $font-12px; + white-space: nowrap; +} + +.mx_PollOption_winnerIcon { + height: 12px; + width: 12px; + color: $accent; + margin-right: $spacing-4; + vertical-align: middle; +} + +.mx_PollOption_checked { + border-color: $accent; + + .mx_PollOption_popularityBackground { + .mx_PollOption_popularityAmount { + background-color: $accent; + } + } + + // override checked radio button styling + // to show checkmark instead + .mx_StyledRadioButton_checked { + input[type="radio"] + div { + border-width: 2px; + border-color: $accent; + background-color: $accent; + background-image: url("$(res)/img/element-icons/check-white.svg"); + background-size: 12px; + background-repeat: no-repeat; + background-position: center; + + div { + visibility: hidden; + } + } + } +} + +/* options not actionable in these states */ +.mx_PollOption_checked, +.mx_PollOption_ended { + pointer-events: none; +} + +.mx_PollOption_popularityBackground { + width: 100%; + height: 8px; + margin-right: 12px; + border-radius: 8px; + background-color: $system; + margin-top: $spacing-8; + + .mx_PollOption_popularityAmount { + width: 0%; + height: 8px; + border-radius: 8px; + background-color: $quaternary-content; + } +} diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index e9ea2bc3dc1..193bd9382a4 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -47,108 +47,6 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/room/composer/poll.svg"); } - .mx_MPollBody_option { - border: 1px solid $quinary-content; - border-radius: 8px; - margin-bottom: 16px; - padding: 6px 12px; - max-width: 550px; - background-color: $background; - - .mx_StyledRadioButton, - .mx_MPollBody_endedOption { - margin-bottom: 8px; - } - - .mx_StyledRadioButton_content, - .mx_MPollBody_endedOption { - padding-top: 2px; - margin-right: 0px; - } - - .mx_StyledRadioButton_spacer { - display: none; - } - - .mx_MPollBody_optionDescription { - display: flex; - justify-content: space-between; - - .mx_MPollBody_optionVoteCount { - color: $secondary-content; - font-size: $font-12px; - white-space: nowrap; - } - } - - .mx_MPollBody_popularityBackground { - width: 100%; - height: 8px; - margin-right: 12px; - border-radius: 8px; - background-color: $system; - - .mx_MPollBody_popularityAmount { - width: 0%; - height: 8px; - border-radius: 8px; - background-color: $quaternary-content; - } - } - } - - .mx_MPollBody_option:last-child { - margin-bottom: 8px; - } - - .mx_MPollBody_option_checked { - border-color: $accent; - - .mx_MPollBody_popularityBackground { - .mx_MPollBody_popularityAmount { - background-color: $accent; - } - } - } - - /* options not actionable in these states */ - .mx_MPollBody_option_checked, - .mx_MPollBody_option_ended { - pointer-events: none; - } - - .mx_StyledRadioButton_checked, - .mx_MPollBody_endedOptionWinner { - input[type="radio"] + div { - border-width: 2px; - border-color: $accent; - background-color: $accent; - background-image: url("$(res)/img/element-icons/check-white.svg"); - background-size: 12px; - background-repeat: no-repeat; - background-position: center; - - div { - visibility: hidden; - } - } - } - - .mx_MPollBody_endedOptionWinner .mx_MPollBody_optionDescription .mx_MPollBody_optionVoteCount::before { - content: ""; - position: relative; - display: inline-block; - margin-right: 4px; - top: 2px; - height: 12px; - width: 12px; - background-color: $accent; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - mask-image: url("$(res)/img/element-icons/trophy.svg"); - } - .mx_MPollBody_totalVotes { display: flex; flex-direction: inline; @@ -168,9 +66,8 @@ limitations under the License. pointer-events: none; } -.mx_MPollBody_option, -/* label has cursor: default in user-agent stylesheet */ -/* override */ -.mx_MPollBody_live-option { - cursor: pointer; +.mx_MPollBody_allOptions { + display: grid; + grid-gap: $spacing-16; + margin-bottom: $spacing-8; } diff --git a/res/img/element-icons/trophy.svg b/res/img/element-icons/trophy.svg index 7caf61fd35e..99f4831b573 100644 --- a/res/img/element-icons/trophy.svg +++ b/res/img/element-icons/trophy.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index f5bb9e81146..c65a0de418e 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; @@ -30,13 +29,13 @@ import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import { IBodyProps } from "./IBodyProps"; import { formatCommaSeparatedList } from "../../../utils/FormattingUtils"; -import StyledRadioButton from "../elements/StyledRadioButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import ErrorDialog from "../dialogs/ErrorDialog"; import { GetRelationsForEvent } from "../rooms/EventTile"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Spinner from "../elements/Spinner"; +import { PollOption } from "../polls/PollOption"; interface IState { poll?: Poll; @@ -230,10 +229,6 @@ export default class MPollBody extends React.Component { this.setState({ selected: answerId }); } - private onOptionSelected = (e: React.FormEvent): void => { - this.selectOption(e.currentTarget.value); - }; - /** * @returns userId -> UserVote */ @@ -329,47 +324,26 @@ export default class MPollBody extends React.Component {
{pollEvent.answers.map((answer: PollAnswerSubevent) => { let answerVotes = 0; - let votesText = ""; if (showResults) { answerVotes = votes.get(answer.id) ?? 0; - votesText = _t("%(count)s votes", { count: answerVotes }); } const checked = (!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount); - const cls = classNames({ - mx_MPollBody_option: true, - mx_MPollBody_option_checked: checked, - mx_MPollBody_option_ended: poll.isEnded, - }); - const answerPercent = totalVotes === 0 ? 0 : Math.round((100.0 * answerVotes) / totalVotes); return ( -
this.selectOption(answer.id)} - > - {poll.isEnded ? ( - - ) : ( - - )} -
-
-
-
+ pollId={pollId} + answer={answer} + isChecked={checked} + isEnded={poll.isEnded} + voteCount={answerVotes} + totalVoteCount={totalVotes} + displayVoteCount={showResults} + onOptionSelected={this.selectOption.bind(this)} + /> ); })}
@@ -381,53 +355,6 @@ export default class MPollBody extends React.Component { ); } } - -interface IEndedPollOptionProps { - answer: PollAnswerSubevent; - checked: boolean; - votesText: string; -} - -function EndedPollOption(props: IEndedPollOptionProps): JSX.Element { - const cls = classNames({ - mx_MPollBody_endedOption: true, - mx_MPollBody_endedOptionWinner: props.checked, - }); - return ( -
-
-
{props.answer.text}
-
{props.votesText}
-
-
- ); -} - -interface ILivePollOptionProps { - pollId: string; - answer: PollAnswerSubevent; - checked: boolean; - votesText: string; - onOptionSelected: (e: React.FormEvent) => void; -} - -function LivePollOption(props: ILivePollOptionProps): JSX.Element { - return ( - -
-
{props.answer.text}
-
{props.votesText}
-
-
- ); -} - export class UserVote { public constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {} } diff --git a/src/components/views/polls/PollOption.tsx b/src/components/views/polls/PollOption.tsx new file mode 100644 index 00000000000..996fbe0d326 --- /dev/null +++ b/src/components/views/polls/PollOption.tsx @@ -0,0 +1,123 @@ +/* +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 classNames from "classnames"; +import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; + +import { _t } from "../../../languageHandler"; +import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg"; +import StyledRadioButton from "../elements/StyledRadioButton"; + +type PollOptionContentProps = { + answer: PollAnswerSubevent; + voteCount: number; + displayVoteCount?: boolean; + isWinner?: boolean; +}; +const PollOptionContent: React.FC = ({ isWinner, answer, voteCount, displayVoteCount }) => { + const votesText = displayVoteCount ? _t("%(count)s votes", { count: voteCount }) : ""; + return ( +
+
{answer.text}
+
+ {isWinner && } + {votesText} +
+
+ ); +}; + +interface PollOptionProps extends PollOptionContentProps { + pollId: string; + totalVoteCount: number; + isEnded?: boolean; + isChecked?: boolean; + onOptionSelected?: (id: string) => void; +} + +const EndedPollOption: React.FC> = ({ + isChecked, + children, + answer, +}) => ( +
+ {children} +
+); + +const ActivePollOption: React.FC> = ({ + pollId, + isChecked, + children, + answer, + onOptionSelected, +}) => ( + onOptionSelected(answer.id)} + > + {children} + +); + +export const PollOption: React.FC = ({ + pollId, + answer, + voteCount, + totalVoteCount, + displayVoteCount, + isEnded, + isChecked, + onOptionSelected, +}) => { + const cls = classNames({ + mx_PollOption: true, + mx_PollOption_checked: isChecked, + mx_PollOption_ended: isEnded, + }); + const isWinner = isEnded && isChecked; + const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount); + const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption; + return ( +
onOptionSelected(answer.id)}> + + + +
+
+
+
+ ); +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 04149317d46..73f171d7406 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2316,6 +2316,8 @@ "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", "You cancelled verification.": "You cancelled verification.", "Verification cancelled": "Verification cancelled", + "%(count)s votes|other": "%(count)s votes", + "%(count)s votes|one": "%(count)s vote", "%(name)s started a video call": "%(name)s started a video call", "Video call ended": "Video call ended", "Sunday": "Sunday", @@ -2418,8 +2420,6 @@ "Based on %(count)s votes|other": "Based on %(count)s votes", "Based on %(count)s votes|one": "Based on %(count)s vote", "edited": "edited", - "%(count)s votes|other": "%(count)s votes", - "%(count)s votes|one": "%(count)s vote", "Error decrypting video": "Error decrypting video", "Error processing voice message": "Error processing voice message", "Add reaction": "Add reaction", diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index 0b1be75e3fd..7b43459ce3c 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -42,7 +42,7 @@ import MPollBody from "../../../../src/components/views/messages/MPollBody"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; -const CHECKED = "mx_MPollBody_option_checked"; +const CHECKED = "mx_PollOption_checked"; const userId = "@me:example.com"; const mockClient = getMockClientWithEventEmitter({ @@ -383,7 +383,7 @@ describe("MPollBody", () => { const votes: MatrixEvent[] = []; const ends: MatrixEvent[] = []; const { container } = await newMPollBody(votes, ends, answers); - expect(container.querySelectorAll(".mx_MPollBody_option").length).toBe(20); + expect(container.querySelectorAll(".mx_PollOption").length).toBe(20); }); it("hides scores if I voted but the poll is undisclosed", async () => { @@ -429,7 +429,7 @@ describe("MPollBody", () => { ]; 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, "pizza")).toBe('
3 votes'); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe("1 vote"); @@ -531,9 +531,9 @@ describe("MPollBody", () => { 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"); + expect(endedVotesCount(renderResult, "poutine")).toBe('
1 vote'); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("1 vote"); + expect(endedVotesCount(renderResult, "wings")).toBe('
1 vote'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 2 votes"); }); @@ -542,7 +542,7 @@ describe("MPollBody", () => { 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"); + expect(endedVotesCount(renderResult, "poutine")).toBe('
1 vote'); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe("0 votes"); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 1 vote"); @@ -564,7 +564,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -584,7 +584,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -607,7 +607,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -634,7 +634,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -653,8 +653,8 @@ describe("MPollBody", () => { expect(endedVoteChecked(renderResult, "pizza")).toBe(false); // Double-check by looking for the endedOptionWinner class - expect(endedVoteDiv(renderResult, "wings").className.includes("mx_MPollBody_endedOptionWinner")).toBe(true); - expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_MPollBody_endedOptionWinner")).toBe(false); + expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_endedOptionWinner")).toBe(true); + expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_endedOptionWinner")).toBe(false); }); it("highlights multiple winning votes", async () => { @@ -670,13 +670,13 @@ describe("MPollBody", () => { expect(endedVoteChecked(renderResult, "wings")).toBe(true); expect(endedVoteChecked(renderResult, "poutine")).toBe(true); expect(endedVoteChecked(renderResult, "italian")).toBe(false); - expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(3); + expect(renderResult.container.getElementsByClassName(CHECKED)).toHaveLength(3); }); it("highlights nothing if poll has no votes", async () => { const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody([], ends); - expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(0); + expect(renderResult.container.getElementsByClassName(CHECKED)).toHaveLength(0); }); it("says poll is not ended if there is no end event", async () => { @@ -745,7 +745,7 @@ describe("MPollBody", () => { expect(inputs[0].getAttribute("value")).toEqual("n1"); expect(inputs[1].getAttribute("value")).toEqual("n2"); expect(inputs[2].getAttribute("value")).toEqual("n3"); - const options = container.querySelectorAll(".mx_MPollBody_optionText"); + const options = container.querySelectorAll(".mx_PollOption_optionText"); expect(options).toHaveLength(3); expect(options[0].innerHTML).toEqual("new answer 1"); expect(options[1].innerHTML).toEqual("new answer 2"); @@ -934,11 +934,11 @@ function voteButton({ getByTestId }: RenderResult, value: string): Element { } function votesCount({ getByTestId }: RenderResult, value: string): string { - return getByTestId(`pollOption-${value}`).querySelector(".mx_MPollBody_optionVoteCount")!.innerHTML; + return getByTestId(`pollOption-${value}`).querySelector(".mx_PollOption_optionVoteCount")!.innerHTML; } function endedVoteChecked({ getByTestId }: RenderResult, value: string): boolean { - return getByTestId(`pollOption-${value}`).className.includes("mx_MPollBody_option_checked"); + return getByTestId(`pollOption-${value}`).className.includes(CHECKED); } function endedVoteDiv({ getByTestId }: RenderResult, value: string): Element { diff --git a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap index 7bc530048cc..3bce56a5403 100644 --- a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -14,129 +14,132 @@ exports[`MPollBody renders a finished poll 1`] = ` class="mx_MPollBody_allOptions" >
Pizza
0 votes
Poutine
0 votes
Italian
+
2 votes
Wings
1 vote
@@ -166,129 +169,135 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = ` class="mx_MPollBody_allOptions" >
Pizza
+
2 votes
Poutine
0 votes
Italian
0 votes
Wings
+
2 votes
@@ -318,129 +327,129 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` class="mx_MPollBody_allOptions" >
Pizza
0 votes
Poutine
0 votes
Italian
0 votes
Wings
0 votes
@@ -472,11 +481,11 @@ exports[`MPollBody renders a poll that I have not voted in 1`] = ` class="mx_MPollBody_allOptions" >
@@ -672,11 +689,11 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = class="mx_MPollBody_allOptions" >