Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Display and send votes in polls #7158

Merged
merged 10 commits into from
Nov 23, 2021
6 changes: 6 additions & 0 deletions res/css/views/messages/_MPollBody.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion src/components/views/messages/IBodyProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
242 changes: 231 additions & 11 deletions src/components/views/messages/MPollBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,174 @@ 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")
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 });
}

private onOptionSelected = (e: React.FormEvent<HTMLInputElement>): void => {
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}
Expand All @@ -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;
}
5 changes: 5 additions & 0 deletions src/components/views/messages/MessageEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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;
}
}
Loading