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:
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 |
@@ -238,7 +238,6 @@
|
|||||||
@import "./views/messages/_MLocationBody.pcss";
|
@import "./views/messages/_MLocationBody.pcss";
|
||||||
@import "./views/messages/_MNoticeBody.pcss";
|
@import "./views/messages/_MNoticeBody.pcss";
|
||||||
@import "./views/messages/_MPollBody.pcss";
|
@import "./views/messages/_MPollBody.pcss";
|
||||||
@import "./views/messages/_MPollEndBody.pcss";
|
|
||||||
@import "./views/messages/_MStickerBody.pcss";
|
@import "./views/messages/_MStickerBody.pcss";
|
||||||
@import "./views/messages/_MTextBody.pcss";
|
@import "./views/messages/_MTextBody.pcss";
|
||||||
@import "./views/messages/_MVideoBody.pcss";
|
@import "./views/messages/_MVideoBody.pcss";
|
||||||
|
|||||||
@@ -50,8 +50,43 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mx_PollOption_checked {
|
.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_popularityBackground {
|
||||||
.mx_PollOption_popularityAmount {
|
.mx_PollOption_popularityAmount {
|
||||||
background-color: var(--cpd-color-icon-accent-tertiary);
|
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 */
|
/* override checked radio button styling to show checkmark instead */
|
||||||
.mx_StyledRadioButton_checked {
|
.mx_StyledRadioButton_checked {
|
||||||
input[type="radio"]:checked + div {
|
input[type="radio"]:checked + div {
|
||||||
border-width: 2px;
|
|
||||||
border-color: var(--cpd-color-icon-accent-tertiary);
|
border-color: var(--cpd-color-icon-accent-tertiary);
|
||||||
background-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%;
|
width: 0%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: $quaternary-content;
|
background-color: var(--cpd-color-icon-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
$poll-max-width: 550px;
|
||||||
|
|
||||||
.mx_MPollBody {
|
.mx_MPollBody {
|
||||||
margin-top: 8px;
|
margin-top: var(--cpd-space-2x);
|
||||||
min-width: 0; /* Override fieldset default min-width: min-content */
|
min-width: 0; /* Override fieldset default min-width: min-content */
|
||||||
width: 100%; /* Ensure fieldset takes full available width */
|
width: 100%; /* Ensure fieldset takes full available width */
|
||||||
border: none; /* Remove default fieldset border */
|
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;
|
font-size: $font-15px;
|
||||||
line-height: $font-24px;
|
line-height: $font-24px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--cpd-space-2x);
|
||||||
letter-spacing: var(--cpd-font-letter-spacing-heading-lg);
|
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 {
|
.mx_MPollBody_edited {
|
||||||
color: $roomtopic-color;
|
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 {
|
.mx_MPollBody_totalVotes {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: inline;
|
flex-direction: inline;
|
||||||
@@ -67,5 +61,5 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-16;
|
gap: $spacing-16;
|
||||||
margin-bottom: $spacing-8;
|
margin-bottom: $spacing-8;
|
||||||
max-width: 550px;
|
max-width: $poll-max-width;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 */
|
/* Keep height equal to text for shield alignment, additional 2px because of 1px padding on text */
|
||||||
height: calc($font-18px + 2px);
|
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) {
|
&:not(.mx_EventTile_noBubble) .mx_EventTile_line:not(.mx_EventTile_mediaLine) {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
|
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 { 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 { 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 { _t } from "../../../languageHandler";
|
||||||
import Modal from "../../../Modal";
|
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>
|
<span className="mx_MPollBody_edited"> ({_t("common|edited")})</span>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const PollIcon = poll.isEnded ? PollsEndIcon : PollsIcon;
|
||||||
|
const pollLabel = poll.isEnded ? _t("poll|ended_poll_label") : _t("poll|poll_label");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className="mx_MPollBody">
|
<fieldset className="mx_MPollBody">
|
||||||
<legend data-testid="pollQuestion">
|
<legend data-testid="pollQuestion">
|
||||||
|
<PollIcon width="20" height="20" aria-label={pollLabel} />
|
||||||
{pollEvent.question.text}
|
{pollEvent.question.text}
|
||||||
{editedSpan}
|
{editedSpan}
|
||||||
</legend>
|
</legend>
|
||||||
<div className="mx_MPollBody_allOptions">
|
<div className="mx_MPollBody_allOptions">
|
||||||
{pollEvent.answers.map((answer: PollAnswerSubevent) => {
|
{pollEvent.answers.map((answer: PollAnswerSubevent, index: number) => {
|
||||||
let answerVotes = 0;
|
let answerVotes = 0;
|
||||||
|
|
||||||
if (showResults) {
|
if (showResults) {
|
||||||
@@ -346,6 +352,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||||||
key={answer.id}
|
key={answer.id}
|
||||||
pollId={pollId}
|
pollId={pollId}
|
||||||
answer={answer}
|
answer={answer}
|
||||||
|
optionNumber={index + 1}
|
||||||
isChecked={checked}
|
isChecked={checked}
|
||||||
isEnded={poll.isEnded}
|
isEnded={poll.isEnded}
|
||||||
voteCount={answerVotes}
|
voteCount={answerVotes}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
M_BEACON_INFO,
|
M_BEACON_INFO,
|
||||||
M_LOCATION,
|
M_LOCATION,
|
||||||
M_POLL_END,
|
|
||||||
M_POLL_START,
|
M_POLL_START,
|
||||||
type IContent,
|
type IContent,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
@@ -34,7 +33,6 @@ import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
|||||||
import MVideoBody from "./MVideoBody";
|
import MVideoBody from "./MVideoBody";
|
||||||
import MStickerBody from "./MStickerBody";
|
import MStickerBody from "./MStickerBody";
|
||||||
import MPollBody from "./MPollBody";
|
import MPollBody from "./MPollBody";
|
||||||
import { MPollEndBody } from "./MPollEndBody";
|
|
||||||
import MLocationBody from "./MLocationBody";
|
import MLocationBody from "./MLocationBody";
|
||||||
import MjolnirBody from "./MjolnirBody";
|
import MjolnirBody from "./MjolnirBody";
|
||||||
import MBeaconBody from "./MBeaconBody";
|
import MBeaconBody from "./MBeaconBody";
|
||||||
@@ -75,8 +73,6 @@ const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
|||||||
[EventType.Sticker, MStickerBody],
|
[EventType.Sticker, MStickerBody],
|
||||||
[M_POLL_START.name, MPollBody],
|
[M_POLL_START.name, MPollBody],
|
||||||
[M_POLL_START.altName, MPollBody],
|
[M_POLL_START.altName, MPollBody],
|
||||||
[M_POLL_END.name, MPollEndBody],
|
|
||||||
[M_POLL_END.altName, MPollEndBody],
|
|
||||||
[M_BEACON_INFO.name, MBeaconBody],
|
[M_BEACON_INFO.name, MBeaconBody],
|
||||||
[M_BEACON_INFO.altName, MBeaconBody],
|
[M_BEACON_INFO.altName, MBeaconBody],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -36,50 +36,67 @@ const PollOptionContent: React.FC<PollOptionContentProps> = ({ isWinner, answer,
|
|||||||
interface PollOptionProps extends PollOptionContentProps {
|
interface PollOptionProps extends PollOptionContentProps {
|
||||||
pollId: string;
|
pollId: string;
|
||||||
totalVoteCount: number;
|
totalVoteCount: number;
|
||||||
|
optionNumber: number;
|
||||||
isEnded?: boolean;
|
isEnded?: boolean;
|
||||||
isChecked?: boolean;
|
isChecked?: boolean;
|
||||||
onOptionSelected?: (id: string) => void;
|
onOptionSelected?: (id: string) => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
|
const ActivePollOption: React.FC<Omit<PollOptionProps, "totalVoteCount"> & { children: ReactNode }> = ({
|
||||||
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">> = ({
|
|
||||||
pollId,
|
pollId,
|
||||||
isChecked,
|
isChecked,
|
||||||
|
isEnded,
|
||||||
|
optionNumber,
|
||||||
|
isWinner,
|
||||||
|
voteCount,
|
||||||
|
displayVoteCount,
|
||||||
children,
|
children,
|
||||||
answer,
|
answer,
|
||||||
onOptionSelected,
|
onOptionSelected,
|
||||||
}) => (
|
}) => {
|
||||||
<StyledRadioButton
|
let ariaLabel: string;
|
||||||
className="mx_PollOption_live-option"
|
|
||||||
name={`poll_answer_select-${pollId}`}
|
if (displayVoteCount && isWinner) {
|
||||||
value={answer.id}
|
ariaLabel = _t("poll|option_label_winning_with_total", {
|
||||||
checked={isChecked}
|
number: optionNumber,
|
||||||
onChange={() => onOptionSelected?.(answer.id)}
|
answer: answer.text,
|
||||||
>
|
count: voteCount,
|
||||||
{children}
|
});
|
||||||
</StyledRadioButton>
|
} 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> = ({
|
export const PollOption: React.FC<PollOptionProps> = ({
|
||||||
pollId,
|
pollId,
|
||||||
answer,
|
answer,
|
||||||
voteCount,
|
voteCount,
|
||||||
totalVoteCount,
|
totalVoteCount,
|
||||||
|
optionNumber,
|
||||||
displayVoteCount,
|
displayVoteCount,
|
||||||
isEnded,
|
isEnded,
|
||||||
isChecked,
|
isChecked,
|
||||||
@@ -92,13 +109,17 @@ export const PollOption: React.FC<PollOptionProps> = ({
|
|||||||
});
|
});
|
||||||
const isWinner = isEnded && isChecked;
|
const isWinner = isEnded && isChecked;
|
||||||
const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount);
|
const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount);
|
||||||
const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption;
|
|
||||||
return (
|
return (
|
||||||
<div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}>
|
<div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}>
|
||||||
<PollOptionWrapper
|
<ActivePollOption
|
||||||
pollId={pollId}
|
pollId={pollId}
|
||||||
answer={answer}
|
answer={answer}
|
||||||
|
optionNumber={optionNumber}
|
||||||
isChecked={isChecked}
|
isChecked={isChecked}
|
||||||
|
isEnded={isEnded}
|
||||||
|
isWinner={isWinner}
|
||||||
|
voteCount={voteCount}
|
||||||
|
displayVoteCount={displayVoteCount}
|
||||||
onOptionSelected={onOptionSelected}
|
onOptionSelected={onOptionSelected}
|
||||||
>
|
>
|
||||||
<PollOptionContent
|
<PollOptionContent
|
||||||
@@ -107,7 +128,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
|
|||||||
voteCount={voteCount}
|
voteCount={voteCount}
|
||||||
displayVoteCount={displayVoteCount}
|
displayVoteCount={displayVoteCount}
|
||||||
/>
|
/>
|
||||||
</PollOptionWrapper>
|
</ActivePollOption>
|
||||||
<div className="mx_PollOption_popularityBackground">
|
<div className="mx_PollOption_popularityBackground">
|
||||||
<div className="mx_PollOption_popularityAmount" style={{ width: `${answerPercent}%` }} />
|
<div className="mx_PollOption_popularityAmount" style={{ width: `${answerPercent}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type EndedPollState = {
|
|||||||
winningAnswers: {
|
winningAnswers: {
|
||||||
answer: PollAnswerSubevent;
|
answer: PollAnswerSubevent;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
|
optionNumber: number;
|
||||||
}[];
|
}[];
|
||||||
totalVoteCount: number;
|
totalVoteCount: number;
|
||||||
};
|
};
|
||||||
@@ -40,10 +41,12 @@ const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollS
|
|||||||
return {
|
return {
|
||||||
totalVoteCount,
|
totalVoteCount,
|
||||||
winningAnswers: poll.pollEvent.answers
|
winningAnswers: poll.pollEvent.answers
|
||||||
.filter((answer) => votes.get(answer.id) === winCount)
|
.map((answer, index) => ({ answerIndex: index, answer })) // keep track of original answer index
|
||||||
.map((answer) => ({
|
.filter(({ answer }) => votes.get(answer.id) === winCount)
|
||||||
|
.map(({ answer, answerIndex }) => ({
|
||||||
answer,
|
answer,
|
||||||
voteCount: votes.get(answer.id) || 0,
|
voteCount: votes.get(answer.id) || 0,
|
||||||
|
optionNumber: answerIndex + 1,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -100,13 +103,14 @@ export const PollListItemEnded: React.FC<Props> = ({ event, poll, onClick }) =>
|
|||||||
</div>
|
</div>
|
||||||
{!!winningAnswers?.length && (
|
{!!winningAnswers?.length && (
|
||||||
<div className="mx_PollListItemEnded_answers">
|
<div className="mx_PollListItemEnded_answers">
|
||||||
{winningAnswers?.map(({ answer, voteCount }) => (
|
{winningAnswers?.map(({ answer, voteCount, optionNumber }) => (
|
||||||
<PollOption
|
<PollOption
|
||||||
key={answer.id}
|
key={answer.id}
|
||||||
answer={answer}
|
answer={answer}
|
||||||
voteCount={voteCount}
|
voteCount={voteCount}
|
||||||
totalVoteCount={totalVoteCount!}
|
totalVoteCount={totalVoteCount!}
|
||||||
pollId={poll.pollId}
|
pollId={poll.pollId}
|
||||||
|
optionNumber={optionNumber}
|
||||||
displayVoteCount
|
displayVoteCount
|
||||||
isChecked
|
isChecked
|
||||||
isEnded
|
isEnded
|
||||||
|
|||||||
@@ -1762,6 +1762,7 @@
|
|||||||
"end_message": "The poll has ended. Top answer: %(topAnswer)s",
|
"end_message": "The poll has ended. Top answer: %(topAnswer)s",
|
||||||
"end_message_no_votes": "The poll has ended. No votes were cast.",
|
"end_message_no_votes": "The poll has ended. No votes were cast.",
|
||||||
"end_title": "End Poll",
|
"end_title": "End Poll",
|
||||||
|
"ended_poll_label": "Poll ended",
|
||||||
"error_ending_description": "Sorry, the poll did not end. Please try again.",
|
"error_ending_description": "Sorry, the poll did not end. Please try again.",
|
||||||
"error_ending_title": "Failed to end poll",
|
"error_ending_title": "Failed to end poll",
|
||||||
"error_voting_description": "Sorry, your vote was not registered. Please try again.",
|
"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_description": "Sorry, the poll you tried to create was not posted.",
|
||||||
"failed_send_poll_title": "Failed to post poll",
|
"failed_send_poll_title": "Failed to post poll",
|
||||||
"notes": "Results are only revealed when you end the 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_add_button": "Add option",
|
||||||
"options_heading": "Create options",
|
"options_heading": "Create options",
|
||||||
"options_label": "Option %(number)s",
|
"options_label": "Option %(number)s",
|
||||||
"options_placeholder": "Write an option",
|
"options_placeholder": "Write an option",
|
||||||
|
"poll_label": "Poll",
|
||||||
"topic_heading": "What is your poll question or topic?",
|
"topic_heading": "What is your poll question or topic?",
|
||||||
"topic_label": "Question or topic",
|
"topic_label": "Question or topic",
|
||||||
"topic_placeholder": "Write something…",
|
"topic_placeholder": "Write something…",
|
||||||
@@ -3529,7 +3540,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"m.poll.end": {
|
"m.poll.end": {
|
||||||
"ended": "Ended a poll",
|
|
||||||
"sender_ended": "%(senderName)s has ended a poll"
|
"sender_ended": "%(senderName)s has ended a poll"
|
||||||
},
|
},
|
||||||
"m.poll.start": "%(senderName)s has started a poll - %(pollQuestion)s",
|
"m.poll.start": "%(senderName)s has started a poll - %(pollQuestion)s",
|
||||||
|
|||||||
@@ -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.
|
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 { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
|
|
||||||
import SettingsStore from "./settings/SettingsStore";
|
import SettingsStore from "./settings/SettingsStore";
|
||||||
@@ -50,6 +50,9 @@ function memberEventDiff(ev: MatrixEvent): IDiff {
|
|||||||
* hitting the settings store
|
* hitting the settings store
|
||||||
*/
|
*/
|
||||||
export default function shouldHideEvent(ev: MatrixEvent, ctx?: IRoomState): boolean {
|
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,
|
// Accessing the settings store directly can be expensive if done frequently,
|
||||||
// so we should prefer using cached values if a RoomContext is available
|
// so we should prefer using cached values if a RoomContext is available
|
||||||
const isEnabled = ctx
|
const isEnabled = ctx
|
||||||
|
|||||||
@@ -505,11 +505,11 @@ describe("MPollBody", () => {
|
|||||||
expect(runFindTopAnswer([])).toEqual("");
|
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 events = [newPollEndEvent()];
|
||||||
const { container } = await newMPollBody([], events);
|
const { container } = await newMPollBody([], events);
|
||||||
expect(container.querySelector(".mx_StyledRadioButton")).not.toBeInTheDocument();
|
expect(container.querySelector(".mx_StyledRadioButton")).toBeInTheDocument();
|
||||||
expect(container.querySelector('input[type="radio"]')).not.toBeInTheDocument();
|
expect(container.querySelector('input[type="radio"][disabled]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("counts votes as normal if the poll is ended", async () => {
|
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 ends = [newPollEndEvent("@me:example.com", 25)];
|
||||||
const renderResult = await newMPollBody(votes, ends);
|
const renderResult = await newMPollBody(votes, ends);
|
||||||
|
|
||||||
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(0);
|
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(4);
|
||||||
expect(renderResult.container.querySelectorAll('input[type="radio"]')).toHaveLength(0);
|
expect(renderResult.container.querySelectorAll('input[type="radio"][disabled]')).toHaveLength(4);
|
||||||
expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes");
|
expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes");
|
||||||
expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes");
|
expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes");
|
||||||
expect(endedVotesCount(renderResult, "italian")).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, "wings")).toBe(true);
|
||||||
expect(endedVoteChecked(renderResult, "pizza")).toBe(false);
|
expect(endedVoteChecked(renderResult, "pizza")).toBe(false);
|
||||||
|
|
||||||
// Double-check by looking for the endedOptionWinner class
|
// Double-check by looking for the checked class
|
||||||
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_endedOptionWinner")).toBe(true);
|
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_checked")).toBe(true);
|
||||||
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_endedOptionWinner")).toBe(false);
|
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_checked")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights multiple winning votes", async () => {
|
it("highlights multiple winning votes", async () => {
|
||||||
@@ -731,9 +731,7 @@ describe("MPollBody", () => {
|
|||||||
});
|
});
|
||||||
pollEvent.makeReplaced(replacingEvent);
|
pollEvent.makeReplaced(replacingEvent);
|
||||||
const { getByTestId, container } = await newMPollBodyFromEvent(pollEvent, []);
|
const { getByTestId, container } = await newMPollBodyFromEvent(pollEvent, []);
|
||||||
expect(getByTestId("pollQuestion").innerHTML).toEqual(
|
expect(getByTestId("pollQuestion").textContent).toEqual("new question (edited)");
|
||||||
'new question<span class="mx_MPollBody_edited"> (edited)</span>',
|
|
||||||
);
|
|
||||||
const inputs = container.querySelectorAll('input[type="radio"]');
|
const inputs = container.querySelectorAll('input[type="radio"]');
|
||||||
expect(inputs).toHaveLength(3);
|
expect(inputs).toHaveLength(3);
|
||||||
expect(inputs[0].getAttribute("value")).toEqual("n1");
|
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 {
|
function endedVoteDiv({ getByTestId }: RenderResult, value: string): Element {
|
||||||
return getByTestId(`pollOption-${value}`).firstElementChild!;
|
return getByTestId(`pollOption-${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function endedVotesCount(renderResult: RenderResult, value: string): string {
|
function endedVotesCount(renderResult: RenderResult, value: string): string {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
`;
|
|
||||||
@@ -158,4 +158,53 @@ describe("<PollListItemEnded />", () => {
|
|||||||
expect(getByText("Nissan Silvia S15")).toBeInTheDocument();
|
expect(getByText("Nissan Silvia S15")).toBeInTheDocument();
|
||||||
expect(queryByText("Mitsubishi Lancer Evolution IX")).not.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"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ exports[`<PollHistory /> Poll detail displays poll detail on active poll list it
|
|||||||
<legend
|
<legend
|
||||||
data-testid="pollQuestion"
|
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?
|
Question?
|
||||||
</legend>
|
</legend>
|
||||||
`;
|
`;
|
||||||
@@ -12,6 +24,21 @@ exports[`<PollHistory /> Poll detail displays poll detail on past poll list item
|
|||||||
<legend
|
<legend
|
||||||
data-testid="pollQuestion"
|
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?
|
What?
|
||||||
</legend>
|
</legend>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user