diff --git a/res/css/views/dialogs/polls/_PollHistoryList.pcss b/res/css/views/dialogs/polls/_PollHistoryList.pcss index 27dfc40aa6e..ee6f0254f71 100644 --- a/res/css/views/dialogs/polls/_PollHistoryList.pcss +++ b/res/css/views/dialogs/polls/_PollHistoryList.pcss @@ -46,3 +46,14 @@ limitations under the License. justify-content: center; color: $secondary-content; } + +.mx_PollHistoryList_loading { + color: $secondary-content; + text-align: center; + + // center in all free space + // when there are no results + &.mx_PollHistoryList_noResultsYet { + margin: auto auto; + } +} diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index fea5e5bdd09..bd6c9fae8a5 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix"; @@ -23,7 +23,8 @@ import BaseDialog from "../BaseDialog"; import { IDialogProps } from "../IDialogProps"; import { PollHistoryList } from "./PollHistoryList"; import { PollHistoryFilter } from "./types"; -import { usePolls } from "./usePollHistory"; +import { usePollsWithRelations } from "./usePollHistory"; +import { useFetchPastPolls } from "./fetchPastPolls"; type PollHistoryDialogProps = Pick & { roomId: string; @@ -34,7 +35,10 @@ const sortEventsByLatest = (left: MatrixEvent, right: MatrixEvent): number => ri const filterPolls = (filter: PollHistoryFilter) => (poll: Poll): boolean => - (filter === "ACTIVE") !== poll.isEnded; + // exclude polls while they are still loading + // to avoid jitter in list + !poll.isFetchingResponses && (filter === "ACTIVE") !== poll.isEnded; + const filterAndSortPolls = (polls: Map, filter: PollHistoryFilter): MatrixEvent[] => { return [...polls.values()] .filter(filterPolls(filter)) @@ -43,19 +47,20 @@ const filterAndSortPolls = (polls: Map, filter: PollHistoryFilter) }; export const PollHistoryDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { - const { polls } = usePolls(roomId, matrixClient); + const room = matrixClient.getRoom(roomId)!; + const { isLoading } = useFetchPastPolls(room, matrixClient); + const { polls } = usePollsWithRelations(roomId, matrixClient); const [filter, setFilter] = useState("ACTIVE"); - const [pollStartEvents, setPollStartEvents] = useState(filterAndSortPolls(polls, filter)); - useEffect(() => { - setPollStartEvents(filterAndSortPolls(polls, filter)); - }, [filter, polls]); + const pollStartEvents = filterAndSortPolls(polls, filter); + const isLoadingPollResponses = [...polls.values()].some((poll) => poll.isFetchingResponses); return (
= ({ noResultsYet }) => ( +
+ + {_t("Loading polls")} +
+); type PollHistoryListProps = { pollStartEvents: MatrixEvent[]; polls: Map; filter: PollHistoryFilter; onFilterChange: (filter: PollHistoryFilter) => void; + isLoading?: boolean; }; -export const PollHistoryList: React.FC = ({ pollStartEvents, polls, filter, onFilterChange }) => { +export const PollHistoryList: React.FC = ({ + pollStartEvents, + polls, + filter, + isLoading, + onFilterChange, +}) => { return (
@@ -42,7 +61,7 @@ export const PollHistoryList: React.FC = ({ pollStartEvent { id: "ENDED", label: "Past polls" }, ]} /> - {!!pollStartEvents.length ? ( + {!!pollStartEvents.length && (
    {pollStartEvents.map((pollStartEvent) => filter === "ACTIVE" ? ( @@ -55,14 +74,17 @@ export const PollHistoryList: React.FC = ({ pollStartEvent /> ), )} + {isLoading && }
- ) : ( + )} + {!pollStartEvents.length && !isLoading && ( {filter === "ACTIVE" ? _t("There are no active polls in this room") : _t("There are no past polls in this room")} )} + {!pollStartEvents.length && isLoading && }
); }; diff --git a/src/components/views/dialogs/polls/fetchPastPolls.ts b/src/components/views/dialogs/polls/fetchPastPolls.ts new file mode 100644 index 00000000000..1d045d3d079 --- /dev/null +++ b/src/components/views/dialogs/polls/fetchPastPolls.ts @@ -0,0 +1,129 @@ +/* +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 { useEffect, useState } from "react"; +import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { EventTimeline, EventTimelineSet, Room } from "matrix-js-sdk/src/matrix"; +import { Filter, IFilterDefinition } from "matrix-js-sdk/src/filter"; +import { logger } from "matrix-js-sdk/src/logger"; + +/** + * Page timeline backwards until either: + * - event older than endOfHistoryPeriodTimestamp is encountered + * - end of timeline is reached + * @param timelineSet - timelineset to page + * @param matrixClient - client + * @param endOfHistoryPeriodTimestamp - epoch timestamp to fetch until + * @returns void + */ +const pagePolls = async ( + timelineSet: EventTimelineSet, + matrixClient: MatrixClient, + endOfHistoryPeriodTimestamp: number, +): Promise => { + const liveTimeline = timelineSet.getLiveTimeline(); + const events = liveTimeline.getEvents(); + const oldestEventTimestamp = events[0]?.getTs() || Date.now(); + const hasMorePages = !!liveTimeline.getPaginationToken(EventTimeline.BACKWARDS); + + if (!hasMorePages || oldestEventTimestamp <= endOfHistoryPeriodTimestamp) { + return; + } + + await matrixClient.paginateEventTimeline(liveTimeline, { + backwards: true, + }); + + return pagePolls(timelineSet, matrixClient, endOfHistoryPeriodTimestamp); +}; + +const ONE_DAY_MS = 60000 * 60 * 24; +/** + * Fetches timeline history for given number of days in past + * @param timelineSet - timelineset to page + * @param matrixClient - client + * @param historyPeriodDays - number of days of history to fetch, from current day + * @returns isLoading - true while fetching history + */ +const useTimelineHistory = ( + timelineSet: EventTimelineSet | null, + matrixClient: MatrixClient, + historyPeriodDays: number, +): { isLoading: boolean } => { + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!timelineSet) { + return; + } + const endOfHistoryPeriodTimestamp = Date.now() - ONE_DAY_MS * historyPeriodDays; + + const doFetchHistory = async (): Promise => { + setIsLoading(true); + try { + await pagePolls(timelineSet, matrixClient, endOfHistoryPeriodTimestamp); + } catch (error) { + logger.error("Failed to fetch room polls history", error); + } finally { + setIsLoading(false); + } + }; + doFetchHistory(); + }, [timelineSet, historyPeriodDays, matrixClient]); + + return { isLoading }; +}; + +const filterDefinition: IFilterDefinition = { + room: { + timeline: { + types: [M_POLL_START.name, M_POLL_START.altName], + }, + }, +}; + +/** + * Fetch poll start events in the last N days of room history + * @param room - room to fetch history for + * @param matrixClient - client + * @param historyPeriodDays - number of days of history to fetch, from current day + * @returns isLoading - true while fetching history + */ +export const useFetchPastPolls = ( + room: Room, + matrixClient: MatrixClient, + historyPeriodDays = 30, +): { isLoading: boolean } => { + const [timelineSet, setTimelineSet] = useState(null); + + useEffect(() => { + const filter = new Filter(matrixClient.getSafeUserId()); + filter.setDefinition(filterDefinition); + const getFilteredTimelineSet = async (): Promise => { + const filterId = await matrixClient.getOrCreateFilter(`POLL_HISTORY_FILTER_${room.roomId}}`, filter); + filter.filterId = filterId; + const timelineSet = room.getOrCreateFilteredTimelineSet(filter); + setTimelineSet(timelineSet); + }; + + getFilteredTimelineSet(); + }, [room, matrixClient]); + + const { isLoading } = useTimelineHistory(timelineSet, matrixClient, historyPeriodDays); + + return { isLoading }; +}; diff --git a/src/components/views/dialogs/polls/usePollHistory.ts b/src/components/views/dialogs/polls/usePollHistory.ts index 1da2b4ee1de..dafb241f198 100644 --- a/src/components/views/dialogs/polls/usePollHistory.ts +++ b/src/components/views/dialogs/polls/usePollHistory.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { useEffect, useState } from "react"; import { Poll, PollEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; @@ -21,6 +22,7 @@ import { useEventEmitterState } from "../../../../hooks/useEventEmitter"; /** * Get poll instances from a room + * Updates to include new polls * @param roomId - id of room to retrieve polls for * @param matrixClient - client * @returns {Map} - Map of Poll instances @@ -37,9 +39,58 @@ export const usePolls = ( throw new Error("Cannot find room"); } - const polls = useEventEmitterState(room, PollEvent.New, () => room.polls); - - // @TODO(kerrya) watch polls for end events, trigger refiltering + // copy room.polls map so changes can be detected + const polls = useEventEmitterState(room, PollEvent.New, () => new Map(room.polls)); return { polls }; }; + +/** + * Get all poll instances from a room + * Fetch their responses (using cached poll responses) + * Updates on: + * - new polls added to room + * - new responses added to polls + * - changes to poll ended state + * @param roomId - id of room to retrieve polls for + * @param matrixClient - client + * @returns {Map} - Map of Poll instances + */ +export const usePollsWithRelations = ( + roomId: string, + matrixClient: MatrixClient, +): { + polls: Map; +} => { + const { polls } = usePolls(roomId, matrixClient); + const [pollsWithRelations, setPollsWithRelations] = useState>(polls); + + useEffect(() => { + const onPollUpdate = async (): Promise => { + // trigger rerender by creating a new poll map + setPollsWithRelations(new Map(polls)); + }; + if (polls) { + for (const poll of polls.values()) { + // listen to changes in responses and end state + poll.on(PollEvent.End, onPollUpdate); + poll.on(PollEvent.Responses, onPollUpdate); + // trigger request to get all responses + // if they are not already in cache + poll.getResponses(); + } + setPollsWithRelations(polls); + } + // unsubscribe + return () => { + if (polls) { + for (const poll of polls.values()) { + poll.off(PollEvent.End, onPollUpdate); + poll.off(PollEvent.Responses, onPollUpdate); + } + } + }; + }, [polls, setPollsWithRelations]); + + return { polls: pollsWithRelations }; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 18bc7e9fb80..632f50d4c1f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3131,6 +3131,7 @@ "Not a valid Security Key": "Not a valid Security Key", "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 ", + "Loading polls": "Loading polls", "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", diff --git a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx index b02bbb409b1..062fe2d0d89 100644 --- a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -16,10 +16,13 @@ limitations under the License. import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { Filter } from "matrix-js-sdk/src/filter"; +import { EventTimeline, Room } from "matrix-js-sdk/src/matrix"; +import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/polls/PollHistoryDialog"; import { + flushPromises, getMockClientWithEventEmitter, makePollEndEvent, makePollStartEvent, @@ -30,6 +33,8 @@ import { } from "../../../../test-utils"; describe("", () => { + // 14.03.2022 16:15 + const now = 1647270879403; const userId = "@alice:domain.org"; const roomId = "!room:domain.org"; const mockClient = getMockClientWithEventEmitter({ @@ -37,8 +42,19 @@ describe("", () => { getRoom: jest.fn(), relations: jest.fn(), decryptEventIfNeeded: jest.fn(), + getOrCreateFilter: jest.fn(), + paginateEventTimeline: jest.fn(), + }); + let room = new Room(roomId, mockClient, userId); + + const expectedFilter = new Filter(userId); + expectedFilter.setDefinition({ + room: { + timeline: { + types: [M_POLL_START.name, M_POLL_START.altName], + }, + }, }); - const room = new Room(roomId, mockClient, userId); const defaultProps = { roomId, @@ -52,10 +68,16 @@ describe("", () => { }); beforeEach(() => { + room = new Room(roomId, mockClient, userId); mockClient.getRoom.mockReturnValue(room); mockClient.relations.mockResolvedValue({ events: [] }); const timeline = room.getLiveTimeline(); jest.spyOn(timeline, "getEvents").mockReturnValue([]); + jest.spyOn(room, "getOrCreateFilteredTimelineSet"); + mockClient.getOrCreateFilter.mockResolvedValue(expectedFilter.filterId!); + mockClient.paginateEventTimeline.mockReset().mockResolvedValue(false); + + jest.spyOn(Date, "now").mockReturnValue(now); }); afterAll(() => { @@ -68,21 +90,161 @@ describe("", () => { expect(() => getComponent()).toThrow("Cannot find room"); }); - it("renders a no polls message when there are no active polls in the timeline", () => { + it("renders a loading message while poll history is fetched", async () => { + const timelineSet = room.getOrCreateFilteredTimelineSet(expectedFilter); + const liveTimeline = timelineSet.getLiveTimeline(); + jest.spyOn(liveTimeline, "getPaginationToken").mockReturnValueOnce("test-pagination-token"); + + const { queryByText, getByText } = getComponent(); + + expect(mockClient.getOrCreateFilter).toHaveBeenCalledWith( + `POLL_HISTORY_FILTER_${room.roomId}}`, + expectedFilter, + ); + // no results not shown until loading finished + expect(queryByText("There are no active polls in this room")).not.toBeInTheDocument(); + expect(getByText("Loading polls")).toBeInTheDocument(); + + // flush filter creation request + await flushPromises(); + + expect(liveTimeline.getPaginationToken).toHaveBeenCalledWith(EventTimeline.BACKWARDS); + expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(liveTimeline, { backwards: true }); + // only one page + expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); + + // finished loading + expect(queryByText("Loading polls")).not.toBeInTheDocument(); + expect(getByText("There are no active polls in this room")).toBeInTheDocument(); + }); + + it("fetches poll history until end of timeline is reached while within time limit", async () => { + const timelineSet = room.getOrCreateFilteredTimelineSet(expectedFilter); + const liveTimeline = timelineSet.getLiveTimeline(); + + // mock three pages of timeline history + jest.spyOn(liveTimeline, "getPaginationToken") + .mockReturnValueOnce("test-pagination-token-1") + .mockReturnValueOnce("test-pagination-token-2") + .mockReturnValueOnce("test-pagination-token-3"); + + const { queryByText, getByText } = getComponent(); + + expect(mockClient.getOrCreateFilter).toHaveBeenCalledWith( + `POLL_HISTORY_FILTER_${room.roomId}}`, + expectedFilter, + ); + + // flush filter creation request + await flushPromises(); + // once per page + expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(3); + + // finished loading + expect(queryByText("Loading polls")).not.toBeInTheDocument(); + expect(getByText("There are no active polls in this room")).toBeInTheDocument(); + }); + + it("fetches poll history until event older than history period is reached", async () => { + const timelineSet = room.getOrCreateFilteredTimelineSet(expectedFilter); + const liveTimeline = timelineSet.getLiveTimeline(); + const thirtyOneDaysAgoTs = now - 60000 * 60 * 24 * 31; + + jest.spyOn(liveTimeline, "getEvents") + .mockReturnValueOnce([]) + .mockReturnValueOnce([makePollStartEvent("Question?", userId, undefined, { ts: thirtyOneDaysAgoTs })]); + + // mock three pages of timeline history + jest.spyOn(liveTimeline, "getPaginationToken") + .mockReturnValueOnce("test-pagination-token-1") + .mockReturnValueOnce("test-pagination-token-2") + .mockReturnValueOnce("test-pagination-token-3"); + + getComponent(); + + // flush filter creation request + await flushPromises(); + // after first fetch the time limit is reached + // stop paging + expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); + }); + + it("displays loader and list while paging timeline", async () => { + const timelineSet = room.getOrCreateFilteredTimelineSet(expectedFilter); + const liveTimeline = timelineSet.getLiveTimeline(); + const tenDaysAgoTs = now - 60000 * 60 * 24 * 10; + + jest.spyOn(liveTimeline, "getEvents").mockReset().mockReturnValue([]); + + // mock three pages of timeline history + jest.spyOn(liveTimeline, "getPaginationToken") + .mockReturnValueOnce("test-pagination-token-1") + .mockReturnValueOnce("test-pagination-token-2") + .mockReturnValueOnce("test-pagination-token-3"); + + // reference to pagination resolve, so we can assert between pages + let resolvePagination1: (value: boolean) => void | undefined; + let resolvePagination2: (value: boolean) => void | undefined; + mockClient.paginateEventTimeline + .mockImplementationOnce(async (_p) => { + const pollStart = makePollStartEvent("Question?", userId, undefined, { ts: now, id: "1" }); + jest.spyOn(liveTimeline, "getEvents").mockReturnValue([pollStart]); + room.processPollEvents([pollStart]); + return new Promise((resolve) => (resolvePagination1 = resolve)); + }) + .mockImplementationOnce(async (_p) => { + const pollStart = makePollStartEvent("Older question?", userId, undefined, { + ts: tenDaysAgoTs, + id: "2", + }); + jest.spyOn(liveTimeline, "getEvents").mockReturnValue([pollStart]); + room.processPollEvents([pollStart]); + return new Promise((resolve) => (resolvePagination2 = resolve)); + }); + + const { getByText, queryByText } = getComponent(); + + await flushPromises(); + + expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); + + resolvePagination1!(true); + await flushPromises(); + + // first page has results, display immediately + expect(getByText("Question?")).toBeInTheDocument(); + // but we are still fetching history, diaply loader + expect(getByText("Loading polls")).toBeInTheDocument(); + + resolvePagination2!(true); + await flushPromises(); + + // additional results addeds + expect(getByText("Older question?")).toBeInTheDocument(); + expect(getByText("Question?")).toBeInTheDocument(); + // finished paging + expect(queryByText("Loading polls")).not.toBeInTheDocument(); + + expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(3); + }); + + it("renders a no polls message when there are no active polls in the room", async () => { const { getByText } = getComponent(); + await flushPromises(); expect(getByText("There are no active polls in this room")).toBeTruthy(); }); - it("renders a no past polls message when there are no past polls in the timeline", () => { + it("renders a no past polls message when there are no past polls in the room", async () => { const { getByText } = getComponent(); + await flushPromises(); 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 () => { + it("renders a list of active polls when there are polls in the room", 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" }); @@ -92,6 +254,9 @@ describe("", () => { const { container, queryByText, getByTestId } = getComponent(); + // flush relations calls for polls + await flushPromises(); + expect(getByTestId("filter-tab-PollHistoryDialog_filter-ACTIVE").firstElementChild).toBeChecked(); expect(container).toMatchSnapshot(); @@ -99,6 +264,32 @@ describe("", () => { expect(queryByText("What?")).not.toBeInTheDocument(); }); + it("updates when new polls are added to the room", 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" }); + // initially room has only one poll + await setupRoomWithPollEvents([pollStart1], [], [], mockClient, room); + + const { getByText } = getComponent(); + + // wait for relations + await flushPromises(); + + expect(getByText("Question?")).toBeInTheDocument(); + + // add another poll + // paged history requests using cli.paginateEventTimeline + // call this with new events + await room.processPollEvents([pollStart2]); + // await relations for new poll + await flushPromises(); + + expect(getByText("Question?")).toBeInTheDocument(); + // list updated to include new poll + expect(getByText("Where?")).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" }); @@ -107,6 +298,7 @@ describe("", () => { await setupRoomWithPollEvents([pollStart1, pollStart2, pollStart3], [], [pollEnd3], mockClient, room); const { getByText, queryByText, getByTestId } = getComponent(); + await flushPromises(); expect(getByText("Question?")).toBeInTheDocument(); expect(getByText("Where?")).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 96bb0578421..aae4cbc1961 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 active polls when there are polls in the timeline 1`] = ` +exports[` renders a list of active polls when there are polls in the room 1`] = `