diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index ae3513a254..30c145a7f5 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1363,7 +1363,7 @@ "count": 2 }, "typescript/no-floating-promises": { - "count": 2 + "count": 1 }, "typescript/no-unsafe-member-access": { "count": 2 @@ -1398,7 +1398,7 @@ "count": 3 }, "typescript/no-floating-promises": { - "count": 3 + "count": 2 }, "typescript/no-unsafe-member-access": { "count": 3 @@ -1802,4 +1802,4 @@ "count": 3 } } -} \ No newline at end of file +} diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 54d9dc9067..edf10b0539 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -80,7 +80,7 @@ export class CustomFeedAPI implements FeedAPI { res.data.feed = res.data.feed.slice(0, limit) } return { - cursor: res.data.feed.length ? res.data.cursor : undefined, + cursor: res.data.cursor, feed: res.data.feed, } } diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index 1511dc833a..0581e64452 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -42,10 +42,8 @@ export class LikesFeedAPI implements FeedAPI { limit, }) if (res.success) { - // HACKFIX: the API incorrectly returns a cursor when there are no items -sfn - const isEmptyPage = res.data.feed.length === 0 return { - cursor: isEmptyPage ? undefined : res.data.cursor, + cursor: res.data.cursor, feed: res.data.feed, } } diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index c341dd53a0..06daaeaa0d 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -213,10 +213,9 @@ class MergeFeedSource { const res = await this._getFeed(this.cursor, n) if (res.success) { this.cursor = res.data.cursor + this.hasMore = Boolean(this.cursor) if (res.data.feed.length) { this.queue = this.queue.concat(res.data.feed) - } else { - this.hasMore = false } } else { this.hasMore = false diff --git a/src/state/queries/activity-subscriptions.ts b/src/state/queries/activity-subscriptions.ts index 30964fe7d6..c4085bba08 100644 --- a/src/state/queries/activity-subscriptions.ts +++ b/src/state/queries/activity-subscriptions.ts @@ -13,6 +13,7 @@ import { useQueryClient, } from '@tanstack/react-query' +import {useAutoPagination} from '#/state/queries/util' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/components/Toast' @@ -22,7 +23,7 @@ export const RQKEY_getNotificationDeclaration = ['notification-declaration'] export function useActivitySubscriptionsQuery() { const agent = useAgent() - return useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: RQKEY_getActivitySubscriptions, queryFn: async ({pageParam}) => { const response = @@ -34,6 +35,13 @@ export function useActivitySubscriptionsQuery() { initialPageParam: undefined as string | undefined, getNextPageParam: prev => prev.cursor, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.subscriptions.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, 50) + return query } export function useNotificationDeclarationQuery() { diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 84e6d88f29..8159a8abb0 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -8,6 +8,7 @@ import { } from '@tanstack/react-query' import {STALE} from '#/state/queries' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' export const RQKEY_ROOT = 'actor-search' @@ -29,7 +30,7 @@ export function useActorSearch({ limit?: number }) { const agent = useAgent() - return useInfiniteQuery< + const result = useInfiniteQuery< AppBskyActorSearchActors.OutputSchema, Error, InfiniteData, @@ -52,6 +53,11 @@ export function useActorSearch({ placeholderData: maintainData ? keepPreviousData : undefined, select, }) + const itemCount = + result.data?.pages.reduce((count, page) => count + page.actors.length, 0) ?? + 0 + useAutoPagination(result, itemCount, limit) + return result } function select(data: InfiniteData) { diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index 97cc3b5e05..bcdd731f26 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -1,7 +1,10 @@ import {type QueryClient, useInfiniteQuery} from '@tanstack/react-query' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' +const PAGE_SIZE = 10 + export const RQKEY_ROOT = 'actor-starter-packs' export const RQKEY_WITH_MEMBERSHIP_ROOT = 'actor-starter-packs-with-membership' export const RQKEY = (did?: string) => [RQKEY_ROOT, did] @@ -19,12 +22,12 @@ export function useActorStarterPacksQuery({ }) { const agent = useAgent() - return useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: RQKEY(did), queryFn: async ({pageParam}: {pageParam?: string}) => { const res = await agent.app.bsky.graph.getActorStarterPacks({ actor: did!, - limit: 10, + limit: PAGE_SIZE, cursor: pageParam, }) return res.data @@ -33,6 +36,13 @@ export function useActorStarterPacksQuery({ initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.starterPacks.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export function useActorStarterPacksWithMembershipsQuery({ @@ -44,12 +54,12 @@ export function useActorStarterPacksWithMembershipsQuery({ }) { const agent = useAgent() - return useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: RQKEY_WITH_MEMBERSHIP(did), queryFn: async ({pageParam}: {pageParam?: string}) => { const res = await agent.app.bsky.graph.getStarterPacksWithMembership({ actor: did!, - limit: 10, + limit: PAGE_SIZE, cursor: pageParam, }) return res.data @@ -58,6 +68,13 @@ export function useActorStarterPacksWithMembershipsQuery({ initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.starterPacksWithMembership.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export async function invalidateActorStarterPacksQuery({ diff --git a/src/state/queries/bookmarks/useBookmarksQuery.ts b/src/state/queries/bookmarks/useBookmarksQuery.ts index 3e8e87a132..c0862a80d8 100644 --- a/src/state/queries/bookmarks/useBookmarksQuery.ts +++ b/src/state/queries/bookmarks/useBookmarksQuery.ts @@ -15,6 +15,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from '#/state/queries/util' import {useAgent} from '#/state/session' import * as bsky from '#/types/bsky' @@ -25,7 +26,7 @@ export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot] export function useBookmarksQuery() { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyBookmarkGetBookmarks.OutputSchema, Error, InfiniteData, @@ -42,6 +43,13 @@ export function useBookmarksQuery() { initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.bookmarks.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, 50) + return query } export async function truncateAndInvalidate(qc: QueryClient) { diff --git a/src/state/queries/find-contacts.ts b/src/state/queries/find-contacts.ts index b1eb6c9c5e..acbe8d5ef9 100644 --- a/src/state/queries/find-contacts.ts +++ b/src/state/queries/find-contacts.ts @@ -6,6 +6,7 @@ import { useQuery, } from '@tanstack/react-query' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' import {type Match} from '#/components/contacts/state' import type * as bsky from '#/types/bsky' @@ -32,7 +33,7 @@ export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches'] export function useContactsMatchesQuery() { const agent = useAgent() - return useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: findContactsGetMatchesQueryKey, queryFn: async ({pageParam}) => { const matches = await agent.app.bsky.contact.getMatches({ @@ -44,6 +45,11 @@ export function useContactsMatchesQuery() { getNextPageParam: lastPage => lastPage.cursor, staleTime: STALE.MINUTES.ONE, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.matches.length, 0) ?? + 0 + useAutoPagination(query, itemCount, 50) + return query } export function optimisticRemoveMatch(queryClient: QueryClient, did: string) { diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index c43c7bb983..61b853dc8d 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -13,6 +13,7 @@ import { } from '@tanstack/react-query' import {STALE} from '#/state/queries' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' const PAGE_SIZE = 30 @@ -25,7 +26,7 @@ export const RQKEY_ALL = (uri: string) => [RQKEY_ROOT_ALL, uri] export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetList.OutputSchema, Error, InfiniteData, @@ -46,6 +47,10 @@ export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) { getNextPageParam: lastPage => lastPage.cursor, enabled: Boolean(uri), }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0 + useAutoPagination(query, itemCount, limit) + return query } export function useAllListMembersQuery(uri?: string) { diff --git a/src/state/queries/lists-with-membership.ts b/src/state/queries/lists-with-membership.ts index 3147b8845f..cfd3a746a3 100644 --- a/src/state/queries/lists-with-membership.ts +++ b/src/state/queries/lists-with-membership.ts @@ -9,7 +9,7 @@ import { useInfiniteQuery, } from '@tanstack/react-query' -import {createQueryKey} from '#/state/queries/util' +import {createQueryKey, useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' export type ListWithMembership = @@ -28,7 +28,7 @@ export function useListsWithMembershipQuery({ }) { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetListsWithMembership.OutputSchema, Error, InfiniteData, @@ -48,6 +48,13 @@ export function useListsWithMembershipQuery({ initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.listsWithMembership.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, 50) + return query } export function updateListMembershipOptimistically({ diff --git a/src/state/queries/my-blocked-accounts.ts b/src/state/queries/my-blocked-accounts.ts index a2e29136f5..bb5fb79441 100644 --- a/src/state/queries/my-blocked-accounts.ts +++ b/src/state/queries/my-blocked-accounts.ts @@ -7,14 +7,16 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useAutoPagination} from './util' const RQKEY_ROOT = 'my-blocked-accounts' +const PAGE_SIZE = 30 export const RQKEY = () => [RQKEY_ROOT] type RQPageParam = string | undefined export function useMyBlockedAccountsQuery() { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetBlocks.OutputSchema, Error, InfiniteData, @@ -24,7 +26,7 @@ export function useMyBlockedAccountsQuery() { queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await agent.app.bsky.graph.getBlocks({ - limit: 30, + limit: PAGE_SIZE, cursor: pageParam, }) return res.data @@ -32,6 +34,11 @@ export function useMyBlockedAccountsQuery() { initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.blocks.length, 0) ?? + 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export function* findAllProfilesInQueryData( diff --git a/src/state/queries/my-muted-accounts.ts b/src/state/queries/my-muted-accounts.ts index bf36b90296..47adf55d8b 100644 --- a/src/state/queries/my-muted-accounts.ts +++ b/src/state/queries/my-muted-accounts.ts @@ -7,14 +7,16 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useAutoPagination} from './util' const RQKEY_ROOT = 'my-muted-accounts' +const PAGE_SIZE = 30 export const RQKEY = () => [RQKEY_ROOT] type RQPageParam = string | undefined export function useMyMutedAccountsQuery() { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetMutes.OutputSchema, Error, InfiniteData, @@ -24,7 +26,7 @@ export function useMyMutedAccountsQuery() { queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await agent.app.bsky.graph.getMutes({ - limit: 30, + limit: PAGE_SIZE, cursor: pageParam, }) return res.data @@ -32,6 +34,10 @@ export function useMyMutedAccountsQuery() { initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.mutes.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export function* findAllProfilesInQueryData( diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index cc24d3d0fc..5c6c11bf23 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -16,7 +16,7 @@ * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. */ -import {useCallback, useEffect, useMemo, useRef} from 'react' +import {useCallback, useMemo, useRef} from 'react' import { AppBskyFeedDefs, AppBskyFeedPost, @@ -40,6 +40,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from '../util' import {type FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' @@ -221,54 +222,9 @@ export function useNotificationFeedQuery(opts: { ), }) - // The server may end up returning an empty page, a page with too few items, - // or a page with items that end up getting filtered out. When we fetch pages, - // we'll keep track of how many items we actually hope to see. If the server - // doesn't return enough items, we're going to continue asking for more items. - const lastItemCount = useRef(0) - const wantedItemCount = useRef(0) - const autoPaginationAttemptCount = useRef(0) - useEffect(() => { - const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = - query - // Count the items that we already have. - let itemCount = 0 - for (const page of data?.pages || []) { - itemCount += page.items.length - } - - // If items got truncated, reset the state we're tracking below. - if (itemCount !== lastItemCount.current) { - if (itemCount < lastItemCount.current) { - wantedItemCount.current = itemCount - } - lastItemCount.current = itemCount - } - - // Now track how many items we really want, and fetch more if needed. - if (isLoading || isRefetching) { - // During the initial fetch, we want to get an entire page's worth of items. - wantedItemCount.current = PAGE_SIZE - } else if (isFetchingNextPage) { - if (itemCount > wantedItemCount.current) { - // We have more items than wantedItemCount, so wantedItemCount must be out of date. - // Some other code must have called fetchNextPage(), for example, from onEndReached. - // Adjust the wantedItemCount to reflect that we want one more full page of items. - wantedItemCount.current = itemCount + PAGE_SIZE - } - } else if (hasNextPage) { - // At this point we're not fetching anymore, so it's time to make a decision. - // If we didn't receive enough items from the server, paginate again until we do. - if (itemCount < wantedItemCount.current) { - autoPaginationAttemptCount.current++ - if (autoPaginationAttemptCount.current < 50 /* failsafe */) { - query.fetchNextPage() - } - } else { - autoPaginationAttemptCount.current = 0 - } - } - }, [query]) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) return query } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 959ed81c28..03f023085e 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useRef} from 'react' +import {useCallback, useMemo, useRef} from 'react' import {AppState} from 'react-native' import { type AppBskyActorDefs, @@ -43,6 +43,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from './util' type ActorDid = string @@ -362,56 +363,13 @@ export function usePostFeedQuery( ), }) - // The server may end up returning an empty page, a page with too few items, - // or a page with items that end up getting filtered out. When we fetch pages, - // we'll keep track of how many items we actually hope to see. If the server - // doesn't return enough items, we're going to continue asking for more items. - const lastItemCount = useRef(0) - const wantedItemCount = useRef(0) - const autoPaginationAttemptCount = useRef(0) - useEffect(() => { - const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} = - query - // Count the items that we already have. - let itemCount = 0 - for (const page of data?.pages || []) { - for (const slice of page.slices) { - itemCount += slice.items.length - } + let itemCount = 0 + for (const page of query.data?.pages || []) { + for (const slice of page.slices) { + itemCount += slice.items.length } - - // If items got truncated, reset the state we're tracking below. - if (itemCount !== lastItemCount.current) { - if (itemCount < lastItemCount.current) { - wantedItemCount.current = itemCount - } - lastItemCount.current = itemCount - } - - // Now track how many items we really want, and fetch more if needed. - if (isLoading || isRefetching) { - // During the initial fetch, we want to get an entire page's worth of items. - wantedItemCount.current = MIN_POSTS - } else if (isFetchingNextPage) { - if (itemCount > wantedItemCount.current) { - // We have more items than wantedItemCount, so wantedItemCount must be out of date. - // Some other code must have called fetchNextPage(), for example, from onEndReached. - // Adjust the wantedItemCount to reflect that we want one more full page of items. - wantedItemCount.current = itemCount + MIN_POSTS - } - } else if (hasNextPage) { - // At this point we're not fetching anymore, so it's time to make a decision. - // If we didn't receive enough items from the server, paginate again until we do. - if (itemCount < wantedItemCount.current) { - autoPaginationAttemptCount.current++ - if (autoPaginationAttemptCount.current < 50 /* failsafe */) { - query.fetchNextPage() - } - } else { - autoPaginationAttemptCount.current = 0 - } - } - }, [query]) + } + useAutoPagination(query, itemCount, MIN_POSTS) return query } diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts index e4f37c14ca..1be8586125 100644 --- a/src/state/queries/post-liked-by.ts +++ b/src/state/queries/post-liked-by.ts @@ -8,7 +8,7 @@ import { } from '@tanstack/react-query' import {STALE} from '#/state/queries' -import {createQueryKey} from '#/state/queries/util' +import {createQueryKey, useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' const PAGE_SIZE = 30 @@ -20,7 +20,7 @@ export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] export function useLikedByQuery(resolvedUri: string | undefined) { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyFeedGetLikes.OutputSchema, Error, InfiniteData, @@ -40,6 +40,10 @@ export function useLikedByQuery(resolvedUri: string | undefined) { getNextPageParam: lastPage => lastPage.cursor, enabled: !!resolvedUri, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.likes.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } /** diff --git a/src/state/queries/post-quotes.ts b/src/state/queries/post-quotes.ts index 1d0fa07e8e..51b56449ef 100644 --- a/src/state/queries/post-quotes.ts +++ b/src/state/queries/post-quotes.ts @@ -17,6 +17,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from './util' const PAGE_SIZE = 30 @@ -27,7 +28,7 @@ export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] export function usePostQuotesQuery(resolvedUri: string | undefined) { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyFeedGetQuotes.OutputSchema, Error, InfiniteData, @@ -65,6 +66,10 @@ export function usePostQuotesQuery(resolvedUri: string | undefined) { } }, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.posts.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export function* findAllProfilesInQueryData( diff --git a/src/state/queries/post-reposted-by.ts b/src/state/queries/post-reposted-by.ts index 814a815aae..14304e6009 100644 --- a/src/state/queries/post-reposted-by.ts +++ b/src/state/queries/post-reposted-by.ts @@ -10,6 +10,7 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useAutoPagination} from './util' const PAGE_SIZE = 30 type RQPageParam = string | undefined @@ -20,7 +21,7 @@ export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] export function usePostRepostedByQuery(resolvedUri: string | undefined) { const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyFeedGetRepostedBy.OutputSchema, Error, InfiniteData, @@ -40,6 +41,13 @@ export function usePostRepostedByQuery(resolvedUri: string | undefined) { getNextPageParam: lastPage => lastPage.cursor, enabled: !!resolvedUri, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.repostedBy.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export function* findAllProfilesInQueryData( diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index 56e7c70182..4c0b090a1c 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -8,6 +8,7 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' import {useModerationOpts} from '../preferences/moderation-opts' @@ -25,7 +26,7 @@ export function useProfileFeedgensQuery( const moderationOpts = useModerationOpts() const enabled = opts?.enabled !== false && Boolean(moderationOpts) const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyFeedGetActorFeeds.OutputSchema, Error, InfiniteData, @@ -66,4 +67,8 @@ export function useProfileFeedgensQuery( } }, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.feeds.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts index 62968af2d8..a6fee858a9 100644 --- a/src/state/queries/profile-followers.ts +++ b/src/state/queries/profile-followers.ts @@ -11,6 +11,7 @@ import { import {useAgent} from '#/state/session' import {useAnalytics} from '#/analytics' +import {useAutoPagination} from './util' const DEFAULT_SORT = 'latest' const PAGE_SIZE = 30 @@ -37,7 +38,7 @@ export function useProfileFollowersQuery( const sortParam = isSortEnabled ? sort || DEFAULT_SORT : undefined - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetFollowers.OutputSchema, Error, InfiniteData, @@ -58,6 +59,13 @@ export function useProfileFollowersQuery( getNextPageParam: lastPage => lastPage.cursor, enabled: !!did, }) + const itemCount = + query.data?.pages.reduce( + (count, page) => count + page.followers.length, + 0, + ) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } export function* findAllProfilesInQueryData( diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts index 1c4d5dc69c..29202fd338 100644 --- a/src/state/queries/profile-follows.ts +++ b/src/state/queries/profile-follows.ts @@ -9,6 +9,7 @@ import { import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' import {useAnalytics} from '#/analytics' +import {useAutoPagination} from './util' const DEFAULT_SORT = 'latest' const PAGE_SIZE = 30 @@ -38,7 +39,7 @@ export function useProfileFollowsQuery( const sortParam = isSortEnabled ? sort || DEFAULT_SORT : undefined - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetFollows.OutputSchema, Error, InfiniteData, @@ -60,6 +61,11 @@ export function useProfileFollowsQuery( getNextPageParam: lastPage => lastPage.cursor, enabled: !!did, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.follows.length, 0) ?? + 0 + useAutoPagination(query, itemCount, limit || PAGE_SIZE) + return query } export function* findAllProfilesInQueryData( diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 965da72e51..4b621dc795 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -5,6 +5,7 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' import {useModerationOpts} from '../preferences/moderation-opts' @@ -18,7 +19,7 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { const moderationOpts = useModerationOpts() const enabled = opts?.enabled !== false && Boolean(moderationOpts) const agent = useAgent() - return useInfiniteQuery< + const query = useInfiniteQuery< AppBskyGraphGetLists.OutputSchema, Error, InfiniteData, @@ -55,4 +56,8 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { } }, }) + const itemCount = + query.data?.pages.reduce((count, page) => count + page.lists.length, 0) ?? 0 + useAutoPagination(query, itemCount, PAGE_SIZE) + return query } diff --git a/src/state/queries/search-posts-v2.ts b/src/state/queries/search-posts-v2.ts index e33328b5a0..2f7d208546 100644 --- a/src/state/queries/search-posts-v2.ts +++ b/src/state/queries/search-posts-v2.ts @@ -24,6 +24,7 @@ import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, + useAutoPagination, } from './util' const searchPostsQueryKeyRoot = 'search-posts' @@ -64,7 +65,7 @@ export function useSearchPostsV2Query({ result: InfiniteData } | null>(null) - return useInfiniteQuery< + const result = useInfiniteQuery< AppBskyFeedSearchPostsV2.OutputSchema, Error, InfiniteData, @@ -170,6 +171,11 @@ export function useSearchPostsV2Query({ [selectArgs], ), }) + const uris = new Set( + result.data?.pages.flatMap(page => page.posts.map(post => post.uri)), + ) + useAutoPagination(result, uris.size, 25) + return result } export function* findAllPostsInQueryData( diff --git a/src/state/queries/starter-pack-search.ts b/src/state/queries/starter-pack-search.ts index 7987d1e07f..854e4eedc7 100644 --- a/src/state/queries/starter-pack-search.ts +++ b/src/state/queries/starter-pack-search.ts @@ -7,6 +7,7 @@ import { } from '@tanstack/react-query' import {STALE} from '#/state/queries' +import {useAutoPagination} from '#/state/queries/util' import {useAgent} from '#/state/session' export const RQKEY_ROOT = 'starter-pack-search' @@ -28,7 +29,7 @@ export function useStarterPackSearch({ limit?: number }) { const agent = useAgent() - return useInfiniteQuery< + const result = useInfiniteQuery< AppBskyGraphSearchStarterPacksV2.OutputSchema, Error, InfiniteData, @@ -51,6 +52,13 @@ export function useStarterPackSearch({ placeholderData: maintainData ? keepPreviousData : undefined, select, }) + const itemCount = + result.data?.pages.reduce( + (count, page) => count + page.starterPacks.length, + 0, + ) ?? 0 + useAutoPagination(result, itemCount, limit) + return result } function select( diff --git a/src/state/queries/util.test.tsx b/src/state/queries/util.test.tsx new file mode 100644 index 0000000000..360be2d05a --- /dev/null +++ b/src/state/queries/util.test.tsx @@ -0,0 +1,41 @@ +import {renderHook} from '@testing-library/react-native' + +import {useAutoPagination} from './util' + +function query(overrides: Record = {}) { + return { + data: {pageParams: [undefined]}, + isLoading: false, + isRefetching: false, + isFetchingNextPage: false, + hasNextPage: true, + fetchNextPage: jest.fn().mockResolvedValue(undefined), + ...overrides, + } +} + +describe('useAutoPagination', () => { + it('fetches another page when visible items are missing', () => { + const value = query() + + renderHook(() => useAutoPagination(value, 0, 10)) + + expect(value.fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it('stops when the requested number of items is visible', () => { + const value = query() + + renderHook(() => useAutoPagination(value, 10, 10)) + + expect(value.fetchNextPage).not.toHaveBeenCalled() + }) + + it('stops when the server repeats a cursor', () => { + const value = query({data: {pageParams: [undefined, 'a', 'a']}}) + + renderHook(() => useAutoPagination(value, 0, 10)) + + expect(value.fetchNextPage).not.toHaveBeenCalled() + }) +}) diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index 7ea54745c0..0d97de6fe3 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -1,3 +1,4 @@ +import {useEffect, useRef} from 'react' import { type AppBskyActorDefs, AppBskyEmbedRecord, @@ -14,6 +15,57 @@ import { import * as bsky from '#/types/bsky' +type AutoPaginationQuery = { + data?: {pageParams: unknown[]} + isLoading: boolean + isRefetching: boolean + isFetchingNextPage: boolean + hasNextPage: boolean + fetchNextPage: () => Promise +} + +export function useAutoPagination( + query: AutoPaginationQuery, + itemCount: number, + pageSize: number, +) { + const lastItemCount = useRef(0) + const wantedItemCount = useRef(pageSize) + const attemptCount = useRef(0) + + useEffect(() => { + if (itemCount !== lastItemCount.current) { + if (itemCount < lastItemCount.current) { + wantedItemCount.current = itemCount + } + lastItemCount.current = itemCount + } + + if (query.isLoading || query.isRefetching) { + wantedItemCount.current = pageSize + } else if (query.isFetchingNextPage) { + if (itemCount > wantedItemCount.current) { + wantedItemCount.current = itemCount + pageSize + } + } else if (query.hasNextPage) { + if (itemCount < wantedItemCount.current) { + const pageParams = query.data?.pageParams + const repeatedCursor = + pageParams && + pageParams.length > 1 && + Object.is(pageParams.at(-1), pageParams.at(-2)) + if (repeatedCursor) return + attemptCount.current++ + if (attemptCount.current < 50) { + void query.fetchNextPage() + } + } else { + attemptCount.current = 0 + } + } + }, [itemCount, pageSize, query]) +} + export type StructuredQueryKey> = readonly [ string, T, diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index effa9ad430..2082f76961 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -79,7 +79,7 @@ export function ProfileFeedgens({ error, refetch, } = useProfileFeedgensQuery(did, opts) - const isEmpty = !isPending && !data?.pages[0]?.feeds.length + const isEmpty = !isPending && !data?.pages.some(page => page.feeds.length) const {data: preferences} = usePreferencesQuery() const navigation = useNavigation() const {currentAccount} = useSession() diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index 04876eefb2..679686ae8a 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -90,7 +90,7 @@ export function ListMembers({ hasNextPage, isFetchingNextPage, } = useListMembersQuery(list) - const isEmpty = !isFetching && !data?.pages[0].items.length + const isEmpty = !isFetching && !data?.pages.some(page => page.items.length) const isOwner = currentAccount && data?.pages[0].list.creator.did === currentAccount.did diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index afc4c63791..373cb60427 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -79,7 +79,7 @@ export function ProfileLists({ error, refetch, } = useProfileListsQuery(did, opts) - const isEmpty = !isPending && !data?.pages[0]?.lists.length + const isEmpty = !isPending && !data?.pages.some(page => page.lists.length) const {data: preferences} = usePreferencesQuery() const navigation = useNavigation() const {currentAccount} = useSession() diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 258a3c9b5f..1e5adb097f 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -36,7 +36,7 @@ export function ModerationBlockedAccounts({}: Props) { fetchNextPage, isFetchingNextPage, } = useMyBlockedAccountsQuery() - const isEmpty = !isFetching && !data?.pages[0]?.blocks.length + const isEmpty = !isFetching && !data?.pages.some(page => page.blocks.length) const profiles = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.blocks) diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 122464d301..160eaad1a2 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -36,7 +36,7 @@ export function ModerationMutedAccounts({}: Props) { fetchNextPage, isFetchingNextPage, } = useMyMutedAccountsQuery() - const isEmpty = !isFetching && !data?.pages[0]?.mutes.length + const isEmpty = !isFetching && !data?.pages.some(page => page.mutes.length) const profiles = useMemo(() => { if (data?.pages) { return data.pages.flatMap(page => page.mutes)