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({
>
検索
+
+ リスト
+
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