feat: add shared decks and multi-account Mastodon OAuth

This commit is contained in:
2026-09-24 16:52:55 +09:00
parent d2cbf4dbd3
commit c47f58f065
100 changed files with 9215 additions and 1027 deletions
+177
View File
@@ -0,0 +1,177 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import type { Connection } from '../connections/model'
import { ColumnEditor } from './column-editor'
const lists = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('#/features/platforms/mastodon-server-functions', () => ({
loadMastodonLists: lists,
}))
vi.mock('#/features/posts/use-list-choices', () => ({
useListChoices: () => ({
isPending: false,
isError: false,
data: [],
refetch: vi.fn(),
}),
}))
const connections: Connection[] = [
{
id: 'twitter',
platform: 'twitter',
origin: 'https://relay.invalid',
accountId: null,
displayName: 'Twitter account',
status: 'connected',
},
{
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
},
{
id: 'mastodon-second',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '2',
displayName: '@second',
status: 'connected',
},
]
beforeEach(() => {
lists
.mockReset()
.mockImplementation(
async ({ data }: { data: { connectionId: string } }) => ({
lists: [
{
id: data.connectionId === 'mastodon-first' ? '10' : '20',
name: data.connectionId,
isPrivate: true,
},
],
}),
)
})
function show() {
const onSave = vi.fn().mockResolvedValue(true)
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ColumnEditor
connections={connections}
onSave={onSave}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('カラム名'), {
target: { value: '調査' },
})
return onSave
}
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-first' },
})
expect(screen.queryByLabelText('表示順')).toBeNull()
expect(screen.queryByLabelText('フォロー中のみ')).toBeNull()
expect(screen.getByText(/結果が0件でも/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'mastodon-first',
source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' },
}),
),
)
})
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
target: { value: 'hashtag' },
})
fireEvent.change(screen.getByLabelText('ハッシュタグ'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
}),
),
)
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'twitter' },
})
expect(screen.getByLabelText('カラムの種類')).toHaveValue('search')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull()
})
it('loads lists for each account and clears the selected list when the binding changes', async () => {
show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
target: { value: 'list' },
})
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('リスト')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-second' },
})
expect(screen.getByLabelText('リスト')).toHaveValue('')
fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }),
)
expect(screen.getByLabelText('リスト')).toHaveValue('20')
expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' },
})
})
it('preserves title and Twitter search options after a rejected save', async () => {
const save = show()
save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitterの検索条件'), {
target: { value: 'from:owner WebMCP' },
})
fireEvent.change(screen.getByLabelText('表示順'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue(
'from:owner WebMCP',
)
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
})
+49 -158
View File
@@ -1,91 +1,54 @@
import { useId, useState } from 'react'
import { useListChoices } from '#/features/posts/use-list-choices'
import type { Connection } from '#/features/connections/model'
import {
ColumnSourceEditor,
defaultColumnSource,
rebindColumnSource,
} from './column-source-editor'
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,
connections,
onSave,
onCancel,
}: {
column?: DeckColumn
profiles: string[]
onSave: (column: DeckColumn) => void
connections: Connection[]
onSave: (column: DeckColumn) => Promise<boolean>
onCancel: () => void
}) {
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const formId = useId()
const [error, setError] = useState<string>()
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
column?.source.kind ?? 'search',
const [connectionId, setConnectionId] = useState(
column?.connectionId ?? available[0]?.id ?? '',
)
const [profileName, setProfileName] = useState(
column?.profileName ?? profiles[0] ?? '',
const [source, setSource] = useState(
column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'),
)
const [target, setTarget] = useState(
column && column.source.kind !== 'search' ? column.source.target : '',
const validConnection = available.some(
(connection) => connection.id === connectionId,
)
return (
<form
className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
onSubmit={(event) => {
onSubmit={async (event) => {
event.preventDefault()
setError(undefined)
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({
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
profileName,
connectionId,
source,
})
if (!parsed.success || !profiles.includes(profileName)) {
if (!parsed.success || !validConnection) {
setError(
parsed.success
? '接続プロファイルを選択してください。'
@@ -93,7 +56,7 @@ export function ColumnEditor({
)
return
}
onSave(parsed.data)
await onSave(parsed.data)
}}
>
<div className="deck-editor-fields">
@@ -112,124 +75,52 @@ export function ColumnEditor({
接続プロファイル
<select
id={`${formId}-profile`}
value={profileName}
onChange={(event) => setProfileName(event.target.value)}
value={connectionId}
required
onChange={(event) => {
const connection = available.find(
(value) => value.id === event.target.value,
)
if (!connection) return
setConnectionId(connection.id)
setSource(rebindColumnSource(source, connection.platform))
}}
>
<option value="" disabled>
選択してください
</option>
{profileName && !profiles.includes(profileName) ? (
<option value={profileName} disabled>
{profileName}(利用不可)
{connectionId && !validConnection && (
<option value={connectionId} disabled>
{connectionId}(利用不可)
</option>
) : null}
{profiles.map((name) => (
<option key={name} value={name}>
{name}
)}
{available.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
</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>
{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>
)}
<ColumnSourceEditor
source={source}
connectionId={validConnection ? connectionId : ''}
onChange={setSource}
/>
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
</p>
{error ? (
{error && (
<p role="alert" className="deck-error">
{error}
</p>
) : null}
)}
<div className="deck-actions">
<button
type="submit"
className="deck-primary"
disabled={!profiles.includes(profileName)}
disabled={!validConnection}
>
{column ? '変更を保存' : '追加して検索'}
</button>
-7
View File
@@ -1,7 +0,0 @@
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 }
}
@@ -0,0 +1,69 @@
import type { ReactElement } from 'react'
import type { Connection } from '../connections/model'
import {
defaultMastodonSource,
MastodonSourceEditor,
} from './mastodon-source-editor'
import type { DeckColumn } from './model'
import {
defaultTwitterSource,
TwitterSourceEditor,
} from './twitter-source-editor'
type Source = DeckColumn['source']
export function defaultColumnSource(platform: Connection['platform']): Source {
switch (platform) {
case 'twitter':
return defaultTwitterSource()
case 'mastodon':
return defaultMastodonSource()
}
}
/** Account-local targets never carry across bindings; compatible searches may. */
export function rebindColumnSource(
source: Source,
platform: Connection['platform'],
): Source {
if (source.platform === platform) {
return source.kind === 'search' ? source : { ...source, target: '' }
}
switch (platform) {
case 'twitter':
return defaultTwitterSource(
source.kind === 'hashtag' ? 'search' : source.kind,
)
case 'mastodon':
return defaultMastodonSource(source.kind)
}
}
export function ColumnSourceEditor({
source,
connectionId,
onChange,
}: {
source: Source
connectionId: string
onChange: (source: Source) => void
}): ReactElement {
switch (source.platform) {
case 'twitter':
return (
<TwitterSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'mastodon':
return (
<MastodonSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
}
}
+13 -8
View File
@@ -1,20 +1,25 @@
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 {
flattenResearchPages,
type useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } 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> }
type ColumnFeed = {
column: DeckColumn
query: ReturnType<typeof useResearchFeed>
}
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof usePostFeed>,
query: ReturnType<typeof useResearchFeed>,
) {
const entry = useRef({ column, query })
entry.current = { column, query }
@@ -32,7 +37,7 @@ const readInput = columnInput.extend({
})
function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenPostPages(feed.query.data?.pages ?? [])
const posts = flattenResearchPages(feed.query.data?.pages ?? [])
const selected = posts.slice(offset, offset + limit)
return {
column: feed.column,
@@ -46,7 +51,7 @@ function read(feed: ColumnFeed, offset = 0, limit = 20) {
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
posts: selected.map(mapTwitterPost),
posts: selected,
loadedCount: posts.length,
offset,
nextOffset:
@@ -95,7 +100,7 @@ export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
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
const offset = flattenResearchPages(query.data?.pages ?? []).length
if (!query.hasNextPage) return read(entry.current, offset)
const result = await query.fetchNextPage({ cancelRefetch: false })
if (
+17 -20
View File
@@ -1,14 +1,17 @@
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 {
flattenResearchPages,
useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
import { describeSource } from './source-description'
export function ResearchColumn({
column,
connectionLabel,
onEdit,
onRemove,
onMove,
@@ -17,6 +20,7 @@ export function ResearchColumn({
registry,
}: {
column: DeckColumn
connectionLabel?: string
onEdit: () => void
onRemove: () => void
onMove: (direction: -1 | 1) => void
@@ -24,14 +28,15 @@ export function ResearchColumn({
last: boolean
registry: ColumnRegistry
}) {
const query = usePostFeed(columnRequest(column))
const query = useResearchFeed(column)
useRegisterColumn(registry, column, query)
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
const posts = flattenResearchPages(query.data?.pages ?? [])
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source)
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
@@ -98,22 +103,14 @@ export function ResearchColumn({
</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
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
>
{connectionLabel ?? '接続が見つかりません'}
</span>
<span>{sourceDescription.label}</span>
</div>
<p className="deck-query">
{column.source.kind === 'search'
? column.source.query
: column.source.target}
</p>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
+145 -31
View File
@@ -1,10 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { useLocation } from '@tanstack/react-router'
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 { ConnectionManager } from '#/features/connections/connection-manager'
import { loadConnections } from '#/features/connections/server-functions'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
@@ -15,26 +17,38 @@ import { useDeckTools } from './webmcp-tools'
import './decks.css'
export function DeckPage() {
const oauthResult = useLocation({
select: (location) =>
new URLSearchParams(location.searchStr).get('mastodon'),
})
const registry = useRef<ColumnRegistry>(new Map()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [managingConnections, setManagingConnections] = useState(false)
const [switcherEditing, setSwitcherEditing] = useState(false)
const {
deck,
workspace,
ready,
saving,
persist,
createTemporary,
refresh,
importLegacy,
legacyAvailable,
storageError,
save,
create,
select,
remove,
getWorkspace,
} = useDeck()
const fetchProfiles = useServerFn(loadProfiles)
} = useDeck(editing !== null || renaming || switcherEditing)
const fetchProfiles = useServerFn(loadConnections)
const profiles = useQuery({
queryKey: ['profiles'],
queryKey: ['connections'],
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
@@ -43,12 +57,15 @@ export function DeckPage() {
useDeckTools({
ready,
saving,
storageError,
save,
select,
remove,
getWorkspace,
profiles: profiles.data?.profiles,
createTemporary,
persist,
connections: profiles.data?.connections,
onApplied: clearEditors,
})
@@ -58,8 +75,8 @@ export function DeckPage() {
setRemoved(undefined)
}
function saveColumn(column: DeckColumn) {
save({
async function saveColumn(column: DeckColumn) {
const saved = await save({
...deck,
columns:
editing === 'new'
@@ -68,7 +85,8 @@ export function DeckPage() {
current.id === column.id ? column : current,
),
})
setEditing(null)
if (saved) setEditing(null)
return saved
}
return (
@@ -79,18 +97,21 @@ export function DeckPage() {
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready}
ready={ready && !saving}
onEditingChange={setSwitcherEditing}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={(title) => {
create(title)
clearEditors()
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
}}
onDelete={() => {
remove(deck.id)
clearEditors()
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
}}
/>
<button
@@ -98,7 +119,7 @@ export function DeckPage() {
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
@@ -124,7 +145,18 @@ export function DeckPage() {
</button>
))}
</nav>
<p className="sidebar-note">このブラウザに保存</p>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<button
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</button>
</>
}
>
@@ -136,18 +168,60 @@ export function DeckPage() {
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<button
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</button>
) : (
<button
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</button>
)}
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready}
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</button>
</div>
</div>
{!deck.persisted && ready ? (
<p className="deck-save-status">
一時ビューです。再読み込みやタブを閉じると失われます。
</p>
) : null}
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<button
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
@@ -161,26 +235,62 @@ export function DeckPage() {
</button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
<button
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</button>
</p>
) : null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
save({ ...deck, title })
setRenaming(false)
if (await save({ ...deck, title })) setRenaming(false)
}}
>
<label htmlFor="deck-title">
@@ -203,8 +313,8 @@ export function DeckPage() {
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
profiles={profiles.data?.profiles ?? []}
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
? undefined
@@ -221,11 +331,10 @@ export function DeckPage() {
<button
type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
onClick={() => {
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
save({ ...deck, columns })
setRemoved(undefined)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
@@ -256,20 +365,25 @@ export function DeckPage() {
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={() => {
setRemoved({ column, index })
save({
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (typeof editing === 'object' && editing?.id === column.id)
setEditing(null)
}}
+17 -12
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import type { Deck } from './model'
import type { DeckView } from './model'
export function DeckSwitcher({
decks,
@@ -10,16 +10,22 @@ export function DeckSwitcher({
onSelect,
onCreate,
onDelete,
onEditingChange,
}: {
decks: Deck[]
decks: DeckView[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => void
onDelete: () => void
onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean>
onEditingChange: (editing: boolean) => void
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
onEditingChange(creating || deleting)
return () => onEditingChange(false)
}, [creating, deleting, onEditingChange])
return (
<div className="deck-workspaces">
<div className="deck-actions">
@@ -37,6 +43,7 @@ export function DeckSwitcher({
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : '(一時)'}
</option>
))}
</select>
@@ -59,7 +66,7 @@ export function DeckSwitcher({
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready || decks.length <= 1}
disabled={!ready}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
@@ -73,14 +80,13 @@ export function DeckSwitcher({
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
<form
className="deck-rename"
onSubmit={(event) => {
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
onCreate(title)
setCreating(false)
if (await onCreate(title)) setCreating(false)
}}
>
<label htmlFor="new-deck-title">
@@ -105,9 +111,8 @@ export function DeckSwitcher({
<p>このデッキとカラムの設定を削除します。</p>
<button
type="button"
onClick={() => {
onDelete()
setDeleting(false)
onClick={async () => {
if (await onDelete()) setDeleting(false)
}}
>
削除する
+80
View File
@@ -0,0 +1,80 @@
import { createHash, randomUUID } from 'node:crypto'
import { and, eq } from 'drizzle-orm'
import { getDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { legacyWorkspaceSchema } from './legacy-import'
import {
createDeck,
DeckPersistenceError,
listDecks,
} from './repository.server'
export function importLegacyWorkspace(
input: unknown,
database = getDatabase(),
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin,
) {
const legacy = legacyWorkspaceSchema.parse(input)
const payloadHash = createHash('sha256')
.update(JSON.stringify(legacy.decks))
.digest('hex')
return database.transaction(
() => {
const marker = database
.select()
.from(legacyImports)
.where(eq(legacyImports.id, 'local-storage-v2'))
.get()
if (marker) {
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。',
)
return listDecks(database)
}
const available = database
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'twitter'),
eq(connections.origin, relayOrigin),
eq(connections.status, 'connected'),
),
)
.all()
const imported = legacy.decks.map((deck) =>
createDeck(
{
...deck,
id: randomUUID(),
columns: deck.columns.map(({ profileName, ...column }) => {
const connection = available.find(
(connection) => connection.relayProfile === profileName,
)
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`,
)
return { ...column, connectionId: connection.id }
}),
},
database,
),
)
database
.insert(legacyImports)
.values({
id: 'local-storage-v2',
payloadHash,
deckIds: imported.map((deck) => deck.id),
createdAt: Date.now(),
})
.run()
return listDecks(database)
},
{ behavior: 'immediate' },
)
}
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { importLegacyWorkspace } from './import.server'
import { createDeck, listDecks } from './repository.server'
let database: AppDatabase
const legacy = {
version: 2,
activeDeckId: 'same-id',
decks: [
{
id: 'same-id',
title: 'Legacy',
columns: [
{
id: 'a',
title: 'Account',
profileName: 'old-profile',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
},
],
},
],
}
beforeEach(() => {
database = openDatabase(':memory:')
database
.insert(connections)
.values({
id: 'new-connection-id',
platform: 'twitter',
origin: 'https://relay.invalid',
relayProfile: 'old-profile',
displayName: 'Old account',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => {
const existing = createDeck(
{ id: 'same-id', title: 'Existing server deck', columns: [] },
database,
)
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(result).toHaveLength(2)
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
const imported = result.find((deck) => deck.title === 'Legacy')
expect(imported?.id).not.toBe('same-id')
expect(imported?.columns[0]).toEqual({
id: 'a',
title: 'Account',
connectionId: 'new-connection-id',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
})
expect(
importLegacyWorkspace(legacy, database, 'https://relay.invalid'),
).toEqual(result)
})
it('blocks a different browser snapshot after the one-time import', () => {
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] },
database,
'https://relay.invalid',
),
).toThrow('旧デッキはすでに取り込み済みです')
expect(listDecks(database)).toEqual(result)
})
it('rolls back all imported decks and marker when any profile is unavailable', () => {
const second = {
...legacy.decks[0],
id: 'second',
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }],
}
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [...legacy.decks, second] },
database,
'https://relay.invalid',
),
).toThrow('対応する接続がありません')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck'
const legacyColumn = columnSchema
.omit({ connectionId: true })
.extend({ profileName: z.string().min(1) })
const legacyDeck = z
.object({
id: deckSchema.shape.id,
title: deckSchema.shape.title,
columns: z.array(legacyColumn).max(MAX_COLUMNS),
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
)
export const legacyWorkspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: z.string(),
decks: z.array(legacyDeck).min(1).max(100),
})
.strict()
.refine(
(value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'デッキ ID が重複しています。',
)
@@ -0,0 +1,230 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react'
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source'
type EditorProps<T extends MastodonSource = MastodonSource> = {
source: T
onChange: (source: MastodonSource) => void
}
export function defaultMastodonSource(
kind: MastodonSource['kind'] = 'search',
): MastodonSource {
switch (kind) {
case 'search':
return { platform: 'mastodon', kind, query: '' }
case 'user':
return { platform: 'mastodon', kind, target: '' }
case 'list':
return { platform: 'mastodon', kind, target: '' }
case 'hashtag':
return { platform: 'mastodon', kind, target: '' }
}
}
export function MastodonSourceEditor({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
return (
<>
<label htmlFor={id}>
カラムの種類
<select
id={id}
value={source.kind}
onChange={(event) =>
onChange(
defaultMastodonSource(
event.target.value as MastodonSource['kind'],
),
)
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
<option value="hashtag">ハッシュタグ</option>
</select>
</label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</>
)
}
function MastodonSourceFields({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <MastodonSearchEditor source={source} onChange={onChange} />
case 'user':
return <MastodonUserEditor source={source} onChange={onChange} />
case 'list':
return (
<MastodonListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'hashtag':
return <MastodonHashtagEditor source={source} onChange={onChange} />
}
}
function MastodonSearchEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
Mastodonの検索条件
<input
id={id}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP"
required
/>
</label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
</p>
</div>
)
}
function MastodonUserEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ユーザー
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="user@server またはアカウントID"
maxLength={256}
required
/>
</label>
</div>
)
}
function MastodonHashtagEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ハッシュタグ
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="# を除いたタグ名"
maxLength={128}
required
/>
</label>
</div>
)
}
function MastodonListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & {
connectionId: string
}) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
リスト
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="このアカウントのリストID"
maxLength={32}
required
/>
</label>
{connectionId && (
<MastodonListChoices
connectionId={connectionId}
onSelect={(target) => onChange({ ...source, target })}
/>
)}
</div>
)
}
function MastodonListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
}) {
const fetchLists = useServerFn(loadMastodonLists)
const lists = useQuery({
queryKey: ['mastodon-lists', connectionId],
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
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.lists.length === 0 ? (
<p>このアカウントのリストはありません。</p>
) : (
lists.data.lists.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
</button>
))
)}
</section>
)
}
+3 -17
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
import { type Deck, deckSchema, moveColumn } from './model'
const deck: Deck = {
id: 'research',
@@ -7,7 +7,7 @@ const deck: Deck = {
columns: ['a', 'b'].map((id) => ({
id,
title: id,
profileName: 'main',
connectionId: 'main',
source: {
platform: 'twitter',
kind: 'search',
@@ -71,21 +71,7 @@ describe('deck definitions', () => {
expect(
deckSchema.safeParse({
...deck,
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],
columns: [{ ...deck.columns[0], connectionId: '' }],
}).success,
).toBe(false)
})
+12 -27
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import {
buildSearchQuery,
normalizeListTarget,
@@ -69,11 +70,14 @@ export const columnSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(80),
profileName: z.string().min(1),
source: z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
connectionId: z.string().min(1),
source: z.union([
z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
mastodonSourceSchema,
]),
})
.strict()
@@ -92,37 +96,18 @@ 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 type Workspace = z.infer<typeof workspaceSchema>
export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] }
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
version: 2,
activeDeckId: EMPTY_DECK.id,
decks: [EMPTY_DECK],
decks: [{ ...EMPTY_DECK, persisted: false }],
}
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { deckSchema } from './model'
export const savedDeckDefinitionSchema = deckSchema
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
revision: z.number().int().positive(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
})
export const replaceDeckInputSchema = z
.object({
deck: savedDeckDefinitionSchema,
expectedRevision: z.number().int().positive(),
})
.strict()
export const deleteDeckInputSchema = z
.object({
id: deckSchema.shape.id,
expectedRevision: z.number().int().positive(),
})
.strict()
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>
export type SavedDeck = z.infer<typeof savedDeckSchema>
export type DeckPersistenceFailure = {
ok: false
error: {
code:
| 'conflict'
| 'not-found'
| 'connection-unavailable'
| 'invalid-input'
| 'storage-unavailable'
message: string
}
}
+184
View File
@@ -0,0 +1,184 @@
import { and, asc, eq } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections, deckColumns, decks } from '../storage/schema'
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
type SavedDeck,
type SavedDeckDefinition,
savedDeckDefinitionSchema,
savedDeckSchema,
} from './persistence-model'
export class DeckPersistenceError extends Error {
constructor(
readonly code: DeckPersistenceFailure['error']['code'],
message: string,
) {
super(message)
}
}
const conflict = () =>
new DeckPersistenceError(
'conflict',
'別の端末で変更されました。最新のデッキを読み直してください。',
)
const missing = () =>
new DeckPersistenceError(
'not-found',
'このデッキは削除されたか、見つかりません。',
)
export function loadDeck(
id: string,
database = getDatabase(),
): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get()
if (!record) return null
const columns = database
.select({
id: deckColumns.id,
title: deckColumns.title,
connectionId: deckColumns.connectionId,
source: deckColumns.source,
})
.from(deckColumns)
.where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position))
.all()
return savedDeckSchema.parse({ ...record, columns })
}
export function listDecks(database = getDatabase()): SavedDeck[] {
return database.transaction(() =>
database
.select({ id: decks.id })
.from(decks)
.orderBy(asc(decks.createdAt), asc(decks.id))
.all()
.map(({ id }) => {
const deck = loadDeck(id, database)
if (!deck) throw missing()
return deck
}),
)
}
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
for (const column of deck.columns) {
const connection = database
.select({ platform: connections.platform, status: connections.status })
.from(connections)
.where(eq(connections.id, column.connectionId))
.get()
if (
!connection ||
connection.platform !== column.source.platform ||
connection.status !== 'connected'
) {
throw new DeckPersistenceError(
'connection-unavailable',
'カラムの接続アカウントが利用できません。接続を確認してください。',
)
}
}
}
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run()
if (deck.columns.length)
database
.insert(deckColumns)
.values(
deck.columns.map((column, position) => ({
...column,
deckId: deck.id,
position,
})),
)
.run()
}
export function createDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input)
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (existing) {
const original = savedDeckDefinitionSchema.parse({
id: existing.id,
title: existing.title,
columns: existing.columns,
})
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict()
return existing
}
validateConnections(deck, database)
const now = Date.now()
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now }
database
.insert(decks)
.values({
id: deck.id,
title: deck.title,
revision: 1,
createdAt: now,
updatedAt: now,
})
.run()
writeColumns(deck, database)
return result
},
{ behavior: 'immediate' },
)
}
export function replaceDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (!existing) throw missing()
if (existing.revision !== expectedRevision) throw conflict()
validateConnections(deck, database)
const updatedAt = Date.now()
const result = database
.update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
writeColumns(deck, database)
return {
...deck,
revision: expectedRevision + 1,
createdAt: existing.createdAt,
updatedAt,
}
},
{ behavior: 'immediate' },
)
}
export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input)
return database.transaction(
() => {
if (!loadDeck(id, database)) throw missing()
const result = database
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
return id
},
{ behavior: 'immediate' },
)
}
+214
View File
@@ -0,0 +1,214 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
deckColumns,
} from '../storage/schema'
import type { SavedDeckDefinition } from './persistence-model'
import {
createDeck,
deleteDeck,
listDecks,
loadDeck,
replaceDeck,
} from './repository.server'
let directory: string
let database: AppDatabase
let second: AppDatabase
const deck: SavedDeckDefinition = {
id: 'request-123',
title: 'Research',
columns: [
{
id: 'first',
title: 'First',
connectionId: 'twitter-one',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
{
id: 'second',
title: 'Second',
connectionId: 'twitter-two',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
},
],
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-'))
database = openDatabase(join(directory, 'app.db'))
second = openDatabase(join(directory, 'app.db'))
database
.insert(connections)
.values(
['twitter-one', 'twitter-two'].map((id) => ({
id,
platform: 'twitter' as const,
origin: 'https://relay.invalid',
relayProfile: id,
displayName: id,
status: 'connected' as const,
createdAt: 1,
updatedAt: 1,
})),
)
.run()
})
afterEach(() => {
second.$client.close()
database.$client.close()
rmSync(directory, { recursive: true })
})
it('shares deck order and per-column account bindings with a separate database client', () => {
const saved = createDeck(deck, database)
expect(saved).toMatchObject({ ...deck, revision: 1 })
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(listDecks(second)).toEqual([saved])
})
it('makes retried creation idempotent but rejects different content using the same ID', () => {
const saved = createDeck(deck, database)
expect(createDeck(deck, second)).toEqual(saved)
expect(() =>
createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('別の端末で変更されました')
expect(listDecks(database)).toEqual([saved])
})
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => {
const initial = createDeck(deck, database)
const reversed = {
...deck,
title: 'Changed',
columns: [...deck.columns].reverse(),
}
const saved = replaceDeck(
{ deck: reversed, expectedRevision: initial.revision },
database,
)
expect(saved).toMatchObject({
...reversed,
revision: 2,
createdAt: initial.createdAt,
})
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'別の端末で変更されました',
)
expect(loadDeck(deck.id, second)).toEqual(saved)
})
it('clears all columns and prevents stale deletion from another device', () => {
createDeck(deck, database)
const saved = replaceDeck(
{ deck: { ...deck, columns: [] }, expectedRevision: 1 },
database,
)
expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('別の端末で変更されました')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
})
it('cascades deleted deck columns and reports already-deleted decks', () => {
createDeck(deck, database)
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'このデッキは削除されたか',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('このデッキは削除されたか')
})
it.each([
'disconnected',
'expired',
] as const)('rejects %s connections without partially saving a deck', (status) => {
const initial = createDeck(deck, database)
database
.update(connections)
.set({ status })
.where(eq(connections.id, 'twitter-two'))
.run()
expect(() =>
replaceDeck(
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('カラムの接続アカウントが利用できません')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
it('rejects a connection from another platform', () => {
database
.update(connections)
.set({ platform: 'mastodon' })
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
)
expect(listDecks(database)).toEqual([])
})
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
)
expect(listDecks(database)).toEqual([])
})
it('rejects duplicate column IDs without changing the saved version', () => {
const saved = createDeck(deck, database)
expect(() =>
replaceDeck(
{
deck: { ...deck, columns: [deck.columns[0], deck.columns[0]] },
expectedRevision: 1,
},
database,
),
).toThrow('カラム ID が重複しています')
expect(loadDeck(deck.id, database)).toEqual(saved)
})
it('returns only deck definitions and revision metadata, never credential data', () => {
database
.insert(connectionCredentials)
.values({
connectionId: 'twitter-one',
encryptedToken: 'secret-envelope',
updatedAt: 1,
})
.run()
createDeck(deck, database)
const loaded = listDecks(database)
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
'columns',
'createdAt',
'id',
'revision',
'title',
'updatedAt',
])
expect(JSON.stringify(loaded)).not.toContain('secret-envelope')
expect(JSON.stringify(loaded)).not.toContain('relayProfile')
})
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import { ResearchPostCard } from './research-post-card'
it('waits for reveal before mounting CW content or sensitive media', () => {
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
html: '<p>Hidden <strong>content</strong></p>',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
sensitive: true,
boostedBy: { name: 'Bob', handle: '[email protected]' },
media: [
{
type: 'photo',
url: 'https://social.invalid/image.png',
alt: 'Hidden image',
},
],
}
render(<ResearchPostCard post={post} />)
expect(screen.getByText('Bob がブースト')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '内容を表示' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: '内容を隠す' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
})
+66 -36
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
@@ -11,8 +12,13 @@ const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
})
export function ResearchPostCard({ post }: { post: ResearchPost }) {
const [revealed, setRevealed] = useState(false)
const concealed = Boolean(post.contentWarning || post.sensitive)
return (
<article className="research-post">
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
) : null}
<header>
{post.author.avatarUrl ? (
<img
@@ -35,43 +41,67 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
</time>
) : null}
</header>
<p className="research-post-text">
<PostText text={post.text} />
</p>
{post.media?.map((media) =>
media.type === 'photo' ? (
<img
className="research-media"
key={media.url}
src={media.url}
alt={media.alt ?? ''}
loading="lazy"
/>
) : (
<a
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<button
type="button"
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
動画を開く
</a>
),
)}
{post.quotedPost ? (
<blockquote>
<strong>{post.quotedPost.author.name}</strong>
<p>
<PostText text={post.quotedPost.text} />
</p>
<a
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
引用元を開く
</a>
</blockquote>
{revealed ? '内容を隠す' : '内容を表示'}
</button>
</div>
) : null}
{!concealed || revealed ? (
<>
{post.html !== undefined ? (
<div
className="research-post-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }}
/>
) : (
<p className="research-post-text">
<PostText text={post.text} />
</p>
)}
{post.media?.map((media) =>
media.type === 'photo' ? (
<img
className="research-media"
key={media.url}
src={media.url}
alt={media.alt ?? ''}
loading="lazy"
/>
) : (
<a
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
>
動画を開く
</a>
),
)}
{post.quotedPost ? (
<blockquote>
<strong>{post.quotedPost.author.name}</strong>
<p>
<PostText text={post.quotedPost.text} />
</p>
<a
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
引用元を開く
</a>
</blockquote>
) : null}
</>
) : null}
<footer>
<a href={post.url} target="_blank" rel="noreferrer noopener">
+60
View File
@@ -0,0 +1,60 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { legacyWorkspaceSchema } from './legacy-import'
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
savedDeckDefinitionSchema,
} from './persistence-model'
async function run<T>(
operation: (
repository: typeof import('./repository.server'),
) => T | Promise<T>,
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
const repository = await import('./repository.server')
try {
return { ok: true, data: await operation(repository) }
} catch (error) {
if (error instanceof repository.DeckPersistenceError)
return { ok: false, error: { code: error.code, message: error.message } }
if (error instanceof z.ZodError)
return {
ok: false,
error: {
code: 'invalid-input',
message: 'デッキの入力内容を確認してください。',
},
}
return {
ok: false,
error: {
code: 'storage-unavailable',
message: 'デッキを保存・取得できませんでした。再試行してください。',
},
}
}
}
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() =>
run((repository) => repository.listDecks()),
)
export const createSavedDeck = createServerFn({ method: 'POST' })
.validator(savedDeckDefinitionSchema)
.handler(({ data }) => run((repository) => repository.createDeck(data)))
export const replaceSavedDeck = createServerFn({ method: 'POST' })
.validator(replaceDeckInputSchema)
.handler(({ data }) => run((repository) => repository.replaceDeck(data)))
export const deleteSavedDeck = createServerFn({ method: 'POST' })
.validator(deleteDeckInputSchema)
.handler(({ data }) => run((repository) => repository.deleteDeck(data)))
export const importBrowserDecks = createServerFn({ method: 'POST' })
.validator(legacyWorkspaceSchema)
.handler(({ data }) =>
run(async () => {
const { importLegacyWorkspace } = await import('./import.server')
return importLegacyWorkspace(data)
}),
)
+37
View File
@@ -0,0 +1,37 @@
import type { DeckColumn } from './model'
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
export function describeSource(source: DeckColumn['source']): {
label: string
query: string
} {
switch (source.platform) {
case 'twitter':
switch (source.kind) {
case 'search':
return {
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`,
query: source.query,
}
case 'user':
return { label: 'ユーザー投稿', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
}
break
case 'mastodon':
switch (source.kind) {
case 'search':
return { label: '全文検索', query: source.query }
case 'user':
return { label: 'ユーザー投稿', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
case 'hashtag':
return { label: 'ハッシュタグ', query: `#${source.target}` }
}
break
}
const exhaustive: never = source
return exhaustive
}
@@ -0,0 +1,223 @@
import { type ReactElement, useId } from 'react'
import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model'
type Source = Extract<DeckColumn['source'], { platform: 'twitter' }>
type EditorProps<T extends Source = Source> = {
source: T
onChange: (source: Source) => void
}
export function defaultTwitterSource(kind: Source['kind'] = 'search'): Source {
switch (kind) {
case 'search':
return {
platform: 'twitter',
kind,
query: '',
product: 'Latest',
following: false,
}
case 'user':
return { platform: 'twitter', kind, target: '' }
case 'list':
return { platform: 'twitter', kind, target: '' }
}
}
export function TwitterSourceEditor({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
return (
<>
<label htmlFor={id}>
カラムの種類
<select
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</>
)
}
function TwitterSourceFields({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <TwitterSearchEditor source={source} onChange={onChange} />
case 'user':
return <TwitterUserEditor source={source} onChange={onChange} />
case 'list':
return (
<TwitterListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
}
}
function TwitterSearchEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'search' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={`${id}-query`}>
Twitterの検索条件
<input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${id}-product`}>
表示順
<select
id={`${id}-product`}
value={source.product}
onChange={(event) =>
onChange({
...source,
product: event.target.value as 'Latest' | 'Top',
})
}
>
<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"
checked={source.following}
onChange={(event) =>
onChange({ ...source, following: event.target.checked })
}
/>
フォロー中のみ
</label>
</div>
)
}
function TwitterUserEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'user' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ユーザー
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="@handle またはプロフィールURL"
maxLength={256}
required
/>
</label>
</div>
)
}
function TwitterListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<Source, { kind: 'list' }>> & { connectionId: string }) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
リスト
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="リストのURLまたはID"
maxLength={256}
required
/>
</label>
{connectionId && (
<TwitterListChoices
connectionId={connectionId}
onSelect={(target) => onChange({ ...source, target })}
/>
)}
</div>
)
}
function TwitterListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
}) {
const lists = useListChoices(connectionId)
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>
)
}
+295 -58
View File
@@ -1,66 +1,303 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { LEGACY_STORAGE_KEY } from './legacy-import'
import type { SavedDeck } from './persistence-model'
import { ACTIVE_DECK_KEY, useDeck } from './use-deck'
const server = vi.hoisted(() => ({
load: vi.fn(),
create: vi.fn(),
replace: vi.fn(),
remove: vi.fn(),
import: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
loadSavedDecks: server.load,
createSavedDeck: server.create,
replaceSavedDeck: server.replace,
deleteSavedDeck: server.remove,
importBrowserDecks: server.import,
}))
const saved: SavedDeck = {
id: 'saved',
title: 'Shared research',
columns: [],
revision: 1,
createdAt: 1,
updatedAt: 1,
}
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
vi.clearAllMocks()
server.load.mockResolvedValue({ ok: true, data: [] })
server.create.mockImplementation(async ({ data }) => ({
ok: true,
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
}))
})
describe('saved decks', () => {
it('restores saved conditions without overwriting them during mount', () => {
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(deck)
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
saved,
it('creates and edits temporary views without writing shared decks', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('AI research')
})
const id = result.current.deck.id
await act(async () => {
await result.current.save({ id, title: 'Revised research', columns: [] })
})
expect(result.current.deck).toMatchObject({
title: 'Revised research',
persisted: false,
})
expect(server.create).not.toHaveBeenCalled()
expect(server.replace).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
await act(async () => {
await result.current.persist(id)
})
expect(result.current.deck).toMatchObject({
id,
persisted: true,
revision: 1,
})
expect(server.create).toHaveBeenCalledTimes(1)
await act(async () => {
await result.current.persist(id)
})
expect(server.create).toHaveBeenCalledTimes(1)
})
it('keeps a temporary view on failed save and retries with the same identity', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Keep this')
})
const id = result.current.deck.id
server.create.mockResolvedValueOnce({
ok: false,
error: { message: 'Server disk unavailable' },
})
await act(async () => {
expect(await result.current.persist(id)).toBe(false)
})
expect(result.current.deck).toMatchObject({
id,
title: 'Keep this',
persisted: false,
})
expect(result.current.storageError).toBe('Server disk unavailable')
await act(async () => {
expect(await result.current.persist(id)).toBe(true)
})
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([
id,
id,
])
})
it('retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
server.replace.mockResolvedValue({
ok: false,
error: { message: 'Revision conflict' },
})
await act(async () => {
expect(await result.current.save({ ...saved, title: 'My draft' })).toBe(
false,
)
})
it('keeps invalid saved data untouched and explains the failure', () => {
localStorage.setItem(DECK_STORAGE_KEY, '{broken')
const { result } = renderHook(useDeck)
expect(result.current.storageError).toContain('読み込めません')
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
})
it('reports failed writes while keeping changes usable in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const { result } = renderHook(useDeck)
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()
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 })
expect(result.current.storageError).toBe('Revision conflict')
expect(server.replace).toHaveBeenCalledWith({
data: {
deck: { id: saved.id, title: 'My draft', columns: [] },
expectedRevision: 1,
},
})
})
it('refreshes on focus but holds remote updates while a form is being edited', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result, rerender } = renderHook(({ editing }) => useDeck(editing), {
initialProps: { editing: false },
})
await waitFor(() => expect(result.current.ready).toBe(true))
rerender({ editing: true })
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Remote edit', revision: 2 }],
})
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(server.load).toHaveBeenCalledTimes(1)
expect(result.current.deck.title).toBe(saved.title)
rerender({ editing: false })
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(result.current.deck).toMatchObject({
title: 'Remote edit',
revision: 2,
})
})
it('keeps the active deck local and copies saved content into a separate temporary view', async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: 'second', title: 'Second' }],
})
localStorage.setItem(ACTIVE_DECK_KEY, 'second')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.deck.id).toBe('second'))
act(() => {
result.current.createTemporary('A copy', result.current.deck.columns)
})
const copy = result.current.deck
expect(copy.persisted).toBe(false)
expect(copy.id).not.toBe('second')
await act(async () => {
await result.current.save({ ...copy, title: 'Changed copy' })
})
expect(
result.current.workspace.decks.find((deck) => deck.id === 'second')?.title,
).toBe('Second')
expect(server.replace).not.toHaveBeenCalled()
})
it('does not import old browser data without an explicit action', async () => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [{ id: 'old', title: 'Old deck', columns: [] }],
}
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy))
server.import.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.legacyAvailable).toBe(true)
expect(server.import).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy))
await act(async () => {
await result.current.importLegacy()
})
expect(server.import).toHaveBeenCalledWith({ data: legacy })
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
})
it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('読み込めませんでした')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
it('recognizes a committed save after a lost response without duplicating its temporary view', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Lost response')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(
result.current.workspace.decks.filter((deck) => deck.id === view.id),
).toHaveLength(1)
expect(result.current.deck.persisted).toBe(true)
})
it('preserves a divergent temporary edit when a lost-response save appears remotely', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Before request')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
await act(async () => {
await result.current.save({ ...view, title: 'Edited after failure' })
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck).toMatchObject({
title: 'Edited after failure',
persisted: false,
})
expect(result.current.deck.id).not.toBe(view.id)
expect(
result.current.workspace.decks.find((deck) => deck.id === view.id),
).toMatchObject({ title: 'Before request', persisted: true })
})
it('ignores an older refresh response after a newer request has completed', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
let resolveOlder: (value: unknown) => void = () => {}
server.load.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve
}),
)
act(() => {
void result.current.refresh()
})
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Newest', revision: 3 }],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck.revision).toBe(3)
await act(async () => {
resolveOlder({
ok: true,
data: [{ ...saved, title: 'Older', revision: 2 }],
})
})
expect(result.current.deck).toMatchObject({ title: 'Newest', revision: 3 })
})
+289 -58
View File
@@ -1,104 +1,335 @@
import { useEffect, useRef, useState } from 'react'
import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from './legacy-import'
import {
type Deck,
type DeckView,
deckSchema,
EMPTY_DECK,
EMPTY_WORKSPACE,
newId,
type Workspace,
workspaceSchema,
} from './model'
import type { SavedDeck } from './persistence-model'
import {
createSavedDeck,
deleteSavedDeck,
importBrowserDecks,
loadSavedDecks,
replaceSavedDeck,
} from './server-functions'
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
export const ACTIVE_DECK_KEY = 'twitter-lite-active-deck'
function asView(deck: SavedDeck): DeckView {
return {
id: deck.id,
title: deck.title,
columns: deck.columns,
revision: deck.revision,
persisted: true,
}
}
function definition(deck: Deck): Deck {
return deckSchema.parse({
id: deck.id,
title: deck.title,
columns: deck.columns,
})
}
export function useDeck() {
export function useDeck(editing = false) {
const services = {
load: useServerFn(loadSavedDecks),
create: useServerFn(createSavedDeck),
replace: useServerFn(replaceSavedDeck),
remove: useServerFn(deleteSavedDeck),
import: useServerFn(importBrowserDecks),
}
const servicesRef = useRef(services)
servicesRef.current = services
const editingRef = useRef(editing)
editingRef.current = editing
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const initialized = useRef(false)
const [storageError, setStorageError] = useState<string>()
const [saving, setSaving] = useState(false)
const busy = useRef(false)
const generation = useRef(0)
const refreshSequence = useRef(0)
const [legacyAvailable, setLegacyAvailable] = useState(false)
const apply = useCallback((next: Workspace) => {
current.current = next
setWorkspace(next)
try {
localStorage.setItem(ACTIVE_DECK_KEY, next.activeDeckId)
} catch {
/* Selection is a device-local preference, not saved deck data. */
}
}, [])
const reconcile = useCallback(
(saved: SavedDeck[]) => {
let preferred = current.current.activeDeckId
let temporary = current.current.decks.filter((deck) => !deck.persisted)
if (!initialized.current)
temporary = saved.length
? []
: [{ ...EMPTY_DECK, id: newId(), persisted: false }]
temporary = temporary.flatMap((view) => {
const accepted = saved.find((deck) => deck.id === view.id)
if (!accepted) return [view]
if (
JSON.stringify(definition(accepted)) ===
JSON.stringify(definition(view))
)
return []
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'保存済みの内容と異なる編集を、一時コピーとして残しました。',
)
return [{ ...view, id }]
})
const all = [...saved.map(asView), ...temporary]
if (!all.length)
all.push({ ...EMPTY_DECK, id: newId(), persisted: false })
if (!initialized.current) {
try {
preferred = localStorage.getItem(ACTIVE_DECK_KEY) ?? preferred
} catch {
/* Optional local preference. */
}
}
const activeDeckId = all.some((deck) => deck.id === preferred)
? preferred
: (all[0]?.id ?? '')
if (initialized.current && activeDeckId !== preferred)
setStorageError('表示中のデッキが別の端末で削除されました。')
initialized.current = true
apply({ activeDeckId, decks: all })
},
[apply],
)
const refresh = useCallback(
async (force = false) => {
if (busy.current || (!force && editingRef.current)) return false
const requestGeneration = generation.current
const requestSequence = ++refreshSequence.current
try {
const result = await servicesRef.current.load()
if (
requestGeneration !== generation.current ||
requestSequence !== refreshSequence.current ||
busy.current ||
(!force && editingRef.current)
)
return false
if (!result.ok) throw new Error(result.error.message)
if (force) setStorageError(undefined)
reconcile(result.data)
setReady(true)
return true
} catch {
if (
requestSequence !== refreshSequence.current ||
requestGeneration !== generation.current
)
return false
setStorageError(
'保存済みデッキを読み込めませんでした。再試行してください。',
)
return false
}
},
[reconcile],
)
useEffect(() => {
try {
const saved = localStorage.getItem(DECK_STORAGE_KEY)
if (saved !== null) {
const restored = workspaceSchema.parse(JSON.parse(saved))
current.current = restored
setWorkspace(restored)
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
legacyWorkspaceSchema.parse(JSON.parse(legacy))
setLegacyAvailable(true)
}
} catch {
setStorageError(
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
'旧デッキを読み込めませんでした。ブラウザの保存内容は変更していません。',
)
}
setReady(true)
}, [])
void refresh()
const onFocus = () => {
if (document.visibilityState === 'visible') void refresh()
}
const timer = setInterval(onFocus, 5000)
window.addEventListener('focus', onFocus)
return () => {
clearInterval(timer)
window.removeEventListener('focus', onFocus)
}
}, [refresh])
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)
return true
} catch {
setStorageError(
'このブラウザに保存できません。現在の変更は、ページを閉じると失われます。',
)
async function mutate<T>(
operation: () => Promise<T>,
accept: (value: T) => void,
) {
if (busy.current) {
setStorageError('保存処理が完了するまでお待ちください。')
return false
}
busy.current = true
generation.current += 1
setSaving(true)
try {
const result = await operation()
accept(result)
setStorageError(undefined)
return true
} catch (error) {
setStorageError(
error instanceof Error
? error.message
: '保存できませんでした。再試行してください。',
)
return false
} finally {
busy.current = false
setSaving(false)
}
}
function save(next: Deck) {
const validated = deckSchema.parse(next)
function upsert(view: DeckView) {
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],
apply({
activeDeckId: view.id,
decks: previous.decks.some((deck) => deck.id === view.id)
? previous.decks.map((deck) => (deck.id === view.id ? view : deck))
: [...previous.decks, view],
})
}
function create(title: string) {
return save({ id: newId(), title, columns: [] })
async function save(next: Deck, expectedRevision?: number) {
const parsed = definition(next)
const existing = current.current.decks.find((deck) => deck.id === parsed.id)
if (!existing?.persisted) {
if (busy.current) return false
generation.current += 1
upsert({ ...parsed, persisted: false })
return true
}
return mutate(
async () => {
const result = await servicesRef.current.replace({
data: {
deck: parsed,
expectedRevision: expectedRevision ?? existing.revision ?? 0,
},
})
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => upsert(asView(result)),
)
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
return persist({ ...current.current, activeDeckId: id })
apply({ ...current.current, activeDeckId: id })
return true
}
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,
})
function createTemporary(title: string, columns: Deck['columns'] = []) {
const view = {
...definition({ id: newId(), title, columns }),
persisted: false,
}
upsert(view)
return view
}
async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
if (view.persisted) return true
return mutate(
async () => {
const result = await servicesRef.current.create({
data: definition(view),
})
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => upsert(asView(result)),
)
}
async function create(title: string) {
const view = createTemporary(title)
return persist(view.id)
}
async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
const accept = () => {
const previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id)
if (!decks.length)
decks.push({ ...EMPTY_DECK, id: newId(), persisted: false })
apply({
activeDeckId:
previous.activeDeckId === id
? (decks[0]?.id ?? '')
: previous.activeDeckId,
decks,
})
}
if (!view.persisted) {
if (busy.current) return false
accept()
return true
}
return mutate(async () => {
const result = await servicesRef.current.remove({
data: { id, expectedRevision: expectedRevision ?? view.revision ?? 0 },
})
if (!result.ok) throw new Error(result.error.message)
}, accept)
}
async function importLegacy() {
return mutate(
async () => {
const data = legacyWorkspaceSchema.parse(
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? 'null'),
)
const result = await servicesRef.current.import({ data })
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => {
reconcile(result)
setLegacyAvailable(false)
try {
localStorage.removeItem(LEGACY_STORAGE_KEY)
} catch {
/* Server marker prevents duplicate imports on retry. */
}
},
)
}
const deck =
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
EMPTY_DECK
return {
deck,
deck: workspace.decks.find(
(deck) => deck.id === workspace.activeDeckId,
) ?? { ...EMPTY_DECK, persisted: false },
workspace,
ready,
saving,
storageError,
legacyAvailable,
save,
create,
createTemporary,
persist,
select,
remove,
refresh,
importLegacy,
getWorkspace: () => current.current,
}
}
+44 -12
View File
@@ -1,15 +1,24 @@
import { describe, expect, it } from 'vitest'
import type { Connection } from '#/features/connections/model'
import { prepareDeck } from './webmcp-contracts'
const account = (id: string): Connection => ({
id,
platform: 'twitter',
origin: 'https://relay.invalid',
accountId: null,
displayName: id,
status: 'connected',
})
const column = {
title: '観点',
profileName: 'main',
connectionId: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
'main',
account('main'),
])
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
@@ -27,19 +36,19 @@ describe('deck tool input', () => {
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
['main'],
[account('main')],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
expect(() =>
prepareDeck({ title: '調査', columns: [identified, identified] }, [
'main',
account('main'),
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, ['work']),
).toThrow('Unknown relay profile')
prepareDeck({ title: '調査', columns: [column] }, [account('work')]),
).toThrow('Unavailable connection')
})
it('accepts user and list columns bound to separate profiles', () => {
const deck = prepareDeck(
@@ -49,14 +58,14 @@ describe('deck tool input', () => {
{ ...column, source: { kind: 'user', target: '@alice' } },
{
...column,
profileName: 'work',
connectionId: 'work',
source: { kind: 'list', target: '123' },
},
],
},
['main', 'work'],
[account('main'), account('work')],
)
expect(deck.columns.map((column) => column.profileName)).toEqual([
expect(deck.columns.map((column) => column.connectionId)).toEqual([
'main',
'work',
])
@@ -77,7 +86,7 @@ describe('deck tool input', () => {
},
],
},
['main'],
[account('main')],
),
).toThrow()
expect(() =>
@@ -95,14 +104,37 @@ describe('deck tool input', () => {
},
],
},
['main'],
[account('main')],
),
).toThrow()
expect(() =>
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
['main'],
[account('main')],
),
).toThrow()
})
})
it('builds a mixed-platform deck with explicit account bindings', () => {
const deck = prepareDeck(
{
title: 'Mixed',
columns: [
column,
{
title: 'Mastodon tag',
connectionId: 'masto',
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
},
],
},
[account('main'), { ...account('masto'), platform: 'mastodon' }],
)
expect(
deck.columns.map((column) => [column.connectionId, column.source.platform]),
).toEqual([
['main', 'twitter'],
['masto', 'mastodon'],
])
})
+55 -29
View File
@@ -1,4 +1,6 @@
import { z } from 'zod'
import type { Connection } from '#/features/connections/model'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
@@ -8,34 +10,40 @@ const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
.describe(
'Keep an existing column ID when editing; omit for a new column.',
),
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(),
source: z.union([
mastodonSourceSchema,
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 deleteDeckToolInput = deckIdInput
.extend({ expectedRevision: z.number().int().positive().optional() })
.strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
@@ -44,19 +52,30 @@ export const setDeckInput = z
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
'Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.',
),
expectedRevision: z
.number()
.int()
.positive()
.optional()
.describe(
'Required when replacing a saved deck; omit for a temporary view.',
),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
'Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.',
),
})
.strict()
export function prepareDeck(input: unknown, profiles: readonly string[]) {
export function prepareDeck(
input: unknown,
connections: readonly Connection[],
) {
const parsed = setDeckInput.parse(input)
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
@@ -67,8 +86,15 @@ export function prepareDeck(input: unknown, profiles: readonly string[]) {
})),
})
for (const column of deck.columns) {
if (!profiles.includes(column.profileName))
throw new InputError(`Unknown relay profile: ${column.profileName}`)
if (
!connections.some(
(connection) =>
connection.id === column.connectionId &&
connection.platform === column.source.platform &&
connection.status === 'connected',
)
)
throw new InputError(`Unavailable connection: ${column.connectionId}`)
}
return deck
}
+101 -59
View File
@@ -1,8 +1,10 @@
import { useWebMCP } from 'usewebmcp'
import type { Deck, Workspace } from './model'
import type { Connection } from '#/features/connections/model'
import type { Deck, DeckView, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import {
deckIdInput,
deleteDeckToolInput,
emptyToolInput,
getDeckInput,
prepareDeck,
@@ -12,126 +14,166 @@ import {
export function useDeckTools({
ready,
saving,
storageError,
save,
select,
remove,
getWorkspace,
profiles,
connections,
onApplied,
createTemporary,
persist,
}: {
ready: boolean
saving: boolean
storageError?: string
save: (deck: Deck) => boolean
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
select: (id: string) => boolean
remove: (id: string) => boolean
remove: (id: string, expectedRevision?: number) => Promise<boolean>
getWorkspace: () => Workspace
profiles: string[] | undefined
connections: Connection[] | undefined
onApplied: () => void
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
persist: (id: string) => Promise<boolean>
}) {
const supported = useWebMCPSupported()
const enabled = supported && ready
function applied(persisted: boolean) {
onApplied()
if (!persisted)
const enabled = useWebMCPSupported() && ready
function requireDeck(id?: string) {
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (id ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return deck
}
function requireIdle() {
if (saving) throw new Error('Wait for the current save to finish.')
}
function applied(success: boolean) {
if (!success)
throw new Error(
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
'The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.',
)
onApplied()
}
useWebMCP({
name: 'list_decks',
name: 'list_connections',
description:
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
'List available connected accounts and stable connection IDs for each platform. Credentials are never returned.',
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
profiles: profiles ?? null,
storageError: storageError ?? null,
}
return { connections: connections ?? null }
}),
})
useWebMCP({
name: 'list_decks',
description:
'List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.',
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { ...getWorkspace(), 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.',
'Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.',
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 }
}),
toolResult(() => ({
deck: requireDeck(getDeckInput.parse(input).deckId),
storageError: storageError ?? null,
})),
})
useWebMCP({
name: 'set_deck',
description:
'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.',
'Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.',
inputSchema: setDeckInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
toolResult(async () => {
requireIdle()
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' }
if (!connections) throw new Error('Connections are not available yet.')
const next = prepareDeck(parsed, connections)
if (!parsed.deckId) {
const deck = createTemporary(next.title, next.columns)
onApplied()
return { deck, persisted: false, posts: 'loading-asynchronously' }
}
const previous = requireDeck(parsed.deckId)
if (previous.persisted && parsed.expectedRevision === undefined)
throw new Error(
'expectedRevision is required to update a saved deck.',
)
applied(await save(next, parsed.expectedRevision))
const deck = requireDeck(next.id)
return {
deck,
persisted: deck.persisted,
posts: 'loading-asynchronously',
}
}),
})
useWebMCP({
name: 'save_deck',
description:
'Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(await persist(deckId))
return { deck: requireDeck(deckId), persisted: true }
}),
})
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.',
'Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
const workspace = getWorkspace()
return {
decks: workspace.decks,
activeDeckId: workspace.activeDeckId,
persisted: true,
}
return getWorkspace()
}),
})
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,
'Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.',
inputSchema: deleteDeckToolInput,
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,
}
toolResult(async () => {
requireIdle()
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input)
if (requireDeck(deckId).persisted && expectedRevision === undefined)
throw new Error(
'expectedRevision is required to delete a saved deck.',
)
applied(await remove(deckId, expectedRevision))
return getWorkspace()
}),
})
}