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 (
-
- {issues.items.map((issue) => (
-
- ))}
-
+ <>
+
+ {issues.items.map((issue) => (
+
+ ))}
+
+ {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 (
-
- {pulls.items.map((pull) => (
-
- ))}
-
+ <>
+
+ {pulls.items.map((pull) => (
+
+ ))}
+
+ {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 }
}