mirror of
https://github.com/element-hq/element-web
synced 2024-11-26 19:26:04 +03:00
Display and send votes in polls (#7158)
Co-authored-by: Travis Ralston <travpc@gmail.com>
This commit is contained in:
parent
a156ba8be9
commit
d705fdd6e4
9 changed files with 1740 additions and 15 deletions
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<IBodyProps, IState> {
|
|||
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<IBodyProps, IState> {
|
|||
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() {
|
||||
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 <div className="mx_MPollBody">
|
||||
<h2>{ pollStart.question[TEXT_NODE_TYPE] }</h2>
|
||||
<h2>{ pollInfo.question[TEXT_NODE_TYPE] }</h2>
|
||||
<div className="mx_MPollBody_allOptions">
|
||||
{
|
||||
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 <div
|
||||
key={answer.id}
|
||||
className={classNames}
|
||||
|
@ -72,22 +198,116 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
onChange={this.onOptionSelected}
|
||||
>
|
||||
<div className="mx_MPollBody_optionVoteCount">
|
||||
{ _t("%(number)s votes", { number: 0 }) }
|
||||
{ _t("%(count)s votes", { count: answerVotes }) }
|
||||
</div>
|
||||
<div className="mx_MPollBody_optionText">
|
||||
{ answer[TEXT_NODE_TYPE] }
|
||||
</div>
|
||||
</StyledRadioButton>
|
||||
<div className="mx_MPollBody_popularityBackground">
|
||||
<div className="mx_MPollBody_popularityAmount" />
|
||||
<div
|
||||
className="mx_MPollBody_popularityAmount"
|
||||
style={{ "width": `${answerPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div className="mx_MPollBody_totalVotes">
|
||||
{ _t( "Based on %(total)s votes", { total: 0 } ) }
|
||||
{ _t( "Based on %(count)s votes", { count: totalVotes } ) }
|
||||
</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;
|
||||
}
|
||||
|
|
|
@ -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<IBodyProps, "onMessageAllowed"> {
|
||||
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
|
||||
overrideBodyTypes?: 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")
|
||||
|
@ -154,6 +158,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||
onMessageAllowed={this.onTileUpdate}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
mediaEventHelper={this.mediaHelper}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/> : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -990,7 +990,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
onHeightChanged={this.props.onHeightChanged}
|
||||
tileShape={this.props.tileShape}
|
||||
editState={this.props.editState}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
</div>,
|
||||
]);
|
||||
|
@ -1324,6 +1325,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
tileShape={this.props.tileShape}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
editState={this.props.editState}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
</div>,
|
||||
<a
|
||||
|
@ -1463,6 +1467,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
callEventGrouper={this.props.callEventGrouper}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>
|
||||
{ keyRequestInfo }
|
||||
{ actionBar }
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
476
test/components/views/messages/MPollBody-test.tsx
Normal file
476
test/components/views/messages/MPollBody-test.tsx
Normal 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();
|
||||
}
|
1000
test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap
Normal file
1000
test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue