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
+32 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { flattenPostPages } from './page' import {
import type { Post, PostPage } from './types' flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from './page'
import type { Post, PostPage, ThreadPage } from './types'
const post = (id: string): Post => ({ const post = (id: string): Post => ({
id, id,
@@ -18,3 +22,29 @@ describe('flattenPostPages', () => {
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3']) expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
}) })
}) })
describe('thread page helpers', () => {
const focal = post('2')
const pages: ThreadPage[] = [
{
focalPost: focal,
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), focal],
},
{
conversationId: '1',
tweets: [focal, post('3')],
},
]
it('takes the focal post from the first page', () => {
expect(focalPostFromPages(pages)).toEqual(focal)
})
it('deduplicates the conversation and removes the focal post', () => {
expect(
flattenConversationPages(pages, focal.id).map(({ id }) => id),
).toEqual(['1', '3'])
})
})
+15 -1
View File
@@ -1,4 +1,4 @@
import type { Post, PostPage } from './types' import type { Post, PostPage, ThreadPage } from './types'
export function flattenPostPages(pages: PostPage[]): Post[] { export function flattenPostPages(pages: PostPage[]): Post[] {
const seen = new Set<string>() const seen = new Set<string>()
@@ -10,3 +10,17 @@ export function flattenPostPages(pages: PostPage[]): Post[] {
}), }),
) )
} }
export function focalPostFromPages(
pages: Array<PostPage | ThreadPage>,
): Post | undefined {
const first = pages[0]
return first && 'focalPost' in first ? first.focalPost : undefined
}
export function flattenConversationPages(
pages: Array<PostPage | ThreadPage>,
focalPostId: string,
): Post[] {
return flattenPostPages(pages).filter(({ id }) => id !== focalPostId)
}
+18 -4
View File
@@ -1,9 +1,13 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from '@tanstack/react-start'
import { searchPageInputSchema, userPageInputSchema } from './inputs' import {
import { loadUserPage, searchPage } from './post-service' searchPageInputSchema,
import type { LoadResult } from './types' threadPageInputSchema,
userPageInputSchema,
} from './inputs'
import { loadThreadPage, loadUserPage, searchPage } from './post-service'
import type { LoadFailure } from './types'
const configFailure = (diagnostic: unknown): LoadResult => { const configFailure = (diagnostic: unknown): LoadFailure => {
console.error('Bird client configuration failed:', diagnostic) console.error('Bird client configuration failed:', diagnostic)
return { return {
ok: false, ok: false,
@@ -39,3 +43,13 @@ export const searchPosts = createServerFn({ method: 'GET' })
return configFailure(error) return configFailure(error)
} }
}) })
export const loadThreadPosts = createServerFn({ method: 'GET' })
.validator(threadPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadThreadPage(await reader(), data)
} catch (error) {
return configFailure(error)
}
})
+73 -10
View File
@@ -16,6 +16,15 @@ vi.mock('@tanstack/react-start', async (importOriginal) => {
return { ...original, useServerFn } return { ...original, useServerFn }
}) })
type FeedLoaders = Parameters<typeof createPostFeedOptions>[1]
const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
loadUser: vi.fn(),
search: vi.fn(),
thread: vi.fn(),
...overrides,
})
describe('createPostFeedOptions', () => { describe('createPostFeedOptions', () => {
it('forwards one user cursor in the server function payload', async () => { it('forwards one user cursor in the server function payload', async () => {
const loadUser = vi.fn().mockResolvedValue({ const loadUser = vi.fn().mockResolvedValue({
@@ -24,7 +33,7 @@ describe('createPostFeedOptions', () => {
}) })
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: '@yuta' }, { kind: 'user', target: '@yuta' },
{ loadUser, search: vi.fn() }, loaders({ loadUser }),
) )
await options.queryFn({ pageParam: 'cursor' } as never) await options.queryFn({ pageParam: 'cursor' } as never)
@@ -38,7 +47,7 @@ describe('createPostFeedOptions', () => {
it('does not retry requests automatically', () => { it('does not retry requests automatically', () => {
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' }, { kind: 'user', target: 'yuta' },
{ loadUser: vi.fn(), search: vi.fn() }, loaders(),
) )
expect(options.retry).toBe(false) expect(options.retry).toBe(false)
@@ -47,7 +56,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the window regains focus', () => { it('does not refetch automatically when the window regains focus', () => {
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' }, { kind: 'user', target: 'yuta' },
{ loadUser: vi.fn(), search: vi.fn() }, loaders(),
) )
expect(options.refetchOnWindowFocus).toBe(false) expect(options.refetchOnWindowFocus).toBe(false)
@@ -56,7 +65,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the connection returns', () => { it('does not refetch automatically when the connection returns', () => {
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' }, { kind: 'user', target: 'yuta' },
{ loadUser: vi.fn(), search: vi.fn() }, loaders(),
) )
expect(options.refetchOnReconnect).toBe(false) expect(options.refetchOnReconnect).toBe(false)
@@ -65,7 +74,7 @@ describe('createPostFeedOptions', () => {
it('takes the next cursor only from the resolved page', () => { it('takes the next cursor only from the resolved page', () => {
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' }, { kind: 'user', target: 'yuta' },
{ loadUser: vi.fn(), search: vi.fn() }, loaders(),
) )
expect( expect(
@@ -82,7 +91,7 @@ describe('createPostFeedOptions', () => {
product: 'Top', product: 'Top',
following: true, following: true,
}, },
{ loadUser: vi.fn(), search: vi.fn() }, loaders(),
) )
expect(options.queryKey).toEqual([ expect(options.queryKey).toEqual([
@@ -103,7 +112,7 @@ describe('createPostFeedOptions', () => {
product: 'Top', product: 'Top',
following: true, following: true,
}, },
{ loadUser: vi.fn(), search }, loaders({ search }),
) )
await options.queryFn({ pageParam: 'search-cursor' } as never) await options.queryFn({ pageParam: 'search-cursor' } as never)
@@ -119,6 +128,61 @@ describe('createPostFeedOptions', () => {
}) })
}) })
it('loads the initial thread and carries its root with the next cursor', async () => {
const thread = vi.fn().mockResolvedValue({
ok: true,
page: {
tweets: [],
focalPost: {
id: '123',
text: 'focal',
author: { username: 'focus', name: 'Focus' },
},
conversationId: '100',
nextCursor: 'thread-next',
},
})
const options = createPostFeedOptions(
{ kind: 'thread', tweetId: '123' },
loaders({ thread }),
)
const page = await options.queryFn({ pageParam: undefined } as never)
expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } })
expect(options.queryKey).toEqual([
'posts',
{ kind: 'thread', tweetId: '123' },
])
expect(options.getNextPageParam(page)).toEqual({
cursor: 'thread-next',
conversationId: '100',
})
})
it('forwards a thread continuation page parameter intact', async () => {
const thread = vi.fn().mockResolvedValue({
ok: true,
page: { tweets: [], conversationId: '100' },
})
const options = createPostFeedOptions(
{ kind: 'thread', tweetId: '123' },
loaders({ thread }),
)
await options.queryFn({
pageParam: { cursor: 'thread-next', conversationId: '100' },
} as never)
expect(thread).toHaveBeenCalledWith({
data: {
tweetId: '123',
cursor: 'thread-next',
conversationId: '100',
},
})
})
it('preserves a typed load failure for the feed', async () => { it('preserves a typed load failure for the feed', async () => {
const error = { const error = {
code: 'upstream' as const, code: 'upstream' as const,
@@ -127,10 +191,9 @@ describe('createPostFeedOptions', () => {
} }
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' }, { kind: 'user', target: 'yuta' },
{ loaders({
loadUser: vi.fn().mockResolvedValue({ ok: false, error }), loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
search: vi.fn(), }),
},
) )
const request = options.queryFn({ pageParam: undefined } as never) const request = options.queryFn({ pageParam: undefined } as never)
+66 -22
View File
@@ -1,12 +1,15 @@
import { useInfiniteQuery } from '@tanstack/react-query' import { useInfiniteQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from '@tanstack/react-start'
import type { SearchProduct } from '@yuta/bird' import type { SearchProduct } from '@yuta/bird'
import { loadUserPosts, searchPosts } from './server-functions' import { loadThreadPosts, loadUserPosts, searchPosts } from './server-functions'
import type { import type {
LoadError, LoadError,
LoadResult, LoadResult,
PostPage, PostPage,
SearchPageInput, SearchPageInput,
ThreadLoadResult,
ThreadPage,
ThreadPageInput,
UserPageInput, UserPageInput,
} from './types' } from './types'
@@ -18,10 +21,19 @@ export type FeedRequest =
product: SearchProduct product: SearchProduct
following: boolean following: boolean
} }
| { kind: 'thread'; tweetId: string }
type ThreadPageParam = {
cursor: string
conversationId: string
}
type FeedPageParam = string | ThreadPageParam | undefined
type Loaders = { type Loaders = {
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult> loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
search: (options: { data: SearchPageInput }) => Promise<LoadResult> search: (options: { data: SearchPageInput }) => Promise<LoadResult>
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
} }
export class PostLoadError extends Error { 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) if (!result.ok) throw new PostLoadError(result.error)
return result.page return result.page
} }
@@ -38,41 +50,73 @@ function unwrap(result: LoadResult): PostPage {
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) { export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return { return {
queryKey: ['posts', request] as const, queryKey: ['posts', request] as const,
initialPageParam: undefined as string | undefined, initialPageParam: undefined as FeedPageParam,
retry: false as const, retry: false as const,
refetchOnWindowFocus: false as const, refetchOnWindowFocus: false as const,
refetchOnReconnect: false as const, refetchOnReconnect: false as const,
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
request.kind === 'user' if (request.kind === 'user') {
? unwrap( return unwrap(
await loaders.loadUser({ await loaders.loadUser({
data: { target: request.target, cursor: pageParam }, data: {
}), target: request.target,
) cursor: typeof pageParam === 'string' ? pageParam : undefined,
: unwrap( },
await loaders.search({ }),
data: { )
query: request.query, }
product: request.product, if (request.kind === 'search') {
following: request.following, return unwrap(
cursor: pageParam, await loaders.search({
}, data: {
}), query: request.query,
), product: request.product,
getNextPageParam: (page: PostPage) => page.nextCursor, 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) { export function usePostFeed(request: FeedRequest | undefined) {
const loadUser = useServerFn(loadUserPosts) const loadUser = useServerFn(loadUserPosts)
const search = useServerFn(searchPosts) const search = useServerFn(searchPosts)
const thread = useServerFn(loadThreadPosts)
const disabled = { const disabled = {
kind: 'user', kind: 'user',
target: '', target: '',
} satisfies FeedRequest } satisfies FeedRequest
return useInfiniteQuery({ return useInfiniteQuery({
...createPostFeedOptions(request ?? disabled, { loadUser, search }), ...createPostFeedOptions(request ?? disabled, {
loadUser,
search,
thread,
}),
enabled: request !== undefined, enabled: request !== undefined,
}) })
} }