diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index 47646c38209..0189384dd9a 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -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'); } diff --git a/res/css/views/messages/_MPollBody.scss b/res/css/views/messages/_MPollBody.scss index 257e04cea7c..e56fc8993a6 100644 --- a/res/css/views/messages/_MPollBody.scss +++ b/res/css/views/messages/_MPollBody.scss @@ -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; - - div { - visibility: hidden; + .mx_StyledRadioButton_checked, .mx_MPollBody_endedOptionWinner { + input[type="radio"] + div { + border-width: 2px; + border-color: $accent; + background-color: $accent; + background-image: url('$(res)/img/element-icons/check-white.svg'); + background-size: 12px; + background-repeat: no-repeat; + background-position: center; + + div { + visibility: hidden; + } } } + .mx_MPollBody_endedOptionWinner .mx_MPollBody_optionDescription .mx_MPollBody_optionVoteCount::before { + content: ''; + position: relative; + display: inline-block; + margin-right: 4px; + top: 2px; + height: 12px; + width: 12px; + background-color: $accent; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + mask-image: url('$(res)/img/element-icons/trophy.svg'); + } + .mx_MPollBody_totalVotes { color: $secondary-content; font-size: $font-12px; diff --git a/res/img/element-icons/trophy.svg b/res/img/element-icons/trophy.svg new file mode 100644 index 00000000000..7caf61fd35e --- /dev/null +++ b/res/img/element-icons/trophy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index ef0b8c09a86..dc33120a76a 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -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 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 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 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 /> ); + if (this.canEndPoll(mxEvent)) { + endPollButton = ( + + ); + } + if (this.props.eventTileOps) { // this event is rendered using TextualBody quoteButton = ( label={_t("View in room")} onClick={this.viewInRoom} /> } + { endPollButton } { quoteButton } { forwardButton } { pinButton } diff --git a/src/components/views/dialogs/EndPollDialog.tsx b/src/components/views/dialogs/EndPollDialog.tsx new file mode 100644 index 00000000000..cf501936393 --- /dev/null +++ b/src/components/views/dialogs/EndPollDialog.tsx @@ -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 { + 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 ( + this.onFinished(endPoll)} + /> + ); + } +} diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 592a00e4ff3..0ef9ac1b6f0 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -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 = collectUserVotes( + allVotes(pollEvent, matrixClient, voteRelations, endRelations), + matrixClient.getUserId(), + null, + ); + + const votes: Map = 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 { public static contextType = MatrixClientContext; public context!: React.ContextType; 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) - ) { - this.props.mxEvent.removeListener( - "Event.relationsCreated", this.onPollRelationsCreated); + private onRelationsCreated = (relationType: string, eventType: string) => { + if (relationType !== "m.reference") { + return; + } - const newPollRelations = this.fetchPollRelations(); - this.addListeners(newPollRelations); - this.removeListeners(this.state.pollRelations); + 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 }); + } - this.setState({ - pollRelations: newPollRelations, - }); + if (this.voteRelationsReceived && this.endRelationsReceived) { + this.props.mxEvent.removeListener( + "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 { 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 { 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 { */ private collectUserVotes(): Map { 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 { * 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 { 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 { 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 { } return
-

{ pollInfo.question[TEXT_NODE_TYPE] }

+

{ pollInfo.question[TEXT_NODE_TYPE.name] }

{ 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
this.selectOption(answer.id)} > - -
-
- { answer[TEXT_NODE_TYPE] } -
-
- { votesText } -
-
-
+ { ( + ended + ? + : + ) }
{ } } +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
+
+
+ { props.answer[TEXT_NODE_TYPE.name] } +
+
+ { props.votesText } +
+
+
; +} + +interface ILivePollOptionProps { + pollId: string; + answer: IPollAnswer; + checked: boolean; + votesText: string; + onOptionSelected: (e: React.FormEvent) => void; +} + +function LivePollOption(props: ILivePollOptionProps) { + return +
+
+ { props.answer[TEXT_NODE_TYPE.name] } +
+
+ { props.votesText } +
+
+
; +} + 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 { - if (pollRelations) { - return pollRelations.getRelations() +export function allVotes( + pollEvent: MatrixEvent, + matrixClient: MatrixClient, + voteRelations: Relations, + endRelations: Relations, +): Array { + 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()) && diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 69ec20dbf57..44105bb35c6 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -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 = - ({ mxEvent, getTile, getReplyChain, permalinkCreator, onFocusChange }) => { - 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(); - - const buttonRect = button.current.getBoundingClientRect(); - contextMenu = ; - } +const OptionsButton: React.FC = ({ + mxEvent, + getTile, + getReplyChain, + permalinkCreator, + onFocusChange, + getRelationsForEvent, +}) => { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + const [onFocus, isActive, ref] = useRovingTabIndex(button); + useEffect(() => { + onFocusChange(menuDisplayed); + }, [onFocusChange, menuDisplayed]); - return - - - { contextMenu } - ; - }; + let contextMenu: ReactElement | null; + if (menuDisplayed) { + const tile = getTile && getTile(); + const replyChain = getReplyChain && getReplyChain(); + + const buttonRect = button.current.getBoundingClientRect(); + contextMenu = ; + } + + return + + + { contextMenu } + ; +}; 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); } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 8cf59a2d5d0..21ca642d243 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -1157,6 +1157,7 @@ export default class EventTile extends React.Component { onFocusChange={this.onActionBarFocusChange} isQuoteExpanded={isQuoteExpanded} toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)} + getRelationsForEvent={this.props.getRelationsForEvent} /> : undefined; const showTimestamp = this.props.mxEvent.getTs() diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 403fab8d03b..5349fd5224e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/polls/consts.ts b/src/polls/consts.ts index 89e458e0a6e..dfb56c0fe8f 100644 --- a/src/polls/consts.ts +++ b/src/polls/consts.ts @@ -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 }), + ), }, }; } diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index 2baaaa944db..4a5d80a9052 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -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 => { + 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): Relations { - const pollRelations = new Relations( - "m.reference", POLL_RESPONSE_EVENT_TYPE.name, null); +function newVoteRelations(relationEvents: Array): Relations { + return newRelations(relationEvents, POLL_RESPONSE_EVENT_TYPE.name); +} + +function newEndRelations(relationEvents: Array): Relations { + return newRelations(relationEvents, POLL_END_EVENT_TYPE.name); +} + +function newRelations( + relationEvents: Array, + 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, + endEvents: Array = [], 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( { 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++; diff --git a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap index 626a7dfa969..7385d0f463f 100644 --- a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -1,5 +1,884 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`MPollBody renders a finished poll 1`] = ` + + +
+

+ What should we order for the party? +

+
+
+ +
+
+
+ Pizza +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Poutine +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Italian +
+
+ 2 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Wings +
+
+ 1 vote +
+
+
+
+
+
+
+
+
+
+ Final result based on 3 votes +
+
+ + +`; + +exports[`MPollBody renders a finished poll with multiple winners 1`] = ` + + +
+

+ What should we order for the party? +

+
+
+ +
+
+
+ Pizza +
+
+ 2 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Poutine +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Italian +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Wings +
+
+ 2 votes +
+
+
+
+
+
+
+
+
+
+ Final result based on 4 votes +
+
+ + +`; + +exports[`MPollBody renders a finished poll with no votes 1`] = ` + + +
+

+ What should we order for the party? +

+
+
+ +
+
+
+ Pizza +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Poutine +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Italian +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+ +
+
+
+ Wings +
+
+ 0 votes +
+
+
+
+
+
+
+
+
+
+ Final result based on 0 votes +
+
+ + +`; + exports[`MPollBody renders a poll that I have not voted in 1`] = ` - -