From f1e1b7be86cabb5ca003f8a21c3df6cf3d340717 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 21 Feb 2022 10:21:35 +0000 Subject: [PATCH] Support "closed" polls whose votes are not visible until they are ended (#7842) --- res/css/views/dialogs/_PollCreateDialog.scss | 4 + .../views/elements/PollCreateDialog.tsx | 52 ++++++++++- src/components/views/messages/MPollBody.tsx | 11 ++- src/i18n/strings/en_EN.json | 6 ++ .../views/elements/PollCreateDialog-test.tsx | 83 ++++++++++++++++- .../PollCreateDialog-test.tsx.snap | 6 +- .../views/messages/MPollBody-test.tsx | 93 ++++++++++++++++++- .../__snapshots__/MPollBody-test.tsx.snap | 4 + 8 files changed, 248 insertions(+), 11 deletions(-) diff --git a/res/css/views/dialogs/_PollCreateDialog.scss b/res/css/views/dialogs/_PollCreateDialog.scss index 358c95a2bf..7c59ea1278 100644 --- a/res/css/views/dialogs/_PollCreateDialog.scss +++ b/res/css/views/dialogs/_PollCreateDialog.scss @@ -37,6 +37,10 @@ limitations under the License. } } + p { + color: $secondary-content; + } + .mx_PollCreateDialog_option { display: flex; align-items: center; diff --git a/src/components/views/elements/PollCreateDialog.tsx b/src/components/views/elements/PollCreateDialog.tsx index a68b5aa495..f713a779ac 100644 --- a/src/components/views/elements/PollCreateDialog.tsx +++ b/src/components/views/elements/PollCreateDialog.tsx @@ -16,7 +16,14 @@ limitations under the License. import React, { ChangeEvent, createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { IPartialEvent, M_POLL_KIND_DISCLOSED, M_POLL_START, PollStartEvent } from "matrix-events-sdk"; +import { + IPartialEvent, + KNOWN_POLL_KIND, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_START, + PollStartEvent, +} from "matrix-events-sdk"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal"; @@ -43,6 +50,7 @@ interface IState extends IScrollableBaseState { question: string; options: string[]; busy: boolean; + kind: KNOWN_POLL_KIND; autoFocusTarget: FocusTarget; } @@ -60,6 +68,7 @@ function creatingInitialState(): IState { question: "", options: arraySeed("", DEFAULT_NUM_OPTIONS), busy: false, + kind: M_POLL_KIND_DISCLOSED, autoFocusTarget: FocusTarget.Topic, }; } @@ -75,6 +84,7 @@ function editingInitialState(editingMxEvent: MatrixEvent): IState { question: poll.question.text, options: poll.answers.map(ans => ans.text), busy: false, + kind: poll.kind, autoFocusTarget: FocusTarget.Topic, }; } @@ -131,7 +141,7 @@ export default class PollCreateDialog extends ScrollableBaseModal a.trim()).filter(a => !!a), - M_POLL_KIND_DISCLOSED, + this.state.kind, ).serialize(); if (!this.props.editingMxEvent) { @@ -190,6 +200,26 @@ export default class PollCreateDialog extends ScrollableBaseModal +

{ _t("Poll type") }

+ + + + +

{ pollTypeNotes(this.state.kind) }

{ _t("What is your poll question or topic?") }

; } + + onPollTypeChange = (e: ChangeEvent) => { + this.setState({ + kind: ( + M_POLL_KIND_DISCLOSED.matches(e.target.value) + ? M_POLL_KIND_DISCLOSED + : M_POLL_KIND_UNDISCLOSED + ), + }); + }; +} + +function pollTypeNotes(kind: KNOWN_POLL_KIND): string { + if (M_POLL_KIND_DISCLOSED.matches(kind.name)) { + return _t("Voters see results as soon as they have voted"); + } else { + return _t("Results are only revealed when you end the poll"); + } } diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 91132949a6..8b754b54b6 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -21,6 +21,7 @@ import { Relations } 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, @@ -431,6 +432,11 @@ export default class MPollBody extends React.Component { 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) { @@ -438,6 +444,8 @@ export default class MPollBody extends React.Component { "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"); @@ -465,8 +473,7 @@ export default class MPollBody extends React.Component { let answerVotes = 0; let votesText = ""; - // Votes are hidden until I vote or the poll ends - if (ended || myVote !== undefined) { + if (showResults) { answerVotes = votes.get(answer.id) ?? 0; votesText = _t("%(count)s votes", { count: answerVotes }); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ca38c23b05..f1bb44f49c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2134,6 +2134,7 @@ "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", + "Results will be visible when the poll is ended": "Results will be visible when the poll is ended", "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", @@ -2321,6 +2322,9 @@ "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.", + "Poll type": "Poll type", + "Open poll": "Open poll", + "Closed poll": "Closed poll", "What is your poll question or topic?": "What is your poll question or topic?", "Question or topic": "Question or topic", "Write something...": "Write something...", @@ -2328,6 +2332,8 @@ "Option %(number)s": "Option %(number)s", "Write an option": "Write an option", "Add option": "Add option", + "Voters see results as soon as they have voted": "Voters see results as soon as they have voted", + "Results are only revealed when you end the poll": "Results are only revealed when you end the poll", "Power level": "Power level", "Custom level": "Custom level", "QR Code": "QR Code", diff --git a/test/components/views/elements/PollCreateDialog-test.tsx b/test/components/views/elements/PollCreateDialog-test.tsx index 41af027a98..d63ddc56d2 100644 --- a/test/components/views/elements/PollCreateDialog-test.tsx +++ b/test/components/views/elements/PollCreateDialog-test.tsx @@ -19,7 +19,13 @@ 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 { + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + 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"; @@ -133,6 +139,71 @@ describe("PollCreateDialog", () => { expect(submitIsDisabled(dialog)).toBe(false); }); + it("shows the open poll description at first", () => { + const dialog = mount( + , + ); + expect( + dialog.find('select').prop("value"), + ).toEqual(M_POLL_KIND_DISCLOSED.name); + expect( + dialog.find('p').text(), + ).toEqual("Voters see results as soon as they have voted"); + }); + + it("shows the closed poll description if we choose it", () => { + const dialog = mount( + , + ); + changeKind(dialog, M_POLL_KIND_UNDISCLOSED.name); + expect( + dialog.find('select').prop("value"), + ).toEqual(M_POLL_KIND_UNDISCLOSED.name); + expect( + dialog.find('p').text(), + ).toEqual("Results are only revealed when you end the poll"); + }); + + it("shows the open poll description if we choose it", () => { + const dialog = mount( + , + ); + changeKind(dialog, M_POLL_KIND_UNDISCLOSED.name); + changeKind(dialog, M_POLL_KIND_DISCLOSED.name); + expect( + dialog.find('select').prop("value"), + ).toEqual(M_POLL_KIND_DISCLOSED.name); + expect( + dialog.find('p').text(), + ).toEqual("Voters see results as soon as they have voted"); + }); + + it("shows the closed poll description when editing a closed poll", () => { + const previousEvent: MatrixEvent = new MatrixEvent( + PollStartEvent.from( + "Poll Q", + ["Answer 1", "Answer 2"], + M_POLL_KIND_UNDISCLOSED, + ).serialize(), + ); + previousEvent.event.event_id = "$prevEventId"; + + const dialog = mount( + , + ); + + expect( + dialog.find('select').prop("value"), + ).toEqual(M_POLL_KIND_UNDISCLOSED.name); + expect( + dialog.find('p').text(), + ).toEqual("Results are only revealed when you end the poll"); + }); + it("displays a spinner after submitting", () => { TestUtils.stubClient(); MatrixClientPeg.get().sendEvent = jest.fn(() => Promise.resolve()); @@ -236,6 +307,7 @@ describe("PollCreateDialog", () => { changeValue(dialog, "Question or topic", "Poll Q updated"); changeValue(dialog, "Option 2", "Answer 2 updated"); + changeKind(dialog, M_POLL_KIND_UNDISCLOSED.name); dialog.find("button").simulate("click"); expect(sentEventContent).toEqual( @@ -253,7 +325,7 @@ describe("PollCreateDialog", () => { [M_TEXT.name]: "Answer 2 updated", }, ], - "kind": M_POLL_KIND_DISCLOSED.name, + "kind": M_POLL_KIND_UNDISCLOSED.name, "max_selections": 1, "question": { "body": "Poll Q updated", @@ -289,6 +361,13 @@ function changeValue(wrapper: ReactWrapper, labelText: string, value: string) { ); } +function changeKind(wrapper: ReactWrapper, value: string) { + wrapper.find("select").simulate( + "change", + { target: { value: value } }, + ); +} + function submitIsDisabled(wrapper: ReactWrapper) { return wrapper.find('button[type="submit"]').prop("aria-disabled") === true; } diff --git a/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap b/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap index ac05f9479f..c3e5433d7d 100644 --- a/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -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 blank poll 1`] = `"

Create poll

Poll type

Voters see results as soon as they have voted

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 a question and some options 1`] = `"

Create poll

Poll type

Voters see results as soon as they have voted

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
"`; +exports[`PollCreateDialog renders info from a previous event 1`] = `"

Edit poll

Poll type

Voters see results as soon as they have voted

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 b01e893ecc..c460e476b1 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -23,6 +23,7 @@ import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; import { M_POLL_END, M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, M_POLL_RESPONSE, M_POLL_START, M_POLL_START_EVENT_CONTENT, @@ -474,6 +475,60 @@ describe("MPollBody", () => { ).toBe(20); }); + it("hides scores if I voted but the poll is undisclosed", () => { + const votes = [ + responseEvent("@me:example.com", "pizza"), + responseEvent("@alice:example.com", "pizza"), + responseEvent("@bellc:example.com", "pizza"), + responseEvent("@catrd:example.com", "poutine"), + responseEvent("@dune2:example.com", "wings"), + ]; + const body = newMPollBody(votes, [], null, false); + expect(votesCount(body, "pizza")).toBe(""); + expect(votesCount(body, "poutine")).toBe(""); + expect(votesCount(body, "italian")).toBe(""); + expect(votesCount(body, "wings")).toBe(""); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe( + "Results will be visible when the poll is ended"); + }); + + it("highlights my vote if the poll is undisclosed", () => { + const votes = [ + responseEvent("@me:example.com", "pizza"), + responseEvent("@alice:example.com", "poutine"), + responseEvent("@bellc:example.com", "poutine"), + responseEvent("@catrd:example.com", "poutine"), + responseEvent("@dune2:example.com", "wings"), + ]; + const body = newMPollBody(votes, [], null, false); + + // My vote is marked + expect(body.find('input[value="pizza"]').prop("checked")).toBeTruthy(); + + // Sanity: other items are not checked + expect(body.find('input[value="poutine"]').prop("checked")).toBeFalsy(); + }); + + it("shows scores if the poll is undisclosed but ended", () => { + const votes = [ + responseEvent("@me:example.com", "pizza"), + responseEvent("@alice:example.com", "pizza"), + responseEvent("@bellc:example.com", "pizza"), + responseEvent("@catrd:example.com", "poutine"), + responseEvent("@dune2:example.com", "wings"), + ]; + const ends = [ + endEvent("@me:example.com", 12), + ]; + const body = newMPollBody(votes, ends, null, false); + expect(endedVotesCount(body, "pizza")).toBe("3 votes"); + expect(endedVotesCount(body, "poutine")).toBe("1 vote"); + expect(endedVotesCount(body, "italian")).toBe("0 votes"); + expect(endedVotesCount(body, "wings")).toBe("1 vote"); + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe( + "Final result based on 5 votes"); + }); + it("sends a vote event when I choose an option", () => { const receivedEvents = []; MatrixClientPeg.matrixClient.sendEvent = ( @@ -993,6 +1048,34 @@ describe("MPollBody", () => { const body = newMPollBody(votes, ends); expect(body).toMatchSnapshot(); }); + + it("renders an undisclosed, unfinished poll", () => { + const votes = [ + responseEvent("@ed:example.com", "pizza", 12), + responseEvent("@rf:example.com", "pizza", 12), + responseEvent("@th:example.com", "wings", 13), + responseEvent("@yh:example.com", "wings", 14), + responseEvent("@th:example.com", "poutine", 13), + responseEvent("@yh:example.com", "poutine", 14), + ]; + const ends = []; + const body = newMPollBody(votes, ends, null, false); + expect(body.html()).toMatchSnapshot(); + }); + + it("renders an undisclosed, finished poll", () => { + const votes = [ + responseEvent("@ed:example.com", "pizza", 12), + responseEvent("@rf:example.com", "pizza", 12), + responseEvent("@th:example.com", "wings", 13), + responseEvent("@yh:example.com", "wings", 14), + responseEvent("@th:example.com", "poutine", 13), + responseEvent("@yh:example.com", "poutine", 14), + ]; + const ends = [endEvent("@me:example.com", 25)]; + const body = newMPollBody(votes, ends, null, false); + expect(body.html()).toMatchSnapshot(); + }); }); function newVoteRelations(relationEvents: Array): Relations { @@ -1018,12 +1101,13 @@ function newMPollBody( relationEvents: Array, endEvents: Array = [], answers?: POLL_ANSWER[], + disclosed = true, ): ReactWrapper { const mxEvent = new MatrixEvent({ "type": M_POLL_START.name, "event_id": "$mypoll", "room_id": "#myroom:example.com", - "content": newPollStart(answers), + "content": newPollStart(answers, null, disclosed), }); return newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); } @@ -1096,6 +1180,7 @@ function endedVotesCount(wrapper: ReactWrapper, value: string): string { function newPollStart( answers?: POLL_ANSWER[], question?: string, + disclosed = true, ): M_POLL_START_EVENT_CONTENT { if (!answers) { answers = [ @@ -1121,7 +1206,11 @@ function newPollStart( "question": { [M_TEXT.name]: question, }, - "kind": M_POLL_KIND_DISCLOSED.name, + "kind": ( + disclosed + ? M_POLL_KIND_DISCLOSED.name + : M_POLL_KIND_UNDISCLOSED.name + ), "answers": answers, }, [M_TEXT.name]: fallback, diff --git a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap index 18988d0290..6d080f7887 100644 --- a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -2872,3 +2872,7 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = ` `; + +exports[`MPollBody renders an undisclosed, finished poll 1`] = `"

What should we order for the party?

Pizza
2 votes
Poutine
0 votes
Italian
0 votes
Wings
2 votes
Final result based on 4 votes
"`; + +exports[`MPollBody renders an undisclosed, unfinished poll 1`] = `"

What should we order for the party?

Results will be visible when the poll is ended
"`;