feat: paginate tweet conversations
This commit is contained in:
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
}
|
||||||
|
if (request.kind === 'search') {
|
||||||
|
return unwrap(
|
||||||
await loaders.search({
|
await loaders.search({
|
||||||
data: {
|
data: {
|
||||||
query: request.query,
|
query: request.query,
|
||||||
product: request.product,
|
product: request.product,
|
||||||
following: request.following,
|
following: request.following,
|
||||||
cursor: pageParam,
|
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
getNextPageParam: (page: PostPage) => page.nextCursor,
|
}
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user