feat: unify research in profile-bound decks with TweetDeck-style UI

This commit is contained in:
2026-09-24 15:59:54 +09:00
parent da8f52e605
commit d2cbf4dbd3
90 changed files with 2617 additions and 6934 deletions
+11 -33
View File
@@ -1,43 +1,21 @@
import { Link } from '@tanstack/react-router'
import { ProfileSwitcher } from '#/features/profiles/profile-switcher'
import { Icon } from './icon'
export function AppShell({
active,
sidebar,
children,
}: {
active?: 'user' | 'search' | 'list' | 'deck'
sidebar: React.ReactNode
children: React.ReactNode
}) {
return (
<main className={active === 'deck' ? 'app app-deck' : 'app'}>
<header className="app-header">
<p className="brand">Twitter Lite</p>
<ProfileSwitcher />
<nav aria-label="閲覧方法" className="tabs">
<Link
aria-current={active === 'deck' ? 'page' : undefined}
to="/deck"
>
デッキ
</Link>
<Link
aria-current={active === 'search' ? 'page' : undefined}
search={{ q: '', product: 'Latest', following: false }}
to="/search"
>
検索
</Link>
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
ユーザー
</Link>
<Link
aria-current={active === 'list' ? 'page' : undefined}
to="/i/lists"
>
リスト
</Link>
</nav>
</header>
<main className="app app-deck">
<aside className="app-sidebar" aria-label="デッキ管理">
<p className="brand">
<Icon name="columns" />
<span>Twitter Lite</span>
</p>
{sidebar}
</aside>
<section className="reader">{children}</section>
</main>
)
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useId, useRef } from 'react'
import { Icon } from './icon'
export function Dialog({
title,
onClose,
children,
}: {
title: string
onClose: () => void
children: React.ReactNode
}) {
const dialog = useRef<HTMLDialogElement>(null)
const headingId = useId()
useEffect(() => {
const element = dialog.current
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
element?.showModal()
return () => {
element?.close()
if (opener?.isConnected) opener.focus({ preventScroll: true })
}
}, [])
return (
<dialog
ref={dialog}
className="deck-dialog"
aria-labelledby={headingId}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
>
<div className="dialog-heading">
<h2 id={headingId}>{title}</h2>
<button
type="button"
className="icon-button"
aria-label="閉じる"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
{children}
</dialog>
)
}
+31
View File
@@ -0,0 +1,31 @@
const paths = {
columns: 'M3 4h7v16H3z M14 4h7v16h-7z',
search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',
user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0',
list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01',
plus: 'M12 5v14 M5 12h14',
close: 'M6 6l12 12 M18 6 6 18',
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
arrow: 'M7 17 17 7 M7 7h10v10',
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5',
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',
} as const
export function Icon({ name }: { name: keyof typeof paths }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={paths[name]} />
</svg>
)
}
+177 -41
View File
@@ -1,17 +1,66 @@
import { useId, useState } from 'react'
import { useListChoices } from '#/features/posts/use-list-choices'
import { columnSchema, type DeckColumn } from './model'
function ListChoices({
profileName,
onSelect,
}: {
profileName: string
onSelect: (id: string) => void
}) {
const lists = useListChoices(profileName)
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
))
)}
</section>
)
}
export function ColumnEditor({
column,
profiles,
onSave,
onCancel,
}: {
column?: DeckColumn
profiles: string[]
onSave: (column: DeckColumn) => void
onCancel: () => void
}) {
const formId = useId()
const [error, setError] = useState<string>()
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
column?.source.kind ?? 'search',
)
const [profileName, setProfileName] = useState(
column?.profileName ?? profiles[0] ?? '',
)
const [target, setTarget] = useState(
column && column.source.kind !== 'search' ? column.source.target : '',
)
return (
<form
className="deck-editor"
@@ -19,28 +68,34 @@ export function ColumnEditor({
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
const source =
kind === 'search'
? {
platform: 'twitter',
kind,
query: data.get('query'),
product: data.get('product'),
following: data.get('following') === 'on',
}
: { platform: 'twitter', kind, target }
const parsed = columnSchema.safeParse({
// getRandomValues also works on HTTP tailnet origins.
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
source: {
platform: 'twitter',
query: data.get('query'),
product: data.get('product'),
following: data.get('following') === 'on',
},
profileName,
source,
})
if (!parsed.success) {
if (!parsed.success || !profiles.includes(profileName)) {
setError(
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
parsed.success
? '接続プロファイルを選択してください。'
: parsed.error.issues[0]?.message,
)
return
}
onSave(parsed.data)
}}
>
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
カラム名
@@ -49,52 +104,133 @@ export function ColumnEditor({
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
placeholder="例:日本語での反応"
placeholder="例:発表元の投稿"
required
/>
</label>
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={column?.source.query ?? ''}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
aria-describedby={`${formId}-help`}
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<label htmlFor={`${formId}-profile`}>
接続プロファイル
<select
id={`${formId}-product`}
name="product"
defaultValue={column?.source.product ?? 'Latest'}
id={`${formId}-profile`}
value={profileName}
onChange={(event) => setProfileName(event.target.value)}
required
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
<option value="" disabled>
選択してください
</option>
{profileName && !profiles.includes(profileName) ? (
<option value={profileName} disabled>
{profileName}(利用不可)
</option>
) : null}
{profiles.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<label htmlFor={`${formId}-kind`}>
カラムの種類
<select
id={`${formId}-kind`}
value={kind}
onChange={(event) => {
setKind(event.target.value as typeof kind)
setTarget('')
}}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
</div>
<p className="deck-help" id={`${formId}-help`}>
from:、lang:、since: など、Twitterの検索構文を使えます。
{kind === 'search' ? (
<div className="deck-source-fields" key="search">
<label htmlFor={`${formId}-query`}>
Twitterの検索条件
<input
id={`${formId}-query`}
name="query"
defaultValue={
column?.source.kind === 'search' ? column.source.query : ''
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${formId}-product`}>
表示順
<select
id={`${formId}-product`}
name="product"
defaultValue={
column?.source.kind === 'search'
? column.source.product
: 'Latest'
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={
column?.source.kind === 'search' && column.source.following
}
/>
フォロー中のみ
</label>
</div>
) : (
<div className="deck-source-fields" key={kind}>
<label htmlFor={`${formId}-target`}>
{kind === 'user' ? 'ユーザー' : 'リスト'}
<input
id={`${formId}-target`}
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={
kind === 'user'
? '@handle またはプロフィールURL'
: 'リストのURLまたはID'
}
maxLength={256}
required
/>
</label>
{kind === 'list' && profiles.includes(profileName) ? (
<ListChoices
key={profileName}
profileName={profileName}
onSelect={setTarget}
/>
) : null}
</div>
)}
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
name="following"
defaultChecked={column?.source.following ?? false}
/>
フォロー中のみ
</label>
{error ? (
<p role="alert" className="deck-error">
{error}
</p>
) : null}
<div className="deck-actions">
<button type="submit" className="deck-primary">
<button
type="submit"
className="deck-primary"
disabled={!profiles.includes(profileName)}
>
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
+7
View File
@@ -0,0 +1,7 @@
import type { FeedRequest } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
export function columnRequest(column: DeckColumn): FeedRequest {
const { platform: _, ...source } = column.source
return { ...source, profileName: column.profileName }
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { z } from 'zod'
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, type usePostFeed } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
type ColumnFeed = { column: DeckColumn; query: ReturnType<typeof usePostFeed> }
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof usePostFeed>,
) {
const entry = useRef({ column, query })
entry.current = { column, query }
useEffect(() => {
registry.set(column.id, entry)
return () => {
if (registry.get(column.id) === entry) registry.delete(column.id)
}
}, [registry, column.id])
}
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
const readInput = columnInput.extend({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(50).default(20),
})
function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenPostPages(feed.query.data?.pages ?? [])
const selected = posts.slice(offset, offset + limit)
return {
column: feed.column,
status: feed.query.isPending
? 'loading'
: feed.query.isError
? 'error'
: 'ready',
loading: feed.query.isFetching,
error:
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
posts: selected.map(mapTwitterPost),
loadedCount: posts.length,
offset,
nextOffset:
offset + selected.length < posts.length ? offset + selected.length : null,
hasMore: Boolean(feed.query.hasNextPage),
}
}
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
const supported = useWebMCPSupported()
const enabled = supported && ready
function requireColumn(id: string) {
const entry = registry.get(id)
if (!entry)
throw new Error(
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
)
return entry
}
useWebMCP({
name: 'get_column_posts',
description:
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
inputSchema: readInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { columnId, offset, limit } = readInput.parse(input)
return read(requireColumn(columnId).current, offset, limit)
}),
})
useWebMCP({
name: 'load_more_column',
description:
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
inputSchema: columnInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
const { columnId } = columnInput.parse(input)
const entry = requireColumn(columnId)
const { column, query } = entry.current
const identity = JSON.stringify(column)
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
throw new Error('Wait for the column to finish loading.')
if (query.isError && !query.isFetchNextPageError) throw query.error
const offset = flattenPostPages(query.data?.pages ?? []).length
if (!query.hasNextPage) return read(entry.current, offset)
const result = await query.fetchNextPage({ cancelRefetch: false })
if (
registry.get(columnId) !== entry ||
JSON.stringify(entry.current.column) !== identity
)
throw new Error(
'The deck or column changed while loading. Read the current deck first.',
)
if (result.isError) throw result.error
return read({ column, query: result }, offset)
}),
})
}
+79 -43
View File
@@ -1,6 +1,9 @@
import { Icon } from '#/components/icon'
import { mapTwitterPost } from '#/features/platforms/twitter'
import { flattenPostPages } from '#/features/posts/page'
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
import { columnRequest } from './column-request'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
@@ -11,6 +14,7 @@ export function ResearchColumn({
onMove,
first,
last,
registry,
}: {
column: DeckColumn
onEdit: () => void
@@ -18,13 +22,10 @@ export function ResearchColumn({
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
registry: ColumnRegistry
}) {
const query = usePostFeed({
kind: 'search',
query: column.source.query,
product: column.source.product,
following: column.source.following,
})
const query = usePostFeed(columnRequest(column))
useRegisterColumn(registry, column, query)
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
const error =
query.error instanceof PostLoadError
@@ -34,50 +35,85 @@ export function ResearchColumn({
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
<div className="deck-column-label">
<span>Twitter</span>
<span>
{column.source.product === 'Latest' ? '最新' : '話題'}
{column.source.following ? ' · フォロー中' : ''}
</span>
</div>
<h2 id={headingId}>{column.title}</h2>
<p className="deck-query">{column.source.query}</p>
<div className="deck-actions">
<button type="button" onClick={onEdit}>
編集
</button>
<div className="deck-column-title">
<Icon name={column.source.kind} />
<h2 id={headingId}>{column.title}</h2>
<button
className="icon-button"
aria-label="更新"
title="更新"
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
>
更新
</button>
<button
type="button"
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
>
←
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
>
→
</button>
<button
type="button"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
削除
<Icon name="refresh" />
</button>
<details className="column-menu">
<summary
className="icon-button"
aria-label={`${column.title}の操作`}
title="カラムの操作"
>
<Icon name="more" />
</summary>
<div className="column-menu-panel">
<button type="button" onClick={onEdit}>
編集
</button>
<button
type="button"
onClick={(event) => {
onMove(-1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={first}
aria-label={`${column.title}を左へ`}
>
← 左へ移動
</button>
<button
type="button"
onClick={(event) => {
onMove(1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={last}
aria-label={`${column.title}を右へ`}
>
右へ移動 →
</button>
<button
type="button"
className="danger"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
カラムを削除
</button>
</div>
</details>
</div>
<div className="deck-column-label">
<span title={`接続プロファイル: ${column.profileName}`}>
{column.profileName}
</span>
<span>
{column.source.kind === 'search'
? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}`
: column.source.kind === 'user'
? 'ユーザー投稿'
: 'リスト'}
</span>
</div>
<p className="deck-query">
{column.source.kind === 'search'
? column.source.query
: column.source.target}
</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
+164 -67
View File
@@ -1,33 +1,63 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { loadProfiles } from '#/features/profiles/server-functions'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
import { DeckSwitcher } from './deck-switcher'
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
import { useDeck } from './use-deck'
import { useDeckTools } from './webmcp-tools'
import './decks.css'
export function DeckPage() {
const { deck, ready, storageError, save } = useDeck()
const registry = useRef<ColumnRegistry>(new Map()).current
const {
deck,
workspace,
ready,
storageError,
save,
create,
select,
remove,
getWorkspace,
} = useDeck()
const fetchProfiles = useServerFn(loadProfiles)
const profiles = useQuery({
queryKey: ['profiles'],
queryFn: () => fetchProfiles(),
retry: false,
})
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
useColumnTools(registry, ready)
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
useDeckTools({
deck,
ready,
storageError,
save,
onApplied: () => {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
},
select,
remove,
getWorkspace,
profiles: profiles.data?.profiles,
onApplied: clearEditors,
})
function clearEditors() {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
}
function saveColumn(column: DeckColumn) {
save({
...deck,
@@ -42,83 +72,148 @@ export function DeckPage() {
}
return (
<AppShell active="deck">
<AppShell
sidebar={
<>
<DeckSwitcher
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={(title) => {
create(title)
clearEditors()
}}
onDelete={() => {
remove(deck.id)
clearEditors()
}}
/>
<button
className="deck-primary sidebar-add"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</button>
<nav className="sidebar-columns" aria-label="カラム一覧">
{deck.columns.map((column) => (
<button
key={column.id}
type="button"
title={column.title}
onClick={() => {
const element = document.getElementById(`column-${column.id}`)
element?.scrollIntoView({ block: 'nearest', inline: 'start' })
element
?.closest('.deck-column')
?.querySelector<HTMLElement>('.deck-column-results')
?.focus({ preventScroll: true })
}}
>
<Icon name={column.source.kind} />
<span>{column.title}</span>
</button>
))}
</nav>
<p className="sidebar-note">このブラウザに保存</p>
</>
}
>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<p className="deck-help">
観点ごとに検索を並べて、投稿を読み比べる。
</p>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready}
onClick={() => setRenaming(!renaming)}
>
名前を変更
</button>
<button
className="deck-primary"
type="button"
disabled={
!ready || deck.columns.length >= MAX_COLUMNS || editing !== null
}
onClick={() => setEditing('new')}
>
カラムを追加
<Icon name="edit" />
</button>
</div>
</div>
<p className="deck-save-status">
{!ready
? 'デッキを読み込んでいます…'
: storageError
? 'デッキの保存状態を確認してください。'
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
</p>
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<button type="button" onClick={() => void profiles.refetch()}>
再試行
</button>
</p>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
</p>
) : null}
{renaming ? (
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<button type="submit">名前を保存</button>
</form>
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<button type="submit">名前を保存</button>
</form>
</Dialog>
) : null}
{editing !== null ? (
<ColumnEditor
key={editing === 'new' ? 'new' : editing.id}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
profiles={profiles.data?.profiles ?? []}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
</Dialog>
) : null}
{removed ? (
<div className="deck-undo" role="status">
@@ -139,9 +234,10 @@ export function DeckPage() {
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<h2>最初の観点を追加しましょう。</h2>
<Icon name="columns" />
<h2>調査のカラムを並べましょう</h2>
<p>
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
検索、ユーザー投稿、リスト。読みたい情報を選んで、ここに追加できます。
</p>
<button
className="deck-primary"
@@ -157,7 +253,8 @@ export function DeckPage() {
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={column.id}
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
first={index === 0}
last={index === deck.columns.length - 1}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import type { Deck } from './model'
export function DeckSwitcher({
decks,
activeId,
ready,
onSelect,
onCreate,
onDelete,
}: {
decks: Deck[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => void
onDelete: () => void
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
return (
<div className="deck-workspaces">
<div className="deck-actions">
<label htmlFor="deck-selection">デッキプロファイル</label>
<select
id="deck-selection"
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
setCreating(false)
setDeleting(false)
}}
>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.title}
</option>
))}
</select>
<button
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
type="button"
disabled={!ready}
onClick={() => {
setCreating(!creating)
setDeleting(false)
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
</button>
<button
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready || decks.length <= 1}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
</button>
</div>
{creating ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
onCreate(title)
setCreating(false)
}}
>
<label htmlFor="new-deck-title">
新しいデッキ名
<input
id="new-deck-title"
name="title"
maxLength={120}
required
/>
</label>
<button type="submit">作成</button>
<button type="button" onClick={() => setCreating(false)}>
キャンセル
</button>
</form>
</Dialog>
) : null}
{deleting ? (
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<button
type="button"
onClick={() => {
onDelete()
setDeleting(false)
}}
>
削除する
</button>
<button type="button" onClick={() => setDeleting(false)}>
キャンセル
</button>
</div>
</Dialog>
) : null}
</div>
)
}
+381 -142
View File
@@ -1,61 +1,68 @@
/* Hallmark · existing Garden tokens · research deck: horizontal columns
* User-requested application layout; inherit the reader's typography and color.
* pre-emit critique: P4 H4 E4 S5 R5 V4
*/
.app-deck .reader {
min-width: 0;
}
.app-deck button,
.app-deck input,
.app-deck select {
font: inherit;
}
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.app-deck button {
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--ink);
background: var(--paper);
background: var(--surface);
cursor: pointer;
white-space: nowrap;
transition: background-color var(--dur-short) var(--ease-out);
}
.app-deck button:hover:not(:disabled) {
background: var(--surface);
}
.app-deck button:active:not(:disabled) {
background: var(--color-paper-3);
}
.app-deck button:active:not(:disabled) {
color: var(--accent);
}
.app-deck button:disabled {
opacity: 0.5;
opacity: 0.45;
cursor: default;
}
.app-deck :focus-visible {
outline: var(--rule-active) solid var(--color-focus);
outline-offset: 3px;
.app-deck .icon-button {
width: 36px;
height: 36px;
padding: var(--space-xs);
border-color: transparent;
background: transparent;
}
.app-deck .deck-primary {
color: var(--color-accent-ink);
background: var(--accent);
border-color: var(--accent);
font-weight: 650;
}
.app-deck .deck-primary:hover:not(:disabled),
.app-deck .deck-primary:active:not(:disabled) {
background: var(--accent-hover);
color: var(--color-accent-ink);
}
.deck-heading {
display: flex;
align-items: start;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
flex-shrink: 0;
gap: var(--space-sm);
min-height: 58px;
padding: var(--space-xs) var(--space-md);
}
.deck-heading > div {
min-width: 0;
}
.deck-heading h1 {
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-actions {
display: flex;
@@ -67,131 +74,82 @@
.deck-save-status,
.deck-column-status {
color: var(--secondary);
font-size: var(--text-sm);
line-height: 1.6;
font-size: var(--text-xs);
line-height: 1.5;
}
.deck-save-status {
margin: var(--space-md) 0;
}
.deck-editor {
margin-block: var(--space-lg);
padding-block: var(--space-lg);
border-block: var(--rule-hairline) solid var(--border);
}
.deck-editor h2,
.deck-empty h2 {
font-size: var(--text-md);
margin: 0 0 var(--space-md);
overflow-wrap: anywhere;
}
.deck-editor-fields {
display: grid;
gap: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input {
min-width: 0;
width: 100%;
min-height: 44px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--paper);
color: var(--ink);
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 44px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin-block: var(--space-md);
}
.deck-rename label {
flex: 1 1 15rem;
}
.deck-error {
color: var(--error);
line-height: 1.6;
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-block: var(--space-md);
overflow-wrap: anywhere;
}
.deck-empty {
max-width: 40rem;
padding-block: var(--space-2xl);
}
.deck-empty p {
line-height: 1.8;
margin-bottom: var(--space-lg);
margin: 0;
padding: 0 var(--space-md) var(--space-xs);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(100%, 23rem);
gap: var(--space-md);
grid-auto-columns: 328px;
justify-content: start;
flex: 1;
min-height: 0;
gap: var(--space-xs);
overflow-x: auto;
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
overflow-y: hidden;
padding: 0 var(--space-xs) var(--space-xs);
scroll-snap-type: x proximity;
scrollbar-color: var(--control-border) var(--canvas);
}
.deck-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: max(30rem, calc(100svh - 20rem));
border: var(--rule-hairline) solid var(--border);
background: var(--paper);
scroll-snap-align: start;
}
.deck-column-header {
padding: var(--space-md);
position: relative;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
background: var(--surface);
}
.deck-column-title {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.deck-column-title > svg {
color: var(--accent);
}
.deck-column h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--text-base);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-label {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-xs);
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column h2 {
margin: var(--space-sm) 0;
font-size: var(--text-md);
.deck-column-label span {
min-width: 0;
overflow-wrap: anywhere;
}
.deck-query {
margin: 0 0 var(--space-md);
font-size: var(--text-sm);
overflow-wrap: anywhere;
max-height: 4.5em;
overflow-y: auto;
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deck-column-status {
margin-bottom: 0;
margin: var(--space-2xs) 0 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
@@ -199,27 +157,70 @@
overflow-y: auto;
flex: 1;
overscroll-behavior-y: contain;
scrollbar-width: thin;
scrollbar-color: var(--control-border) var(--paper);
}
.column-menu {
position: relative;
}
.column-menu summary {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
list-style: none;
cursor: pointer;
}
.column-menu summary::-webkit-details-marker {
display: none;
}
.column-menu summary:hover,
.column-menu[open] summary {
background: var(--color-paper-3);
}
.column-menu-panel {
position: absolute;
top: 100%;
right: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 158px;
padding: var(--space-2xs);
border: var(--rule-hairline) solid var(--control-border);
background: var(--surface);
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
}
.column-menu-panel button {
justify-content: flex-start;
border: 0;
}
.deck-message {
padding: var(--space-md);
color: var(--secondary);
line-height: 1.7;
font-size: var(--text-sm);
line-height: 1.6;
}
.research-post {
padding: var(--space-md);
padding: var(--space-sm);
border-bottom: var(--rule-hairline) solid var(--border);
overflow-wrap: anywhere;
}
.research-post header {
display: flex;
align-items: center;
gap: var(--space-sm);
gap: var(--space-xs);
}
.research-post header > div {
min-width: 0;
}
.research-post header img {
border-radius: 50%;
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong {
font-size: var(--text-sm);
@@ -227,20 +228,22 @@
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.8;
line-height: 1.55;
font-size: var(--text-sm);
margin: var(--space-xs) 0;
}
.research-post blockquote {
margin: var(--space-md) 0;
padding-left: var(--space-md);
border-left: var(--rule-hairline) solid var(--border);
margin: var(--space-sm) 0;
padding: var(--space-sm);
border: var(--rule-hairline) solid var(--border);
border-radius: var(--radius-sm);
}
.research-post footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-md);
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--secondary);
font-size: var(--text-xs);
}
@@ -253,22 +256,258 @@
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-sm);
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
@media (min-width: 52rem) {
.app.app-deck {
width: 100%;
grid-template-columns: 11rem minmax(0, 1fr);
.deck-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: var(--space-lg);
overflow-y: auto;
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-md);
background: var(--paper);
color: var(--ink);
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
}
.deck-dialog::backdrop {
background: var(--color-overlay);
}
.dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.dialog-heading h2,
.deck-editor h2,
.deck-empty h2 {
margin: 0;
font-size: var(--text-md);
overflow-wrap: anywhere;
}
.deck-editor {
margin: 0;
padding: 0;
}
.deck-editor-fields,
.deck-source-fields {
display: grid;
gap: var(--space-md);
}
.deck-source-fields {
margin-block: var(--space-md);
}
.deck-editor label,
.deck-rename label {
display: grid;
gap: var(--space-xs);
font-size: var(--text-sm);
min-width: 0;
}
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input,
.deck-workspaces select {
min-width: 0;
width: 100%;
min-height: 40px;
padding: var(--space-xs);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--canvas);
color: var(--ink);
}
.deck-editor .deck-checkbox {
display: flex;
align-items: center;
min-height: 40px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-sm);
margin: 0;
}
.deck-rename label {
flex: 1 1 100%;
}
.deck-error {
color: var(--error);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.reader > .deck-error {
margin: 0;
padding: var(--space-xs) var(--space-md);
}
.deck-undo {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
.deck-undo p {
margin: 0;
}
.deck-empty {
margin: auto;
max-width: 32rem;
padding: var(--space-lg);
}
.deck-empty p {
color: var(--secondary);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
.deck-workspaces {
min-width: 0;
}
.deck-workspaces > .deck-actions {
align-items: stretch;
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > select {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
flex: 1;
}
.deck-list-choices {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.app-deck .sidebar-add {
width: 100%;
}
@media (max-width: 767px) {
.deck-workspaces {
flex: 1;
}
.app-deck .reader {
padding: var(--space-xl) var(--space-lg);
.deck-workspaces > .deck-actions {
flex-wrap: nowrap;
gap: var(--space-2xs);
}
.deck-editor-fields {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
.deck-workspaces > .deck-actions > label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.deck-workspaces > .deck-actions > select {
flex: 1;
width: 0;
}
.deck-workspaces > .deck-actions > button {
flex: none;
}
.app-deck .sidebar-add {
width: 36px;
flex-shrink: 0;
padding: var(--space-xs);
}
.sidebar-add span {
display: none;
}
.deck-heading {
min-height: 52px;
padding-inline: var(--space-sm);
}
.deck-heading .deck-actions {
flex-wrap: nowrap;
}
.deck-board {
grid-auto-columns: min(328px, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.app-deck button {
transition: none;
@media (pointer: coarse) {
.app-deck .icon-button,
.column-menu summary {
min-width: 40px;
min-height: 40px;
}
}
.deck-count {
color: var(--secondary);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column-status {
text-align: right;
}
@media (max-width: 767px) {
.workspace-create span,
.workspace-delete span {
display: none;
}
.app-deck .workspace-create,
.app-deck .workspace-delete {
width: 36px;
padding: var(--space-xs);
}
}
.sidebar-columns {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
min-height: 0;
overflow-y: auto;
}
.app-deck .sidebar-columns button {
flex-shrink: 0;
justify-content: flex-start;
width: 100%;
background: transparent;
border-color: transparent;
}
.sidebar-columns span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.research-post header > div {
flex: 1;
}
.research-post header time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.research-post footer a {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.research-post footer svg {
width: 14px;
height: 14px;
}
.column-menu-panel .danger {
color: var(--error);
}
@media (max-width: 767px) {
.sidebar-columns {
display: none;
}
}
+64 -10
View File
@@ -1,26 +1,67 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
const deck: Deck = {
version: 1,
id: 'research',
title: '調査',
columns: ['a', 'b'].map((id) => ({
id,
title: id,
profileName: 'main',
source: {
platform: 'twitter',
kind: 'search',
query: 'AI lang:ja',
product: 'Latest',
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native Twitter search conditions when serialized', () => {
it('preserves native search and the bound relay profile when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
it('rejects duplicate column identities and unsupported platforms', () => {
it('normalizes user and list targets and rejects invalid targets', () => {
const column = deck.columns[0]
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'user', target: '@alice' },
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: {
platform: 'twitter',
kind: 'list',
target: 'https://x.com/i/lists/123',
},
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
},
],
}).success,
).toBe(false)
})
it('rejects duplicate identities and missing profiles', () => {
expect(
deckSchema.safeParse({
...deck,
@@ -30,13 +71,25 @@ describe('deck definitions', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
],
columns: [{ ...deck.columns[0], profileName: '' }],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: 'missing',
decks: [deck],
}).success,
).toBe(false)
expect(
workspaceSchema.safeParse({
version: 2,
activeDeckId: deck.id,
decks: [deck, deck],
}).success,
).toBe(false)
})
it('rejects queries that exceed the upstream limit after adding follows', () => {
it('rejects queries exceeding the upstream limit after adding follows', () => {
expect(
deckSchema.safeParse({
...deck,
@@ -45,6 +98,7 @@ describe('deck definitions', () => {
...deck.columns[0],
source: {
platform: 'twitter',
kind: 'search',
query: 'a'.repeat(512),
product: 'Latest',
following: true,
@@ -54,7 +108,7 @@ describe('deck definitions', () => {
}).success,
).toBe(false)
})
it('moves a column without changing its query or the previous deck', () => {
it('moves a column without changing its conditions or the previous deck', () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([
deck.columns[1],
deck.columns[0],
+82 -8
View File
@@ -1,13 +1,16 @@
import { z } from 'zod'
import { buildSearchQuery } from '#/features/posts/inputs'
import {
buildSearchQuery,
normalizeListTarget,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const MAX_COLUMNS = 6
// Queries stay native to the provider. Add a discriminated-union member when
// the next connector is implemented instead of inventing a shared search DSL.
const columnSourceSchema = z
const searchSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']),
following: z.boolean(),
@@ -27,17 +30,57 @@ const columnSourceSchema = z
}
})
function targetSchema(normalize: (target: string) => string) {
return z
.string()
.trim()
.min(1)
.max(256)
.transform((target, ctx) => {
try {
return normalize(target)
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : '対象を確認してください。',
})
return z.NEVER
}
})
}
const userSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('user'),
target: targetSchema(normalizeUserTarget),
})
.strict()
const listSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('list'),
target: targetSchema(normalizeListTarget),
})
.strict()
export const columnSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(80),
source: columnSourceSchema,
profileName: z.string().min(1),
source: z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
})
.strict()
export const deckSchema = z
.object({
version: z.literal(1),
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(120),
columns: z.array(columnSchema).max(MAX_COLUMNS),
})
@@ -49,10 +92,41 @@ export const deckSchema = z
'カラム ID が重複しています。',
)
export const workspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: deckSchema.shape.id,
decks: z.array(deckSchema).min(1),
})
.strict()
.refine(
(workspace) =>
new Set(workspace.decks.map((deck) => deck.id)).size ===
workspace.decks.length,
'デッキ ID が重複しています。',
)
.refine(
(workspace) =>
workspace.decks.some((deck) => deck.id === workspace.activeDeckId),
'選択したデッキが見つかりません。',
)
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
export type Workspace = z.infer<typeof workspaceSchema>
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
version: 2,
activeDeckId: EMPTY_DECK.id,
decks: [EMPTY_DECK],
}
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
}
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id)
+16 -11
View File
@@ -1,9 +1,12 @@
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'short',
timeStyle: 'short',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo',
})
@@ -15,15 +18,22 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
<img
src={post.author.avatarUrl}
alt=""
width={32}
height={32}
width={36}
height={36}
loading="lazy"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">{post.author.handle}</span>
<span className="handle">
@{post.author.handle.replace(/^@/, '')}
</span>
</div>
{post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}>
{dateFormatter.format(new Date(post.createdAt))}
</time>
) : null}
</header>
<p className="research-post-text">
<PostText text={post.text} />
@@ -64,13 +74,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
</blockquote>
) : null}
<footer>
{post.createdAt ? (
<time dateTime={post.createdAt}>
{dateFormatter.format(new Date(post.createdAt))}
</time>
) : null}
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く
元の投稿を開く <Icon name="arrow" />
</a>
</footer>
</article>
+36 -15
View File
@@ -1,20 +1,20 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_DECK } from './model'
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('saved deck', () => {
describe('saved decks', () => {
it('restores saved conditions without overwriting them during mount', () => {
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
const deck = { ...EMPTY_DECK, title: 'WebMCP調査' }
const saved = { ...EMPTY_WORKSPACE, decks: [deck] }
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
const { result } = renderHook(useDeck)
expect(result.current.ready).toBe(true)
expect(result.current.deck).toEqual(saved)
expect(result.current.deck).toEqual(deck)
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
saved,
)
@@ -25,21 +25,42 @@ describe('saved deck', () => {
expect(result.current.storageError).toContain('読み込めません')
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
})
it('persists edits and restores them on remount', () => {
const first = renderHook(useDeck)
act(() =>
first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }),
)
first.unmount()
expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査')
})
it('reports failed writes instead of claiming that changes were saved', () => {
it('reports failed writes while keeping changes usable in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const { result } = renderHook(useDeck)
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
let persisted = true
act(() => {
persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' })
})
expect(persisted).toBe(false)
expect(result.current.deck.title).toBe('未保存')
expect(result.current.storageError).toContain('保存できません')
})
it('keeps decks isolated through immediate create, edit, switch and reload', () => {
const { result, unmount } = renderHook(useDeck)
const firstId = result.current.deck.id
let secondId = ''
act(() => {
result.current.create('第二の調査')
secondId = result.current.getWorkspace().activeDeckId
result.current.save({
id: secondId,
title: '変更した第二の調査',
columns: [],
})
result.current.select(firstId)
})
expect(result.current.deck.title).toBe('新しい調査')
expect(result.current.workspace.decks).toHaveLength(2)
unmount()
const restored = renderHook(useDeck)
act(() => restored.result.current.select(secondId))
expect(restored.result.current.deck.title).toBe('変更した第二の調査')
act(() => restored.result.current.remove(secondId))
expect(restored.result.current.deck.id).toBe(firstId)
expect(() => restored.result.current.remove(firstId)).toThrow('最後')
expect(() => restored.result.current.select('missing')).toThrow()
})
})
+73 -8
View File
@@ -1,17 +1,30 @@
import { useEffect, useState } from 'react'
import { type Deck, deckSchema, EMPTY_DECK } from './model'
import { useEffect, useRef, useState } from 'react'
import {
type Deck,
deckSchema,
EMPTY_DECK,
EMPTY_WORKSPACE,
newId,
type Workspace,
workspaceSchema,
} from './model'
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
export function useDeck() {
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const [storageError, setStorageError] = useState<string>()
useEffect(() => {
try {
const saved = localStorage.getItem(DECK_STORAGE_KEY)
if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved)))
if (saved !== null) {
const restored = workspaceSchema.parse(JSON.parse(saved))
current.current = restored
setWorkspace(restored)
}
} catch {
setStorageError(
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
@@ -20,9 +33,10 @@ export function useDeck() {
setReady(true)
}, [])
function save(next: Deck) {
const validated = deckSchema.parse(next)
setDeck(validated)
function persist(next: Workspace) {
const validated = workspaceSchema.parse(next)
current.current = validated
setWorkspace(validated)
try {
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
setStorageError(undefined)
@@ -35,5 +49,56 @@ export function useDeck() {
}
}
return { deck, ready, storageError, save }
function save(next: Deck) {
const validated = deckSchema.parse(next)
const previous = current.current
const exists = previous.decks.some((deck) => deck.id === validated.id)
return persist({
...previous,
activeDeckId: validated.id,
decks: exists
? previous.decks.map((deck) =>
deck.id === validated.id ? validated : deck,
)
: [...previous.decks, validated],
})
}
function create(title: string) {
return save({ id: newId(), title, columns: [] })
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
return persist({ ...current.current, activeDeckId: id })
}
function remove(id: string) {
const previous = current.current
if (!previous.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
const decks = previous.decks.filter((deck) => deck.id !== id)
const first = decks[0]
if (!first) throw new Error('最後のデッキは削除できません。')
return persist({
...previous,
decks,
activeDeckId:
previous.activeDeckId === id ? first.id : previous.activeDeckId,
})
}
const deck =
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
EMPTY_DECK
return {
deck,
workspace,
ready,
storageError,
save,
create,
select,
remove,
getWorkspace: () => current.current,
}
}
@@ -0,0 +1,8 @@
import { useEffect, useState } from 'react'
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
}
+86 -38
View File
@@ -1,60 +1,108 @@
import { describe, expect, it } from 'vitest'
import { prepareDeck } from './webmcp-contracts'
const column = {
title: '観点',
profileName: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent IDs to new columns', () => {
const deck = prepareDeck({
title: '調査',
columns: [
{ title: '日本語', source: { query: 'WebMCP lang:ja' } },
{ title: '海外', source: { query: 'WebMCP lang:en' } },
],
})
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
'main',
])
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
query: 'WebMCP lang:ja',
kind: 'search',
query: 'AI',
product: 'Latest',
following: false,
})
expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2)
})
it('keeps existing IDs and rejects duplicates before applying a deck', () => {
const column = { id: 'existing', title: '観点', source: { query: 'AI' } }
expect(
prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id,
).toBe('existing')
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
).toBe(3)
})
it('keeps supplied identities and rejects duplicates', () => {
const identified = { ...column, id: 'existing' }
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
['main'],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
expect(() =>
prepareDeck({ title: '調査', columns: [column, column] }),
prepareDeck({ title: '調査', columns: [identified, identified] }, [
'main',
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, ['work']),
).toThrow('Unknown relay profile')
})
it('accepts user and list columns bound to separate profiles', () => {
const deck = prepareDeck(
{
title: '調査',
columns: [
{ ...column, source: { kind: 'user', target: '@alice' } },
{
...column,
profileName: 'work',
source: { kind: 'list', target: '123' },
},
],
},
['main', 'work'],
)
expect(deck.columns.map((column) => column.profileName)).toEqual([
'main',
'work',
])
expect(deck.columns.map((column) => column.source.kind)).toEqual([
'user',
'list',
])
})
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
expect(() =>
prepareDeck({
title: '調査',
columns: [
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
],
}),
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: { kind: 'search', platform: 'nostr', query: 'AI' },
},
],
},
['main'],
),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: [
{
title: '観点',
source: { query: 'a'.repeat(512), following: true },
},
],
}),
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: {
kind: 'search',
query: 'a'.repeat(512),
following: true,
},
},
],
},
['main'],
),
).toThrow()
expect(() =>
prepareDeck({
title: '調査',
columns: Array.from({ length: 7 }, () => ({
title: '観点',
source: { query: 'AI' },
})),
}),
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
['main'],
),
).toThrow()
})
})
+78 -19
View File
@@ -1,43 +1,102 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
const source = columnSchema.shape.source
const toolColumn = columnSchema.omit({ id: true }).extend({
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing ID from get_deck when editing; omit for a new column.',
'Keep an existing column ID when editing; omit for a new column.',
),
source: z
.object({
...source.shape,
platform: source.shape.platform.default('twitter'),
product: source.shape.product.default('Latest'),
following: source.shape.following.default(false),
})
.strict(),
source: z.discriminatedUnion('kind', [
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']).default('Latest'),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('user'),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('list'),
target: z.string().min(1).max(256),
})
.strict(),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
export const setDeckInput = z
.object({
title: deckSchema.shape.title.describe('The research topic or deck title.'),
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
),
})
.strict()
export function prepareDeck(input: unknown) {
export function prepareDeck(input: unknown, profiles: readonly string[]) {
const parsed = setDeckInput.parse(input)
return deckSchema.parse({
version: 1,
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
columns: parsed.columns.map((column) => ({
...column,
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
id: column.id ?? newId(),
})),
})
for (const column of deck.columns) {
if (!profiles.includes(column.profileName))
throw new InputError(`Unknown relay profile: ${column.profileName}`)
}
return deck
}
export async function toolResult(run: () => unknown | Promise<unknown>) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
} catch (error) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: JSON.stringify({
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
retryable: false,
}),
},
] as const,
}
}
}
+101 -27
View File
@@ -1,63 +1,137 @@
import { useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts'
import { useWebMCPSupported } from '#/features/posts/webmcp-tools'
import type { Deck } from './model'
import { prepareDeck, setDeckInput } from './webmcp-contracts'
import type { Deck, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import {
deckIdInput,
emptyToolInput,
getDeckInput,
prepareDeck,
setDeckInput,
toolResult,
} from './webmcp-contracts'
export function useDeckTools({
deck,
ready,
storageError,
save,
select,
remove,
getWorkspace,
profiles,
onApplied,
}: {
deck: Deck
ready: boolean
storageError?: string
save: (deck: Deck) => boolean
select: (id: string) => boolean
remove: (id: string) => boolean
getWorkspace: () => Workspace
profiles: string[] | undefined
onApplied: () => void
}) {
const supported = useWebMCPSupported()
const current = useRef({ deck, storageError })
current.current = { deck, storageError }
const enabled = supported && ready
function applied(persisted: boolean) {
onApplied()
if (!persisted)
throw new Error(
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
)
}
useWebMCP({
name: 'get_deck',
name: 'list_decks',
description:
'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.',
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
inputSchema: emptyToolInput,
enabled: supported && ready,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
const workspace = getWorkspace()
return {
deck: current.current.deck,
storageError: current.current.storageError ?? null,
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
profiles: profiles ?? null,
storageError: storageError ?? null,
}
}),
})
useWebMCP({
name: 'get_deck',
description:
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = getDeckInput.parse(input)
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return { deck, storageError: storageError ?? null }
}),
})
useWebMCP({
name: 'set_deck',
description:
'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.',
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
inputSchema: setDeckInput,
enabled: supported && ready,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
// Validate the whole definition before modifying either storage or UI.
const next = prepareDeck(input)
const persisted = save(next)
onApplied()
const error = persisted
? undefined
: 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.'
current.current = { deck: next, storageError: error }
if (error) throw new Error(error)
const parsed = setDeckInput.parse(input)
if (
parsed.deckId &&
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
)
throw new Error('Deck not found.')
if (!profiles) throw new Error('Relay profiles are not available yet.')
const next = prepareDeck(parsed, profiles)
applied(save(next))
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
}),
})
useWebMCP({
name: 'select_deck',
description:
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
}),
})
useWebMCP({
name: 'delete_deck',
description:
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(() => {
const { deckId } = deckIdInput.parse(input)
applied(remove(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
}),
})
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { TwitterClient } from '@yuta/bird'
import type { BirdReader } from './post-service'
const clients = new Map<string | undefined, TwitterClient>()
const clients = new Map<string, TwitterClient>()
export function getBirdReader(profileName?: string): BirdReader {
export function getBirdReader(profileName: string): BirdReader {
const existing = clients.get(profileName)
if (existing) return existing
@@ -1,152 +0,0 @@
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'
const searchValues = {
q: '',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
product: 'Latest' as const,
following: false,
}
describe('UserForm', () => {
it('submits only after the user enters a target', () => {
const onSubmit = vi.fn()
render(<UserForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), {
target: { value: '@yuta' },
})
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(onSubmit).toHaveBeenCalledWith('yuta')
})
it('explains invalid input beside the field', () => {
const onSubmit = vi.fn()
render(<UserForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'ハンドルまたはプロフィール URL を入力してください。',
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('ListForm', () => {
it('submits the list ID from a pasted list URL', () => {
const onSubmit = vi.fn()
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('リスト URL または ID'), {
target: { value: 'https://x.com/i/lists/1234567890' },
})
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(onSubmit).toHaveBeenCalledWith('1234567890')
})
it('explains invalid input beside the field', () => {
const onSubmit = vi.fn()
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: '表示' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'リストの URL または ID を入力してください。',
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('SearchForm', () => {
it('uses a labelled grouping for ranking controls', () => {
render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
})
it('submits the selected ranking and follows filter', () => {
const onSubmit = vi.fn()
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: ' AI lang:ja ' },
})
fireEvent.click(screen.getByLabelText('人気順'))
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
q: 'AI lang:ja',
from: '',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
product: 'Top',
following: true,
})
})
it('submits conditions selected with the filter buttons', () => {
const onSubmit = vi.fn()
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('検索語'), {
target: { value: 'TypeScript' },
})
fireEvent.click(screen.getByRole('button', { name: '投稿者' }))
fireEvent.change(screen.getByLabelText('投稿者'), {
target: { value: '@tan_stack' },
})
fireEvent.click(screen.getByRole('button', { name: '言語' }))
fireEvent.click(screen.getByLabelText('日本語'))
fireEvent.click(screen.getByRole('button', { name: '投稿の種類' }))
fireEvent.click(screen.getByLabelText('画像'))
fireEvent.click(screen.getByRole('button', { name: '除外' }))
fireEvent.click(screen.getByLabelText('返信'))
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
...searchValues,
q: 'TypeScript',
from: '@tan_stack',
lang: 'ja',
content: 'images',
excludeReplies: true,
})
})
it('removes only the selected condition from its active chip', () => {
const onSubmit = vi.fn()
render(
<SearchForm
initialValues={{ ...searchValues, q: 'AI', from: 'yuta', lang: 'ja' }}
onSubmit={onSubmit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' }))
fireEvent.click(screen.getByRole('button', { name: '検索' }))
expect(onSubmit).toHaveBeenCalledWith({
...searchValues,
q: 'AI',
lang: 'ja',
})
})
})
@@ -1,47 +0,0 @@
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>
)
}
@@ -1,65 +0,0 @@
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}
params={{ listId: list.id }}
to="/i/lists/$listId"
>
<span>{list.name}</span>
<small>
{list.isPrivate ? '非公開 · ' : ''}
{list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー
</small>
{list.description ? <p>{list.description}</p> : null}
</Link>
</li>
))}
</ul>
)}
</section>
)
}
@@ -1,159 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { Post } from '../types'
import { PostCard } from './post-card'
const richPost: Post = {
id: '123',
text: '詳細 https://content.invalid/article',
author: {
username: 'yuta',
name: 'Yuta',
profileImageUrl: 'https://media.invalid/avatar.jpg',
},
createdAt: '2026-07-13T00:00:00.000Z',
replyCount: 3,
retweetCount: 4,
likeCount: 5,
media: [
{
type: 'photo',
url: 'https://media.invalid/photo.jpg',
width: 1200,
height: 800,
},
{
type: 'video',
url: 'https://media.invalid/video-poster.jpg',
videoUrl: 'https://media.invalid/video.mp4',
},
{
type: 'animated_gif',
url: 'https://media.invalid/gif-poster.jpg',
videoUrl: 'https://media.invalid/animation.mp4',
},
],
article: { title: 'Article title', previewText: 'Preview' },
quotedTweet: {
id: '122',
text: 'quoted',
author: { username: 'other', name: 'Other' },
},
}
describe('PostCard', () => {
it('renders rich read-only content without mutation controls', () => {
const { container } = render(<PostCard post={richPost} />)
expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(screen.getByText('@yuta')).toBeInTheDocument()
expect(container.querySelector('time')).toHaveAttribute(
'datetime',
richPost.createdAt,
)
expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument()
expect(container.querySelectorAll('video')).toHaveLength(2)
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
'src',
'https://media.invalid/video.mp4',
)
expect(screen.getByText('Article title')).toBeInTheDocument()
expect(screen.getByText('Preview')).toBeInTheDocument()
expect(screen.getByText('quoted')).toBeInTheDocument()
expect(screen.getByText(/返信 3.*再投稿 4.*いいね 5/)).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('uses deliberate safe external links without linking the author', () => {
render(<PostCard post={richPost} />)
const original = screen.getByRole('link', { name: '元の投稿を開く' })
expect(original).toHaveAttribute('href', 'https://x.com/yuta/status/123')
expect(original).toHaveAttribute('target', '_blank')
expect(original).toHaveAttribute('rel', 'noreferrer noopener')
const textLink = screen.getByRole('link', {
name: 'https://content.invalid/article',
})
expect(textLink).toHaveAttribute('target', '_blank')
expect(textLink).toHaveAttribute('rel', 'noreferrer noopener')
expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument()
expect(screen.getByText('Yuta').closest('a')).toBeNull()
expect(screen.getByText('@yuta').closest('a')).toBeNull()
})
it('decodes HTML entities in the post body before linking URLs', () => {
render(
<PostCard
post={{
...richPost,
text: 'A &gt; B &amp; C &#39;D&#39; https://example.com/?a=1&amp;b=2',
}}
/>,
)
expect(
screen.getByText((_, element) =>
Boolean(
element?.classList.contains('post-text') &&
element.textContent ===
"A > B & C 'D' https://example.com/?a=1&b=2",
),
),
).toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
})
it('links deliberately to the internal detail page', () => {
render(<PostCard post={richPost} />)
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
expect(detail).toHaveAttribute('href', '/yuta/status/123')
expect(detail.parentElement).toHaveClass('post-actions')
expect(
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
).toBe(detail.parentElement)
})
it('marks the focal post without linking to its current page', () => {
render(<PostCard current post={{ ...richPost, quotedTweet: undefined }} />)
expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull()
expect(screen.getByRole('article')).toHaveClass('current-post')
})
it('collapses a malformed optional timestamp without losing the post', () => {
const { container } = render(
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
)
expect(screen.getByText('Yuta')).toBeInTheDocument()
expect(container.querySelector('time')).not.toBeInTheDocument()
})
it('does not try to play a poster image when Bird has no video URL', () => {
const { container } = render(
<PostCard
post={{
...richPost,
media: [
{
type: 'video',
url: 'https://media.invalid/video-poster.jpg',
previewUrl: 'https://media.invalid/video-preview.jpg',
},
],
}}
/>,
)
expect(container.querySelector('video')).not.toBeInTheDocument()
expect(
screen.getByRole('img', { name: '動画のプレビュー' }),
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
})
})
@@ -1,87 +0,0 @@
import { PostText } from '#/components/post-text'
import type { Post } from '../types'
import { PostMedia } from './post-media'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'Asia/Tokyo',
})
function formattedTime(createdAt?: string): string | undefined {
if (!createdAt) return undefined
const date = new Date(createdAt)
return Number.isNaN(date.getTime()) ? undefined : dateFormatter.format(date)
}
export function PostCard({
post,
quoted = false,
current = false,
}: {
post: Post
quoted?: boolean
current?: boolean
}) {
const time = formattedTime(post.createdAt)
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
return (
<article
className={quoted ? 'post quote' : current ? 'post current-post' : 'post'}
>
<header className="post-header">
{post.author.profileImageUrl ? (
<img
alt=""
className="avatar"
height="40"
loading="lazy"
src={post.author.profileImageUrl}
width="40"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<span className="handle">@{post.author.username}</span>
</div>
{time && post.createdAt ? (
<time dateTime={post.createdAt}>{time}</time>
) : null}
</header>
<p className="post-text">
<PostText text={post.text} />
</p>
{post.media ? <PostMedia media={post.media} /> : null}
{post.article ? (
<section className="article-card">
<strong>{post.article.title}</strong>
{post.article.previewText ? <p>{post.article.previewText}</p> : null}
</section>
) : null}
{post.quotedTweet && !quoted ? (
<PostCard post={post.quotedTweet} quoted />
) : null}
{!quoted ? (
<footer className="post-footer">
<span className="counts">
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
{post.likeCount ?? 0}
</span>
<span className="post-actions">
{!current ? (
<a
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
>
詳細・スレッド
</a>
) : null}
<a href={original} rel="noreferrer noopener" target="_blank">
元の投稿を開く
</a>
</span>
</footer>
) : null}
</article>
)
}
@@ -1,414 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Post } from '../types'
import { PostLoadError } from '../use-post-feed'
import { PostFeed } from './post-feed'
const usePostFeed = vi.hoisted(() => vi.fn())
vi.mock('../use-post-feed', async (importOriginal) => {
const original = await importOriginal<typeof import('../use-post-feed')>()
return { ...original, usePostFeed }
})
const observe = vi.fn()
const unobserve = vi.fn()
const disconnect = vi.fn()
let observerCallback: IntersectionObserverCallback = () => {}
let observerOptions: IntersectionObserverInit | undefined
class FakeIntersectionObserver {
readonly root = null
readonly rootMargin: string
readonly thresholds: readonly number[] = []
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
observerCallback = callback
observerOptions = options
this.rootMargin = options?.rootMargin ?? '0px'
}
disconnect = disconnect
observe = observe
takeRecords = () => []
unobserve = unobserve
}
const post = (id: string): Post => ({
id,
text: `post-${id}`,
author: { username: `user-${id}`, name: `User ${id}` },
})
const queryResult = () => ({
data: undefined,
error: null,
fetchNextPage: vi.fn().mockResolvedValue(undefined),
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isPending: false,
refetch: vi.fn().mockResolvedValue(undefined),
})
describe('PostFeed', () => {
beforeEach(() => {
usePostFeed.mockReset()
usePostFeed.mockReturnValue(queryResult() as never)
observe.mockReset()
unobserve.mockReset()
disconnect.mockReset()
observerCallback = () => {}
observerOptions = undefined
vi.stubGlobal(
'IntersectionObserver',
FakeIntersectionObserver as unknown as typeof IntersectionObserver,
)
})
afterEach(() => vi.unstubAllGlobals())
it('renders no feed state before a deliberate request', () => {
render(<PostFeed request={undefined} />)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
expect(screen.queryByRole('article')).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('announces the initial loading state', () => {
usePostFeed.mockReturnValue({
...queryResult(),
isPending: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
expect(screen.getByRole('status')).toHaveTextContent(
'投稿を取得しています…',
)
})
it('renders a quiet empty state', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: { pages: [{ tweets: [] }], pageParams: [undefined] },
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
expect(screen.getByText('条件に一致する投稿はありません。')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('continues from an empty page when a next cursor exists', () => {
const fetchNextPage = vi.fn(() => new Promise<never>(() => undefined))
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [], nextCursor: 'next' }],
pageParams: [undefined],
},
fetchNextPage,
hasNextPage: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
act(() => {
observerCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(
screen.queryByText('条件に一致する投稿はありません。'),
).not.toBeInTheDocument()
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it('deduplicates every page while preserving a quiet end state', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
{ tweets: [post('2'), post('3')] },
],
pageParams: [undefined, 'next'],
},
} as never)
const { container } = render(
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
)
expect(screen.getAllByRole('article')).toHaveLength(3)
expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument()
expect(screen.getByText('これ以上の投稿はありません。')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('renders the focal post once above its deduplicated conversation', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{
focalPost: post('2'),
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), post('2')],
},
{
conversationId: '1',
tweets: [post('2'), post('3')],
},
],
pageParams: [undefined, { cursor: 'next', conversationId: '1' }],
},
} as never)
const { container } = render(
<PostFeed request={{ kind: 'thread', tweetId: '2' }} />,
)
expect(screen.getByText('表示中の投稿')).toBeVisible()
expect(screen.getAllByText('post-2')).toHaveLength(1)
expect(screen.getAllByRole('article')).toHaveLength(3)
expect(container.querySelector('.current-post')).toBeInTheDocument()
})
it('keeps the focal post visible when there are no other posts', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{
focalPost: post('2'),
conversationId: '1',
tweets: [post('2')],
},
],
pageParams: [undefined],
},
} as never)
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
expect(screen.getByText('post-2')).toBeVisible()
expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible()
})
it('keeps the focal post and conversation across a later-page error', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [
{
focalPost: post('2'),
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), post('2')],
},
],
pageParams: [undefined],
},
error: new PostLoadError({
code: 'upstream',
message: 'X から投稿を取得できませんでした。',
retryable: true,
}),
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
} as never)
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
expect(screen.getByText('post-2')).toBeVisible()
expect(screen.getByText('post-1')).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent(
'続きの投稿を取得できませんでした。',
)
})
it('retries an initial retryable error with refetch', () => {
const refetch = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
error: new PostLoadError({
code: 'upstream',
message: '最初の取得に失敗しました。',
retryable: true,
}),
isError: true,
refetch,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'最初の取得に失敗しました。',
)
expect(refetch).toHaveBeenCalledOnce()
})
it('does not offer retry for a non-retryable initial error', () => {
usePostFeed.mockReturnValue({
...queryResult(),
error: new PostLoadError({
code: 'invalid-input',
message: '入力を確認してください。',
retryable: false,
}),
isError: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'bad' }} />)
expect(screen.getByRole('alert')).toHaveTextContent(
'入力を確認してください。',
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('keeps loaded posts while retrying a later page', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('kept')], nextCursor: 'next' }],
pageParams: [undefined],
},
error: new PostLoadError({
code: 'timeout',
message: '取得がタイムアウトしました。',
retryable: true,
}),
fetchNextPage,
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(screen.getByText('post-kept')).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent(
'続きの投稿を取得できませんでした。',
)
expect(observe).not.toHaveBeenCalled()
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it('retries a later page after an empty page with fetchNextPage', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
const refetch = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [], nextCursor: 'next' }],
pageParams: [undefined],
},
error: new PostLoadError({
code: 'timeout',
message: '取得がタイムアウトしました。',
retryable: true,
}),
fetchNextPage,
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
refetch,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'続きの投稿を取得できませんでした。',
)
expect(fetchNextPage).toHaveBeenCalledOnce()
expect(refetch).not.toHaveBeenCalled()
})
it('announces next-page loading', () => {
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
pageParams: [undefined],
},
hasNextPage: true,
isFetchingNextPage: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
expect(screen.getByRole('status')).toHaveTextContent(
'次の投稿を取得しています…',
)
})
it('does not auto-fetch while a next page is already loading', () => {
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
pageParams: [undefined],
},
fetchNextPage,
hasNextPage: true,
isFetchingNextPage: true,
} as never)
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
act(() => {
observerCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
expect(observe).not.toHaveBeenCalled()
expect(fetchNextPage).not.toHaveBeenCalled()
})
it('auto-fetches once near the sentinel and disconnects on cleanup', () => {
const fetchNextPage = vi.fn(() => new Promise<never>(() => undefined))
usePostFeed.mockReturnValue({
...queryResult(),
data: {
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
pageParams: [undefined],
},
fetchNextPage,
hasNextPage: true,
} as never)
const { unmount } = render(
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
)
act(() => {
const entries = [{ isIntersecting: true } as IntersectionObserverEntry]
observerCallback(entries, {} as IntersectionObserver)
observerCallback(entries, {} as IntersectionObserver)
})
expect(observerOptions).toEqual({ rootMargin: '600px 0px' })
expect(observe).toHaveBeenCalledOnce()
expect(fetchNextPage).toHaveBeenCalledOnce()
unmount()
expect(disconnect).toHaveBeenCalledOnce()
})
})
-127
View File
@@ -1,127 +0,0 @@
import { useEffect, useRef } from 'react'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from '../page'
import type { Post } from '../types'
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
import { useFeedTools } from '../webmcp-tools'
import { PostCard } from './post-card'
function FocalPost({ post }: { post: Post }) {
return (
<section aria-labelledby="focal-post-label" className="focal-post">
<p className="focal-label" id="focal-post-label">
表示中の投稿
</p>
<PostCard current post={post} />
</section>
)
}
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
const query = usePostFeed(request)
useFeedTools(request, query)
const sentinel = useRef<HTMLDivElement>(null)
const pages = query.data?.pages ?? []
const focalPost =
request?.kind === 'thread' ? focalPostFromPages(pages) : undefined
const posts = focalPost
? flattenConversationPages(pages, focalPost.id)
: flattenPostPages(pages)
useEffect(() => {
if (
!sentinel.current ||
!query.hasNextPage ||
query.isFetchNextPageError ||
query.isFetchingNextPage
) {
return
}
let requested = false
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !requested) {
requested = true
void query.fetchNextPage({ cancelRefetch: false })
}
},
{ rootMargin: '600px 0px' },
)
observer.observe(sentinel.current)
return () => observer.disconnect()
}, [
query.fetchNextPage,
query.hasNextPage,
query.isFetchNextPageError,
query.isFetchingNextPage,
])
if (!request) return null
if (query.isPending) {
return (
<p className="state" role="status">
投稿を取得しています…
</p>
)
}
if (query.isError && !query.isFetchNextPageError && posts.length === 0) {
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
return (
<div className="state" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button onClick={() => void query.refetch()} type="button">
再試行
</button>
) : null}
</div>
)
}
if (posts.length === 0 && !query.hasNextPage) {
return (
<>
{focalPost ? <FocalPost post={focalPost} /> : null}
<p className="state">
{focalPost
? '会話にほかの投稿はありません。'
: '条件に一致する投稿はありません。'}
</p>
</>
)
}
return (
<>
{focalPost ? <FocalPost post={focalPost} /> : null}
<section aria-live="polite" className="feed">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<div aria-hidden="true" ref={sentinel} />
{query.isFetchingNextPage ? (
<div className="loading-rail" role="status">
次の投稿を取得しています…
</div>
) : null}
{query.isFetchNextPageError ? (
<div className="state" role="alert">
<p>続きの投稿を取得できませんでした。</p>
<button onClick={() => void query.fetchNextPage()} type="button">
再試行
</button>
</div>
) : null}
{!query.hasNextPage ? (
<p className="state">これ以上の投稿はありません。</p>
) : null}
</section>
</>
)
}
@@ -1,40 +0,0 @@
import type { Post } from '../types'
type Media = NonNullable<Post['media']>[number]
export function PostMedia({ media }: { media: Media[] }) {
if (media.length === 0) return null
return (
<div className="media-grid">
{media.map((item) => {
if (item.type === 'photo' || !item.videoUrl) {
return (
<img
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
className="media"
height={item.height}
key={item.url}
loading="lazy"
src={item.previewUrl ?? item.url}
width={item.width}
/>
)
}
return (
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
<video
className="media"
controls
key={item.videoUrl}
loop={item.type === 'animated_gif'}
poster={item.previewUrl ?? item.url}
preload="metadata"
src={item.videoUrl}
/>
)
})}
</div>
)
}
@@ -1,413 +0,0 @@
import type { SearchProduct } from '@yuta/bird'
import { type FormEvent, useState } from 'react'
import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
export type SearchValues = AdvancedSearchQuery & {
product: SearchProduct
following: boolean
}
type Props = {
initialValues: SearchValues
onSubmit: (values: SearchValues) => void
}
type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude'
const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const
const contentLabels = {
all: 'すべて',
images: '画像',
videos: '動画',
links: 'リンク',
} as const
function localDate(daysAgo: number): string {
const date = new Date()
date.setDate(date.getDate() - daysAgo)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function SearchForm({ initialValues, onSubmit }: Props) {
const [values, setValues] = useState(initialValues)
const [openFilter, setOpenFilter] = useState<FilterName>()
const [error, setError] = useState('')
const update = <Key extends keyof SearchValues>(
key: Key,
value: SearchValues[Key],
) => setValues((current) => ({ ...current, [key]: value }))
const submit = (event: FormEvent) => {
event.preventDefault()
const normalized = {
...values,
q: values.q.trim(),
from: values.from.trim(),
}
try {
buildFilteredSearchQuery(normalized, normalized.following)
setError('')
onSubmit(normalized)
} catch (cause) {
setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。',
)
}
}
const toggleFilter = (filter: FilterName) => {
setOpenFilter((current) => (current === filter ? undefined : filter))
}
const setRecentPeriod = (days: number) => {
setValues((current) => ({
...current,
since: localDate(days - 1),
until: '',
}))
}
let preview = '検索語または投稿者を入力すると表示されます。'
try {
preview = buildFilteredSearchQuery(values, values.following)
} catch {
// The submit error gives the actionable message; the preview stays quiet.
}
const filterButtons: Array<{
name: FilterName
label: string
active: boolean
}> = [
{ name: 'author', label: '投稿者', active: Boolean(values.from) },
{
name: 'period',
label: '期間',
active: Boolean(values.since || values.until),
},
{ name: 'language', label: '言語', active: values.lang !== 'all' },
{
name: 'content',
label: '投稿の種類',
active: values.content !== 'all',
},
{
name: 'exclude',
label: '除外',
active: values.excludeReplies || values.excludeReposts,
},
]
return (
<form className="intent-form search-form" onSubmit={submit}>
<div className="field search-query">
<label htmlFor="search-query">検索語</label>
<input
aria-describedby="search-query-help"
autoComplete="off"
id="search-query"
name="q"
value={values.q}
onChange={(event) => update('q', event.currentTarget.value)}
placeholder="キーワードや高度な検索式"
/>
<span className="field-help" id="search-query-help">
演算子を直接入力することもできます。
</span>
</div>
<fieldset className="search-filter-buttons">
<legend>条件を追加</legend>
{filterButtons.map((filter) => (
<button
aria-controls={`search-filter-${filter.name}`}
aria-expanded={openFilter === filter.name}
className="filter-button"
data-active={filter.active || undefined}
key={filter.name}
onClick={() => toggleFilter(filter.name)}
type="button"
>
{filter.label}
{filter.active ? <span aria-hidden="true">●</span> : null}
</button>
))}
</fieldset>
{openFilter ? (
<fieldset
className="search-filter-panel"
id={`search-filter-${openFilter}`}
>
<legend>
{filterButtons.find((item) => item.name === openFilter)?.label}
の条件
</legend>
{openFilter === 'author' ? (
<div className="field compact-field">
<label htmlFor="search-author">投稿者</label>
<input
aria-describedby="search-author-help"
autoComplete="off"
id="search-author"
name="from"
onChange={(event) => update('from', event.currentTarget.value)}
placeholder="@handle"
value={values.from}
/>
<span className="field-help" id="search-author-help">
@は省略できます。
</span>
</div>
) : null}
{openFilter === 'period' ? (
<>
<fieldset className="segmented search-filter-options">
<legend>すぐ選ぶ</legend>
<button onClick={() => setRecentPeriod(1)} type="button">
今日
</button>
<button onClick={() => setRecentPeriod(7)} type="button">
過去7日
</button>
<button onClick={() => setRecentPeriod(30)} type="button">
過去30日
</button>
<button
onClick={() =>
setValues((current) => ({
...current,
since: '',
until: '',
}))
}
type="button"
>
すべて
</button>
</fieldset>
<div className="date-fields">
<div className="field compact-field">
<label htmlFor="search-since">開始日</label>
<input
id="search-since"
name="since"
onChange={(event) =>
update('since', event.currentTarget.value)
}
type="date"
value={values.since}
/>
<span className="field-help" />
</div>
<div className="field compact-field">
<label htmlFor="search-until">
終了日(この日は含まない)
</label>
<input
id="search-until"
name="until"
onChange={(event) =>
update('until', event.currentTarget.value)
}
type="date"
value={values.until}
/>
<span className="field-help" />
</div>
</div>
</>
) : null}
{openFilter === 'language' ? (
<fieldset className="segmented search-filter-options">
<legend>言語</legend>
{(
Object.keys(languageLabels) as Array<
keyof typeof languageLabels
>
).map((value) => (
<label key={value}>
<input
checked={values.lang === value}
name="lang"
onChange={() => update('lang', value)}
type="radio"
value={value}
/>
{languageLabels[value]}
</label>
))}
</fieldset>
) : null}
{openFilter === 'content' ? (
<fieldset className="segmented search-filter-options">
<legend>投稿の種類</legend>
{(
Object.keys(contentLabels) as Array<keyof typeof contentLabels>
).map((value) => (
<label key={value}>
<input
checked={values.content === value}
name="content"
onChange={() => update('content', value)}
type="radio"
value={value}
/>
{contentLabels[value]}
</label>
))}
</fieldset>
) : null}
{openFilter === 'exclude' ? (
<fieldset className="search-exclusions">
<legend>検索結果から除外</legend>
<label className="check">
<input
checked={values.excludeReplies}
onChange={(event) =>
update('excludeReplies', event.currentTarget.checked)
}
type="checkbox"
/>
返信
</label>
<label className="check">
<input
checked={values.excludeReposts}
onChange={(event) =>
update('excludeReposts', event.currentTarget.checked)
}
type="checkbox"
/>
リポスト
</label>
</fieldset>
) : null}
</fieldset>
) : null}
<ul aria-label="選択中の条件" className="active-filters">
{values.from ? (
<li>
<button
aria-label={`投稿者 ${values.from} を解除`}
onClick={() => update('from', '')}
type="button"
>
{values.from.startsWith('@') ? values.from : `@${values.from}`}{' '}
<span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.since || values.until ? (
<li>
<button
aria-label="期間を解除"
onClick={() =>
setValues((current) => ({
...current,
since: '',
until: '',
}))
}
type="button"
>
{values.since || '指定なし'} → {values.until || '現在'}{' '}
<span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.lang !== 'all' ? (
<li>
<button
aria-label={`言語 ${languageLabels[values.lang]} を解除`}
onClick={() => update('lang', 'all')}
type="button"
>
{languageLabels[values.lang]} <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.content !== 'all' ? (
<li>
<button
aria-label={`投稿の種類 ${contentLabels[values.content]} を解除`}
onClick={() => update('content', 'all')}
type="button"
>
{contentLabels[values.content]} <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.excludeReplies ? (
<li>
<button
aria-label="返信の除外を解除"
onClick={() => update('excludeReplies', false)}
type="button"
>
返信を除外 <span aria-hidden="true">×</span>
</button>
</li>
) : null}
{values.excludeReposts ? (
<li>
<button
aria-label="リポストの除外を解除"
onClick={() => update('excludeReposts', false)}
type="button"
>
リポストを除外 <span aria-hidden="true">×</span>
</button>
</li>
) : null}
</ul>
<details className="search-query-preview">
<summary>検索式を確認</summary>
<code>{preview}</code>
</details>
{error ? (
<p className="field-error search-error" id="query-error" role="alert">
{error}
</p>
) : null}
<fieldset className="segmented search-ranking">
<legend>並び順</legend>
{(['Top', 'Latest'] as const).map((value) => (
<label key={value}>
<input
checked={values.product === value}
name="product"
onChange={() => update('product', value)}
type="radio"
value={value}
/>
{value === 'Top' ? '人気順' : '最新順'}
</label>
))}
</fieldset>
<label className="check search-following">
<input
checked={values.following}
onChange={(event) => update('following', event.currentTarget.checked)}
type="checkbox"
/>
フォロー中のみ
</label>
<button className="search-submit" type="submit">
検索
</button>
</form>
)
}
@@ -1,47 +0,0 @@
import { type FormEvent, useState } from 'react'
import { normalizeUserTarget } from '../inputs'
type Props = {
initialTarget: string
onSubmit: (target: string) => void
}
export function UserForm({ initialTarget, onSubmit }: Props) {
const [target, setTarget] = useState(initialTarget)
const [error, setError] = useState('')
const submit = (event: FormEvent) => {
event.preventDefault()
try {
const handle = normalizeUserTarget(target)
setError('')
onSubmit(handle)
} catch (cause) {
setError(
cause instanceof Error ? cause.message : '入力内容を確認してください。',
)
}
}
return (
<form className="intent-form" onSubmit={submit}>
<label className="field">
<span>ハンドルまたはプロフィール URL</span>
<input
autoComplete="off"
aria-describedby={error ? 'target-error' : undefined}
aria-invalid={Boolean(error)}
name="target"
value={target}
onChange={(event) => setTarget(event.currentTarget.value)}
placeholder="@handle または https://x.com/handle"
/>
{error ? (
<span className="field-error" id="target-error" role="alert">
{error}
</span>
) : null}
</label>
<button type="submit">表示</button>
</form>
)
}
+17 -172
View File
@@ -1,52 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
buildAdvancedSearchQuery,
buildFilteredSearchQuery,
buildSearchQuery,
listChoicesInputSchema,
listPageInputSchema,
normalizeListTarget,
normalizeTweetId,
normalizeUserTarget,
postSearchRouteSchema,
threadPageInputSchema,
searchPageInputSchema,
userPageInputSchema,
} from './inputs'
describe('threadPageInputSchema', () => {
it('accepts initial and complete continuation requests', () => {
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
tweetId: '123',
})
expect(
threadPageInputSchema.parse({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
}),
).toEqual({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
})
describe('explicit relay profile binding', () => {
it.each([
{ tweetId: '' },
{ tweetId: 'abc' },
{ tweetId: '123', cursor: 'thread-next' },
{ tweetId: '123', conversationId: '100' },
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
])('rejects an invalid thread request %#', (input) => {
expect(() => threadPageInputSchema.parse(input)).toThrow()
})
})
describe('normalizeTweetId', () => {
it('keeps a decimal post ID', () => {
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
})
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
expect(() => normalizeTweetId(input)).toThrow(
'投稿 ID を確認してください。',
[
searchPageInputSchema,
{ query: 'AI', product: 'Latest', following: false },
],
[listPageInputSchema, { target: '123' }],
[userPageInputSchema, { target: 'yuta' }],
[listChoicesInputSchema, {}],
])('rejects missing or empty bindings for loader %#', (schema, input) => {
expect(schema.safeParse(input).success).toBe(false)
expect(schema.safeParse({ ...input, profileName: ' ' }).success).toBe(false)
expect(schema.safeParse({ ...input, profileName: 'work' }).success).toBe(
true,
)
})
})
@@ -111,134 +87,3 @@ describe('buildSearchQuery', () => {
expect(() => buildSearchQuery(' ', false)).toThrow()
})
})
describe('buildAdvancedSearchQuery', () => {
it('builds a query from button-friendly filters', () => {
expect(
buildAdvancedSearchQuery({
q: 'TypeScript',
from: '@tan_stack',
since: '2026-08-04',
until: '2026-08-11',
lang: 'ja',
content: 'images',
excludeReplies: true,
excludeReposts: true,
}),
).toBe(
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
)
})
it('allows an author search without keywords', () => {
expect(
buildAdvancedSearchQuery({
q: '',
from: 'tan_stack',
since: '',
until: '',
lang: 'all',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toBe('from:tan_stack')
})
it('rejects filters that have no keyword or author', () => {
expect(() =>
buildAdvancedSearchQuery({
q: '',
from: '',
since: '2026-08-04',
until: '',
lang: 'ja',
content: 'all',
excludeReplies: false,
excludeReposts: false,
}),
).toThrow('検索語または投稿者を入力してください。')
})
it('rejects an invalid author and reversed dates', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }),
).toThrow('投稿者のハンドルを確認してください。')
expect(() =>
buildAdvancedSearchQuery({
...base,
since: '2026-08-11',
until: '2026-08-10',
}),
).toThrow('終了日は開始日より後の日付を選んでください。')
})
it('rejects nonexistent dates and a compiled query over 512 characters', () => {
const base = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'all' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(() =>
buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }),
).toThrow('開始日を確認してください。')
expect(() =>
buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('buildFilteredSearchQuery', () => {
it('includes the follows filter in the final query and enforces its length', () => {
const input = {
q: 'TypeScript',
from: '',
since: '',
until: '',
lang: 'ja' as const,
content: 'all' as const,
excludeReplies: false,
excludeReposts: false,
}
expect(buildFilteredSearchQuery(input, true)).toBe(
'TypeScript lang:ja filter:follows',
)
expect(() =>
buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true),
).toThrow('検索語は 512 文字以内で入力してください。')
})
})
describe('postSearchRouteSchema', () => {
it('keeps omitted filter parameters optional', () => {
expect(
postSearchRouteSchema.parse({
q: 'AI',
product: 'Latest',
following: false,
}),
).toEqual({
q: 'AI',
product: 'Latest',
following: false,
})
})
})
+7 -128
View File
@@ -1,157 +1,36 @@
import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/
const LIST_ID = /^\d{1,32}$/
const tweetIdSchema = z
.string()
.trim()
.regex(TWEET_ID, '投稿 ID を確認してください。')
const profileNameSchema = z.string().trim().min(1).max(256)
export class InputError extends Error {}
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
type SearchLanguage = z.infer<typeof searchLanguageSchema>
type SearchContent = z.infer<typeof searchContentSchema>
export type AdvancedSearchQuery = {
q: string
from: string
since: string
until: string
lang: SearchLanguage
content: SearchContent
excludeReplies: boolean
excludeReposts: boolean
}
export const threadPageInputSchema = z.union([
z.object({
tweetId: tweetIdSchema,
conversationId: z.undefined().optional(),
cursor: z.undefined().optional(),
}),
z.object({
tweetId: tweetIdSchema,
conversationId: tweetIdSchema,
cursor: z.string().min(1),
}),
])
export const userPageInputSchema = z.object({
profileName: profileNameSchema,
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
})
export const listPageInputSchema = z.object({
profileName: profileNameSchema,
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
})
export const listChoicesInputSchema = z.object({})
export const listChoicesInputSchema = z.object({
profileName: profileNameSchema,
})
export const searchPageInputSchema = z.object({
profileName: profileNameSchema,
query: z.string().trim().min(1).max(512),
product: z.enum(['Top', 'Latest']),
following: z.boolean(),
cursor: z.string().min(1).optional(),
})
export const postSearchRouteSchema = z.object({
q: z.string().catch(''),
from: z
.string()
.refine((value) => !value || HANDLE.test(value.replace(/^@/, '')))
.optional()
.catch(undefined),
since: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
until: z
.string()
.refine((value) => !value || isValidDate(value))
.optional()
.catch(undefined),
lang: searchLanguageSchema.optional().catch(undefined),
content: searchContentSchema.optional().catch(undefined),
excludeReplies: z.boolean().optional().catch(undefined),
excludeReposts: z.boolean().optional().catch(undefined),
product: z.enum(['Top', 'Latest']).catch('Latest'),
following: z.boolean().catch(false),
})
function isValidDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false
const [year, month, day] = value.split('-').map(Number)
const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day))
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() + 1 === month &&
date.getUTCDate() === day
)
}
function requireDate(value: string, label: string): string {
if (!isValidDate(value)) {
throw new InputError(`${label}を確認してください。`)
}
return value
}
function normalizeSearchAuthor(raw: string): string {
const value = raw.trim().replace(/^@/, '')
if (!HANDLE.test(value)) {
throw new InputError('投稿者のハンドルを確認してください。')
}
return value
}
export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string {
const query = input.q.trim()
const author = input.from.trim()
if (!query && !author) {
throw new InputError('検索語または投稿者を入力してください。')
}
const parts = [query]
if (author) parts.push(`from:${normalizeSearchAuthor(author)}`)
const since = input.since ? requireDate(input.since, '開始日') : ''
const until = input.until ? requireDate(input.until, '終了日') : ''
if (since && until && since >= until) {
throw new InputError('終了日は開始日より後の日付を選んでください。')
}
if (since) parts.push(`since:${since}`)
if (until) parts.push(`until:${until}`)
if (input.lang !== 'all') parts.push(`lang:${input.lang}`)
if (input.content !== 'all') parts.push(`filter:${input.content}`)
if (input.excludeReplies) parts.push('-filter:replies')
if (input.excludeReposts) parts.push('-filter:retweets')
return buildSearchQuery(parts.filter(Boolean).join(' '), false)
}
export function buildFilteredSearchQuery(
input: AdvancedSearchQuery,
following: boolean,
): string {
return buildSearchQuery(buildAdvancedSearchQuery(input), following)
}
export function normalizeTweetId(raw: string): string {
const value = raw.trim()
if (!TWEET_ID.test(value)) {
throw new InputError('投稿 ID を確認してください。')
}
return value
}
function requireHandle(value: string): string {
if (!HANDLE.test(value)) {
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
+2 -32
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from './page'
import type { Post, PostPage, ThreadPage } from './types'
import { flattenPostPages } from './page'
import type { Post, PostPage } from './types'
const post = (id: string): Post => ({
id,
@@ -22,29 +18,3 @@ describe('flattenPostPages', () => {
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
})
})
describe('thread page helpers', () => {
const focal = post('2')
const pages: ThreadPage[] = [
{
focalPost: focal,
conversationId: '1',
nextCursor: 'next',
tweets: [post('1'), focal],
},
{
conversationId: '1',
tweets: [focal, post('3')],
},
]
it('takes the focal post from the first page', () => {
expect(focalPostFromPages(pages)).toEqual(focal)
})
it('deduplicates the conversation and removes the focal post', () => {
expect(
flattenConversationPages(pages, focal.id).map(({ id }) => id),
).toEqual(['1', '3'])
})
})
+1 -15
View File
@@ -1,4 +1,4 @@
import type { Post, PostPage, ThreadPage } from './types'
import type { Post, PostPage } from './types'
export function flattenPostPages(pages: PostPage[]): Post[] {
const seen = new Set<string>()
@@ -10,17 +10,3 @@ export function flattenPostPages(pages: PostPage[]): Post[] {
}),
)
}
export function focalPostFromPages(
pages: Array<PostPage | ThreadPage>,
): Post | undefined {
const first = pages[0]
return first && 'focalPost' in first ? first.focalPost : undefined
}
export function flattenConversationPages(
pages: Array<PostPage | ThreadPage>,
focalPostId: string,
): Post[] {
return flattenPostPages(pages).filter(({ id }) => id !== focalPostId)
}
-247
View File
@@ -3,7 +3,6 @@ import {
type BirdReader,
loadListChoices,
loadListPage,
loadThreadPage,
loadUserPage,
searchPage,
} from './post-service'
@@ -24,27 +23,6 @@ const reader = (): BirdReader => ({
},
],
}),
getTweet: vi.fn().mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
},
}),
getThreadPaged: vi.fn().mockResolvedValue({
success: true,
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
},
],
nextCursor: 'thread-next',
}),
getUserIdByUsername: vi
.fn()
.mockResolvedValue({ success: true, userId: '42' }),
@@ -114,231 +92,6 @@ describe('loadListChoices', () => {
})
})
describe('loadThreadPage', () => {
it('resolves the focal post and fetches one root conversation page', async () => {
const client = reader()
const result = await loadThreadPage(client, { tweetId: '123' })
expect(client.getTweet).toHaveBeenCalledWith('123')
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
maxPages: 1,
pageDelayMs: 0,
})
expect(result).toEqual({
ok: true,
page: {
focalPost: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
},
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
},
],
conversationId: '100',
nextCursor: 'thread-next',
},
})
})
it('uses the carried root for exactly one continuation read', async () => {
const client = reader()
const result = await loadThreadPage(client, {
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
expect(client.getTweet).not.toHaveBeenCalled()
expect(client.getThreadPaged).toHaveBeenCalledOnce()
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
cursor: 'thread-next',
maxPages: 1,
pageDelayMs: 0,
})
expect(result).toMatchObject({
ok: true,
page: { conversationId: '100', focalPost: undefined },
})
})
it('does not expose a continuation when the focal post has no replies', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
replyCount: 0,
author: { username: 'focus', name: 'Focus' },
},
})
const result = await loadThreadPage(client, { tweetId: '123' })
expect(result).toMatchObject({
ok: true,
page: { nextCursor: undefined },
})
})
it.each([
{
name: 'an empty final page',
tweets: [],
returnedCursor: 'thread-final',
},
{
name: 'the same cursor again',
tweets: [
{
id: '101',
text: 'last reply',
author: { username: 'reply', name: 'Reply' },
},
],
returnedCursor: 'thread-next',
},
])('stops after $name', async ({ tweets, returnedCursor }) => {
const client = reader()
vi.mocked(client.getThreadPaged).mockResolvedValue({
success: true,
tweets,
nextCursor: returnedCursor,
})
const result = await loadThreadPage(client, {
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
expect(result).toMatchObject({
ok: true,
page: { nextCursor: undefined },
})
})
it('removes raw data from focal, conversation, and quotes', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
quotedTweet: {
id: '90',
text: 'quote',
author: { username: 'quote', name: 'Quote' },
_raw: { rest_id: 'private-quote' },
},
_raw: { rest_id: 'private-focal' },
},
})
vi.mocked(client.getThreadPaged).mockResolvedValue({
success: true,
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
_raw: { rest_id: 'private-thread' },
},
],
})
const result = await loadThreadPage(client, { tweetId: '123' })
expect(JSON.stringify(result)).not.toContain('private-')
})
it('returns a safe non-retryable missing-post error', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: false,
error: 'Tweet not found: private relay detail',
})
expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({
ok: false,
error: {
code: 'post-not-found',
message: '投稿が見つかりませんでした。',
retryable: false,
},
})
expect(client.getThreadPaged).not.toHaveBeenCalled()
})
it('classifies malformed Bird conversation metadata as an upstream failure', async () => {
const client = reader()
const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9'
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: privateMetadata,
author: { username: 'focus', name: 'Focus' },
},
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const result = await loadThreadPage(client, { tweetId: '123' })
expect(result).toEqual({
ok: false,
error: {
code: 'upstream',
message: 'X から投稿を取得できませんでした。',
retryable: true,
},
})
expect(client.getTweet).toHaveBeenCalledWith('123')
expect(client.getThreadPaged).not.toHaveBeenCalled()
expect(errorSpy).toHaveBeenCalledTimes(1)
expect(errorSpy).toHaveBeenCalledWith(
'Unexpected Bird failure:',
expect.objectContaining({
message: '投稿 ID を確認してください。',
}),
)
expect(JSON.stringify(result)).not.toContain(privateMetadata)
expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(
privateMetadata,
)
} finally {
errorSpy.mockRestore()
}
})
it('rejects an invalid ID without a Bird call', async () => {
const client = reader()
expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({
ok: false,
error: {
code: 'invalid-input',
message: '投稿 ID を確認してください。',
retryable: false,
},
})
expect(client.getTweet).not.toHaveBeenCalled()
expect(client.getThreadPaged).not.toHaveBeenCalled()
})
})
describe('loadUserPage', () => {
it('resolves a handle and fetches exactly one page', async () => {
const client = reader()
+5 -84
View File
@@ -1,14 +1,8 @@
import type {
GetTweetResult,
SearchProduct,
SearchResult,
TweetData,
} from '@yuta/bird'
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
import {
buildSearchQuery,
InputError,
normalizeListTarget,
normalizeTweetId,
normalizeUserTarget,
} from './inputs'
import type {
@@ -19,8 +13,6 @@ import type {
LoadResult,
Post,
SearchPageInput,
ThreadLoadResult,
ThreadPageInput,
UserPageInput,
} from './types'
@@ -45,15 +37,6 @@ type BirdListsResult =
export interface BirdReader {
getLists(count?: number): Promise<BirdListsResult>
getTweet(tweetId: string): Promise<GetTweetResult>
getThreadPaged(
tweetId: string,
options: {
cursor?: string
maxPages: number
pageDelayMs: number
},
): Promise<SearchResult>
getUserIdByUsername(username: string): Promise<UserLookupResult>
getUserTweetsPaged(
userId: string,
@@ -87,12 +70,11 @@ const failure = (
retryable: boolean,
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
type FailureSubject = 'user' | 'post' | 'list'
type FailureSubject = 'user' | 'list'
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
user: () =>
failure('user-not-found', 'ユーザーが見つかりませんでした。', false),
post: () => failure('post-not-found', '投稿が見つかりませんでした。', false),
list: () =>
failure('list-not-found', 'リストが見つかりませんでした。', false),
}
@@ -100,7 +82,6 @@ const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
user: () =>
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false),
post: () => failure('post-unavailable', 'この投稿は取得できません。', false),
list: () =>
failure('list-unavailable', 'このリストは取得できません。', false),
}
@@ -181,69 +162,9 @@ function resultPage(
: upstreamFailure(result.error, subject)
}
export async function loadThreadPage(
reader: BirdReader,
input: ThreadPageInput,
): Promise<ThreadLoadResult> {
try {
const tweetId = normalizeTweetId(input.tweetId)
let focalPost: Post | undefined
let conversationId: string
if (input.cursor && input.conversationId) {
conversationId = normalizeTweetId(input.conversationId)
} else {
const focal = await reader.getTweet(tweetId)
if (!focal.success || !focal.tweet) {
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
}
focalPost = publicPost(focal.tweet)
try {
conversationId = normalizeTweetId(
focal.tweet.conversationId ?? focal.tweet.id,
)
} catch (error) {
return upstreamFailure(error, 'post')
}
}
const result = await reader.getThreadPaged(conversationId, {
...(input.cursor ? { cursor: input.cursor } : {}),
maxPages: 1,
pageDelayMs: 0,
})
if (!result.success) {
return upstreamFailure(result.error, 'post')
}
const tweets = result.tweets ?? []
const nextCursor =
result.nextCursor &&
tweets.length > 0 &&
result.nextCursor !== input.cursor &&
focalPost?.replyCount !== 0
? result.nextCursor
: undefined
return {
ok: true,
page: {
tweets: tweets.map(publicPost),
focalPost,
conversationId,
nextCursor,
},
}
} catch (error) {
if (error instanceof InputError) {
return failure('invalid-input', error.message, false)
}
return upstreamFailure(error, 'post')
}
}
export async function loadUserPage(
reader: BirdReader,
input: UserPageInput,
input: Omit<UserPageInput, 'profileName'>,
): Promise<LoadResult> {
try {
const handle = normalizeUserTarget(input.target)
@@ -271,7 +192,7 @@ export async function loadUserPage(
export async function loadListPage(
reader: BirdReader,
input: ListPageInput,
input: Omit<ListPageInput, 'profileName'>,
): Promise<LoadResult> {
try {
const listId = normalizeListTarget(input.target)
@@ -295,7 +216,7 @@ export async function loadListPage(
export async function searchPage(
reader: BirdReader,
input: SearchPageInput,
input: Omit<SearchPageInput, 'profileName'>,
): Promise<LoadResult> {
try {
return resultPage(
+20 -20
View File
@@ -1,21 +1,30 @@
import { createServerFn } from '@tanstack/react-start'
import { ProfileUnavailableError } from '../profiles/errors'
import {
listChoicesInputSchema,
listPageInputSchema,
searchPageInputSchema,
threadPageInputSchema,
userPageInputSchema,
} from './inputs'
import {
loadListChoices,
loadListPage,
loadThreadPage,
loadUserPage,
searchPage,
} from './post-service'
import type { LoadFailure } from './types'
const configFailure = (diagnostic: unknown): LoadFailure => {
if (diagnostic instanceof ProfileUnavailableError) {
return {
ok: false,
error: {
code: 'profile-unavailable',
message: diagnostic.message,
retryable: true,
},
}
}
console.error('Bird client configuration failed:', diagnostic)
return {
ok: false,
@@ -27,19 +36,20 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
}
}
async function reader() {
const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([
async function reader(profileName: string) {
const [{ getBirdReader }, { requireProfileName }] = await Promise.all([
import('./bird-client.server'),
import('../profiles/profile-service.server'),
])
return getBirdReader(getSelectedProfileName())
await requireProfileName(profileName)
return getBirdReader(profileName)
}
export const loadUserPosts = createServerFn({ method: 'GET' })
.validator(userPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadUserPage(await reader(), data)
return await loadUserPage(await reader(data.profileName), data)
} catch (error) {
return configFailure(error)
}
@@ -49,7 +59,7 @@ export const loadListPosts = createServerFn({ method: 'GET' })
.validator(listPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadListPage(await reader(), data)
return await loadListPage(await reader(data.profileName), data)
} catch (error) {
return configFailure(error)
}
@@ -57,9 +67,9 @@ export const loadListPosts = createServerFn({ method: 'GET' })
export const loadLists = createServerFn({ method: 'GET' })
.validator(listChoicesInputSchema)
.handler(async () => {
.handler(async ({ data }) => {
try {
return await loadListChoices(await reader())
return await loadListChoices(await reader(data.profileName))
} catch (error) {
return configFailure(error)
}
@@ -69,17 +79,7 @@ export const searchPosts = createServerFn({ method: 'GET' })
.validator(searchPageInputSchema)
.handler(async ({ data }) => {
try {
return await searchPage(await reader(), data)
} catch (error) {
return configFailure(error)
}
})
export const loadThreadPosts = createServerFn({ method: 'GET' })
.validator(threadPageInputSchema)
.handler(async ({ data }) => {
try {
return await loadThreadPage(await reader(), data)
return await searchPage(await reader(data.profileName), data)
} catch (error) {
return configFailure(error)
}
+4 -21
View File
@@ -9,19 +9,13 @@ export type PostPage = {
nextCursor?: string
}
export type ThreadPage = PostPage & {
focalPost?: Post
conversationId: string
}
type LoadErrorCode =
| 'invalid-input'
| 'list-not-found'
| 'list-unavailable'
| 'post-not-found'
| 'post-unavailable'
| 'user-not-found'
| 'user-unavailable'
| 'profile-unavailable'
| 'relay-config'
| 'timeout'
| 'upstream'
@@ -38,8 +32,6 @@ export type LoadResult<TPage extends PostPage = PostPage> =
| { ok: true; page: TPage }
| LoadFailure
export type ThreadLoadResult = LoadResult<ThreadPage>
export type ListChoice = {
id: string
name: string
@@ -50,29 +42,20 @@ export type ListChoice = {
export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure
export type ThreadPageInput =
| {
tweetId: string
conversationId?: never
cursor?: never
}
| {
tweetId: string
conversationId: string
cursor: string
}
export type UserPageInput = {
profileName: string
target: string
cursor?: string
}
export type ListPageInput = {
profileName: string
target: string
cursor?: string
}
export type SearchPageInput = {
profileName: string
query: string
product: SearchProduct
following: boolean
+4 -3
View File
@@ -10,13 +10,13 @@ describe('createListChoicesOptions', () => {
ok: true,
lists: [{ id: '123', name: 'My List' }],
})
const options = createListChoicesOptions(loader)
const options = createListChoicesOptions('main', loader)
await expect(options.queryFn()).resolves.toEqual([
{ id: '123', name: 'My List' },
])
expect(loader).toHaveBeenCalledWith({ data: {} })
expect(options.queryKey).toEqual(['list-choices'])
expect(loader).toHaveBeenCalledWith({ data: { profileName: 'main' } })
expect(options.queryKey).toEqual(['list-choices', 'main'])
expect(options.retry).toBe(false)
expect(options.refetchOnWindowFocus).toBe(false)
expect(options.refetchOnReconnect).toBe(false)
@@ -29,6 +29,7 @@ describe('createListChoicesOptions', () => {
retryable: true,
}
const options = createListChoicesOptions(
'main',
vi.fn().mockResolvedValue({ ok: false, error }),
)
+12 -6
View File
@@ -4,7 +4,7 @@ import { loadLists } from './server-functions'
import type { ListChoice, ListChoicesResult, LoadError } from './types'
type ListChoicesLoader = (options: {
data: Record<string, never>
data: { profileName: string }
}) => Promise<ListChoicesResult>
export class ListChoicesLoadError extends Error {
@@ -13,11 +13,14 @@ export class ListChoicesLoadError extends Error {
}
}
export function createListChoicesOptions(loader: ListChoicesLoader) {
export function createListChoicesOptions(
profileName: string,
loader: ListChoicesLoader,
) {
return {
queryKey: ['list-choices'] as const,
queryKey: ['list-choices', profileName] as const,
queryFn: async (): Promise<ListChoice[]> => {
const result = await loader({ data: {} })
const result = await loader({ data: { profileName } })
if (!result.ok) throw new ListChoicesLoadError(result.error)
return result.lists
},
@@ -27,7 +30,10 @@ export function createListChoicesOptions(loader: ListChoicesLoader) {
}
}
export function useListChoices() {
export function useListChoices(profileName: string) {
const loader = useServerFn(loadLists)
return useQuery(createListChoicesOptions(loader))
return useQuery({
...createListChoicesOptions(profileName, loader),
enabled: Boolean(profileName),
})
}
+43 -151
View File
@@ -4,7 +4,6 @@ import { createElement, type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createPostFeedOptions,
getPostFeedData,
PostLoadError,
usePostFeed,
} from './use-post-feed'
@@ -23,7 +22,6 @@ const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
loadUser: vi.fn(),
loadList: vi.fn(),
search: vi.fn(),
thread: vi.fn(),
...overrides,
})
@@ -34,7 +32,7 @@ describe('createPostFeedOptions', () => {
page: { tweets: [], nextCursor: 'next' },
})
const options = createPostFeedOptions(
{ kind: 'user', target: '@yuta' },
{ profileName: 'main', kind: 'user', target: '@yuta' },
loaders({ loadUser }),
)
@@ -42,7 +40,7 @@ describe('createPostFeedOptions', () => {
expect(loadUser).toHaveBeenCalledOnce()
expect(loadUser).toHaveBeenCalledWith({
data: { target: '@yuta', cursor: 'cursor' },
data: { profileName: 'main', target: '@yuta', cursor: 'cursor' },
})
})
@@ -52,7 +50,7 @@ describe('createPostFeedOptions', () => {
page: { tweets: [], nextCursor: 'next' },
})
const options = createPostFeedOptions(
{ kind: 'list', target: '1234567890' },
{ profileName: 'main', kind: 'list', target: '1234567890' },
loaders({ loadList }),
)
@@ -60,17 +58,17 @@ describe('createPostFeedOptions', () => {
expect(loadList).toHaveBeenCalledOnce()
expect(loadList).toHaveBeenCalledWith({
data: { target: '1234567890', cursor: 'cursor' },
data: { profileName: 'main', target: '1234567890', cursor: 'cursor' },
})
expect(options.queryKey).toEqual([
'posts',
{ kind: 'list', target: '1234567890' },
{ profileName: 'main', kind: 'list', target: '1234567890' },
])
})
it('does not retry requests automatically', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -79,7 +77,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the window regains focus', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -88,7 +86,7 @@ describe('createPostFeedOptions', () => {
it('does not refetch automatically when the connection returns', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -97,7 +95,7 @@ describe('createPostFeedOptions', () => {
it('takes the next cursor only from the resolved page', () => {
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders(),
)
@@ -110,6 +108,7 @@ describe('createPostFeedOptions', () => {
it('includes every search control in the query key', () => {
const options = createPostFeedOptions(
{
profileName: 'main',
kind: 'search',
query: 'AI',
product: 'Top',
@@ -120,7 +119,13 @@ describe('createPostFeedOptions', () => {
expect(options.queryKey).toEqual([
'posts',
{ kind: 'search', query: 'AI', product: 'Top', following: true },
{
profileName: 'main',
kind: 'search',
query: 'AI',
product: 'Top',
following: true,
},
])
})
@@ -131,6 +136,7 @@ describe('createPostFeedOptions', () => {
})
const options = createPostFeedOptions(
{
profileName: 'main',
kind: 'search',
query: 'AI lang:ja',
product: 'Top',
@@ -144,6 +150,7 @@ describe('createPostFeedOptions', () => {
expect(search).toHaveBeenCalledOnce()
expect(search).toHaveBeenCalledWith({
data: {
profileName: 'main',
query: 'AI lang:ja',
product: 'Top',
following: true,
@@ -152,61 +159,6 @@ describe('createPostFeedOptions', () => {
})
})
it('loads the initial thread and carries its root with the next cursor', async () => {
const thread = vi.fn().mockResolvedValue({
ok: true,
page: {
tweets: [],
focalPost: {
id: '123',
text: 'focal',
author: { username: 'focus', name: 'Focus' },
},
conversationId: '100',
nextCursor: 'thread-next',
},
})
const options = createPostFeedOptions(
{ kind: 'thread', tweetId: '123' },
loaders({ thread }),
)
const page = await options.queryFn({ pageParam: undefined } as never)
expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } })
expect(options.queryKey).toEqual([
'posts',
{ kind: 'thread', tweetId: '123' },
])
expect(options.getNextPageParam(page)).toEqual({
cursor: 'thread-next',
conversationId: '100',
})
})
it('forwards a thread continuation page parameter intact', async () => {
const thread = vi.fn().mockResolvedValue({
ok: true,
page: { tweets: [], conversationId: '100' },
})
const options = createPostFeedOptions(
{ kind: 'thread', tweetId: '123' },
loaders({ thread }),
)
await options.queryFn({
pageParam: { cursor: 'thread-next', conversationId: '100' },
} as never)
expect(thread).toHaveBeenCalledWith({
data: {
tweetId: '123',
cursor: 'thread-next',
conversationId: '100',
},
})
})
it('preserves a typed load failure for the feed', async () => {
const error = {
code: 'upstream' as const,
@@ -214,7 +166,7 @@ describe('createPostFeedOptions', () => {
retryable: true,
}
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
{ profileName: 'main', kind: 'user', target: 'yuta' },
loaders({
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
}),
@@ -230,93 +182,33 @@ describe('createPostFeedOptions', () => {
})
})
describe('getPostFeedData', () => {
it('reuses the visible feed without fetching a second copy', async () => {
const loadUser = vi
.fn()
.mockResolvedValue({ ok: true, page: { tweets: [] } })
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
describe('profile-bound feed cache', () => {
it('keeps cached posts and pagination isolated between bound profiles', async () => {
const loadUser = vi.fn(async ({ data }) => ({
ok: true as const,
page: { tweets: [], nextCursor: `${data.profileName}-next` },
}))
const main = createPostFeedOptions(
{ kind: 'user', target: 'yuta', profileName: 'main' },
loaders({ loadUser }),
)
const work = createPostFeedOptions(
{ kind: 'user', target: 'yuta', profileName: 'work' },
loaders({ loadUser }),
)
const client = new QueryClient()
const visible = await client.fetchInfiniteQuery(options)
expect(await getPostFeedData(client, options)).toEqual(visible)
expect(loadUser).toHaveBeenCalledOnce()
client.clear()
})
it('waits for the active profile refresh instead of returning old cached posts', async () => {
let release = () => {}
const gate = new Promise<void>((resolve) => {
release = resolve
await client.ensureInfiniteQueryData(main)
expect(
(await client.ensureInfiniteQueryData(work)).pages[0]?.nextCursor,
).toBe('work-next')
expect(
(await client.ensureInfiniteQueryData(main)).pages[0]?.nextCursor,
).toBe('main-next')
expect(loadUser).toHaveBeenCalledTimes(2)
await work.queryFn({ pageParam: 'work-next' })
expect(loadUser).toHaveBeenLastCalledWith({
data: { target: 'yuta', profileName: 'work', cursor: 'work-next' },
})
const freshPage = {
tweets: [
{
id: 'new-profile',
text: 'fresh',
author: { username: 'new', name: 'New' },
},
],
}
const loadUser = vi.fn(async () => {
await gate
return { ok: true as const, page: freshPage }
})
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
loaders({ loadUser }),
)
const client = new QueryClient()
client.setQueryData(options.queryKey, {
pages: [{ tweets: [] }],
pageParams: [undefined],
})
await client.invalidateQueries({
queryKey: options.queryKey,
refetchType: 'none',
})
const refresh = client.fetchInfiniteQuery(options)
const resolved = vi.fn()
const tool = getPostFeedData(client, options).then(resolved)
await Promise.resolve()
expect(resolved).not.toHaveBeenCalled()
release()
await Promise.all([refresh, tool])
expect(resolved).toHaveBeenCalledWith({
pages: [freshPage],
pageParams: [undefined],
})
expect(loadUser).toHaveBeenCalledOnce()
client.clear()
})
it('refreshes invalidated cached data before returning it', async () => {
const loadUser = vi.fn().mockResolvedValue({
ok: true,
page: { tweets: [], nextCursor: 'fresh' },
})
const options = createPostFeedOptions(
{ kind: 'user', target: 'yuta' },
loaders({ loadUser }),
)
const client = new QueryClient()
client.setQueryData(options.queryKey, {
pages: [{ tweets: [] }],
pageParams: [undefined],
})
await client.invalidateQueries({
queryKey: options.queryKey,
refetchType: 'none',
})
expect((await getPostFeedData(client, options)).pages[0]?.nextCursor).toBe(
'fresh',
)
expect(loadUser).toHaveBeenCalledOnce()
client.clear()
})
})
+21 -69
View File
@@ -1,25 +1,17 @@
import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import type { SearchProduct } from '@yuta/bird'
import {
loadListPosts,
loadThreadPosts,
loadUserPosts,
searchPosts,
} from './server-functions'
import { loadListPosts, loadUserPosts, searchPosts } from './server-functions'
import type {
ListPageInput,
LoadError,
LoadResult,
PostPage,
SearchPageInput,
ThreadLoadResult,
ThreadPage,
ThreadPageInput,
UserPageInput,
} from './types'
export type FeedRequest =
export type FeedRequest = { profileName: string } & (
| { kind: 'user'; target: string }
| { kind: 'list'; target: string }
| {
@@ -28,20 +20,12 @@ export type FeedRequest =
product: SearchProduct
following: boolean
}
| { kind: 'thread'; tweetId: string }
type ThreadPageParam = {
cursor: string
conversationId: string
}
type FeedPageParam = string | ThreadPageParam | undefined
)
type Loaders = {
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
}
export class PostLoadError extends Error {
@@ -58,17 +42,18 @@ function unwrap<TPage extends PostPage>(result: LoadResult<TPage>): TPage {
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return {
queryKey: ['posts', request] as const,
initialPageParam: undefined as FeedPageParam,
initialPageParam: undefined as string | undefined,
retry: false as const,
refetchOnWindowFocus: false as const,
refetchOnReconnect: false as const,
queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
if (request.kind === 'user') {
return unwrap(
await loaders.loadUser({
data: {
profileName: request.profileName,
target: request.target,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
cursor: pageParam,
},
}),
)
@@ -77,80 +62,47 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
return unwrap(
await loaders.loadList({
data: {
profileName: request.profileName,
target: request.target,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
},
}),
)
}
if (request.kind === 'search') {
return unwrap(
await loaders.search({
data: {
query: request.query,
product: request.product,
following: request.following,
cursor: typeof pageParam === 'string' ? pageParam : undefined,
cursor: pageParam,
},
}),
)
}
return unwrap(
await loaders.thread({
data:
typeof pageParam === 'object'
? {
tweetId: request.tweetId,
cursor: pageParam.cursor,
conversationId: pageParam.conversationId,
}
: { tweetId: request.tweetId },
await loaders.search({
data: {
profileName: request.profileName,
query: request.query,
product: request.product,
following: request.following,
cursor: pageParam,
},
}),
)
},
getNextPageParam: (page: PostPage | ThreadPage) => {
if (!page.nextCursor) return undefined
return request.kind === 'thread' && 'conversationId' in page
? {
cursor: page.nextCursor,
conversationId: page.conversationId,
}
: page.nextCursor
},
getNextPageParam: (page: PostPage) => page.nextCursor,
}
}
export function usePostFeedOptions() {
function usePostFeedOptions() {
const loadUser = useServerFn(loadUserPosts)
const loadList = useServerFn(loadListPosts)
const search = useServerFn(searchPosts)
const thread = useServerFn(loadThreadPosts)
return (request: FeedRequest) =>
createPostFeedOptions(request, {
loadUser,
loadList,
search,
thread,
})
}
export function getPostFeedData(
client: QueryClient,
options: ReturnType<typeof createPostFeedOptions>,
) {
const state = client.getQueryState(options.queryKey)
// Profile changes invalidate and refresh an existing feed. Wait for that
// refresh instead of returning the previous profile's cached posts.
return state?.fetchStatus === 'fetching' || state?.isInvalidated
? client.fetchInfiniteQuery(options)
: client.ensureInfiniteQueryData(options)
}
export function usePostFeed(request: FeedRequest | undefined) {
const options = usePostFeedOptions()
const disabled = {
kind: 'user',
target: '',
profileName: '',
} satisfies FeedRequest
return useInfiniteQuery({
-241
View File
@@ -1,241 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Post, ThreadPage } from './types'
import { type FeedRequest, PostLoadError } from './use-post-feed'
import {
feedResult,
prepareSearch,
readToolInput,
toolResult,
} from './webmcp-contracts'
const post = (id: string): Post => ({
id,
text: `Post ${id}`,
author: { username: 'reader', name: 'Reader' },
})
const request = { kind: 'user', target: 'reader' } satisfies FeedRequest
describe('WebMCP search input', () => {
it('defaults to chronological search without adding filters', () => {
expect(prepareSearch({ q: ' hello ' })).toMatchObject({
search: { q: 'hello', product: 'Latest', following: false },
request: {
kind: 'search',
query: 'hello',
product: 'Latest',
following: false,
},
})
})
it('compiles the same author, date, content, and exclusion filters as the UI', () => {
expect(
prepareSearch({
q: 'release',
from: '@reader',
since: '2026-09-01',
until: '2026-09-24',
lang: 'ja',
content: 'links',
excludeReplies: true,
excludeReposts: true,
product: 'Top',
following: true,
}).request,
).toEqual({
kind: 'search',
query:
'release from:reader since:2026-09-01 until:2026-09-24 lang:ja filter:links -filter:replies -filter:retweets filter:follows',
product: 'Top',
following: true,
})
})
it('accepts an author as the deliberate search intent', () => {
expect(prepareSearch({ from: 'reader' }).request.query).toBe('from:reader')
})
it.each([
['an impossible date', { q: 'hello', since: '2026-02-30' }],
['an invalid date format', { q: 'hello', until: 'yesterday' }],
[
'an inverted period',
{ q: 'hello', since: '2026-09-24', until: '2026-09-01' },
],
[
'an empty period',
{ q: 'hello', since: '2026-09-24', until: '2026-09-24' },
],
['missing search intent', {}],
['whitespace-only intent', { q: ' ', from: ' ' }],
['an unrecognized property', { q: 'hello', profile: 'other' }],
])('rejects %s', (_description, input) => {
expect(() => prepareSearch(input)).toThrow()
})
})
describe('WebMCP read input', () => {
it('defaults to the first 20 loaded posts', () => {
expect(readToolInput.parse({})).toEqual({ offset: 0, limit: 20 })
})
it('accepts the maximum batch size', () => {
expect(readToolInput.parse({ offset: 10, limit: 50 })).toEqual({
offset: 10,
limit: 50,
})
})
it.each([
{ limit: 0 },
{ limit: 51 },
{ limit: 1.5 },
{ limit: '20' },
{ offset: -1 },
{ offset: 0.5 },
{ cursor: 'upstream-cursor' },
])('rejects invalid pagination %j', (input) => {
expect(readToolInput.safeParse(input).success).toBe(false)
})
})
describe('WebMCP feed output', () => {
it('bounds post text and indicates when text was truncated', () => {
const result = feedResult(request, [
{
tweets: [
{ ...post('1'), text: 'a'.repeat(2001) },
{ ...post('2'), text: 'b'.repeat(2000) },
],
},
])
expect(result.posts).toEqual([
{
id: '1',
author: { username: 'reader', name: 'Reader' },
text: 'a'.repeat(2000),
textTruncated: true,
createdAt: undefined,
url: 'https://x.com/reader/status/1',
},
{
id: '2',
author: { username: 'reader', name: 'Reader' },
text: 'b'.repeat(2000),
textTruncated: false,
createdAt: undefined,
url: 'https://x.com/reader/status/2',
},
])
})
it('deduplicates loaded pages before applying the requested offset and limit', () => {
const result = feedResult(
request,
[
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
{ tweets: [post('2'), post('3'), post('4')] },
],
1,
2,
)
expect(result.posts.map(({ id }) => id)).toEqual(['2', '3'])
expect(result).toMatchObject({
loadedCount: 4,
offset: 1,
nextOffset: 3,
hasMore: false,
})
})
it('includes the focal post once before the deduplicated conversation', () => {
const focal = post('2')
const pages: ThreadPage[] = [
{
focalPost: focal,
conversationId: '1',
tweets: [post('1'), focal],
nextCursor: 'next',
},
{ conversationId: '1', tweets: [focal, post('3'), post('1')] },
]
const result = feedResult({ kind: 'thread', tweetId: '2' }, pages)
expect(result.posts.map(({ id }) => id)).toEqual(['2', '1', '3'])
expect(result.loadedCount).toBe(3)
})
it('reports upstream continuation separately from remaining loaded posts', () => {
const result = feedResult(request, [
{ tweets: [post('1')], nextCursor: 'more-on-server' },
])
expect(result).toMatchObject({
loadedCount: 1,
nextOffset: null,
hasMore: true,
})
})
it('returns an empty batch when the offset is beyond the loaded posts', () => {
expect(
feedResult(request, [{ tweets: [post('1')] }], 10, 20),
).toMatchObject({
posts: [],
loadedCount: 1,
offset: 10,
nextOffset: null,
hasMore: false,
})
})
})
describe('WebMCP execution result', () => {
it.each([
true,
false,
])('preserves a load failure with retryable=%s', async (retryable) => {
const detail = {
code: 'upstream' as const,
message: 'Relay unavailable',
retryable,
}
const result = await toolResult(() =>
Promise.reject(new PostLoadError(detail)),
)
expect(result).toEqual({
isError: true,
content: [{ type: 'text', text: JSON.stringify(detail) }],
})
})
it('returns invalid-input for schema validation failures', async () => {
const result = await toolResult(() => readToolInput.parse({ limit: 100 }))
expect(result).toMatchObject({ isError: true })
expect(JSON.parse(result.content[0].text)).toMatchObject({
code: 'invalid-input',
retryable: false,
})
})
it('keeps successful empty results distinguishable from failures', async () => {
const result = await toolResult(() => feedResult(request, [{ tweets: [] }]))
expect(result).not.toHaveProperty('isError')
expect(JSON.parse(result.content[0].text)).toEqual({
request,
posts: [],
loadedCount: 0,
offset: 0,
nextOffset: null,
hasMore: false,
})
})
})
-146
View File
@@ -1,146 +0,0 @@
import { z } from 'zod'
import { buildFilteredSearchQuery, InputError } from './inputs'
import {
flattenConversationPages,
flattenPostPages,
focalPostFromPages,
} from './page'
import type { Post, PostPage, ThreadPage } from './types'
import { type FeedRequest, PostLoadError } from './use-post-feed'
export const searchToolInput = z
.object({
q: z
.string()
.trim()
.max(512)
.default('')
.describe(
'Search text; raw X search operators are supported. Provide q or from.',
),
from: z
.string()
.trim()
.max(16)
.default('')
.describe('Author handle, with or without @.'),
since: z
.string()
.default('')
.describe(
'Inclusive start date, YYYY-MM-DD, using X search date semantics.',
),
until: z
.string()
.default('')
.describe('Exclusive end date, YYYY-MM-DD; must be after since.'),
lang: z.enum(['all', 'ja', 'en']).default('all'),
content: z.enum(['all', 'images', 'videos', 'links']).default('all'),
excludeReplies: z.boolean().default(false),
excludeReposts: z.boolean().default(false),
product: z
.enum(['Latest', 'Top'])
.default('Latest')
.describe('Latest is chronological; Top uses X ranking.'),
following: z
.boolean()
.default(false)
.describe('Restrict to accounts followed by the active relay profile.'),
})
.strict()
export const readToolInput = z
.object({
offset: z
.number()
.int()
.min(0)
.default(0)
.describe('Offset into the currently loaded posts, starting at zero.'),
limit: z.number().int().min(1).max(50).default(20),
})
.strict()
export const emptyToolInput = z.object({}).strict()
export function prepareSearch(input: unknown) {
const search = searchToolInput.parse(input)
const request = {
kind: 'search',
query: buildFilteredSearchQuery(search, search.following),
product: search.product,
following: search.following,
} satisfies FeedRequest
return { search, request }
}
function summarizePost(post: Post) {
return {
id: post.id,
author: { username: post.author.username, name: post.author.name },
text: post.text.slice(0, 2000),
textTruncated: post.text.length > 2000,
createdAt: post.createdAt,
url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`,
}
}
export function loadedPosts(
request: FeedRequest,
pages: Array<PostPage | ThreadPage>,
) {
const focal =
request.kind === 'thread' ? focalPostFromPages(pages) : undefined
return focal
? [focal, ...flattenConversationPages(pages, focal.id)]
: flattenPostPages(pages)
}
export function feedResult(
request: FeedRequest,
pages: Array<PostPage | ThreadPage>,
offset = 0,
limit = 20,
) {
const posts = loadedPosts(request, pages)
const selected = posts.slice(offset, offset + limit)
const nextOffset = offset + selected.length
return {
request,
posts: selected.map(summarizePost),
loadedCount: posts.length,
offset,
nextOffset: nextOffset < posts.length ? nextOffset : null,
hasMore: Boolean(pages.at(-1)?.nextCursor),
}
}
// Keep the tool's failure distinguishable from a successful empty result.
export async function toolResult(run: () => unknown | Promise<unknown>) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
} catch (error) {
const detail =
error instanceof PostLoadError
? error.detail
: {
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
retryable: false,
}
return {
isError: true,
content: [
{ type: 'text' as const, text: JSON.stringify(detail) },
] as const,
}
}
}
-155
View File
@@ -1,155 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { useWebMCP } from 'usewebmcp'
import {
type FeedRequest,
getPostFeedData,
PostLoadError,
type usePostFeed,
usePostFeedOptions,
} from './use-post-feed'
import {
emptyToolInput,
feedResult,
loadedPosts,
prepareSearch,
readToolInput,
searchToolInput,
toolResult,
} from './webmcp-contracts'
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
}
export function SearchTool() {
const supported = useWebMCPSupported()
const router = useRouter()
const queryClient = useQueryClient()
const options = usePostFeedOptions()
const searching = useRef(false)
useWebMCP({
name: 'search_posts',
description:
'Search X posts and return the first result slice. Updates the visible search page and its filters. Uses the active relay profile. Returns post text, authors, dates, source URLs and pagination information.',
inputSchema: searchToolInput,
enabled: supported,
annotations: {
readOnlyHint: false,
untrustedContentHint: true,
},
execute: (input) =>
toolResult(async () => {
const { search, request } = prepareSearch(input)
if (searching.current)
throw new Error('A search is already running. Wait for it to finish.')
searching.current = true
try {
await router.navigate({ to: '/search', search })
const href = router.buildLocation({ to: '/search', search }).href
if (router.state.location.href !== href)
throw new Error(
'The page changed. Search again from the current page.',
)
// Join the UI request (or use its completed data), rather than fetch twice.
const result = await getPostFeedData(queryClient, options(request))
if (router.state.location.href !== href)
throw new Error(
'The page changed while searching. Read the current feed or search again.',
)
return feedResult(request, result.pages)
} finally {
searching.current = false
}
}),
})
return null
}
export function useFeedTools(
request: FeedRequest | undefined,
query: ReturnType<typeof usePostFeed>,
) {
const supported = useWebMCPSupported()
const generation = useRef(0)
const requestKey = JSON.stringify(request)
useEffect(() => {
// Executions belong to the feed that was visible when they started.
void requestKey
generation.current += 1
return () => {
generation.current += 1
}
}, [requestKey])
useWebMCP({
name: 'get_loaded_posts',
description:
'Read a slice of posts already loaded in the current timeline, search or conversation without a network request. Includes the selected conversation post. Returns active criteria, loading status, source URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation.',
inputSchema: readToolInput,
enabled: supported && Boolean(request),
annotations: {
readOnlyHint: true,
untrustedContentHint: true,
},
execute: (input) =>
toolResult(() => {
const { offset, limit } = readToolInput.parse(input)
if (!request)
throw new Error('Open a timeline, search or conversation first.')
return {
...feedResult(request, query.data?.pages ?? [], offset, limit),
status: query.isPending
? 'loading'
: query.isError
? 'error'
: 'ready',
loading: query.isFetching,
error:
query.error instanceof PostLoadError ? query.error.detail : null,
}
}),
})
useWebMCP({
name: 'load_more_posts',
description:
'Load one continuation page into the current feed and return only newly appended posts. Joins an in-progress scroll request. Returns nextOffset if additional loaded posts remain outside this response. At the end returns an empty posts array with hasMore false. Can retry a failed continuation.',
inputSchema: emptyToolInput,
enabled: supported && Boolean(request),
annotations: {
readOnlyHint: false,
untrustedContentHint: true,
},
execute: (input) =>
toolResult(async () => {
emptyToolInput.parse(input)
if (!request || query.isPending)
throw new Error(
'Wait for the initial feed to load before requesting more posts.',
)
if (query.isError && !query.isFetchNextPageError) throw query.error
if (query.isFetching && !query.isFetchingNextPage)
throw new Error(
'The feed is refreshing. Wait before requesting more posts.',
)
const pages = query.data?.pages ?? []
const offset = loadedPosts(request, pages).length
if (!query.hasNextPage) return feedResult(request, pages, offset)
const started = generation.current
const result = await query.fetchNextPage({ cancelRefetch: false })
if (generation.current !== started)
throw new Error(
'The feed changed while loading. Read the current feed before requesting more posts.',
)
if (result.isError) throw result.error
return feedResult(request, result.data?.pages ?? [], offset)
}),
})
}
+1
View File
@@ -0,0 +1 @@
export class ProfileUnavailableError extends Error {}
+11 -40
View File
@@ -1,12 +1,6 @@
import {
deleteCookie,
getCookie,
setCookie,
} from '@tanstack/react-start/server'
import { z } from 'zod'
import type { ProfileSettings } from './types'
import { ProfileUnavailableError } from './errors'
const PROFILE_COOKIE = 'twitter-lite-profile'
const profilesSchema = z.object({
profiles: z.array(z.string().min(1)),
})
@@ -32,38 +26,15 @@ export async function fetchProfileNames(
return profilesSchema.parse(await response.json()).profiles
}
export function getSelectedProfileName(): string | undefined {
return getCookie(PROFILE_COOKIE) || process.env.BIRD_PROFILE_NAME
}
export async function loadProfileSettings(): Promise<ProfileSettings> {
const profiles = await fetchProfileNames()
const selectedProfile = getSelectedProfileName()
return {
profiles,
...(selectedProfile && profiles.includes(selectedProfile)
? { selectedProfile }
: {}),
}
}
export async function saveProfileSelection(
profileName: string | null,
): Promise<void> {
if (profileName === null) {
deleteCookie(PROFILE_COOKIE, { path: '/' })
return
}
const profiles = await fetchProfileNames()
if (!profiles.includes(profileName)) {
throw new Error(`Unknown relay profile: ${profileName}`)
}
setCookie(PROFILE_COOKIE, profileName, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
export async function requireProfileName(profileName: string): Promise<void> {
const profiles = await fetchProfileNames().catch(() => {
throw new ProfileUnavailableError(
'プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
)
})
if (!profiles.includes(profileName)) {
throw new ProfileUnavailableError(
'このプロファイルは利用できません。カラムのプロファイルを選び直してください。',
)
}
}
+35 -2
View File
@@ -1,7 +1,40 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { fetchProfileNames } from './profile-service.server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProfileUnavailableError } from './errors'
import { fetchProfileNames, requireProfileName } from './profile-service.server'
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
describe('requireProfileName', () => {
it('accepts a discovered profile and rejects a stale column binding', async () => {
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid')
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ profiles: ['main', 'work'] })),
)
await expect(requireProfileName('work')).resolves.toBeUndefined()
await expect(requireProfileName('removed')).rejects.toBeInstanceOf(
ProfileUnavailableError,
)
})
it('reports discovery failures without silently selecting another profile', async () => {
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid')
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 503 })),
)
await expect(requireProfileName('work')).rejects.toThrow(
'プロファイルを確認できませんでした。',
)
})
})
describe('fetchProfileNames', () => {
it('loads profile names from the relay endpoint', async () => {
@@ -1,60 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ProfileSwitcher } from './profile-switcher'
const serverFns = vi.hoisted(() => ({
loadProfiles: vi.fn(),
selectProfile: vi.fn(),
}))
vi.mock('./server-functions', () => serverFns)
vi.mock('@tanstack/react-start', () => ({
useServerFn: (serverFn: unknown) => serverFn,
}))
function renderSwitcher() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={queryClient}>
<ProfileSwitcher />
</QueryClientProvider>,
)
}
describe('ProfileSwitcher', () => {
it('shows relay profiles and switches the active profile', async () => {
serverFns.loadProfiles.mockResolvedValue({
profiles: ['main', 'work'],
selectedProfile: 'main',
})
serverFns.selectProfile.mockResolvedValue({ profileName: 'work' })
renderSwitcher()
const select = await screen.findByLabelText('プロファイル')
await waitFor(() => expect(select).toHaveValue('main'))
fireEvent.change(select, { target: { value: 'work' } })
await waitFor(() =>
expect(serverFns.selectProfile).toHaveBeenCalledWith({
data: { profileName: 'work' },
}),
)
expect(await screen.findByRole('status')).toHaveTextContent(
'切り替えました。',
)
})
it('reports a relay profiles failure without offering stale choices', async () => {
serverFns.loadProfiles.mockRejectedValue(new Error('relay unavailable'))
renderSwitcher()
const select = await screen.findByLabelText('プロファイル')
await waitFor(() => expect(select).toBeDisabled())
expect(select).toHaveTextContent('取得失敗')
expect(select).toHaveAttribute('aria-invalid', 'true')
})
})
@@ -1,72 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { loadProfiles, selectProfile } from './server-functions'
export function ProfileSwitcher() {
const queryClient = useQueryClient()
const load = useServerFn(loadProfiles)
const select = useServerFn(selectProfile)
const profiles = useQuery({
queryKey: ['profiles'],
queryFn: () => load(),
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const selection = useMutation({
mutationFn: (profileName: string | null) =>
select({ data: { profileName } }),
onSuccess: () => queryClient.invalidateQueries(),
})
const state = profiles.isError
? 'error'
: selection.isError
? 'error'
: selection.isSuccess
? 'success'
: profiles.isPending || selection.isPending
? 'loading'
: 'default'
return (
<div className="profile-switcher" data-state={state}>
<label htmlFor="relay-profile">プロファイル</label>
<select
aria-invalid={state === 'error' ? 'true' : undefined}
disabled={profiles.isPending || profiles.isError || selection.isPending}
id="relay-profile"
onChange={(event) =>
selection.mutate(
event.target.value.length ? event.target.value : null,
)
}
value={profiles.data?.selectedProfile ?? ''}
>
<option value="">
{profiles.isPending
? '読み込み中'
: profiles.isError
? '取得失敗'
: '自動'}
</option>
{profiles.data?.profiles.map((profile) => (
<option key={profile} value={profile}>
{profile}
</option>
))}
</select>
<span
aria-live="polite"
className="profile-switcher-status"
role={selection.isError ? 'alert' : 'status'}
>
{selection.isError
? '切り替えに失敗しました。'
: selection.isSuccess
? '切り替えました。'
: ''}
</span>
</div>
)
}
+2 -16
View File
@@ -1,22 +1,8 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import type { ProfileSelection } from './types'
const profileSelectionSchema = z.object({
profileName: z.string().min(1).nullable(),
})
export const loadProfiles = createServerFn({ method: 'GET' }).handler(
async () => {
const { loadProfileSettings } = await import('./profile-service.server')
return loadProfileSettings()
const { fetchProfileNames } = await import('./profile-service.server')
return { profiles: await fetchProfileNames() }
},
)
export const selectProfile = createServerFn({ method: 'POST' })
.validator(profileSelectionSchema)
.handler(async ({ data }): Promise<ProfileSelection> => {
const { saveProfileSelection } = await import('./profile-service.server')
await saveProfileSelection(data.profileName)
return data
})
-8
View File
@@ -1,8 +0,0 @@
export type ProfileSettings = {
profiles: string[]
selectedProfile?: string
}
export type ProfileSelection = {
profileName: string | null
}
+3 -115
View File
@@ -9,127 +9,48 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SearchRouteImport } from './routes/search'
import { Route as DeckRouteImport } from './routes/deck'
import { Route as HandleRouteImport } from './routes/$handle'
import { Route as IndexRouteImport } from './routes/index'
import { Route as IListsRouteImport } from './routes/i.lists'
import { Route as IListsListIdRouteImport } from './routes/i.lists_.$listId'
import { Route as HandleStatusTweetIdRouteImport } from './routes/$handle_.status.$tweetId'
const SearchRoute = SearchRouteImport.update({
id: '/search',
path: '/search',
getParentRoute: () => rootRouteImport,
} as any)
const DeckRoute = DeckRouteImport.update({
id: '/deck',
path: '/deck',
getParentRoute: () => rootRouteImport,
} as any)
const HandleRoute = HandleRouteImport.update({
id: '/$handle',
path: '/$handle',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const IListsRoute = IListsRouteImport.update({
id: '/i/lists',
path: '/i/lists',
getParentRoute: () => rootRouteImport,
} as any)
const IListsListIdRoute = IListsListIdRouteImport.update({
id: '/i/lists_/$listId',
path: '/i/lists/$listId',
getParentRoute: () => rootRouteImport,
} as any)
const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
id: '/$handle_/status/$tweetId',
path: '/$handle/status/$tweetId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
'/i/lists/$listId': typeof IListsListIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
'/i/lists/$listId': typeof IListsListIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$handle': typeof HandleRoute
'/deck': typeof DeckRoute
'/search': typeof SearchRoute
'/i/lists': typeof IListsRoute
'/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute
'/i/lists_/$listId': typeof IListsListIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle/status/$tweetId'
| '/i/lists/$listId'
fullPaths: '/' | '/deck'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle/status/$tweetId'
| '/i/lists/$listId'
id:
| '__root__'
| '/'
| '/$handle'
| '/deck'
| '/search'
| '/i/lists'
| '/$handle_/status/$tweetId'
| '/i/lists_/$listId'
to: '/' | '/deck'
id: '__root__' | '/' | '/deck'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
HandleRoute: typeof HandleRoute
DeckRoute: typeof DeckRoute
SearchRoute: typeof SearchRoute
IListsRoute: typeof IListsRoute
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
IListsListIdRoute: typeof IListsListIdRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/search': {
id: '/search'
path: '/search'
fullPath: '/search'
preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport
}
'/deck': {
id: '/deck'
path: '/deck'
@@ -137,13 +58,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DeckRouteImport
parentRoute: typeof rootRouteImport
}
'/$handle': {
id: '/$handle'
path: '/$handle'
fullPath: '/$handle'
preLoaderRoute: typeof HandleRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -151,38 +65,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/i/lists': {
id: '/i/lists'
path: '/i/lists'
fullPath: '/i/lists'
preLoaderRoute: typeof IListsRouteImport
parentRoute: typeof rootRouteImport
}
'/i/lists_/$listId': {
id: '/i/lists_/$listId'
path: '/i/lists/$listId'
fullPath: '/i/lists/$listId'
preLoaderRoute: typeof IListsListIdRouteImport
parentRoute: typeof rootRouteImport
}
'/$handle_/status/$tweetId': {
id: '/$handle_/status/$tweetId'
path: '/$handle/status/$tweetId'
fullPath: '/$handle/status/$tweetId'
preLoaderRoute: typeof HandleStatusTweetIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
HandleRoute: HandleRoute,
DeckRoute: DeckRoute,
SearchRoute: SearchRoute,
IListsRoute: IListsRoute,
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
IListsListIdRoute: IListsListIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
-11
View File
@@ -1,11 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { UserPage } from './index'
export const Route = createFileRoute('/$handle')({
component: UserRoute,
})
function UserRoute() {
const { handle } = Route.useParams()
return <UserPage handle={handle} />
}
-39
View File
@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import {
InputError,
normalizeTweetId,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/$handle_/status/$tweetId')({
component: StatusRoute,
})
function StatusRoute() {
const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams()
let tweetId: string | undefined
let error: string | undefined
try {
normalizeUserTarget(rawHandle)
tweetId = normalizeTweetId(rawTweetId)
} catch (cause) {
error =
cause instanceof InputError ? cause.message : 'URL を確認してください。'
}
return (
<AppShell>
<h1>会話</h1>
<p className="intro">選んだ投稿と、その会話だけを表示します。</p>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed request={tweetId ? { kind: 'thread', tweetId } : undefined} />
</AppShell>
)
}
-231
View File
@@ -1,231 +0,0 @@
import { QueryClient } from '@tanstack/react-query'
import {
createMemoryHistory,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
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 =
await importOriginal<typeof import('../features/posts/use-post-feed')>()
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('../features/profiles/profile-switcher', () => ({
ProfileSwitcher: () => null,
}))
vi.mock('./__root', async () => {
const { createRootRouteWithContext, Outlet } = await import(
'@tanstack/react-router'
)
return {
Route: createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: Outlet,
}),
}
})
async function renderRoute(path: string) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [path] }),
context: { queryClient: new QueryClient() },
})
await router.load()
render(<RouterProvider router={router} />)
}
describe('post feed route wiring', () => {
beforeEach(() => {
vi.stubGlobal('scrollTo', vi.fn())
usePostFeed.mockReset()
useListChoices.mockReset()
usePostFeed.mockReturnValue({
data: undefined,
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
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())
it('keeps an empty user URL disabled', async () => {
await renderRoute('/')
expect(
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
).toBeVisible()
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a manually entered user URL into a deliberate request', async () => {
await renderRoute('/yuta')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'user',
target: 'yuta',
}),
)
})
it('keeps an invalid user path local and idle', async () => {
await renderRoute('/not-a-handle')
expect(await screen.findByRole('alert')).toHaveTextContent(
'ハンドルは英数字とアンダースコアで入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('keeps an empty list URL disabled', async () => {
await renderRoute('/i/lists')
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('/i/lists/1234567890')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'list',
target: '1234567890',
}),
)
expect(
await screen.findByRole('link', { name: 'リスト', current: 'page' }),
).toBeVisible()
})
it('keeps an invalid list path local and idle', async () => {
await renderRoute('/i/lists/not-a-list')
expect(await screen.findByRole('alert')).toHaveTextContent(
'リスト ID は数字で入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a selected authenticated list into a deliberate request', async () => {
await renderRoute('/i/lists')
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')
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search',
query: 'AI lang:ja filter:follows',
product: 'Top',
following: true,
}),
)
})
it('compiles button-selected search conditions from the URL', async () => {
await renderRoute(
'/search?q=TypeScript&from=%40tan_stack&since=2026-08-04&until=2026-08-11&lang=ja&content=images&excludeReplies=true&excludeReposts=true&product=Latest&following=false',
)
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'search',
query:
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
product: 'Latest',
following: false,
}),
)
expect(
screen.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
).toBeVisible()
})
it('keeps contradictory date parameters local and idle', async () => {
await renderRoute(
'/search?q=TypeScript&since=2026-08-11&until=2026-08-10&product=Latest&following=false',
)
fireEvent.click(screen.getByRole('button', { name: '期間' }))
expect(await screen.findByLabelText('開始日')).toHaveValue('2026-08-11')
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('turns a decimal status path into a deliberate thread request', async () => {
await renderRoute('/yuta/status/123')
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
await waitFor(() =>
expect(usePostFeed).toHaveBeenCalledWith({
kind: 'thread',
tweetId: '123',
}),
)
expect(
screen.queryByRole('link', { name: 'ユーザー', current: 'page' }),
).toBeNull()
expect(
screen.queryByRole('link', { name: '検索', current: 'page' }),
).toBeNull()
})
it('keeps an invalid tweet path local and idle', async () => {
await renderRoute('/yuta/status/not-a-tweet')
expect(await screen.findByRole('alert')).toHaveTextContent(
'投稿 ID を確認してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
it('keeps an invalid status handle local and idle', async () => {
await renderRoute('/not-a-handle/status/123')
expect(await screen.findByRole('alert')).toHaveTextContent(
'ハンドルは英数字とアンダースコアで入力してください。',
)
expect(usePostFeed).toHaveBeenCalledWith(undefined)
})
})
+1 -7
View File
@@ -5,7 +5,6 @@ import {
Outlet,
Scripts,
} from '@tanstack/react-router'
import { SearchTool } from '#/features/posts/webmcp-tools'
import appCss from '../styles.css?url'
type RouterContext = { queryClient: QueryClient }
@@ -34,12 +33,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
})
function RootContent() {
return (
<>
<SearchTool />
<Outlet />
</>
)
return <Outlet />
}
function RootDocument({ children }: { children: React.ReactNode }) {
-63
View File
@@ -1,63 +0,0 @@
import { createFileRoute, useNavigate } 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 { InputError, normalizeListTarget } from '#/features/posts/inputs'
export const Route = createFileRoute('/i/lists')({
component: ListIndexRoute,
})
function ListIndexRoute() {
return <ListPage />
}
export function ListPage({ listId = '' }: { listId?: string }) {
const navigate = useNavigate()
let normalizedListId: string | undefined
let error: string | undefined
if (listId) {
try {
normalizedListId = normalizeListTarget(listId)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: 'リスト ID を確認してください。'
}
}
return (
<AppShell active="list">
<h1 className="reader-title-long">どのリストを読みますか?</h1>
<p className="intro">
自分のリストを選ぶか、リストURLを入力してください。
</p>
<ListPicker selectedId={listId || undefined} />
<ListForm
initialTarget={listId}
key={listId}
onSubmit={(nextListId) =>
navigate({
to: '/i/lists/$listId',
params: { listId: nextListId },
})
}
/>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed
request={
normalizedListId
? { kind: 'list', target: normalizedListId }
: undefined
}
/>
</AppShell>
)
}
-11
View File
@@ -1,11 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { ListPage } from './i.lists'
export const Route = createFileRoute('/i/lists_/$listId')({
component: ListRoute,
})
function ListRoute() {
const { listId } = Route.useParams()
return <ListPage listId={listId} />
}
+3 -55
View File
@@ -1,56 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import { UserForm } from '#/features/posts/components/user-form'
import { InputError, normalizeUserTarget } from '#/features/posts/inputs'
import { createFileRoute } from '@tanstack/react-router'
import { DeckPage } from '#/features/decks/deck-page'
export const Route = createFileRoute('/')({
component: UserIndexRoute,
})
function UserIndexRoute() {
return <UserPage />
}
export function UserPage({ handle = '' }: { handle?: string }) {
const navigate = useNavigate()
let normalizedHandle: string | undefined
let error: string | undefined
if (handle) {
try {
normalizedHandle = normalizeUserTarget(handle)
} catch (cause) {
error =
cause instanceof InputError
? cause.message
: 'ハンドルを確認してください。'
}
}
return (
<AppShell active="user">
<h1>誰の投稿を見ますか?</h1>
<p className="intro">履歴やおすすめは表示しません。</p>
<UserForm
initialTarget={handle}
key={handle}
onSubmit={(nextHandle) =>
navigate({ to: '/$handle', params: { handle: nextHandle } })
}
/>
{error ? (
<p className="state" role="alert">
{error}
</p>
) : null}
<PostFeed
request={
normalizedHandle
? { kind: 'user', target: normalizedHandle }
: undefined
}
/>
</AppShell>
)
}
export const Route = createFileRoute('/')({ component: DeckPage })
-76
View File
@@ -1,76 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { AppShell } from '#/components/app-shell'
import { PostFeed } from '#/features/posts/components/post-feed'
import {
SearchForm,
type SearchValues,
} from '#/features/posts/components/search-form'
import {
buildFilteredSearchQuery,
postSearchRouteSchema,
} from '#/features/posts/inputs'
export const Route = createFileRoute('/search')({
validateSearch: (search) => postSearchRouteSchema.parse(search),
component: SearchRoute,
})
function compileQuery(values: SearchValues): string | undefined {
if (!values.q && !values.from) return undefined
try {
return buildFilteredSearchQuery(values, values.following)
} catch {
return undefined
}
}
function SearchRoute() {
const values = Route.useSearch()
const navigate = Route.useNavigate()
const formValues = {
...values,
from: values.from ?? '',
since: values.since ?? '',
until: values.until ?? '',
lang: values.lang ?? 'all',
content: values.content ?? 'all',
excludeReplies: values.excludeReplies ?? false,
excludeReposts: values.excludeReposts ?? false,
} satisfies SearchValues
const query = compileQuery(formValues)
const formKey = [
formValues.q,
formValues.from,
formValues.since,
formValues.until,
formValues.lang,
formValues.content,
formValues.excludeReplies,
formValues.excludeReposts,
formValues.product,
formValues.following,
].join(':')
return (
<AppShell active="search">
<h1>何を確認しますか?</h1>
<p className="intro">入力した条件だけを検索します。</p>
<SearchForm
initialValues={formValues}
key={formKey}
onSubmit={(search) => navigate({ search })}
/>
<PostFeed
request={
query
? {
kind: 'search',
query,
product: values.product,
following: values.following,
}
: undefined
}
/>
</AppShell>
)
}
+65 -1386
View File
File diff suppressed because it is too large Load Diff