feat: add authenticated list reading
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ListForm } from './list-form'
|
||||
import { SearchForm } from './search-form'
|
||||
import { UserForm } from './user-form'
|
||||
|
||||
@@ -29,6 +30,32 @@ describe('UserForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ListForm', () => {
|
||||
it('submits the list ID from a pasted list URL', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('リスト URL または ID'), {
|
||||
target: { value: 'https://x.com/i/lists/1234567890' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('1234567890')
|
||||
})
|
||||
|
||||
it('explains invalid input beside the field', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'リストの URL または ID を入力してください。',
|
||||
)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchForm', () => {
|
||||
it('uses a labelled grouping for ranking controls', () => {
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { normalizeListTarget } from '../inputs'
|
||||
|
||||
type Props = {
|
||||
initialTarget: string
|
||||
onSubmit: (target: string) => void
|
||||
}
|
||||
|
||||
export function ListForm({ initialTarget, onSubmit }: Props) {
|
||||
const [target, setTarget] = useState(initialTarget)
|
||||
const [error, setError] = useState('')
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const listId = normalizeListTarget(target)
|
||||
setError('')
|
||||
onSubmit(listId)
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="intent-form" onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>リスト URL または ID</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? 'list-error' : undefined}
|
||||
aria-invalid={Boolean(error)}
|
||||
name="target"
|
||||
value={target}
|
||||
onChange={(event) => setTarget(event.currentTarget.value)}
|
||||
placeholder="https://x.com/i/lists/1234567890 または ID"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="field-error" id="list-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<button type="submit">表示</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ListChoicesLoadError, useListChoices } from '../use-list-choices'
|
||||
|
||||
export function ListPicker({ selectedId }: { selectedId?: string }) {
|
||||
const query = useListChoices()
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||
<h2 id="list-picker-title">自分のリスト</h2>
|
||||
<p className="list-picker-state" role="status">
|
||||
リストを取得しています…
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
const error =
|
||||
query.error instanceof ListChoicesLoadError
|
||||
? query.error.detail
|
||||
: { message: 'リストを取得できませんでした。', retryable: true }
|
||||
return (
|
||||
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||
<h2 id="list-picker-title">自分のリスト</h2>
|
||||
<div className="list-picker-state" role="alert">
|
||||
<p>{error.message}</p>
|
||||
{error.retryable ? (
|
||||
<button onClick={() => void query.refetch()} type="button">
|
||||
再試行
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||
<h2 id="list-picker-title">自分のリスト</h2>
|
||||
{query.data.length === 0 ? (
|
||||
<p className="list-picker-state">リストはありません。</p>
|
||||
) : (
|
||||
<ul className="list-choices">
|
||||
{query.data.map((list) => (
|
||||
<li key={list.id}>
|
||||
<Link
|
||||
aria-current={selectedId === list.id ? 'page' : undefined}
|
||||
search={{ target: list.id }}
|
||||
to="/list"
|
||||
>
|
||||
<span>{list.name}</span>
|
||||
<small>
|
||||
{list.isPrivate ? '非公開 · ' : ''}
|
||||
{list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー
|
||||
</small>
|
||||
{list.description ? <p>{list.description}</p> : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
listRouteSearchSchema,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
threadPageInputSchema,
|
||||
@@ -67,6 +69,36 @@ describe('normalizeUserTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeListTarget', () => {
|
||||
it.each([
|
||||
['1234567890', '1234567890'],
|
||||
[' https://x.com/i/lists/1234567890 ', '1234567890'],
|
||||
['https://twitter.com/i/lists/1234567890/', '1234567890'],
|
||||
])('normalizes %s', (input, expected) => {
|
||||
expect(normalizeListTarget(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'not-a-list',
|
||||
'12345',
|
||||
'https://example.com/i/lists/1234567890',
|
||||
'https://x.com/tan_stack/lists',
|
||||
'https://x.com/i/lists/1234567890/members',
|
||||
'https://x.com/i/lists/not-a-list',
|
||||
])('rejects %s', (input) => {
|
||||
expect(() => normalizeListTarget(input)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listRouteSearchSchema', () => {
|
||||
it('keeps a numeric list target from a hand-entered URL', () => {
|
||||
expect(listRouteSearchSchema.parse({ target: 1234567890 })).toEqual({
|
||||
target: '1234567890',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSearchQuery', () => {
|
||||
it('keeps a deliberate query unchanged', () => {
|
||||
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||
const TWEET_ID = /^\d{1,32}$/
|
||||
const LIST_ID = /^\d{1,32}$/
|
||||
const tweetIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -28,6 +29,13 @@ export const userPageInputSchema = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const listPageInputSchema = z.object({
|
||||
target: z.string().trim().min(1).max(256),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const listChoicesInputSchema = z.object({})
|
||||
|
||||
export const searchPageInputSchema = z.object({
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Top', 'Latest']),
|
||||
@@ -39,6 +47,11 @@ export const userRouteSearchSchema = z.object({
|
||||
target: z.string().catch(''),
|
||||
})
|
||||
|
||||
export const listRouteSearchSchema = z.object({
|
||||
// List IDs are numeric, and the router JSON-parses numeric search values.
|
||||
target: z.coerce.string().catch(''),
|
||||
})
|
||||
|
||||
export const postSearchRouteSchema = z.object({
|
||||
q: z.string().catch(''),
|
||||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||
@@ -88,6 +101,38 @@ export function normalizeUserTarget(raw: string): string {
|
||||
return requireHandle(segments[0] ?? '')
|
||||
}
|
||||
|
||||
function requireListId(value: string): string {
|
||||
if (!LIST_ID.test(value)) {
|
||||
throw new InputError('リスト ID は数字で入力してください。')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalizeListTarget(raw: string): string {
|
||||
const value = raw.trim()
|
||||
if (!value) {
|
||||
throw new InputError('リストの URL または ID を入力してください。')
|
||||
}
|
||||
if (!value.includes('://')) {
|
||||
return requireListId(value)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new InputError('リスト URL の形式を確認してください。')
|
||||
}
|
||||
if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) {
|
||||
throw new InputError('x.com または twitter.com の URL を入力してください。')
|
||||
}
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
if (segments.length !== 3 || segments[0] !== 'i' || segments[1] !== 'lists') {
|
||||
throw new InputError('リストの URL を入力してください。')
|
||||
}
|
||||
return requireListId(segments[2] ?? '')
|
||||
}
|
||||
|
||||
export function buildSearchQuery(raw: string, following: boolean): string {
|
||||
const query = raw.trim()
|
||||
if (!query) {
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
type BirdReader,
|
||||
loadListChoices,
|
||||
loadListPage,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
|
||||
const reader = (): BirdReader => ({
|
||||
getLists: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
lists: [
|
||||
{
|
||||
id: '1234567890',
|
||||
name: 'My List',
|
||||
description: 'Useful accounts',
|
||||
memberCount: 10,
|
||||
subscriberCount: 4,
|
||||
isPrivate: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
owner: { id: '42', username: 'private', name: 'Private' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
getTweet: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
@@ -43,6 +60,58 @@ const reader = (): BirdReader => ({
|
||||
tweets: [],
|
||||
nextCursor: 'search-next',
|
||||
}),
|
||||
getAllListTimeline: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [
|
||||
{ id: '7', text: 'listed', author: { username: 'bird', name: 'Bird' } },
|
||||
],
|
||||
nextCursor: 'list-next',
|
||||
}),
|
||||
})
|
||||
|
||||
describe('loadListChoices', () => {
|
||||
it('returns only the fields needed by the list picker', async () => {
|
||||
const client = reader()
|
||||
|
||||
expect(await loadListChoices(client)).toEqual({
|
||||
ok: true,
|
||||
lists: [
|
||||
{
|
||||
id: '1234567890',
|
||||
name: 'My List',
|
||||
description: 'Useful accounts',
|
||||
memberCount: 10,
|
||||
isPrivate: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(client.getLists).toHaveBeenCalledWith(100)
|
||||
})
|
||||
|
||||
it('returns a safe retryable error without leaking Bird details', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getLists).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'PRIVATE_LIST_DIAGNOSTIC',
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const result = await loadListChoices(client)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'upstream',
|
||||
message: 'X から投稿を取得できませんでした。',
|
||||
retryable: true,
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('PRIVATE_LIST_DIAGNOSTIC')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadThreadPage', () => {
|
||||
@@ -250,6 +319,101 @@ describe('loadUserPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadListPage', () => {
|
||||
it('resolves a list URL and fetches exactly one page', async () => {
|
||||
const client = reader()
|
||||
const result = await loadListPage(client, {
|
||||
target: 'https://x.com/i/lists/1234567890',
|
||||
cursor: 'cursor-1',
|
||||
})
|
||||
|
||||
expect(client.getAllListTimeline).toHaveBeenCalledWith('1234567890', {
|
||||
cursor: 'cursor-1',
|
||||
maxPages: 1,
|
||||
})
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
page: {
|
||||
tweets: [
|
||||
{
|
||||
id: '7',
|
||||
text: 'listed',
|
||||
author: { username: 'bird', name: 'Bird' },
|
||||
},
|
||||
],
|
||||
nextCursor: 'list-next',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an invalid target without a Bird call', async () => {
|
||||
const client = reader()
|
||||
|
||||
expect(await loadListPage(client, { target: 'not-a-list' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-input',
|
||||
message: 'リスト ID は数字で入力してください。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
expect(client.getAllListTimeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a safe not-found error', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getAllListTimeline).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'List not found: private relay detail',
|
||||
})
|
||||
|
||||
expect(await loadListPage(client, { target: '1234567890' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'list-not-found',
|
||||
message: 'リストが見つかりませんでした。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a safe unavailable error for a protected list', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getAllListTimeline).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'List is protected: private relay detail',
|
||||
})
|
||||
|
||||
expect(await loadListPage(client, { target: '1234567890' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'list-unavailable',
|
||||
message: 'このリストは取得できません。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('removes raw response data from posts', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getAllListTimeline).mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [
|
||||
{
|
||||
id: '7',
|
||||
text: 'listed',
|
||||
author: { username: 'bird', name: 'Bird' },
|
||||
_raw: { rest_id: 'private-list-detail' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await loadListPage(client, { target: '1234567890' })
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain('private-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchPage', () => {
|
||||
it('forwards Top and appends follows once', async () => {
|
||||
const client = reader()
|
||||
|
||||
@@ -7,10 +7,13 @@ import type {
|
||||
import {
|
||||
buildSearchQuery,
|
||||
InputError,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
} from './inputs'
|
||||
import type {
|
||||
ListChoicesResult,
|
||||
ListPageInput,
|
||||
LoadError,
|
||||
LoadFailure,
|
||||
LoadResult,
|
||||
@@ -27,7 +30,21 @@ type UserLookupResult = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
type BirdListsResult =
|
||||
| {
|
||||
success: true
|
||||
lists?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
memberCount?: number
|
||||
isPrivate?: boolean
|
||||
}>
|
||||
}
|
||||
| { success: false; error?: string }
|
||||
|
||||
export interface BirdReader {
|
||||
getLists(count?: number): Promise<BirdListsResult>
|
||||
getTweet(tweetId: string): Promise<GetTweetResult>
|
||||
getThreadPaged(
|
||||
tweetId: string,
|
||||
@@ -55,6 +72,13 @@ export interface BirdReader {
|
||||
maxPages: number
|
||||
},
|
||||
): Promise<SearchResult>
|
||||
getAllListTimeline(
|
||||
listId: string,
|
||||
options: {
|
||||
cursor?: string
|
||||
maxPages: number
|
||||
},
|
||||
): Promise<SearchResult>
|
||||
}
|
||||
|
||||
const failure = (
|
||||
@@ -63,9 +87,27 @@ const failure = (
|
||||
retryable: boolean,
|
||||
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
|
||||
|
||||
type FailureSubject = 'user' | 'post' | 'list'
|
||||
|
||||
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||
user: () =>
|
||||
failure('user-not-found', 'ユーザーが見つかりませんでした。', false),
|
||||
post: () => failure('post-not-found', '投稿が見つかりませんでした。', false),
|
||||
list: () =>
|
||||
failure('list-not-found', 'リストが見つかりませんでした。', false),
|
||||
}
|
||||
|
||||
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||
user: () =>
|
||||
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false),
|
||||
post: () => failure('post-unavailable', 'この投稿は取得できません。', false),
|
||||
list: () =>
|
||||
failure('list-unavailable', 'このリストは取得できません。', false),
|
||||
}
|
||||
|
||||
function upstreamFailure(
|
||||
diagnostic: unknown = '',
|
||||
subject: 'user' | 'post' = 'user',
|
||||
subject: FailureSubject = 'user',
|
||||
): LoadFailure {
|
||||
const message =
|
||||
diagnostic instanceof Error
|
||||
@@ -78,18 +120,10 @@ function upstreamFailure(
|
||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||
}
|
||||
if (lower.includes('not found')) {
|
||||
return subject === 'post'
|
||||
? failure('post-not-found', '投稿が見つかりませんでした。', false)
|
||||
: failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
||||
return notFoundFailure[subject]()
|
||||
}
|
||||
if (lower.includes('suspended') || lower.includes('protected')) {
|
||||
return subject === 'post'
|
||||
? failure('post-unavailable', 'この投稿は取得できません。', false)
|
||||
: failure(
|
||||
'user-unavailable',
|
||||
'このユーザーの投稿は取得できません。',
|
||||
false,
|
||||
)
|
||||
return unavailableFailure[subject]()
|
||||
}
|
||||
console.error('Unexpected Bird failure:', diagnostic)
|
||||
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
||||
@@ -104,7 +138,38 @@ function publicPost(tweet: TweetData): Post {
|
||||
return post
|
||||
}
|
||||
|
||||
function resultPage(result: SearchResult): LoadResult {
|
||||
export async function loadListChoices(
|
||||
reader: BirdReader,
|
||||
): Promise<ListChoicesResult> {
|
||||
try {
|
||||
const result = await reader.getLists(100)
|
||||
if (!result.success) {
|
||||
return upstreamFailure(result.error, 'list')
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
lists: (result.lists ?? []).map((list) => ({
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
...(list.description ? { description: list.description } : {}),
|
||||
...(list.memberCount === undefined
|
||||
? {}
|
||||
: { memberCount: list.memberCount }),
|
||||
...(list.isPrivate === undefined ? {} : { isPrivate: list.isPrivate }),
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||
}
|
||||
return upstreamFailure(error, 'list')
|
||||
}
|
||||
}
|
||||
|
||||
function resultPage(
|
||||
result: SearchResult,
|
||||
subject: FailureSubject = 'user',
|
||||
): LoadResult {
|
||||
return result.success
|
||||
? {
|
||||
ok: true,
|
||||
@@ -113,7 +178,7 @@ function resultPage(result: SearchResult): LoadResult {
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
}
|
||||
: upstreamFailure(result.error)
|
||||
: upstreamFailure(result.error, subject)
|
||||
}
|
||||
|
||||
export async function loadThreadPage(
|
||||
@@ -196,6 +261,30 @@ export async function loadUserPage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadListPage(
|
||||
reader: BirdReader,
|
||||
input: ListPageInput,
|
||||
): Promise<LoadResult> {
|
||||
try {
|
||||
const listId = normalizeListTarget(input.target)
|
||||
return resultPage(
|
||||
await reader.getAllListTimeline(listId, {
|
||||
cursor: input.cursor,
|
||||
maxPages: 1,
|
||||
}),
|
||||
'list',
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||
}
|
||||
if (error instanceof InputError) {
|
||||
return failure('invalid-input', error.message, false)
|
||||
}
|
||||
return upstreamFailure(error, 'list')
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchPage(
|
||||
reader: BirdReader,
|
||||
input: SearchPageInput,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import {
|
||||
listChoicesInputSchema,
|
||||
listPageInputSchema,
|
||||
searchPageInputSchema,
|
||||
threadPageInputSchema,
|
||||
userPageInputSchema,
|
||||
} from './inputs'
|
||||
import { loadThreadPage, loadUserPage, searchPage } from './post-service'
|
||||
import {
|
||||
loadListChoices,
|
||||
loadListPage,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
import type { LoadFailure } from './types'
|
||||
|
||||
const configFailure = (diagnostic: unknown): LoadFailure => {
|
||||
@@ -34,6 +42,26 @@ export const loadUserPosts = createServerFn({ method: 'GET' })
|
||||
}
|
||||
})
|
||||
|
||||
export const loadListPosts = createServerFn({ method: 'GET' })
|
||||
.validator(listPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await loadListPage(await reader(), data)
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const loadLists = createServerFn({ method: 'GET' })
|
||||
.validator(listChoicesInputSchema)
|
||||
.handler(async () => {
|
||||
try {
|
||||
return await loadListChoices(await reader())
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const searchPosts = createServerFn({ method: 'GET' })
|
||||
.validator(searchPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
|
||||
@@ -16,6 +16,8 @@ export type ThreadPage = PostPage & {
|
||||
|
||||
type LoadErrorCode =
|
||||
| 'invalid-input'
|
||||
| 'list-not-found'
|
||||
| 'list-unavailable'
|
||||
| 'post-not-found'
|
||||
| 'post-unavailable'
|
||||
| 'user-not-found'
|
||||
@@ -38,6 +40,16 @@ export type LoadResult<TPage extends PostPage = PostPage> =
|
||||
|
||||
export type ThreadLoadResult = LoadResult<ThreadPage>
|
||||
|
||||
export type ListChoice = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
memberCount?: number
|
||||
isPrivate?: boolean
|
||||
}
|
||||
|
||||
export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure
|
||||
|
||||
export type ThreadPageInput =
|
||||
| {
|
||||
tweetId: string
|
||||
@@ -55,6 +67,11 @@ export type UserPageInput = {
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export type ListPageInput = {
|
||||
target: string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export type SearchPageInput = {
|
||||
query: string
|
||||
product: SearchProduct
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createListChoicesOptions,
|
||||
ListChoicesLoadError,
|
||||
} from './use-list-choices'
|
||||
|
||||
describe('createListChoicesOptions', () => {
|
||||
it('loads the authenticated list choices once without automatic retries', async () => {
|
||||
const loader = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
lists: [{ id: '123', name: 'My List' }],
|
||||
})
|
||||
const options = createListChoicesOptions(loader)
|
||||
|
||||
await expect(options.queryFn()).resolves.toEqual([
|
||||
{ id: '123', name: 'My List' },
|
||||
])
|
||||
expect(loader).toHaveBeenCalledWith({ data: {} })
|
||||
expect(options.queryKey).toEqual(['list-choices'])
|
||||
expect(options.retry).toBe(false)
|
||||
expect(options.refetchOnWindowFocus).toBe(false)
|
||||
expect(options.refetchOnReconnect).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves a typed load failure for the picker', async () => {
|
||||
const error = {
|
||||
code: 'upstream' as const,
|
||||
message: '取得できませんでした。',
|
||||
retryable: true,
|
||||
}
|
||||
const options = createListChoicesOptions(
|
||||
vi.fn().mockResolvedValue({ ok: false, error }),
|
||||
)
|
||||
|
||||
const request = options.queryFn()
|
||||
await expect(request).rejects.toBeInstanceOf(ListChoicesLoadError)
|
||||
await expect(request).rejects.toMatchObject({ detail: error })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { loadLists } from './server-functions'
|
||||
import type { ListChoice, ListChoicesResult, LoadError } from './types'
|
||||
|
||||
type ListChoicesLoader = (options: {
|
||||
data: Record<string, never>
|
||||
}) => Promise<ListChoicesResult>
|
||||
|
||||
export class ListChoicesLoadError extends Error {
|
||||
constructor(readonly detail: LoadError) {
|
||||
super(detail.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function createListChoicesOptions(loader: ListChoicesLoader) {
|
||||
return {
|
||||
queryKey: ['list-choices'] as const,
|
||||
queryFn: async (): Promise<ListChoice[]> => {
|
||||
const result = await loader({ data: {} })
|
||||
if (!result.ok) throw new ListChoicesLoadError(result.error)
|
||||
return result.lists
|
||||
},
|
||||
retry: false as const,
|
||||
refetchOnWindowFocus: false as const,
|
||||
refetchOnReconnect: false as const,
|
||||
}
|
||||
}
|
||||
|
||||
export function useListChoices() {
|
||||
const loader = useServerFn(loadLists)
|
||||
return useQuery(createListChoicesOptions(loader))
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type FeedLoaders = Parameters<typeof createPostFeedOptions>[1]
|
||||
|
||||
const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
|
||||
loadUser: vi.fn(),
|
||||
loadList: vi.fn(),
|
||||
search: vi.fn(),
|
||||
thread: vi.fn(),
|
||||
...overrides,
|
||||
@@ -44,6 +45,28 @@ describe('createPostFeedOptions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards one list cursor in the server function payload', async () => {
|
||||
const loadList = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
page: { tweets: [], nextCursor: 'next' },
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'list', target: '1234567890' },
|
||||
loaders({ loadList }),
|
||||
)
|
||||
|
||||
await options.queryFn({ pageParam: 'cursor' } as never)
|
||||
|
||||
expect(loadList).toHaveBeenCalledOnce()
|
||||
expect(loadList).toHaveBeenCalledWith({
|
||||
data: { target: '1234567890', cursor: 'cursor' },
|
||||
})
|
||||
expect(options.queryKey).toEqual([
|
||||
'posts',
|
||||
{ kind: 'list', target: '1234567890' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry requests automatically', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import { loadThreadPosts, loadUserPosts, searchPosts } from './server-functions'
|
||||
import {
|
||||
loadListPosts,
|
||||
loadThreadPosts,
|
||||
loadUserPosts,
|
||||
searchPosts,
|
||||
} from './server-functions'
|
||||
import type {
|
||||
ListPageInput,
|
||||
LoadError,
|
||||
LoadResult,
|
||||
PostPage,
|
||||
@@ -15,6 +21,7 @@ import type {
|
||||
|
||||
export type FeedRequest =
|
||||
| { kind: 'user'; target: string }
|
||||
| { kind: 'list'; target: string }
|
||||
| {
|
||||
kind: 'search'
|
||||
query: string
|
||||
@@ -32,6 +39,7 @@ type FeedPageParam = string | ThreadPageParam | undefined
|
||||
|
||||
type Loaders = {
|
||||
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
|
||||
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
|
||||
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
||||
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
|
||||
}
|
||||
@@ -65,6 +73,16 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (request.kind === 'list') {
|
||||
return unwrap(
|
||||
await loaders.loadList({
|
||||
data: {
|
||||
target: request.target,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (request.kind === 'search') {
|
||||
return unwrap(
|
||||
await loaders.search({
|
||||
@@ -104,6 +122,7 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
|
||||
export function usePostFeed(request: FeedRequest | undefined) {
|
||||
const loadUser = useServerFn(loadUserPosts)
|
||||
const loadList = useServerFn(loadListPosts)
|
||||
const search = useServerFn(searchPosts)
|
||||
const thread = useServerFn(loadThreadPosts)
|
||||
const disabled = {
|
||||
@@ -114,6 +133,7 @@ export function usePostFeed(request: FeedRequest | undefined) {
|
||||
return useInfiniteQuery({
|
||||
...createPostFeedOptions(request ?? disabled, {
|
||||
loadUser,
|
||||
loadList,
|
||||
search,
|
||||
thread,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user