From d705fdd6e42f0c7e9683fd75d666fd1175e01028 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Tue, 23 Nov 2021 10:27:44 +0000 Subject: [PATCH] Display and send votes in polls (#7158) Co-authored-by: Travis Ralston --- res/css/views/messages/_MPollBody.scss | 6 + src/components/views/messages/IBodyProps.ts | 6 +- src/components/views/messages/MPollBody.tsx | 242 +++- .../views/messages/MessageEvent.tsx | 5 + src/components/views/rooms/EventTile.tsx | 7 +- src/i18n/strings/en_EN.json | 6 +- src/polls/consts.ts | 7 + .../views/messages/MPollBody-test.tsx | 476 ++++++++ .../__snapshots__/MPollBody-test.tsx.snap | 1000 +++++++++++++++++ 9 files changed, 1740 insertions(+), 15 deletions(-) create mode 100644 test/components/views/messages/MPollBody-test.tsx create mode 100644 test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap diff --git a/res/css/views/messages/_MPollBody.scss b/res/css/views/messages/_MPollBody.scss index cff1d46cb4..0c307fbc4d 100644 --- a/res/css/views/messages/_MPollBody.scss +++ b/res/css/views/messages/_MPollBody.scss @@ -86,6 +86,12 @@ limitations under the License. .mx_MPollBody_option_checked { border-color: $accent; + + .mx_MPollBody_popularityBackground { + .mx_MPollBody_popularityAmount { + background-color: $accent; + } + } } .mx_StyledRadioButton_checked input[type="radio"] + div { diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index daa05c3b1a..9b077e6e50 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { TileShape } from "../rooms/EventTile"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { Relations } from "matrix-js-sdk/src/models/relations"; export interface IBodyProps { mxEvent: MatrixEvent; @@ -41,4 +42,7 @@ export interface IBodyProps { onMessageAllowed: () => void; // TODO: Docs permalinkCreator: RoomPermalinkCreator; mediaEventHelper: MediaEventHelper; + + // helper function to access relations for this event + getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; } diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 93fbe974d1..ee2cd663d7 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -18,14 +18,24 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { IBodyProps } from "./IBodyProps"; -import { IPollAnswer, IPollContent, POLL_START_EVENT_TYPE } from '../../../polls/consts'; +import { + IPollAnswer, + IPollContent, + IPollResponse, + POLL_RESPONSE_EVENT_TYPE, + POLL_START_EVENT_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 { MatrixClientPeg } from '../../../MatrixClientPeg'; // TODO: [andyb] Use extensible events library when ready const TEXT_NODE_TYPE = "org.matrix.msc1767.text"; interface IState { selected?: string; + pollRelations: Relations; } @replaceableComponent("views.messages.MPollBody") @@ -33,12 +43,88 @@ export default class MPollBody extends React.Component { constructor(props: IBodyProps) { super(props); - this.state = { - selected: null, - }; + const pollRelations = this.fetchPollRelations(); + let selected = null; + + const userVotes = collectUserVotes(allVotes(pollRelations), null); + const userId = MatrixClientPeg.get().getUserId(); + const currentVote = userVotes.get(userId); + if (currentVote) { + selected = currentVote.answers[0]; + } + + this.state = { selected, pollRelations }; + + this.addListeners(this.state.pollRelations); + this.props.mxEvent.on("Event.relationsCreated", this.onPollRelationsCreated); } + componentWillUnmount() { + this.props.mxEvent.off("Event.relationsCreated", this.onPollRelationsCreated); + this.removeListeners(this.state.pollRelations); + } + + 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 removeListeners(pollRelations?: Relations) { + if (pollRelations) { + pollRelations.off("Relations.add", this.onRelationsChange); + pollRelations.off("Relations.remove", this.onRelationsChange); + pollRelations.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); + + const newPollRelations = this.fetchPollRelations(); + this.addListeners(newPollRelations); + this.removeListeners(this.state.pollRelations); + + this.setState({ + pollRelations: newPollRelations, + }); + } + }; + + private onRelationsChange = () => { + // We hold pollRelations in our state, and it has changed under us + this.forceUpdate(); + }; + private selectOption(answerId: string) { + if (answerId === this.state.selected) { + return; + } + + const responseContent: IPollResponse = { + [POLL_RESPONSE_EVENT_TYPE.name]: { + "answers": [answerId], + }, + "m.relates_to": { + "event_id": this.props.mxEvent.getId(), + "rel_type": "m.reference", + }, + }; + MatrixClientPeg.get().sendEvent( + this.props.mxEvent.getRoomId(), + POLL_RESPONSE_EVENT_TYPE.name, + responseContent, + ).catch(e => { + console.error("Failed to submit poll response event:", e); + }); + this.setState({ selected: answerId }); } @@ -46,20 +132,60 @@ export default class MPollBody extends React.Component { this.selectOption(e.currentTarget.value); }; + private fetchPollRelations(): Relations | null { + if (this.props.getRelationsForEvent) { + return this.props.getRelationsForEvent( + this.props.mxEvent.getId(), + "m.reference", + POLL_RESPONSE_EVENT_TYPE.name, + ); + } else { + return null; + } + } + + /** + * @returns answer-id -> number-of-votes + */ + private collectVotes(): Map { + return countVotes( + collectUserVotes(allVotes(this.state.pollRelations), this.state.selected), + this.props.mxEvent.getContent(), + ); + } + + private totalVotes(collectedVotes: Map): number { + let sum = 0; + for (const v of collectedVotes.values()) { + sum += v; + } + return sum; + } + render() { - const pollStart: IPollContent = - this.props.mxEvent.getContent()[POLL_START_EVENT_TYPE.name]; + const pollStart: IPollContent = this.props.mxEvent.getContent(); + const pollInfo = pollStart[POLL_START_EVENT_TYPE.name]; + + if (pollInfo.answers.length < 1 || pollInfo.answers.length > 20) { + return null; + } + const pollId = this.props.mxEvent.getId(); + const votes = this.collectVotes(); + const totalVotes = this.totalVotes(votes); return
-

{ pollStart.question[TEXT_NODE_TYPE] }

+

{ pollInfo.question[TEXT_NODE_TYPE] }

{ - pollStart.answers.map((answer: IPollAnswer) => { + pollInfo.answers.map((answer: IPollAnswer) => { const checked = this.state.selected === answer.id; const classNames = `mx_MPollBody_option${ checked ? " mx_MPollBody_option_checked": "" }`; + const answerVotes = votes.get(answer.id) ?? 0; + const answerPercent = Math.round( + 100.0 * answerVotes / totalVotes); return
{ onChange={this.onOptionSelected} >
- { _t("%(number)s votes", { number: 0 }) } + { _t("%(count)s votes", { count: answerVotes }) }
{ answer[TEXT_NODE_TYPE] }
-
+
; }) }
- { _t( "Based on %(total)s votes", { total: 0 } ) } + { _t( "Based on %(count)s votes", { count: totalVotes } ) }
; } } + +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 answers = pr[POLL_RESPONSE_EVENT_TYPE.name].answers; + + return new UserVote( + event.getTs(), + event.getSender(), + answers, + ); +} + +export function allVotes(pollRelations: Relations): Array { + function isPollResponse(responseEvent: MatrixEvent): boolean { + return ( + responseEvent.getType() === POLL_RESPONSE_EVENT_TYPE.name && + responseEvent.getContent().hasOwnProperty(POLL_RESPONSE_EVENT_TYPE.name) + ); + } + + if (pollRelations) { + return pollRelations.getRelations() + .filter(isPollResponse) + .map(userResponseFromPollResponseEvent); + } else { + return []; + } +} + +/** + * Figure out the correct vote for each user. + * @returns a Map of user ID to their vote info + */ +function collectUserVotes( + userResponses: Array, + selected?: string, +): Map { + const userVotes: Map = new Map(); + + for (const response of userResponses) { + const otherResponse = userVotes.get(response.sender); + if (!otherResponse || otherResponse.ts < response.ts) { + userVotes.set(response.sender, response); + } + } + + if (selected) { + const client = MatrixClientPeg.get(); + const userId = client.getUserId(); + userVotes.set(userId, new UserVote(0, userId, [selected])); + } + + return userVotes; +} + +function countVotes( + userVotes: Map, + pollStart: IPollContent, +): Map { + const collected = new Map(); + + const pollInfo = pollStart[POLL_START_EVENT_TYPE.name]; + const maxSelections = 1; // See MSC3381 - later this will be in pollInfo + + const allowedAnswerIds = pollInfo.answers.map((ans: IPollAnswer) => ans.id); + function isValidAnswer(answerId: string) { + return allowedAnswerIds.includes(answerId); + } + + for (const response of userVotes.values()) { + if (response.answers.every(isValidAnswer)) { + for (const [index, answerId] of response.answers.entries()) { + if (index >= maxSelections) { + break; + } + if (collected.has(answerId)) { + collected.set(answerId, collected.get(answerId) + 1); + } else { + collected.set(answerId, 1); + } + } + } + } + + return collected; +} diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 9590cd1ed7..6cffd79149 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -28,12 +28,16 @@ import { ReactAnyComponent } from "../../../@types/common"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { IBodyProps } from "./IBodyProps"; import { POLL_START_EVENT_TYPE } from '../../../polls/consts'; +import { Relations } from 'matrix-js-sdk/src/models/relations'; // onMessageAllowed is handled internally interface IProps extends Omit { /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ overrideBodyTypes?: Record; overrideEventTypes?: Record; + + // helper function to access relations for this event + getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; } @replaceableComponent("views.messages.MessageEvent") @@ -154,6 +158,7 @@ export default class MessageEvent extends React.Component implements IMe onMessageAllowed={this.onTileUpdate} permalinkCreator={this.props.permalinkCreator} mediaEventHelper={this.mediaHelper} + getRelationsForEvent={this.props.getRelationsForEvent} /> : null; } } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index e2e6ecc2da..0d17276c22 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -990,7 +990,7 @@ export default class EventTile extends React.Component { return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); }; - private onReactionsCreated = (relationType, eventType) => { + private onReactionsCreated = (relationType: string, eventType: string) => { if (relationType !== "m.annotation" || eventType !== "m.reaction") { return; } @@ -1286,6 +1286,7 @@ export default class EventTile extends React.Component { onHeightChanged={this.props.onHeightChanged} tileShape={this.props.tileShape} editState={this.props.editState} + getRelationsForEvent={this.props.getRelationsForEvent} />
, ]); @@ -1324,6 +1325,7 @@ export default class EventTile extends React.Component { tileShape={this.props.tileShape} editState={this.props.editState} replacingEventId={this.props.replacingEventId} + getRelationsForEvent={this.props.getRelationsForEvent} /> { actionBar } { timestamp } @@ -1373,6 +1375,7 @@ export default class EventTile extends React.Component { onHeightChanged={this.props.onHeightChanged} callEventGrouper={this.props.callEventGrouper} tileShape={this.props.tileShape} + getRelationsForEvent={this.props.getRelationsForEvent} /> { keyRequestInfo } { this.renderThreadPanelSummary() } @@ -1410,6 +1413,7 @@ export default class EventTile extends React.Component { tileShape={this.props.tileShape} onHeightChanged={this.props.onHeightChanged} editState={this.props.editState} + getRelationsForEvent={this.props.getRelationsForEvent} />
, { permalinkCreator={this.props.permalinkCreator} onHeightChanged={this.props.onHeightChanged} callEventGrouper={this.props.callEventGrouper} + getRelationsForEvent={this.props.getRelationsForEvent} /> { keyRequestInfo } { actionBar } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a6dc59d8ed..0f1f5b0547 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2045,8 +2045,10 @@ "Declining …": "Declining …", "%(name)s wants to verify": "%(name)s wants to verify", "You sent a verification request": "You sent a verification request", - "%(number)s votes": "%(number)s votes", - "Based on %(total)s votes": "Based on %(total)s votes", + "%(count)s votes|other": "%(count)s votes", + "%(count)s votes|one": "%(count)s vote", + "Based on %(count)s votes|other": "Based on %(count)s votes", + "Based on %(count)s votes|one": "Based on %(count)s vote", "Error decrypting video": "Error decrypting video", "Error processing voice message": "Error processing voice message", "Add reaction": "Add reaction", diff --git a/src/polls/consts.ts b/src/polls/consts.ts index be9eb97b5e..89e458e0a6 100644 --- a/src/polls/consts.ts +++ b/src/polls/consts.ts @@ -18,6 +18,7 @@ import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; 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_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"); @@ -40,6 +41,12 @@ export interface IPollContent extends IContent { [TEXT_NODE_TYPE]: string; } +export interface IPollResponse extends IContent { + [POLL_RESPONSE_EVENT_TYPE.name]: { + answers: string[]; + }; +} + export function makePollContent(question: string, answers: string[], kind: string): IPollContent { question = question.trim(); answers = answers.map(a => a.trim()).filter(a => !!a); diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx new file mode 100644 index 0000000000..60d75e7d75 --- /dev/null +++ b/test/components/views/messages/MPollBody-test.tsx @@ -0,0 +1,476 @@ +/* +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 { mount, ReactWrapper } from "enzyme"; + +import sdk from "../../../skinned-sdk"; +import * as TestUtils from "../../../test-utils"; + +import { Callback, IContent, MatrixEvent } 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 } from "../../../../src/polls/consts"; +import { UserVote, allVotes } from "../../../../src/components/views/messages/MPollBody"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; + +const _MPollBody = sdk.getComponent("views.messages.MPollBody"); +const MPollBody = TestUtils.wrapInMatrixClientContext(_MPollBody); + +MatrixClientPeg.matrixClient = { + getUserId: () => "@me:example.com", + sendEvent: () => Promise.resolve({ "event_id": "fake_send_id" }), +}; + +describe("MPollBody", () => { + it("finds no votes if there are none", () => { + expect(allVotes(newPollRelations([]))).toEqual([]); + }); + + it("can find all the valid responses to a poll", () => { + const ev1 = responseEvent(); + const ev2 = responseEvent(); + const badEvent = badResponseEvent(); + + const pollRelations = newPollRelations([ev1, badEvent, ev2]); + expect(allVotes(pollRelations)).toEqual([ + new UserVote( + ev1.getTs(), + ev1.getSender(), + ev1.getContent()["org.matrix.msc3381.poll.response"].answers, + ), + new UserVote( + ev2.getTs(), + ev2.getSender(), + ev2.getContent()["org.matrix.msc3381.poll.response"].answers, + ), + ]); + }); + + it("finds no votes if none were made", () => { + const votes = []; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("0 votes"); + expect(votesCount(body, "wings")).toBe("0 votes"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 0 votes"); + }); + + it("finds votes from multiple people", () => { + const votes = [ + responseEvent("@andyb:example.com", "pizza"), + responseEvent("@bellc:example.com", "pizza"), + responseEvent("@catrd:example.com", "poutine"), + responseEvent("@dune2:example.com", "wings"), + ]; + const body = newMPollBody(votes); + 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("takes someone's most recent vote if they voted several times", () => { + const votes = [ + responseEvent("@fiona:example.com", "pizza", 12), + responseEvent("@fiona:example.com", "wings", 20), // latest fiona + responseEvent("@qbert:example.com", "pizza", 14), + responseEvent("@qbert:example.com", "poutine", 16), // latest qbert + responseEvent("@qbert:example.com", "wings", 15), + ]; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("0 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 2 votes"); + }); + + it("uses my local vote", () => { + // Given I haven't voted + const votes = [ + responseEvent("@nf:example.com", "pizza", 15), + responseEvent("@fg:example.com", "pizza", 15), + responseEvent("@hi:example.com", "pizza", 15), + ]; + const body = newMPollBody(votes); + + // When I vote for Italian + clickRadio(body, "italian"); + + // My vote is counted + expect(votesCount(body, "pizza")).toBe("3 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("1 vote"); + expect(votesCount(body, "wings")).toBe("0 votes"); + + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 4 votes"); + }); + + it("overrides my other votes with my local vote", () => { + // Given two of us have voted for Italian + const votes = [ + responseEvent("@me:example.com", "pizza", 12), + responseEvent("@me:example.com", "poutine", 13), + responseEvent("@me:example.com", "italian", 14), + responseEvent("@nf:example.com", "italian", 15), + ]; + const body = newMPollBody(votes); + + // When I click Wings + clickRadio(body, "wings"); + + // Then my vote is counted for Wings, and not for Italian + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("1 vote"); + expect(votesCount(body, "wings")).toBe("1 vote"); + + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes"); + }); + + it("ignores extra answers", () => { + // When cb votes for 2 things, we consider the first only + const votes = [ + responseEvent("@cb:example.com", ["pizza", "wings"]), + responseEvent("@da:example.com", "wings"), + ]; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("1 vote"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("0 votes"); + expect(votesCount(body, "wings")).toBe("1 vote"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes"); + }); + + it("allows un-voting by passing an empty vote", () => { + const votes = [ + responseEvent("@nc:example.com", "pizza", 12), + responseEvent("@nc:example.com", [], 13), + responseEvent("@md:example.com", "italian"), + ]; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("1 vote"); + expect(votesCount(body, "wings")).toBe("0 votes"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 1 vote"); + }); + + it("allows re-voting after un-voting", () => { + const votes = [ + responseEvent("@op:example.com", "pizza", 12), + responseEvent("@op:example.com", [], 13), + responseEvent("@op:example.com", "italian", 14), + responseEvent("@qr:example.com", "italian"), + ]; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("2 votes"); + expect(votesCount(body, "wings")).toBe("0 votes"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes"); + }); + + it("treats any invalid answer as a spoiled ballot", () => { + // Note that tr's second vote has a valid first answer, but + // the ballot is still spoiled because the second answer is + // invalid, even though we would ignore it if we continued. + const votes = [ + responseEvent("@tr:example.com", "pizza", 12), + responseEvent("@tr:example.com", ["pizza", "doesntexist"], 13), + responseEvent("@uy:example.com", "italian", 14), + responseEvent("@uy:example.com", "doesntexist", 15), + ]; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("0 votes"); + expect(votesCount(body, "wings")).toBe("0 votes"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 0 votes"); + }); + + it("allows re-voting after a spoiled ballot", () => { + const votes = [ + responseEvent("@tr:example.com", "pizza", 12), + responseEvent("@tr:example.com", ["pizza", "doesntexist"], 13), + responseEvent("@uy:example.com", "italian", 14), + responseEvent("@uy:example.com", "doesntexist", 15), + responseEvent("@uy:example.com", "poutine", 16), + ]; + const body = newMPollBody(votes); + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("1 vote"); + expect(votesCount(body, "italian")).toBe("0 votes"); + expect(votesCount(body, "wings")).toBe("0 votes"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 1 vote"); + }); + + it("renders nothing if poll has no answers", () => { + const answers = []; + const votes = []; + const body = newMPollBody(votes, answers); + expect(body.html()).toBe(""); + }); + + it("renders nothing if poll has more than 20 answers", () => { + const answers = [...Array(21).keys()].map((i) => { + return { "id": `id${i}`, "org.matrix.msc1767.text": `Name ${i}` }; + }); + const votes = []; + const body = newMPollBody(votes, answers); + expect(body.html()).toBe(""); + }); + + it("sends a vote event when I choose an option", () => { + 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 votes = []; + const body = newMPollBody(votes); + clickRadio(body, "wings"); + expect(receivedEvents).toEqual([ + expectedResponseEvent("wings"), + ]); + }); + + it("sends only one vote event when I click several times", () => { + 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 votes = []; + const body = newMPollBody(votes); + clickRadio(body, "wings"); + clickRadio(body, "wings"); + clickRadio(body, "wings"); + clickRadio(body, "wings"); + expect(receivedEvents).toEqual([ + expectedResponseEvent("wings"), + ]); + }); + + it("sends several events when I click different options", () => { + 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 votes = []; + const body = newMPollBody(votes); + clickRadio(body, "wings"); + clickRadio(body, "italian"); + clickRadio(body, "poutine"); + expect(receivedEvents).toEqual([ + expectedResponseEvent("wings"), + expectedResponseEvent("italian"), + expectedResponseEvent("poutine"), + ]); + }); + + it("renders a poll with no votes", () => { + const votes = []; + const body = newMPollBody(votes); + expect(body).toMatchSnapshot(); + }); + + it("renders a poll with only non-local votes", () => { + const votes = [ + responseEvent("@op:example.com", "pizza", 12), + responseEvent("@op:example.com", [], 13), + responseEvent("@op:example.com", "italian", 14), + responseEvent("@st:example.com", "wings", 15), + responseEvent("@qr:example.com", "italian", 16), + ]; + const body = newMPollBody(votes); + expect(body).toMatchSnapshot(); + }); + + it("renders a poll with local, non-local and invalid votes", () => { + const votes = [ + responseEvent("@a:example.com", "pizza", 12), + responseEvent("@b:example.com", [], 13), + responseEvent("@c:example.com", "italian", 14), + responseEvent("@d:example.com", "italian", 14), + responseEvent("@e:example.com", "wings", 15), + responseEvent("@me:example.com", "italian", 16), + ]; + const body = newMPollBody(votes); + clickRadio(body, "italian"); + expect(body).toMatchSnapshot(); + }); +}); + +function newPollRelations(relationEvents: Array): Relations { + const pollRelations = new Relations( + "m.reference", "org.matrix.msc3381.poll.response", null); + for (const ev of relationEvents) { + pollRelations.addEvent(ev); + } + return pollRelations; +} + +function newMPollBody( + relationEvents: Array, + answers?: IPollAnswer[], +): ReactWrapper { + const pollRelations = new Relations( + "m.reference", "org.matrix.msc3381.poll.response", null); + for (const ev of relationEvents) { + pollRelations.addEvent(ev); + } + + return mount( { + expect(eventId).toBe("$mypoll"); + expect(relationType).toBe("m.reference"); + expect(eventType).toBe("org.matrix.msc3381.poll.response"); + return pollRelations; + } + } + />); +} + +function clickRadio(wrapper: ReactWrapper, value: string) { + wrapper.find(`StyledRadioButton[value="${value}"]`).simulate("click"); +} + +function votesCount(wrapper: ReactWrapper, value: string): string { + return wrapper.find( + `StyledRadioButton[value="${value}"] .mx_MPollBody_optionVoteCount`, + ).text(); +} + +function newPollStart(answers?: IPollAnswer[]): IPollContent { + if (!answers) { + answers = [ + { "id": "pizza", "org.matrix.msc1767.text": "Pizza" }, + { "id": "poutine", "org.matrix.msc1767.text": "Poutine" }, + { "id": "italian", "org.matrix.msc1767.text": "Italian" }, + { "id": "wings", "org.matrix.msc1767.text": "Wings" }, + ]; + } + + return { + "org.matrix.msc3381.poll.start": { + "question": { + "org.matrix.msc1767.text": "What should we order for the party?", + }, + "kind": "org.matrix.msc3381.poll.disclosed", + "answers": answers, + }, + "org.matrix.msc1767.text": "What should we order for the party?\n" + + "1. Pizza\n2. Poutine\n3. Italian\n4. Wings", + }; +} + +function badResponseEvent(): MatrixEvent { + return new MatrixEvent( + { + "event_id": nextId(), + "type": "org.matrix.msc3381.poll.response", + "content": { + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$mypoll", + }, + // Does not actually contain a response + }, + }, + ); +} + +function responseEvent( + sender = "@alice:example.com", + answers: string | Array = "italian", + ts = 0, +): MatrixEvent { + const ans = typeof answers === "string" ? [answers] : answers; + return new MatrixEvent( + { + "event_id": nextId(), + "room_id": "#myroom:example.com", + "origin_server_ts": ts, + "type": "org.matrix.msc3381.poll.response", + "sender": sender, + "content": { + "m.relates_to": { + "rel_type": "m.reference", + "event_id": "$mypoll", + }, + "org.matrix.msc3381.poll.response": { + "answers": ans, + }, + }, + }, + ); +} + +function expectedResponseEvent(answer: string) { + return { + "content": { + "org.matrix.msc3381.poll.response": { + "answers": [answer], + }, + "m.relates_to": { + "event_id": "$mypoll", + "rel_type": "m.reference", + }, + }, + "eventType": "org.matrix.msc3381.poll.response", + "roomId": "#myroom:example.com", + "txnId": undefined, + "callback": undefined, + }; +} + +let EVENT_ID = 0; +function nextId(): string { + EVENT_ID++; + return EVENT_ID.toString(); +} diff --git a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap new file mode 100644 index 0000000000..37689b2011 --- /dev/null +++ b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -0,0 +1,1000 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = ` + + +
+

+ What should we order for the party? +

+
+
+ +