Poll history: fetch more poll history (#10235)

* load more pages of polls

* load more and no results messages

* style no results message

* remove debug

* strict fixes

* comments

* i18n pluralisations

* pluralisation the right way
This commit is contained in:
Kerry 2023-02-28 15:56:27 +13:00 committed by GitHub
parent f57495d3cd
commit 7c70dd9d16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 300 additions and 105 deletions

View file

@ -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;
}

View file

@ -57,9 +57,9 @@ export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({
onFinished,
}) => {
const { polls } = usePollsWithRelations(room.roomId, matrixClient);
const { isLoading, loadMorePolls, oldestEventTimestamp } = useFetchPastPolls(room, matrixClient);
const [filter, setFilter] = useState<PollHistoryFilter>("ACTIVE");
const [focusedPollId, setFocusedPollId] = useState<string | null>(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<PollHistoryDialogProps> = ({
<PollDetail poll={focusedPoll} permalinkCreator={permalinkCreator} requestModalClose={onFinished} />
) : (
<PollHistoryList
onItemClick={setFocusedPollId}
pollStartEvents={pollStartEvents}
isLoading={isLoading || isLoadingPollResponses}
oldestFetchedEventTimestamp={oldestEventTimestamp}
polls={polls}
filter={filter}
onFilterChange={setFilter}
onItemClick={setFocusedPollId}
loadMorePolls={loadMorePolls}
/>
)}
</div>

View file

@ -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 }) => (
<div
@ -36,20 +37,93 @@ const LoadingPolls: React.FC<{ noResultsYet?: boolean }> = ({ noResultsYet }) =>
</div>
);
const LoadMorePolls: React.FC<{ loadMorePolls?: () => void; isLoading?: boolean }> = ({ isLoading, loadMorePolls }) =>
loadMorePolls ? (
<AccessibleButton
className="mx_PollHistoryList_loadMorePolls"
kind="link_inline"
onClick={() => loadMorePolls()}
>
{_t("Load more polls")}
{isLoading && <InlineSpinner />}
</AccessibleButton>
) : 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 <LoadingPolls noResultsYet />;
}
return (
<span className="mx_PollHistoryList_noResults">
{getNoResultsMessage(filter, oldestFetchedEventTimestamp, loadMorePolls)}
{!!loadMorePolls && <LoadMorePolls loadMorePolls={loadMorePolls} isLoading={isLoading} />}
</span>
);
};
type PollHistoryListProps = {
pollStartEvents: MatrixEvent[];
polls: Map<string, Poll>;
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<PollHistoryListProps> = ({
pollStartEvents,
polls,
filter,
isLoading,
oldestFetchedEventTimestamp,
onFilterChange,
loadMorePolls,
onItemClick,
}) => {
return (
@ -81,17 +155,18 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({
/>
),
)}
{isLoading && <LoadingPolls />}
{isLoading && !loadMorePolls && <LoadingPolls />}
{!!loadMorePolls && <LoadMorePolls loadMorePolls={loadMorePolls} isLoading={isLoading} />}
</ol>
)}
{!pollStartEvents.length && !isLoading && (
<span className="mx_PollHistoryList_noResults">
{filter === "ACTIVE"
? _t("There are no active polls in this room")
: _t("There are no past polls in this room")}
</span>
{!pollStartEvents.length && (
<NoResults
oldestFetchedEventTimestamp={oldestFetchedEventTimestamp}
isLoading={isLoading}
filter={filter}
loadMorePolls={loadMorePolls}
/>
)}
{!pollStartEvents.length && isLoading && <LoadingPolls noResultsYet />}
</div>
);
};

View file

@ -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<void> => {
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<void> => {
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<void>;
loadMorePolls?: () => Promise<void>;
} => {
const [isLoading, setIsLoading] = useState(true);
const [oldestEventTimestamp, setOldestEventTimestamp] = useState<number | undefined>(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<void> => {
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<EventTimelineSet | null>(null);
): {
isLoading: boolean;
oldestEventTimestamp?: number;
loadMorePolls?: () => Promise<void>;
} => {
const [timelineSet, setTimelineSet] = useState<EventTimelineSet | undefined>(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 };
};

View file

@ -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",

View file

@ -176,12 +176,20 @@ describe("<PollHistoryDialog />", () => {
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("<PollHistoryDialog />", () => {
.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 () => {

View file

@ -168,3 +168,32 @@ exports[`<PollHistoryDialog /> renders a list of active polls when there are pol
/>
</div>
`;
exports[`<PollHistoryDialog /> renders a no polls message and a load more button when not at end of timeline 1`] = `
<div
class="mx_AccessibleButton mx_PollHistoryList_loadMorePolls mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Load more polls
<div
class="mx_InlineSpinner"
>
<div
aria-label="Loading…"
class="mx_InlineSpinner_icon mx_Spinner_icon"
style="width: 16px; height: 16px;"
/>
</div>
</div>
`;
exports[`<PollHistoryDialog /> renders a no polls message and a load more button when not at end of timeline 2`] = `
<div
class="mx_AccessibleButton mx_PollHistoryList_loadMorePolls mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Load more polls
</div>
`;