feat: paginate tweet conversations

This commit is contained in:
2026-07-14 00:29:03 +09:00
parent 1eb2733b38
commit 8e6ff70d81
5 changed files with 204 additions and 39 deletions
+66 -22
View File
@@ -1,12 +1,15 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import type { SearchProduct } from '@yuta/bird'
import { loadUserPosts, searchPosts } from './server-functions'
import { loadThreadPosts, loadUserPosts, searchPosts } from './server-functions'
import type {
LoadError,
LoadResult,
PostPage,
SearchPageInput,
ThreadLoadResult,
ThreadPage,
ThreadPageInput,
UserPageInput,
} from './types'
@@ -18,10 +21,19 @@ export type FeedRequest =
product: SearchProduct
following: boolean
}
| { kind: 'thread'; tweetId: string }
type ThreadPageParam = {
cursor: string
conversationId: string
}
type FeedPageParam = string | ThreadPageParam | undefined
type Loaders = {
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
}
export class PostLoadError extends Error {
@@ -30,7 +42,7 @@ export class PostLoadError extends Error {
}
}
function unwrap(result: LoadResult): PostPage {
function unwrap<TPage extends PostPage>(result: LoadResult<TPage>): TPage {
if (!result.ok) throw new PostLoadError(result.error)
return result.page
}
@@ -38,41 +50,73 @@ function unwrap(result: LoadResult): PostPage {
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return {
queryKey: ['posts', request] as const,
initialPageParam: undefined as string | undefined,
initialPageParam: undefined as FeedPageParam,
retry: false as const,
refetchOnWindowFocus: false as const,
refetchOnReconnect: false as const,
queryFn: async ({ pageParam }: { pageParam: string | undefined }) =>
request.kind === 'user'
? unwrap(
await loaders.loadUser({
data: { target: request.target, cursor: pageParam },
}),
)
: unwrap(
await loaders.search({
data: {
query: request.query,
product: request.product,
following: request.following,
cursor: pageParam,
},
}),
),
getNextPageParam: (page: PostPage) => page.nextCursor,
queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
if (request.kind === 'user') {
return unwrap(
await loaders.loadUser({
data: {
target: request.target,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
},
}),
)
}
if (request.kind === 'search') {
return unwrap(
await loaders.search({
data: {
query: request.query,
product: request.product,
following: request.following,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
},
}),
)
}
return unwrap(
await loaders.thread({
data:
typeof pageParam === 'object'
? {
tweetId: request.tweetId,
cursor: pageParam.cursor,
conversationId: pageParam.conversationId,
}
: { tweetId: request.tweetId },
}),
)
},
getNextPageParam: (page: PostPage | ThreadPage) => {
if (!page.nextCursor) return undefined
return request.kind === 'thread' && 'conversationId' in page
? {
cursor: page.nextCursor,
conversationId: page.conversationId,
}
: page.nextCursor
},
}
}
export function usePostFeed(request: FeedRequest | undefined) {
const loadUser = useServerFn(loadUserPosts)
const search = useServerFn(searchPosts)
const thread = useServerFn(loadThreadPosts)
const disabled = {
kind: 'user',
target: '',
} satisfies FeedRequest
return useInfiniteQuery({
...createPostFeedOptions(request ?? disabled, { loadUser, search }),
...createPostFeedOptions(request ?? disabled, {
loadUser,
search,
thread,
}),
enabled: request !== undefined,
})
}