Allow ending polls (#7305)

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Andy Balaam 2021-12-08 14:56:48 +00:00 committed by GitHub
parent 697b5d28b3
commit 2b52e17a80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 2814 additions and 680 deletions

View file

@ -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 }

View 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)}
/>
);
}
}

View file

@ -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()) &&

View file

@ -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}
/>);
}

View file

@ -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()

View file

@ -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",

View file

@ -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 }),
),
},
};
}