From 8e6ff70d8144faaff2aebf94ee732b140916de4b Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Tue, 14 Jul 2026 00:29:03 +0900 Subject: [PATCH] feat: paginate tweet conversations --- src/features/posts/page.test.ts | 34 ++++++++- src/features/posts/page.ts | 16 ++++- src/features/posts/server-functions.ts | 22 ++++-- src/features/posts/use-post-feed.test.ts | 83 +++++++++++++++++++--- src/features/posts/use-post-feed.ts | 88 ++++++++++++++++++------ 5 files changed, 204 insertions(+), 39 deletions(-) diff --git a/src/features/posts/page.test.ts b/src/features/posts/page.test.ts index fa842ed..f394298 100644 --- a/src/features/posts/page.test.ts +++ b/src/features/posts/page.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { flattenPostPages } from './page' -import type { Post, PostPage } from './types' +import { + flattenConversationPages, + flattenPostPages, + focalPostFromPages, +} from './page' +import type { Post, PostPage, ThreadPage } from './types' const post = (id: string): Post => ({ id, @@ -18,3 +22,29 @@ describe('flattenPostPages', () => { 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']) + }) +}) diff --git a/src/features/posts/page.ts b/src/features/posts/page.ts index d737758..ddeeb1f 100644 --- a/src/features/posts/page.ts +++ b/src/features/posts/page.ts @@ -1,4 +1,4 @@ -import type { Post, PostPage } from './types' +import type { Post, PostPage, ThreadPage } from './types' export function flattenPostPages(pages: PostPage[]): Post[] { const seen = new Set() @@ -10,3 +10,17 @@ export function flattenPostPages(pages: PostPage[]): Post[] { }), ) } + +export function focalPostFromPages( + pages: Array, +): Post | undefined { + const first = pages[0] + return first && 'focalPost' in first ? first.focalPost : undefined +} + +export function flattenConversationPages( + pages: Array, + focalPostId: string, +): Post[] { + return flattenPostPages(pages).filter(({ id }) => id !== focalPostId) +} diff --git a/src/features/posts/server-functions.ts b/src/features/posts/server-functions.ts index fd79c4c..2669f13 100644 --- a/src/features/posts/server-functions.ts +++ b/src/features/posts/server-functions.ts @@ -1,9 +1,13 @@ import { createServerFn } from '@tanstack/react-start' -import { searchPageInputSchema, userPageInputSchema } from './inputs' -import { loadUserPage, searchPage } from './post-service' -import type { LoadResult } from './types' +import { + searchPageInputSchema, + 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) return { ok: false, @@ -39,3 +43,13 @@ export const searchPosts = createServerFn({ method: 'GET' }) 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) + } + }) diff --git a/src/features/posts/use-post-feed.test.ts b/src/features/posts/use-post-feed.test.ts index 0a44e55..dd5ebf8 100644 --- a/src/features/posts/use-post-feed.test.ts +++ b/src/features/posts/use-post-feed.test.ts @@ -16,6 +16,15 @@ vi.mock('@tanstack/react-start', async (importOriginal) => { return { ...original, useServerFn } }) +type FeedLoaders = Parameters[1] + +const loaders = (overrides: Partial = {}): FeedLoaders => ({ + loadUser: vi.fn(), + search: vi.fn(), + thread: vi.fn(), + ...overrides, +}) + describe('createPostFeedOptions', () => { it('forwards one user cursor in the server function payload', async () => { const loadUser = vi.fn().mockResolvedValue({ @@ -24,7 +33,7 @@ describe('createPostFeedOptions', () => { }) const options = createPostFeedOptions( { kind: 'user', target: '@yuta' }, - { loadUser, search: vi.fn() }, + loaders({ loadUser }), ) await options.queryFn({ pageParam: 'cursor' } as never) @@ -38,7 +47,7 @@ describe('createPostFeedOptions', () => { it('does not retry requests automatically', () => { const options = createPostFeedOptions( { kind: 'user', target: 'yuta' }, - { loadUser: vi.fn(), search: vi.fn() }, + loaders(), ) expect(options.retry).toBe(false) @@ -47,7 +56,7 @@ describe('createPostFeedOptions', () => { it('does not refetch automatically when the window regains focus', () => { const options = createPostFeedOptions( { kind: 'user', target: 'yuta' }, - { loadUser: vi.fn(), search: vi.fn() }, + loaders(), ) expect(options.refetchOnWindowFocus).toBe(false) @@ -56,7 +65,7 @@ describe('createPostFeedOptions', () => { it('does not refetch automatically when the connection returns', () => { const options = createPostFeedOptions( { kind: 'user', target: 'yuta' }, - { loadUser: vi.fn(), search: vi.fn() }, + loaders(), ) expect(options.refetchOnReconnect).toBe(false) @@ -65,7 +74,7 @@ describe('createPostFeedOptions', () => { it('takes the next cursor only from the resolved page', () => { const options = createPostFeedOptions( { kind: 'user', target: 'yuta' }, - { loadUser: vi.fn(), search: vi.fn() }, + loaders(), ) expect( @@ -82,7 +91,7 @@ describe('createPostFeedOptions', () => { product: 'Top', following: true, }, - { loadUser: vi.fn(), search: vi.fn() }, + loaders(), ) expect(options.queryKey).toEqual([ @@ -103,7 +112,7 @@ describe('createPostFeedOptions', () => { product: 'Top', following: true, }, - { loadUser: vi.fn(), search }, + loaders({ search }), ) 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 () => { const error = { code: 'upstream' as const, @@ -127,10 +191,9 @@ describe('createPostFeedOptions', () => { } const options = createPostFeedOptions( { kind: 'user', target: 'yuta' }, - { + loaders({ loadUser: vi.fn().mockResolvedValue({ ok: false, error }), - search: vi.fn(), - }, + }), ) const request = options.queryFn({ pageParam: undefined } as never) diff --git a/src/features/posts/use-post-feed.ts b/src/features/posts/use-post-feed.ts index a369b70..04ec597 100644 --- a/src/features/posts/use-post-feed.ts +++ b/src/features/posts/use-post-feed.ts @@ -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 search: (options: { data: SearchPageInput }) => Promise + thread: (options: { data: ThreadPageInput }) => Promise } export class PostLoadError extends Error { @@ -30,7 +42,7 @@ export class PostLoadError extends Error { } } -function unwrap(result: LoadResult): PostPage { +function unwrap(result: LoadResult): 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, }) }