feat: add authenticated list reading

This commit is contained in:
2026-07-26 12:58:35 +09:00
parent 83992bd962
commit 604c7f16e2
28 changed files with 947 additions and 29 deletions
@@ -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>
)
}
+32
View File
@@ -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')
+45
View File
@@ -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) {
+164
View File
@@ -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()
+102 -13
View File
@@ -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,
+29 -1
View File
@@ -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 }) => {
+17
View File
@@ -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 })
})
})
+33
View File
@@ -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))
}
+23
View File
@@ -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' },
+21 -1
View File
@@ -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,
}),