Allow ending polls (#7305)

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Andy Balaam 2021-12-08 14:56:48 +00:00 committed by GitHub
parent 697b5d28b3
commit 2b52e17a80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 2814 additions and 680 deletions

View file

@ -54,6 +54,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/settings/appearance.svg');
}
.mx_MessageContextMenu_iconEndPoll::before {
mask-image: url('$(res)/img/element-icons/check-white.svg');
}
.mx_MessageContextMenu_iconForward::before {
mask-image: url('$(res)/img/element-icons/message/fwd.svg');
}

View file

@ -45,16 +45,17 @@ limitations under the License.
border: 1px solid $quinary-content;
border-radius: 8px;
margin-bottom: 16px;
padding: 6px;
padding: 6px 12px;
max-width: 550px;
background-color: $background;
.mx_StyledRadioButton {
.mx_StyledRadioButton, .mx_MPollBody_endedOption {
margin-bottom: 8px;
}
.mx_StyledRadioButton_content {
.mx_StyledRadioButton_content, .mx_MPollBody_endedOption {
padding-top: 2px;
margin-right: 0px;
}
.mx_StyledRadioButton_spacer {
@ -73,7 +74,7 @@ limitations under the License.
}
.mx_MPollBody_popularityBackground {
width: calc(100% - 6px);
width: 100%;
height: 8px;
margin-right: 12px;
border-radius: 8px;
@ -102,20 +103,37 @@ limitations under the License.
}
}
.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;
.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;
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 {
color: $secondary-content;
font-size: $font-12px;

View file

@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 1.33333H9.33333V0.666667C9.33333 0.3 9.03333 0 8.66667 0H3.33333C2.96667 0 2.66667 0.3 2.66667 0.666667V1.33333H1.33333C0.6 1.33333 0 1.93333 0 2.66667V3.33333C0 5.03333 1.28 6.42 2.92667 6.62667C3.34667 7.62667 4.24667 8.38 5.33333 8.6V10.6667H3.33333C2.96667 10.6667 2.66667 10.9667 2.66667 11.3333C2.66667 11.7 2.96667 12 3.33333 12H8.66667C9.03333 12 9.33333 11.7 9.33333 11.3333C9.33333 10.9667 9.03333 10.6667 8.66667 10.6667H6.66667V8.6C7.75333 8.38 8.65333 7.62667 9.07333 6.62667C10.72 6.42 12 5.03333 12 3.33333V2.66667C12 1.93333 11.4 1.33333 10.6667 1.33333ZM1.33333 3.33333V2.66667H2.66667V5.21333C1.89333 4.93333 1.33333 4.2 1.33333 3.33333ZM10.6667 3.33333C10.6667 4.2 10.1067 4.93333 9.33333 5.21333V2.66667H10.6667V3.33333Z" fill="#0DBD8B"/>
</svg>

After

Width:  |  Height:  |  Size: 880 B

View file

@ -41,6 +41,10 @@ import { IPosition, ChevronFace } from '../../structures/ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
import EndPollDialog from '../dialogs/EndPollDialog';
import { Relations } from 'matrix-js-sdk/src/models/relations';
import { isPollEnded } from '../messages/MPollBody';
export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -68,6 +72,11 @@ interface IProps extends IPosition {
onFinished(): void;
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
onCloseDialog?(): void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
interface IState {
@ -123,6 +132,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}
private canEndPoll(mxEvent: MatrixEvent): boolean {
return (
mxEvent.getType() === POLL_START_EVENT_TYPE.name &&
this.state.canRedact &&
!isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent)
);
}
private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(reaction);
@ -215,6 +232,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu();
};
private onEndPollClick = (): void => {
const matrixClient = MatrixClientPeg.get();
Modal.createTrackedDialog('End Poll', '', EndPollDialog, {
matrixClient,
event: this.props.mxEvent,
getRelationsForEvent: this.props.getRelationsForEvent,
}, 'mx_Dialog_endPoll');
this.closeMenu();
};
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
@ -250,6 +277,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
const eventStatus = mxEvent.status;
const unsentReactionsCount = this.getUnsentReactions().length;
let endPollButton: JSX.Element;
let resendReactionsButton: JSX.Element;
let redactButton: JSX.Element;
let forwardButton: JSX.Element;
@ -345,6 +373,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
/>
);
if (this.canEndPoll(mxEvent)) {
endPollButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconEndPoll"
label={_t("End Poll")}
onClick={this.onEndPollClick}
/>
);
}
if (this.props.eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<IconizedContextMenuOption
@ -415,6 +453,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
label={_t("View in room")}
onClick={this.viewInRoom}
/> }
{ endPollButton }
{ quoteButton }
{ forwardButton }
{ pinButton }

View file

@ -0,0 +1,103 @@
/*
Copyright 2021 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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import QuestionDialog from "./QuestionDialog";
import { IPollEndContent, POLL_END_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts";
import { findTopAnswer } from "../messages/MPollBody";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
event: MatrixEvent;
onFinished: (success: boolean) => void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
export default class EndPollDialog extends React.Component<IProps> {
private onFinished = (endPoll: boolean) => {
const topAnswer = findTopAnswer(
this.props.event,
this.props.matrixClient,
this.props.getRelationsForEvent,
);
const message = (
(topAnswer === "")
? _t("The poll has ended. No votes were cast.")
: _t(
"The poll has ended. Top answer: %(topAnswer)s",
{ topAnswer },
)
);
if (endPoll) {
const endContent: IPollEndContent = {
[POLL_END_EVENT_TYPE.name]: {},
"m.relates_to": {
"event_id": this.props.event.getId(),
"rel_type": "m.reference",
},
[TEXT_NODE_TYPE.name]: message,
};
this.props.matrixClient.sendEvent(
this.props.event.getRoomId(), POLL_END_EVENT_TYPE.name, endContent,
).catch((e: any) => {
console.error("Failed to submit poll response event:", e);
Modal.createTrackedDialog(
'Failed to end poll',
'',
ErrorDialog,
{
title: _t("Failed to end poll"),
description: _t(
"Sorry, the poll did not end. Please try again."),
},
);
});
}
this.props.onFinished(endPoll);
};
render() {
return (
<QuestionDialog
title={_t("End Poll")}
description={
_t(
"Are you sure you want to end this poll? " +
"This will show the final results of the poll and " +
"stop people from being able to vote.",
)
}
button={_t("End Poll")}
onFinished={(endPoll: boolean) => this.onFinished(endPoll)}
/>
);
}
}

View file

@ -15,29 +15,125 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Modal from '../../../Modal';
import { IBodyProps } from "./IBodyProps";
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import {
IPollAnswer,
IPollContent,
IPollResponse,
IPollResponseContent,
POLL_END_EVENT_TYPE,
POLL_RESPONSE_EVENT_TYPE,
POLL_START_EVENT_TYPE,
TEXT_NODE_TYPE,
} from '../../../polls/consts';
import StyledRadioButton from '../elements/StyledRadioButton';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from 'matrix-js-sdk/src/models/relations';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import ErrorDialog from '../dialogs/ErrorDialog';
// TODO: [andyb] Use extensible events library when ready
const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
interface IState {
selected?: string; // Which option was clicked by the local user
pollRelations: Relations; // Allows us to access voting events
voteRelations: Relations; // Voting (response) events
endRelations: Relations; // Poll end events
}
export function findTopAnswer(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations,
): string {
if (!getRelationsForEvent) {
return "";
}
const pollContents: IPollContent = pollEvent.getContent();
const findAnswerText = (answerId: string) => {
for (const answer of pollContents[POLL_START_EVENT_TYPE.name].answers) {
if (answer.id == answerId) {
return answer[TEXT_NODE_TYPE.name];
}
}
return "";
};
const voteRelations: Relations = getRelationsForEvent(
pollEvent.getId(),
"m.reference",
POLL_RESPONSE_EVENT_TYPE.name,
);
const endRelations: Relations = getRelationsForEvent(
pollEvent.getId(),
"m.reference",
POLL_END_EVENT_TYPE.name,
);
const userVotes: Map<string, UserVote> = collectUserVotes(
allVotes(pollEvent, matrixClient, voteRelations, endRelations),
matrixClient.getUserId(),
null,
);
const votes: Map<string, number> = countVotes(userVotes, pollEvent.getContent());
const highestScore: number = Math.max(...votes.values());
const bestAnswerIds: string[] = [];
for (const [answerId, score] of votes) {
if (score == highestScore) {
bestAnswerIds.push(answerId);
}
}
const bestAnswerTexts = bestAnswerIds.map(findAnswerText);
return formatCommaSeparatedList(bestAnswerTexts, 3);
}
export function isPollEnded(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations,
): boolean {
if (!getRelationsForEvent) {
return false;
}
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
function userCanRedact(endEvent: MatrixEvent) {
return roomCurrentState.maySendRedactionForEvent(
pollEvent,
endEvent.getSender(),
);
}
const endRelations = getRelationsForEvent(
pollEvent.getId(),
"m.reference",
POLL_END_EVENT_TYPE.name,
);
if (!endRelations) {
return false;
}
const authorisedRelations = endRelations.getRelations().filter(userCanRedact);
return authorisedRelations.length > 0;
}
@replaceableComponent("views.messages.MPollBody")
@ -45,60 +141,83 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
private seenEventIds: string[] = []; // Events we have already seen
private voteRelationsReceived = false;
private endRelationsReceived = false;
constructor(props: IBodyProps) {
super(props);
this.state = {
selected: null,
pollRelations: this.fetchPollRelations(),
voteRelations: this.fetchVoteRelations(),
endRelations: this.fetchEndRelations(),
};
this.addListeners(this.state.pollRelations);
this.props.mxEvent.on("Event.relationsCreated", this.onPollRelationsCreated);
this.addListeners(this.state.voteRelations, this.state.endRelations);
this.props.mxEvent.on("Event.relationsCreated", this.onRelationsCreated);
}
componentWillUnmount() {
this.props.mxEvent.off("Event.relationsCreated", this.onPollRelationsCreated);
this.removeListeners(this.state.pollRelations);
this.props.mxEvent.off("Event.relationsCreated", this.onRelationsCreated);
this.removeListeners(this.state.voteRelations, this.state.endRelations);
}
private addListeners(pollRelations?: Relations) {
if (pollRelations) {
pollRelations.on("Relations.add", this.onRelationsChange);
pollRelations.on("Relations.remove", this.onRelationsChange);
pollRelations.on("Relations.redaction", this.onRelationsChange);
private addListeners(voteRelations?: Relations, endRelations?: Relations) {
if (voteRelations) {
voteRelations.on("Relations.add", this.onRelationsChange);
voteRelations.on("Relations.remove", this.onRelationsChange);
voteRelations.on("Relations.redaction", this.onRelationsChange);
}
if (endRelations) {
endRelations.on("Relations.add", this.onRelationsChange);
endRelations.on("Relations.remove", this.onRelationsChange);
endRelations.on("Relations.redaction", this.onRelationsChange);
}
}
private removeListeners(pollRelations?: Relations) {
if (pollRelations) {
pollRelations.off("Relations.add", this.onRelationsChange);
pollRelations.off("Relations.remove", this.onRelationsChange);
pollRelations.off("Relations.redaction", this.onRelationsChange);
private removeListeners(voteRelations?: Relations, endRelations?: Relations) {
if (voteRelations) {
voteRelations.off("Relations.add", this.onRelationsChange);
voteRelations.off("Relations.remove", this.onRelationsChange);
voteRelations.off("Relations.redaction", this.onRelationsChange);
}
if (endRelations) {
endRelations.off("Relations.add", this.onRelationsChange);
endRelations.off("Relations.remove", this.onRelationsChange);
endRelations.off("Relations.redaction", this.onRelationsChange);
}
}
private onPollRelationsCreated = (relationType: string, eventType: string) => {
if (
relationType === "m.reference" &&
POLL_RESPONSE_EVENT_TYPE.matches(eventType)
) {
private onRelationsCreated = (relationType: string, eventType: string) => {
if (relationType !== "m.reference") {
return;
}
if (POLL_RESPONSE_EVENT_TYPE.matches(eventType)) {
this.voteRelationsReceived = true;
const newVoteRelations = this.fetchVoteRelations();
this.addListeners(newVoteRelations);
this.removeListeners(this.state.voteRelations);
this.setState({ voteRelations: newVoteRelations });
} else if (POLL_END_EVENT_TYPE.matches(eventType)) {
this.endRelationsReceived = true;
const newEndRelations = this.fetchEndRelations();
this.addListeners(newEndRelations);
this.removeListeners(this.state.endRelations);
this.setState({ endRelations: newEndRelations });
}
if (this.voteRelationsReceived && this.endRelationsReceived) {
this.props.mxEvent.removeListener(
"Event.relationsCreated", this.onPollRelationsCreated);
const newPollRelations = this.fetchPollRelations();
this.addListeners(newPollRelations);
this.removeListeners(this.state.pollRelations);
this.setState({
pollRelations: newPollRelations,
});
"Event.relationsCreated", this.onRelationsCreated);
}
};
private onRelationsChange = () => {
// We hold pollRelations in our state, and it has changed under us
// We hold Relations in our state, and they changed under us.
// Check whether we should delete our selection, and then
// re-render.
// Note: re-rendering is a side effect of unselectIfNewEventFromMe().
this.unselectIfNewEventFromMe();
};
@ -106,8 +225,11 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
if (answerId === this.state.selected) {
return;
}
if (this.isEnded()) {
return;
}
const responseContent: IPollResponse = {
const responseContent: IPollResponseContent = {
[POLL_RESPONSE_EVENT_TYPE.name]: {
"answers": [answerId],
},
@ -143,12 +265,20 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
this.selectOption(e.currentTarget.value);
};
private fetchPollRelations(): Relations | null {
private fetchVoteRelations(): Relations | null {
return this.fetchRelations(POLL_RESPONSE_EVENT_TYPE.name);
}
private fetchEndRelations(): Relations | null {
return this.fetchRelations(POLL_END_EVENT_TYPE.name);
}
private fetchRelations(eventType: string): Relations | null {
if (this.props.getRelationsForEvent) {
return this.props.getRelationsForEvent(
this.props.mxEvent.getId(),
"m.reference",
POLL_RESPONSE_EVENT_TYPE.name,
eventType,
);
} else {
return null;
@ -160,7 +290,12 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
*/
private collectUserVotes(): Map<string, UserVote> {
return collectUserVotes(
allVotes(this.state.pollRelations),
allVotes(
this.props.mxEvent,
this.context,
this.state.voteRelations,
this.state.endRelations,
),
this.context.getUserId(),
this.state.selected,
);
@ -175,7 +310,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
* have already seen.
*/
private unselectIfNewEventFromMe() {
const newEvents: MatrixEvent[] = this.state.pollRelations.getRelations()
const newEvents: MatrixEvent[] = this.state.voteRelations.getRelations()
.filter(isPollResponse)
.filter((mxEvent: MatrixEvent) =>
!this.seenEventIds.includes(mxEvent.getId()));
@ -201,6 +336,14 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
return sum;
}
private isEnded(): boolean {
return isPollEnded(
this.props.mxEvent,
this.context,
this.props.getRelationsForEvent,
);
}
render() {
const pollStart: IPollContent = this.props.mxEvent.getContent();
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
@ -209,14 +352,22 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
return null;
}
const ended = this.isEnded();
const pollId = this.props.mxEvent.getId();
const userVotes = this.collectUserVotes();
const votes = countVotes(userVotes, this.props.mxEvent.getContent());
const totalVotes = this.totalVotes(votes);
const winCount = Math.max(...votes.values());
const userId = this.context.getUserId();
const myVote = userVotes.get(userId)?.answers[0];
let totalText: string;
if (myVote === undefined) {
if (ended) {
totalText = _t(
"Final result based on %(count)s votes",
{ count: totalVotes },
);
} else if (myVote === undefined) {
if (totalVotes === 0) {
totalText = _t("No votes cast");
} else {
@ -230,42 +381,51 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
}
return <div className="mx_MPollBody">
<h2>{ pollInfo.question[TEXT_NODE_TYPE] }</h2>
<h2>{ pollInfo.question[TEXT_NODE_TYPE.name] }</h2>
<div className="mx_MPollBody_allOptions">
{
pollInfo.answers.map((answer: IPollAnswer) => {
const checked = myVote === answer.id;
const classNames = `mx_MPollBody_option${
checked ? " mx_MPollBody_option_checked": ""
}`;
let answerVotes = 0;
let votesText = "";
if (myVote !== undefined) { // Votes hidden if I didn't vote
// Votes are hidden until I vote or the poll ends
if (ended || myVote !== undefined) {
answerVotes = votes.get(answer.id) ?? 0;
votesText = _t("%(count)s votes", { count: answerVotes });
}
const answerPercent = Math.round(
100.0 * answerVotes / totalVotes);
const checked = (
(!ended && myVote === answer.id) ||
(ended && answerVotes === winCount)
);
const cls = classNames({
"mx_MPollBody_option": true,
"mx_MPollBody_option_checked": checked,
});
const answerPercent = (
totalVotes === 0
? 0
: Math.round(100.0 * answerVotes / totalVotes)
);
return <div
key={answer.id}
className={classNames}
className={cls}
onClick={() => this.selectOption(answer.id)}
>
<StyledRadioButton
name={`poll_answer_select-${pollId}`}
value={answer.id}
checked={checked}
onChange={this.onOptionSelected}
>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ answer[TEXT_NODE_TYPE] }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ votesText }
</div>
</div>
</StyledRadioButton>
{ (
ended
? <EndedPollOption
answer={answer}
checked={checked}
votesText={votesText} />
: <LivePollOption
pollId={pollId}
answer={answer}
checked={checked}
votesText={votesText}
onOptionSelected={this.onOptionSelected} />
) }
<div className="mx_MPollBody_popularityBackground">
<div
className="mx_MPollBody_popularityAmount"
@ -283,13 +443,62 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
}
}
interface IEndedPollOptionProps {
answer: IPollAnswer;
checked: boolean;
votesText: string;
}
function EndedPollOption(props: IEndedPollOptionProps) {
const cls = classNames({
"mx_MPollBody_endedOption": true,
"mx_MPollBody_endedOptionWinner": props.checked,
});
return <div className={cls} data-value={props.answer.id}>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ props.answer[TEXT_NODE_TYPE.name] }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ props.votesText }
</div>
</div>
</div>;
}
interface ILivePollOptionProps {
pollId: string;
answer: IPollAnswer;
checked: boolean;
votesText: string;
onOptionSelected: (e: React.FormEvent<HTMLInputElement>) => void;
}
function LivePollOption(props: ILivePollOptionProps) {
return <StyledRadioButton
name={`poll_answer_select-${props.pollId}`}
value={props.answer.id}
checked={props.checked}
onChange={props.onOptionSelected}
>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ props.answer[TEXT_NODE_TYPE.name] }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ props.votesText }
</div>
</div>
</StyledRadioButton>;
}
export class UserVote {
constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {
}
}
function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
const pr = event.getContent() as IPollResponse;
const pr = event.getContent() as IPollResponseContent;
const answers = pr[POLL_RESPONSE_EVENT_TYPE.name].answers;
return new UserVote(
@ -299,16 +508,68 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
);
}
export function allVotes(pollRelations: Relations): Array<UserVote> {
if (pollRelations) {
return pollRelations.getRelations()
export function allVotes(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
voteRelations: Relations,
endRelations: Relations,
): Array<UserVote> {
const endTs = pollEndTs(pollEvent, matrixClient, endRelations);
function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean {
// From MSC3381:
// "Votes sent on or before the end event's timestamp are valid votes"
return (
endTs === null ||
responseEvent.getTs() <= endTs
);
}
if (voteRelations) {
return voteRelations.getRelations()
.filter(isPollResponse)
.filter(isOnOrBeforeEnd)
.map(userResponseFromPollResponseEvent);
} else {
return [];
}
}
/**
* Returns the earliest timestamp from the supplied list of end_poll events
* or null if there are no authorised events.
*/
export function pollEndTs(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
endRelations: Relations,
): number | null {
if (!endRelations) {
return null;
}
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
function userCanRedact(endEvent: MatrixEvent) {
return roomCurrentState.maySendRedactionForEvent(
pollEvent,
endEvent.getSender(),
);
}
const tss: number[] = (
endRelations
.getRelations()
.filter(userCanRedact)
.map((evt: MatrixEvent) => evt.getTs())
);
if (tss.length === 0) {
return null;
} else {
return Math.min(...tss);
}
}
function isPollResponse(responseEvent: MatrixEvent): boolean {
return (
POLL_RESPONSE_EVENT_TYPE.matches(responseEvent.getType()) &&

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect } from 'react';
import React, { ReactElement, useEffect } from 'react';
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import type { Relations } from 'matrix-js-sdk/src/models/relations';
@ -51,46 +51,58 @@ interface IOptionsButtonProps {
getReplyChain: () => ReplyChain;
permalinkCreator: RoomPermalinkCreator;
onFocusChange: (menuDisplayed: boolean) => void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
const OptionsButton: React.FC<IOptionsButtonProps> =
({ mxEvent, getTile, getReplyChain, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
const OptionsButton: React.FC<IOptionsButtonProps> = ({
mxEvent,
getTile,
getReplyChain,
permalinkCreator,
onFocusChange,
getRelationsForEvent,
}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
const [onFocus, isActive, ref] = useRovingTabIndex(button);
useEffect(() => {
onFocusChange(menuDisplayed);
}, [onFocusChange, menuDisplayed]);
let contextMenu;
if (menuDisplayed) {
const tile = getTile && getTile();
const replyChain = getReplyChain && getReplyChain();
let contextMenu: ReactElement | null;
if (menuDisplayed) {
const tile = getTile && getTile();
const replyChain = getReplyChain && getReplyChain();
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <MessageContextMenu
{...aboveLeftOf(buttonRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
onFinished={closeMenu}
/>;
}
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <MessageContextMenu
{...aboveLeftOf(buttonRect)}
mxEvent={mxEvent}
permalinkCreator={permalinkCreator}
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
onFinished={closeMenu}
getRelationsForEvent={getRelationsForEvent}
/>;
}
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
/>
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={openMenu}
isExpanded={menuDisplayed}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
/>
{ contextMenu }
</React.Fragment>;
};
{ contextMenu }
</React.Fragment>;
};
interface IReactButtonProps {
mxEvent: MatrixEvent;
@ -138,6 +150,11 @@ interface IMessageActionBarProps {
onFocusChange?: (menuDisplayed: boolean) => void;
toggleThreadExpanded: () => void;
isQuoteExpanded?: boolean;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
@replaceableComponent("views.messages.MessageActionBar")
@ -378,6 +395,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
key="menu"
getRelationsForEvent={this.props.getRelationsForEvent}
/>);
}

View file

@ -1157,6 +1157,7 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange}
isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
getRelationsForEvent={this.props.getRelationsForEvent}
/> : undefined;
const showTimestamp = this.props.mxEvent.getTs()

View file

@ -2073,6 +2073,8 @@
"Failed to load map": "Failed to load map",
"Vote not registered": "Vote not registered",
"Sorry, your vote was not registered. Please try again.": "Sorry, your vote was not registered. Please try again.",
"Final result based on %(count)s votes|other": "Final result based on %(count)s votes",
"Final result based on %(count)s votes|one": "Final result based on %(count)s vote",
"No votes cast": "No votes cast",
"%(count)s votes cast. Vote to see the results|other": "%(count)s votes cast. Vote to see the results",
"%(count)s votes cast. Vote to see the results|one": "%(count)s vote cast. Vote to see the results",
@ -2462,6 +2464,12 @@
"Developer Tools": "Developer Tools",
"There was an error updating your community. The server is unable to process your request.": "There was an error updating your community. The server is unable to process your request.",
"Update community": "Update community",
"The poll has ended. No votes were cast.": "The poll has ended. No votes were cast.",
"The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s",
"Failed to end poll": "Failed to end poll",
"Sorry, the poll did not end. Please try again.": "Sorry, the poll did not end. Please try again.",
"End Poll": "End Poll",
"Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.",
"An error has occurred.": "An error has occurred.",
"Enter a number between %(min)s and %(max)s": "Enter a number between %(min)s and %(max)s",
"Size can only be a number between %(min)s MB and %(max)s MB": "Size can only be a number between %(min)s MB and %(max)s MB",

View file

@ -19,45 +19,60 @@ import { IContent } from "matrix-js-sdk/src/models/event";
export const POLL_START_EVENT_TYPE = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start");
export const POLL_RESPONSE_EVENT_TYPE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response");
export const POLL_END_EVENT_TYPE = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end");
export const POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed");
export const POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed");
// TODO: [TravisR] Use extensible events library when ready
const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
export const TEXT_NODE_TYPE = new UnstableValue("m.text", "org.matrix.msc1767.text");
export interface IPollAnswer extends IContent {
id: string;
[TEXT_NODE_TYPE]: string;
[TEXT_NODE_TYPE.name]: string;
}
export interface IPollContent extends IContent {
[POLL_START_EVENT_TYPE.name]: {
kind: string; // disclosed or undisclosed (untypeable for now)
question: {
[TEXT_NODE_TYPE]: string;
[TEXT_NODE_TYPE.name]: string;
};
answers: IPollAnswer[];
};
[TEXT_NODE_TYPE]: string;
[TEXT_NODE_TYPE.name]: string;
}
export interface IPollResponse extends IContent {
export interface IPollResponseContent extends IContent {
[POLL_RESPONSE_EVENT_TYPE.name]: {
answers: string[];
};
"m.relates_to": {
"event_id": string;
"rel_type": string;
};
}
export interface IPollEndContent extends IContent {
[POLL_END_EVENT_TYPE.name]: {};
"m.relates_to": {
"event_id": string;
"rel_type": string;
};
}
export function makePollContent(question: string, answers: string[], kind: string): IPollContent {
question = question.trim();
answers = answers.map(a => a.trim()).filter(a => !!a);
return {
[TEXT_NODE_TYPE]: `${question}\n${answers.map((a, i) => `${i + 1}. ${a}`).join('\n')}`,
[TEXT_NODE_TYPE.name]: `${question}\n${answers.map((a, i) => `${i + 1}. ${a}`).join('\n')}`,
[POLL_START_EVENT_TYPE.name]: {
kind: kind,
question: {
[TEXT_NODE_TYPE]: question,
[TEXT_NODE_TYPE.name]: question,
},
answers: answers.map((a, i) => ({ id: `${i}-${a}`, [TEXT_NODE_TYPE]: a })),
answers: answers.map(
(a, i) => ({ id: `${i}-${a}`, [TEXT_NODE_TYPE.name]: a }),
),
},
};
}

View file

@ -20,11 +20,23 @@ import { mount, ReactWrapper } from "enzyme";
import sdk from "../../../skinned-sdk";
import * as TestUtils from "../../../test-utils";
import { Callback, IContent, MatrixEvent } from "matrix-js-sdk";
import { Callback, IContent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk";
import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { IPollAnswer, IPollContent, POLL_RESPONSE_EVENT_TYPE } from "../../../../src/polls/consts";
import { UserVote, allVotes } from "../../../../src/components/views/messages/MPollBody";
import {
IPollAnswer,
IPollContent,
POLL_END_EVENT_TYPE,
POLL_RESPONSE_EVENT_TYPE,
TEXT_NODE_TYPE,
} from "../../../../src/polls/consts";
import {
UserVote,
allVotes,
findTopAnswer,
pollEndTs,
isPollEnded,
} from "../../../../src/components/views/messages/MPollBody";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { IBodyProps } from "../../../../src/components/views/messages/IBodyProps";
@ -37,10 +49,18 @@ MatrixClientPeg.matrixClient = {
getUserId: () => "@me:example.com",
sendEvent: () => Promise.resolve({ "event_id": "fake_send_id" }),
};
setRedactionAllowedForMeOnly(MatrixClientPeg.matrixClient);
describe("MPollBody", () => {
it("finds no votes if there are none", () => {
expect(allVotes(newPollRelations([]))).toEqual([]);
expect(
allVotes(
{ getRoomId: () => "$room" } as MatrixEvent,
MatrixClientPeg.get(),
newVoteRelations([]),
newEndRelations([]),
),
).toEqual([]);
});
it("can find all the valid responses to a poll", () => {
@ -48,8 +68,15 @@ describe("MPollBody", () => {
const ev2 = responseEvent();
const badEvent = badResponseEvent();
const pollRelations = newPollRelations([ev1, badEvent, ev2]);
expect(allVotes(pollRelations)).toEqual([
const voteRelations = newVoteRelations([ev1, badEvent, ev2]);
expect(
allVotes(
{ getRoomId: () => "$room" } as MatrixEvent,
MatrixClientPeg.get(),
voteRelations,
newEndRelations([]),
),
).toEqual([
new UserVote(
ev1.getTs(),
ev1.getSender(),
@ -63,6 +90,71 @@ describe("MPollBody", () => {
]);
});
it("finds the first end poll event", () => {
const endRelations = newEndRelations([
endEvent("@me:example.com", 25),
endEvent("@me:example.com", 12),
endEvent("@me:example.com", 45),
endEvent("@me:example.com", 13),
]);
const matrixClient = TestUtils.createTestClient();
setRedactionAllowedForMeOnly(matrixClient);
expect(
pollEndTs(
{ getRoomId: () => "$room" } as MatrixEvent,
matrixClient,
endRelations,
),
).toBe(12);
});
it("ignores unauthorised end poll event when finding end ts", () => {
const endRelations = newEndRelations([
endEvent("@me:example.com", 25),
endEvent("@unauthorised:example.com", 12),
endEvent("@me:example.com", 45),
endEvent("@me:example.com", 13),
]);
const matrixClient = TestUtils.createTestClient();
setRedactionAllowedForMeOnly(matrixClient);
expect(
pollEndTs(
{ getRoomId: () => "$room" } as MatrixEvent,
matrixClient,
endRelations,
),
).toBe(13);
});
it("counts only votes before the end poll event", () => {
const voteRelations = newVoteRelations([
responseEvent("sf@matrix.org", "wings", 13),
responseEvent("jr@matrix.org", "poutine", 40),
responseEvent("ak@matrix.org", "poutine", 37),
responseEvent("id@matrix.org", "wings", 13),
responseEvent("ps@matrix.org", "wings", 19),
]);
const endRelations = newEndRelations([
endEvent("@me:example.com", 25),
]);
expect(
allVotes(
{ getRoomId: () => "$room" } as MatrixEvent,
MatrixClientPeg.get(),
voteRelations,
endRelations,
),
).toEqual([
new UserVote(13, "sf@matrix.org", ["wings"]),
new UserVote(13, "id@matrix.org", ["wings"]),
new UserVote(19, "ps@matrix.org", ["wings"]),
]);
});
it("renders no votes if none were made", () => {
const votes = [];
const body = newMPollBody(votes);
@ -88,6 +180,27 @@ describe("MPollBody", () => {
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 4 votes");
});
it("ignores end poll events from unauthorised users", () => {
const votes = [
responseEvent("@me:example.com", "pizza"),
responseEvent("@bellc:example.com", "pizza"),
responseEvent("@catrd:example.com", "poutine"),
responseEvent("@dune2:example.com", "wings"),
];
const ends = [
endEvent("@notallowed:example.com", 12),
];
const body = newMPollBody(votes, ends);
// Even though an end event was sent, we render the poll as unfinished
// because this person is not allowed to send these events
expect(votesCount(body, "pizza")).toBe("2 votes");
expect(votesCount(body, "poutine")).toBe("1 vote");
expect(votesCount(body, "italian")).toBe("0 votes");
expect(votesCount(body, "wings")).toBe("1 vote");
expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 4 votes");
});
it("hides scores if I have not voted", () => {
const votes = [
responseEvent("@alice:example.com", "pizza"),
@ -185,12 +298,12 @@ describe("MPollBody", () => {
const votes = [responseEvent("@me:example.com", "pizza", 100)];
const body = newMPollBody(votes);
const props: IBodyProps = body.instance().props as IBodyProps;
const pollRelations: Relations = props.getRelationsForEvent(
const voteRelations: Relations = props.getRelationsForEvent(
"$mypoll", "m.reference", POLL_RESPONSE_EVENT_TYPE.name);
clickRadio(body, "pizza");
// When a new vote from me comes in
pollRelations.addEvent(responseEvent("@me:example.com", "wings", 101));
voteRelations.addEvent(responseEvent("@me:example.com", "wings", 101));
// Then the new vote is counted, not the old one
expect(votesCount(body, "pizza")).toBe("0 votes");
@ -206,12 +319,12 @@ describe("MPollBody", () => {
const votes = [responseEvent("@me:example.com", "pizza")];
const body = newMPollBody(votes);
const props: IBodyProps = body.instance().props as IBodyProps;
const pollRelations: Relations = props.getRelationsForEvent(
const voteRelations: Relations = props.getRelationsForEvent(
"$mypoll", "m.reference", POLL_RESPONSE_EVENT_TYPE.name);
clickRadio(body, "pizza");
// When a new vote from someone else comes in
pollRelations.addEvent(responseEvent("@xx:example.com", "wings", 101));
voteRelations.addEvent(responseEvent("@xx:example.com", "wings", 101));
// Then my vote is still for pizza
// NOTE: the new event does not affect the counts for other people -
@ -314,6 +427,7 @@ describe("MPollBody", () => {
responseEvent("@uy:example.com", "poutine", 16),
];
const body = newMPollBody(votes);
expect(body.find('input[type="radio"]')).toHaveLength(4);
expect(votesCount(body, "pizza")).toBe("0 votes");
expect(votesCount(body, "poutine")).toBe("1 vote");
expect(votesCount(body, "italian")).toBe("0 votes");
@ -324,7 +438,8 @@ describe("MPollBody", () => {
it("renders nothing if poll has no answers", () => {
const answers = [];
const votes = [];
const body = newMPollBody(votes, answers);
const ends = [];
const body = newMPollBody(votes, ends, answers);
expect(body.html()).toBe("");
});
@ -333,7 +448,8 @@ describe("MPollBody", () => {
return { "id": `id${i}`, "org.matrix.msc1767.text": `Name ${i}` };
});
const votes = [];
const body = newMPollBody(votes, answers);
const ends = [];
const body = newMPollBody(votes, ends, answers);
expect(body.html()).toBe("");
});
@ -407,6 +523,309 @@ describe("MPollBody", () => {
]);
});
it("sends no events when I click in an ended poll", () => {
const receivedEvents = [];
MatrixClientPeg.matrixClient.sendEvent = (
roomId: string,
eventType: string,
content: IContent,
txnId?: string,
callback?: Callback,
): Promise<ISendEventResponse> => {
receivedEvents.push( { roomId, eventType, content, txnId, callback } );
return Promise.resolve({ "event_id": "fake_tracked_send_id" });
};
const ends = [
endEvent("@me:example.com", 25),
];
const votes = [
responseEvent("@uy:example.com", "wings", 15),
responseEvent("@uy:example.com", "poutine", 15),
];
const body = newMPollBody(votes, ends);
clickEndedOption(body, "wings");
clickEndedOption(body, "italian");
clickEndedOption(body, "poutine");
expect(receivedEvents).toEqual([]);
});
it("finds the top answer among several votes", () => {
// 2 votes for poutine, 1 for pizza. "me" made an invalid vote.
const votes = [
responseEvent("@me:example.com", "pizza", 12),
responseEvent("@me:example.com", ["pizza", "doesntexist"], 13),
responseEvent("@uy:example.com", "italian", 14),
responseEvent("@uy:example.com", "doesntexist", 15),
responseEvent("@uy:example.com", "poutine", 16),
responseEvent("@ab:example.com", "pizza", 17),
responseEvent("@fa:example.com", "poutine", 18),
];
expect(runFindTopAnswer(votes, [])).toEqual("Poutine");
});
it("finds all top answers when there is a draw", () => {
const votes = [
responseEvent("@uy:example.com", "italian", 14),
responseEvent("@ab:example.com", "pizza", 17),
responseEvent("@fa:example.com", "poutine", 18),
];
expect(runFindTopAnswer(votes, [])).toEqual("Italian, Pizza and Poutine");
});
it("finds all top answers ignoring late votes", () => {
const votes = [
responseEvent("@uy:example.com", "italian", 14),
responseEvent("@ab:example.com", "pizza", 17),
responseEvent("@io:example.com", "poutine", 30), // Late
responseEvent("@fa:example.com", "poutine", 18),
responseEvent("@of:example.com", "poutine", 31), // Late
];
const ends = [
endEvent("@me:example.com", 25),
];
expect(runFindTopAnswer(votes, ends)).toEqual("Italian, Pizza and Poutine");
});
it("is silent about the top answer if there are no votes", () => {
expect(runFindTopAnswer([], [])).toEqual("");
});
it("is silent about the top answer if there are no votes when ended", () => {
expect(runFindTopAnswer([], [endEvent("@me:example.com", 13)])).toEqual("");
});
it("shows non-radio buttons if the poll is ended", () => {
const events = [endEvent()];
const body = newMPollBody([], events);
expect(body.find(".mx_StyledRadioButton")).toHaveLength(0);
expect(body.find('input[type="radio"]')).toHaveLength(0);
});
it("counts votes as normal if the poll is ended", () => {
const votes = [
responseEvent("@me:example.com", "pizza", 12),
responseEvent("@me:example.com", "wings", 20), // latest me
responseEvent("@qbert:example.com", "pizza", 14),
responseEvent("@qbert:example.com", "poutine", 16), // latest qbert
responseEvent("@qbert:example.com", "wings", 15),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(endedVotesCount(body, "pizza")).toBe("0 votes");
expect(endedVotesCount(body, "poutine")).toBe("1 vote");
expect(endedVotesCount(body, "italian")).toBe("0 votes");
expect(endedVotesCount(body, "wings")).toBe("1 vote");
expect(
body.find(".mx_MPollBody_totalVotes").text(),
).toBe("Final result based on 2 votes");
});
it("counts a single vote as normal if the poll is ended", () => {
const votes = [responseEvent("@qbert:example.com", "poutine", 16)];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(endedVotesCount(body, "pizza")).toBe("0 votes");
expect(endedVotesCount(body, "poutine")).toBe("1 vote");
expect(endedVotesCount(body, "italian")).toBe("0 votes");
expect(endedVotesCount(body, "wings")).toBe("0 votes");
expect(
body.find(".mx_MPollBody_totalVotes").text(),
).toBe("Final result based on 1 vote");
});
it("shows ended vote counts of different numbers", () => {
const votes = [
responseEvent("@me:example.com", "wings", 20),
responseEvent("@qb:example.com", "wings", 14),
responseEvent("@xy:example.com", "wings", 15),
responseEvent("@fg:example.com", "pizza", 15),
responseEvent("@hi:example.com", "pizza", 15),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(body.find(".mx_StyledRadioButton")).toHaveLength(0);
expect(body.find('input[type="radio"]')).toHaveLength(0);
expect(endedVotesCount(body, "pizza")).toBe("2 votes");
expect(endedVotesCount(body, "poutine")).toBe("0 votes");
expect(endedVotesCount(body, "italian")).toBe("0 votes");
expect(endedVotesCount(body, "wings")).toBe("3 votes");
expect(
body.find(".mx_MPollBody_totalVotes").text(),
).toBe("Final result based on 5 votes");
});
it("ignores votes that arrived after poll ended", () => {
const votes = [
responseEvent("@sd:example.com", "wings", 30), // Late
responseEvent("@ff:example.com", "wings", 20),
responseEvent("@ut:example.com", "wings", 14),
responseEvent("@iu:example.com", "wings", 15),
responseEvent("@jf:example.com", "wings", 35), // Late
responseEvent("@wf:example.com", "pizza", 15),
responseEvent("@ld:example.com", "pizza", 15),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(endedVotesCount(body, "pizza")).toBe("2 votes");
expect(endedVotesCount(body, "poutine")).toBe("0 votes");
expect(endedVotesCount(body, "italian")).toBe("0 votes");
expect(endedVotesCount(body, "wings")).toBe("3 votes");
expect(
body.find(".mx_MPollBody_totalVotes").text(),
).toBe("Final result based on 5 votes");
});
it("counts votes that arrived after an unauthorised poll end event", () => {
const votes = [
responseEvent("@sd:example.com", "wings", 30), // Late
responseEvent("@ff:example.com", "wings", 20),
responseEvent("@ut:example.com", "wings", 14),
responseEvent("@iu:example.com", "wings", 15),
responseEvent("@jf:example.com", "wings", 35), // Late
responseEvent("@wf:example.com", "pizza", 15),
responseEvent("@ld:example.com", "pizza", 15),
];
const ends = [
endEvent("@unauthorised:example.com", 5), // Should be ignored
endEvent("@me:example.com", 25),
];
const body = newMPollBody(votes, ends);
expect(endedVotesCount(body, "pizza")).toBe("2 votes");
expect(endedVotesCount(body, "poutine")).toBe("0 votes");
expect(endedVotesCount(body, "italian")).toBe("0 votes");
expect(endedVotesCount(body, "wings")).toBe("3 votes");
expect(
body.find(".mx_MPollBody_totalVotes").text(),
).toBe("Final result based on 5 votes");
});
it("ignores votes that arrived after the first end poll event", () => {
// From MSC3381:
// "Votes sent on or before the end event's timestamp are valid votes"
const votes = [
responseEvent("@sd:example.com", "wings", 30), // Late
responseEvent("@ff:example.com", "wings", 20),
responseEvent("@ut:example.com", "wings", 14),
responseEvent("@iu:example.com", "wings", 25), // Just on time
responseEvent("@jf:example.com", "wings", 35), // Late
responseEvent("@wf:example.com", "pizza", 15),
responseEvent("@ld:example.com", "pizza", 15),
];
const ends = [
endEvent("@me:example.com", 65),
endEvent("@me:example.com", 25),
endEvent("@me:example.com", 75),
];
const body = newMPollBody(votes, ends);
expect(endedVotesCount(body, "pizza")).toBe("2 votes");
expect(endedVotesCount(body, "poutine")).toBe("0 votes");
expect(endedVotesCount(body, "italian")).toBe("0 votes");
expect(endedVotesCount(body, "wings")).toBe("3 votes");
expect(
body.find(".mx_MPollBody_totalVotes").text(),
).toBe("Final result based on 5 votes");
});
it("highlights the winning vote in an ended poll", () => {
// Given I voted for pizza but the winner is wings
const votes = [
responseEvent("@me:example.com", "pizza", 20),
responseEvent("@qb:example.com", "wings", 14),
responseEvent("@xy:example.com", "wings", 15),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
// Then the winner is highlighted
expect(endedVoteChecked(body, "wings")).toBe(true);
expect(endedVoteChecked(body, "pizza")).toBe(false);
// Double-check by looking for the endedOptionWinner class
expect(
endedVoteDiv(body, "wings").hasClass("mx_MPollBody_endedOptionWinner"),
).toBe(true);
expect(
endedVoteDiv(body, "pizza").hasClass("mx_MPollBody_endedOptionWinner"),
).toBe(false);
});
it("highlights multiple winning votes", () => {
const votes = [
responseEvent("@me:example.com", "pizza", 20),
responseEvent("@xy:example.com", "wings", 15),
responseEvent("@fg:example.com", "poutine", 15),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(endedVoteChecked(body, "pizza")).toBe(true);
expect(endedVoteChecked(body, "wings")).toBe(true);
expect(endedVoteChecked(body, "poutine")).toBe(true);
expect(endedVoteChecked(body, "italian")).toBe(false);
expect(body.find(".mx_MPollBody_option_checked")).toHaveLength(3);
});
it("highlights nothing if poll has no votes", () => {
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody([], ends);
expect(body.find(".mx_MPollBody_option_checked")).toHaveLength(0);
});
it("says poll is not ended if there is no end event", () => {
const ends = [];
expect(runIsPollEnded(ends)).toBe(false);
});
it("says poll is ended if there is an end event", () => {
const ends = [endEvent("@me:example.com", 25)];
expect(runIsPollEnded(ends)).toBe(true);
});
it("says poll is not ended if endRelations is undefined", () => {
const pollEvent = new MatrixEvent();
const matrixClient = TestUtils.createTestClient();
setRedactionAllowedForMeOnly(matrixClient);
expect(isPollEnded(pollEvent, matrixClient, undefined)).toBe(false);
});
it("says poll is not ended if asking for relations returns undefined", () => {
const pollEvent = new MatrixEvent({
"event_id": "$mypoll",
"room_id": "#myroom:example.com",
"content": newPollStart([]),
});
MatrixClientPeg.matrixClient.getRoom = () => {
return {
currentState: {
maySendRedactionForEvent: (_evt: MatrixEvent, userId: string) => {
return userId === "@me:example.com";
},
},
};
};
const getRelationsForEvent =
(eventId: string, relationType: string, eventType: string) => {
expect(eventId).toBe("$mypoll");
expect(relationType).toBe("m.reference");
expect(eventType).toBe(POLL_END_EVENT_TYPE.name);
return undefined;
};
expect(
isPollEnded(
pollEvent,
MatrixClientPeg.get(),
getRelationsForEvent,
),
).toBe(false);
});
it("renders a poll with no votes", () => {
const votes = [];
const body = newMPollBody(votes);
@ -450,25 +869,75 @@ describe("MPollBody", () => {
const body = newMPollBody(votes);
expect(body).toMatchSnapshot();
});
it("renders a finished poll with no votes", () => {
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody([], ends);
expect(body).toMatchSnapshot();
});
it("renders a finished poll", () => {
const votes = [
responseEvent("@op:example.com", "pizza", 12),
responseEvent("@op:example.com", [], 13),
responseEvent("@op:example.com", "italian", 14),
responseEvent("@yo:example.com", "wings", 15),
responseEvent("@qr:example.com", "italian", 16),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(body).toMatchSnapshot();
});
it("renders a finished poll with multiple winners", () => {
const votes = [
responseEvent("@ed:example.com", "pizza", 12),
responseEvent("@rf:example.com", "pizza", 12),
responseEvent("@th:example.com", "wings", 13),
responseEvent("@yh:example.com", "wings", 14),
responseEvent("@th:example.com", "poutine", 13),
responseEvent("@yh:example.com", "poutine", 14),
];
const ends = [endEvent("@me:example.com", 25)];
const body = newMPollBody(votes, ends);
expect(body).toMatchSnapshot();
});
});
function newPollRelations(relationEvents: Array<MatrixEvent>): Relations {
const pollRelations = new Relations(
"m.reference", POLL_RESPONSE_EVENT_TYPE.name, null);
function newVoteRelations(relationEvents: Array<MatrixEvent>): Relations {
return newRelations(relationEvents, POLL_RESPONSE_EVENT_TYPE.name);
}
function newEndRelations(relationEvents: Array<MatrixEvent>): Relations {
return newRelations(relationEvents, POLL_END_EVENT_TYPE.name);
}
function newRelations(
relationEvents: Array<MatrixEvent>,
eventType: string,
): Relations {
const voteRelations = new Relations("m.reference", eventType, null);
for (const ev of relationEvents) {
pollRelations.addEvent(ev);
voteRelations.addEvent(ev);
}
return pollRelations;
return voteRelations;
}
function newMPollBody(
relationEvents: Array<MatrixEvent>,
endEvents: Array<MatrixEvent> = [],
answers?: IPollAnswer[],
): ReactWrapper {
const pollRelations = new Relations(
const voteRelations = new Relations(
"m.reference", POLL_RESPONSE_EVENT_TYPE.name, null);
for (const ev of relationEvents) {
pollRelations.addEvent(ev);
voteRelations.addEvent(ev);
}
const endRelations = new Relations(
"m.reference", POLL_END_EVENT_TYPE.name, null);
for (const ev of endEvents) {
endRelations.addEvent(ev);
}
return mount(<MPollBody
@ -481,15 +950,28 @@ function newMPollBody(
(eventId: string, relationType: string, eventType: string) => {
expect(eventId).toBe("$mypoll");
expect(relationType).toBe("m.reference");
expect(eventType).toBe(POLL_RESPONSE_EVENT_TYPE.name);
return pollRelations;
if (POLL_RESPONSE_EVENT_TYPE.matches(eventType)) {
return voteRelations;
} else if (POLL_END_EVENT_TYPE.matches(eventType)) {
return endRelations;
} else {
fail("Unexpected eventType: " + eventType);
}
}
}
/>);
}
function clickRadio(wrapper: ReactWrapper, value: string) {
wrapper.find(`StyledRadioButton[value="${value}"]`).simulate("click");
const div = wrapper.find(`StyledRadioButton[value="${value}"]`);
expect(div).toHaveLength(1);
div.simulate("click");
}
function clickEndedOption(wrapper: ReactWrapper, value: string) {
const div = wrapper.find(`div[data-value="${value}"]`);
expect(div).toHaveLength(1);
div.simulate("click");
}
function voteButton(wrapper: ReactWrapper, value: string): ReactWrapper {
@ -504,6 +986,22 @@ function votesCount(wrapper: ReactWrapper, value: string): string {
).text();
}
function endedVoteChecked(wrapper: ReactWrapper, value: string): boolean {
return endedVoteDiv(wrapper, value)
.closest(".mx_MPollBody_option")
.hasClass("mx_MPollBody_option_checked");
}
function endedVoteDiv(wrapper: ReactWrapper, value: string): ReactWrapper {
return wrapper.find(`div[data-value="${value}"]`);
}
function endedVotesCount(wrapper: ReactWrapper, value: string): string {
return wrapper.find(
`div[data-value="${value}"] .mx_MPollBody_optionVoteCount`,
).text();
}
function newPollStart(answers?: IPollAnswer[]): IPollContent {
if (!answers) {
answers = [
@ -587,6 +1085,85 @@ function expectedResponseEvent(answer: string) {
};
}
function endEvent(
sender = "@me:example.com",
ts = 0,
): MatrixEvent {
return new MatrixEvent(
{
"event_id": nextId(),
"room_id": "#myroom:example.com",
"origin_server_ts": ts,
"type": POLL_END_EVENT_TYPE.name,
"sender": sender,
"content": {
"m.relates_to": {
"rel_type": "m.reference",
"event_id": "$mypoll",
},
[POLL_END_EVENT_TYPE.name]: {},
[TEXT_NODE_TYPE.name]: "The poll has ended. Something.",
},
},
);
}
function runIsPollEnded(ends: MatrixEvent[]) {
const pollEvent = new MatrixEvent({
"event_id": "$mypoll",
"room_id": "#myroom:example.com",
"content": newPollStart(),
});
const matrixClient = TestUtils.createTestClient();
setRedactionAllowedForMeOnly(matrixClient);
const getRelationsForEvent =
(eventId: string, relationType: string, eventType: string) => {
expect(eventId).toBe("$mypoll");
expect(relationType).toBe("m.reference");
expect(eventType).toBe(POLL_END_EVENT_TYPE.name);
return newEndRelations(ends);
};
return isPollEnded(pollEvent, matrixClient, getRelationsForEvent);
}
function runFindTopAnswer(votes: MatrixEvent[], ends: MatrixEvent[]) {
const pollEvent = new MatrixEvent({
"event_id": "$mypoll",
"room_id": "#myroom:example.com",
"content": newPollStart(),
});
const getRelationsForEvent =
(eventId: string, relationType: string, eventType: string) => {
expect(eventId).toBe("$mypoll");
expect(relationType).toBe("m.reference");
if (POLL_RESPONSE_EVENT_TYPE.matches(eventType)) {
return newVoteRelations(votes);
} else if (POLL_END_EVENT_TYPE.matches(eventType)) {
return newEndRelations(ends);
} else {
fail(`eventType should be end or vote but was ${eventType}`);
}
};
return findTopAnswer(pollEvent, MatrixClientPeg.get(), getRelationsForEvent);
}
function setRedactionAllowedForMeOnly(matrixClient: MatrixClient) {
matrixClient.getRoom = (_roomId: string) => {
return {
currentState: {
maySendRedactionForEvent: (_evt: MatrixEvent, userId: string) => {
return userId === "@me:example.com";
},
},
} as Room;
};
}
let EVENT_ID = 0;
function nextId(): string {
EVENT_ID++;