Display and send votes in polls (#7158)

Co-authored-by: Travis Ralston <travpc@gmail.com>
This commit is contained in:
Andy Balaam 2021-11-23 10:27:44 +00:00 committed by GitHub
parent a156ba8be9
commit d705fdd6e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1740 additions and 15 deletions

View file

@ -86,6 +86,12 @@ limitations under the License.
.mx_MPollBody_option_checked { .mx_MPollBody_option_checked {
border-color: $accent; border-color: $accent;
.mx_MPollBody_popularityBackground {
.mx_MPollBody_popularityAmount {
background-color: $accent;
}
}
} }
.mx_StyledRadioButton_checked input[type="radio"] + div { .mx_StyledRadioButton_checked input[type="radio"] + div {

View file

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { TileShape } from "../rooms/EventTile";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { Relations } from "matrix-js-sdk/src/models/relations";
export interface IBodyProps { export interface IBodyProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -41,4 +42,7 @@ export interface IBodyProps {
onMessageAllowed: () => void; // TODO: Docs onMessageAllowed: () => void; // TODO: Docs
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
mediaEventHelper: MediaEventHelper; mediaEventHelper: MediaEventHelper;
// helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
} }

View file

@ -18,14 +18,24 @@ import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IBodyProps } from "./IBodyProps"; 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 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 // TODO: [andyb] Use extensible events library when ready
const TEXT_NODE_TYPE = "org.matrix.msc1767.text"; const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
interface IState { interface IState {
selected?: string; selected?: string;
pollRelations: Relations;
} }
@replaceableComponent("views.messages.MPollBody") @replaceableComponent("views.messages.MPollBody")
@ -33,12 +43,88 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
constructor(props: IBodyProps) { constructor(props: IBodyProps) {
super(props); super(props);
this.state = { const pollRelations = this.fetchPollRelations();
selected: null, 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) { 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 }); this.setState({ selected: answerId });
} }
@ -46,20 +132,60 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
this.selectOption(e.currentTarget.value); 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<string, number> {
return countVotes(
collectUserVotes(allVotes(this.state.pollRelations), this.state.selected),
this.props.mxEvent.getContent(),
);
}
private totalVotes(collectedVotes: Map<string, number>): number {
let sum = 0;
for (const v of collectedVotes.values()) {
sum += v;
}
return sum;
}
render() { render() {
const pollStart: IPollContent = const pollStart: IPollContent = this.props.mxEvent.getContent();
this.props.mxEvent.getContent()[POLL_START_EVENT_TYPE.name]; 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 pollId = this.props.mxEvent.getId();
const votes = this.collectVotes();
const totalVotes = this.totalVotes(votes);
return <div className="mx_MPollBody"> return <div className="mx_MPollBody">
<h2>{ pollStart.question[TEXT_NODE_TYPE] }</h2> <h2>{ pollInfo.question[TEXT_NODE_TYPE] }</h2>
<div className="mx_MPollBody_allOptions"> <div className="mx_MPollBody_allOptions">
{ {
pollStart.answers.map((answer: IPollAnswer) => { pollInfo.answers.map((answer: IPollAnswer) => {
const checked = this.state.selected === answer.id; const checked = this.state.selected === answer.id;
const classNames = `mx_MPollBody_option${ const classNames = `mx_MPollBody_option${
checked ? " mx_MPollBody_option_checked": "" checked ? " mx_MPollBody_option_checked": ""
}`; }`;
const answerVotes = votes.get(answer.id) ?? 0;
const answerPercent = Math.round(
100.0 * answerVotes / totalVotes);
return <div return <div
key={answer.id} key={answer.id}
className={classNames} className={classNames}
@ -72,22 +198,116 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
onChange={this.onOptionSelected} onChange={this.onOptionSelected}
> >
<div className="mx_MPollBody_optionVoteCount"> <div className="mx_MPollBody_optionVoteCount">
{ _t("%(number)s votes", { number: 0 }) } { _t("%(count)s votes", { count: answerVotes }) }
</div> </div>
<div className="mx_MPollBody_optionText"> <div className="mx_MPollBody_optionText">
{ answer[TEXT_NODE_TYPE] } { answer[TEXT_NODE_TYPE] }
</div> </div>
</StyledRadioButton> </StyledRadioButton>
<div className="mx_MPollBody_popularityBackground"> <div className="mx_MPollBody_popularityBackground">
<div className="mx_MPollBody_popularityAmount" /> <div
className="mx_MPollBody_popularityAmount"
style={{ "width": `${answerPercent}%` }}
/>
</div> </div>
</div>; </div>;
}) })
} }
</div> </div>
<div className="mx_MPollBody_totalVotes"> <div className="mx_MPollBody_totalVotes">
{ _t( "Based on %(total)s votes", { total: 0 } ) } { _t( "Based on %(count)s votes", { count: totalVotes } ) }
</div> </div>
</div>; </div>;
} }
} }
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<UserVote> {
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<UserVote>,
selected?: string,
): Map<string, UserVote> {
const userVotes: Map<string, UserVote> = 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<string, UserVote>,
pollStart: IPollContent,
): Map<string, number> {
const collected = new Map<string, number>();
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;
}

View file

@ -28,12 +28,16 @@ import { ReactAnyComponent } from "../../../@types/common";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { POLL_START_EVENT_TYPE } from '../../../polls/consts'; import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
import { Relations } from 'matrix-js-sdk/src/models/relations';
// onMessageAllowed is handled internally // onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> { interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes?: Record<string, React.Component>; overrideBodyTypes?: Record<string, React.Component>;
overrideEventTypes?: Record<string, React.Component>; overrideEventTypes?: Record<string, React.Component>;
// helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
} }
@replaceableComponent("views.messages.MessageEvent") @replaceableComponent("views.messages.MessageEvent")
@ -154,6 +158,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
onMessageAllowed={this.onTileUpdate} onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.mediaHelper} mediaEventHelper={this.mediaHelper}
getRelationsForEvent={this.props.getRelationsForEvent}
/> : null; /> : null;
} }
} }

View file

@ -990,7 +990,7 @@ export default class EventTile extends React.Component<IProps, IState> {
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); 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") { if (relationType !== "m.annotation" || eventType !== "m.reaction") {
return; return;
} }
@ -1286,6 +1286,7 @@ export default class EventTile extends React.Component<IProps, IState> {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
editState={this.props.editState} editState={this.props.editState}
getRelationsForEvent={this.props.getRelationsForEvent}
/> />
</div>, </div>,
]); ]);
@ -1324,6 +1325,7 @@ export default class EventTile extends React.Component<IProps, IState> {
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
editState={this.props.editState} editState={this.props.editState}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
getRelationsForEvent={this.props.getRelationsForEvent}
/> />
{ actionBar } { actionBar }
{ timestamp } { timestamp }
@ -1373,6 +1375,7 @@ export default class EventTile extends React.Component<IProps, IState> {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
callEventGrouper={this.props.callEventGrouper} callEventGrouper={this.props.callEventGrouper}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
getRelationsForEvent={this.props.getRelationsForEvent}
/> />
{ keyRequestInfo } { keyRequestInfo }
{ this.renderThreadPanelSummary() } { this.renderThreadPanelSummary() }
@ -1410,6 +1413,7 @@ export default class EventTile extends React.Component<IProps, IState> {
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
editState={this.props.editState} editState={this.props.editState}
getRelationsForEvent={this.props.getRelationsForEvent}
/> />
</div>, </div>,
<a <a
@ -1463,6 +1467,7 @@ export default class EventTile extends React.Component<IProps, IState> {
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
callEventGrouper={this.props.callEventGrouper} callEventGrouper={this.props.callEventGrouper}
getRelationsForEvent={this.props.getRelationsForEvent}
/> />
{ keyRequestInfo } { keyRequestInfo }
{ actionBar } { actionBar }

View file

@ -2045,8 +2045,10 @@
"Declining …": "Declining …", "Declining …": "Declining …",
"%(name)s wants to verify": "%(name)s wants to verify", "%(name)s wants to verify": "%(name)s wants to verify",
"You sent a verification request": "You sent a verification request", "You sent a verification request": "You sent a verification request",
"%(number)s votes": "%(number)s votes", "%(count)s votes|other": "%(count)s votes",
"Based on %(total)s votes": "Based on %(total)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 decrypting video": "Error decrypting video",
"Error processing voice message": "Error processing voice message", "Error processing voice message": "Error processing voice message",
"Add reaction": "Add reaction", "Add reaction": "Add reaction",

View file

@ -18,6 +18,7 @@ import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
import { IContent } from "matrix-js-sdk/src/models/event"; 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_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_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"); 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; [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 { export function makePollContent(question: string, answers: string[], kind: string): IPollContent {
question = question.trim(); question = question.trim();
answers = answers.map(a => a.trim()).filter(a => !!a); answers = answers.map(a => a.trim()).filter(a => !!a);

View file

@ -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<ISendEventResponse> => {
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<ISendEventResponse> => {
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<ISendEventResponse> => {
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<MatrixEvent>): 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<MatrixEvent>,
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(<MPollBody
mxEvent={new MatrixEvent({
"event_id": "$mypoll",
"room_id": "#myroom:example.com",
"content": newPollStart(answers),
})}
getRelationsForEvent={
(eventId: string, relationType: string, eventType: string) => {
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<string> = "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();
}

File diff suppressed because it is too large Load diff