diff --git a/res/css/views/dialogs/polls/_PollHistoryList.pcss b/res/css/views/dialogs/polls/_PollHistoryList.pcss index ee6f0254f7..be6ca7423a 100644 --- a/res/css/views/dialogs/polls/_PollHistoryList.pcss +++ b/res/css/views/dialogs/polls/_PollHistoryList.pcss @@ -41,10 +41,20 @@ limitations under the License. .mx_PollHistoryList_noResults { height: 100%; width: 100%; + box-sizing: border-box; + padding: 0 $spacing-64; display: flex; + flex-direction: column; align-items: center; justify-content: center; + text-align: center; + + line-height: $font-24px; color: $secondary-content; + + .mx_PollHistoryList_loadMorePolls { + margin-top: $spacing-16; + } } .mx_PollHistoryList_loading { @@ -57,3 +67,7 @@ limitations under the License. margin: auto auto; } } + +.mx_PollHistoryList_loadMorePolls { + width: max-content; +} diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index ba16ed2b0c..a1cc273bf9 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -57,9 +57,9 @@ export const PollHistoryDialog: React.FC = ({ onFinished, }) => { const { polls } = usePollsWithRelations(room.roomId, matrixClient); + const { isLoading, loadMorePolls, oldestEventTimestamp } = useFetchPastPolls(room, matrixClient); const [filter, setFilter] = useState("ACTIVE"); const [focusedPollId, setFocusedPollId] = useState(null); - const { isLoading } = useFetchPastPolls(room, matrixClient); const pollStartEvents = filterAndSortPolls(polls, filter); const isLoadingPollResponses = [...polls.values()].some((poll) => poll.isFetchingResponses); @@ -78,12 +78,14 @@ export const PollHistoryDialog: React.FC = ({ ) : ( )} diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx index d3b0cae504..c3f2f2fcf4 100644 --- a/src/components/views/dialogs/polls/PollHistoryList.tsx +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -24,6 +24,7 @@ import InlineSpinner from "../../elements/InlineSpinner"; import { PollHistoryFilter } from "./types"; import { PollListItem } from "./PollListItem"; import { PollListItemEnded } from "./PollListItemEnded"; +import AccessibleButton from "../../elements/AccessibleButton"; const LoadingPolls: React.FC<{ noResultsYet?: boolean }> = ({ noResultsYet }) => (
= ({ noResultsYet }) =>
); +const LoadMorePolls: React.FC<{ loadMorePolls?: () => void; isLoading?: boolean }> = ({ isLoading, loadMorePolls }) => + loadMorePolls ? ( + loadMorePolls()} + > + {_t("Load more polls")} + {isLoading && } + + ) : null; + +const ONE_DAY_MS = 60000 * 60 * 24; +const getNoResultsMessage = ( + filter: PollHistoryFilter, + oldestEventTimestamp?: number, + loadMorePolls?: () => void, +): string => { + if (!loadMorePolls) { + return filter === "ACTIVE" + ? _t("There are no active polls in this room") + : _t("There are no past polls in this room"); + } + + // we don't know how much history has been fetched + if (!oldestEventTimestamp) { + return filter === "ACTIVE" + ? _t("There are no active polls. Load more polls to view polls for previous months") + : _t("There are no past polls. Load more polls to view polls for previous months"); + } + + const fetchedHistoryDaysCount = Math.ceil((Date.now() - oldestEventTimestamp) / ONE_DAY_MS); + return filter === "ACTIVE" + ? _t( + "There are no active polls for the past %(count)s days. Load more polls to view polls for previous months", + { count: fetchedHistoryDaysCount }, + ) + : _t("There are no past polls for the past %(count)s days. Load more polls to view polls for previous months", { + count: fetchedHistoryDaysCount, + }); +}; + +const NoResults: React.FC<{ + filter: PollHistoryFilter; + oldestFetchedEventTimestamp?: number; + loadMorePolls?: () => void; + isLoading?: boolean; +}> = ({ filter, isLoading, oldestFetchedEventTimestamp, loadMorePolls }) => { + // we can't page the timeline anymore + // just use plain loader + if (!loadMorePolls && isLoading) { + return ; + } + + return ( + + {getNoResultsMessage(filter, oldestFetchedEventTimestamp, loadMorePolls)} + + {!!loadMorePolls && } + + ); +}; + type PollHistoryListProps = { pollStartEvents: MatrixEvent[]; polls: Map; filter: PollHistoryFilter; - isLoading?: boolean; + /** + * server ts of the oldest fetched poll + * ignoring filter + * used to render no results in last x days message + * undefined when no polls are found + */ + oldestFetchedEventTimestamp?: number; onFilterChange: (filter: PollHistoryFilter) => void; onItemClick: (pollId: string) => void; + loadMorePolls?: () => void; + isLoading?: boolean; }; export const PollHistoryList: React.FC = ({ pollStartEvents, polls, filter, isLoading, + oldestFetchedEventTimestamp, onFilterChange, + loadMorePolls, onItemClick, }) => { return ( @@ -81,17 +155,18 @@ export const PollHistoryList: React.FC = ({ /> ), )} - {isLoading && } + {isLoading && !loadMorePolls && } + {!!loadMorePolls && } )} - {!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 && ( + )} - {!pollStartEvents.length && isLoading && } ); }; diff --git a/src/components/views/dialogs/polls/fetchPastPolls.ts b/src/components/views/dialogs/polls/fetchPastPolls.ts index 1d045d3d07..e97755874d 100644 --- a/src/components/views/dialogs/polls/fetchPastPolls.ts +++ b/src/components/views/dialogs/polls/fetchPastPolls.ts @@ -14,41 +14,82 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useEffect, useState } from "react"; +import { useCallback, 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 { Direction, 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) { +const getOldestEventTimestamp = (timelineSet?: EventTimelineSet): number | undefined => { + if (!timelineSet) { return; } + const liveTimeline = timelineSet?.getLiveTimeline(); + const events = liveTimeline.getEvents(); + return events[0]?.getTs(); +}; + +/** + * Page backwards in timeline history + * @param timelineSet - timelineset to page + * @param matrixClient - client + * @param canPageBackward - whether the timeline has more pages + * @param oldestEventTimestamp - server ts of the oldest encountered event + */ +const pagePollHistory = async ( + timelineSet: EventTimelineSet, + matrixClient: MatrixClient, +): Promise<{ + oldestEventTimestamp?: number; + canPageBackward: boolean; +}> => { + if (!timelineSet) { + return { canPageBackward: false }; + } + + const liveTimeline = timelineSet.getLiveTimeline(); + await matrixClient.paginateEventTimeline(liveTimeline, { backwards: true, }); - return pagePolls(timelineSet, matrixClient, endOfHistoryPeriodTimestamp); + return { + oldestEventTimestamp: getOldestEventTimestamp(timelineSet), + canPageBackward: !!liveTimeline.getPaginationToken(EventTimeline.BACKWARDS), + }; +}; + +/** + * Page timeline backwards until either: + * - event older than timestamp is encountered + * - end of timeline is reached + * @param timelineSet - timeline set to page + * @param matrixClient - client + * @param timestamp - epoch timestamp to page until + * @param canPageBackward - whether the timeline has more pages + * @param oldestEventTimestamp - server ts of the oldest encountered event + */ +const fetchHistoryUntilTimestamp = async ( + timelineSet: EventTimelineSet | undefined, + matrixClient: MatrixClient, + timestamp: number, + canPageBackward: boolean, + oldestEventTimestamp?: number, +): Promise => { + if (!timelineSet || !canPageBackward || (oldestEventTimestamp && oldestEventTimestamp < timestamp)) { + return; + } + const result = await pagePollHistory(timelineSet, matrixClient); + + return fetchHistoryUntilTimestamp( + timelineSet, + matrixClient, + timestamp, + result.canPageBackward, + result.oldestEventTimestamp, + ); }; const ONE_DAY_MS = 60000 * 60 * 24; @@ -57,35 +98,73 @@ const ONE_DAY_MS = 60000 * 60 * 24; * @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 + * @returns isLoading - true while fetching + * @returns oldestEventTimestamp - timestamp of oldest encountered poll, undefined when no polls found in timeline so far + * @returns loadMorePolls - function to page timeline backwards, undefined when timeline cannot be paged backwards + * @returns loadTimelineHistory - loads timeline history for the given history period */ const useTimelineHistory = ( - timelineSet: EventTimelineSet | null, + timelineSet: EventTimelineSet | undefined, matrixClient: MatrixClient, historyPeriodDays: number, -): { isLoading: boolean } => { +): { + isLoading: boolean; + oldestEventTimestamp?: number; + loadTimelineHistory: () => Promise; + loadMorePolls?: () => Promise; +} => { const [isLoading, setIsLoading] = useState(true); + const [oldestEventTimestamp, setOldestEventTimestamp] = useState(undefined); + const [canPageBackward, setCanPageBackward] = useState(false); - useEffect(() => { + const loadTimelineHistory = useCallback(async () => { + const endOfHistoryPeriodTimestamp = Date.now() - ONE_DAY_MS * historyPeriodDays; + setIsLoading(true); + try { + const liveTimeline = timelineSet?.getLiveTimeline(); + const canPageBackward = !!liveTimeline?.getPaginationToken(Direction.Backward); + const oldestEventTimestamp = getOldestEventTimestamp(timelineSet); + + await fetchHistoryUntilTimestamp( + timelineSet, + matrixClient, + endOfHistoryPeriodTimestamp, + canPageBackward, + oldestEventTimestamp, + ); + + setCanPageBackward(!!timelineSet?.getLiveTimeline()?.getPaginationToken(EventTimeline.BACKWARDS)); + setOldestEventTimestamp(getOldestEventTimestamp(timelineSet)); + } catch (error) { + logger.error("Failed to fetch room polls history", error); + } finally { + setIsLoading(false); + } + }, [historyPeriodDays, timelineSet, matrixClient]); + + const loadMorePolls = useCallback(async () => { if (!timelineSet) { return; } - const endOfHistoryPeriodTimestamp = Date.now() - ONE_DAY_MS * historyPeriodDays; + setIsLoading(true); + try { + const result = await pagePollHistory(timelineSet, matrixClient); - 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]); + setCanPageBackward(result.canPageBackward); + setOldestEventTimestamp(result.oldestEventTimestamp); + } catch (error) { + logger.error("Failed to fetch room polls history", error); + } finally { + setIsLoading(false); + } + }, [timelineSet, matrixClient]); - return { isLoading }; + return { + isLoading, + oldestEventTimestamp, + loadTimelineHistory, + loadMorePolls: canPageBackward ? loadMorePolls : undefined, + }; }; const filterDefinition: IFilterDefinition = { @@ -97,18 +176,24 @@ const filterDefinition: IFilterDefinition = { }; /** - * Fetch poll start events in the last N days of room history + * Fetches 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 + * @returns oldestEventTimestamp - timestamp of oldest encountered poll, undefined when no polls found in timeline so far + * @returns loadMorePolls - function to page timeline backwards, undefined when timeline cannot be paged backwards */ export const useFetchPastPolls = ( room: Room, matrixClient: MatrixClient, historyPeriodDays = 30, -): { isLoading: boolean } => { - const [timelineSet, setTimelineSet] = useState(null); +): { + isLoading: boolean; + oldestEventTimestamp?: number; + loadMorePolls?: () => Promise; +} => { + const [timelineSet, setTimelineSet] = useState(undefined); useEffect(() => { const filter = new Filter(matrixClient.getSafeUserId()); @@ -123,7 +208,15 @@ export const useFetchPastPolls = ( getFilteredTimelineSet(); }, [room, matrixClient]); - const { isLoading } = useTimelineHistory(timelineSet, matrixClient, historyPeriodDays); + const { isLoading, oldestEventTimestamp, loadMorePolls, loadTimelineHistory } = useTimelineHistory( + timelineSet, + matrixClient, + historyPeriodDays, + ); - return { isLoading }; + useEffect(() => { + loadTimelineHistory(); + }, [loadTimelineHistory]); + + return { isLoading, oldestEventTimestamp, loadMorePolls }; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a2cacaf4c9..055f3c1d07 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3143,8 +3143,15 @@ "Active polls": "Active polls", "Past polls": "Past polls", "Loading polls": "Loading polls", + "Load more polls": "Load more 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", + "There are no active polls. Load more polls to view polls for previous months": "There are no active polls. Load more polls to view polls for previous months", + "There are no past polls. Load more polls to view polls for previous months": "There are no past polls. Load more polls to view polls for previous months", + "There are no active polls for the past %(count)s days. Load more polls to view polls for previous months|other": "There are no active polls for the past %(count)s days. Load more polls to view polls for previous months", + "There are no active polls for the past %(count)s days. Load more polls to view polls for previous months|one": "There are no active polls for the past day. Load more polls to view polls for previous months", + "There are no past polls for the past %(count)s days. Load more polls to view polls for previous months|other": "There are no past polls for the past %(count)s days. Load more polls to view polls for previous months", + "There are no past polls for the past %(count)s days. Load more polls to view polls for previous months|one": "There are no past polls for the past day. Load more polls to view polls for previous months", "View poll": "View poll", "Send custom account data event": "Send custom account data event", "Send custom room account data event": "Send custom room account data event", diff --git a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx index f1da27141c..d113d73ac5 100644 --- a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -176,12 +176,20 @@ describe("", () => { expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); }); - it("displays loader and list while paging timeline", async () => { + 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 polls message and a load more button when not at end of timeline", async () => { const timelineSet = room.getOrCreateFilteredTimelineSet(expectedFilter); const liveTimeline = timelineSet.getLiveTimeline(); - const tenDaysAgoTs = now - 60000 * 60 * 24 * 10; + const fourtyDaysAgoTs = now - 60000 * 60 * 24 * 40; + const pollStart = makePollStartEvent("Question?", userId, undefined, { ts: fourtyDaysAgoTs, id: "1" }); - jest.spyOn(liveTimeline, "getEvents").mockReset().mockReturnValue([]); + jest.spyOn(liveTimeline, "getEvents").mockReset().mockReturnValueOnce([]).mockReturnValueOnce([pollStart]); // mock three pages of timeline history jest.spyOn(liveTimeline, "getPaginationToken") @@ -189,57 +197,24 @@ describe("", () => { .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(); - + const { getByText } = getComponent(); await flushPromises(); expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); - resolvePagination1!(true); + expect(getByText("There are no active polls. Load more polls to view polls for previous months")).toBeTruthy(); + + fireEvent.click(getByText("Load more polls")); + + // paged again + expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(2); + // load more polls button still in UI, with loader + expect(getByText("Load more polls")).toMatchSnapshot(); + 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(); + // no more spinner + expect(getByText("Load more polls")).toMatchSnapshot(); }); it("renders a no past polls message when there are no past polls in the room", async () => { 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 76c76484fb..9c08631c2a 100644 --- a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap +++ b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap @@ -168,3 +168,32 @@ exports[` renders a list of active polls when there are pol /> `; + +exports[` renders a no polls message and a load more button when not at end of timeline 1`] = ` +