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
+3 -2
View File
@@ -1,14 +1,15 @@
# Twitter Lite # Twitter Lite
Twitter Lite is an intentional, read-only X reader. It shows content only after Twitter Lite is an intentional, read-only X reader. It shows content only after
you enter a user handle, profile URL, or search query, follow a deliberate post you enter a user handle, profile URL, search query, or list URL, follow a
detail link, or manually open a valid `/status/:tweetId` URL. deliberate post detail link, or manually open a valid `/status/:tweetId` URL.
## Scope ## Scope
- User timelines and raw X search syntax - User timelines and raw X search syntax
- Popular (`Top`) and chronological (`Latest`) search - Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search - Optional `filter:follows` search
- Authenticated account list selection and list timelines from a deliberate URL or ID
- Infinite cursor pagination with explicit retry - Infinite cursor pagination with explicit retry
- Deliberate post detail pages with the visible conversation - Deliberate post detail pages with the visible conversation
- Infinite conversation loading with explicit continuation retry - Infinite conversation loading with explicit continuation retry
+1 -1
View File
@@ -17,7 +17,7 @@
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11; pnpm = pkgs.pnpm_11;
fetcherVersion = 4; fetcherVersion = 4;
hash = "sha256-TF1cCC86a24Cukks7+BbeM2fvU0SkHtSm6RUIF55RuY="; hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
}; };
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
+1 -1
View File
@@ -30,7 +30,7 @@
"@tanstack/react-router": "1.170.17", "@tanstack/react-router": "1.170.17",
"@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.27", "@tanstack/react-start": "1.168.27",
"@yuta/bird": "0.10.0", "@yuta/bird": "0.10.1",
"nitro": "3.0.260610-beta", "nitro": "3.0.260610-beta",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
+5 -5
View File
@@ -21,8 +21,8 @@ importers:
specifier: 1.168.27 specifier: 1.168.27
version: 1.168.27([email protected]([email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])) version: 1.168.27([email protected]([email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
'@yuta/bird': '@yuta/bird':
specifier: 0.10.0 specifier: 0.10.1
version: 0.10.0 version: 0.10.1
nitro: nitro:
specifier: 3.0.260610-beta specifier: 3.0.260610-beta
version: 3.0.260610-beta([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])) version: 3.0.260610-beta([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
@@ -1044,8 +1044,8 @@ packages:
'@vitest/[email protected]': '@vitest/[email protected]':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
'@yuta/[email protected].0': '@yuta/[email protected].1':
resolution: {integrity: sha512-qU/zveaouKVEvEGsXxK4f1djgY7BLhi//NvyCMobEE93T+kzVFHD3wLbW9Yiz1hVqnsRIad5RtCYXeYz8gVMgg==, tarball: https://git.yutakobayashi.com/api/packages/yuta/npm/%40yuta%2Fbird/-/0.10.0/bird-0.10.0.tgz} resolution: {integrity: sha512-WS6rI6P3ilwmTH6V9ANEp4pb1zxhnYAlKW1oDKsTwz7wJrh+toZ/bsAYaxbN/5+2wOdZEyWxSC5J8m8/goinBA==, tarball: https://git.yutakobayashi.com/api/packages/yuta/npm/%40yuta%2Fbird/-/0.10.1/bird-0.10.1.tgz}
hasBin: true hasBin: true
[email protected]: [email protected]:
@@ -2821,7 +2821,7 @@ snapshots:
convert-source-map: 2.0.0 convert-source-map: 2.0.0
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
'@yuta/[email protected].0': '@yuta/[email protected].1':
dependencies: dependencies:
commander: 14.0.3 commander: 14.0.3
json5: 2.2.3 json5: 2.2.3
+8 -1
View File
@@ -4,7 +4,7 @@ export function AppShell({
active, active,
children, children,
}: { }: {
active?: 'user' | 'search' active?: 'user' | 'search' | 'list'
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
@@ -26,6 +26,13 @@ export function AppShell({
> >
検索 検索
</Link> </Link>
<Link
aria-current={active === 'list' ? 'page' : undefined}
search={{ target: '' }}
to="/list"
>
リスト
</Link>
</nav> </nav>
</header> </header>
<section className="reader">{children}</section> <section className="reader">{children}</section>
@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { ListForm } from './list-form'
import { SearchForm } from './search-form' import { SearchForm } from './search-form'
import { UserForm } from './user-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', () => { describe('SearchForm', () => {
it('uses a labelled grouping for ranking controls', () => { it('uses a labelled grouping for ranking controls', () => {
render( 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 { describe, expect, it } from 'vitest'
import { import {
buildSearchQuery, buildSearchQuery,
listRouteSearchSchema,
normalizeListTarget,
normalizeTweetId, normalizeTweetId,
normalizeUserTarget, normalizeUserTarget,
threadPageInputSchema, 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', () => { describe('buildSearchQuery', () => {
it('keeps a deliberate query unchanged', () => { it('keeps a deliberate query unchanged', () => {
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja') 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 HANDLE = /^[A-Za-z0-9_]{1,15}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/ const TWEET_ID = /^\d{1,32}$/
const LIST_ID = /^\d{1,32}$/
const tweetIdSchema = z const tweetIdSchema = z
.string() .string()
.trim() .trim()
@@ -28,6 +29,13 @@ export const userPageInputSchema = z.object({
cursor: z.string().min(1).optional(), 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({ export const searchPageInputSchema = z.object({
query: z.string().trim().min(1).max(512), query: z.string().trim().min(1).max(512),
product: z.enum(['Top', 'Latest']), product: z.enum(['Top', 'Latest']),
@@ -39,6 +47,11 @@ export const userRouteSearchSchema = z.object({
target: z.string().catch(''), 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({ export const postSearchRouteSchema = z.object({
q: z.string().catch(''), q: z.string().catch(''),
product: z.enum(['Top', 'Latest']).catch('Latest'), product: z.enum(['Top', 'Latest']).catch('Latest'),
@@ -88,6 +101,38 @@ export function normalizeUserTarget(raw: string): string {
return requireHandle(segments[0] ?? '') 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 { export function buildSearchQuery(raw: string, following: boolean): string {
const query = raw.trim() const query = raw.trim()
if (!query) { if (!query) {
+164
View File
@@ -1,12 +1,29 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { import {
type BirdReader, type BirdReader,
loadListChoices,
loadListPage,
loadThreadPage, loadThreadPage,
loadUserPage, loadUserPage,
searchPage, searchPage,
} from './post-service' } from './post-service'
const reader = (): BirdReader => ({ 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({ getTweet: vi.fn().mockResolvedValue({
success: true, success: true,
tweet: { tweet: {
@@ -43,6 +60,58 @@ const reader = (): BirdReader => ({
tweets: [], tweets: [],
nextCursor: 'search-next', 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', () => { 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', () => { describe('searchPage', () => {
it('forwards Top and appends follows once', async () => { it('forwards Top and appends follows once', async () => {
const client = reader() const client = reader()
+102 -13
View File
@@ -7,10 +7,13 @@ import type {
import { import {
buildSearchQuery, buildSearchQuery,
InputError, InputError,
normalizeListTarget,
normalizeTweetId, normalizeTweetId,
normalizeUserTarget, normalizeUserTarget,
} from './inputs' } from './inputs'
import type { import type {
ListChoicesResult,
ListPageInput,
LoadError, LoadError,
LoadFailure, LoadFailure,
LoadResult, LoadResult,
@@ -27,7 +30,21 @@ type UserLookupResult = {
error?: string 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 { export interface BirdReader {
getLists(count?: number): Promise<BirdListsResult>
getTweet(tweetId: string): Promise<GetTweetResult> getTweet(tweetId: string): Promise<GetTweetResult>
getThreadPaged( getThreadPaged(
tweetId: string, tweetId: string,
@@ -55,6 +72,13 @@ export interface BirdReader {
maxPages: number maxPages: number
}, },
): Promise<SearchResult> ): Promise<SearchResult>
getAllListTimeline(
listId: string,
options: {
cursor?: string
maxPages: number
},
): Promise<SearchResult>
} }
const failure = ( const failure = (
@@ -63,9 +87,27 @@ const failure = (
retryable: boolean, retryable: boolean,
): LoadFailure => ({ ok: false, error: { code, message, retryable } }) ): 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( function upstreamFailure(
diagnostic: unknown = '', diagnostic: unknown = '',
subject: 'user' | 'post' = 'user', subject: FailureSubject = 'user',
): LoadFailure { ): LoadFailure {
const message = const message =
diagnostic instanceof Error diagnostic instanceof Error
@@ -78,18 +120,10 @@ function upstreamFailure(
return failure('timeout', '取得がタイムアウトしました。', true) return failure('timeout', '取得がタイムアウトしました。', true)
} }
if (lower.includes('not found')) { if (lower.includes('not found')) {
return subject === 'post' return notFoundFailure[subject]()
? failure('post-not-found', '投稿が見つかりませんでした。', false)
: failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
} }
if (lower.includes('suspended') || lower.includes('protected')) { if (lower.includes('suspended') || lower.includes('protected')) {
return subject === 'post' return unavailableFailure[subject]()
? failure('post-unavailable', 'この投稿は取得できません。', false)
: failure(
'user-unavailable',
'このユーザーの投稿は取得できません。',
false,
)
} }
console.error('Unexpected Bird failure:', diagnostic) console.error('Unexpected Bird failure:', diagnostic)
return failure('upstream', 'X から投稿を取得できませんでした。', true) return failure('upstream', 'X から投稿を取得できませんでした。', true)
@@ -104,7 +138,38 @@ function publicPost(tweet: TweetData): Post {
return 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 return result.success
? { ? {
ok: true, ok: true,
@@ -113,7 +178,7 @@ function resultPage(result: SearchResult): LoadResult {
nextCursor: result.nextCursor, nextCursor: result.nextCursor,
}, },
} }
: upstreamFailure(result.error) : upstreamFailure(result.error, subject)
} }
export async function loadThreadPage( 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( export async function searchPage(
reader: BirdReader, reader: BirdReader,
input: SearchPageInput, input: SearchPageInput,
+29 -1
View File
@@ -1,10 +1,18 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from '@tanstack/react-start'
import { import {
listChoicesInputSchema,
listPageInputSchema,
searchPageInputSchema, searchPageInputSchema,
threadPageInputSchema, threadPageInputSchema,
userPageInputSchema, userPageInputSchema,
} from './inputs' } from './inputs'
import { loadThreadPage, loadUserPage, searchPage } from './post-service' import {
loadListChoices,
loadListPage,
loadThreadPage,
loadUserPage,
searchPage,
} from './post-service'
import type { LoadFailure } from './types' import type { LoadFailure } from './types'
const configFailure = (diagnostic: unknown): LoadFailure => { 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' }) export const searchPosts = createServerFn({ method: 'GET' })
.validator(searchPageInputSchema) .validator(searchPageInputSchema)
.handler(async ({ data }) => { .handler(async ({ data }) => {
+17
View File
@@ -16,6 +16,8 @@ export type ThreadPage = PostPage & {
type LoadErrorCode = type LoadErrorCode =
| 'invalid-input' | 'invalid-input'
| 'list-not-found'
| 'list-unavailable'
| 'post-not-found' | 'post-not-found'
| 'post-unavailable' | 'post-unavailable'
| 'user-not-found' | 'user-not-found'
@@ -38,6 +40,16 @@ export type LoadResult<TPage extends PostPage = PostPage> =
export type ThreadLoadResult = LoadResult<ThreadPage> 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 = export type ThreadPageInput =
| { | {
tweetId: string tweetId: string
@@ -55,6 +67,11 @@ export type UserPageInput = {
cursor?: string cursor?: string
} }
export type ListPageInput = {
target: string
cursor?: string
}
export type SearchPageInput = { export type SearchPageInput = {
query: string query: string
product: SearchProduct 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 => ({ const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
loadUser: vi.fn(), loadUser: vi.fn(),
loadList: vi.fn(),
search: vi.fn(), search: vi.fn(),
thread: vi.fn(), thread: vi.fn(),
...overrides, ...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', () => { it('does not retry requests automatically', () => {
const options = createPostFeedOptions( const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' }, { kind: 'user', target: 'yuta' },
+21 -1
View File
@@ -1,8 +1,14 @@
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 { loadThreadPosts, loadUserPosts, searchPosts } from './server-functions' import {
loadListPosts,
loadThreadPosts,
loadUserPosts,
searchPosts,
} from './server-functions'
import type { import type {
ListPageInput,
LoadError, LoadError,
LoadResult, LoadResult,
PostPage, PostPage,
@@ -15,6 +21,7 @@ import type {
export type FeedRequest = export type FeedRequest =
| { kind: 'user'; target: string } | { kind: 'user'; target: string }
| { kind: 'list'; target: string }
| { | {
kind: 'search' kind: 'search'
query: string query: string
@@ -32,6 +39,7 @@ type FeedPageParam = string | ThreadPageParam | undefined
type Loaders = { type Loaders = {
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult> loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
search: (options: { data: SearchPageInput }) => Promise<LoadResult> search: (options: { data: SearchPageInput }) => Promise<LoadResult>
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult> 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') { if (request.kind === 'search') {
return unwrap( return unwrap(
await loaders.search({ await loaders.search({
@@ -104,6 +122,7 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
export function usePostFeed(request: FeedRequest | undefined) { export function usePostFeed(request: FeedRequest | undefined) {
const loadUser = useServerFn(loadUserPosts) const loadUser = useServerFn(loadUserPosts)
const loadList = useServerFn(loadListPosts)
const search = useServerFn(searchPosts) const search = useServerFn(searchPosts)
const thread = useServerFn(loadThreadPosts) const thread = useServerFn(loadThreadPosts)
const disabled = { const disabled = {
@@ -114,6 +133,7 @@ export function usePostFeed(request: FeedRequest | undefined) {
return useInfiniteQuery({ return useInfiniteQuery({
...createPostFeedOptions(request ?? disabled, { ...createPostFeedOptions(request ?? disabled, {
loadUser, loadUser,
loadList,
search, search,
thread, thread,
}), }),
+21 -3
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as UserRouteImport } from './routes/user' import { Route as UserRouteImport } from './routes/user'
import { Route as SearchRouteImport } from './routes/search' import { Route as SearchRouteImport } from './routes/search'
import { Route as ListRouteImport } from './routes/list'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId' import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId'
@@ -24,6 +25,11 @@ const SearchRoute = SearchRouteImport.update({
path: '/search', path: '/search',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ListRoute = ListRouteImport.update({
id: '/list',
path: '/list',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -37,12 +43,14 @@ const StatusTweetIdRoute = StatusTweetIdRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/list': typeof ListRoute
'/search': typeof SearchRoute '/search': typeof SearchRoute
'/user': typeof UserRoute '/user': typeof UserRoute
'/status/$tweetId': typeof StatusTweetIdRoute '/status/$tweetId': typeof StatusTweetIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/list': typeof ListRoute
'/search': typeof SearchRoute '/search': typeof SearchRoute
'/user': typeof UserRoute '/user': typeof UserRoute
'/status/$tweetId': typeof StatusTweetIdRoute '/status/$tweetId': typeof StatusTweetIdRoute
@@ -50,20 +58,22 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/list': typeof ListRoute
'/search': typeof SearchRoute '/search': typeof SearchRoute
'/user': typeof UserRoute '/user': typeof UserRoute
'/status/$tweetId': typeof StatusTweetIdRoute '/status/$tweetId': typeof StatusTweetIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/search' | '/user' | '/status/$tweetId' fullPaths: '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/search' | '/user' | '/status/$tweetId' to: '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
id: '__root__' | '/' | '/search' | '/user' | '/status/$tweetId' id: '__root__' | '/' | '/list' | '/search' | '/user' | '/status/$tweetId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
ListRoute: typeof ListRoute
SearchRoute: typeof SearchRoute SearchRoute: typeof SearchRoute
UserRoute: typeof UserRoute UserRoute: typeof UserRoute
StatusTweetIdRoute: typeof StatusTweetIdRoute StatusTweetIdRoute: typeof StatusTweetIdRoute
@@ -85,6 +95,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SearchRouteImport preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/list': {
id: '/list'
path: '/list'
fullPath: '/list'
preLoaderRoute: typeof ListRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -104,6 +121,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
ListRoute: ListRoute,
SearchRoute: SearchRoute, SearchRoute: SearchRoute,
UserRoute: UserRoute, UserRoute: UserRoute,
StatusTweetIdRoute: StatusTweetIdRoute, StatusTweetIdRoute: StatusTweetIdRoute,
+54 -1
View File
@@ -4,11 +4,12 @@ import {
createRouter, createRouter,
RouterProvider, RouterProvider,
} from '@tanstack/react-router' } 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { routeTree } from '../routeTree.gen' import { routeTree } from '../routeTree.gen'
const usePostFeed = vi.hoisted(() => vi.fn()) const usePostFeed = vi.hoisted(() => vi.fn())
const useListChoices = vi.hoisted(() => vi.fn())
vi.mock('../features/posts/use-post-feed', async (importOriginal) => { vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
const original = const original =
@@ -16,6 +17,12 @@ vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
return { ...original, usePostFeed } 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 () => { vi.mock('./__root', async () => {
const { createRootRouteWithContext, Outlet } = await import( const { createRootRouteWithContext, Outlet } = await import(
'@tanstack/react-router' '@tanstack/react-router'
@@ -41,6 +48,7 @@ describe('post feed route wiring', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('scrollTo', vi.fn()) vi.stubGlobal('scrollTo', vi.fn())
usePostFeed.mockReset() usePostFeed.mockReset()
useListChoices.mockReset()
usePostFeed.mockReturnValue({ usePostFeed.mockReturnValue({
data: undefined, data: undefined,
error: null, error: null,
@@ -52,6 +60,13 @@ describe('post feed route wiring', () => {
isPending: false, isPending: false,
refetch: vi.fn(), refetch: vi.fn(),
} as never) } 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()) 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 () => { it('forwards every manually entered search control', async () => {
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true') await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
+31
View File
@@ -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>
)
}
+74
View File
@@ -116,6 +116,73 @@ a {
line-height: 1.7; 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 { .intent-form {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -167,6 +234,7 @@ a {
} }
.intent-form button, .intent-form button,
.list-picker-state button,
.state button { .state button {
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.65rem 1rem; padding: 0.65rem 1rem;
@@ -457,6 +525,7 @@ video.media {
} }
.intent-form button:hover, .intent-form button:hover,
.list-picker-state button:hover,
.state button:hover { .state button:hover {
border-color: var(--accent-hover); border-color: var(--accent-hover);
background: var(--accent-hover); background: var(--accent-hover);
@@ -470,6 +539,11 @@ video.media {
border-color: var(--accent-hover); border-color: var(--accent-hover);
background: var(--accent-hover); background: var(--accent-hover);
} }
.list-choices a:hover {
border-color: var(--accent);
color: var(--ink);
}
} }
@media (max-width: 42rem) { @media (max-width: 42rem) {
+90
View File
@@ -102,6 +102,8 @@ const server = createServer(async (request, response) => {
'UserTweets', 'UserTweets',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'ListsManagementPageTimeline',
'ListLatestTweetsTimeline',
].find((name) => url.pathname.endsWith(`/${name}`)) ].find((name) => url.pathname.endsWith(`/${name}`))
if (!operation) { if (!operation) {
@@ -178,6 +180,94 @@ const server = createServer(async (request, response) => {
return return
} }
if (operation === 'ListsManagementPageTimeline') {
if (variables.count !== 100) {
fail(response, 400, 'invalid lists management variables')
return
}
const features = url.searchParams.get('features')
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error()
} catch {
fail(response, 400, 'invalid lists management feature lock')
return
}
send(response, {
data: {
viewer: {
list_management_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
clientEventInfo: {
component: 'suggest_owned_subscribed_list',
},
items: [
{
item: {
itemContent: {
list: {
id_str: '1234567890',
name: 'My E2E List',
description: 'Accounts selected for testing',
member_count: 10,
mode: 'Public',
created_at: 1_704_067_200_000,
user_results: {
result: {
rest_id: '42',
core: {
screen_name: 'yuta',
name: 'Yuta',
},
},
},
},
},
},
},
],
},
},
],
},
],
},
},
},
},
errors: [{ message: 'optional banner field failed' }],
})
return
}
if (operation === 'ListLatestTweetsTimeline') {
const expectedCursor =
variables.cursor === undefined || variables.cursor === 'list-next'
if (
typeof variables.listId !== 'string' ||
!/^\d+$/.test(variables.listId) ||
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, 'invalid list timeline variables')
return
}
const secondPage = variables.cursor === 'list-next'
const entries = secondPage
? [tweet('3010', 'list page 2', 'listed')]
: [tweet('3001', 'list page 1', 'listed'), cursor('list-next')]
send(response, {
data: {
list: { tweets_timeline: { timeline: timeline(entries) } },
},
})
return
}
if (operation === 'TweetDetail') { if (operation === 'TweetDetail') {
const focalTweetId = variables.focalTweetId const focalTweetId = variables.focalTweetId
if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) { if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) {
+15
View File
@@ -62,6 +62,20 @@ test('requires intent and infinitely loads a user timeline', async ({
}) })
}) })
test('selects an authenticated list and infinitely loads its timeline', async ({
page,
}) => {
await openReader(page, '/list?target=')
await expect(page.locator('article')).toHaveCount(0)
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click()
await expect(page).toHaveURL(/\/list\?target=(?:%22)?1234567890/)
await expect(page.getByText('list page 1')).toBeVisible()
await expect(page.getByText('list page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
})
test('opens a card detail and infinitely loads its conversation', async ({ test('opens a card detail and infinitely loads its conversation', async ({
page, page,
}) => { }) => {
@@ -151,6 +165,7 @@ test('keeps keyboard focus intentional and discovery or mutation controls absent
await tabTo(page, page.getByRole('link', { name: 'ユーザー' })) await tabTo(page, page.getByRole('link', { name: 'ユーザー' }))
await tabTo(page, page.getByRole('link', { name: '検索' })) await tabTo(page, page.getByRole('link', { name: '検索' }))
await tabTo(page, page.getByRole('link', { name: 'リスト' }))
await tabTo(page, page.getByLabel('検索語')) await tabTo(page, page.getByLabel('検索語'))
const ranking = page.getByLabel('最新順') const ranking = page.getByLabel('最新順')
await expect(ranking).toBeChecked() await expect(ranking).toBeChecked()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB