Update polls UX to match EX Mobile and improve accessibility (#31245)

* Remove poll ended event UI.

* Add better aria labels for screen reader and change ui to match mobile UX.

- Checkmark and progress bar are only green if the poll is ended.
- Updated the Poll icon for open and ended state and added labels
- Right align total votes count and update text

* Update jest tests

* Fix total votes alignment

* Fix screenshots

* Update snapshot

* Update e2e tests

* fix more e2e tests

* Clean up CSS

* Add back text for undisclosed poll (total should be hidden)

* Update checkmark and progress colours to more closely match mobile

* Don't compute optionNumber on each render

* "Total votes" working doesn't really work with the current web behaviour

Web doesn't show the votes for undisclosed polls(mobile does). reverting and that behaviour change should be addressed in a different PR(or on mobile.).

* Fix e2e test

* Update screenshots

* Move positioning of total votes label back to the left side

as we are no longer changing the copy to match mobile

* Don't concatenate label

* Fix translation order

* Remove unneeded translations

* remove O(n^2) code

* fix snapshots

* Fix check style in poll option

* prettier
This commit is contained in:
David Langley 2025-12-05 19:56:04 +00:00 committed by GitHub
parent d610c3d1ae
commit be8a18964e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1095 additions and 870 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -238,7 +238,6 @@
@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

@ -50,8 +50,43 @@ Please see LICENSE files in the repository root for full details.
}
.mx_PollOption_checked {
border-color: var(--cpd-color-border-interactive-hovered);
.mx_PollOption_popularityBackground {
.mx_PollOption_popularityAmount {
background-color: var(--cpd-color-icon-primary);
}
}
/* override checked radio button styling to show checkmark instead */
.mx_StyledRadioButton_checked {
input[type="radio"]:checked + div {
position: relative;
border-width: 2px;
border-color: var(--cpd-color-icon-primary);
background-color: var(--cpd-color-icon-primary);
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
mask-image: url("@vector-im/compound-design-tokens/icons/check.svg");
mask-size: 12px 12px;
mask-repeat: no-repeat;
mask-position: center;
background-color: var(--cpd-color-icon-on-solid-primary);
width: 12px;
height: 12px;
}
div {
visibility: hidden;
}
}
}
}
.mx_PollOption_ended.mx_PollOption_checked {
.mx_PollOption_popularityBackground {
.mx_PollOption_popularityAmount {
background-color: var(--cpd-color-icon-accent-tertiary);
@ -61,17 +96,8 @@ Please see LICENSE files in the repository root for full details.
/* override checked radio button styling to show checkmark instead */
.mx_StyledRadioButton_checked {
input[type="radio"]:checked + div {
border-width: 2px;
border-color: var(--cpd-color-icon-accent-tertiary);
background-color: var(--cpd-color-icon-accent-tertiary);
background-image: url("@vector-im/compound-design-tokens/icons/check.svg");
background-size: 12px;
background-repeat: no-repeat;
background-position: center;
div {
visibility: hidden;
}
}
}
}
@ -94,6 +120,6 @@ Please see LICENSE files in the repository root for full details.
width: 0%;
height: 8px;
border-radius: 8px;
background-color: $quaternary-content;
background-color: var(--cpd-color-icon-primary);
}
}

View File

@ -6,8 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
$poll-max-width: 550px;
.mx_MPollBody {
margin-top: 8px;
margin-top: var(--cpd-space-2x);
min-width: 0; /* Override fieldset default min-width: min-content */
width: 100%; /* Ensure fieldset takes full available width */
border: none; /* Remove default fieldset border */
@ -18,8 +20,16 @@ Please see LICENSE files in the repository root for full details.
font-size: $font-15px;
line-height: $font-24px;
margin-top: 0;
margin-bottom: 8px;
margin-bottom: var(--cpd-space-2x);
letter-spacing: var(--cpd-font-letter-spacing-heading-lg);
display: flex;
align-items: center;
gap: var(--cpd-space-3x);
svg {
flex-shrink: 0;
color: var(--cpd-color-icon-primary);
}
.mx_MPollBody_edited {
color: $roomtopic-color;
@ -28,22 +38,6 @@ Please see LICENSE files in the repository root for full details.
}
}
legend::before {
content: "";
position: relative;
display: inline-block;
margin-right: 12px;
top: 3px;
left: 3px;
height: 20px;
width: 20px;
background-color: $secondary-content;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
mask-image: url("@vector-im/compound-design-tokens/icons/polls.svg");
}
.mx_MPollBody_totalVotes {
display: flex;
flex-direction: inline;
@ -67,5 +61,5 @@ Please see LICENSE files in the repository root for full details.
display: grid;
gap: $spacing-16;
margin-bottom: $spacing-8;
max-width: 550px;
max-width: $poll-max-width;
}

View File

@ -1,14 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MPollEndBody_icon {
height: 14px;
margin-right: $spacing-8;
vertical-align: middle;
color: $secondary-content;
}

View File

@ -355,11 +355,6 @@ Please see LICENSE files in the repository root for full details.
/* Keep height equal to text for shield alignment, additional 2px because of 1px padding on text */
height: calc($font-18px + 2px);
}
.mx_MPollEndBody {
/* Prevent the poll end body from exceeding the tile width */
width: 100%;
}
}
&:not(.mx_EventTile_noBubble) .mx_EventTile_line:not(.mx_EventTile_mediaLine) {

View File

@ -22,6 +22,8 @@ import {
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
import { type PollStartEvent, type PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import PollsIcon from "@vector-im/compound-design-tokens/assets/web/icons/polls";
import PollsEndIcon from "@vector-im/compound-design-tokens/assets/web/icons/polls-end";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
@ -324,14 +326,18 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
<span className="mx_MPollBody_edited"> ({_t("common|edited")})</span>
) : null;
const PollIcon = poll.isEnded ? PollsEndIcon : PollsIcon;
const pollLabel = poll.isEnded ? _t("poll|ended_poll_label") : _t("poll|poll_label");
return (
<fieldset className="mx_MPollBody">
<legend data-testid="pollQuestion">
<PollIcon width="20" height="20" aria-label={pollLabel} />
{pollEvent.question.text}
{editedSpan}
</legend>
<div className="mx_MPollBody_allOptions">
{pollEvent.answers.map((answer: PollAnswerSubevent) => {
{pollEvent.answers.map((answer: PollAnswerSubevent, index: number) => {
let answerVotes = 0;
if (showResults) {
@ -346,6 +352,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
key={answer.id}
pollId={pollId}
answer={answer}
optionNumber={index + 1}
isChecked={checked}
isEnded={poll.isEnded}
voteCount={answerVotes}

View File

@ -1,108 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useEffect, useState, useContext, type JSX } from "react";
import { MatrixEvent, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { PollsEndIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import MatrixClientContext, { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { _t } from "../../../languageHandler";
import { textForEvent } from "../../../TextForEvent";
import { Caption } from "../typography/Caption";
import { type 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 = ({ mxEvent, ref, ...props }: IBodyProps): JSX.Element => {
const cli = useMatrixClientContext();
const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent);
if (!pollStartEvent) {
const pollEndFallbackMessage = M_TEXT.findIn<string>(mxEvent.getContent()) || textForEvent(mxEvent, cli);
return (
<>
<PollsEndIcon className="mx_MPollEndBody_icon" />
{!isLoadingPollStartEvent && pollEndFallbackMessage}
</>
);
}
return (
<div className="mx_MPollEndBody" ref={ref}>
<Caption>{_t("timeline|m.poll.end|ended")}</Caption>
<MPollBody mxEvent={pollStartEvent} {...props} />
</div>
);
};

View File

@ -15,7 +15,6 @@ import {
MatrixEventEvent,
M_BEACON_INFO,
M_LOCATION,
M_POLL_END,
M_POLL_START,
type IContent,
} from "matrix-js-sdk/src/matrix";
@ -34,7 +33,6 @@ 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";
@ -75,8 +73,6 @@ const baseEvTypes = new Map<string, React.ComponentType<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

@ -36,50 +36,67 @@ const PollOptionContent: React.FC<PollOptionContentProps> = ({ isWinner, answer,
interface PollOptionProps extends PollOptionContentProps {
pollId: string;
totalVoteCount: number;
optionNumber: number;
isEnded?: boolean;
isChecked?: boolean;
onOptionSelected?: (id: string) => void;
children?: ReactNode;
}
const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
isChecked,
children,
answer,
}) => (
<div
className={classNames("mx_PollOption_endedOption", {
mx_PollOption_endedOptionWinner: isChecked,
})}
data-value={answer.id}
>
{children}
</div>
);
const ActivePollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
const ActivePollOption: React.FC<Omit<PollOptionProps, "totalVoteCount"> & { children: ReactNode }> = ({
pollId,
isChecked,
isEnded,
optionNumber,
isWinner,
voteCount,
displayVoteCount,
children,
answer,
onOptionSelected,
}) => (
<StyledRadioButton
className="mx_PollOption_live-option"
name={`poll_answer_select-${pollId}`}
value={answer.id}
checked={isChecked}
onChange={() => onOptionSelected?.(answer.id)}
>
{children}
</StyledRadioButton>
);
}) => {
let ariaLabel: string;
if (displayVoteCount && isWinner) {
ariaLabel = _t("poll|option_label_winning_with_total", {
number: optionNumber,
answer: answer.text,
count: voteCount,
});
} else if (displayVoteCount) {
ariaLabel = _t("poll|option_label_with_total", {
number: optionNumber,
answer: answer.text,
count: voteCount,
});
} else {
ariaLabel = _t("poll|option_label", {
number: optionNumber,
answer: answer.text,
});
}
return (
<StyledRadioButton
className="mx_PollOption_live-option"
name={`poll_answer_select-${pollId}`}
value={answer.id}
checked={isChecked}
disabled={isEnded}
aria-label={ariaLabel}
onChange={() => onOptionSelected?.(answer.id)}
>
<div aria-hidden="true">{children}</div>
</StyledRadioButton>
);
};
export const PollOption: React.FC<PollOptionProps> = ({
pollId,
answer,
voteCount,
totalVoteCount,
optionNumber,
displayVoteCount,
isEnded,
isChecked,
@ -92,13 +109,17 @@ export const PollOption: React.FC<PollOptionProps> = ({
});
const isWinner = isEnded && isChecked;
const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount);
const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption;
return (
<div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}>
<PollOptionWrapper
<ActivePollOption
pollId={pollId}
answer={answer}
optionNumber={optionNumber}
isChecked={isChecked}
isEnded={isEnded}
isWinner={isWinner}
voteCount={voteCount}
displayVoteCount={displayVoteCount}
onOptionSelected={onOptionSelected}
>
<PollOptionContent
@ -107,7 +128,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
voteCount={voteCount}
displayVoteCount={displayVoteCount}
/>
</PollOptionWrapper>
</ActivePollOption>
<div className="mx_PollOption_popularityBackground">
<div className="mx_PollOption_popularityAmount" style={{ width: `${answerPercent}%` }} />
</div>

View File

@ -28,6 +28,7 @@ type EndedPollState = {
winningAnswers: {
answer: PollAnswerSubevent;
voteCount: number;
optionNumber: number;
}[];
totalVoteCount: number;
};
@ -40,10 +41,12 @@ const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollS
return {
totalVoteCount,
winningAnswers: poll.pollEvent.answers
.filter((answer) => votes.get(answer.id) === winCount)
.map((answer) => ({
.map((answer, index) => ({ answerIndex: index, answer })) // keep track of original answer index
.filter(({ answer }) => votes.get(answer.id) === winCount)
.map(({ answer, answerIndex }) => ({
answer,
voteCount: votes.get(answer.id) || 0,
optionNumber: answerIndex + 1,
})),
};
};
@ -100,13 +103,14 @@ export const PollListItemEnded: React.FC<Props> = ({ event, poll, onClick }) =>
</div>
{!!winningAnswers?.length && (
<div className="mx_PollListItemEnded_answers">
{winningAnswers?.map(({ answer, voteCount }) => (
{winningAnswers?.map(({ answer, voteCount, optionNumber }) => (
<PollOption
key={answer.id}
answer={answer}
voteCount={voteCount}
totalVoteCount={totalVoteCount!}
pollId={poll.pollId}
optionNumber={optionNumber}
displayVoteCount
isChecked
isEnded

View File

@ -1762,6 +1762,7 @@
"end_message": "The poll has ended. Top answer: %(topAnswer)s",
"end_message_no_votes": "The poll has ended. No votes were cast.",
"end_title": "End Poll",
"ended_poll_label": "Poll ended",
"error_ending_description": "Sorry, the poll did not end. Please try again.",
"error_ending_title": "Failed to end poll",
"error_voting_description": "Sorry, your vote was not registered. Please try again.",
@ -1769,10 +1770,20 @@
"failed_send_poll_description": "Sorry, the poll you tried to create was not posted.",
"failed_send_poll_title": "Failed to post poll",
"notes": "Results are only revealed when you end the poll",
"option_label": "Option %(number)s, %(answer)s",
"option_label_winning_with_total": {
"one": "Option %(number)s, %(answer)s, winning option, %(count)s vote",
"other": "Option %(number)s, %(answer)s, winning option, %(count)s votes"
},
"option_label_with_total": {
"one": "Option %(number)s, %(answer)s, %(count)s vote",
"other": "Option %(number)s, %(answer)s, %(count)s votes"
},
"options_add_button": "Add option",
"options_heading": "Create options",
"options_label": "Option %(number)s",
"options_placeholder": "Write an option",
"poll_label": "Poll",
"topic_heading": "What is your poll question or topic?",
"topic_label": "Question or topic",
"topic_placeholder": "Write something…",
@ -3529,7 +3540,6 @@
}
},
"m.poll.end": {
"ended": "Ended a poll",
"sender_ended": "%(senderName)s has ended a poll"
},
"m.poll.start": "%(senderName)s has started a poll - %(pollQuestion)s",

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { type MatrixEvent, EventType, RelationType, M_POLL_END } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import SettingsStore from "./settings/SettingsStore";
@ -50,6 +50,9 @@ function memberEventDiff(ev: MatrixEvent): IDiff {
* hitting the settings store
*/
export default function shouldHideEvent(ev: MatrixEvent, ctx?: IRoomState): boolean {
// Hide all poll end events
if (M_POLL_END.matches(ev.getType())) return true;
// Accessing the settings store directly can be expensive if done frequently,
// so we should prefer using cached values if a RoomContext is available
const isEnabled = ctx

View File

@ -505,11 +505,11 @@ describe("MPollBody", () => {
expect(runFindTopAnswer([])).toEqual("");
});
it("shows non-radio buttons if the poll is ended", async () => {
it("shows disabled radio buttons if the poll is ended", async () => {
const events = [newPollEndEvent()];
const { container } = await newMPollBody([], events);
expect(container.querySelector(".mx_StyledRadioButton")).not.toBeInTheDocument();
expect(container.querySelector('input[type="radio"]')).not.toBeInTheDocument();
expect(container.querySelector(".mx_StyledRadioButton")).toBeInTheDocument();
expect(container.querySelector('input[type="radio"][disabled]')).toBeInTheDocument();
});
it("counts votes as normal if the poll is ended", async () => {
@ -551,8 +551,8 @@ describe("MPollBody", () => {
const ends = [newPollEndEvent("@me:example.com", 25)];
const renderResult = await newMPollBody(votes, ends);
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(0);
expect(renderResult.container.querySelectorAll('input[type="radio"]')).toHaveLength(0);
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(4);
expect(renderResult.container.querySelectorAll('input[type="radio"][disabled]')).toHaveLength(4);
expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes");
expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes");
expect(endedVotesCount(renderResult, "italian")).toBe("0 votes");
@ -646,9 +646,9 @@ describe("MPollBody", () => {
expect(endedVoteChecked(renderResult, "wings")).toBe(true);
expect(endedVoteChecked(renderResult, "pizza")).toBe(false);
// Double-check by looking for the endedOptionWinner class
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_endedOptionWinner")).toBe(true);
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_endedOptionWinner")).toBe(false);
// Double-check by looking for the checked class
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_checked")).toBe(true);
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_checked")).toBe(false);
});
it("highlights multiple winning votes", async () => {
@ -731,9 +731,7 @@ describe("MPollBody", () => {
});
pollEvent.makeReplaced(replacingEvent);
const { getByTestId, container } = await newMPollBodyFromEvent(pollEvent, []);
expect(getByTestId("pollQuestion").innerHTML).toEqual(
'new question<span class="mx_MPollBody_edited"> (edited)</span>',
);
expect(getByTestId("pollQuestion").textContent).toEqual("new question (edited)");
const inputs = container.querySelectorAll('input[type="radio"]');
expect(inputs).toHaveLength(3);
expect(inputs[0].getAttribute("value")).toEqual("n1");
@ -951,7 +949,7 @@ function endedVoteChecked({ getByTestId }: RenderResult, value: string): boolean
}
function endedVoteDiv({ getByTestId }: RenderResult, value: string): Element {
return getByTestId(`pollOption-${value}`).firstElementChild!;
return getByTestId(`pollOption-${value}`);
}
function endedVotesCount(renderResult: RenderResult, value: string): string {

View File

@ -1,193 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, waitFor } from "jest-matrix-react";
import { type EventTimeline, type MatrixEvent, Room, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import { MPollEndBody } from "../../../../../src/components/views/messages/MPollEndBody";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { type 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,
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.getEffectiveEvent());
});
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, getByRole, queryByRole } = getComponent();
// while fetching event, only icon is shown
expect(container).toMatchSnapshot();
await waitFor(() => expect(getByRole("progressbar")).toBeInTheDocument());
await waitFor(() => expect(queryByRole("progressbar")).not.toBeInTheDocument());
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

@ -1,129 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
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>
<svg
class="mx_MPollEndBody_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 10.659V19q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h8.341A6 6 0 0 0 13 5H5v14h14v-8a6 6 0 0 0 2-.341"
/>
<path
d="M13.803 8a6 6 0 0 0 1.88 2H13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 9q0-.424.287-.713A.97.97 0 0 1 13 8zm2.91 7.713A.97.97 0 0 1 16 16h-3a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 15q0-.424.287-.713A.97.97 0 0 1 13 14h3q.424 0 .712.287.288.288.288.713 0 .424-.288.713m-6.299-5.3A1.93 1.93 0 0 1 9 11q-.825 0-1.412-.588A1.93 1.93 0 0 1 7 9q0-.825.588-1.412A1.93 1.93 0 0 1 9 7q.825 0 1.412.588Q11 8.175 11 9t-.588 1.412m.001 6.001A1.93 1.93 0 0 1 9 17q-.825 0-1.412-.587A1.93 1.93 0 0 1 7 15q0-.825.588-1.412A1.93 1.93 0 0 1 9 13q.825 0 1.412.588Q11 14.175 11 15q0 .824-.588 1.413m12.295-14.12a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L18 5.586l3.293-3.293a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
`;
exports[`<MPollEndBody /> when poll start event exists in current timeline renders an ended poll 1`] = `
<div>
<div
class="mx_MPollEndBody"
>
<span
class="mx_Caption"
>
Ended a poll
</span>
<fieldset
class="mx_MPollBody"
>
<legend
data-testid="pollQuestion"
>
Question?
</legend>
<div
class="mx_MPollBody_allOptions"
>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-socks"
>
<div
class="mx_PollOption_endedOption"
data-value="socks"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Socks
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-shoes"
>
<div
class="mx_PollOption_endedOption"
data-value="shoes"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Shoes
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_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>
</fieldset>
</div>
</div>
`;

View File

@ -158,4 +158,53 @@ describe("<PollListItemEnded />", () => {
expect(getByText("Nissan Silvia S15")).toBeInTheDocument();
expect(queryByText("Mitsubishi Lancer Evolution IX")).not.toBeInTheDocument();
});
it("maintains correct option numbers when only later answers win", async () => {
// Create a poll with 3 answers
const answerThree = {
id: "answerThreeId",
[M_TEXT.name]: "Toyota Supra MK4",
};
const pollStartEventThreeAnswers = makePollStartEvent(
"Question?",
userId,
[answerOne, answerTwo, answerThree],
{
roomId,
id: pollId,
ts: timestamp,
},
);
// Only answer 3 (index 2) wins with 2 votes, answers 1 and 2 (indices 0 and 1) get 0 or 1 votes
const responses = [
makePollResponseEvent(pollId, [answerOne.id], userId, roomId, timestamp + 1),
makePollResponseEvent(pollId, [answerThree.id], "@bob:domain.org", roomId, timestamp + 1),
makePollResponseEvent(pollId, [answerThree.id], "@charlie:domain.org", roomId, timestamp + 1),
];
await setupRoomWithPollEvents([pollStartEventThreeAnswers], responses, [pollEndEvent], mockClient, room);
const poll = room.polls.get(pollId)!;
const { getByText, queryByText, findByText, getByTestId } = getComponent({
event: pollStartEventThreeAnswers,
poll,
});
await expect(findByText("Final result based on 3 votes")).resolves.toBeInTheDocument();
// Only the third answer should be shown (it won)
expect(queryByText("Nissan Silvia S15")).not.toBeInTheDocument();
expect(queryByText("Mitsubishi Lancer Evolution IX")).not.toBeInTheDocument();
expect(getByText("Toyota Supra MK4")).toBeInTheDocument();
// The option number should be 3 (original index 2 + 1), not 1
// PollOption component receives optionNumber prop which is used in its aria-label
const pollOption = getByTestId("pollOption-answerThreeId");
expect(pollOption).toBeInTheDocument();
// The optionNumber is maintained correctly
const radioInput = pollOption.querySelector('input[type="radio"]');
expect(radioInput).toHaveAttribute("aria-label", expect.stringContaining("Option 3, Toyota Supra MK4"));
});
});

View File

@ -4,6 +4,18 @@ exports[`<PollHistory /> Poll detail displays poll detail on active poll list it
<legend
data-testid="pollQuestion"
>
<svg
aria-label="Poll"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 10q.424 0 .712-.287A.97.97 0 0 0 17 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 16 8h-3a.97.97 0 0 0-.713.287A.97.97 0 0 0 12 9q0 .424.287.713.288.287.713.287zm0 6q.424 0 .712-.287A.97.97 0 0 0 17 15a.97.97 0 0 0-.288-.713A.97.97 0 0 0 16 14h-3a.97.97 0 0 0-.713.287A.97.97 0 0 0 12 15q0 .424.287.713.288.287.713.287zm-7-5q.825 0 1.412-.588Q11 9.826 11 9t-.588-1.412A1.93 1.93 0 0 0 9 7q-.825 0-1.412.588A1.93 1.93 0 0 0 7 9q0 .825.588 1.412Q8.175 11 9 11m0 6q.825 0 1.412-.587Q11 15.825 11 15t-.588-1.412A1.93 1.93 0 0 0 9 13q-.825 0-1.412.588A1.93 1.93 0 0 0 7 15q0 .824.588 1.413Q8.175 17 9 17m-4 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h14q.824 0 1.413.587Q21 4.176 21 5v14q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21zm0-2h14V5H5z"
/>
</svg>
Question?
</legend>
`;
@ -12,6 +24,21 @@ exports[`<PollHistory /> Poll detail displays poll detail on past poll list item
<legend
data-testid="pollQuestion"
>
<svg
aria-label="Poll ended"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 10.659V19q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h8.341A6 6 0 0 0 13 5H5v14h14v-8a6 6 0 0 0 2-.341"
/>
<path
d="M13.803 8a6 6 0 0 0 1.88 2H13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 9q0-.424.287-.713A.97.97 0 0 1 13 8zm2.91 7.713A.97.97 0 0 1 16 16h-3a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 15q0-.424.287-.713A.97.97 0 0 1 13 14h3q.424 0 .712.287.288.288.288.713 0 .424-.288.713m-6.299-5.3A1.93 1.93 0 0 1 9 11q-.825 0-1.412-.588A1.93 1.93 0 0 1 7 9q0-.825.588-1.412A1.93 1.93 0 0 1 9 7q.825 0 1.412.588Q11 8.175 11 9t-.588 1.412m.001 6.001A1.93 1.93 0 0 1 9 17q-.825 0-1.412-.587A1.93 1.93 0 0 1 7 15q0-.825.588-1.412A1.93 1.93 0 0 1 9 13q.825 0 1.412.588Q11 14.175 11 15q0 .824-.588 1.413m12.295-14.12a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L18 5.586l3.293-3.293a1 1 0 0 1 1.414 0"
/>
</svg>
What?
</legend>
`;