feat: add authenticated list reading
This commit is contained in:
@@ -4,7 +4,7 @@ export function AppShell({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active?: 'user' | 'search'
|
||||
active?: 'user' | 'search' | 'list'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
@@ -26,6 +26,13 @@ export function AppShell({
|
||||
>
|
||||
検索
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'list' ? 'page' : undefined}
|
||||
search={{ target: '' }}
|
||||
to="/list"
|
||||
>
|
||||
リスト
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<section className="reader">{children}</section>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
+21
-3
@@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UserRouteImport } from './routes/user'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as ListRouteImport } from './routes/list'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId'
|
||||
|
||||
@@ -24,6 +25,11 @@ const SearchRoute = SearchRouteImport.update({
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ListRoute = ListRouteImport.update({
|
||||
id: '/list',
|
||||
path: '/list',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -37,12 +43,14 @@ const StatusTweetIdRoute = StatusTweetIdRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/list': typeof ListRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/list': typeof ListRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
@@ -50,20 +58,22 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/list': typeof ListRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
fullPaths: '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
id: '__root__' | '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
to: '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
|
||||
id: '__root__' | '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ListRoute: typeof ListRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
UserRoute: typeof UserRoute
|
||||
StatusTweetIdRoute: typeof StatusTweetIdRoute
|
||||
@@ -85,6 +95,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/list': {
|
||||
id: '/list'
|
||||
path: '/list'
|
||||
fullPath: '/list'
|
||||
preLoaderRoute: typeof ListRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -104,6 +121,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ListRoute: ListRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
UserRoute: UserRoute,
|
||||
StatusTweetIdRoute: StatusTweetIdRoute,
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { routeTree } from '../routeTree.gen'
|
||||
|
||||
const usePostFeed = vi.hoisted(() => vi.fn())
|
||||
const useListChoices = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -16,6 +17,12 @@ vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
||||
return { ...original, usePostFeed }
|
||||
})
|
||||
|
||||
vi.mock('../features/posts/use-list-choices', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../features/posts/use-list-choices')>()
|
||||
return { ...original, useListChoices }
|
||||
})
|
||||
|
||||
vi.mock('./__root', async () => {
|
||||
const { createRootRouteWithContext, Outlet } = await import(
|
||||
'@tanstack/react-router'
|
||||
@@ -41,6 +48,7 @@ describe('post feed route wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('scrollTo', vi.fn())
|
||||
usePostFeed.mockReset()
|
||||
useListChoices.mockReset()
|
||||
usePostFeed.mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
@@ -52,6 +60,13 @@ describe('post feed route wiring', () => {
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
} as never)
|
||||
useListChoices.mockReturnValue({
|
||||
data: [{ id: '1234567890', name: 'My List', memberCount: 10 }],
|
||||
error: null,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
@@ -76,6 +91,44 @@ describe('post feed route wiring', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an empty list URL disabled', async () => {
|
||||
await renderRoute('/list?target=')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
|
||||
).toBeVisible()
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('turns a manually entered list URL into a deliberate request', async () => {
|
||||
await renderRoute('/list?target=1234567890')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'list',
|
||||
target: '1234567890',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('link', { name: 'リスト', current: 'page' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('turns a selected authenticated list into a deliberate request', async () => {
|
||||
await renderRoute('/list?target=')
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'list',
|
||||
target: '1234567890',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards every manually entered search control', async () => {
|
||||
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { ListForm } from '#/features/posts/components/list-form'
|
||||
import { ListPicker } from '#/features/posts/components/list-picker'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import { listRouteSearchSchema } from '#/features/posts/inputs'
|
||||
|
||||
export const Route = createFileRoute('/list')({
|
||||
validateSearch: (search) => listRouteSearchSchema.parse(search),
|
||||
component: ListRoute,
|
||||
})
|
||||
|
||||
function ListRoute() {
|
||||
const { target } = Route.useSearch()
|
||||
const navigate = Route.useNavigate()
|
||||
return (
|
||||
<AppShell active="list">
|
||||
<h1>どのリストを読みますか?</h1>
|
||||
<p className="intro">
|
||||
自分のリストを選ぶか、リストURLを入力してください。
|
||||
</p>
|
||||
<ListPicker selectedId={target || undefined} />
|
||||
<ListForm
|
||||
initialTarget={target}
|
||||
key={target}
|
||||
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
|
||||
/>
|
||||
<PostFeed request={target ? { kind: 'list', target } : undefined} />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -116,6 +116,73 @@ a {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.list-picker {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.list-picker h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.list-picker-state {
|
||||
margin: 0;
|
||||
color: var(--secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.list-picker-state p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.list-choices {
|
||||
display: grid;
|
||||
max-height: 22rem;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.list-choices a {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.65rem;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.list-choices a[aria-current="page"] {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--paper));
|
||||
box-shadow: inset 3px 0 var(--accent);
|
||||
}
|
||||
|
||||
.list-choices span {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.list-choices small {
|
||||
color: var(--secondary);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.list-choices p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.intent-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -167,6 +234,7 @@ a {
|
||||
}
|
||||
|
||||
.intent-form button,
|
||||
.list-picker-state button,
|
||||
.state button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
@@ -457,6 +525,7 @@ video.media {
|
||||
}
|
||||
|
||||
.intent-form button:hover,
|
||||
.list-picker-state button:hover,
|
||||
.state button:hover {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
@@ -470,6 +539,11 @@ video.media {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.list-choices a:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
|
||||
Reference in New Issue
Block a user