Adding cursor pagination. Fixes #2155 (#2173)

* Adding cursor pagination. Fixes #2155

* Addressing PR comments.

---------

Co-authored-by: SleeplessOne1917 <abias1122@gmail.com>
This commit is contained in:
Dessalines 2023-10-06 09:08:55 -04:00 committed by GitHub
parent ae4c37ed44
commit 2c1f4538be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 138 additions and 76 deletions

View file

@ -20,21 +20,26 @@ const [hostname, port] = process.env["LEMMY_UI_HOST"]
server.use(express.json()); server.use(express.json());
server.use(express.urlencoded({ extended: false })); server.use(express.urlencoded({ extended: false }));
server.use(
getStaticDir(), const serverPath = path.resolve("./dist");
express.static(path.resolve("./dist"), {
maxAge: 24 * 60 * 60 * 1000, // 1 day
immutable: true,
}),
);
server.use(setCacheControl);
if ( if (
!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DISABLE_CSP"] &&
!process.env["LEMMY_UI_DEBUG"] && !process.env["LEMMY_UI_DEBUG"] &&
process.env["NODE_ENV"] !== "development" process.env["NODE_ENV"] !== "development"
) { ) {
server.use(
getStaticDir(),
express.static(serverPath, {
maxAge: 24 * 60 * 60 * 1000, // 1 day
immutable: true,
}),
);
server.use(setDefaultCsp); server.use(setDefaultCsp);
server.use(setCacheControl);
} else {
// In debug mode, don't use the maxAge and immutable, or it breaks live reload for dev
server.use(getStaticDir(), express.static(serverPath));
} }
server.get("/.well-known/security.txt", SecurityHandler); server.get("/.well-known/security.txt", SecurityHandler);

View file

@ -0,0 +1,46 @@
import { Component, linkEvent } from "inferno";
import { I18NextService } from "../../services";
import { PaginationCursor } from "lemmy-js-client";
interface PaginatorCursorProps {
prevPage?: PaginationCursor;
nextPage?: PaginationCursor;
onNext(val: PaginationCursor): void;
onPrev(): void;
}
function handlePrev(i: PaginatorCursor) {
i.props.onPrev();
}
function handleNext(i: PaginatorCursor) {
if (i.props.nextPage) {
i.props.onNext(i.props.nextPage);
}
}
export class PaginatorCursor extends Component<PaginatorCursorProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div className="paginator my-2">
<button
className="btn btn-secondary me-2"
disabled={!this.props.prevPage}
onClick={linkEvent(this, handlePrev)}
>
{I18NextService.i18n.t("prev")}
</button>
<button
className="btn btn-secondary"
onClick={linkEvent(this, handleNext)}
disabled={!this.props.nextPage}
>
{I18NextService.i18n.t("next")}
</button>
</div>
);
}
}

View file

@ -14,11 +14,7 @@ import {
updateCommunityBlock, updateCommunityBlock,
updatePersonBlock, updatePersonBlock,
} from "@utils/app"; } from "@utils/app";
import { import { getQueryParams, getQueryString } from "@utils/helpers";
getPageFromString,
getQueryParams,
getQueryString,
} from "@utils/helpers";
import type { QueryParams } from "@utils/types"; import type { QueryParams } from "@utils/types";
import { RouteDataResponse } from "@utils/types"; import { RouteDataResponse } from "@utils/types";
import { Component, RefObject, createRef, linkEvent } from "inferno"; import { Component, RefObject, createRef, linkEvent } from "inferno";
@ -62,6 +58,7 @@ import {
MarkCommentReplyAsRead, MarkCommentReplyAsRead,
MarkPersonMentionAsRead, MarkPersonMentionAsRead,
MarkPostAsRead, MarkPostAsRead,
PaginationCursor,
PostResponse, PostResponse,
PurgeComment, PurgeComment,
PurgeCommunity, PurgeCommunity,
@ -96,12 +93,12 @@ import { BannerIconHeader } from "../common/banner-icon-header";
import { DataTypeSelect } from "../common/data-type-select"; import { DataTypeSelect } from "../common/data-type-select";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { Paginator } from "../common/paginator";
import { SortSelect } from "../common/sort-select"; import { SortSelect } from "../common/sort-select";
import { Sidebar } from "../community/sidebar"; import { Sidebar } from "../community/sidebar";
import { SiteSidebar } from "../home/site-sidebar"; import { SiteSidebar } from "../home/site-sidebar";
import { PostListings } from "../post/post-listings"; import { PostListings } from "../post/post-listings";
import { CommunityLink } from "./community-link"; import { CommunityLink } from "./community-link";
import { PaginatorCursor } from "../common/paginator-cursor";
type CommunityData = RouteDataResponse<{ type CommunityData = RouteDataResponse<{
communityRes: GetCommunityResponse; communityRes: GetCommunityResponse;
@ -122,13 +119,13 @@ interface State {
interface CommunityProps { interface CommunityProps {
dataType: DataType; dataType: DataType;
sort: SortType; sort: SortType;
page: number; pageCursor?: PaginationCursor;
} }
function getCommunityQueryParams() { function getCommunityQueryParams() {
return getQueryParams<CommunityProps>({ return getQueryParams<CommunityProps>({
dataType: getDataTypeFromQuery, dataType: getDataTypeFromQuery,
page: getPageFromString, pageCursor: cursor => cursor,
sort: getSortTypeFromQuery, sort: getSortTypeFromQuery,
}); });
} }
@ -165,7 +162,8 @@ export class Community extends Component<
this.handleSortChange = this.handleSortChange.bind(this); this.handleSortChange = this.handleSortChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this); this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageNext = this.handlePageNext.bind(this);
this.handlePagePrev = this.handlePagePrev.bind(this);
// All of the action binds // All of the action binds
this.handleDeleteCommunity = this.handleDeleteCommunity.bind(this); this.handleDeleteCommunity = this.handleDeleteCommunity.bind(this);
@ -236,7 +234,7 @@ export class Community extends Component<
static async fetchInitialData({ static async fetchInitialData({
client, client,
path, path,
query: { dataType: urlDataType, page: urlPage, sort: urlSort }, query: { dataType: urlDataType, pageCursor, sort: urlSort },
}: InitialFetchRequest<QueryParams<CommunityProps>>): Promise< }: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<
Promise<CommunityData> Promise<CommunityData>
> { > {
@ -251,15 +249,13 @@ export class Community extends Component<
const sort = getSortTypeFromQuery(urlSort); const sort = getSortTypeFromQuery(urlSort);
const page = getPageFromString(urlPage);
let postsResponse: RequestState<GetPostsResponse> = EMPTY_REQUEST; let postsResponse: RequestState<GetPostsResponse> = EMPTY_REQUEST;
let commentsResponse: RequestState<GetCommentsResponse> = EMPTY_REQUEST; let commentsResponse: RequestState<GetCommentsResponse> = EMPTY_REQUEST;
if (dataType === DataType.Post) { if (dataType === DataType.Post) {
const getPostsForm: GetPosts = { const getPostsForm: GetPosts = {
community_name: communityName, community_name: communityName,
page, page_cursor: pageCursor,
limit: fetchLimit, limit: fetchLimit,
sort, sort,
type_: "All", type_: "All",
@ -270,7 +266,6 @@ export class Community extends Component<
} else { } else {
const getCommentsForm: GetComments = { const getCommentsForm: GetComments = {
community_name: communityName, community_name: communityName,
page,
limit: fetchLimit, limit: fetchLimit,
sort: postToCommentSortType(sort), sort: postToCommentSortType(sort),
type_: "All", type_: "All",
@ -287,6 +282,12 @@ export class Community extends Component<
}; };
} }
get getNextPage(): PaginationCursor | undefined {
return this.state.postsRes.state === "success"
? this.state.postsRes.data.next_page
: undefined;
}
get documentTitle(): string { get documentTitle(): string {
const cRes = this.state.communityRes; const cRes = this.state.communityRes;
return cRes.state === "success" return cRes.state === "success"
@ -295,6 +296,7 @@ export class Community extends Component<
} }
renderCommunity() { renderCommunity() {
const { pageCursor } = getCommunityQueryParams();
switch (this.state.communityRes.state) { switch (this.state.communityRes.state) {
case "loading": case "loading":
return ( return (
@ -304,7 +306,6 @@ export class Community extends Component<
); );
case "success": { case "success": {
const res = this.state.communityRes.data; const res = this.state.communityRes.data;
const { page } = getCommunityQueryParams();
return ( return (
<> <>
@ -341,13 +342,11 @@ export class Community extends Component<
</div> </div>
{this.selects(res)} {this.selects(res)}
{this.listings(res)} {this.listings(res)}
<Paginator <PaginatorCursor
page={page} prevPage={pageCursor}
onChange={this.handlePageChange} nextPage={this.getNextPage}
nextDisabled={ onNext={this.handlePageNext}
this.state.postsRes.state !== "success" || onPrev={this.handlePagePrev}
fetchLimit > this.state.postsRes.data.posts.length
}
/> />
</main> </main>
<aside className="d-none d-md-block col-md-4 col-lg-3"> <aside className="d-none d-md-block col-md-4 col-lg-3">
@ -542,18 +541,22 @@ export class Community extends Component<
); );
} }
handlePageChange(page: number) { handlePagePrev() {
this.updateUrl({ page }); this.props.history.back();
}
handlePageNext(nextPage: PaginationCursor) {
this.updateUrl({ pageCursor: nextPage });
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
handleSortChange(sort: SortType) { handleSortChange(sort: SortType) {
this.updateUrl({ sort, page: 1 }); this.updateUrl({ sort, pageCursor: undefined });
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
handleDataTypeChange(dataType: DataType) { handleDataTypeChange(dataType: DataType) {
this.updateUrl({ dataType, page: 1 }); this.updateUrl({ dataType, pageCursor: undefined });
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@ -563,16 +566,12 @@ export class Community extends Component<
})); }));
} }
async updateUrl({ dataType, page, sort }: Partial<CommunityProps>) { async updateUrl({ dataType, pageCursor, sort }: Partial<CommunityProps>) {
const { const { dataType: urlDataType, sort: urlSort } = getCommunityQueryParams();
dataType: urlDataType,
page: urlPage,
sort: urlSort,
} = getCommunityQueryParams();
const queryParams: QueryParams<CommunityProps> = { const queryParams: QueryParams<CommunityProps> = {
dataType: getDataTypeString(dataType ?? urlDataType), dataType: getDataTypeString(dataType ?? urlDataType),
page: (page ?? urlPage).toString(), pageCursor: pageCursor,
sort: sort ?? urlSort, sort: sort ?? urlSort,
}; };
@ -584,14 +583,14 @@ export class Community extends Component<
} }
async fetchData() { async fetchData() {
const { dataType, page, sort } = getCommunityQueryParams(); const { dataType, pageCursor, sort } = getCommunityQueryParams();
const { name } = this.props.match.params; const { name } = this.props.match.params;
if (dataType === DataType.Post) { if (dataType === DataType.Post) {
this.setState({ postsRes: LOADING_REQUEST }); this.setState({ postsRes: LOADING_REQUEST });
this.setState({ this.setState({
postsRes: await HttpService.client.getPosts({ postsRes: await HttpService.client.getPosts({
page, page_cursor: pageCursor,
limit: fetchLimit, limit: fetchLimit,
sort, sort,
type_: "All", type_: "All",
@ -603,7 +602,6 @@ export class Community extends Component<
this.setState({ commentsRes: LOADING_REQUEST }); this.setState({ commentsRes: LOADING_REQUEST });
this.setState({ this.setState({
commentsRes: await HttpService.client.getComments({ commentsRes: await HttpService.client.getComments({
page,
limit: fetchLimit, limit: fetchLimit,
sort: postToCommentSortType(sort), sort: postToCommentSortType(sort),
type_: "All", type_: "All",

View file

@ -14,7 +14,6 @@ import {
updatePersonBlock, updatePersonBlock,
} from "@utils/app"; } from "@utils/app";
import { import {
getPageFromString,
getQueryParams, getQueryParams,
getQueryString, getQueryString,
getRandomFromList, getRandomFromList,
@ -60,6 +59,7 @@ import {
MarkCommentReplyAsRead, MarkCommentReplyAsRead,
MarkPersonMentionAsRead, MarkPersonMentionAsRead,
MarkPostAsRead, MarkPostAsRead,
PaginationCursor,
PostResponse, PostResponse,
PurgeComment, PurgeComment,
PurgeItemResponse, PurgeItemResponse,
@ -98,11 +98,11 @@ import { DataTypeSelect } from "../common/data-type-select";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { ListingTypeSelect } from "../common/listing-type-select"; import { ListingTypeSelect } from "../common/listing-type-select";
import { Paginator } from "../common/paginator";
import { SortSelect } from "../common/sort-select"; import { SortSelect } from "../common/sort-select";
import { CommunityLink } from "../community/community-link"; import { CommunityLink } from "../community/community-link";
import { PostListings } from "../post/post-listings"; import { PostListings } from "../post/post-listings";
import { SiteSidebar } from "./site-sidebar"; import { SiteSidebar } from "./site-sidebar";
import { PaginatorCursor } from "../common/paginator-cursor";
interface HomeState { interface HomeState {
postsRes: RequestState<GetPostsResponse>; postsRes: RequestState<GetPostsResponse>;
@ -123,7 +123,7 @@ interface HomeProps {
listingType?: ListingType; listingType?: ListingType;
dataType: DataType; dataType: DataType;
sort: SortType; sort: SortType;
page: number; pageCursor?: PaginationCursor;
} }
type HomeData = RouteDataResponse<{ type HomeData = RouteDataResponse<{
@ -185,13 +185,14 @@ function getSortTypeFromQuery(type?: string): SortType {
return (type ? (type as SortType) : mySortType) ?? "Active"; return (type ? (type as SortType) : mySortType) ?? "Active";
} }
const getHomeQueryParams = () => function getHomeQueryParams() {
getQueryParams<HomeProps>({ return getQueryParams<HomeProps>({
sort: getSortTypeFromQuery, sort: getSortTypeFromQuery,
listingType: getListingTypeFromQuery, listingType: getListingTypeFromQuery,
page: getPageFromString, pageCursor: cursor => cursor,
dataType: getDataTypeFromQuery, dataType: getDataTypeFromQuery,
}); });
}
const MobileButton = ({ const MobileButton = ({
textKey, textKey,
@ -245,7 +246,8 @@ export class Home extends Component<any, HomeState> {
this.handleSortChange = this.handleSortChange.bind(this); this.handleSortChange = this.handleSortChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this); this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this); this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageNext = this.handlePageNext.bind(this);
this.handlePagePrev = this.handlePagePrev.bind(this);
this.handleCreateComment = this.handleCreateComment.bind(this); this.handleCreateComment = this.handleCreateComment.bind(this);
this.handleEditComment = this.handleEditComment.bind(this); this.handleEditComment = this.handleEditComment.bind(this);
@ -315,7 +317,7 @@ export class Home extends Component<any, HomeState> {
static async fetchInitialData({ static async fetchInitialData({
client, client,
query: { dataType: urlDataType, listingType, page: urlPage, sort: urlSort }, query: { dataType: urlDataType, listingType, pageCursor, sort: urlSort },
site, site,
}: InitialFetchRequest<QueryParams<HomeProps>>): Promise<HomeData> { }: InitialFetchRequest<QueryParams<HomeProps>>): Promise<HomeData> {
const dataType = getDataTypeFromQuery(urlDataType); const dataType = getDataTypeFromQuery(urlDataType);
@ -324,15 +326,13 @@ export class Home extends Component<any, HomeState> {
site.site_view.local_site.default_post_listing_type; site.site_view.local_site.default_post_listing_type;
const sort = getSortTypeFromQuery(urlSort); const sort = getSortTypeFromQuery(urlSort);
const page = urlPage ? Number(urlPage) : 1;
let postsRes: RequestState<GetPostsResponse> = EMPTY_REQUEST; let postsRes: RequestState<GetPostsResponse> = EMPTY_REQUEST;
let commentsRes: RequestState<GetCommentsResponse> = EMPTY_REQUEST; let commentsRes: RequestState<GetCommentsResponse> = EMPTY_REQUEST;
if (dataType === DataType.Post) { if (dataType === DataType.Post) {
const getPostsForm: GetPosts = { const getPostsForm: GetPosts = {
type_, type_,
page, page_cursor: pageCursor,
limit: fetchLimit, limit: fetchLimit,
sort, sort,
saved_only: false, saved_only: false,
@ -341,7 +341,6 @@ export class Home extends Component<any, HomeState> {
postsRes = await client.getPosts(getPostsForm); postsRes = await client.getPosts(getPostsForm);
} else { } else {
const getCommentsForm: GetComments = { const getCommentsForm: GetComments = {
page,
limit: fetchLimit, limit: fetchLimit,
sort: postToCommentSortType(sort), sort: postToCommentSortType(sort),
type_, type_,
@ -616,18 +615,22 @@ export class Home extends Component<any, HomeState> {
); );
} }
async updateUrl({ dataType, listingType, page, sort }: Partial<HomeProps>) { async updateUrl({
dataType,
listingType,
pageCursor,
sort,
}: Partial<HomeProps>) {
const { const {
dataType: urlDataType, dataType: urlDataType,
listingType: urlListingType, listingType: urlListingType,
page: urlPage,
sort: urlSort, sort: urlSort,
} = getHomeQueryParams(); } = getHomeQueryParams();
const queryParams: QueryParams<HomeProps> = { const queryParams: QueryParams<HomeProps> = {
dataType: getDataTypeString(dataType ?? urlDataType), dataType: getDataTypeString(dataType ?? urlDataType),
listingType: listingType ?? urlListingType, listingType: listingType ?? urlListingType,
page: (page ?? urlPage).toString(), pageCursor: pageCursor,
sort: sort ?? urlSort, sort: sort ?? urlSort,
}; };
@ -645,26 +648,30 @@ export class Home extends Component<any, HomeState> {
} }
get posts() { get posts() {
const { page } = getHomeQueryParams(); const { pageCursor } = getHomeQueryParams();
return ( return (
<div className="main-content-wrapper"> <div className="main-content-wrapper">
<div> <div>
{this.selects} {this.selects}
{this.listings} {this.listings}
<Paginator <PaginatorCursor
page={page} prevPage={pageCursor}
onChange={this.handlePageChange} nextPage={this.getNextPage}
nextDisabled={ onNext={this.handlePageNext}
this.state.postsRes?.state !== "success" || onPrev={this.handlePagePrev}
fetchLimit > this.state.postsRes.data.posts.length
}
/> />
</div> </div>
</div> </div>
); );
} }
get getNextPage(): PaginationCursor | undefined {
return this.state.postsRes.state === "success"
? this.state.postsRes.data.next_page
: undefined;
}
get listings() { get listings() {
const { dataType } = getHomeQueryParams(); const { dataType } = getHomeQueryParams();
const siteRes = this.state.siteRes; const siteRes = this.state.siteRes;
@ -804,7 +811,7 @@ export class Home extends Component<any, HomeState> {
} }
async fetchData() { async fetchData() {
const { dataType, page, listingType, sort } = getHomeQueryParams(); const { dataType, pageCursor, listingType, sort } = getHomeQueryParams();
if (dataType === DataType.Post) { if (dataType === DataType.Post) {
if (HomeCacheService.active) { if (HomeCacheService.active) {
@ -814,13 +821,12 @@ export class Home extends Component<any, HomeState> {
window.scrollTo({ window.scrollTo({
left: 0, left: 0,
top: scrollY, top: scrollY,
behavior: "instant",
}); });
} else { } else {
this.setState({ postsRes: LOADING_REQUEST }); this.setState({ postsRes: LOADING_REQUEST });
this.setState({ this.setState({
postsRes: await HttpService.client.getPosts({ postsRes: await HttpService.client.getPosts({
page, page_cursor: pageCursor,
limit: fetchLimit, limit: fetchLimit,
sort, sort,
saved_only: false, saved_only: false,
@ -834,7 +840,6 @@ export class Home extends Component<any, HomeState> {
this.setState({ commentsRes: LOADING_REQUEST }); this.setState({ commentsRes: LOADING_REQUEST });
this.setState({ this.setState({
commentsRes: await HttpService.client.getComments({ commentsRes: await HttpService.client.getComments({
page,
limit: fetchLimit, limit: fetchLimit,
sort: postToCommentSortType(sort), sort: postToCommentSortType(sort),
saved_only: false, saved_only: false,
@ -862,24 +867,32 @@ export class Home extends Component<any, HomeState> {
i.setState({ subscribedCollapsed: !i.state.subscribedCollapsed }); i.setState({ subscribedCollapsed: !i.state.subscribedCollapsed });
} }
handlePageChange(page: number) { handlePagePrev() {
this.props.history.back();
// A hack to scroll to top
setTimeout(() => {
window.scrollTo(0, 0);
}, 50);
}
handlePageNext(nextPage: PaginationCursor) {
this.setState({ scrolled: false }); this.setState({ scrolled: false });
this.updateUrl({ page }); this.updateUrl({ pageCursor: nextPage });
} }
handleSortChange(val: SortType) { handleSortChange(val: SortType) {
this.setState({ scrolled: false }); this.setState({ scrolled: false });
this.updateUrl({ sort: val, page: 1 }); this.updateUrl({ sort: val, pageCursor: undefined });
} }
handleListingTypeChange(val: ListingType) { handleListingTypeChange(val: ListingType) {
this.setState({ scrolled: false }); this.setState({ scrolled: false });
this.updateUrl({ listingType: val, page: 1 }); this.updateUrl({ listingType: val, pageCursor: undefined });
} }
handleDataTypeChange(val: DataType) { handleDataTypeChange(val: DataType) {
this.setState({ scrolled: false }); this.setState({ scrolled: false });
this.updateUrl({ dataType: val, page: 1 }); this.updateUrl({ dataType: val, pageCursor: undefined });
} }
async handleAddModToCommunity(form: AddModToCommunity) { async handleAddModToCommunity(form: AddModToCommunity) {