element-portable/src/components/views/messages/MPollBody.tsx
Eric Eastwood 2393510a7f
Fix getRelationsForEvent under TypeScript strict mode (#9558)
* Fix getRelationsForEvent tsc strictness

* Use shared type for GetRelationsForEvent

* Fix lint

* Add alternative type

* getRelationsForEvent is not required

* Relations are optional

* Reactions are optional

* We expect relations in these tests

* Add more protection if the eventID is not defined

* Allow null too

* Better test typing

* User ID is not necessary unless something is selected

* It's okay to [].includes(null)

* Null is as good as undefined here

* Null or undefined is good here

* We have some expectations for the tests

* The room and user can be undefined too

* Protec

* Reactions are optional

* Try match signatures

* Null or undefined

* More null or undefined

* Protec

* Fix typo (wrong variable)

* Remove optional params

See https://github.com/matrix-org/matrix-react-sdk/pull/9558#discussion_r1017515913

* Fix up last maaaaybe relevant lint

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2022-11-21 20:54:24 -07:00

768 lines
25 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 from 'react';
import classNames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { Relations, RelationsEvent } from 'matrix-js-sdk/src/models/relations';
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
import {
M_POLL_END,
M_POLL_KIND_DISCLOSED,
M_POLL_RESPONSE,
M_POLL_START,
NamespacedValue,
PollAnswerSubevent,
PollResponseEvent,
PollStartEvent,
} from "matrix-events-sdk";
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import { IBodyProps } from "./IBodyProps";
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import StyledRadioButton from '../elements/StyledRadioButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import ErrorDialog from '../dialogs/ErrorDialog';
import { GetRelationsForEvent } from "../rooms/EventTile";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IState {
selected?: string | null | undefined; // Which option was clicked by the local user
voteRelations: RelatedRelations; // Voting (response) events
endRelations: RelatedRelations; // Poll end events
}
export function createVoteRelations(
getRelationsForEvent: GetRelationsForEvent,
eventId: string,
) {
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,
matrixClient: MatrixClient,
getRelationsForEvent?: GetRelationsForEvent,
): string {
if (!getRelationsForEvent) {
return "";
}
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) => {
return poll.answers.find(a => a.id === answerId)?.text ?? "";
};
const voteRelations = createVoteRelations(getRelationsForEvent, pollEventId);
const relationsList: Relations[] = [];
const pollEndRelations = getRelationsForEvent(
pollEventId,
"m.reference",
M_POLL_END.name,
);
if (pollEndRelations) {
relationsList.push(pollEndRelations);
}
const pollEndAltRelations = getRelationsForEvent(
pollEventId,
"m.reference",
M_POLL_END.altName,
);
if (pollEndAltRelations) {
relationsList.push(pollEndAltRelations);
}
const endRelations = new RelatedRelations(relationsList);
const userVotes: Map<string, UserVote> = collectUserVotes(
allVotes(pollEvent, matrixClient, voteRelations, endRelations),
);
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,
getRelationsForEvent?: GetRelationsForEvent,
): boolean {
if (!getRelationsForEvent) {
return false;
}
const pollEventId = pollEvent.getId();
if (!pollEventId) {
logger.warn(
"isPollEnded: Poll event must have event ID in order to determine whether it has ended " +
"- assuming poll has not ended",
);
return false;
}
const roomId = pollEvent.getRoomId();
if (!roomId) {
logger.warn(
"isPollEnded: Poll event must have room ID in order to determine whether it has ended " +
"- assuming poll has not ended",
);
return false;
}
const roomCurrentState = matrixClient.getRoom(roomId)?.currentState;
function userCanRedact(endEvent: MatrixEvent) {
const endEventSender = endEvent.getSender();
return endEventSender && roomCurrentState && roomCurrentState.maySendRedactionForEvent(
pollEvent,
endEventSender,
);
}
const relationsList: Relations[] = [];
const pollEndRelations = getRelationsForEvent(
pollEventId,
"m.reference",
M_POLL_END.name,
);
if (pollEndRelations) {
relationsList.push(pollEndRelations);
}
const pollEndAltRelations = getRelationsForEvent(
pollEventId,
"m.reference",
M_POLL_END.altName,
);
if (pollEndAltRelations) {
relationsList.push(pollEndAltRelations);
}
const endRelations = new RelatedRelations(relationsList);
if (!endRelations) {
return false;
}
const authorisedRelations = endRelations.getRelations().filter(userCanRedact);
return authorisedRelations.length > 0;
}
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 {
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 {
Modal.createDialog(
PollCreateDialog,
{
room: MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
threadId: mxEvent.getThread()?.id ?? null,
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
private voteRelationsReceived = false;
private endRelationsReceived = false;
constructor(props: IBodyProps) {
super(props);
this.state = {
selected: null,
voteRelations: this.fetchVoteRelations(),
endRelations: this.fetchEndRelations(),
};
this.addListeners(this.state.voteRelations, this.state.endRelations);
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);
}
componentWillUnmount() {
this.props.mxEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);
this.removeListeners(this.state.voteRelations, this.state.endRelations);
}
private addListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations) {
if (voteRelations) {
voteRelations.on(RelationsEvent.Add, this.onRelationsChange);
voteRelations.on(RelationsEvent.Remove, this.onRelationsChange);
voteRelations.on(RelationsEvent.Redaction, this.onRelationsChange);
}
if (endRelations) {
endRelations.on(RelationsEvent.Add, this.onRelationsChange);
endRelations.on(RelationsEvent.Remove, this.onRelationsChange);
endRelations.on(RelationsEvent.Redaction, this.onRelationsChange);
}
}
private removeListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations) {
if (voteRelations) {
voteRelations.off(RelationsEvent.Add, this.onRelationsChange);
voteRelations.off(RelationsEvent.Remove, this.onRelationsChange);
voteRelations.off(RelationsEvent.Redaction, this.onRelationsChange);
}
if (endRelations) {
endRelations.off(RelationsEvent.Add, this.onRelationsChange);
endRelations.off(RelationsEvent.Remove, this.onRelationsChange);
endRelations.off(RelationsEvent.Redaction, this.onRelationsChange);
}
}
private onRelationsCreated = (relationType: string, eventType: string) => {
if (relationType !== "m.reference") {
return;
}
if (M_POLL_RESPONSE.matches(eventType)) {
this.voteRelationsReceived = true;
const newVoteRelations = this.fetchVoteRelations();
this.addListeners(newVoteRelations);
this.removeListeners(this.state.voteRelations);
this.setState({ voteRelations: newVoteRelations });
} else if (M_POLL_END.matches(eventType)) {
this.endRelationsReceived = true;
const newEndRelations = this.fetchEndRelations();
this.addListeners(newEndRelations);
this.removeListeners(this.state.endRelations);
this.setState({ endRelations: newEndRelations });
}
if (this.voteRelationsReceived && this.endRelationsReceived) {
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);
}
};
private onRelationsChange = () => {
// 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) {
if (this.isEnded()) {
return;
}
const userVotes = this.collectUserVotes();
const userId = this.context.getUserId();
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 });
}
private onOptionSelected = (e: React.FormEvent<HTMLInputElement>): void => {
this.selectOption(e.currentTarget.value);
};
private fetchVoteRelations(): RelatedRelations | null {
return this.fetchRelations(M_POLL_RESPONSE);
}
private fetchEndRelations(): RelatedRelations | null {
return this.fetchRelations(M_POLL_END);
}
private fetchRelations(eventType: NamespacedValue<string, string>): RelatedRelations | null {
if (this.props.getRelationsForEvent) {
const relationsList: Relations[] = [];
const eventId = this.props.mxEvent.getId();
if (!eventId) {
return null;
}
const relations = this.props.getRelationsForEvent(
eventId,
"m.reference",
eventType.name,
);
if (relations) {
relationsList.push(relations);
}
// If there is an alternatve experimental event type, also look for that
if (eventType.altName) {
const altRelations = this.props.getRelationsForEvent(
eventId,
"m.reference",
eventType.altName,
);
if (altRelations) {
relationsList.push(altRelations);
}
}
return new RelatedRelations(relationsList);
} else {
return null;
}
}
/**
* @returns userId -> UserVote
*/
private collectUserVotes(): Map<string, UserVote> {
return collectUserVotes(
allVotes(
this.props.mxEvent,
this.context,
this.state.voteRelations,
this.state.endRelations,
),
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() {
const newEvents: MatrixEvent[] = this.state.voteRelations.getRelations()
.filter(isPollResponse)
.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;
}
private isEnded(): boolean {
return isPollEnded(
this.props.mxEvent,
this.context,
this.props.getRelationsForEvent,
);
}
render() {
const poll = this.props.mxEvent.unstableExtensibleEvent as PollStartEvent;
if (!poll?.isEquivalentTo(M_POLL_START)) return null; // invalid
const ended = this.isEnded();
const pollId = this.props.mxEvent.getId();
const userVotes = this.collectUserVotes();
const votes = countVotes(userVotes, poll);
const totalVotes = this.totalVotes(votes);
const winCount = Math.max(...votes.values());
const userId = this.context.getUserId();
const myVote = userVotes?.get(userId!)?.answers[0];
const disclosed = M_POLL_KIND_DISCLOSED.matches(poll.kind.name);
// Disclosed: votes are hidden until I vote or the poll ends
// Undisclosed: votes are hidden until poll ends
const showResults = ended || (disclosed && myVote !== undefined);
let totalText: string;
if (ended) {
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>{ poll.question.text }{ editedSpan }</h2>
<div className="mx_MPollBody_allOptions">
{
poll.answers.map((answer: PollAnswerSubevent) => {
let answerVotes = 0;
let votesText = "";
if (showResults) {
answerVotes = votes.get(answer.id) ?? 0;
votesText = _t("%(count)s votes", { count: answerVotes });
}
const checked = (
(!ended && myVote === answer.id) ||
(ended && answerVotes === winCount)
);
const cls = classNames({
"mx_MPollBody_option": true,
"mx_MPollBody_option_checked": checked,
"mx_MPollBody_option_ended": ended,
});
const answerPercent = (
totalVotes === 0
? 0
: Math.round(100.0 * answerVotes / totalVotes)
);
return <div
key={answer.id}
className={cls}
onClick={() => this.selectOption(answer.id)}
>
{ (
ended
? <EndedPollOption
answer={answer}
checked={checked}
votesText={votesText} />
: <LivePollOption
pollId={pollId}
answer={answer}
checked={checked}
votesText={votesText}
onOptionSelected={this.onOptionSelected} />
) }
<div className="mx_MPollBody_popularityBackground">
<div
className="mx_MPollBody_popularityAmount"
style={{ "width": `${answerPercent}%` }}
/>
</div>
</div>;
})
}
</div>
<div className="mx_MPollBody_totalVotes">
{ totalText }
</div>
</div>;
}
}
interface IEndedPollOptionProps {
answer: PollAnswerSubevent;
checked: boolean;
votesText: string;
}
function EndedPollOption(props: IEndedPollOptionProps) {
const cls = classNames({
"mx_MPollBody_endedOption": true,
"mx_MPollBody_endedOptionWinner": props.checked,
});
return <div className={cls} data-value={props.answer.id}>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ props.answer.text }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ props.votesText }
</div>
</div>
</div>;
}
interface ILivePollOptionProps {
pollId: string;
answer: PollAnswerSubevent;
checked: boolean;
votesText: string;
onOptionSelected: (e: React.FormEvent<HTMLInputElement>) => void;
}
function LivePollOption(props: ILivePollOptionProps) {
return <StyledRadioButton
className="mx_MPollBody_live-option"
name={`poll_answer_select-${props.pollId}`}
value={props.answer.id}
checked={props.checked}
onChange={props.onOptionSelected}
>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ props.answer.text }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ props.votesText }
</div>
</div>
</StyledRadioButton>;
}
export class UserVote {
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(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
voteRelations: RelatedRelations,
endRelations: RelatedRelations,
): Array<UserVote> {
const endTs = pollEndTs(pollEvent, matrixClient, endRelations);
function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean {
// From MSC3381:
// "Votes sent on or before the end event's timestamp are valid votes"
return (
endTs === null ||
responseEvent.getTs() <= endTs
);
}
if (voteRelations) {
return voteRelations.getRelations()
.filter(isPollResponse)
.filter(isOnOrBeforeEnd)
.map(userResponseFromPollResponseEvent);
} else {
return [];
}
}
/**
* Returns the earliest timestamp from the supplied list of end_poll events
* or null if there are no authorised events.
*/
export function pollEndTs(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
endRelations: RelatedRelations,
): number | null {
if (!endRelations) {
return null;
}
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
function userCanRedact(endEvent: MatrixEvent) {
return roomCurrentState.maySendRedactionForEvent(
pollEvent,
endEvent.getSender(),
);
}
const tss: number[] = (
endRelations
.getRelations()
.filter(userCanRedact)
.map((evt: MatrixEvent) => evt.getTs())
);
if (tss.length === 0) {
return null;
} else {
return Math.min(...tss);
}
}
function isPollResponse(responseEvent: MatrixEvent): boolean {
return responseEvent.unstableExtensibleEvent?.isEquivalentTo(M_POLL_RESPONSE);
}
/**
* 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
*/
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;
}
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;
}