From 902d9e140c68d3e2bc1f248f1dd189c3c29eb666 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Fri, 26 Aug 2022 20:18:08 -0700 Subject: [PATCH] Create and use new `usePagination` hook for paginating loading (#769) * Create and use new `usePagination` hook for paginating loading * Fix index for new comment list code --- firestore.indexes.json | 4 ++ web/components/comments-list.tsx | 119 ++++++++++++++++++------------- web/components/pagination.tsx | 59 ++++++++++----- web/hooks/use-pagination.ts | 109 ++++++++++++++++++++++++++++ web/lib/firebase/comments.ts | 9 ++- 5 files changed, 227 insertions(+), 73 deletions(-) create mode 100644 web/hooks/use-pagination.ts diff --git a/firestore.indexes.json b/firestore.indexes.json index 874344be..80b08996 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -40,6 +40,10 @@ "collectionGroup": "comments", "queryScope": "COLLECTION_GROUP", "fields": [ + { + "fieldPath": "commentType", + "order": "ASCENDING" + }, { "fieldPath": "userId", "order": "ASCENDING" diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 280787dd..12ae0649 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -1,8 +1,7 @@ -import { useEffect, useState } from 'react' - -import { Comment, ContractComment } from 'common/comment' +import { ContractComment } from 'common/comment' import { groupConsecutive } from 'common/util/array' -import { getUsersComments } from 'web/lib/firebase/comments' +import { getUserCommentsQuery } from 'web/lib/firebase/comments' +import { usePagination } from 'web/hooks/use-pagination' import { SiteLink } from './site-link' import { Row } from './layout/row' import { Avatar } from './avatar' @@ -11,10 +10,14 @@ import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' import { Content } from './editor' -import { Pagination } from './pagination' import { LoadingIndicator } from './loading-indicator' +import { PaginationNextPrev } from 'web/components/pagination' -const COMMENTS_PER_PAGE = 50 +type ContractKey = { + contractId: string + contractSlug: string + contractQuestion: string +} function contractPath(slug: string) { // by convention this includes the contract creator username, but we don't @@ -24,67 +27,83 @@ function contractPath(slug: string) { export function UserCommentsList(props: { user: User }) { const { user } = props - const [comments, setComments] = useState() - const [page, setPage] = useState(0) - const start = page * COMMENTS_PER_PAGE - const end = start + COMMENTS_PER_PAGE - useEffect(() => { - getUsersComments(user.id).then((cs) => { - // we don't show comments in groups here atm, just comments on contracts - setComments( - cs.filter((c) => c.commentType == 'contract') as ContractComment[] - ) - }) - }, [user.id]) + const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 }) + const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page - if (comments == null) { + const pageComments = groupConsecutive(getItems(), (c) => { + return { + contractId: c.contractId, + contractQuestion: c.contractQuestion, + contractSlug: c.contractSlug, + } + }) + + if (isLoading) { return } - const pageComments = groupConsecutive(comments.slice(start, end), (c) => { - return { question: c.contractQuestion, slug: c.contractSlug } - }) + if (pageComments.length === 0) { + if (isStart && isEnd) { + return

This user hasn't made any comments yet.

+ } else { + // this can happen if their comment count is a multiple of page size + return

No more comments to display.

+ } + } + return ( {pageComments.map(({ key, items }, i) => { - return ( -
- - {key.question} - - - {items.map((comment) => ( - - ))} - -
- ) + return })} - + ) } -function ProfileComment(props: { comment: Comment; className?: string }) { - const { comment, className } = props +function ProfileCommentGroup(props: { + groupKey: ContractKey + items: ContractComment[] +}) { + const { groupKey, items } = props + const { contractSlug, contractQuestion } = groupKey + const path = contractPath(contractSlug) + return ( +
+ + {contractQuestion} + + + {items.map((c) => ( + + ))} + +
+ ) +} + +function ProfileComment(props: { comment: ContractComment }) { + const { comment } = props const { text, content, userUsername, userName, userAvatarUrl, createdTime } = comment // TODO: find and attach relevant bets by comment betId at some point return ( - +

diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index 5f3d4da2..8c008ab0 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -1,6 +1,40 @@ +import { ReactNode } from 'react' import clsx from 'clsx' import { Spacer } from './layout/spacer' +import { Row } from './layout/row' +export function PaginationNextPrev(props: { + className?: string + prev?: ReactNode + next?: ReactNode + onClickPrev: () => void + onClickNext: () => void + scrollToTop?: boolean +}) { + const { className, prev, next, onClickPrev, onClickNext, scrollToTop } = props + return ( + + {prev != null && ( + + {prev ?? 'Previous'} + + )} + {next != null && ( + + {next ?? 'Next'} + + )} + + ) +} export function Pagination(props: { page: number itemsPerPage: number @@ -44,24 +78,13 @@ export function Pagination(props: { of {totalItems} results

- + 0 ? prevTitle ?? 'Previous' : null} + next={page < maxPage ? nextTitle ?? 'Next' : null} + onClickPrev={() => setPage(page - 1)} + onClickNext={() => setPage(page + 1)} + scrollToTop={scrollToTop} + /> ) } diff --git a/web/hooks/use-pagination.ts b/web/hooks/use-pagination.ts new file mode 100644 index 00000000..485afca8 --- /dev/null +++ b/web/hooks/use-pagination.ts @@ -0,0 +1,109 @@ +// adapted from https://github.com/premshree/use-pagination-firestore + +import { useEffect, useReducer } from 'react' +import { + Query, + QuerySnapshot, + QueryDocumentSnapshot, + queryEqual, + limit, + onSnapshot, + query, + startAfter, +} from 'firebase/firestore' + +interface State { + baseQ: Query + docs: QueryDocumentSnapshot[] + pageStart: number + pageEnd: number + pageSize: number + isLoading: boolean + isComplete: boolean +} + +type ActionBase = V extends void ? { type: K } : { type: K } & V + +type Action = + | ActionBase<'INIT', { opts: PaginationOptions }> + | ActionBase<'LOAD', { snapshot: QuerySnapshot }> + | ActionBase<'PREV'> + | ActionBase<'NEXT'> + +const getReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'INIT': { + return getInitialState(action.opts) + } + case 'LOAD': { + const docs = state.docs.concat(action.snapshot.docs) + const isComplete = action.snapshot.docs.length < state.pageSize + return { ...state, docs, isComplete, isLoading: false } + } + case 'PREV': { + const { pageStart, pageSize } = state + const prevStart = pageStart - pageSize + const isLoading = false + return { ...state, isLoading, pageStart: prevStart, pageEnd: pageStart } + } + case 'NEXT': { + const { docs, pageEnd, isComplete, pageSize } = state + const nextEnd = pageEnd + pageSize + const isLoading = !isComplete && docs.length < nextEnd + return { ...state, isLoading, pageStart: pageEnd, pageEnd: nextEnd } + } + default: + throw new Error('Invalid action.') + } + } + +export type PaginationOptions = { q: Query; pageSize: number } + +const getInitialState = (opts: PaginationOptions): State => { + return { + baseQ: opts.q, + docs: [], + pageStart: 0, + pageEnd: opts.pageSize, + pageSize: opts.pageSize, + isLoading: true, + isComplete: false, + } +} + +export const usePagination = (opts: PaginationOptions) => { + const [state, dispatch] = useReducer(getReducer(), opts, getInitialState) + + useEffect(() => { + // save callers the effort of ref-izing their opts by checking for + // deep equality over here + if (queryEqual(opts.q, state.baseQ) && opts.pageSize === state.pageSize) { + return + } + dispatch({ type: 'INIT', opts }) + }, [opts, state.baseQ, state.pageSize]) + + useEffect(() => { + if (state.isLoading) { + const lastDoc = state.docs[state.docs.length - 1] + const nextQ = lastDoc + ? query(state.baseQ, startAfter(lastDoc), limit(state.pageSize)) + : query(state.baseQ, limit(state.pageSize)) + return onSnapshot(nextQ, (snapshot) => { + dispatch({ type: 'LOAD', snapshot }) + }) + } + }, [state.isLoading, state.baseQ, state.docs, state.pageSize]) + + return { + isLoading: state.isLoading, + isStart: state.pageStart === 0, + isEnd: state.isComplete && state.pageEnd >= state.docs.length, + getPrev: () => dispatch({ type: 'PREV' }), + getNext: () => dispatch({ type: 'NEXT' }), + getItems: () => + state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()), + } +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index f7c947fe..70785858 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -1,4 +1,5 @@ import { + Query, collection, collectionGroup, doc, @@ -148,12 +149,10 @@ export function listenForRecentComments( return listenForValues(recentCommentsQuery, setComments) } -const getUsersCommentsQuery = (userId: string) => +export const getUserCommentsQuery = (userId: string) => query( collectionGroup(db, 'comments'), where('userId', '==', userId), + where('commentType', '==', 'contract'), orderBy('createdTime', 'desc') - ) -export async function getUsersComments(userId: string) { - return await getValues(getUsersCommentsQuery(userId)) -} + ) as Query