diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b589bd66366..0d56e1b512a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -18,6 +18,7 @@ @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; @import "./components/views/context_menus/_KebabContextMenu.pcss"; @import "./components/views/dialogs/polls/_PollListItem.pcss"; +@import "./components/views/dialogs/polls/_PollListItemEnded.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/elements/_FilterTabGroup.pcss"; @import "./components/views/elements/_LearnMore.pcss"; diff --git a/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss new file mode 100644 index 00000000000..6518052ab61 --- /dev/null +++ b/res/css/components/views/dialogs/polls/_PollListItemEnded.pcss @@ -0,0 +1,60 @@ +/* +Copyright 2023 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. +*/ + +.mx_PollListItemEnded { + width: 100%; + display: flex; + flex-direction: column; + color: $primary-content; +} + +.mx_PollListItemEnded_title { + display: grid; + justify-content: left; + align-items: center; + grid-gap: $spacing-8; + grid-template-columns: min-content 1fr min-content; + grid-template-rows: auto; +} + +.mx_PollListItemEnded_icon { + height: 14px; + width: 14px; + color: $quaternary-content; + padding-left: $spacing-8; +} + +.mx_PollListItemEnded_date { + font-size: $font-12px; + color: $secondary-content; +} + +.mx_PollListItemEnded_question { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mx_PollListItemEnded_answers { + display: grid; + grid-gap: $spacing-8; + margin-top: $spacing-12; +} + +.mx_PollListItemEnded_voteCount { + // 6px to match PollOption padding + margin: $spacing-8 0 0 6px; +} diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss index e5a97b7f2b7..da4c66d6cf1 100644 --- a/res/css/components/views/polls/_PollOption.pcss +++ b/res/css/components/views/polls/_PollOption.pcss @@ -18,7 +18,6 @@ limitations under the License. border: 1px solid $quinary-content; border-radius: 8px; padding: 6px 12px; - max-width: 550px; background-color: $background; .mx_StyledRadioButton_content, diff --git a/res/css/views/dialogs/polls/_PollHistoryList.pcss b/res/css/views/dialogs/polls/_PollHistoryList.pcss index 6a0a003ce1e..27dfc40aa6e 100644 --- a/res/css/views/dialogs/polls/_PollHistoryList.pcss +++ b/res/css/views/dialogs/polls/_PollHistoryList.pcss @@ -32,6 +32,10 @@ limitations under the License. grid-gap: $spacing-20; padding-right: $spacing-64; margin: $spacing-32 0; + + &.mx_PollHistoryList_list_ENDED { + grid-gap: $spacing-32; + } } .mx_PollHistoryList_noResults { diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index 193bd9382a4..e7f3118d571 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -70,4 +70,5 @@ limitations under the License. display: grid; grid-gap: $spacing-16; margin-bottom: $spacing-8; + max-width: 550px; } diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index e5525fbbaf7..fea5e5bdd09 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -54,7 +54,12 @@ export const PollHistoryDialog: React.FC = ({ roomId, ma return (
- +
); diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx index 7c8714aeac9..5f4ea6dd4d1 100644 --- a/src/components/views/dialogs/polls/PollHistoryList.tsx +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -15,19 +15,22 @@ limitations under the License. */ import React from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import classNames from "classnames"; +import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix"; -import PollListItem from "./PollListItem"; import { _t } from "../../../../languageHandler"; -import { FilterTabGroup } from "../../elements/FilterTabGroup"; import { PollHistoryFilter } from "./types"; +import { PollListItem } from "./PollListItem"; +import { PollListItemEnded } from "./PollListItemEnded"; +import { FilterTabGroup } from "../../elements/FilterTabGroup"; type PollHistoryListProps = { pollStartEvents: MatrixEvent[]; + polls: Map; filter: PollHistoryFilter; onFilterChange: (filter: PollHistoryFilter) => void; }; -export const PollHistoryList: React.FC = ({ pollStartEvents, filter, onFilterChange }) => { +export const PollHistoryList: React.FC = ({ pollStartEvents, polls, filter, onFilterChange }) => { return (
@@ -40,10 +43,18 @@ export const PollHistoryList: React.FC = ({ pollStartEvent ]} /> {!!pollStartEvents.length ? ( -
    - {pollStartEvents.map((pollStartEvent) => ( - - ))} +
      + {pollStartEvents.map((pollStartEvent) => + filter === "ACTIVE" ? ( + + ) : ( + + ), + )}
    ) : ( diff --git a/src/components/views/dialogs/polls/PollListItem.tsx b/src/components/views/dialogs/polls/PollListItem.tsx index 49df399bd73..7a4ace08b05 100644 --- a/src/components/views/dialogs/polls/PollListItem.tsx +++ b/src/components/views/dialogs/polls/PollListItem.tsx @@ -25,7 +25,7 @@ interface Props { event: MatrixEvent; } -const PollListItem: React.FC = ({ event }) => { +export const PollListItem: React.FC = ({ event }) => { const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent; if (!pollEvent) { return null; @@ -39,5 +39,3 @@ const PollListItem: React.FC = ({ event }) => { ); }; - -export default PollListItem; diff --git a/src/components/views/dialogs/polls/PollListItemEnded.tsx b/src/components/views/dialogs/polls/PollListItemEnded.tsx new file mode 100644 index 00000000000..8a6b2c9595f --- /dev/null +++ b/src/components/views/dialogs/polls/PollListItemEnded.tsx @@ -0,0 +1,127 @@ +/* +Copyright 2023 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, { useEffect, useState } from "react"; +import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import { MatrixEvent, Poll, PollEvent } from "matrix-js-sdk/src/matrix"; +import { Relations } from "matrix-js-sdk/src/models/relations"; + +import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg"; +import { _t } from "../../../../languageHandler"; +import { formatLocalDateShort } from "../../../../DateUtils"; +import { allVotes, collectUserVotes, countVotes } from "../../messages/MPollBody"; +import { PollOption } from "../../polls/PollOption"; +import { Caption } from "../../typography/Caption"; + +interface Props { + event: MatrixEvent; + poll: Poll; +} + +type EndedPollState = { + winningAnswers: { + answer: PollAnswerSubevent; + voteCount: number; + }[]; + totalVoteCount: number; +}; +const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollState => { + const userVotes = collectUserVotes(allVotes(responseRelations)); + const votes = countVotes(userVotes, poll.pollEvent); + const totalVoteCount = [...votes.values()].reduce((sum, vote) => sum + vote, 0); + const winCount = Math.max(...votes.values()); + + return { + totalVoteCount, + winningAnswers: poll.pollEvent.answers + .filter((answer) => votes.get(answer.id) === winCount) + .map((answer) => ({ + answer, + voteCount: votes.get(answer.id) || 0, + })), + }; +}; + +/** + * Get deduplicated and validated poll responses + * Will use cached responses from Poll instance when existing + * Updates on changes to Poll responses (paging relations or from sync) + * Returns winning answers and total vote count + */ +const usePollVotes = (poll: Poll): Partial => { + const [results, setResults] = useState({ totalVoteCount: 0 }); + + useEffect(() => { + const getResponses = async (): Promise => { + const responseRelations = await poll.getResponses(); + setResults(getWinningAnswers(poll, responseRelations)); + }; + const onPollResponses = (responseRelations: Relations): void => + setResults(getWinningAnswers(poll, responseRelations)); + poll.on(PollEvent.Responses, onPollResponses); + + getResponses(); + + return () => { + poll.off(PollEvent.Responses, onPollResponses); + }; + }, [poll]); + + return results; +}; + +/** + * Render an ended poll with the winning answer and vote count + * @param event - the poll start MatrixEvent + * @param poll - Poll instance + */ +export const PollListItemEnded: React.FC = ({ event, poll }) => { + const pollEvent = poll.pollEvent; + const { winningAnswers, totalVoteCount } = usePollVotes(poll); + if (!pollEvent) { + return null; + } + const formattedDate = formatLocalDateShort(event.getTs()); + + return ( +
  1. +
    + + {pollEvent.question.text} + {formattedDate} +
    + {!!winningAnswers?.length && ( +
    + {winningAnswers?.map(({ answer, voteCount }) => ( + + ))} +
    + )} +
    + {_t("Final result based on %(count)s votes", { count: totalVoteCount })} +
    +
  2. + ); +}; diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index c65a0de418e..6a92185e619 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -384,7 +384,7 @@ export function allVotes(voteRelations: Relations): Array { * @param {string?} selected Local echo selected option for the userId * @returns a Map of user ID to their vote info */ -function collectUserVotes( +export function collectUserVotes( userResponses: Array, userId?: string | null | undefined, selected?: string | null | undefined, @@ -405,7 +405,7 @@ function collectUserVotes( return userVotes; } -function countVotes(userVotes: Map, pollStart: PollStartEvent): Map { +export function countVotes(userVotes: Map, pollStart: PollStartEvent): Map { const collected = new Map(); for (const response of userVotes.values()) { diff --git a/test/components/views/dialogs/polls/PollListItem-test.tsx b/test/components/views/dialogs/polls/PollListItem-test.tsx index b9e8ffcc749..5744a31df5d 100644 --- a/test/components/views/dialogs/polls/PollListItem-test.tsx +++ b/test/components/views/dialogs/polls/PollListItem-test.tsx @@ -18,7 +18,7 @@ import React from "react"; import { render } from "@testing-library/react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; -import PollListItem from "../../../../../src/components/views/dialogs/polls/PollListItem"; +import { PollListItem } from "../../../../../src/components/views/dialogs/polls/PollListItem"; import { makePollStartEvent, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../../../../test-utils"; describe("", () => { diff --git a/test/components/views/dialogs/polls/PollListItemEnded-test.tsx b/test/components/views/dialogs/polls/PollListItemEnded-test.tsx new file mode 100644 index 00000000000..c58de7c5209 --- /dev/null +++ b/test/components/views/dialogs/polls/PollListItemEnded-test.tsx @@ -0,0 +1,181 @@ +/* +Copyright 2023 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 { render } from "@testing-library/react"; +import { MatrixEvent, Poll, Room } from "matrix-js-sdk/src/matrix"; +import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; + +import { PollListItemEnded } from "../../../../../src/components/views/dialogs/polls/PollListItemEnded"; +import { + flushPromises, + getMockClientWithEventEmitter, + makePollEndEvent, + makePollResponseEvent, + makePollStartEvent, + mockClientMethodsUser, + mockIntlDateTimeFormat, + setupRoomWithPollEvents, + unmockIntlDateTimeFormat, +} from "../../../../test-utils"; + +describe("", () => { + const userId = "@alice:domain.org"; + const roomId = "!room:domain.org"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getRoom: jest.fn(), + relations: jest.fn(), + decryptEventIfNeeded: jest.fn(), + }); + const room = new Room(roomId, mockClient, userId); + const timestamp = 1675300825090; + + const pollId = "1"; + const answerOne = { + id: "answerOneId", + [M_TEXT.name]: "Nissan Silvia S15", + }; + const answerTwo = { + id: "answerTwoId", + [M_TEXT.name]: "Mitsubishi Lancer Evolution IX", + }; + const pollStartEvent = makePollStartEvent("Question?", userId, [answerOne, answerTwo], { + roomId, + id: pollId, + ts: timestamp, + }); + const pollEndEvent = makePollEndEvent(pollId, roomId, userId, timestamp + 60000); + + const getComponent = (props: { event: MatrixEvent; poll: Poll }) => render(); + + beforeAll(() => { + // mock default locale to en-GB and set timezone + // so these tests run the same everywhere + mockIntlDateTimeFormat(); + }); + + afterAll(() => { + unmockIntlDateTimeFormat(); + }); + + it("renders a poll with no responses", async () => { + await setupRoomWithPollEvents([pollStartEvent], [], [pollEndEvent], mockClient, room); + const poll = room.polls.get(pollId)!; + + const { container } = getComponent({ event: pollStartEvent, poll }); + expect(container).toMatchSnapshot(); + }); + + it("renders a poll with one winning answer", async () => { + const responses = [ + makePollResponseEvent(pollId, [answerOne.id], userId, roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerOne.id], "@bob:domain.org", roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], "@charlie:domain.org", roomId, timestamp + 1), + ]; + await setupRoomWithPollEvents([pollStartEvent], responses, [pollEndEvent], mockClient, room); + const poll = room.polls.get(pollId)!; + + const { getByText } = getComponent({ event: pollStartEvent, poll }); + // fetch relations + await flushPromises(); + expect(getByText("Final result based on 3 votes")).toBeInTheDocument(); + // winning answer + expect(getByText("Nissan Silvia S15")).toBeInTheDocument(); + }); + + it("renders a poll with two winning answers", async () => { + const responses = [ + makePollResponseEvent(pollId, [answerOne.id], userId, roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerOne.id], "@han:domain.org", roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], "@sean:domain.org", roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], "@dk:domain.org", roomId, timestamp + 1), + ]; + await setupRoomWithPollEvents([pollStartEvent], responses, [pollEndEvent], mockClient, room); + const poll = room.polls.get(pollId)!; + + const { getByText } = getComponent({ event: pollStartEvent, poll }); + // fetch relations + await flushPromises(); + expect(getByText("Final result based on 4 votes")).toBeInTheDocument(); + // both answers answer + expect(getByText("Nissan Silvia S15")).toBeInTheDocument(); + expect(getByText("Mitsubishi Lancer Evolution IX")).toBeInTheDocument(); + }); + + it("counts one unique vote per user", async () => { + const responses = [ + makePollResponseEvent(pollId, [answerTwo.id], userId, roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], userId, roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], userId, roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerOne.id], userId, roomId, timestamp + 2), + makePollResponseEvent(pollId, [answerOne.id], "@bob:domain.org", roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], "@charlie:domain.org", roomId, timestamp + 1), + ]; + await setupRoomWithPollEvents([pollStartEvent], responses, [pollEndEvent], mockClient, room); + const poll = room.polls.get(pollId)!; + + const { getByText } = getComponent({ event: pollStartEvent, poll }); + // fetch relations + await flushPromises(); + + // still only 3 unique votes + expect(getByText("Final result based on 3 votes")).toBeInTheDocument(); + // only latest vote counted + expect(getByText("Nissan Silvia S15")).toBeInTheDocument(); + }); + + it("excludes malformed responses", async () => { + const responses = [ + makePollResponseEvent(pollId, ["bad-answer-id"], userId, roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerOne.id], "@bob:domain.org", roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], "@charlie:domain.org", roomId, timestamp + 1), + ]; + await setupRoomWithPollEvents([pollStartEvent], responses, [pollEndEvent], mockClient, room); + const poll = room.polls.get(pollId)!; + + const { getByText } = getComponent({ event: pollStartEvent, poll }); + // fetch relations + await flushPromises(); + + // invalid vote excluded + expect(getByText("Final result based on 2 votes")).toBeInTheDocument(); + }); + + it("updates on new responses", async () => { + const responses = [ + makePollResponseEvent(pollId, [answerOne.id], "@bob:domain.org", roomId, timestamp + 1), + makePollResponseEvent(pollId, [answerTwo.id], "@charlie:domain.org", roomId, timestamp + 1), + ]; + await setupRoomWithPollEvents([pollStartEvent], responses, [pollEndEvent], mockClient, room); + const poll = room.polls.get(pollId)!; + + const { getByText, queryByText } = getComponent({ event: pollStartEvent, poll }); + // fetch relations + await flushPromises(); + + expect(getByText("Final result based on 2 votes")).toBeInTheDocument(); + + await room.processPollEvents([ + makePollResponseEvent(pollId, [answerOne.id], "@han:domain.org", roomId, timestamp + 1), + ]); + + // updated with more responses + expect(getByText("Final result based on 3 votes")).toBeInTheDocument(); + expect(getByText("Nissan Silvia S15")).toBeInTheDocument(); + expect(queryByText("Mitsubishi Lancer Evolution IX")).not.toBeInTheDocument(); + }); +}); diff --git a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap index 1672f66fd67..96bb0578421 100644 --- a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap +++ b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap @@ -65,7 +65,7 @@ exports[` renders a list of active polls when there are pol
    1. renders a poll with no responses 1`] = ` +
      +
    2. +
      +
      + + Question? + + + 02/02/23 + +
      +
      + + Final result based on 0 votes + +
      +
    3. +
+`; diff --git a/test/test-utils/poll.ts b/test/test-utils/poll.ts index 37f90cd4bcf..81d95d532ab 100644 --- a/test/test-utils/poll.ts +++ b/test/test-utils/poll.ts @@ -17,7 +17,13 @@ limitations under the License. import { Mocked } from "jest-mock"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { M_POLL_START, PollAnswer, M_POLL_KIND_DISCLOSED, M_POLL_END } from "matrix-js-sdk/src/@types/polls"; +import { + M_POLL_START, + PollAnswer, + M_POLL_KIND_DISCLOSED, + M_POLL_END, + M_POLL_RESPONSE, +} from "matrix-js-sdk/src/@types/polls"; import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; import { uuid4 } from "@sentry/utils"; @@ -78,6 +84,30 @@ export const makePollEndEvent = (pollStartEventId: string, roomId: string, sende }); }; +export const makePollResponseEvent = ( + pollId: string, + answerIds: string[], + sender: string, + roomId: string, + ts = 0, +): MatrixEvent => + new MatrixEvent({ + event_id: uuid4(), + room_id: roomId, + origin_server_ts: ts, + type: M_POLL_RESPONSE.name, + sender, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + [M_POLL_RESPONSE.name]: { + answers: answerIds, + }, + }, + }); + /** * Creates a room with attached poll events * Returns room from mockClient