diff --git a/README.md b/README.md index 612a522..0685bd9 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # Twitter Lite 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 -detail link, or manually open a valid `/status/:tweetId` URL. +you enter a user handle, profile URL, search query, or list URL, follow a +deliberate post detail link, or manually open a valid `/status/:tweetId` URL. ## Scope - User timelines and raw X search syntax - Popular (`Top`) and chronological (`Latest`) search - Optional `filter:follows` search +- Authenticated account list selection and list timelines from a deliberate URL or ID - Infinite cursor pagination with explicit retry - Deliberate post detail pages with the visible conversation - Infinite conversation loading with explicit continuation retry diff --git a/flake.nix b/flake.nix index 113118d..cdb4736 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_11; fetcherVersion = 4; - hash = "sha256-TF1cCC86a24Cukks7+BbeM2fvU0SkHtSm6RUIF55RuY="; + hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE="; }; nativeBuildInputs = with pkgs; [ diff --git a/package.json b/package.json index 4e36f4b..5f5e4d6 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@tanstack/react-router": "1.170.17", "@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-start": "1.168.27", - "@yuta/bird": "0.10.0", + "@yuta/bird": "0.10.1", "nitro": "3.0.260610-beta", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6a0a5..4edaa0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: 1.168.27 version: 1.168.27(crossws@0.4.10(srvx@0.11.22))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) '@yuta/bird': - specifier: 0.10.0 - version: 0.10.0 + specifier: 0.10.1 + version: 0.10.1 nitro: specifier: 3.0.260610-beta version: 3.0.260610-beta(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.2)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -1044,8 +1044,8 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - '@yuta/bird@0.10.0': - 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} + '@yuta/bird@0.10.1': + 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 ansi-regex@5.0.1: @@ -2821,7 +2821,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@yuta/bird@0.10.0': + '@yuta/bird@0.10.1': dependencies: commander: 14.0.3 json5: 2.2.3 diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index f057971..deefbf6 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -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({ > 検索 + + リスト +
{children}
diff --git a/src/features/posts/components/forms.test.tsx b/src/features/posts/components/forms.test.tsx index 95feb61..798dd07 100644 --- a/src/features/posts/components/forms.test.tsx +++ b/src/features/posts/components/forms.test.tsx @@ -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() + + 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() + + 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( diff --git a/src/features/posts/components/list-form.tsx b/src/features/posts/components/list-form.tsx new file mode 100644 index 0000000..10fd95f --- /dev/null +++ b/src/features/posts/components/list-form.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/src/features/posts/components/list-picker.tsx b/src/features/posts/components/list-picker.tsx new file mode 100644 index 0000000..6bfcc18 --- /dev/null +++ b/src/features/posts/components/list-picker.tsx @@ -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 ( +
+

自分のリスト

+

+ リストを取得しています… +

+
+ ) + } + + if (query.isError) { + const error = + query.error instanceof ListChoicesLoadError + ? query.error.detail + : { message: 'リストを取得できませんでした。', retryable: true } + return ( +
+

自分のリスト

+
+

{error.message}

+ {error.retryable ? ( + + ) : null} +
+
+ ) + } + + return ( +
+

自分のリスト

+ {query.data.length === 0 ? ( +

リストはありません。

+ ) : ( +
    + {query.data.map((list) => ( +
  • + + {list.name} + + {list.isPrivate ? '非公開 · ' : ''} + {list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー + + {list.description ?

    {list.description}

    : null} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/features/posts/inputs.test.ts b/src/features/posts/inputs.test.ts index da1aefc..0b59dc2 100644 --- a/src/features/posts/inputs.test.ts +++ b/src/features/posts/inputs.test.ts @@ -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') diff --git a/src/features/posts/inputs.ts b/src/features/posts/inputs.ts index 04597a4..f05a227 100644 --- a/src/features/posts/inputs.ts +++ b/src/features/posts/inputs.ts @@ -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) { diff --git a/src/features/posts/post-service.test.ts b/src/features/posts/post-service.test.ts index 892d19f..5e386a9 100644 --- a/src/features/posts/post-service.test.ts +++ b/src/features/posts/post-service.test.ts @@ -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() diff --git a/src/features/posts/post-service.ts b/src/features/posts/post-service.ts index 2378c0c..af36f11 100644 --- a/src/features/posts/post-service.ts +++ b/src/features/posts/post-service.ts @@ -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 getTweet(tweetId: string): Promise getThreadPaged( tweetId: string, @@ -55,6 +72,13 @@ export interface BirdReader { maxPages: number }, ): Promise + getAllListTimeline( + listId: string, + options: { + cursor?: string + maxPages: number + }, + ): Promise } 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 LoadFailure> = { + user: () => + failure('user-not-found', 'ユーザーが見つかりませんでした。', false), + post: () => failure('post-not-found', '投稿が見つかりませんでした。', false), + list: () => + failure('list-not-found', 'リストが見つかりませんでした。', false), +} + +const unavailableFailure: Record 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 { + 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 { + 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, diff --git a/src/features/posts/server-functions.ts b/src/features/posts/server-functions.ts index 2669f13..f2922e1 100644 --- a/src/features/posts/server-functions.ts +++ b/src/features/posts/server-functions.ts @@ -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 }) => { diff --git a/src/features/posts/types.ts b/src/features/posts/types.ts index ee96112..40a27ff 100644 --- a/src/features/posts/types.ts +++ b/src/features/posts/types.ts @@ -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 = export type ThreadLoadResult = LoadResult +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 diff --git a/src/features/posts/use-list-choices.test.ts b/src/features/posts/use-list-choices.test.ts new file mode 100644 index 0000000..8f9cb46 --- /dev/null +++ b/src/features/posts/use-list-choices.test.ts @@ -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 }) + }) +}) diff --git a/src/features/posts/use-list-choices.ts b/src/features/posts/use-list-choices.ts new file mode 100644 index 0000000..c4bba9a --- /dev/null +++ b/src/features/posts/use-list-choices.ts @@ -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 +}) => Promise + +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 => { + 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)) +} diff --git a/src/features/posts/use-post-feed.test.ts b/src/features/posts/use-post-feed.test.ts index dd5ebf8..20889d9 100644 --- a/src/features/posts/use-post-feed.test.ts +++ b/src/features/posts/use-post-feed.test.ts @@ -20,6 +20,7 @@ type FeedLoaders = Parameters[1] const loaders = (overrides: Partial = {}): 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' }, diff --git a/src/features/posts/use-post-feed.ts b/src/features/posts/use-post-feed.ts index 04ec597..50f784b 100644 --- a/src/features/posts/use-post-feed.ts +++ b/src/features/posts/use-post-feed.ts @@ -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 + loadList: (options: { data: ListPageInput }) => Promise search: (options: { data: SearchPageInput }) => Promise thread: (options: { data: ThreadPageInput }) => Promise } @@ -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, }), diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c6f91ea..2339253 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/-feed-wiring.test.tsx b/src/routes/-feed-wiring.test.tsx index 22322ae..72931e8 100644 --- a/src/routes/-feed-wiring.test.tsx +++ b/src/routes/-feed-wiring.test.tsx @@ -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() + 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') diff --git a/src/routes/list.tsx b/src/routes/list.tsx new file mode 100644 index 0000000..d5c3ced --- /dev/null +++ b/src/routes/list.tsx @@ -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 ( + +

どのリストを読みますか?

+

+ 自分のリストを選ぶか、リストURLを入力してください。 +

+ + navigate({ search: { target: nextTarget } })} + /> + +
+ ) +} diff --git a/src/styles.css b/src/styles.css index f8beb09..7b8bd28 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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) { diff --git a/tests/e2e/mock-relay.mjs b/tests/e2e/mock-relay.mjs index 32f73c9..8341338 100644 --- a/tests/e2e/mock-relay.mjs +++ b/tests/e2e/mock-relay.mjs @@ -102,6 +102,8 @@ const server = createServer(async (request, response) => { 'UserTweets', 'TweetDetail', 'SearchTimeline', + 'ListsManagementPageTimeline', + 'ListLatestTweetsTimeline', ].find((name) => url.pathname.endsWith(`/${name}`)) if (!operation) { @@ -178,6 +180,94 @@ const server = createServer(async (request, response) => { 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') { const focalTweetId = variables.focalTweetId if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) { diff --git a/tests/e2e/reader.spec.ts b/tests/e2e/reader.spec.ts index 0781ed4..f0e6b7c 100644 --- a/tests/e2e/reader.spec.ts +++ b/tests/e2e/reader.spec.ts @@ -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 ({ 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.getByLabel('検索語')) const ranking = page.getByLabel('最新順') await expect(ranking).toBeChecked() diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png index 7809f20..01ab3a5 100644 Binary files a/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png and b/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png differ diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png index e95c2dc..728d313 100644 Binary files a/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png and b/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png differ diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png index 12dbfd7..67ba188 100644 Binary files a/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png and b/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png differ diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png index 5e4f84d..1bd7f19 100644 Binary files a/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png and b/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png differ