Allow ending polls (#7305)
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
697b5d28b3
commit
2b52e17a80
12 changed files with 2814 additions and 680 deletions
|
@ -41,6 +41,10 @@ import { IPosition, ChevronFace } from '../../structures/ContextMenu';
|
|||
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
|
||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
|
||||
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
|
||||
import EndPollDialog from '../dialogs/EndPollDialog';
|
||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
import { isPollEnded } from '../messages/MPollBody';
|
||||
|
||||
export function canCancel(eventStatus: EventStatus): boolean {
|
||||
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
|
||||
|
@ -68,6 +72,11 @@ interface IProps extends IPosition {
|
|||
onFinished(): void;
|
||||
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
|
||||
onCloseDialog?(): void;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -123,6 +132,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
private canEndPoll(mxEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
mxEvent.getType() === POLL_START_EVENT_TYPE.name &&
|
||||
this.state.canRedact &&
|
||||
!isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent)
|
||||
);
|
||||
}
|
||||
|
||||
private onResendReactionsClick = (): void => {
|
||||
for (const reaction of this.getUnsentReactions()) {
|
||||
Resend.resend(reaction);
|
||||
|
@ -215,6 +232,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
this.closeMenu();
|
||||
};
|
||||
|
||||
private onEndPollClick = (): void => {
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
Modal.createTrackedDialog('End Poll', '', EndPollDialog, {
|
||||
matrixClient,
|
||||
event: this.props.mxEvent,
|
||||
getRelationsForEvent: this.props.getRelationsForEvent,
|
||||
}, 'mx_Dialog_endPoll');
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
@ -250,6 +277,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
const eventStatus = mxEvent.status;
|
||||
const unsentReactionsCount = this.getUnsentReactions().length;
|
||||
|
||||
let endPollButton: JSX.Element;
|
||||
let resendReactionsButton: JSX.Element;
|
||||
let redactButton: JSX.Element;
|
||||
let forwardButton: JSX.Element;
|
||||
|
@ -345,6 +373,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
/>
|
||||
);
|
||||
|
||||
if (this.canEndPoll(mxEvent)) {
|
||||
endPollButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconEndPoll"
|
||||
label={_t("End Poll")}
|
||||
onClick={this.onEndPollClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.eventTileOps) { // this event is rendered using TextualBody
|
||||
quoteButton = (
|
||||
<IconizedContextMenuOption
|
||||
|
@ -415,6 +453,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
label={_t("View in room")}
|
||||
onClick={this.viewInRoom}
|
||||
/> }
|
||||
{ endPollButton }
|
||||
{ quoteButton }
|
||||
{ forwardButton }
|
||||
{ pinButton }
|
||||
|
|
103
src/components/views/dialogs/EndPollDialog.tsx
Normal file
103
src/components/views/dialogs/EndPollDialog.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import { IPollEndContent, POLL_END_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts";
|
||||
import { findTopAnswer } from "../messages/MPollBody";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "./ErrorDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
event: MatrixEvent;
|
||||
onFinished: (success: boolean) => void;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
export default class EndPollDialog extends React.Component<IProps> {
|
||||
private onFinished = (endPoll: boolean) => {
|
||||
const topAnswer = findTopAnswer(
|
||||
this.props.event,
|
||||
this.props.matrixClient,
|
||||
this.props.getRelationsForEvent,
|
||||
);
|
||||
|
||||
const message = (
|
||||
(topAnswer === "")
|
||||
? _t("The poll has ended. No votes were cast.")
|
||||
: _t(
|
||||
"The poll has ended. Top answer: %(topAnswer)s",
|
||||
{ topAnswer },
|
||||
)
|
||||
);
|
||||
|
||||
if (endPoll) {
|
||||
const endContent: IPollEndContent = {
|
||||
[POLL_END_EVENT_TYPE.name]: {},
|
||||
"m.relates_to": {
|
||||
"event_id": this.props.event.getId(),
|
||||
"rel_type": "m.reference",
|
||||
},
|
||||
[TEXT_NODE_TYPE.name]: message,
|
||||
};
|
||||
|
||||
this.props.matrixClient.sendEvent(
|
||||
this.props.event.getRoomId(), POLL_END_EVENT_TYPE.name, endContent,
|
||||
).catch((e: any) => {
|
||||
console.error("Failed to submit poll response event:", e);
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to end poll',
|
||||
'',
|
||||
ErrorDialog,
|
||||
{
|
||||
title: _t("Failed to end poll"),
|
||||
description: _t(
|
||||
"Sorry, the poll did not end. Please try again."),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
this.props.onFinished(endPoll);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<QuestionDialog
|
||||
title={_t("End Poll")}
|
||||
description={
|
||||
_t(
|
||||
"Are you sure you want to end this poll? " +
|
||||
"This will show the final results of the poll and " +
|
||||
"stop people from being able to vote.",
|
||||
)
|
||||
}
|
||||
button={_t("End Poll")}
|
||||
onFinished={(endPoll: boolean) => this.onFinished(endPoll)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,29 +15,125 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Modal from '../../../Modal';
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import {
|
||||
IPollAnswer,
|
||||
IPollContent,
|
||||
IPollResponse,
|
||||
IPollResponseContent,
|
||||
POLL_END_EVENT_TYPE,
|
||||
POLL_RESPONSE_EVENT_TYPE,
|
||||
POLL_START_EVENT_TYPE,
|
||||
TEXT_NODE_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 MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import ErrorDialog from '../dialogs/ErrorDialog';
|
||||
|
||||
// TODO: [andyb] Use extensible events library when ready
|
||||
const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||
|
||||
interface IState {
|
||||
selected?: string; // Which option was clicked by the local user
|
||||
pollRelations: Relations; // Allows us to access voting events
|
||||
voteRelations: Relations; // Voting (response) events
|
||||
endRelations: Relations; // Poll end events
|
||||
}
|
||||
|
||||
export function findTopAnswer(
|
||||
pollEvent: MatrixEvent,
|
||||
matrixClient: MatrixClient,
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations,
|
||||
): string {
|
||||
if (!getRelationsForEvent) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pollContents: IPollContent = pollEvent.getContent();
|
||||
|
||||
const findAnswerText = (answerId: string) => {
|
||||
for (const answer of pollContents[POLL_START_EVENT_TYPE.name].answers) {
|
||||
if (answer.id == answerId) {
|
||||
return answer[TEXT_NODE_TYPE.name];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const voteRelations: Relations = getRelationsForEvent(
|
||||
pollEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_RESPONSE_EVENT_TYPE.name,
|
||||
);
|
||||
|
||||
const endRelations: Relations = getRelationsForEvent(
|
||||
pollEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_END_EVENT_TYPE.name,
|
||||
);
|
||||
|
||||
const userVotes: Map<string, UserVote> = collectUserVotes(
|
||||
allVotes(pollEvent, matrixClient, voteRelations, endRelations),
|
||||
matrixClient.getUserId(),
|
||||
null,
|
||||
);
|
||||
|
||||
const votes: Map<string, number> = countVotes(userVotes, pollEvent.getContent());
|
||||
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?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations,
|
||||
): boolean {
|
||||
if (!getRelationsForEvent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
|
||||
function userCanRedact(endEvent: MatrixEvent) {
|
||||
return roomCurrentState.maySendRedactionForEvent(
|
||||
pollEvent,
|
||||
endEvent.getSender(),
|
||||
);
|
||||
}
|
||||
|
||||
const endRelations = getRelationsForEvent(
|
||||
pollEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_END_EVENT_TYPE.name,
|
||||
);
|
||||
|
||||
if (!endRelations) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authorisedRelations = endRelations.getRelations().filter(userCanRedact);
|
||||
|
||||
return authorisedRelations.length > 0;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MPollBody")
|
||||
|
@ -45,60 +141,83 @@ 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,
|
||||
pollRelations: this.fetchPollRelations(),
|
||||
voteRelations: this.fetchVoteRelations(),
|
||||
endRelations: this.fetchEndRelations(),
|
||||
};
|
||||
|
||||
this.addListeners(this.state.pollRelations);
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onPollRelationsCreated);
|
||||
this.addListeners(this.state.voteRelations, this.state.endRelations);
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onRelationsCreated);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.mxEvent.off("Event.relationsCreated", this.onPollRelationsCreated);
|
||||
this.removeListeners(this.state.pollRelations);
|
||||
this.props.mxEvent.off("Event.relationsCreated", this.onRelationsCreated);
|
||||
this.removeListeners(this.state.voteRelations, this.state.endRelations);
|
||||
}
|
||||
|
||||
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 addListeners(voteRelations?: Relations, endRelations?: Relations) {
|
||||
if (voteRelations) {
|
||||
voteRelations.on("Relations.add", this.onRelationsChange);
|
||||
voteRelations.on("Relations.remove", this.onRelationsChange);
|
||||
voteRelations.on("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
if (endRelations) {
|
||||
endRelations.on("Relations.add", this.onRelationsChange);
|
||||
endRelations.on("Relations.remove", this.onRelationsChange);
|
||||
endRelations.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 removeListeners(voteRelations?: Relations, endRelations?: Relations) {
|
||||
if (voteRelations) {
|
||||
voteRelations.off("Relations.add", this.onRelationsChange);
|
||||
voteRelations.off("Relations.remove", this.onRelationsChange);
|
||||
voteRelations.off("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
if (endRelations) {
|
||||
endRelations.off("Relations.add", this.onRelationsChange);
|
||||
endRelations.off("Relations.remove", this.onRelationsChange);
|
||||
endRelations.off("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
}
|
||||
|
||||
private onPollRelationsCreated = (relationType: string, eventType: string) => {
|
||||
if (
|
||||
relationType === "m.reference" &&
|
||||
POLL_RESPONSE_EVENT_TYPE.matches(eventType)
|
||||
) {
|
||||
private onRelationsCreated = (relationType: string, eventType: string) => {
|
||||
if (relationType !== "m.reference") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (POLL_RESPONSE_EVENT_TYPE.matches(eventType)) {
|
||||
this.voteRelationsReceived = true;
|
||||
const newVoteRelations = this.fetchVoteRelations();
|
||||
this.addListeners(newVoteRelations);
|
||||
this.removeListeners(this.state.voteRelations);
|
||||
this.setState({ voteRelations: newVoteRelations });
|
||||
} else if (POLL_END_EVENT_TYPE.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(
|
||||
"Event.relationsCreated", this.onPollRelationsCreated);
|
||||
|
||||
const newPollRelations = this.fetchPollRelations();
|
||||
this.addListeners(newPollRelations);
|
||||
this.removeListeners(this.state.pollRelations);
|
||||
|
||||
this.setState({
|
||||
pollRelations: newPollRelations,
|
||||
});
|
||||
"Event.relationsCreated", this.onRelationsCreated);
|
||||
}
|
||||
};
|
||||
|
||||
private onRelationsChange = () => {
|
||||
// We hold pollRelations in our state, and it has changed under us
|
||||
// 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();
|
||||
};
|
||||
|
||||
|
@ -106,8 +225,11 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
if (answerId === this.state.selected) {
|
||||
return;
|
||||
}
|
||||
if (this.isEnded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseContent: IPollResponse = {
|
||||
const responseContent: IPollResponseContent = {
|
||||
[POLL_RESPONSE_EVENT_TYPE.name]: {
|
||||
"answers": [answerId],
|
||||
},
|
||||
|
@ -143,12 +265,20 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
this.selectOption(e.currentTarget.value);
|
||||
};
|
||||
|
||||
private fetchPollRelations(): Relations | null {
|
||||
private fetchVoteRelations(): Relations | null {
|
||||
return this.fetchRelations(POLL_RESPONSE_EVENT_TYPE.name);
|
||||
}
|
||||
|
||||
private fetchEndRelations(): Relations | null {
|
||||
return this.fetchRelations(POLL_END_EVENT_TYPE.name);
|
||||
}
|
||||
|
||||
private fetchRelations(eventType: string): Relations | null {
|
||||
if (this.props.getRelationsForEvent) {
|
||||
return this.props.getRelationsForEvent(
|
||||
this.props.mxEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_RESPONSE_EVENT_TYPE.name,
|
||||
eventType,
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
|
@ -160,7 +290,12 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
*/
|
||||
private collectUserVotes(): Map<string, UserVote> {
|
||||
return collectUserVotes(
|
||||
allVotes(this.state.pollRelations),
|
||||
allVotes(
|
||||
this.props.mxEvent,
|
||||
this.context,
|
||||
this.state.voteRelations,
|
||||
this.state.endRelations,
|
||||
),
|
||||
this.context.getUserId(),
|
||||
this.state.selected,
|
||||
);
|
||||
|
@ -175,7 +310,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
* have already seen.
|
||||
*/
|
||||
private unselectIfNewEventFromMe() {
|
||||
const newEvents: MatrixEvent[] = this.state.pollRelations.getRelations()
|
||||
const newEvents: MatrixEvent[] = this.state.voteRelations.getRelations()
|
||||
.filter(isPollResponse)
|
||||
.filter((mxEvent: MatrixEvent) =>
|
||||
!this.seenEventIds.includes(mxEvent.getId()));
|
||||
|
@ -201,6 +336,14 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
return sum;
|
||||
}
|
||||
|
||||
private isEnded(): boolean {
|
||||
return isPollEnded(
|
||||
this.props.mxEvent,
|
||||
this.context,
|
||||
this.props.getRelationsForEvent,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const pollStart: IPollContent = this.props.mxEvent.getContent();
|
||||
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
|
||||
|
@ -209,14 +352,22 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const ended = this.isEnded();
|
||||
const pollId = this.props.mxEvent.getId();
|
||||
const userVotes = this.collectUserVotes();
|
||||
const votes = countVotes(userVotes, this.props.mxEvent.getContent());
|
||||
const totalVotes = this.totalVotes(votes);
|
||||
const winCount = Math.max(...votes.values());
|
||||
const userId = this.context.getUserId();
|
||||
const myVote = userVotes.get(userId)?.answers[0];
|
||||
|
||||
let totalText: string;
|
||||
if (myVote === undefined) {
|
||||
if (ended) {
|
||||
totalText = _t(
|
||||
"Final result based on %(count)s votes",
|
||||
{ count: totalVotes },
|
||||
);
|
||||
} else if (myVote === undefined) {
|
||||
if (totalVotes === 0) {
|
||||
totalText = _t("No votes cast");
|
||||
} else {
|
||||
|
@ -230,42 +381,51 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
}
|
||||
|
||||
return <div className="mx_MPollBody">
|
||||
<h2>{ pollInfo.question[TEXT_NODE_TYPE] }</h2>
|
||||
<h2>{ pollInfo.question[TEXT_NODE_TYPE.name] }</h2>
|
||||
<div className="mx_MPollBody_allOptions">
|
||||
{
|
||||
pollInfo.answers.map((answer: IPollAnswer) => {
|
||||
const checked = myVote === answer.id;
|
||||
const classNames = `mx_MPollBody_option${
|
||||
checked ? " mx_MPollBody_option_checked": ""
|
||||
}`;
|
||||
let answerVotes = 0;
|
||||
let votesText = "";
|
||||
if (myVote !== undefined) { // Votes hidden if I didn't vote
|
||||
|
||||
// Votes are hidden until I vote or the poll ends
|
||||
if (ended || myVote !== undefined) {
|
||||
answerVotes = votes.get(answer.id) ?? 0;
|
||||
votesText = _t("%(count)s votes", { count: answerVotes });
|
||||
}
|
||||
const answerPercent = Math.round(
|
||||
100.0 * answerVotes / totalVotes);
|
||||
|
||||
const checked = (
|
||||
(!ended && myVote === answer.id) ||
|
||||
(ended && answerVotes === winCount)
|
||||
);
|
||||
const cls = classNames({
|
||||
"mx_MPollBody_option": true,
|
||||
"mx_MPollBody_option_checked": checked,
|
||||
});
|
||||
|
||||
const answerPercent = (
|
||||
totalVotes === 0
|
||||
? 0
|
||||
: Math.round(100.0 * answerVotes / totalVotes)
|
||||
);
|
||||
return <div
|
||||
key={answer.id}
|
||||
className={classNames}
|
||||
className={cls}
|
||||
onClick={() => this.selectOption(answer.id)}
|
||||
>
|
||||
<StyledRadioButton
|
||||
name={`poll_answer_select-${pollId}`}
|
||||
value={answer.id}
|
||||
checked={checked}
|
||||
onChange={this.onOptionSelected}
|
||||
>
|
||||
<div className="mx_MPollBody_optionDescription">
|
||||
<div className="mx_MPollBody_optionText">
|
||||
{ answer[TEXT_NODE_TYPE] }
|
||||
</div>
|
||||
<div className="mx_MPollBody_optionVoteCount">
|
||||
{ votesText }
|
||||
</div>
|
||||
</div>
|
||||
</StyledRadioButton>
|
||||
{ (
|
||||
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"
|
||||
|
@ -283,13 +443,62 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
interface IEndedPollOptionProps {
|
||||
answer: IPollAnswer;
|
||||
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_NODE_TYPE.name] }
|
||||
</div>
|
||||
<div className="mx_MPollBody_optionVoteCount">
|
||||
{ props.votesText }
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
interface ILivePollOptionProps {
|
||||
pollId: string;
|
||||
answer: IPollAnswer;
|
||||
checked: boolean;
|
||||
votesText: string;
|
||||
onOptionSelected: (e: React.FormEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function LivePollOption(props: ILivePollOptionProps) {
|
||||
return <StyledRadioButton
|
||||
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_NODE_TYPE.name] }
|
||||
</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 pr = event.getContent() as IPollResponse;
|
||||
const pr = event.getContent() as IPollResponseContent;
|
||||
const answers = pr[POLL_RESPONSE_EVENT_TYPE.name].answers;
|
||||
|
||||
return new UserVote(
|
||||
|
@ -299,16 +508,68 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
|
|||
);
|
||||
}
|
||||
|
||||
export function allVotes(pollRelations: Relations): Array<UserVote> {
|
||||
if (pollRelations) {
|
||||
return pollRelations.getRelations()
|
||||
export function allVotes(
|
||||
pollEvent: MatrixEvent,
|
||||
matrixClient: MatrixClient,
|
||||
voteRelations: Relations,
|
||||
endRelations: Relations,
|
||||
): 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: Relations,
|
||||
): 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 (
|
||||
POLL_RESPONSE_EVENT_TYPE.matches(responseEvent.getType()) &&
|
||||
|
|
|
@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import type { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
|
||||
|
@ -51,46 +51,58 @@ interface IOptionsButtonProps {
|
|||
getReplyChain: () => ReplyChain;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onFocusChange: (menuDisplayed: boolean) => void;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
const OptionsButton: React.FC<IOptionsButtonProps> =
|
||||
({ mxEvent, getTile, getReplyChain, permalinkCreator, onFocusChange }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
const OptionsButton: React.FC<IOptionsButtonProps> = ({
|
||||
mxEvent,
|
||||
getTile,
|
||||
getReplyChain,
|
||||
permalinkCreator,
|
||||
onFocusChange,
|
||||
getRelationsForEvent,
|
||||
}) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(button);
|
||||
useEffect(() => {
|
||||
onFocusChange(menuDisplayed);
|
||||
}, [onFocusChange, menuDisplayed]);
|
||||
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const tile = getTile && getTile();
|
||||
const replyChain = getReplyChain && getReplyChain();
|
||||
let contextMenu: ReactElement | null;
|
||||
if (menuDisplayed) {
|
||||
const tile = getTile && getTile();
|
||||
const replyChain = getReplyChain && getReplyChain();
|
||||
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
contextMenu = <MessageContextMenu
|
||||
{...aboveLeftOf(buttonRect)}
|
||||
mxEvent={mxEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
|
||||
collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
|
||||
onFinished={closeMenu}
|
||||
/>;
|
||||
}
|
||||
const buttonRect = button.current.getBoundingClientRect();
|
||||
contextMenu = <MessageContextMenu
|
||||
{...aboveLeftOf(buttonRect)}
|
||||
mxEvent={mxEvent}
|
||||
permalinkCreator={permalinkCreator}
|
||||
eventTileOps={tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined}
|
||||
collapseReplyChain={replyChain && replyChain.canCollapse() ? replyChain.collapse : undefined}
|
||||
onFinished={closeMenu}
|
||||
getRelationsForEvent={getRelationsForEvent}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
title={_t("Options")}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
return <React.Fragment>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||
title={_t("Options")}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
{ contextMenu }
|
||||
</React.Fragment>;
|
||||
};
|
||||
|
||||
interface IReactButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -138,6 +150,11 @@ interface IMessageActionBarProps {
|
|||
onFocusChange?: (menuDisplayed: boolean) => void;
|
||||
toggleThreadExpanded: () => void;
|
||||
isQuoteExpanded?: boolean;
|
||||
getRelationsForEvent?: (
|
||||
eventId: string,
|
||||
relationType: string,
|
||||
eventType: string
|
||||
) => Relations;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MessageActionBar")
|
||||
|
@ -378,6 +395,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
|||
permalinkCreator={this.props.permalinkCreator}
|
||||
onFocusChange={this.onFocusChange}
|
||||
key="menu"
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/>);
|
||||
}
|
||||
|
||||
|
|
|
@ -1157,6 +1157,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
onFocusChange={this.onActionBarFocusChange}
|
||||
isQuoteExpanded={isQuoteExpanded}
|
||||
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/> : undefined;
|
||||
|
||||
const showTimestamp = this.props.mxEvent.getTs()
|
||||
|
|
|
@ -2073,6 +2073,8 @@
|
|||
"Failed to load map": "Failed to load map",
|
||||
"Vote not registered": "Vote not registered",
|
||||
"Sorry, your vote was not registered. Please try again.": "Sorry, your vote was not registered. Please try again.",
|
||||
"Final result based on %(count)s votes|other": "Final result based on %(count)s votes",
|
||||
"Final result based on %(count)s votes|one": "Final result based on %(count)s vote",
|
||||
"No votes cast": "No votes cast",
|
||||
"%(count)s votes cast. Vote to see the results|other": "%(count)s votes cast. Vote to see the results",
|
||||
"%(count)s votes cast. Vote to see the results|one": "%(count)s vote cast. Vote to see the results",
|
||||
|
@ -2462,6 +2464,12 @@
|
|||
"Developer Tools": "Developer Tools",
|
||||
"There was an error updating your community. The server is unable to process your request.": "There was an error updating your community. The server is unable to process your request.",
|
||||
"Update community": "Update community",
|
||||
"The poll has ended. No votes were cast.": "The poll has ended. No votes were cast.",
|
||||
"The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s",
|
||||
"Failed to end poll": "Failed to end poll",
|
||||
"Sorry, the poll did not end. Please try again.": "Sorry, the poll did not end. Please try again.",
|
||||
"End Poll": "End Poll",
|
||||
"Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.": "Are you sure you want to end this poll? This will show the final results of the poll and stop people from being able to vote.",
|
||||
"An error has occurred.": "An error has occurred.",
|
||||
"Enter a number between %(min)s and %(max)s": "Enter a number between %(min)s and %(max)s",
|
||||
"Size can only be a number between %(min)s MB and %(max)s MB": "Size can only be a number between %(min)s MB and %(max)s MB",
|
||||
|
|
|
@ -19,45 +19,60 @@ 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_END_EVENT_TYPE = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end");
|
||||
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");
|
||||
|
||||
// TODO: [TravisR] Use extensible events library when ready
|
||||
const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
|
||||
export const TEXT_NODE_TYPE = new UnstableValue("m.text", "org.matrix.msc1767.text");
|
||||
|
||||
export interface IPollAnswer extends IContent {
|
||||
id: string;
|
||||
[TEXT_NODE_TYPE]: string;
|
||||
[TEXT_NODE_TYPE.name]: string;
|
||||
}
|
||||
|
||||
export interface IPollContent extends IContent {
|
||||
[POLL_START_EVENT_TYPE.name]: {
|
||||
kind: string; // disclosed or undisclosed (untypeable for now)
|
||||
question: {
|
||||
[TEXT_NODE_TYPE]: string;
|
||||
[TEXT_NODE_TYPE.name]: string;
|
||||
};
|
||||
answers: IPollAnswer[];
|
||||
};
|
||||
[TEXT_NODE_TYPE]: string;
|
||||
[TEXT_NODE_TYPE.name]: string;
|
||||
}
|
||||
|
||||
export interface IPollResponse extends IContent {
|
||||
export interface IPollResponseContent extends IContent {
|
||||
[POLL_RESPONSE_EVENT_TYPE.name]: {
|
||||
answers: string[];
|
||||
};
|
||||
"m.relates_to": {
|
||||
"event_id": string;
|
||||
"rel_type": string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IPollEndContent extends IContent {
|
||||
[POLL_END_EVENT_TYPE.name]: {};
|
||||
"m.relates_to": {
|
||||
"event_id": string;
|
||||
"rel_type": string;
|
||||
};
|
||||
}
|
||||
|
||||
export function makePollContent(question: string, answers: string[], kind: string): IPollContent {
|
||||
question = question.trim();
|
||||
answers = answers.map(a => a.trim()).filter(a => !!a);
|
||||
return {
|
||||
[TEXT_NODE_TYPE]: `${question}\n${answers.map((a, i) => `${i + 1}. ${a}`).join('\n')}`,
|
||||
[TEXT_NODE_TYPE.name]: `${question}\n${answers.map((a, i) => `${i + 1}. ${a}`).join('\n')}`,
|
||||
[POLL_START_EVENT_TYPE.name]: {
|
||||
kind: kind,
|
||||
question: {
|
||||
[TEXT_NODE_TYPE]: question,
|
||||
[TEXT_NODE_TYPE.name]: question,
|
||||
},
|
||||
answers: answers.map((a, i) => ({ id: `${i}-${a}`, [TEXT_NODE_TYPE]: a })),
|
||||
answers: answers.map(
|
||||
(a, i) => ({ id: `${i}-${a}`, [TEXT_NODE_TYPE.name]: a }),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue