diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index d982cf9004..87e9a06fb9 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -16,7 +16,8 @@ limitations under the License. import React, { ChangeEvent, createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; +import { IPartialEvent, M_POLL_KIND_DISCLOSED, M_POLL_START, PollStartEvent } from "matrix-events-sdk"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; import { IDialogProps } from "../dialogs/IDialogProps"; @@ -31,6 +32,7 @@ import Spinner from "./Spinner"; interface IProps extends IDialogProps { room: Room; threadId?: string; + editingMxEvent?: MatrixEvent; // Truthy if we are editing an existing poll } interface IState extends IScrollableBaseState { @@ -45,21 +47,42 @@ const DEFAULT_NUM_OPTIONS = 2; const MAX_QUESTION_LENGTH = 340; const MAX_OPTION_LENGTH = 340; +function creatingInitialState(): IState { + return { + title: _t("Create poll"), + actionLabel: _t("Create Poll"), + canSubmit: false, // need to add a question and at least one option first + question: "", + options: arraySeed("", DEFAULT_NUM_OPTIONS), + busy: false, + }; +} + +function editingInitialState(editingMxEvent: MatrixEvent): IState { + const poll = editingMxEvent.unstableExtensibleEvent as PollStartEvent; + if (!poll?.isEquivalentTo(M_POLL_START)) return creatingInitialState(); + + return { + title: _t("Edit poll"), + actionLabel: _t("Done"), + canSubmit: true, + question: poll.question.text, + options: poll.answers.map(ans => ans.text), + busy: false, + }; +} + export default class PollCreateDialog extends ScrollableBaseModal { private addOptionRef = createRef(); public constructor(props: IProps) { super(props); - this.state = { - title: _t("Create poll"), - actionLabel: _t("Create Poll"), - canSubmit: false, // need to add a question and at least one option first - - question: "", - options: arraySeed("", DEFAULT_NUM_OPTIONS), - busy: false, - }; + this.state = ( + props.editingMxEvent + ? editingInitialState(props.editingMxEvent) + : creatingInitialState() + ); } private checkCanSubmit() { @@ -97,13 +120,32 @@ export default class PollCreateDialog extends ScrollableBaseModal { + const pollStart = PollStartEvent.from( this.state.question.trim(), this.state.options.map(a => a.trim()).filter(a => !!a), M_POLL_KIND_DISCLOSED, ).serialize(); + + if (!this.props.editingMxEvent) { + return pollStart; + } else { + return { + "content": { + "m.new_content": pollStart.content, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": this.props.editingMxEvent.getId(), + }, + }, + "type": pollStart.type, + }; + } + } + + protected submit(): void { + this.setState({ busy: true, canSubmit: false }); + const pollEvent = this.createEvent(); this.matrixClient.sendEvent( this.props.room.roomId, this.props.threadId, @@ -159,7 +201,10 @@ export default class PollCreateDialog extends ScrollableBaseModal this.onOptionChange(i, e)} + onChange={ + (e: ChangeEvent) => + this.onOptionChange(i, e) + } usePlaceholderAsHint={true} disabled={this.state.busy} /> diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 7498995e9a..90dd4f554c 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -45,6 +45,28 @@ interface IState { endRelations: RelatedRelations; // Poll end events } +export function createVoteRelations( + getRelationsForEvent: ( + eventId: string, + relationType: string, + eventType: string + ) => Relations, + eventId: string, +) { + return new RelatedRelations([ + getRelationsForEvent( + eventId, + "m.reference", + M_POLL_RESPONSE.name, + ), + getRelationsForEvent( + eventId, + "m.reference", + M_POLL_RESPONSE.altName, + ), + ]); +} + export function findTopAnswer( pollEvent: MatrixEvent, matrixClient: MatrixClient, @@ -68,18 +90,7 @@ export function findTopAnswer( return poll.answers.find(a => a.id === answerId)?.text ?? ""; }; - const voteRelations = new RelatedRelations([ - getRelationsForEvent( - pollEvent.getId(), - "m.reference", - M_POLL_RESPONSE.name, - ), - getRelationsForEvent( - pollEvent.getId(), - "m.reference", - M_POLL_RESPONSE.altName, - ), - ]); + const voteRelations = createVoteRelations(getRelationsForEvent, pollEvent.getId()); const endRelations = new RelatedRelations([ getRelationsForEvent( diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 969ae0a4de..e07eb0b7c0 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -20,6 +20,7 @@ import React, { ReactElement, useEffect } from 'react'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import classNames from 'classnames'; import { MsgType } from 'matrix-js-sdk/src/@types/event'; +import { M_POLL_START } from 'matrix-events-sdk'; import type { Relations } from 'matrix-js-sdk/src/models/relations'; import { _t } from '../../../languageHandler'; @@ -42,6 +43,10 @@ import ReplyChain from '../elements/ReplyChain'; import { showThread } from '../../../dispatcher/dispatch-actions/threads'; import ReactionPicker from "../emojipicker/ReactionPicker"; import { CardContext } from '../right_panel/BaseCard'; +import Modal from '../../../Modal'; +import PollCreateDialog from '../elements/PollCreateDialog'; +import ErrorDialog from '../dialogs/ErrorDialog'; +import { createVoteRelations } from './MPollBody'; interface IOptionsButtonProps { mxEvent: MatrixEvent; @@ -228,12 +233,59 @@ export default class MessageActionBar extends React.PureComponent { - dis.dispatch({ - action: Action.EditEvent, - event: this.props.mxEvent, - timelineRenderingType: this.context.timelineRenderingType, - }); + private pollAlreadyHasVotes = (): boolean => { + if (!this.props.getRelationsForEvent) { + return false; + } + + const voteRelations = createVoteRelations( + this.props.getRelationsForEvent, + this.props.mxEvent.getId(), + ); + + return voteRelations.getRelations().length > 0; + }; + + private launchPollEditor = (): void => { + if (this.pollAlreadyHasVotes()) { + Modal.createTrackedDialog( + 'Not allowed to edit poll', + '', + ErrorDialog, + { + title: _t("Can't edit poll"), + description: _t( + "Sorry, you can't edit a poll after votes have been cast.", + ), + }, + ); + } else { + Modal.createTrackedDialog( + 'Polls', + 'create', + PollCreateDialog, + { + room: this.context.room, + threadId: this.context.threadId ?? null, + editingMxEvent: this.props.mxEvent, + }, + 'mx_CompoundDialog', + false, // isPriorityModal + true, // isStaticModal + ); + } + }; + + private onEditClick = (): void => { + if (M_POLL_START.matches(this.props.mxEvent.getType())) { + this.launchPollEditor(); + } else { + dis.dispatch({ + action: Action.EditEvent, + event: this.props.mxEvent, + timelineRenderingType: this.context.timelineRenderingType, + }); + } }; private readonly forbiddenThreadHeadMsgType = [ diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 74f10b8000..68ebbf3649 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2092,6 +2092,8 @@ "Go": "Go", "Error processing audio message": "Error processing audio message", "React": "React", + "Can't edit poll": "Can't edit poll", + "Sorry, you can't edit a poll after votes have been cast.": "Sorry, you can't edit a poll after votes have been cast.", "Edit": "Edit", "Reply in thread": "Reply in thread", "Reply": "Reply", @@ -2314,6 +2316,8 @@ "Language Dropdown": "Language Dropdown", "Create poll": "Create poll", "Create Poll": "Create Poll", + "Edit poll": "Edit poll", + "Done": "Done", "Failed to post poll": "Failed to post poll", "Sorry, the poll you tried to create was not posted.": "Sorry, the poll you tried to create was not posted.", "What is your poll question or topic?": "What is your poll question or topic?", @@ -2817,7 +2821,6 @@ "Not Trusted": "Not Trusted", "Manually Verify by Text": "Manually Verify by Text", "Interactively verify by Emoji": "Interactively verify by Emoji", - "Done": "Done", "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files": "Upload files", "Upload all": "Upload all", diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 5bb07cf1e5..9925f0cb75 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -58,8 +58,14 @@ export function isContentActionable(mxEvent: MatrixEvent): boolean { } export function canEditContent(mxEvent: MatrixEvent): boolean { - if (mxEvent.status === EventStatus.CANCELLED || - mxEvent.getType() !== EventType.RoomMessage || + const isCancellable = ( + mxEvent.getType() === EventType.RoomMessage || + M_POLL_START.matches(mxEvent.getType()) + ); + + if ( + !isCancellable || + mxEvent.status === EventStatus.CANCELLED || mxEvent.isRedacted() || mxEvent.isRelation(RelationType.Replace) || mxEvent.getSender() !== MatrixClientPeg.get().getUserId() @@ -68,7 +74,14 @@ export function canEditContent(mxEvent: MatrixEvent): boolean { } const { msgtype, body } = mxEvent.getOriginalContent(); - return (msgtype === MsgType.Text || msgtype === MsgType.Emote) && body && typeof body === 'string'; + return ( + M_POLL_START.matches(mxEvent.getType()) || + ( + (msgtype === MsgType.Text || msgtype === MsgType.Emote) && + body && + typeof body === 'string' + ) + ); } export function canEditOwnEvent(mxEvent: MatrixEvent): boolean { diff --git a/test/components/views/elements/PollCreateDialog-test.tsx b/test/components/views/elements/PollCreateDialog-test.tsx index bec362638c..f8b99ca130 100644 --- a/test/components/views/elements/PollCreateDialog-test.tsx +++ b/test/components/views/elements/PollCreateDialog-test.tsx @@ -19,6 +19,8 @@ import '../../../skinned-sdk'; import React from "react"; import { mount, ReactWrapper } from "enzyme"; import { Room } from "matrix-js-sdk/src/models/room"; +import { M_POLL_KIND_DISCLOSED, M_POLL_START, M_TEXT, PollStartEvent } from 'matrix-events-sdk'; +import { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import * as TestUtils from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -64,6 +66,27 @@ describe("PollCreateDialog", () => { expect(dialog.html()).toMatchSnapshot(); }); + it("renders info from a previous event", () => { + const previousEvent: MatrixEvent = new MatrixEvent( + PollStartEvent.from( + "Poll Q", + ["Answer 1", "Answer 2"], + M_POLL_KIND_DISCLOSED, + ).serialize(), + ); + + const dialog = mount( + , + ); + + expect(submitIsDisabled(dialog)).toBe(false); + expect(dialog.html()).toMatchSnapshot(); + }); + it("doesn't allow submitting until there are options", () => { const dialog = mount( , @@ -102,6 +125,129 @@ describe("PollCreateDialog", () => { dialog.find("button").simulate("click"); expect(dialog.find("Spinner").length).toBe(1); }); + + it("sends a poll create event when submitted", () => { + TestUtils.stubClient(); + let sentEventContent: IContent = null; + MatrixClientPeg.get().sendEvent = jest.fn( + ( + _roomId: string, + _threadId: string, + eventType: string, + content: IContent, + ) => { + expect(M_POLL_START.matches(eventType)).toBeTruthy(); + sentEventContent = content; + return Promise.resolve(); + }, + ); + + const dialog = mount( + , + ); + changeValue(dialog, "Question or topic", "Q"); + changeValue(dialog, "Option 1", "A1"); + changeValue(dialog, "Option 2", "A2"); + + dialog.find("button").simulate("click"); + expect(sentEventContent).toEqual( + { + [M_TEXT.name]: "Q\n1. A1\n2. A2", + [M_POLL_START.name]: { + "answers": [ + { + "id": expect.any(String), + [M_TEXT.name]: "A1", + }, + { + "id": expect.any(String), + [M_TEXT.name]: "A2", + }, + ], + "kind": M_POLL_KIND_DISCLOSED.name, + "max_selections": 1, + "question": { + "body": "Q", + "format": undefined, + "formatted_body": undefined, + "msgtype": "m.text", + [M_TEXT.name]: "Q", + }, + }, + }, + ); + }); + + it("sends a poll edit event when editing", () => { + TestUtils.stubClient(); + let sentEventContent: IContent = null; + MatrixClientPeg.get().sendEvent = jest.fn( + ( + _roomId: string, + _threadId: string, + eventType: string, + content: IContent, + ) => { + expect(M_POLL_START.matches(eventType)).toBeTruthy(); + sentEventContent = content; + return Promise.resolve(); + }, + ); + + const previousEvent: MatrixEvent = new MatrixEvent( + PollStartEvent.from( + "Poll Q", + ["Answer 1", "Answer 2"], + M_POLL_KIND_DISCLOSED, + ).serialize(), + ); + previousEvent.event.event_id = "$prevEventId"; + + const dialog = mount( + , + ); + + changeValue(dialog, "Question or topic", "Poll Q updated"); + changeValue(dialog, "Option 2", "Answer 2 updated"); + dialog.find("button").simulate("click"); + + expect(sentEventContent).toEqual( + { + "m.new_content": { + [M_TEXT.name]: "Poll Q updated\n1. Answer 1\n2. Answer 2 updated", + [M_POLL_START.name]: { + "answers": [ + { + "id": expect.any(String), + [M_TEXT.name]: "Answer 1", + }, + { + "id": expect.any(String), + [M_TEXT.name]: "Answer 2 updated", + }, + ], + "kind": M_POLL_KIND_DISCLOSED.name, + "max_selections": 1, + "question": { + "body": "Poll Q updated", + "format": undefined, + "formatted_body": undefined, + "msgtype": "m.text", + [M_TEXT.name]: "Poll Q updated", + }, + }, + }, + "m.relates_to": { + "event_id": previousEvent.getId(), + "rel_type": "m.replace", + }, + }, + ); + }); }); function createRoom(): Room { diff --git a/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap b/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap index 8edc355883..f302810c3b 100644 --- a/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap @@ -3,3 +3,5 @@ exports[`PollCreateDialog renders a blank poll 1`] = `"

Create poll

What is your poll question or topic?

Create options

Add option
Cancel
"`; exports[`PollCreateDialog renders a question and some options 1`] = `"

Create poll

What is your poll question or topic?

Create options

Add option
Cancel
"`; + +exports[`PollCreateDialog renders info from a previous event 1`] = `"

Edit poll

What is your poll question or topic?

Create options

Add option
Cancel
"`; diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index 7ed059950f..b01e893ecc 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -1110,8 +1110,8 @@ function newPollStart( question = "What should we order for the party?"; } - const answersFallback = Array.from(answers.entries()) - .map(([i, a]) => `${i + 1}. ${a[M_TEXT.name]}`) + const answersFallback = answers + .map((a, i) => `${i + 1}. ${a[M_TEXT.name]}`) .join("\n"); const fallback = `${question}\n${answersFallback}`;