430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
/*
|
|
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, { ReactNode } from "react";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
|
|
import { Relations } from "matrix-js-sdk/src/models/relations";
|
|
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls";
|
|
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
|
|
import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
|
|
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
|
|
import { Poll, PollEvent } from "matrix-js-sdk/src/models/poll";
|
|
|
|
import { _t } from "../../../languageHandler";
|
|
import Modal from "../../../Modal";
|
|
import { IBodyProps } from "./IBodyProps";
|
|
import { formatCommaSeparatedList } from "../../../utils/FormattingUtils";
|
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
|
import { GetRelationsForEvent } from "../rooms/EventTile";
|
|
import PollCreateDialog from "../elements/PollCreateDialog";
|
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
|
import Spinner from "../elements/Spinner";
|
|
import { PollOption } from "../polls/PollOption";
|
|
|
|
interface IState {
|
|
poll?: Poll;
|
|
// poll instance has fetched at least one page of responses
|
|
pollInitialised: boolean;
|
|
selected?: string | null | undefined; // Which option was clicked by the local user
|
|
voteRelations?: Relations; // Voting (response) events
|
|
}
|
|
|
|
export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations {
|
|
const relationsList: Relations[] = [];
|
|
|
|
const pollResponseRelations = getRelationsForEvent(eventId, "m.reference", M_POLL_RESPONSE.name);
|
|
if (pollResponseRelations) {
|
|
relationsList.push(pollResponseRelations);
|
|
}
|
|
|
|
const pollResposnseAltRelations = getRelationsForEvent(eventId, "m.reference", M_POLL_RESPONSE.altName);
|
|
if (pollResposnseAltRelations) {
|
|
relationsList.push(pollResposnseAltRelations);
|
|
}
|
|
|
|
return new RelatedRelations(relationsList);
|
|
}
|
|
|
|
export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations): string {
|
|
const pollEventId = pollEvent.getId();
|
|
if (!pollEventId) {
|
|
logger.warn(
|
|
"findTopAnswer: Poll event needs an event ID to fetch relations in order to determine " +
|
|
"the top answer - assuming no best answer",
|
|
);
|
|
return "";
|
|
}
|
|
|
|
const poll = pollEvent.unstableExtensibleEvent as PollStartEvent;
|
|
if (!poll?.isEquivalentTo(M_POLL_START)) {
|
|
logger.warn("Failed to parse poll to determine top answer - assuming no best answer");
|
|
return "";
|
|
}
|
|
|
|
const findAnswerText = (answerId: string): string => {
|
|
return poll.answers.find((a) => a.id === answerId)?.text ?? "";
|
|
};
|
|
|
|
const userVotes: Map<string, UserVote> = collectUserVotes(allVotes(voteRelations));
|
|
|
|
const votes: Map<string, number> = countVotes(userVotes, poll);
|
|
const highestScore: number = Math.max(...votes.values());
|
|
|
|
const bestAnswerIds: string[] = [];
|
|
for (const [answerId, score] of votes) {
|
|
if (score == highestScore) {
|
|
bestAnswerIds.push(answerId);
|
|
}
|
|
}
|
|
|
|
const bestAnswerTexts = bestAnswerIds.map(findAnswerText);
|
|
|
|
return formatCommaSeparatedList(bestAnswerTexts, 3);
|
|
}
|
|
|
|
export function isPollEnded(pollEvent: MatrixEvent, matrixClient: MatrixClient): boolean {
|
|
const room = matrixClient.getRoom(pollEvent.getRoomId());
|
|
const poll = room?.polls.get(pollEvent.getId()!);
|
|
if (!poll || poll.isFetchingResponses) {
|
|
return false;
|
|
}
|
|
return poll.isEnded;
|
|
}
|
|
|
|
export function pollAlreadyHasVotes(mxEvent: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): boolean {
|
|
if (!getRelationsForEvent) return false;
|
|
|
|
const eventId = mxEvent.getId();
|
|
if (!eventId) return false;
|
|
|
|
const voteRelations = createVoteRelations(getRelationsForEvent, eventId);
|
|
return voteRelations.getRelations().length > 0;
|
|
}
|
|
|
|
export function launchPollEditor(mxEvent: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): void {
|
|
const room = MatrixClientPeg.safeGet().getRoom(mxEvent.getRoomId());
|
|
if (pollAlreadyHasVotes(mxEvent, getRelationsForEvent)) {
|
|
Modal.createDialog(ErrorDialog, {
|
|
title: _t("Can't edit poll"),
|
|
description: _t("Sorry, you can't edit a poll after votes have been cast."),
|
|
});
|
|
} else if (room) {
|
|
Modal.createDialog(
|
|
PollCreateDialog,
|
|
{
|
|
room,
|
|
threadId: mxEvent.getThread()?.id,
|
|
editingMxEvent: mxEvent,
|
|
},
|
|
"mx_CompoundDialog",
|
|
false, // isPriorityModal
|
|
true, // isStaticModal
|
|
);
|
|
}
|
|
}
|
|
|
|
export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|
public static contextType = MatrixClientContext;
|
|
public context!: React.ContextType<typeof MatrixClientContext>;
|
|
private seenEventIds: string[] = []; // Events we have already seen
|
|
|
|
public constructor(props: IBodyProps) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
selected: null,
|
|
pollInitialised: false,
|
|
};
|
|
}
|
|
|
|
public componentDidMount(): void {
|
|
const room = this.context?.getRoom(this.props.mxEvent.getRoomId());
|
|
const poll = room?.polls.get(this.props.mxEvent.getId()!);
|
|
if (poll) {
|
|
this.setPollInstance(poll);
|
|
} else {
|
|
room?.on(PollEvent.New, this.setPollInstance.bind(this));
|
|
}
|
|
}
|
|
|
|
public componentWillUnmount(): void {
|
|
this.removeListeners();
|
|
}
|
|
|
|
private async setPollInstance(poll: Poll): Promise<void> {
|
|
if (poll.pollId !== this.props.mxEvent.getId()) {
|
|
return;
|
|
}
|
|
this.setState({ poll }, () => {
|
|
this.addListeners();
|
|
});
|
|
const responses = await poll.getResponses();
|
|
const voteRelations = responses;
|
|
|
|
this.setState({ pollInitialised: true, voteRelations });
|
|
}
|
|
|
|
private addListeners(): void {
|
|
this.state.poll?.on(PollEvent.Responses, this.onResponsesChange);
|
|
this.state.poll?.on(PollEvent.End, this.onRelationsChange);
|
|
this.state.poll?.on(PollEvent.UndecryptableRelations, this.render.bind(this));
|
|
}
|
|
|
|
private removeListeners(): void {
|
|
if (this.state.poll) {
|
|
this.state.poll.off(PollEvent.Responses, this.onResponsesChange);
|
|
this.state.poll.off(PollEvent.End, this.onRelationsChange);
|
|
this.state.poll.off(PollEvent.UndecryptableRelations, this.render.bind(this));
|
|
}
|
|
}
|
|
|
|
private onResponsesChange = (responses: Relations): void => {
|
|
this.setState({ voteRelations: responses });
|
|
this.onRelationsChange();
|
|
};
|
|
|
|
private onRelationsChange = (): void => {
|
|
// We hold Relations in our state, and they changed under us.
|
|
// Check whether we should delete our selection, and then
|
|
// re-render.
|
|
// Note: re-rendering is a side effect of unselectIfNewEventFromMe().
|
|
this.unselectIfNewEventFromMe();
|
|
};
|
|
|
|
private selectOption(answerId: string): void {
|
|
if (this.state.poll?.isEnded) {
|
|
return;
|
|
}
|
|
const userVotes = this.collectUserVotes();
|
|
const userId = this.context.getSafeUserId();
|
|
const myVote = userVotes.get(userId)?.answers[0];
|
|
if (answerId === myVote) {
|
|
return;
|
|
}
|
|
|
|
const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()!).serialize();
|
|
|
|
this.context.sendEvent(this.props.mxEvent.getRoomId()!, response.type, response.content).catch((e: any) => {
|
|
console.error("Failed to submit poll response event:", e);
|
|
|
|
Modal.createDialog(ErrorDialog, {
|
|
title: _t("Vote not registered"),
|
|
description: _t("Sorry, your vote was not registered. Please try again."),
|
|
});
|
|
});
|
|
|
|
this.setState({ selected: answerId });
|
|
}
|
|
|
|
/**
|
|
* @returns userId -> UserVote
|
|
*/
|
|
private collectUserVotes(): Map<string, UserVote> {
|
|
if (!this.state.voteRelations || !this.context) {
|
|
return new Map<string, UserVote>();
|
|
}
|
|
return collectUserVotes(allVotes(this.state.voteRelations), this.context.getUserId(), this.state.selected);
|
|
}
|
|
|
|
/**
|
|
* If we've just received a new event that we hadn't seen
|
|
* before, and that event is me voting (e.g. from a different
|
|
* device) then forget when the local user selected.
|
|
*
|
|
* Either way, calls setState to update our list of events we
|
|
* have already seen.
|
|
*/
|
|
private unselectIfNewEventFromMe(): void {
|
|
const relations = this.state.voteRelations?.getRelations() || [];
|
|
const newEvents: MatrixEvent[] = relations.filter(
|
|
(mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!),
|
|
);
|
|
let newSelected = this.state.selected;
|
|
|
|
if (newEvents.length > 0) {
|
|
for (const mxEvent of newEvents) {
|
|
if (mxEvent.getSender() === this.context.getUserId()) {
|
|
newSelected = null;
|
|
}
|
|
}
|
|
}
|
|
const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()!);
|
|
this.seenEventIds = this.seenEventIds.concat(newEventIds);
|
|
this.setState({ selected: newSelected });
|
|
}
|
|
|
|
private totalVotes(collectedVotes: Map<string, number>): number {
|
|
let sum = 0;
|
|
for (const v of collectedVotes.values()) {
|
|
sum += v;
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
public render(): ReactNode {
|
|
const { poll, pollInitialised } = this.state;
|
|
if (!poll?.pollEvent) {
|
|
return null;
|
|
}
|
|
|
|
const pollEvent = poll.pollEvent;
|
|
|
|
const pollId = this.props.mxEvent.getId()!;
|
|
const isFetchingResponses = !pollInitialised || poll.isFetchingResponses;
|
|
const userVotes = this.collectUserVotes();
|
|
const votes = countVotes(userVotes, pollEvent);
|
|
const totalVotes = this.totalVotes(votes);
|
|
const winCount = Math.max(...votes.values());
|
|
const userId = this.context.getSafeUserId();
|
|
const myVote = userVotes?.get(userId)?.answers[0];
|
|
const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name);
|
|
|
|
// Disclosed: votes are hidden until I vote or the poll ends
|
|
// Undisclosed: votes are hidden until poll ends
|
|
const showResults = poll.isEnded || (disclosed && myVote !== undefined);
|
|
|
|
let totalText: string;
|
|
if (showResults && poll.undecryptableRelationsCount) {
|
|
totalText = _t("Due to decryption errors, some votes may not be counted");
|
|
} else if (poll.isEnded) {
|
|
totalText = _t("Final result based on %(count)s votes", { count: totalVotes });
|
|
} else if (!disclosed) {
|
|
totalText = _t("Results will be visible when the poll is ended");
|
|
} else if (myVote === undefined) {
|
|
if (totalVotes === 0) {
|
|
totalText = _t("No votes cast");
|
|
} else {
|
|
totalText = _t("%(count)s votes cast. Vote to see the results", { count: totalVotes });
|
|
}
|
|
} else {
|
|
totalText = _t("Based on %(count)s votes", { count: totalVotes });
|
|
}
|
|
|
|
const editedSpan = this.props.mxEvent.replacingEvent() ? (
|
|
<span className="mx_MPollBody_edited"> ({_t("edited")})</span>
|
|
) : null;
|
|
|
|
return (
|
|
<div className="mx_MPollBody">
|
|
<h2 data-testid="pollQuestion">
|
|
{pollEvent.question.text}
|
|
{editedSpan}
|
|
</h2>
|
|
<div className="mx_MPollBody_allOptions">
|
|
{pollEvent.answers.map((answer: PollAnswerSubevent) => {
|
|
let answerVotes = 0;
|
|
|
|
if (showResults) {
|
|
answerVotes = votes.get(answer.id) ?? 0;
|
|
}
|
|
|
|
const checked =
|
|
(!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount);
|
|
|
|
return (
|
|
<PollOption
|
|
key={answer.id}
|
|
pollId={pollId}
|
|
answer={answer}
|
|
isChecked={checked}
|
|
isEnded={poll.isEnded}
|
|
voteCount={answerVotes}
|
|
totalVoteCount={totalVotes}
|
|
displayVoteCount={showResults}
|
|
onOptionSelected={this.selectOption.bind(this)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div data-testid="totalVotes" className="mx_MPollBody_totalVotes">
|
|
{totalText}
|
|
{isFetchingResponses && <Spinner w={16} h={16} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
export class UserVote {
|
|
public constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {}
|
|
}
|
|
|
|
function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
|
|
const response = event.unstableExtensibleEvent as PollResponseEvent;
|
|
if (!response?.isEquivalentTo(M_POLL_RESPONSE)) {
|
|
throw new Error("Failed to parse Poll Response Event to determine user response");
|
|
}
|
|
|
|
return new UserVote(event.getTs(), event.getSender()!, response.answerIds);
|
|
}
|
|
|
|
export function allVotes(voteRelations: Relations): Array<UserVote> {
|
|
if (voteRelations) {
|
|
return voteRelations.getRelations().map(userResponseFromPollResponseEvent);
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Figure out the correct vote for each user.
|
|
* @param userResponses current vote responses in the poll
|
|
* @param {string?} userId The userId for which the `selected` option will apply to.
|
|
* Should be set to the current user ID.
|
|
* @param {string?} selected Local echo selected option for the userId
|
|
* @returns a Map of user ID to their vote info
|
|
*/
|
|
export function collectUserVotes(
|
|
userResponses: Array<UserVote>,
|
|
userId?: string | null | undefined,
|
|
selected?: string | null | undefined,
|
|
): 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 && userId) {
|
|
userVotes.set(userId, new UserVote(0, userId, [selected]));
|
|
}
|
|
|
|
return userVotes;
|
|
}
|
|
|
|
export function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, number> {
|
|
const collected = new Map<string, number>();
|
|
|
|
for (const response of userVotes.values()) {
|
|
const tempResponse = PollResponseEvent.from(response.answers, "$irrelevant");
|
|
tempResponse.validateAgainst(pollStart);
|
|
if (!tempResponse.spoiled) {
|
|
for (const answerId of tempResponse.answerIds) {
|
|
if (collected.has(answerId)) {
|
|
collected.set(answerId, collected.get(answerId)! + 1);
|
|
} else {
|
|
collected.set(answerId, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return collected;
|
|
}
|