diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 541178b926..fff2d7ebff 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -19,6 +19,7 @@ @import "./components/views/context_menus/_KebabContextMenu.pcss"; @import "./components/views/dialogs/polls/_PollListItem.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; +@import "./components/views/elements/_FilterTabGroup.pcss"; @import "./components/views/elements/_LearnMore.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss"; diff --git a/res/css/components/views/elements/_FilterTabGroup.pcss b/res/css/components/views/elements/_FilterTabGroup.pcss new file mode 100644 index 0000000000..bbf1a279ad --- /dev/null +++ b/res/css/components/views/elements/_FilterTabGroup.pcss @@ -0,0 +1,46 @@ +/* +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_FilterTabGroup { + color: $primary-content; + label { + margin-right: $spacing-12; + cursor: pointer; + span { + display: inline-block; + line-height: $font-24px; + } + } + input[type="radio"] { + appearance: none; + margin: 0; + padding: 0; + + &:focus, + &:hover { + & + span { + color: $secondary-content; + } + } + + &:checked + span { + color: $accent; + font-weight: $font-semi-bold; + // underline + box-shadow: 0 1.5px 0 0 currentColor; + } + } +} diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index 4671da9246..e5525fbbaf 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -14,26 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useEffect, useState } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../languageHandler"; import BaseDialog from "../BaseDialog"; import { IDialogProps } from "../IDialogProps"; import { PollHistoryList } from "./PollHistoryList"; -import { getPolls } from "./usePollHistory"; +import { PollHistoryFilter } from "./types"; +import { usePolls } from "./usePollHistory"; type PollHistoryDialogProps = Pick & { roomId: string; matrixClient: MatrixClient; }; + +const sortEventsByLatest = (left: MatrixEvent, right: MatrixEvent): number => right.getTs() - left.getTs(); +const filterPolls = + (filter: PollHistoryFilter) => + (poll: Poll): boolean => + (filter === "ACTIVE") !== poll.isEnded; +const filterAndSortPolls = (polls: Map, filter: PollHistoryFilter): MatrixEvent[] => { + return [...polls.values()] + .filter(filterPolls(filter)) + .map((poll) => poll.rootEvent) + .sort(sortEventsByLatest); +}; + export const PollHistoryDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { - const pollStartEvents = getPolls(roomId, matrixClient); + const { polls } = usePolls(roomId, matrixClient); + const [filter, setFilter] = useState("ACTIVE"); + const [pollStartEvents, setPollStartEvents] = useState(filterAndSortPolls(polls, filter)); + + useEffect(() => { + setPollStartEvents(filterAndSortPolls(polls, filter)); + }, [filter, polls]); return (
- +
); diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx index ff0ea3a7cf..7c8714aeac 100644 --- a/src/components/views/dialogs/polls/PollHistoryList.tsx +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -19,13 +19,26 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import PollListItem from "./PollListItem"; import { _t } from "../../../../languageHandler"; +import { FilterTabGroup } from "../../elements/FilterTabGroup"; +import { PollHistoryFilter } from "./types"; type PollHistoryListProps = { pollStartEvents: MatrixEvent[]; + filter: PollHistoryFilter; + onFilterChange: (filter: PollHistoryFilter) => void; }; -export const PollHistoryList: React.FC = ({ pollStartEvents }) => { +export const PollHistoryList: React.FC = ({ pollStartEvents, filter, onFilterChange }) => { return (
+ + name="PollHistoryDialog_filter" + value={filter} + onFilterChange={onFilterChange} + tabs={[ + { id: "ACTIVE", label: "Active polls" }, + { id: "ENDED", label: "Past polls" }, + ]} + /> {!!pollStartEvents.length ? (
    {pollStartEvents.map((pollStartEvent) => ( @@ -33,7 +46,11 @@ export const PollHistoryList: React.FC = ({ pollStartEvent ))}
) : ( - {_t("There are no polls in this room")} + + {filter === "ACTIVE" + ? _t("There are no active polls in this room") + : _t("There are no past polls in this room")} + )}
); diff --git a/src/components/views/dialogs/polls/types.ts b/src/components/views/dialogs/polls/types.ts new file mode 100644 index 0000000000..1664203475 --- /dev/null +++ b/src/components/views/dialogs/polls/types.ts @@ -0,0 +1,22 @@ +/* +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. +*/ + +/** + * Possible values for the "filter" setting in the poll history dialog + * + * Ended polls have a valid M_POLL_END event + */ +export type PollHistoryFilter = "ACTIVE" | "ENDED"; diff --git a/src/components/views/dialogs/polls/usePollHistory.ts b/src/components/views/dialogs/polls/usePollHistory.ts index aa730b84ee..1da2b4ee1d 100644 --- a/src/components/views/dialogs/polls/usePollHistory.ts +++ b/src/components/views/dialogs/polls/usePollHistory.ts @@ -14,27 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { Poll, PollEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { useEventEmitterState } from "../../../../hooks/useEventEmitter"; + /** - * Get poll start events in a rooms live timeline + * Get poll instances from a room * @param roomId - id of room to retrieve polls for * @param matrixClient - client - * @returns {MatrixEvent[]} - array fo poll start events + * @returns {Map} - Map of Poll instances */ -export const getPolls = (roomId: string, matrixClient: MatrixClient): MatrixEvent[] => { +export const usePolls = ( + roomId: string, + matrixClient: MatrixClient, +): { + polls: Map; +} => { const room = matrixClient.getRoom(roomId); if (!room) { throw new Error("Cannot find room"); } - // @TODO(kerrya) poll history will be actively fetched in PSG-1043 - // for now, just display polls that are in the current timeline - const timelineEvents = room.getLiveTimeline().getEvents(); - const pollStartEvents = timelineEvents.filter((event) => M_POLL_START.matches(event.getType())); + const polls = useEventEmitterState(room, PollEvent.New, () => room.polls); - return pollStartEvents; + // @TODO(kerrya) watch polls for end events, trigger refiltering + + return { polls }; }; diff --git a/src/components/views/elements/FilterTabGroup.tsx b/src/components/views/elements/FilterTabGroup.tsx new file mode 100644 index 0000000000..91991fbd0e --- /dev/null +++ b/src/components/views/elements/FilterTabGroup.tsx @@ -0,0 +1,57 @@ +/* +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, { FieldsetHTMLAttributes, ReactNode } from "react"; + +export type FilterTab = { + label: string | ReactNode; + id: T; +}; +type FilterTabGroupProps = FieldsetHTMLAttributes & { + // group name used for radio buttons + name: string; + onFilterChange: (id: T) => void; + // active tab's id + value: T; + // tabs to display + tabs: FilterTab[]; +}; + +/** + * React component which styles a set of content filters as tabs + * + * This is used in displays which show a list of content items, and the user can select between one of several + * filters for those items. For example, in the Poll History dialog, the user can select between "Active" and "Ended" + * polls. + * + * Type `T` is used for the `value` attribute for the buttons in the radio group. + */ +export const FilterTabGroup = ({ + name, + value, + tabs, + onFilterChange, + ...rest +}: FilterTabGroupProps): JSX.Element => ( +
+ {tabs.map(({ label, id }) => ( + + ))} +
+); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d894261c74..7cc713811f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3144,7 +3144,8 @@ "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.", "If you've forgotten your Security Key you can ": "If you've forgotten your Security Key you can ", - "There are no polls in this room": "There are no polls in this room", + "There are no active polls in this room": "There are no active polls in this room", + "There are no past polls in this room": "There are no past polls in this room", "Send custom account data event": "Send custom account data event", "Send custom room account data event": "Send custom room account data event", "Event Type": "Event Type", diff --git a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx index 3c937bdc68..b02bbb409b 100644 --- a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -15,15 +15,17 @@ limitations under the License. */ import React from "react"; -import { render } from "@testing-library/react"; -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { fireEvent, render } from "@testing-library/react"; +import { Room } from "matrix-js-sdk/src/matrix"; import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/polls/PollHistoryDialog"; import { getMockClientWithEventEmitter, + makePollEndEvent, makePollStartEvent, mockClientMethodsUser, mockIntlDateTimeFormat, + setupRoomWithPollEvents, unmockIntlDateTimeFormat, } from "../../../../test-utils"; @@ -33,6 +35,8 @@ describe("", () => { const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), getRoom: jest.fn(), + relations: jest.fn(), + decryptEventIfNeeded: jest.fn(), }); const room = new Room(roomId, mockClient, userId); @@ -49,6 +53,7 @@ describe("", () => { beforeEach(() => { mockClient.getRoom.mockReturnValue(room); + mockClient.relations.mockResolvedValue({ events: [] }); const timeline = room.getLiveTimeline(); jest.spyOn(timeline, "getEvents").mockReturnValue([]); }); @@ -63,24 +68,58 @@ describe("", () => { expect(() => getComponent()).toThrow("Cannot find room"); }); - it("renders a no polls message when there are no polls in the timeline", () => { + it("renders a no polls message when there are no active polls in the timeline", () => { const { getByText } = getComponent(); - expect(getByText("There are no polls in this room")).toBeTruthy(); + expect(getByText("There are no active polls in this room")).toBeTruthy(); }); - it("renders a list of polls when there are polls in the timeline", async () => { + it("renders a no past polls message when there are no past polls in the timeline", () => { + const { getByText } = getComponent(); + + fireEvent.click(getByText("Past polls")); + + expect(getByText("There are no past polls in this room")).toBeTruthy(); + }); + + it("renders a list of active polls when there are polls in the timeline", async () => { + const timestamp = 1675300825090; + const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: timestamp, id: "$1" }); + const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: timestamp + 10000, id: "$2" }); + const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: timestamp + 70000, id: "$3" }); + const pollEnd3 = makePollEndEvent(pollStart3.getId()!, roomId, userId, timestamp + 1); + await setupRoomWithPollEvents([pollStart2, pollStart3, pollStart1], [], [pollEnd3], mockClient, room); + + const { container, queryByText, getByTestId } = getComponent(); + + expect(getByTestId("filter-tab-PollHistoryDialog_filter-ACTIVE").firstElementChild).toBeChecked(); + + expect(container).toMatchSnapshot(); + // this poll is ended, and default filter is ACTIVE + expect(queryByText("What?")).not.toBeInTheDocument(); + }); + + it("filters ended polls", async () => { const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: 1675300825090, id: "$1" }); const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: 1675300725090, id: "$2" }); const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: 1675200725090, id: "$3" }); - const message = new MatrixEvent({ - type: "m.room.message", - content: {}, - }); - const timeline = room.getLiveTimeline(); - jest.spyOn(timeline, "getEvents").mockReturnValue([pollStart1, pollStart2, pollStart3, message]); - const { container } = getComponent(); + const pollEnd3 = makePollEndEvent(pollStart3.getId()!, roomId, userId, 1675200725090 + 1); + await setupRoomWithPollEvents([pollStart1, pollStart2, pollStart3], [], [pollEnd3], mockClient, room); - expect(container).toMatchSnapshot(); + const { getByText, queryByText, getByTestId } = getComponent(); + + expect(getByText("Question?")).toBeInTheDocument(); + expect(getByText("Where?")).toBeInTheDocument(); + // this poll is ended, and default filter is ACTIVE + expect(queryByText("What?")).not.toBeInTheDocument(); + + fireEvent.click(getByText("Past polls")); + expect(getByTestId("filter-tab-PollHistoryDialog_filter-ENDED").firstElementChild).toBeChecked(); + + // active polls no longer shown + expect(queryByText("Question?")).not.toBeInTheDocument(); + expect(queryByText("Where?")).not.toBeInTheDocument(); + // this poll is ended + expect(getByText("What?")).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 fd572bc2d1..1672f66fd6 100644 --- a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap +++ b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders a list of polls when there are polls in the timeline 1`] = ` +exports[` renders a list of active polls when there are polls in the timeline 1`] = `
renders a list of polls when there are polls in t
+
+ + +
    -
  1. - - 02/02/23 - -
    - - Question? - -
  2. renders a list of polls when there are polls in t
  3. - 31/01/23 + 02/02/23
    renders a list of polls when there are polls in t - What? + Question?
diff --git a/test/components/views/elements/FilterTabGroup-test.tsx b/test/components/views/elements/FilterTabGroup-test.tsx new file mode 100644 index 0000000000..0dc146fd37 --- /dev/null +++ b/test/components/views/elements/FilterTabGroup-test.tsx @@ -0,0 +1,54 @@ +/* +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 { fireEvent, render } from "@testing-library/react"; + +import { FilterTabGroup } from "../../../../src/components/views/elements/FilterTabGroup"; + +describe("", () => { + enum TestOption { + Apple = "Apple", + Banana = "Banana", + Orange = "Orange", + } + const defaultProps = { + "name": "test", + "value": TestOption.Apple, + "onFilterChange": jest.fn(), + "tabs": [ + { id: TestOption.Apple, label: `Label for ${TestOption.Apple}` }, + { id: TestOption.Banana, label: `Label for ${TestOption.Banana}` }, + { id: TestOption.Orange, label: `Label for ${TestOption.Orange}` }, + ], + "data-testid": "test", + }; + const getComponent = (props = {}) => {...defaultProps} {...props} />; + + it("renders options", () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it("calls onChange handler on selection", () => { + const onFilterChange = jest.fn(); + const { getByText } = render(getComponent({ onFilterChange })); + + fireEvent.click(getByText("Label for Banana")); + + expect(onFilterChange).toHaveBeenCalledWith(TestOption.Banana); + }); +}); diff --git a/test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap b/test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap new file mode 100644 index 0000000000..e87979a8ad --- /dev/null +++ b/test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders options 1`] = ` +
+
+ + + +
+
+`; diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index a24cc5de0c..0b1be75e3f 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -227,7 +227,7 @@ describe("MPollBody", () => { content: newPollStart(undefined, undefined, true), }); const props = getMPollBodyPropsFromEvent(mxEvent); - const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient); + const room = await setupRoomWithPollEvents([mxEvent], votes, [], mockClient); const renderResult = renderMPollBodyWithWrapper(props); // wait for /relations promise to resolve await flushPromises(); @@ -255,7 +255,7 @@ describe("MPollBody", () => { content: newPollStart(undefined, undefined, true), }); const props = getMPollBodyPropsFromEvent(mxEvent); - const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient); + const room = await setupRoomWithPollEvents([mxEvent], votes, [], mockClient); const renderResult = renderMPollBodyWithWrapper(props); // wait for /relations promise to resolve await flushPromises(); @@ -700,7 +700,7 @@ describe("MPollBody", () => { }); const ends = [newPollEndEvent("@me:example.com", 25)]; - await setupRoomWithPollEvents(pollEvent, [], ends, mockClient); + await setupRoomWithPollEvents([pollEvent], [], ends, mockClient); const poll = mockClient.getRoom(pollEvent.getRoomId()!)!.polls.get(pollEvent.getId()!)!; // start fetching, dont await poll.getResponses(); @@ -920,7 +920,7 @@ async function newMPollBodyFromEvent( ): Promise { const props = getMPollBodyPropsFromEvent(mxEvent); - await setupRoomWithPollEvents(mxEvent, relationEvents, endEvents, mockClient); + await setupRoomWithPollEvents([mxEvent], relationEvents, endEvents, mockClient); return renderMPollBodyWithWrapper(props); } @@ -1036,7 +1036,7 @@ async function runIsPollEnded(ends: MatrixEvent[]) { content: newPollStart(), }); - await setupRoomWithPollEvents(pollEvent, [], ends, mockClient); + await setupRoomWithPollEvents([pollEvent], [], ends, mockClient); return isPollEnded(pollEvent, mockClient); } diff --git a/test/components/views/messages/MPollEndBody-test.tsx b/test/components/views/messages/MPollEndBody-test.tsx index f34d2003db..2e78646ceb 100644 --- a/test/components/views/messages/MPollEndBody-test.tsx +++ b/test/components/views/messages/MPollEndBody-test.tsx @@ -50,7 +50,7 @@ describe("", () => { const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise => { if (pollStart) { - await setupRoomWithPollEvents(pollStart, [], [pollEnd], mockClient); + await setupRoomWithPollEvents([pollStart], [], [pollEnd], mockClient); } const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId); diff --git a/test/test-utils/poll.ts b/test/test-utils/poll.ts index 9cb108b676..37f90cd4bc 100644 --- a/test/test-utils/poll.ts +++ b/test/test-utils/poll.ts @@ -89,13 +89,14 @@ export const makePollEndEvent = (pollStartEventId: string, roomId: string, sende * @returns */ export const setupRoomWithPollEvents = async ( - mxEvent: MatrixEvent, + pollStartEvents: MatrixEvent[], relationEvents: Array, endEvents: Array = [], mockClient: Mocked, + existingRoom?: Room, ): Promise => { - const room = new Room(mxEvent.getRoomId()!, mockClient, mockClient.getSafeUserId()); - room.processPollEvents([mxEvent, ...relationEvents, ...endEvents]); + const room = existingRoom || new Room(pollStartEvents[0].getRoomId()!, mockClient, mockClient.getSafeUserId()); + room.processPollEvents([...pollStartEvents, ...relationEvents, ...endEvents]); // set redaction allowed for current user only // poll end events are validated against this @@ -106,8 +107,10 @@ export const setupRoomWithPollEvents = async ( // wait for events to process on room await flushPromises(); mockClient.getRoom.mockReturnValue(room); - mockClient.relations.mockResolvedValue({ - events: [...relationEvents, ...endEvents], + mockClient.relations.mockImplementation(async (_roomId: string, eventId: string) => { + return { + events: [...relationEvents, ...endEvents].filter((event) => event.getRelation()?.event_id === eventId), + }; }); return room; };