Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Poll history: fetch last 30 days of polls #10157

Merged
merged 17 commits into from
Feb 20, 2023
Merged
11 changes: 11 additions & 0 deletions res/css/views/dialogs/polls/_PollHistoryList.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,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;
}
}
27 changes: 18 additions & 9 deletions src/components/views/dialogs/polls/PollHistoryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<IDialogProps, "onFinished"> & {
roomId: string;
Expand All @@ -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<string, Poll>, filter: PollHistoryFilter): MatrixEvent[] => {
return [...polls.values()]
.filter(filterPolls(filter))
Expand All @@ -43,18 +47,23 @@ const filterAndSortPolls = (polls: Map<string, Poll>, filter: PollHistoryFilter)
};

export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ 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<PollHistoryFilter>("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 (
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
<div className="mx_PollHistoryDialog_content">
<PollHistoryList pollStartEvents={pollStartEvents} filter={filter} onFilterChange={setFilter} />
<PollHistoryList
pollStartEvents={pollStartEvents}
isLoading={isLoading || isLoadingPollResponses}
filter={filter}
onFilterChange={setFilter}
/>
</div>
</BaseDialog>
);
Expand Down
42 changes: 33 additions & 9 deletions src/components/views/dialogs/polls/PollHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,37 @@ limitations under the License.

import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";

import PollListItem from "./PollListItem";
import { _t } from "../../../../languageHandler";
import { FilterTabGroup } from "../../elements/FilterTabGroup";
import InlineSpinner from "../../elements/InlineSpinner";
import { PollHistoryFilter } from "./types";
import PollListItem from "./PollListItem";

const LoadingPolls: React.FC<{ noResultsYet?: boolean }> = ({ noResultsYet }) => (
<div
className={classNames("mx_PollHistoryList_loading", {
mx_PollHistoryList_noResultsYet: noResultsYet,
})}
>
<InlineSpinner />
{_t("Loading polls")}
</div>
);

type PollHistoryListProps = {
pollStartEvents: MatrixEvent[];
filter: PollHistoryFilter;
onFilterChange: (filter: PollHistoryFilter) => void;
isLoading?: boolean;
};
export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvents, filter, onFilterChange }) => {
export const PollHistoryList: React.FC<PollHistoryListProps> = ({
pollStartEvents,
filter,
isLoading,
onFilterChange,
}) => {
return (
<div className="mx_PollHistoryList">
<FilterTabGroup<PollHistoryFilter>
Expand All @@ -39,19 +58,24 @@ export const PollHistoryList: React.FC<PollHistoryListProps> = ({ pollStartEvent
{ id: "ENDED", label: "Past polls" },
]}
/>
{!!pollStartEvents.length ? (
<ol className="mx_PollHistoryList_list">
{pollStartEvents.map((pollStartEvent) => (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
))}
</ol>
) : (
{!!pollStartEvents.length && (
<>
<ol className="mx_PollHistoryList_list">
{pollStartEvents.map((pollStartEvent) => (
<PollListItem key={pollStartEvent.getId()!} event={pollStartEvent} />
))}
{isLoading && <LoadingPolls />}
</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 && isLoading && <LoadingPolls noResultsYet />}
</div>
);
};
129 changes: 129 additions & 0 deletions src/components/views/dialogs/polls/fetchPastPolls.ts
Original file line number Diff line number Diff line change
@@ -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<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) {
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<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]);

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<EventTimelineSet | null>(null);

useEffect(() => {
const filter = new Filter(matrixClient.getSafeUserId());
filter.setDefinition(filterDefinition);
const getFilteredTimelineSet = async (): Promise<void> => {
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 };
};
57 changes: 54 additions & 3 deletions src/components/views/dialogs/polls/usePollHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ 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";

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<string, Poll>} - Map of Poll instances
Expand All @@ -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<string, Poll>(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<string, Poll>} - Map of Poll instances
*/
export const usePollsWithRelations = (
roomId: string,
matrixClient: MatrixClient,
): {
polls: Map<string, Poll>;
} => {
const { polls } = usePolls(roomId, matrixClient);
const [pollsWithRelations, setPollsWithRelations] = useState<Map<string, Poll>>(polls);

useEffect(() => {
const onPollUpdate = async (): Promise<void> => {
// 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 };
};
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3141,6 +3141,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 <button>set up new recovery options</button>": "If you've forgotten your Security Key you can <button>set up new recovery options</button>",
"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",
Expand Down
Loading