diff --git a/src/components/repo/IssueComments.tsx b/src/components/repo/IssueComments.tsx index 5257023..f54d81d 100644 --- a/src/components/repo/IssueComments.tsx +++ b/src/components/repo/IssueComments.tsx @@ -7,6 +7,7 @@ import { useVisibleActor } from '../../hooks/useVisibleActor' import type { CommentRecord } from '../../lib/tangled/feed' import { ProfileAvatar } from '../profile/ProfileAvatar' import { MarkdownContent } from '../shared/MarkdownContent' +import { LoadMoreButton } from '../shared/LoadMoreButton' import { SurfaceCard } from '../shared/SurfaceCard' const COMMENT_SKELETON_COUNT = 3 @@ -16,9 +17,9 @@ type IssueCommentsProps = { } export function IssueComments({ issueUri }: IssueCommentsProps) { - const { comments, error } = useIssueComments(issueUri) + const { comments, error, hasMore, isLoadingMore, loadMore } = useIssueComments(issueUri) - if (error) { + if (error && comments === null) { return

Could not load comments: {error.message}

} @@ -43,6 +44,20 @@ export function IssueComments({ issueUri }: IssueCommentsProps) { ))} + {hasMore && ( +
+ void loadMore()} + /> +
+ )} + {error && ( +

+ Could not load more comments: {error.message} +

+ )} ) } diff --git a/src/components/repo/RepoIssues.tsx b/src/components/repo/RepoIssues.tsx index 61a8ba9..519c005 100644 --- a/src/components/repo/RepoIssues.tsx +++ b/src/components/repo/RepoIssues.tsx @@ -6,6 +6,7 @@ import { useVisibleActor } from '../../hooks/useVisibleActor' import { useRepoIssues } from '../../hooks/useRepoIssues' import { getRecordRkey, type Issue } from '../../lib/tangled/repo' import { SurfaceCard } from '../shared/SurfaceCard' +import { LoadMoreButton } from '../shared/LoadMoreButton' type RepoIssuesProps = { repoOwnerHandle: Handle @@ -14,9 +15,9 @@ type RepoIssuesProps = { } export function RepoIssues({ repoOwnerHandle, repoDid, repoKey }: RepoIssuesProps) { - const { issues, error } = useRepoIssues(repoDid) + const { issues, error, hasMore, isLoadingMore, loadMore } = useRepoIssues(repoDid) - if (error) { + if (error && issues === null) { return

Could not load issues: {error.message}

} @@ -29,16 +30,28 @@ export function RepoIssues({ repoOwnerHandle, repoDid, repoKey }: RepoIssuesProp } return ( - + <> + + {hasMore && ( +
+ void loadMore()} + /> +
+ )} + {error && issues !== null &&

Could not load more issues: {error.message}

} + ) } diff --git a/src/components/repo/RepoPulls.tsx b/src/components/repo/RepoPulls.tsx index 202e855..8e8f3ea 100644 --- a/src/components/repo/RepoPulls.tsx +++ b/src/components/repo/RepoPulls.tsx @@ -6,6 +6,7 @@ import { useVisibleActor } from '../../hooks/useVisibleActor' import { useRepoPulls } from '../../hooks/useRepoPulls' import { getRecordRkey, type Pull } from '../../lib/tangled/repo' import { SurfaceCard } from '../shared/SurfaceCard' +import { LoadMoreButton } from '../shared/LoadMoreButton' type RepoPullsProps = { repoOwnerHandle: Handle @@ -14,9 +15,9 @@ type RepoPullsProps = { } export function RepoPulls({ repoOwnerHandle, repoDid, repoKey }: RepoPullsProps) { - const { pulls, error } = useRepoPulls(repoDid) + const { pulls, error, hasMore, isLoadingMore, loadMore } = useRepoPulls(repoDid) - if (error) { + if (error && pulls === null) { return

Could not load pulls: {error.message}

} @@ -29,16 +30,30 @@ export function RepoPulls({ repoOwnerHandle, repoDid, repoKey }: RepoPullsProps) } return ( - + <> + + {hasMore && ( +
+ void loadMore()} + /> +
+ )} + {error && pulls !== null && ( +

Could not load more pull requests: {error.message}

+ )} + ) } diff --git a/src/components/shared/LoadMoreButton.tsx b/src/components/shared/LoadMoreButton.tsx new file mode 100644 index 0000000..de39110 --- /dev/null +++ b/src/components/shared/LoadMoreButton.tsx @@ -0,0 +1,18 @@ +type LoadMoreButtonProps = { + isLoading: boolean + label: string + onClick: () => void +} + +export function LoadMoreButton({ isLoading, label, onClick }: LoadMoreButtonProps) { + return ( + + ) +} diff --git a/src/hooks/useCursorList.ts b/src/hooks/useCursorList.ts new file mode 100644 index 0000000..a14da99 --- /dev/null +++ b/src/hooks/useCursorList.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type CursorList = { + cursor?: string + items: T[] +} + +type CursorListOptions = { + cursor?: string + limit: number +} + +type CursorListState = { + data?: CursorList + error?: Error + key: string +} + +export function useCursorList( + key: string | null, + load: (options: CursorListOptions) => Promise>, + limit: number, +) { + const [state, setState] = useState | null>(null) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const loadRef = useRef(load) + loadRef.current = load + + useEffect(() => { + if (key === null) return + + let isCancelled = false + setState({ key }) + + void loadRef + .current({ limit }) + .then((data) => { + if (!isCancelled) setState({ key, data }) + }) + .catch((caught) => { + if (!isCancelled) { + setState({ + key, + error: caught instanceof Error ? caught : new Error('Unable to load list'), + }) + } + }) + + return () => { + isCancelled = true + } + }, [key, limit]) + + const isCurrentList = state?.key === key + const data = isCurrentList ? (state.data ?? null) : null + const error = isCurrentList ? (state.error ?? null) : null + + const loadMore = useCallback(async () => { + if (key === null || data?.cursor === undefined || isLoadingMore) return + + setIsLoadingMore(true) + try { + const nextPage = await loadRef.current({ cursor: data.cursor, limit }) + setState((currentState) => { + if (currentState?.key !== key || currentState.data === undefined) return currentState + + return { + key, + data: { + items: [...currentState.data.items, ...nextPage.items], + cursor: nextPage.cursor, + }, + } + }) + } catch (caught) { + setState((currentState) => { + if (currentState?.key !== key || currentState.data === undefined) return currentState + + return { + key, + data: currentState.data, + error: caught instanceof Error ? caught : new Error('Unable to load more items'), + } + }) + } finally { + setIsLoadingMore(false) + } + }, [data?.cursor, isLoadingMore, key, limit]) + + return { + data, + error, + hasMore: data?.cursor !== undefined, + isLoadingMore, + loadMore, + } +} diff --git a/src/hooks/useIssueComments.ts b/src/hooks/useIssueComments.ts index 55cbe6d..d661f48 100644 --- a/src/hooks/useIssueComments.ts +++ b/src/hooks/useIssueComments.ts @@ -1,36 +1,15 @@ import type { ResourceUri } from '@atcute/lexicons' -import { useEffect, useState } from 'react' import { listComments, type CommentList } from '../lib/tangled/feed' +import { useCursorList } from './useCursorList' -const INITIAL_COMMENT_LIMIT = 50 +const INITIAL_COMMENT_LIMIT = 20 export function useIssueComments(issue: ResourceUri) { - const [comments, setComments] = useState(null) - const [error, setError] = useState(null) + const { data, error, hasMore, isLoadingMore, loadMore } = useCursorList( + issue, + (options) => listComments(issue, { ...options, order: 'asc' }), + INITIAL_COMMENT_LIMIT, + ) - useEffect(() => { - let isCancelled = false - - async function loadComments() { - setComments(null) - setError(null) - - try { - const result = await listComments(issue, { limit: INITIAL_COMMENT_LIMIT, order: 'asc' }) - if (!isCancelled) setComments(result) - } catch (caught) { - if (!isCancelled) { - setError(caught instanceof Error ? caught : new Error('Unable to load comments.')) - } - } - } - - void loadComments() - - return () => { - isCancelled = true - } - }, [issue]) - - return { comments, error } + return { comments: data as CommentList | null, error, hasMore, isLoadingMore, loadMore } } diff --git a/src/hooks/useRepoIssues.ts b/src/hooks/useRepoIssues.ts index 5193b9b..1438df9 100644 --- a/src/hooks/useRepoIssues.ts +++ b/src/hooks/useRepoIssues.ts @@ -1,25 +1,15 @@ import type { Did } from '@atcute/lexicons' -import { useEffect, useState } from 'react' import { listIssues, type IssueList } from '../lib/tangled/repo' +import { useCursorList } from './useCursorList' const INITIAL_ISSUE_LIMIT = 20 export function useRepoIssues(repoDid: Did) { - const [issues, setIssues] = useState(null) - const [error, setError] = useState(null) + const { data, error, hasMore, isLoadingMore, loadMore } = useCursorList( + repoDid, + (options) => listIssues(repoDid, options), + INITIAL_ISSUE_LIMIT, + ) - useEffect(() => { - async function loadIssues() { - setError(null) - try { - const result = await listIssues(repoDid, { limit: INITIAL_ISSUE_LIMIT }) - setIssues(result) - } catch (caught) { - setError(caught instanceof Error ? caught : new Error('Unable to load issues.')) - } - } - - void loadIssues() - }, [repoDid]) - return { issues, error } + return { issues: data as IssueList | null, error, hasMore, isLoadingMore, loadMore } } diff --git a/src/hooks/useRepoPulls.ts b/src/hooks/useRepoPulls.ts index 2d3f631..df79ca7 100644 --- a/src/hooks/useRepoPulls.ts +++ b/src/hooks/useRepoPulls.ts @@ -1,26 +1,15 @@ import type { Did } from '@atcute/lexicons' -import { useEffect, useState } from 'react' import { listPulls, type PullList } from '../lib/tangled/repo' +import { useCursorList } from './useCursorList' const INITIAL_PULL_LIMIT = 20 export function useRepoPulls(repoDid: Did) { - const [pulls, setPulls] = useState(null) - const [error, setError] = useState(null) + const { data, error, hasMore, isLoadingMore, loadMore } = useCursorList( + repoDid, + (options) => listPulls(repoDid, options), + INITIAL_PULL_LIMIT, + ) - useEffect(() => { - async function loadPulls() { - setError(null) - try { - const result = await listPulls(repoDid, { limit: INITIAL_PULL_LIMIT }) - setPulls(result) - } catch (caught) { - setError(caught instanceof Error ? caught : new Error('Unable to load pulls.')) - } - } - - void loadPulls() - }, [repoDid]) - - return { pulls, error } + return { pulls: data as PullList | null, error, hasMore, isLoadingMore, loadMore } }