feat: unify research in profile-bound decks with TweetDeck-style UI
This commit is contained in:
@@ -1,2 +1 @@
|
||||
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
|
||||
BIRD_PROFILE_NAME=
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"macrostructure": "Workbench",
|
||||
"theme": "custom",
|
||||
"theme_axes": "dark navy / system sans / blue",
|
||||
"enrichment": "none",
|
||||
"brief": "Full-height TweetDeck-style research columns with compact sidebar and modal editors"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"macrostructure": "Workbench",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"date": "2026-07-26",
|
||||
"font_stack": "Inter / Noto Sans JP / system sans declared in src/styles.css",
|
||||
"palette": "CSS custom properties with muted teal hex values",
|
||||
"motion": "motion-cut; no motion library",
|
||||
"spacing": "no named scale",
|
||||
"date": "2026-09-24",
|
||||
"font_stack": "System sans; tokens.css",
|
||||
"palette": "Dark navy and blue OKLCH tokens",
|
||||
"motion": "motion-cut",
|
||||
"spacing": "named token scale",
|
||||
"framework": "React 19 + TanStack Start + Vite 8",
|
||||
"preserve": ["routes", "information architecture", "read-only behavior"]
|
||||
"preserve": ["deck data model", "profile binding", "WebMCP contracts"],
|
||||
"override": "User requested TweetDeck-like full UI redesign instead of Garden reader"
|
||||
}
|
||||
|
||||
@@ -1,50 +1,32 @@
|
||||
# Twitter Lite
|
||||
|
||||
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||
you enter a user handle, profile URL, search query, or list URL, follow a
|
||||
deliberate post detail link, or manually open a valid
|
||||
`/:handle/status/:tweetId` URL.
|
||||
Twitter Lite is a read-only research deck for X. Create multiple named decks,
|
||||
and arrange up to six columns per deck for searches, user timelines, and lists.
|
||||
Each column is bound to an explicit relay profile, so different accounts can
|
||||
be used side by side.
|
||||
|
||||
## Scope
|
||||
|
||||
- User timelines and raw X search syntax
|
||||
- Button-driven search filters for author, period, language, content type, replies, and reposts
|
||||
- Popular (`Top`) and chronological (`Latest`) search
|
||||
- Optional `filter:follows` search
|
||||
- Authenticated account list selection and list timelines from a deliberate URL or ID
|
||||
- Relay profile switching from the profiles exposed by the relay
|
||||
- Infinite cursor pagination with explicit retry
|
||||
- Deliberate post detail pages with the visible conversation
|
||||
- Infinite conversation loading with explicit continuation retry
|
||||
- Read-only cards for text, media, quotes, articles, and quiet engagement counts
|
||||
- Experimental WebMCP tools for search, loaded-post reading, and continuation
|
||||
- Deck WebMCP tools to inspect or create/replace up to six columns at once
|
||||
- Research deck with named Twitter search columns, independent pagination,
|
||||
editing, ordering, and browser-local condition persistence
|
||||
- Multiple deck profiles with creation, selection, renaming, and deletion
|
||||
- Search, user timeline, and list columns with independent relay profiles
|
||||
- Native X search syntax, Top/Latest ranking, and optional `filter:follows`
|
||||
- List discovery for the profile selected in the column editor
|
||||
- Column editing, ordering, deletion/undo, manual refresh, and cursor pagination
|
||||
- Browser-local persistence of deck definitions and the active deck
|
||||
- Read-only cards with original-post links, text, media, and quotes
|
||||
- Experimental WebMCP tools to manage decks and read or paginate their columns
|
||||
|
||||
It intentionally has no home feed, recommendations, trends, notifications,
|
||||
history, account directory, or write actions.
|
||||
Both `/` and `/deck` open the deck workspace. The separate reader, search,
|
||||
list, user-profile, and conversation routes have been removed. Original-post
|
||||
links open X; conversations are not rendered inside the app.
|
||||
|
||||
Post detail pages show only the selected post and its visible conversation;
|
||||
they do not add related-post recommendations or an account-discovery surface.
|
||||
|
||||
## Routes
|
||||
|
||||
- `/` shows the empty user input.
|
||||
- `/deck` opens the research deck (up to six search columns).
|
||||
- `/:handle` shows a user timeline.
|
||||
- `/search` shows search results. Its typed query parameters preserve the query,
|
||||
author, period, language, content type, exclusions, ranking, and follows filter
|
||||
so filtered searches can be reloaded or shared.
|
||||
- `/i/lists` shows the authenticated account's lists.
|
||||
- `/i/lists/:listId` shows a list timeline.
|
||||
- `/:handle/status/:tweetId` shows one selected post and its visible conversation.
|
||||
Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr
|
||||
connectors are not implemented yet. Twitter is the only supported platform.
|
||||
|
||||
## Requirements and setup
|
||||
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||
routes the `@yuta` scope to the public Gitea Packages registry, where Bird
|
||||
`0.10.0` provides the required Top and Latest search interface.
|
||||
routes the `@yuta` scope to the public Gitea Packages registry.
|
||||
|
||||
```bash
|
||||
nix develop -c pnpm install --frozen-lockfile
|
||||
@@ -52,21 +34,19 @@ cp .env.example .env.local
|
||||
```
|
||||
|
||||
The Nix development shell installs Hallmark's agent skill for the supported
|
||||
local agent targets.
|
||||
|
||||
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development.
|
||||
`BIRD_PROFILE_NAME` optionally selects the initial relay profile; the header
|
||||
selector can switch between the profiles returned by the relay's `/profiles`
|
||||
endpoint. Export the same values in the process environment before `start` or
|
||||
`test:live`:
|
||||
local agent targets. Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite
|
||||
development, or export it before running the production server:
|
||||
|
||||
```bash
|
||||
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
|
||||
export BIRD_PROFILE_NAME=
|
||||
```
|
||||
|
||||
Both values are read by server-only code at runtime; neither value nor Bird is
|
||||
sent to the browser.
|
||||
The app discovers available account profiles through the relay's `/profiles`
|
||||
endpoint. Select a profile for each column. There is no global account selector,
|
||||
profile cookie, or application-level `BIRD_PROFILE_NAME` default. Requests
|
||||
validate that the bound profile still exists before fetching posts or lists.
|
||||
Bird and relay credentials stay on the server; saved deck definitions include
|
||||
the selected profile names.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -82,68 +62,64 @@ nix develop -c pnpm build
|
||||
nix develop -c pnpm start
|
||||
```
|
||||
|
||||
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
|
||||
the dev shell. Browser tests are organized under `e2e/`: page object models
|
||||
and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1
|
||||
A and AA. The tests keep a standalone HTTP relay so TanStack Start server
|
||||
functions and SSR requests exercise the same network boundary as production.
|
||||
The API client and fixture types are generated with Orval from a pinned
|
||||
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba);
|
||||
run `generate:e2e-openapi` only when updating that pin or the selected
|
||||
operations. Generated files are committed, so normal E2E runs do not require
|
||||
network access. E2E fixtures use the generated response types but stay
|
||||
deterministic. Runtime requests continue through Bird because its transport
|
||||
differs from the upstream OpenAPI client. Relay-only endpoints, request
|
||||
transport differences, cursors, and stateful failure scenarios remain in the
|
||||
handwritten mock server.
|
||||
`test:live` is an explicit, opt-in smoke test that performs exactly one
|
||||
read-only Top search using the configured relay.
|
||||
`test:e2e` uses the system Chromium supplied by the Nix dev shell. Playwright
|
||||
integration and Axe accessibility tests live under `e2e/`. A standalone mock
|
||||
HTTP relay exercises the production server-function boundary without contacting
|
||||
X or a personal relay. Orval generates API fixture types from a pinned
|
||||
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba).
|
||||
Generated files are committed; run `generate:e2e-openapi` only when changing
|
||||
the pin or selected operations. Runtime requests use Bird.
|
||||
|
||||
## Research deck
|
||||
`test:live` is an explicit, opt-in smoke test that performs one read-only Top
|
||||
search. For this standalone probe only, `BIRD_PROFILE_NAME` selects the account
|
||||
and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
|
||||
|
||||
Open **デッキ** in the navigation, name the investigation, and add a column
|
||||
for each perspective. Each column has a title, native Twitter search query,
|
||||
Top/Latest ordering, and an optional follows filter. Update or paginate a
|
||||
column independently, edit its conditions, move it left/right, or undo a
|
||||
removal. All columns use the currently selected relay profile.
|
||||
## Deck workspace
|
||||
|
||||
One deck's conditions are stored in this browser's localStorage. Reloading
|
||||
fetches the first page again; collected posts are not archived or synced
|
||||
between devices. Other open tabs are not synchronized. A WebMCP-capable agent
|
||||
can use `get_deck` and `set_deck` on `/deck` to create or edit the whole deck.
|
||||
Built-in AI planning, summaries, and the Mastodon/Bluesky/Threads/Nostr connectors
|
||||
are not implemented yet. Only Twitter can currently be selected. See
|
||||
[the platform boundary and next steps](docs/research-decks.md).
|
||||
The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar,
|
||||
compact column headers, and independently scrolling post columns. On narrow
|
||||
screens, the sidebar becomes a compact top bar. Add/edit forms open in native
|
||||
dialogs; Escape closes them and returns focus. Column menus contain editing,
|
||||
ordering, and deletion; refresh stays available directly in each header.
|
||||
|
||||
## WebMCP prototype
|
||||
Create a deck for an investigation, then add columns for each perspective.
|
||||
Choose a relay profile and a source: search, user timeline, or list. Profile
|
||||
changes affect only the edited column. Matching source conditions and profiles
|
||||
share the query cache; different profiles never share posts or cursors.
|
||||
|
||||
In a WebMCP-enabled browser, `search_posts` searches and updates the page,
|
||||
`get_loaded_posts` reads a bounded slice of the active feed, and
|
||||
`load_more_posts` appends a continuation and returns the new posts. The tools
|
||||
share the reader's query cache and active relay profile. They do not publish
|
||||
posts or change profiles.
|
||||
The workspace saves multiple decks and the active selection in localStorage.
|
||||
Only the active deck is rendered and fetched. Posts and cursors are not saved;
|
||||
reloading fetches first pages. Devices and tabs do not synchronize edits.
|
||||
|
||||
Old single-deck data is not migrated automatically because it has no explicit
|
||||
column profile binding. Invalid or older saved data remains untouched while
|
||||
the app shows an empty workspace and an explanation. Saving a new edit replaces
|
||||
that saved data. See [the deck model and persistence contract](docs/research-decks.md).
|
||||
|
||||
## WebMCP
|
||||
|
||||
A WebMCP-enabled browser exposes `list_decks`, `get_deck`, `set_deck`,
|
||||
`select_deck`, `delete_deck`, `get_column_posts`, and `load_more_column` on
|
||||
both deck routes. Start with `list_decks` to discover deck IDs and available
|
||||
relay profiles. `set_deck` creates a new deck when `deckId` is omitted; supply
|
||||
an existing ID to replace that deck's complete ordered columns and activate it.
|
||||
|
||||
Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the
|
||||
app on `http://localhost:3000` or `http://127.0.0.1:3000`. Use the
|
||||
local app. Use the
|
||||
[Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension)
|
||||
to list and invoke tools. Try `search_posts` with
|
||||
`{"q":"TypeScript","lang":"ja","product":"Latest"}`.
|
||||
|
||||
This prototype uses native `document.modelContext` through `usewebmcp`; it
|
||||
does not initialize a polyfill or provide an external MCP transport. Browsers
|
||||
without the API keep the regular reader UI. Public deployment requires the
|
||||
appropriate browser support/origin trial and a secure context. See
|
||||
[tool contracts and verification](docs/webmcp-prototype.md) for details.
|
||||
to invoke tools. Registration uses native `document.modelContext` through
|
||||
`usewebmcp`; there is no polyfill or external MCP transport. Unsupported
|
||||
browsers retain the manual deck UI. See [tool contracts and verification](docs/webmcp-prototype.md).
|
||||
|
||||
Development binds to `127.0.0.1` by default. `dev:tailscale` binds to
|
||||
`0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it
|
||||
only on a trusted network and obtain the Tailscale address with
|
||||
`tailscale ip -4`.
|
||||
`0.0.0.0`, including LAN interfaces. Native WebMCP needs a secure context;
|
||||
use a Tailscale Serve HTTPS origin for remote access, and allow its exact
|
||||
hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process
|
||||
environment. HTTP and HTTPS origins have separate localStorage.
|
||||
|
||||
## NixOS service
|
||||
|
||||
The flake provides both a production package and a NixOS module. Import the
|
||||
module and configure the relay:
|
||||
The flake provides a production package and a NixOS module:
|
||||
|
||||
```nix
|
||||
{
|
||||
@@ -159,7 +135,6 @@ module and configure the relay:
|
||||
services.twitter-lite = {
|
||||
enable = true;
|
||||
relayBaseUrl = "http://127.0.0.1:6900";
|
||||
# profileName = "default";
|
||||
};
|
||||
}
|
||||
];
|
||||
@@ -170,13 +145,10 @@ module and configure the relay:
|
||||
|
||||
The service listens on `127.0.0.1:3000` by default. Set
|
||||
`services.twitter-lite.host` or `services.twitter-lite.port` to change the
|
||||
listener. The package can also be built directly with `nix build`.
|
||||
listener. Profiles are selected in column definitions, not service options.
|
||||
The package can also be built directly with `nix build`.
|
||||
|
||||
## Reliability
|
||||
|
||||
Bird uses X's internal GraphQL operations through the configured relay. Query
|
||||
IDs and response shapes can change without notice.
|
||||
|
||||
Conversation pages use Bird's current per-page chronological ordering. Keeping
|
||||
X's original ranked branch order is deferred until Bird exposes that order
|
||||
without expanding the relay surface.
|
||||
|
||||
+114
-108
@@ -1,134 +1,140 @@
|
||||
# Research decks
|
||||
|
||||
## Workspace interface
|
||||
|
||||
The deck occupies the viewport with a dark sidebar and horizontally arranged,
|
||||
independently scrolling columns. A compact toolbar names the active deck.
|
||||
The sidebar switches decks, adds columns, and jumps to a column; on mobile,
|
||||
it becomes a compact top bar. Column header menus expose editing, ordering,
|
||||
and deletion. Creation and editing use native modal dialogs with Escape and
|
||||
focus restoration. Tokens use a navy/blue palette and a system sans font.
|
||||
This replaces the original spacious Garden reader layout. Layout inspiration:
|
||||
[Twitter's TweetDeck design notes](https://blog.x.com/en_us/a/2012/designing-the-new-tweetdeck).
|
||||
|
||||
## Product direction
|
||||
|
||||
A research topic should become a TweetDeck-style workspace: columns represent
|
||||
questions or perspectives, collect posts across social platforms, and support
|
||||
AI summaries with references back to the evidence. The intended platforms are
|
||||
Twitter, Mastodon, Bluesky, Threads, and Nostr.
|
||||
A research topic becomes a TweetDeck-style workspace: columns represent
|
||||
questions or perspectives and will eventually collect posts across Twitter,
|
||||
Mastodon, Bluesky, Threads, and Nostr for AI summaries with source references.
|
||||
The current implementation supports Twitter only, with manually or
|
||||
WebMCP-authored decks. Built-in planning, summaries, and other connectors
|
||||
remain future work.
|
||||
|
||||
The first increment is a manually composed deck backed by Twitter. It makes
|
||||
the deck definition, column lifecycle, and post normalization concrete before
|
||||
adding AI or more connectors.
|
||||
## Workspace and column model
|
||||
|
||||
## Implemented boundary
|
||||
Both `/` and `/deck` render the same deck-only application. Separate reader,
|
||||
search, list, user-profile, and conversation pages have been removed.
|
||||
Original-post links open X.
|
||||
|
||||
- `src/features/decks/model.ts` validates a versioned deck definition: name,
|
||||
ordered columns with stable IDs, and provider-specific search conditions.
|
||||
At most six columns are loaded, bounding concurrent first-page requests.
|
||||
- `src/features/platforms/types.ts` defines the display/evidence record without
|
||||
importing Bird: stable key, platform, native identity, original URL, text,
|
||||
author, optional publication time, media, and quoted post.
|
||||
- `src/features/platforms/twitter.ts` maps existing reader posts into that
|
||||
record. Keys use `twitter:<id>` rather than the author's mutable handle.
|
||||
Raw provider responses and credentials do not enter the common record.
|
||||
- `ResearchColumn` uses the existing Twitter search hook and server functions.
|
||||
Equal search conditions share the reader's query cache. Distinct conditions
|
||||
have independent loading/error/pagination state. Profile changes invalidate
|
||||
queries through the existing profile switcher.
|
||||
- `ResearchPostCard` only consumes the common record. Provider-specific detail
|
||||
routes remain in the existing Twitter reader. Original-post links, media,
|
||||
and one level of quotes are shown in the deck; engagement metrics and
|
||||
Twitter article previews are not normalized yet.
|
||||
`src/features/decks/model.ts` validates a version-2 workspace containing
|
||||
`activeDeckId` and one or more named decks. Each deck has a stable ID and up to
|
||||
six ordered columns. Only the active deck mounts its columns. The UI supports
|
||||
creating, selecting, renaming, and deleting deck profiles; the last deck cannot
|
||||
be deleted. Deleting the active deck selects the first remaining deck.
|
||||
|
||||
The current source is a single Twitter search per column. Its `platform`
|
||||
discriminator is the extension point for another real connector, not a claim
|
||||
that five adapters already work. Twitter's raw syntax, Top/Latest, and follows
|
||||
filter are not requirements imposed on the other platforms. Unsupported
|
||||
source definitions fail validation instead of silently dropping conditions.
|
||||
Each column has a stable ID, title, required `profileName`, and one source:
|
||||
|
||||
The existing reader and its active-feed WebMCP tools remain separate from the
|
||||
deck. Mounting multiple reader `PostFeed` components would register conflicting
|
||||
active-feed tools; deck columns therefore use the search hook directly.
|
||||
On `/deck`, `get_deck` and `set_deck` expose the definition once local storage
|
||||
has loaded. They unregister when leaving the route.
|
||||
| Source kind | Conditions |
|
||||
| --- | --- |
|
||||
| `search` | Native Twitter `query`, `product` (`Top`/`Latest`), and `following` |
|
||||
| `user` | `target`: handle or X/Twitter profile URL, normalized to a handle |
|
||||
| `list` | `target`: numeric ID or X/Twitter list URL, normalized to an ID |
|
||||
|
||||
All sources currently require `platform: "twitter"`. Unsupported definitions
|
||||
fail validation. Column IDs must be unique within a deck, and deck IDs within
|
||||
the workspace. Manual edits and agent tools use the same final schema.
|
||||
|
||||
`profileName` is the relay account binding, distinct from a named deck profile.
|
||||
The editor discovers names through `/profiles` and lists through the selected
|
||||
profile. A column's profile can be changed independently. Every feed and list
|
||||
request carries an explicit profile name; the server confirms it still exists.
|
||||
Deleted profiles and unavailable discovery produce errors instead of falling
|
||||
back to another account. There is no browser-wide profile selection.
|
||||
|
||||
The complete source and profile participate in query-cache identity. Equal
|
||||
conditions on the same profile share loaded pages; distinct profiles retain
|
||||
separate results and cursors. Columns have independent refresh, pagination,
|
||||
and error state. Pagination and refresh are manual, with no polling.
|
||||
|
||||
## Platform boundary
|
||||
|
||||
`src/features/platforms/types.ts` defines the display/evidence record without
|
||||
Bird imports: stable key, platform, native identity, original URL, text,
|
||||
author, optional publication time, media, and quoted post. The Twitter mapper
|
||||
uses `twitter:<id>` keys rather than mutable author handles. Raw responses
|
||||
and credentials do not enter this record. Cards consume the normalized record;
|
||||
engagement metrics and Twitter article previews are not normalized yet.
|
||||
|
||||
The source union is the extension point for future connectors. Twitter search
|
||||
syntax and ranking controls are provider-specific. When implementing another
|
||||
connector, add its real schema and server operation, normalize stable identity,
|
||||
and bind connection details into cache identity. Multiple sources in one
|
||||
column should wait until a second connector exercises that need; each source
|
||||
must retain its own opaque continuation and error state.
|
||||
|
||||
## Persistence
|
||||
|
||||
The workspace is stored under `twitter-lite-research-deck` in localStorage,
|
||||
including all deck definitions and the active selection. It stores conditions
|
||||
and relay profile names, not credentials, posts, summaries, or cursors.
|
||||
Reloading fetches first pages of the selected deck. SSR and the first browser
|
||||
render show a loading state until storage has been read.
|
||||
|
||||
There is no automatic migration of the former single-deck format, which did
|
||||
not pin profiles to columns. Invalid or older saved data remains untouched
|
||||
while the UI presents an empty workspace and an error. An explicit saved edit
|
||||
replaces it. Storage failures are visible: changes still apply in the current
|
||||
tab, but persistence failures mean they will be lost on reload. Tabs and
|
||||
devices do not synchronize; the last write to an origin's localStorage wins.
|
||||
HTTP and HTTPS origins maintain separate workspaces.
|
||||
|
||||
## Deck WebMCP tools
|
||||
|
||||
`get_deck({})` returns the current definition (including column IDs) and any
|
||||
storage error. `set_deck` replaces the complete ordered definition and closes
|
||||
unsaved editor forms. Read before editing, keep IDs of retained columns, and
|
||||
include every column you want to keep. Omit IDs for new columns. An empty array
|
||||
clears the deck. The same deck schema validates the entire input before changes.
|
||||
Both deck routes expose workspace management and active-column reading.
|
||||
`list_decks` discovers definitions and available relay profiles; `get_deck`
|
||||
reads a specific or active deck. `set_deck` creates or replaces and activates a
|
||||
deck; `select_deck` and `delete_deck` operate by ID. `get_column_posts` and
|
||||
`load_more_column` read or paginate columns in the active deck. See the
|
||||
[full tool contracts](webmcp-prototype.md).
|
||||
|
||||
For example, after discovering a relay profile named `main`, create a deck:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "WebMCPの反応",
|
||||
"columns": [
|
||||
{ "title": "日本語", "source": { "query": "WebMCP lang:ja" } },
|
||||
{ "title": "海外の話題", "source": { "query": "WebMCP lang:en", "product": "Top" } }
|
||||
{
|
||||
"title": "日本語",
|
||||
"profileName": "main",
|
||||
"source": { "kind": "search", "query": "WebMCP lang:ja" }
|
||||
},
|
||||
{
|
||||
"title": "開発者",
|
||||
"profileName": "main",
|
||||
"source": { "kind": "user", "target": "@example" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`source.platform` defaults to `twitter`, `product` to `Latest`, and `following`
|
||||
to false. `set_deck` returns the applied definition and `persisted: true` before
|
||||
post loading completes. It does not claim that searches succeeded. A storage
|
||||
failure returns `isError: true` and explains that the in-memory change was
|
||||
applied but will be lost on reload. Invalid input changes neither UI nor storage.
|
||||
Omitting `deckId` creates a deck. To edit, read first and include its `deckId`
|
||||
and every column to retain; keep existing column IDs. Omitted columns are
|
||||
removed and omitted column IDs are generated. Post loading is asynchronous,
|
||||
so a successful save does not mean the upstream requests succeeded.
|
||||
|
||||
Native WebMCP needs a supported browser and a secure context. Use the HTTPS
|
||||
Tailscale Serve origin rather than an HTTP tailnet IP. For Vite, allow that exact
|
||||
hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the dev process's
|
||||
environment. HTTP and HTTPS origins have separate browser-local deck storage.
|
||||
## Future AI work
|
||||
|
||||
## Persistence and fetching
|
||||
|
||||
One deck is saved under `twitter-lite-research-deck` in localStorage. It contains
|
||||
only conditions and names, not posts, summaries, credentials, or cursors.
|
||||
SSR and the initial browser render show a loading state before reading it.
|
||||
Invalid saved data is retained until the user explicitly saves an edit. Storage
|
||||
read/write failures are surfaced in the UI.
|
||||
|
||||
The selected relay profile is browser-wide, not pinned to a column. Reopening
|
||||
a deck uses that current profile and fetches first pages. Pagination is manual;
|
||||
there is no polling or background collection. Tabs do not synchronize deck
|
||||
edits; the last edit written to localStorage wins. LocalStorage is an initial
|
||||
single-browser workspace, not the eventual research archive.
|
||||
|
||||
## Adding the second platform
|
||||
|
||||
1. Add a real provider-specific source schema and a validated server-side
|
||||
search operation. Keep authentication on the server. Extend the source
|
||||
union and put dispatch at the feed boundary, outside the post card.
|
||||
2. Implement normalization with stable provider identity. For Mastodon, do not
|
||||
treat an instance-local numeric ID as globally unique; for Bluesky prefer
|
||||
the canonical record identity; for Nostr use event identity. The human
|
||||
original-post URL and the deduplication identity are separate fields.
|
||||
3. Add a connection reference when per-column accounts, Mastodon instances,
|
||||
or Nostr relay sets are introduced. Include it in query-cache identity.
|
||||
4. Support multiple sources within a column only when a second connector can
|
||||
exercise it. Each source needs its own continuation and error state. Do not
|
||||
merge opaque upstream cursors into one cross-platform cursor or claim a
|
||||
globally complete chronological feed from separately ranked searches.
|
||||
|
||||
Search capability is connection-dependent. Mastodon documents that status
|
||||
search depends on the instance's search backend and authentication:
|
||||
[Mastodon search API](https://docs.joinmastodon.org/methods/search/).
|
||||
Nostr's full-text search is an optional relay capability:
|
||||
[NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md).
|
||||
Validate actual connection capabilities when those connectors are added.
|
||||
Threads authorization and Bluesky endpoint behavior must likewise be verified
|
||||
when implementing those adapters, not inferred from Twitter's contract.
|
||||
|
||||
## AI increment
|
||||
|
||||
The next product slice should turn a topic into validated column definitions,
|
||||
then let the user refine them. AI-generated conditions use the same schema as
|
||||
manual edits. Summaries should reference a persisted collection snapshot
|
||||
(post keys, original URLs, retrieval time, source/query and profile context),
|
||||
so a later refresh does not change the evidence behind an earlier claim.
|
||||
LocalStorage of conditions alone does not provide this evidence store.
|
||||
|
||||
ACP or Codex app-server can connect the planner to the application, but they
|
||||
are not part of the platform data model or this implementation.
|
||||
A planner can generate definitions through the existing schema and tools.
|
||||
Summaries will need persisted collection snapshots: post identity and URL,
|
||||
retrieval time, source conditions, and profile context. Saved conditions alone
|
||||
do not preserve the evidence behind a summary. ACP or Codex app-server may
|
||||
connect a future planner, but neither is part of this implementation.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests cover normalization, source/definition validation, ordering and
|
||||
storage errors. Playwright tests use the existing standalone mock relay through
|
||||
the real server functions to verify independent pagination, retry, editing,
|
||||
ordering, reload, deletion/undo, and invalid saved data. They also check viewport
|
||||
widths 320/375/414/768 and run Axe on the populated deck.
|
||||
|
||||
No live SNS search is required by these tests.
|
||||
Unit tests cover normalization, source/workspace validation, ordering,
|
||||
profile-specific caching and pagination, profile discovery failures, and
|
||||
storage behavior. Playwright uses a standalone mock relay through real server
|
||||
functions to exercise deck switching, column/profile editing, list selection,
|
||||
pagination, persistence, and native WebMCP. Accessibility checks run on the
|
||||
workspace. Automated tests do not need a live SNS search.
|
||||
|
||||
+81
-96
@@ -1,98 +1,88 @@
|
||||
# WebMCP prototype
|
||||
|
||||
## Scope and design
|
||||
## Scope and registration
|
||||
|
||||
Expose the existing intentional reader to agents through three React-owned
|
||||
reader tools. The deck additionally exposes `get_deck` and `set_deck` on `/deck`;
|
||||
see [deck tool contracts](research-decks.md#deck-webmcp-tools).
|
||||
Search remains available across routes. Feed tools are registered only
|
||||
while a valid timeline, search, or conversation is open. Returning home or to
|
||||
an empty search/list page removes the feed tools. Unsupported browsers do not
|
||||
register tools or fetch posts automatically.
|
||||
The deck workspace exposes seven React-owned tools on `/` and `/deck`.
|
||||
`usewebmcp` owns native browser registration and cleanup. There is no polyfill
|
||||
or external MCP transport. Unsupported browsers retain the manual UI. Tools
|
||||
are enabled after local storage loads; relay-profile discovery may still be
|
||||
pending, which `list_decks` reports as `profiles: null`.
|
||||
|
||||
`usewebmcp` 5.1.0 owns browser registration and cleanup. No polyfill is
|
||||
initialized. The root search tool navigates through TanStack Router and awaits
|
||||
the same query options as the visible feed. Completed data is reused through
|
||||
`ensureInfiniteQueryData`; an invalidated or in-progress query is awaited
|
||||
through `fetchInfiniteQuery`, including refreshes after a profile change.
|
||||
Reading the feed uses its current React Query result. Continuation and the
|
||||
scroll observer use `fetchNextPage({ cancelRefetch: false })` to join an
|
||||
existing request. A feed change during continuation returns an error instead
|
||||
of reporting old results as belonging to the new feed.
|
||||
The former `search_posts`, `get_loaded_posts`, and `load_more_posts` tools and
|
||||
standalone reader routes have been removed. Agents manage named decks and
|
||||
address columns explicitly, including their bound relay profiles.
|
||||
|
||||
## Tool contracts
|
||||
## Workspace tools
|
||||
|
||||
### `search_posts`
|
||||
|
||||
Search X, show the criteria and results in the UI, and return the first slice
|
||||
after retrieval. Searches use the currently selected relay profile.
|
||||
|
||||
Inputs match the existing search controls:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| Tool | Input | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `q` | empty | Search text, including raw X search syntax |
|
||||
| `from` | empty | Author handle, optionally prefixed with `@` |
|
||||
| `since` | empty | Inclusive `YYYY-MM-DD` date using X search semantics |
|
||||
| `until` | empty | Exclusive `YYYY-MM-DD` date, later than `since` |
|
||||
| `lang` | `all` | `all`, `ja`, or `en` |
|
||||
| `content` | `all` | `all`, `images`, `videos`, or `links` |
|
||||
| `excludeReplies`, `excludeReposts` | `false` | Exclusions |
|
||||
| `product` | `Latest` | `Latest` or `Top` |
|
||||
| `following` | `false` | Restrict to followed accounts |
|
||||
| `list_decks` | `{}` | Return all deck definitions, `activeDeckId`, available `profiles`, and `storageError` |
|
||||
| `get_deck` | Optional `deckId` | Read a saved deck; omitted ID selects the active deck |
|
||||
| `set_deck` | Optional `deckId`, required `title` and `columns` | Create when ID is omitted; otherwise replace an existing deck, then activate it |
|
||||
| `select_deck` | `deckId` | Activate a saved deck and persist the selection |
|
||||
| `delete_deck` | `deckId` | Permanently remove the definition; cannot delete the last deck |
|
||||
|
||||
Supply `q` or `from`. Unknown properties and invalid inputs fail before
|
||||
navigation. Existing query construction validates dates, handles, and the
|
||||
512-character compiled-query limit. A concurrent search fails with an
|
||||
actionable message. Navigating elsewhere during a search prevents a stale
|
||||
success response.
|
||||
`set_deck` accepts at most six columns. Each requires `title`, `profileName`
|
||||
from `list_decks`, and a discriminated `source`. Its `kind` is `search`, `user`,
|
||||
or `list`; `platform` defaults to `twitter`. Searches require `query`, with
|
||||
`product` defaulting to `Latest` and `following` to false. User and list sources
|
||||
require `target` (handle/profile URL or list ID/URL). Unknown source fields,
|
||||
invalid targets, duplicate column IDs, and unknown profiles fail before saving.
|
||||
|
||||
### `get_loaded_posts`
|
||||
Read before editing. Include every column to keep; omitted columns are removed.
|
||||
Preserve IDs for retained columns and omit IDs for new ones. An empty columns
|
||||
array clears a deck. A supplied deck ID must already exist. Successful mutation
|
||||
closes unsaved editor forms. `set_deck` returns the applied definition,
|
||||
`persisted: true`, and `posts: "loading-asynchronously"`; searches can fail
|
||||
independently after the save succeeds.
|
||||
|
||||
Return a slice of the current feed without a network request. Accepts
|
||||
`offset` (default 0, nonnegative integer) and `limit` (default 20, integer
|
||||
1–50). Conversation results place the selected post first, once.
|
||||
Deleting the active deck selects the first remaining one. Deletion has no
|
||||
workspace-tool undo. Storage failures return `isError: true` explaining that
|
||||
the mutation applied in memory but could not be persisted. Saving replaces
|
||||
invalid or legacy saved data; there is no automatic legacy migration.
|
||||
|
||||
The response includes `status` (`loading`, `ready`, or `error`), `loading`,
|
||||
and `error` information alongside the common result. This is a snapshot of
|
||||
all loaded posts, not just posts inside the viewport.
|
||||
## Column tools
|
||||
|
||||
### `load_more_posts`
|
||||
`get_column_posts` accepts `columnId`, `offset` (default 0, nonnegative integer),
|
||||
and `limit` (default 20, integer 1–50). It reads already loaded posts without a
|
||||
network request. Only columns mounted in the active deck are available; select
|
||||
the deck and allow it to render first.
|
||||
|
||||
Accepts `{}`. Wait for one continuation, append it to the UI, and return up to
|
||||
20 newly appended posts. An in-progress scroll request is shared. Initial
|
||||
loading or a feed refresh must finish first. A failed continuation can be retried explicitly.
|
||||
At the end of the feed the result is an empty `posts` array and `hasMore: false`.
|
||||
`load_more_column` accepts `columnId`. It loads or retries one continuation
|
||||
using that column's bound profile. Wait for its initial load or refresh before
|
||||
calling. Concurrent pagination joins the existing request. If the deck or
|
||||
column changes during the request, the tool reports an error instead of
|
||||
returning results under the new identity. At the end, it returns no appended
|
||||
posts and `hasMore: false`.
|
||||
|
||||
Both return:
|
||||
|
||||
- `column`: ID, title, bound profile, and source definition
|
||||
- `status`: `loading`, `ready`, or `error`, plus `loading` and `error` details
|
||||
- `posts`: normalized records with original URLs, identity, text, author,
|
||||
and available media/quotes
|
||||
- `loadedCount`, `offset`, and `nextOffset` for slicing deduplicated cached posts
|
||||
- `hasMore`: whether the current feed has an upstream continuation
|
||||
|
||||
Continuation returns up to 20 newly appended posts. Use `nextOffset` with
|
||||
`get_column_posts` to read additional already loaded records, and
|
||||
`load_more_column` for an upstream page. These are cache snapshots; manual UI
|
||||
refreshes and pagination can change the available records.
|
||||
|
||||
## Results and errors
|
||||
|
||||
Successful tool results contain JSON in an MCP text content block:
|
||||
Tools return JSON in an MCP text content block. Execution failures set
|
||||
`isError: true` with `code`, `message`, and `retryable`. Schema failures use
|
||||
`invalid-input`; other tool failures use `tool-error`. Column snapshots report
|
||||
underlying relay failures through their `error` field. An empty successful
|
||||
query is not an error.
|
||||
|
||||
- `request`: active feed kind and normalized criteria.
|
||||
- `posts`: `id`, `author` (username/name), `text`, `textTruncated`, optional
|
||||
`createdAt`, and the original X `url`.
|
||||
- `loadedCount`: number of deduplicated, loaded posts.
|
||||
- `offset`: start of this slice within the loaded feed.
|
||||
- `nextOffset`: next unread offset within the already loaded feed, or `null`.
|
||||
- `hasMore`: whether the last loaded page has an upstream continuation.
|
||||
`list_decks`, `get_deck`, and `get_column_posts` carry `readOnlyHint: true`.
|
||||
The other tools change local UI or storage. `delete_deck` carries
|
||||
`destructiveHint: true`; tools returning external posts mark them untrusted.
|
||||
Annotations are metadata, not authorization controls. No tool writes to X.
|
||||
|
||||
Use `get_loaded_posts` with `nextOffset` for loaded posts outside a returned
|
||||
slice; use `load_more_posts` for an upstream continuation. Scrolling can load
|
||||
more posts independently, so these fields describe a snapshot. Post text is
|
||||
capped at 2,000 characters per post and explicitly marked when truncated;
|
||||
the source URL remains available. Media, quote bodies, and article previews
|
||||
are not included in this initial text-oriented tool response.
|
||||
|
||||
Failures use `isError: true` and JSON containing `code`, `message`, and
|
||||
`retryable`. Existing relay error details are preserved. A successful empty
|
||||
search is not an error.
|
||||
|
||||
All tools mark returned external content with `untrustedContentHint: true`.
|
||||
Only `get_loaded_posts` has `readOnlyHint: true`: search and continuation
|
||||
change local UI state. No tool performs X write actions. Annotations are
|
||||
metadata, not authorization controls.
|
||||
|
||||
## Verification and limits
|
||||
## Verification and browser setup
|
||||
|
||||
```sh
|
||||
nix develop -c pnpm test
|
||||
@@ -100,24 +90,19 @@ nix develop -c pnpm typecheck
|
||||
nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts
|
||||
```
|
||||
|
||||
The E2E file enables native Chromium WebMCP/testing flags and uses
|
||||
`navigator.modelContextTesting` to invoke actual registered tools. The
|
||||
standalone mock relay supplies deterministic data through the production
|
||||
server-function boundary. Tests do not contact X or a personal relay.
|
||||
The E2E tests enable native Chromium WebMCP/testing flags and use
|
||||
`navigator.modelContextTesting` to invoke actual registered tools. A mock
|
||||
relay supplies deterministic responses through real server functions.
|
||||
|
||||
For interactive testing, enable Chrome's WebMCP testing flag, restart, run
|
||||
`nix develop -c pnpm dev`, and open the local site with Model Context Tool
|
||||
Inspector. Registration uses `document.modelContext`; availability is checked
|
||||
at mount, so reload after changing browser support. No production origin-trial
|
||||
token or external MCP-client bridge is configured by this prototype.
|
||||
For interactive testing, enable `chrome://flags/#enable-webmcp-testing`,
|
||||
restart Chrome, and use Model Context Tool Inspector on the app. Registration
|
||||
uses `document.modelContext`; reload after changing browser support. Native
|
||||
WebMCP requires a secure context: local loopback works for development; remote
|
||||
Tailscale access should use an HTTPS Serve origin. Allow the exact hostname
|
||||
through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process environment.
|
||||
HTTP and HTTPS have separate browser-local workspaces.
|
||||
|
||||
The hook does not forward the browser's execution AbortSignal to application
|
||||
callbacks. Browser cancellation therefore does not guarantee cancellation of
|
||||
the shared read request. Tools do detect navigation changes before returning
|
||||
their asynchronous results. Agent task-selection quality still needs manual
|
||||
evaluation with the consuming agent; deterministic browser tests verify the
|
||||
tool contracts and UI behavior.
|
||||
|
||||
Design references: [Chrome best practices](https://developer.chrome.com/docs/ai/webmcp/best-practices),
|
||||
[workflow design](https://developer.chrome.com/docs/ai/webmcp/build-tools),
|
||||
and [usewebmcp](https://github.com/WebMCP-org/npm-packages/tree/main/packages/usewebmcp).
|
||||
No production origin-trial token or external MCP-client bridge is configured.
|
||||
Browser cancellation does not guarantee cancellation of a shared feed request.
|
||||
Agent task-selection quality still needs evaluation with the consuming agent;
|
||||
automated browser tests verify contracts and UI behavior.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('home page has no accessibility violations', async ({
|
||||
a11y,
|
||||
homePage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect(homePage.articlesLocator).toHaveCount(0)
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('list picker has no accessibility violations', async ({
|
||||
a11y,
|
||||
listsPage,
|
||||
}) => {
|
||||
await listsPage.goTo()
|
||||
await expect(listsPage.articlesLocator).toHaveCount(0)
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
|
||||
test('list timeline has no accessibility violations', async ({
|
||||
a11y,
|
||||
listsPage,
|
||||
}) => {
|
||||
await listsPage.goTo()
|
||||
await listsPage.openList(/My E2E List.*10 メンバー/)
|
||||
await expect(listsPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('empty search page has no accessibility violations', async ({
|
||||
a11y,
|
||||
searchPage,
|
||||
}) => {
|
||||
await searchPage.goTo()
|
||||
await expect(searchPage.articlesLocator).toHaveCount(0)
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
|
||||
test('search results have no accessibility violations', async ({
|
||||
a11y,
|
||||
searchPage,
|
||||
}) => {
|
||||
await searchPage.goTo()
|
||||
await searchPage.search('AI lang:ja')
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
|
||||
test('open filters and active condition chips have no accessibility violations', async ({
|
||||
a11y,
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await searchPage.goTo()
|
||||
await searchPage.queryInputLocator.fill('TypeScript')
|
||||
await page.getByRole('button', { name: '投稿者' }).click()
|
||||
await page.getByLabel('投稿者').fill('@tan_stack')
|
||||
await page.getByRole('button', { name: '言語' }).click()
|
||||
await page.getByLabel('日本語').check()
|
||||
await expect(
|
||||
page.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
|
||||
).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('conversation has no accessibility violations', async ({
|
||||
a11y,
|
||||
threadPage,
|
||||
}) => {
|
||||
await threadPage.goTo('yuta', '1001')
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
test('user timeline has no accessibility violations', async ({
|
||||
a11y,
|
||||
userPage,
|
||||
}) => {
|
||||
await userPage.goTo('yuta')
|
||||
await expect(userPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const result = await a11y().analyze()
|
||||
expect(result.violations).toEqual([])
|
||||
})
|
||||
+4
-24
@@ -1,34 +1,14 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { HomePage } from './models/HomePage'
|
||||
import { ListsPage } from './models/ListsPage'
|
||||
import { SearchPage } from './models/SearchPage'
|
||||
import { ThreadPage } from './models/ThreadPage'
|
||||
import { UserPage } from './models/UserPage'
|
||||
|
||||
type Fixtures = {
|
||||
a11y: () => AxeBuilder
|
||||
homePage: HomePage
|
||||
listsPage: ListsPage
|
||||
searchPage: SearchPage
|
||||
threadPage: ThreadPage
|
||||
userPage: UserPage
|
||||
}
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
export const test = base.extend<{ a11y: () => AxeBuilder }>({
|
||||
a11y: async ({ page }, use) => {
|
||||
const makeAxeBuilder = () =>
|
||||
await use(() =>
|
||||
new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.disableRules(['meta-viewport'])
|
||||
|
||||
await use(makeAxeBuilder)
|
||||
.disableRules(['meta-viewport']),
|
||||
)
|
||||
},
|
||||
homePage: ({ page }, use) => use(new HomePage(page)),
|
||||
listsPage: ({ page }, use) => use(new ListsPage(page)),
|
||||
searchPage: ({ page }, use) => use(new SearchPage(page)),
|
||||
threadPage: ({ page }, use) => use(new ThreadPage(page)),
|
||||
userPage: ({ page }, use) => use(new UserPage(page)),
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
|
||||
+174
-100
@@ -5,13 +5,28 @@ async function addColumn(
|
||||
page: Page,
|
||||
title: string,
|
||||
query: string,
|
||||
top = false,
|
||||
profile = 'e2e',
|
||||
kind = 'search',
|
||||
) {
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
|
||||
await editor.getByLabel('カラム名', { exact: true }).fill(title)
|
||||
await editor.getByLabel('Twitterの検索条件').fill(query)
|
||||
if (top) await editor.getByLabel('表示順').selectOption('Top')
|
||||
await editor
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption(profile)
|
||||
await editor
|
||||
.getByRole('combobox', { name: 'カラムの種類', exact: true })
|
||||
.selectOption(kind)
|
||||
await editor
|
||||
.getByLabel(
|
||||
kind === 'search'
|
||||
? 'Twitterの検索条件'
|
||||
: kind === 'user'
|
||||
? 'ユーザー'
|
||||
: 'リスト',
|
||||
{ exact: true },
|
||||
)
|
||||
.fill(query)
|
||||
await editor.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(
|
||||
page
|
||||
@@ -22,63 +37,114 @@ async function addColumn(
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/deck')
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('creates independent columns, edits, reorders, and restores saved conditions', async ({
|
||||
test('binds identical searches to independent profiles through paging, editing and reload', async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await page.getByRole('button', { name: '名前を変更' }).click()
|
||||
await page.getByLabel('調査テーマ・デッキ名').fill('WebMCPの反応')
|
||||
await page.getByRole('button', { name: '名前を保存' }).click()
|
||||
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
|
||||
await addColumn(page, '話題の投稿', 'WebMCP', true)
|
||||
const first = page.getByRole('region', { name: '日本語の反応', exact: true })
|
||||
const second = page.getByRole('region', { name: '話題の投稿', exact: true })
|
||||
await addColumn(page, '通常の接続', 'same-query')
|
||||
await addColumn(page, '別の接続', 'same-query', 'alternate')
|
||||
const first = page.getByRole('region', { name: '通常の接続', exact: true })
|
||||
const second = page.getByRole('region', { name: '別の接続', exact: true })
|
||||
await expect(
|
||||
first.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
second.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await first.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(first.locator('article')).toHaveCount(2)
|
||||
await expect(second.locator('article')).toHaveCount(1)
|
||||
await second.getByRole('button', { name: '話題の投稿を左へ' }).click()
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'話題の投稿',
|
||||
'日本語の反応',
|
||||
])
|
||||
await second.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(
|
||||
second.getByText('alternate · Latest · all page 2'),
|
||||
).toBeVisible()
|
||||
await second.locator('summary[aria-label="別の接続の操作"]').click()
|
||||
await second.getByRole('button', { name: '編集', exact: true }).click()
|
||||
await page
|
||||
.getByRole('form', { name: 'カラムを編集' })
|
||||
.getByLabel('カラム名', { exact: true })
|
||||
.fill('公式の投稿')
|
||||
await page.getByLabel('Twitterの検索条件').fill('from:OpenAI')
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption('e2e')
|
||||
await page.getByRole('button', { name: '変更を保存' }).click()
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||
'WebMCPの反応',
|
||||
await expect(
|
||||
second.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'公式の投稿',
|
||||
'日本語の反応',
|
||||
])
|
||||
const official = page.getByRole('region', { name: '公式の投稿', exact: true })
|
||||
await expect(official.locator('.deck-query')).toHaveText('from:OpenAI')
|
||||
await expect(official.locator('article')).toHaveCount(1)
|
||||
await official.getByRole('button', { name: '公式の投稿を削除' }).click()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(1)
|
||||
await page.getByRole('button', { name: '元に戻す' }).click()
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'公式の投稿',
|
||||
'日本語の反応',
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
await page.reload()
|
||||
await expect(
|
||||
second.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(first.locator('article')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('keeps other columns usable when pagination fails and retries only that column', async ({
|
||||
test('creates, switches, renames and deletes persisted deck profiles', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: '名前を変更' }).click()
|
||||
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
|
||||
await page.getByRole('button', { name: '名前を保存' }).click()
|
||||
await addColumn(page, '最初の観点', 'first-deck')
|
||||
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
|
||||
await page.getByLabel('新しいデッキ名').fill('別の調査')
|
||||
await page.getByRole('button', { name: '作成', exact: true }).click()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(0)
|
||||
await addColumn(page, '別の観点', 'second-deck', 'alternate')
|
||||
await page
|
||||
.getByLabel('デッキプロファイル', { exact: true })
|
||||
.selectOption({ label: '最初の調査' })
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
||||
await page
|
||||
.getByLabel('デッキプロファイル', { exact: true })
|
||||
.selectOption({ label: '別の調査' })
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
|
||||
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
|
||||
await page.getByRole('button', { name: '削除する', exact: true }).click()
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
|
||||
await expect(
|
||||
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
|
||||
).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('reads user and list timelines within columns and reorders, deletes and restores them', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addColumn(page, 'アカウント', 'yuta', 'alternate', 'user')
|
||||
await addColumn(page, 'リストの投稿', '1234567890', 'e2e', 'list')
|
||||
const user = page.getByRole('region', { name: 'アカウント', exact: true })
|
||||
const list = page.getByRole('region', { name: 'リストの投稿', exact: true })
|
||||
await expect(user.getByText('alternate profile page 1')).toBeVisible()
|
||||
await expect(list.getByText('list page 1')).toBeVisible()
|
||||
await list.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
await expect(list.getByText('list page 2')).toBeVisible()
|
||||
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
|
||||
await list.getByRole('button', { name: 'リストの投稿を左へ' }).click()
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'リストの投稿',
|
||||
'アカウント',
|
||||
])
|
||||
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
|
||||
await list.getByRole('button', { name: 'リストの投稿を削除' }).click()
|
||||
await page.getByRole('button', { name: '元に戻す' }).click()
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'リストの投稿',
|
||||
'アカウント',
|
||||
])
|
||||
await page.reload()
|
||||
await expect(user.getByText('alternate profile page 1')).toBeVisible()
|
||||
await expect(list.getByText('list page 1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps other columns usable when pagination fails and retries that column', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
|
||||
await addColumn(page, '別の検索', 'independent-deck', true)
|
||||
await addColumn(page, '別の検索', 'independent-deck')
|
||||
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
|
||||
const other = page.getByRole('region', { name: '別の検索', exact: true })
|
||||
await failed.getByRole('button', { name: '続きを読み込む' }).click()
|
||||
@@ -88,15 +154,15 @@ test('keeps other columns usable when pagination fails and retries only that col
|
||||
await expect(other.locator('article')).toHaveCount(2)
|
||||
await failed.getByRole('button', { name: '再試行' }).click()
|
||||
await expect(failed.locator('article')).toHaveCount(2)
|
||||
await expect(failed.getByRole('alert')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('keeps columns within narrow viewports and accessible', async ({
|
||||
test('keeps the deck and editor accessible within narrow viewports', async ({
|
||||
page,
|
||||
a11y,
|
||||
}, testInfo) => {
|
||||
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
|
||||
await addColumn(page, '海外の反応', 'WebMCP lang:en', true)
|
||||
await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate')
|
||||
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user')
|
||||
for (const width of [320, 375, 414, 768]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
expect(
|
||||
@@ -104,16 +170,36 @@ test('keeps columns within narrow viewports and accessible', async ({
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollHeight <= innerHeight,
|
||||
),
|
||||
).toBe(true)
|
||||
const board = await page.locator('.deck-board').boundingBox()
|
||||
expect(board).not.toBeNull()
|
||||
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901)
|
||||
}
|
||||
await page.setViewportSize({
|
||||
width: testInfo.project.name === 'desktop' ? 1440 : 393,
|
||||
height: 900,
|
||||
})
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: '日本語の反応', exact: true })
|
||||
.locator('article'),
|
||||
).toHaveCount(1)
|
||||
if (testInfo.project.name === 'desktop') {
|
||||
await page
|
||||
.getByRole('navigation', { name: 'カラム一覧' })
|
||||
.getByRole('button', { name: '公式アカウント' })
|
||||
.click()
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: '公式アカウント', exact: true })
|
||||
.locator('.deck-column-results'),
|
||||
).toBeFocused()
|
||||
}
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('deck-populated.png'),
|
||||
fullPage: true,
|
||||
})
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
expect((await a11y().analyze()).violations).toEqual([])
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('deck.png'),
|
||||
@@ -121,9 +207,22 @@ test('keeps columns within narrow viewports and accessible', async ({
|
||||
})
|
||||
})
|
||||
|
||||
test('does not overwrite an unreadable saved deck on reload', async ({
|
||||
test('closes the column editor with Escape and restores focus without applying edits', async ({
|
||||
page,
|
||||
}) => {
|
||||
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
|
||||
await opener.click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
await expect(opener).toBeFocused()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(0)
|
||||
await opener.click()
|
||||
await expect(page.getByLabel('カラム名', { exact: true })).toHaveValue('')
|
||||
})
|
||||
|
||||
test('does not overwrite unreadable storage on reload', async ({ page }) => {
|
||||
await page.evaluate(() =>
|
||||
localStorage.setItem('twitter-lite-research-deck', '{broken'),
|
||||
)
|
||||
@@ -136,58 +235,33 @@ test('does not overwrite an unreadable saved deck on reload', async ({
|
||||
).toBe('{broken')
|
||||
})
|
||||
|
||||
test('keeps the six-column limit when undo and add overlap', async ({
|
||||
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.evaluate(() =>
|
||||
localStorage.setItem(
|
||||
'twitter-lite-research-deck',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
title: '上限の確認',
|
||||
columns: Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `column-${index}`,
|
||||
title: `観点${index}`,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
query: `limit-${index}`,
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
),
|
||||
)
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'カラムを追加', exact: true }),
|
||||
).toBeDisabled()
|
||||
await page.getByRole('button', { name: '観点0を削除' }).click()
|
||||
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled()
|
||||
await page.getByLabel('カラム名', { exact: true }).fill('追加の観点')
|
||||
await page.getByLabel('Twitterの検索条件').fill('new-limit')
|
||||
await page.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(6)
|
||||
await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled()
|
||||
await page.reload()
|
||||
await expect(page.locator('.deck-column')).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('creates columns without the secure-context-only randomUUID API', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() =>
|
||||
Object.defineProperty(crypto, 'randomUUID', { value: undefined }),
|
||||
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
|
||||
await page
|
||||
.getByRole('combobox', { name: 'カラムの種類', exact: true })
|
||||
.selectOption('list')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'My E2E List', exact: true }),
|
||||
).toBeVisible()
|
||||
await page
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption('alternate')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'My E2E List', exact: true }),
|
||||
).toHaveCount(0)
|
||||
await page
|
||||
.getByRole('button', { name: 'Alternate List', exact: true })
|
||||
.click()
|
||||
await expect(page.getByLabel('リスト', { exact: true })).toHaveValue(
|
||||
'1234567890',
|
||||
)
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await addColumn(page, 'HTTP接続からの調査', 'http-deck')
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: 'HTTP接続からの調査', exact: true })
|
||||
.locator('article'),
|
||||
).toHaveCount(1)
|
||||
.getByRole('region', { name: '購読リスト', exact: true })
|
||||
.getByText('list page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { expect, test } from '../fixtures'
|
||||
|
||||
let consoleErrors: string[]
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors = []
|
||||
pageErrors = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
expect(consoleErrors, 'browser console errors').toEqual([])
|
||||
expect(pageErrors, 'uncaught browser errors').toEqual([])
|
||||
})
|
||||
|
||||
test('requires intent and infinitely loads a user timeline', async ({
|
||||
homePage,
|
||||
page,
|
||||
userPage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
|
||||
'content',
|
||||
'no-referrer',
|
||||
)
|
||||
await expect(homePage.articlesLocator).toHaveCount(0)
|
||||
await expect(homePage.ambientLinksLocator).toHaveCount(0)
|
||||
|
||||
await homePage.displayUser('@yuta')
|
||||
|
||||
await expect(page).toHaveURL(/\/yuta$/)
|
||||
await expect(page.getByText('user page 1')).toBeVisible()
|
||||
await expect(page.getByText('user page 2')).toBeVisible()
|
||||
await expect(userPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-user.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('switches the relay profile and reloads visible content', async ({
|
||||
homePage,
|
||||
page,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
|
||||
const profile = page.getByLabel('プロファイル')
|
||||
await expect(profile).toHaveValue('e2e')
|
||||
await homePage.displayUser('@yuta')
|
||||
await expect(page.getByText('user page 1')).toBeVisible()
|
||||
|
||||
await profile.selectOption('alternate')
|
||||
await expect(page.getByRole('status')).toHaveText('切り替えました。')
|
||||
|
||||
await expect(page.getByText('alternate profile page 1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the profile selector within narrow viewports', async ({
|
||||
homePage,
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [320, 375, 414, 768]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await homePage.goTo()
|
||||
await expect(page.getByLabel('プロファイル')).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth,
|
||||
),
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('selects an authenticated list and infinitely loads its timeline', async ({
|
||||
listsPage,
|
||||
page,
|
||||
}) => {
|
||||
await listsPage.goTo()
|
||||
await expect(listsPage.articlesLocator).toHaveCount(0)
|
||||
|
||||
await listsPage.openList(/My E2E List.*10 メンバー/)
|
||||
|
||||
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
|
||||
await expect(page.getByText('list page 1')).toBeVisible()
|
||||
await expect(page.getByText('list page 2')).toBeVisible()
|
||||
await expect(listsPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens a card detail and infinitely loads its conversation', async ({
|
||||
page,
|
||||
threadPage,
|
||||
userPage,
|
||||
}) => {
|
||||
await userPage.goTo('yuta')
|
||||
await userPage.openPostDetails('user page 1')
|
||||
|
||||
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
|
||||
await expect(threadPage.headingLocator).toBeVisible()
|
||||
await expect(threadPage.selectedPostLabelLocator).toBeVisible()
|
||||
await expect(page.getByText('user page 1')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-thread.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves a reply root and retries only the failed continuation', async ({
|
||||
page,
|
||||
threadPage,
|
||||
}) => {
|
||||
await threadPage.goTo('yuta', '2001')
|
||||
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
await expect(threadPage.continuationErrorLocator).toHaveCount(1)
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
|
||||
await threadPage.retryContinuation()
|
||||
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('ends a thread without loading a continuation when there are no replies', async ({
|
||||
page,
|
||||
threadPage,
|
||||
}) => {
|
||||
await threadPage.goTo('yuta', '3001')
|
||||
|
||||
await expect(page.getByText('no replies')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('searches Top posts from followed accounts', async ({
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await searchPage.goTo()
|
||||
await expect(searchPage.articlesLocator).toHaveCount(0)
|
||||
await searchPage.searchTopFromFollowing('AI lang:ja')
|
||||
await expect(searchPage.followingOnlyLocator).toBeChecked()
|
||||
|
||||
await expect(page).toHaveURL(/following=true/)
|
||||
await expect(page.getByText('Top · follows page 1')).toBeVisible()
|
||||
await expect(page.getByText('Top · follows page 2')).toBeVisible()
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('retains the first page through a pagination error and retries explicitly', async ({
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await searchPage.goTo()
|
||||
await expect(searchPage.articlesLocator).toHaveCount(0)
|
||||
await searchPage.search(`retry-${testInfo.project.name}`)
|
||||
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(searchPage.continuationErrorLocator).toHaveCount(1)
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
|
||||
|
||||
await searchPage.retryContinuation()
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
|
||||
searchPage,
|
||||
}) => {
|
||||
await searchPage.goTo()
|
||||
|
||||
await searchPage.tabTo(
|
||||
searchPage.page.getByRole('combobox', { name: 'プロファイル' }),
|
||||
)
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'デッキ' }))
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
|
||||
await searchPage.tabTo(
|
||||
searchPage.page.getByRole('link', { name: 'ユーザー' }),
|
||||
)
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
|
||||
await searchPage.tabTo(searchPage.queryInputLocator)
|
||||
await searchPage.tabTo(
|
||||
searchPage.page.getByRole('button', { name: '投稿者' }),
|
||||
)
|
||||
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '期間' }))
|
||||
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '言語' }))
|
||||
await searchPage.tabTo(
|
||||
searchPage.page.getByRole('button', { name: '投稿の種類' }),
|
||||
)
|
||||
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '除外' }))
|
||||
await searchPage.tabTo(
|
||||
searchPage.page.getByText('検索式を確認', { exact: true }),
|
||||
)
|
||||
await expect(searchPage.latestRankingLocator).toBeChecked()
|
||||
await searchPage.tabTo(searchPage.latestRankingLocator)
|
||||
await searchPage.tabTo(searchPage.followingOnlyLocator)
|
||||
await searchPage.tabTo(searchPage.searchButtonLocator)
|
||||
|
||||
await expect(searchPage.ambientLinksLocator).toHaveCount(0)
|
||||
await expect(searchPage.mutationButtonsLocator).toHaveCount(0)
|
||||
await expect(searchPage.mutationLinksLocator).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('removes loading rail animation for reduced motion', async ({
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await searchPage.goTo()
|
||||
await expect(searchPage.articlesLocator).toHaveCount(0)
|
||||
await searchPage.search(`slow-${testInfo.project.name}`)
|
||||
|
||||
const rail = searchPage.loadingRailLocator
|
||||
await expect(rail).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
rail.evaluate((element) =>
|
||||
getComputedStyle(element, '::after').getPropertyValue('animation-name'),
|
||||
),
|
||||
)
|
||||
.toBe('none')
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
+178
-259
@@ -1,26 +1,15 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, test } from '../fixtures'
|
||||
import { SearchPage } from '../models/SearchPage'
|
||||
|
||||
type NativeTesting = {
|
||||
listTools(): { name: string }[]
|
||||
executeTool(name: string, input: string): Promise<string | null>
|
||||
}
|
||||
|
||||
type ToolResult = {
|
||||
content: { type: string; text: string }[]
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
type FeedResult = {
|
||||
request: { kind: string; query: string; product: string; following: boolean }
|
||||
posts: { id: string; text: string; url: string }[]
|
||||
loadedCount: number
|
||||
offset: number
|
||||
nextOffset: number | null
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
||||
@@ -38,7 +27,6 @@ async function toolNames(page: Page) {
|
||||
.sort(),
|
||||
)
|
||||
}
|
||||
|
||||
async function executeTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
@@ -55,290 +43,139 @@ async function executeTool(
|
||||
if (result === null) throw new Error(`${name} returned no result`)
|
||||
return JSON.parse(result)
|
||||
}
|
||||
|
||||
async function executeFeedTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<FeedResult> {
|
||||
const result = await executeTool(page, name, input)
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true)
|
||||
expect(result.content[0]?.type).toBe('text')
|
||||
return JSON.parse(result.content[0]?.text ?? '')
|
||||
}
|
||||
const column = (title: string, profileName = 'e2e') => ({
|
||||
title,
|
||||
profileName,
|
||||
source: { kind: 'search', query: 'WebMCP' },
|
||||
})
|
||||
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
pageErrors = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
if (testInfo.title === 'joins pagination already started by scrolling') return
|
||||
// Keep pagination under the tool's control without replacing WebMCP itself.
|
||||
await page.addInitScript(() => {
|
||||
window.IntersectionObserver = class {
|
||||
readonly root = null
|
||||
readonly rootMargin = '0px'
|
||||
readonly scrollMargin = '0px'
|
||||
readonly thresholds = [0]
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
expect(pageErrors, 'uncaught browser errors').toEqual([])
|
||||
})
|
||||
|
||||
test('searches through native WebMCP and keeps results synchronized with the UI', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
|
||||
|
||||
const first = await executeFeedTool(page, 'search_posts', {
|
||||
q: 'AI',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
|
||||
expect(first.request).toMatchObject({
|
||||
kind: 'search',
|
||||
query: 'AI filter:follows',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
expect(first.posts.map((post) => post.text)).toEqual(['Top · follows page 1'])
|
||||
expect(first).toMatchObject({ loadedCount: 1, offset: 0, hasMore: true })
|
||||
await expect(page).toHaveURL(/\/search\?/)
|
||||
await expect(searchPage.queryInputLocator).toHaveValue('AI')
|
||||
await expect(searchPage.topRankingLocator).toBeChecked()
|
||||
await expect(searchPage.followingOnlyLocator).toBeChecked()
|
||||
await expect(
|
||||
page.getByText(first.posts[0]?.text ?? '', { exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => toolNames(page))
|
||||
.toEqual(['get_loaded_posts', 'load_more_posts', 'search_posts'])
|
||||
const more = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(more.posts.map((post) => post.text)).toEqual(['Top · follows page 2'])
|
||||
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const slice = await executeFeedTool(page, 'get_loaded_posts', {
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
})
|
||||
expect(slice.posts).toEqual(more.posts)
|
||||
expect(slice).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
|
||||
const end = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(end).toMatchObject({ posts: [], loadedCount: 2, hasMore: false })
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
|
||||
// SPA navigation must unregister the old feed's tools in the same document.
|
||||
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
|
||||
})
|
||||
|
||||
test('rejects invalid search conditions without navigating or dropping the active feed', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toContain('search_posts')
|
||||
const first = await executeFeedTool(page, 'search_posts', { q: 'existing' })
|
||||
const previousURL = page.url()
|
||||
const result = await executeTool(page, 'search_posts', {
|
||||
q: 'invalid dates',
|
||||
since: '2026-09-24',
|
||||
until: '2026-09-23',
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type).toBe('text')
|
||||
expect(() => JSON.parse(result.content[0]?.text ?? '')).not.toThrow()
|
||||
await expect(page).toHaveURL(previousURL)
|
||||
await expect(searchPage.queryInputLocator).toHaveValue('existing')
|
||||
const current = await executeFeedTool(page, 'get_loaded_posts')
|
||||
expect(current.posts).toEqual(first.posts)
|
||||
})
|
||||
|
||||
test('retries a failed continuation without losing the first page', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toContain('search_posts')
|
||||
const first = await executeFeedTool(page, 'search_posts', {
|
||||
q: `retry-webmcp-${testInfo.project.name}`,
|
||||
})
|
||||
const failed = await executeTool(page, 'load_more_posts')
|
||||
expect(failed.isError, JSON.stringify(failed.content)).toBe(true)
|
||||
await expect(searchPage.continuationErrorLocator).toBeVisible()
|
||||
const retained = await executeFeedTool(page, 'get_loaded_posts')
|
||||
expect(retained.posts).toEqual(first.posts)
|
||||
|
||||
const recovered = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(recovered.posts.map((post) => post.text)).toEqual([
|
||||
'Latest · all page 2',
|
||||
])
|
||||
expect(recovered).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('joins pagination already started by scrolling', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toContain('search_posts')
|
||||
await executeFeedTool(page, 'search_posts', {
|
||||
q: `slow-webmcp-${testInfo.project.name}`,
|
||||
})
|
||||
await expect(searchPage.loadingRailLocator).toBeVisible()
|
||||
|
||||
const more = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(more.posts.map((post) => post.text)).toEqual(['Latest · all page 2'])
|
||||
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the normal search form functional without native WebMCP', async ({
|
||||
playwright,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
||||
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
|
||||
})
|
||||
const page = await browser.newPage({ baseURL })
|
||||
const searchPage = new SearchPage(page)
|
||||
try {
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
await searchPage.goTo()
|
||||
expect(await page.evaluate(() => Boolean(document.modelContext))).toBe(
|
||||
false,
|
||||
)
|
||||
await searchPage.search('ordinary search')
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
} catch (error) {
|
||||
await testInfo.attach('unsupported-browser-state', {
|
||||
body: JSON.stringify({ url: page.url(), html: await page.content() }),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
await testInfo.attach('unsupported-browser-screen', {
|
||||
body: await page.screenshot(),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('creates a deck through native WebMCP, edits it, and restores its saved definition', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/deck')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect
|
||||
.poll(() => toolNames(page))
|
||||
.toEqual(['get_deck', 'search_posts', 'set_deck'])
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
.toEqual([
|
||||
'delete_deck',
|
||||
'get_column_posts',
|
||||
'get_deck',
|
||||
'list_decks',
|
||||
'load_more_column',
|
||||
'select_deck',
|
||||
'set_deck',
|
||||
])
|
||||
})
|
||||
test.afterEach(() => expect(pageErrors, 'uncaught browser errors').toEqual([]))
|
||||
|
||||
test('creates, replaces, switches and deletes persisted decks through native WebMCP', async ({
|
||||
page,
|
||||
}) => {
|
||||
const inventory = await executeTool(page, 'list_decks')
|
||||
expect(JSON.parse(inventory.content[0]?.text ?? '').profiles).toEqual([
|
||||
'e2e',
|
||||
'alternate',
|
||||
])
|
||||
const created = await executeTool(page, 'set_deck', {
|
||||
title: 'WebMCPの反応',
|
||||
columns: [
|
||||
{ id: 'new', title: '日本語', source: { query: 'WebMCP lang:ja' } },
|
||||
{ title: '話題', source: { query: 'WebMCP', product: 'Top' } },
|
||||
{ ...column('日本語'), id: 'new' },
|
||||
column('別の接続', 'alternate'),
|
||||
],
|
||||
})
|
||||
expect(result.isError).not.toBe(true)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['日本語', '話題'])
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Top · all page 1')).toBeVisible()
|
||||
const before = await executeTool(page, 'get_deck')
|
||||
const { deck } = JSON.parse(before.content[0]?.text ?? '')
|
||||
expect(deck.columns[0].id).toBe('new')
|
||||
// Tool-provided IDs must not collide with the UI's new-column editing state.
|
||||
expect(created.isError).not.toBe(true)
|
||||
const { deck } = JSON.parse(created.content[0]?.text ?? '')
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'日本語',
|
||||
'別の接続',
|
||||
])
|
||||
await expect(
|
||||
page.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await page
|
||||
.getByRole('region', { name: '日本語', exact: true })
|
||||
.locator('summary[aria-label="日本語の操作"]')
|
||||
.click()
|
||||
await page
|
||||
.getByRole('region', { name: '日本語', exact: true })
|
||||
.getByRole('button', { name: '編集', exact: true })
|
||||
.click()
|
||||
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue(
|
||||
'WebMCP lang:ja',
|
||||
)
|
||||
const edited = await executeTool(page, 'set_deck', {
|
||||
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue('WebMCP')
|
||||
const replaced = await executeTool(page, 'set_deck', {
|
||||
deckId: deck.id,
|
||||
title: '更新した調査',
|
||||
columns: [deck.columns[1]],
|
||||
})
|
||||
expect(edited.isError).not.toBe(true)
|
||||
expect(replaced.isError).not.toBe(true)
|
||||
await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['話題'])
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['別の接続'])
|
||||
const another = await executeTool(page, 'set_deck', {
|
||||
title: '別の調査',
|
||||
columns: [],
|
||||
})
|
||||
const anotherId = JSON.parse(another.content[0]?.text ?? '').deck.id
|
||||
expect(
|
||||
(await executeTool(page, 'select_deck', { deckId: deck.id })).isError,
|
||||
).not.toBe(true)
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||
'更新した調査',
|
||||
)
|
||||
await expect.poll(() => toolNames(page)).toContain('get_deck')
|
||||
const restored = await executeTool(page, 'get_deck')
|
||||
expect(JSON.parse(restored.content[0]?.text ?? '').deck.columns[0].id).toBe(
|
||||
deck.columns[1].id,
|
||||
const restored = JSON.parse(
|
||||
(await executeTool(page, 'get_deck')).content[0]?.text ?? '',
|
||||
).deck
|
||||
expect(restored).toEqual({
|
||||
...deck,
|
||||
title: '更新した調査',
|
||||
columns: [deck.columns[1]],
|
||||
})
|
||||
expect(
|
||||
(await executeTool(page, 'delete_deck', { deckId: anotherId })).isError,
|
||||
).not.toBe(true)
|
||||
const after = JSON.parse(
|
||||
(await executeTool(page, 'list_decks')).content[0]?.text ?? '',
|
||||
)
|
||||
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
|
||||
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
|
||||
expect(
|
||||
after.decks.some((item: { id: string }) => item.id === anotherId),
|
||||
).toBe(false)
|
||||
expect(after.activeDeckId).toBe(deck.id)
|
||||
})
|
||||
|
||||
test('rejects an invalid deck without changing the visible or saved columns', async ({
|
||||
test('rejects duplicate IDs, unavailable profiles and invalid sources without mutations', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/deck')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect.poll(() => toolNames(page)).toContain('set_deck')
|
||||
await executeTool(page, 'set_deck', {
|
||||
title: '残す調査',
|
||||
columns: [{ title: '残す観点', source: { query: 'AI' } }],
|
||||
columns: [column('残す観点')],
|
||||
})
|
||||
const saved = await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
)
|
||||
const column = { id: 'duplicate', title: '不正', source: { query: 'AI' } }
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
title: '変更しない',
|
||||
columns: [column, column],
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
),
|
||||
).toBe(saved)
|
||||
const duplicate = { ...column('不正'), id: 'duplicate' }
|
||||
for (const columns of [
|
||||
[duplicate, duplicate],
|
||||
[column('不明', 'missing')],
|
||||
[{ ...column('不正'), source: { kind: 'list', target: 'not-a-list' } }],
|
||||
]) {
|
||||
expect(
|
||||
(await executeTool(page, 'set_deck', { title: '変更しない', columns }))
|
||||
.isError,
|
||||
).toBe(true)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
),
|
||||
).toBe(saved)
|
||||
}
|
||||
})
|
||||
|
||||
test('reports a deck persistence failure instead of claiming it was saved', async ({
|
||||
test('reports persistence failure while keeping the applied deck visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/deck')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect.poll(() => toolNames(page)).toContain('set_deck')
|
||||
await page.evaluate(() => {
|
||||
Storage.prototype.setItem = () => {
|
||||
throw new Error('QuotaExceededError')
|
||||
const setItem = Storage.prototype.setItem
|
||||
Storage.prototype.setItem = function (key, value) {
|
||||
if (this === localStorage) throw new Error('QuotaExceededError')
|
||||
setItem.call(this, key, value)
|
||||
}
|
||||
})
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
@@ -354,3 +191,85 @@ test('reports a deck persistence failure instead of claiming it was saved', asyn
|
||||
)
|
||||
await expect(page.getByRole('alert')).toContainText('保存できません')
|
||||
})
|
||||
|
||||
test('keeps manual deck creation functional when WebMCP is disabled', async ({
|
||||
playwright,
|
||||
baseURL,
|
||||
}) => {
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
||||
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
|
||||
})
|
||||
try {
|
||||
const page = await browser.newPage({ baseURL })
|
||||
await page.goto('/')
|
||||
await page
|
||||
.getByRole('button', { name: 'カラムを追加', exact: true })
|
||||
.click()
|
||||
await page.getByLabel('カラム名', { exact: true }).fill('手動で調査')
|
||||
await page
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption('e2e')
|
||||
await page.getByLabel('Twitterの検索条件').fill('ordinary-search')
|
||||
await page.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(
|
||||
page.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('reads and pages the bound column profile without mixing sibling feeds', async ({
|
||||
page,
|
||||
}) => {
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
title: '接続を比較',
|
||||
columns: [column('通常'), column('別の接続', 'alternate')],
|
||||
})
|
||||
const { deck } = JSON.parse(result.content[0]?.text ?? '')
|
||||
await expect(
|
||||
page.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
const boundId = deck.columns[1].id
|
||||
const loaded = JSON.parse(
|
||||
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
|
||||
.content[0]?.text ?? '',
|
||||
)
|
||||
expect(loaded.column.profileName).toBe('alternate')
|
||||
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
|
||||
'alternate · Latest · all page 1',
|
||||
])
|
||||
const moreResult = await executeTool(page, 'load_more_column', {
|
||||
columnId: boundId,
|
||||
})
|
||||
expect(moreResult.isError).not.toBe(true)
|
||||
const more = JSON.parse(moreResult.content[0]?.text ?? '')
|
||||
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
expect(more.posts.map((post: { text: string }) => post.text)).toEqual([
|
||||
'alternate · Latest · all page 2',
|
||||
])
|
||||
await expect(
|
||||
page.getByRole('region', { name: '通常', exact: true }).locator('article'),
|
||||
).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: '別の接続', exact: true })
|
||||
.locator('article'),
|
||||
).toHaveCount(2)
|
||||
const slice = JSON.parse(
|
||||
(
|
||||
await executeTool(page, 'get_column_posts', {
|
||||
columnId: boundId,
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
})
|
||||
).content[0]?.text ?? '',
|
||||
)
|
||||
expect(slice.posts).toEqual(more.posts)
|
||||
await executeTool(page, 'set_deck', { title: '別デッキ', columns: [] })
|
||||
expect(
|
||||
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
|
||||
.isError,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
+5
-2
@@ -218,7 +218,10 @@ const server = createServer(async (request, response) => {
|
||||
itemContent: {
|
||||
list: {
|
||||
id_str: '1234567890',
|
||||
name: 'My E2E List',
|
||||
name:
|
||||
profileName === 'alternate'
|
||||
? 'Alternate List'
|
||||
: 'My E2E List',
|
||||
description: 'Accounts selected for testing',
|
||||
member_count: 10,
|
||||
mode: 'Public',
|
||||
@@ -456,7 +459,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
|
||||
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
|
||||
const label = `${variables.product} · ${follows}`
|
||||
const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}`
|
||||
const id = encodeURIComponent(rawQuery)
|
||||
const entries = secondPage
|
||||
? [tweet(`s-${id}-2`, `${label} page 2`)]
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
export class Base {
|
||||
readonly ambientLinksLocator: Locator
|
||||
readonly articlesLocator: Locator
|
||||
readonly continuationErrorLocator: Locator
|
||||
readonly endOfFeedLocator: Locator
|
||||
readonly loadingRailLocator: Locator
|
||||
readonly mutationButtonsLocator: Locator
|
||||
readonly mutationLinksLocator: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
|
||||
const mutationControlNames =
|
||||
/投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
|
||||
|
||||
this.ambientLinksLocator = page.getByRole('link', {
|
||||
name: ambientLinkNames,
|
||||
})
|
||||
this.articlesLocator = page.locator('article')
|
||||
this.continuationErrorLocator = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。')
|
||||
this.loadingRailLocator = page.locator('.loading-rail')
|
||||
this.mutationButtonsLocator = page.getByRole('button', {
|
||||
name: mutationControlNames,
|
||||
})
|
||||
this.mutationLinksLocator = page.getByRole('link', {
|
||||
name: mutationControlNames,
|
||||
})
|
||||
}
|
||||
|
||||
protected async navigateTo(path: string) {
|
||||
await this.page.goto(path)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async tabTo(target: Locator) {
|
||||
await this.page.keyboard.press('Tab')
|
||||
await expect(target).toBeFocused()
|
||||
}
|
||||
|
||||
async retryContinuation() {
|
||||
await this.continuationErrorLocator
|
||||
.getByRole('button', { name: '再試行' })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class HomePage extends Base {
|
||||
readonly handleInputLocator: Locator
|
||||
readonly submitButtonLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL')
|
||||
this.submitButtonLocator = page.getByRole('button', { name: '表示' })
|
||||
}
|
||||
|
||||
async goTo() {
|
||||
await this.navigateTo('/')
|
||||
}
|
||||
|
||||
async displayUser(handle: string) {
|
||||
await this.handleInputLocator.fill(handle)
|
||||
await this.submitButtonLocator.click()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Base } from './Base'
|
||||
|
||||
export class ListsPage extends Base {
|
||||
async goTo() {
|
||||
await this.navigateTo('/i/lists')
|
||||
}
|
||||
|
||||
async openList(name: RegExp | string) {
|
||||
await this.page.getByRole('link', { name }).click()
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class SearchPage extends Base {
|
||||
readonly followingOnlyLocator: Locator
|
||||
readonly latestRankingLocator: Locator
|
||||
readonly queryInputLocator: Locator
|
||||
readonly searchButtonLocator: Locator
|
||||
readonly topRankingLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.followingOnlyLocator = page.getByLabel('フォロー中のみ')
|
||||
this.latestRankingLocator = page.getByLabel('最新順')
|
||||
this.queryInputLocator = page.getByLabel('検索語')
|
||||
this.searchButtonLocator = page.getByRole('button', { name: '検索' })
|
||||
this.topRankingLocator = page.getByLabel('人気順')
|
||||
}
|
||||
|
||||
async goTo() {
|
||||
await this.navigateTo('/search?q=&product=Latest&following=false')
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.queryInputLocator.fill(query)
|
||||
await this.searchButtonLocator.click()
|
||||
}
|
||||
|
||||
async searchTopFromFollowing(query: string) {
|
||||
await this.queryInputLocator.fill(query)
|
||||
await this.topRankingLocator.check()
|
||||
await this.followingOnlyLocator.check()
|
||||
await this.searchButtonLocator.click()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class ThreadPage extends Base {
|
||||
readonly headingLocator: Locator
|
||||
readonly selectedPostLabelLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.headingLocator = page.getByRole('heading', { name: '会話' })
|
||||
this.selectedPostLabelLocator = page.getByText('表示中の投稿')
|
||||
}
|
||||
|
||||
async goTo(handle: string, tweetId: string) {
|
||||
await this.navigateTo(`/${handle}/status/${tweetId}`)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Base } from './Base'
|
||||
|
||||
export class UserPage extends Base {
|
||||
async goTo(handle: string) {
|
||||
await this.navigateTo(`/${handle}`)
|
||||
}
|
||||
|
||||
async openPostDetails(postText: string) {
|
||||
await this.page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: postText })
|
||||
.getByRole('link', { name: '詳細・スレッド' })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
@@ -113,13 +113,6 @@
|
||||
description = "Base URL of the Twitter relay.";
|
||||
};
|
||||
|
||||
profileName = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.nonEmptyStr;
|
||||
default = null;
|
||||
example = "default";
|
||||
description = "Relay profile to use when the relay has multiple profiles.";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.nonEmptyStr;
|
||||
default = "127.0.0.1";
|
||||
@@ -142,8 +135,6 @@
|
||||
HOST = cfg.host;
|
||||
PORT = toString cfg.port;
|
||||
TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl;
|
||||
} // lib.optionalAttrs (cfg.profileName != null) {
|
||||
BIRD_PROFILE_NAME = cfg.profileName;
|
||||
};
|
||||
serviceConfig = {
|
||||
DynamicUser = true;
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ProfileSwitcher } from '#/features/profiles/profile-switcher'
|
||||
import { Icon } from './icon'
|
||||
|
||||
export function AppShell({
|
||||
active,
|
||||
sidebar,
|
||||
children,
|
||||
}: {
|
||||
active?: 'user' | 'search' | 'list' | 'deck'
|
||||
sidebar: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className={active === 'deck' ? 'app app-deck' : 'app'}>
|
||||
<header className="app-header">
|
||||
<p className="brand">Twitter Lite</p>
|
||||
<ProfileSwitcher />
|
||||
<nav aria-label="閲覧方法" className="tabs">
|
||||
<Link
|
||||
aria-current={active === 'deck' ? 'page' : undefined}
|
||||
to="/deck"
|
||||
>
|
||||
デッキ
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'search' ? 'page' : undefined}
|
||||
search={{ q: '', product: 'Latest', following: false }}
|
||||
to="/search"
|
||||
>
|
||||
検索
|
||||
</Link>
|
||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||
ユーザー
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'list' ? 'page' : undefined}
|
||||
to="/i/lists"
|
||||
>
|
||||
リスト
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="app app-deck">
|
||||
<aside className="app-sidebar" aria-label="デッキ管理">
|
||||
<p className="brand">
|
||||
<Icon name="columns" />
|
||||
<span>Twitter Lite</span>
|
||||
</p>
|
||||
{sidebar}
|
||||
</aside>
|
||||
<section className="reader">{children}</section>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { Icon } from './icon'
|
||||
|
||||
export function Dialog({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const headingId = useId()
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
const opener =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
element?.showModal()
|
||||
return () => {
|
||||
element?.close()
|
||||
if (opener?.isConnected) opener.focus({ preventScroll: true })
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="deck-dialog"
|
||||
aria-labelledby={headingId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="閉じる"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const paths = {
|
||||
columns: 'M3 4h7v16H3z M14 4h7v16h-7z',
|
||||
search: 'M21 21l-5-5 M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0',
|
||||
user: 'M20 21v-2a7 7 0 0 0-14 0v2 M17 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0',
|
||||
list: 'M8 6h13 M8 12h13 M8 18h13 M3 6h.01 M3 12h.01 M3 18h.01',
|
||||
plus: 'M12 5v14 M5 12h14',
|
||||
close: 'M6 6l12 12 M18 6 6 18',
|
||||
refresh: 'M20 7v5h-5 M4 17v-5h5 M6 6a8 8 0 0 1 14 6 M4 12a8 8 0 0 0 14 6',
|
||||
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
|
||||
arrow: 'M7 17 17 7 M7 7h10v10',
|
||||
edit: 'm16 3 5 5-12 12H4v-5z M14 5l5 5',
|
||||
trash: 'M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',
|
||||
} as const
|
||||
|
||||
export function Icon({ name }: { name: keyof typeof paths }) {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={paths[name]} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,66 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useListChoices } from '#/features/posts/use-list-choices'
|
||||
import { columnSchema, type DeckColumn } from './model'
|
||||
|
||||
function ListChoices({
|
||||
profileName,
|
||||
onSelect,
|
||||
}: {
|
||||
profileName: string
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
const lists = useListChoices(profileName)
|
||||
if (lists.isPending) return <p role="status">リストを取得しています…</p>
|
||||
if (lists.isError)
|
||||
return (
|
||||
<p role="alert">
|
||||
リストを取得できませんでした。
|
||||
<button type="button" onClick={() => void lists.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
return (
|
||||
<section
|
||||
className="deck-list-choices"
|
||||
aria-label="このプロファイルのリスト"
|
||||
>
|
||||
{lists.data.length === 0 ? (
|
||||
<p>リストはありません。URLまたはIDでも指定できます。</p>
|
||||
) : (
|
||||
lists.data.map((list) => (
|
||||
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
|
||||
{list.name}
|
||||
{list.isPrivate ? ' · 非公開' : ''}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColumnEditor({
|
||||
column,
|
||||
profiles,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
column?: DeckColumn
|
||||
profiles: string[]
|
||||
onSave: (column: DeckColumn) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const formId = useId()
|
||||
const [error, setError] = useState<string>()
|
||||
const [kind, setKind] = useState<DeckColumn['source']['kind']>(
|
||||
column?.source.kind ?? 'search',
|
||||
)
|
||||
const [profileName, setProfileName] = useState(
|
||||
column?.profileName ?? profiles[0] ?? '',
|
||||
)
|
||||
const [target, setTarget] = useState(
|
||||
column && column.source.kind !== 'search' ? column.source.target : '',
|
||||
)
|
||||
return (
|
||||
<form
|
||||
className="deck-editor"
|
||||
@@ -19,28 +68,34 @@ export function ColumnEditor({
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
const source =
|
||||
kind === 'search'
|
||||
? {
|
||||
platform: 'twitter',
|
||||
kind,
|
||||
query: data.get('query'),
|
||||
product: data.get('product'),
|
||||
following: data.get('following') === 'on',
|
||||
}
|
||||
: { platform: 'twitter', kind, target }
|
||||
const parsed = columnSchema.safeParse({
|
||||
// getRandomValues also works on HTTP tailnet origins.
|
||||
id:
|
||||
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
title: data.get('title'),
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
query: data.get('query'),
|
||||
product: data.get('product'),
|
||||
following: data.get('following') === 'on',
|
||||
},
|
||||
profileName,
|
||||
source,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
if (!parsed.success || !profiles.includes(profileName)) {
|
||||
setError(
|
||||
'カラム名と検索条件を確認してください。検索語はフォロー条件を含め512文字以内です。',
|
||||
parsed.success
|
||||
? '接続プロファイルを選択してください。'
|
||||
: parsed.error.issues[0]?.message,
|
||||
)
|
||||
return
|
||||
}
|
||||
onSave(parsed.data)
|
||||
}}
|
||||
>
|
||||
<h2>{column ? 'カラムを編集' : '調べる観点を追加'}</h2>
|
||||
<div className="deck-editor-fields">
|
||||
<label htmlFor={`${formId}-title`}>
|
||||
カラム名
|
||||
@@ -49,52 +104,133 @@ export function ColumnEditor({
|
||||
name="title"
|
||||
defaultValue={column?.title ?? ''}
|
||||
maxLength={80}
|
||||
placeholder="例:日本語での反応"
|
||||
placeholder="例:発表元の投稿"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
id={`${formId}-query`}
|
||||
name="query"
|
||||
defaultValue={column?.source.query ?? ''}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
aria-describedby={`${formId}-help`}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-product`}>
|
||||
表示順
|
||||
<label htmlFor={`${formId}-profile`}>
|
||||
接続プロファイル
|
||||
<select
|
||||
id={`${formId}-product`}
|
||||
name="product"
|
||||
defaultValue={column?.source.product ?? 'Latest'}
|
||||
id={`${formId}-profile`}
|
||||
value={profileName}
|
||||
onChange={(event) => setProfileName(event.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
<option value="" disabled>
|
||||
選択してください
|
||||
</option>
|
||||
{profileName && !profiles.includes(profileName) ? (
|
||||
<option value={profileName} disabled>
|
||||
{profileName}(利用不可)
|
||||
</option>
|
||||
) : null}
|
||||
{profiles.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-kind`}>
|
||||
カラムの種類
|
||||
<select
|
||||
id={`${formId}-kind`}
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
setKind(event.target.value as typeof kind)
|
||||
setTarget('')
|
||||
}}
|
||||
>
|
||||
<option value="search">検索</option>
|
||||
<option value="user">ユーザー投稿</option>
|
||||
<option value="list">リスト</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="deck-help" id={`${formId}-help`}>
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
{kind === 'search' ? (
|
||||
<div className="deck-source-fields" key="search">
|
||||
<label htmlFor={`${formId}-query`}>
|
||||
Twitterの検索条件
|
||||
<input
|
||||
id={`${formId}-query`}
|
||||
name="query"
|
||||
defaultValue={
|
||||
column?.source.kind === 'search' ? column.source.query : ''
|
||||
}
|
||||
maxLength={512}
|
||||
placeholder="例:WebMCP lang:ja"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-product`}>
|
||||
表示順
|
||||
<select
|
||||
id={`${formId}-product`}
|
||||
name="product"
|
||||
defaultValue={
|
||||
column?.source.kind === 'search'
|
||||
? column.source.product
|
||||
: 'Latest'
|
||||
}
|
||||
>
|
||||
<option value="Latest">最新</option>
|
||||
<option value="Top">話題</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="deck-help">
|
||||
from:、lang:、since: など、Twitterの検索構文を使えます。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="following"
|
||||
defaultChecked={
|
||||
column?.source.kind === 'search' && column.source.following
|
||||
}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="deck-source-fields" key={kind}>
|
||||
<label htmlFor={`${formId}-target`}>
|
||||
{kind === 'user' ? 'ユーザー' : 'リスト'}
|
||||
<input
|
||||
id={`${formId}-target`}
|
||||
value={target}
|
||||
onChange={(event) => setTarget(event.target.value)}
|
||||
placeholder={
|
||||
kind === 'user'
|
||||
? '@handle またはプロフィールURL'
|
||||
: 'リストのURLまたはID'
|
||||
}
|
||||
maxLength={256}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{kind === 'list' && profiles.includes(profileName) ? (
|
||||
<ListChoices
|
||||
key={profileName}
|
||||
profileName={profileName}
|
||||
onSelect={setTarget}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="deck-help">
|
||||
このカラムは選択した接続プロファイルで取得します。
|
||||
</p>
|
||||
<label className="deck-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="following"
|
||||
defaultChecked={column?.source.following ?? false}
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
{error ? (
|
||||
<p role="alert" className="deck-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="deck-actions">
|
||||
<button type="submit" className="deck-primary">
|
||||
<button
|
||||
type="submit"
|
||||
className="deck-primary"
|
||||
disabled={!profiles.includes(profileName)}
|
||||
>
|
||||
{column ? '変更を保存' : '追加して検索'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FeedRequest } from '#/features/posts/use-post-feed'
|
||||
import type { DeckColumn } from './model'
|
||||
|
||||
export function columnRequest(column: DeckColumn): FeedRequest {
|
||||
const { platform: _, ...source } = column.source
|
||||
return { ...source, profileName: column.profileName }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import { z } from 'zod'
|
||||
import { mapTwitterPost } from '#/features/platforms/twitter'
|
||||
import { flattenPostPages } from '#/features/posts/page'
|
||||
import { PostLoadError, type usePostFeed } from '#/features/posts/use-post-feed'
|
||||
import type { DeckColumn } from './model'
|
||||
import { useWebMCPSupported } from './use-webmcp-supported'
|
||||
import { toolResult } from './webmcp-contracts'
|
||||
|
||||
type ColumnFeed = { column: DeckColumn; query: ReturnType<typeof usePostFeed> }
|
||||
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
|
||||
|
||||
export function useRegisterColumn(
|
||||
registry: ColumnRegistry,
|
||||
column: DeckColumn,
|
||||
query: ReturnType<typeof usePostFeed>,
|
||||
) {
|
||||
const entry = useRef({ column, query })
|
||||
entry.current = { column, query }
|
||||
useEffect(() => {
|
||||
registry.set(column.id, entry)
|
||||
return () => {
|
||||
if (registry.get(column.id) === entry) registry.delete(column.id)
|
||||
}
|
||||
}, [registry, column.id])
|
||||
}
|
||||
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
|
||||
const readInput = columnInput.extend({
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
|
||||
function read(feed: ColumnFeed, offset = 0, limit = 20) {
|
||||
const posts = flattenPostPages(feed.query.data?.pages ?? [])
|
||||
const selected = posts.slice(offset, offset + limit)
|
||||
return {
|
||||
column: feed.column,
|
||||
status: feed.query.isPending
|
||||
? 'loading'
|
||||
: feed.query.isError
|
||||
? 'error'
|
||||
: 'ready',
|
||||
loading: feed.query.isFetching,
|
||||
error:
|
||||
feed.query.error instanceof PostLoadError
|
||||
? feed.query.error.detail
|
||||
: null,
|
||||
posts: selected.map(mapTwitterPost),
|
||||
loadedCount: posts.length,
|
||||
offset,
|
||||
nextOffset:
|
||||
offset + selected.length < posts.length ? offset + selected.length : null,
|
||||
hasMore: Boolean(feed.query.hasNextPage),
|
||||
}
|
||||
}
|
||||
|
||||
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
|
||||
const supported = useWebMCPSupported()
|
||||
const enabled = supported && ready
|
||||
function requireColumn(id: string) {
|
||||
const entry = registry.get(id)
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
|
||||
)
|
||||
return entry
|
||||
}
|
||||
useWebMCP({
|
||||
name: 'get_column_posts',
|
||||
description:
|
||||
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
|
||||
inputSchema: readInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { columnId, offset, limit } = readInput.parse(input)
|
||||
return read(requireColumn(columnId).current, offset, limit)
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'load_more_column',
|
||||
description:
|
||||
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
|
||||
inputSchema: columnInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
const { columnId } = columnInput.parse(input)
|
||||
const entry = requireColumn(columnId)
|
||||
const { column, query } = entry.current
|
||||
const identity = JSON.stringify(column)
|
||||
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
|
||||
throw new Error('Wait for the column to finish loading.')
|
||||
if (query.isError && !query.isFetchNextPageError) throw query.error
|
||||
const offset = flattenPostPages(query.data?.pages ?? []).length
|
||||
if (!query.hasNextPage) return read(entry.current, offset)
|
||||
const result = await query.fetchNextPage({ cancelRefetch: false })
|
||||
if (
|
||||
registry.get(columnId) !== entry ||
|
||||
JSON.stringify(entry.current.column) !== identity
|
||||
)
|
||||
throw new Error(
|
||||
'The deck or column changed while loading. Read the current deck first.',
|
||||
)
|
||||
if (result.isError) throw result.error
|
||||
return read({ column, query: result }, offset)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Icon } from '#/components/icon'
|
||||
import { mapTwitterPost } from '#/features/platforms/twitter'
|
||||
import { flattenPostPages } from '#/features/posts/page'
|
||||
import { PostLoadError, usePostFeed } from '#/features/posts/use-post-feed'
|
||||
import { columnRequest } from './column-request'
|
||||
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
|
||||
import type { DeckColumn } from './model'
|
||||
import { ResearchPostCard } from './research-post-card'
|
||||
|
||||
@@ -11,6 +14,7 @@ export function ResearchColumn({
|
||||
onMove,
|
||||
first,
|
||||
last,
|
||||
registry,
|
||||
}: {
|
||||
column: DeckColumn
|
||||
onEdit: () => void
|
||||
@@ -18,13 +22,10 @@ export function ResearchColumn({
|
||||
onMove: (direction: -1 | 1) => void
|
||||
first: boolean
|
||||
last: boolean
|
||||
registry: ColumnRegistry
|
||||
}) {
|
||||
const query = usePostFeed({
|
||||
kind: 'search',
|
||||
query: column.source.query,
|
||||
product: column.source.product,
|
||||
following: column.source.following,
|
||||
})
|
||||
const query = usePostFeed(columnRequest(column))
|
||||
useRegisterColumn(registry, column, query)
|
||||
const posts = flattenPostPages(query.data?.pages ?? []).map(mapTwitterPost)
|
||||
const error =
|
||||
query.error instanceof PostLoadError
|
||||
@@ -34,50 +35,85 @@ export function ResearchColumn({
|
||||
return (
|
||||
<section className="deck-column" aria-labelledby={headingId}>
|
||||
<header className="deck-column-header">
|
||||
<div className="deck-column-label">
|
||||
<span>Twitter</span>
|
||||
<span>
|
||||
{column.source.product === 'Latest' ? '最新' : '話題'}
|
||||
{column.source.following ? ' · フォロー中' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<p className="deck-query">{column.source.query}</p>
|
||||
<div className="deck-actions">
|
||||
<button type="button" onClick={onEdit}>
|
||||
編集
|
||||
</button>
|
||||
<div className="deck-column-title">
|
||||
<Icon name={column.source.kind} />
|
||||
<h2 id={headingId}>{column.title}</h2>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="更新"
|
||||
title="更新"
|
||||
type="button"
|
||||
onClick={() => void query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
更新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(1)}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
削除
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
<details className="column-menu">
|
||||
<summary
|
||||
className="icon-button"
|
||||
aria-label={`${column.title}の操作`}
|
||||
title="カラムの操作"
|
||||
>
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="column-menu-panel">
|
||||
<button type="button" onClick={onEdit}>
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(-1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
disabled={first}
|
||||
aria-label={`${column.title}を左へ`}
|
||||
>
|
||||
← 左へ移動
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onMove(1)
|
||||
event.currentTarget
|
||||
.closest('details')
|
||||
?.removeAttribute('open')
|
||||
}}
|
||||
disabled={last}
|
||||
aria-label={`${column.title}を右へ`}
|
||||
>
|
||||
右へ移動 →
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={onRemove}
|
||||
aria-label={`${column.title}を削除`}
|
||||
>
|
||||
カラムを削除
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div className="deck-column-label">
|
||||
<span title={`接続プロファイル: ${column.profileName}`}>
|
||||
{column.profileName}
|
||||
</span>
|
||||
<span>
|
||||
{column.source.kind === 'search'
|
||||
? `${column.source.product === 'Latest' ? '最新' : '話題'}${column.source.following ? ' · フォロー中' : ''}`
|
||||
: column.source.kind === 'user'
|
||||
? 'ユーザー投稿'
|
||||
: 'リスト'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="deck-query">
|
||||
{column.source.kind === 'search'
|
||||
? column.source.query
|
||||
: column.source.target}
|
||||
</p>
|
||||
<p className="deck-column-status" role="status">
|
||||
{query.isFetching
|
||||
? '投稿を取得しています…'
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { useRef, useState } from 'react'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import { loadProfiles } from '#/features/profiles/server-functions'
|
||||
import { ColumnEditor } from './column-editor'
|
||||
import { type ColumnRegistry, useColumnTools } from './column-tools'
|
||||
import { ResearchColumn } from './deck-column'
|
||||
import { DeckSwitcher } from './deck-switcher'
|
||||
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
|
||||
import { useDeck } from './use-deck'
|
||||
import { useDeckTools } from './webmcp-tools'
|
||||
import './decks.css'
|
||||
|
||||
export function DeckPage() {
|
||||
const { deck, ready, storageError, save } = useDeck()
|
||||
const registry = useRef<ColumnRegistry>(new Map()).current
|
||||
const {
|
||||
deck,
|
||||
workspace,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
create,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
} = useDeck()
|
||||
const fetchProfiles = useServerFn(loadProfiles)
|
||||
const profiles = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => fetchProfiles(),
|
||||
retry: false,
|
||||
})
|
||||
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
useColumnTools(registry, ready)
|
||||
const [removed, setRemoved] = useState<{
|
||||
column: DeckColumn
|
||||
index: number
|
||||
}>()
|
||||
|
||||
useDeckTools({
|
||||
deck,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
onApplied: () => {
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
},
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
profiles: profiles.data?.profiles,
|
||||
onApplied: clearEditors,
|
||||
})
|
||||
|
||||
function clearEditors() {
|
||||
setEditing(null)
|
||||
setRenaming(false)
|
||||
setRemoved(undefined)
|
||||
}
|
||||
|
||||
function saveColumn(column: DeckColumn) {
|
||||
save({
|
||||
...deck,
|
||||
@@ -42,83 +72,148 @@ export function DeckPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell active="deck">
|
||||
<AppShell
|
||||
sidebar={
|
||||
<>
|
||||
<DeckSwitcher
|
||||
key={deck.id}
|
||||
decks={workspace.decks}
|
||||
activeId={deck.id}
|
||||
ready={ready}
|
||||
onSelect={(id) => {
|
||||
select(id)
|
||||
clearEditors()
|
||||
}}
|
||||
onCreate={(title) => {
|
||||
create(title)
|
||||
clearEditors()
|
||||
}}
|
||||
onDelete={() => {
|
||||
remove(deck.id)
|
||||
clearEditors()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="deck-primary sidebar-add"
|
||||
aria-label="カラムを追加"
|
||||
title="カラムを追加"
|
||||
type="button"
|
||||
disabled={!ready || deck.columns.length >= MAX_COLUMNS}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>カラムを追加</span>
|
||||
</button>
|
||||
<nav className="sidebar-columns" aria-label="カラム一覧">
|
||||
{deck.columns.map((column) => (
|
||||
<button
|
||||
key={column.id}
|
||||
type="button"
|
||||
title={column.title}
|
||||
onClick={() => {
|
||||
const element = document.getElementById(`column-${column.id}`)
|
||||
element?.scrollIntoView({ block: 'nearest', inline: 'start' })
|
||||
element
|
||||
?.closest('.deck-column')
|
||||
?.querySelector<HTMLElement>('.deck-column-results')
|
||||
?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<Icon name={column.source.kind} />
|
||||
<span>{column.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<p className="sidebar-note">このブラウザに保存</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="deck-heading">
|
||||
<div>
|
||||
<h1>{deck.title}</h1>
|
||||
<p className="deck-help">
|
||||
観点ごとに検索を並べて、投稿を読み比べる。
|
||||
</p>
|
||||
<span className="deck-count">
|
||||
{deck.columns.length} / {MAX_COLUMNS} カラム
|
||||
</span>
|
||||
</div>
|
||||
<div className="deck-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="名前を変更"
|
||||
title="名前を変更"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setRenaming(!renaming)}
|
||||
>
|
||||
名前を変更
|
||||
</button>
|
||||
<button
|
||||
className="deck-primary"
|
||||
type="button"
|
||||
disabled={
|
||||
!ready || deck.columns.length >= MAX_COLUMNS || editing !== null
|
||||
}
|
||||
onClick={() => setEditing('new')}
|
||||
>
|
||||
カラムを追加
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="deck-save-status">
|
||||
{!ready
|
||||
? 'デッキを読み込んでいます…'
|
||||
: storageError
|
||||
? 'デッキの保存状態を確認してください。'
|
||||
: `このブラウザにデッキの条件を保存 · ${deck.columns.length} / ${MAX_COLUMNS} カラム`}
|
||||
</p>
|
||||
{!ready ? (
|
||||
<p className="deck-save-status" role="status">
|
||||
デッキを読み込んでいます…
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.isError ? (
|
||||
<p role="alert" className="deck-error">
|
||||
接続プロファイルを取得できませんでした。
|
||||
<button type="button" onClick={() => void profiles.refetch()}>
|
||||
再試行
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.isPending ? (
|
||||
<p role="status">接続プロファイルを取得しています…</p>
|
||||
) : null}
|
||||
{storageError ? (
|
||||
<p className="deck-error" role="alert">
|
||||
{storageError}
|
||||
</p>
|
||||
) : null}
|
||||
{renaming ? (
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
save({ ...deck, title })
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
save({ ...deck, title })
|
||||
setRenaming(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="deck-title">
|
||||
調査テーマ・デッキ名
|
||||
<input
|
||||
id="deck-title"
|
||||
name="title"
|
||||
defaultValue={deck.title}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">名前を保存</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{editing !== null ? (
|
||||
<ColumnEditor
|
||||
key={editing === 'new' ? 'new' : editing.id}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
<Dialog
|
||||
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
|
||||
onClose={() => setEditing(null)}
|
||||
>
|
||||
<ColumnEditor
|
||||
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}-${profiles.data?.profiles.join(',')}`}
|
||||
profiles={profiles.data?.profiles ?? []}
|
||||
column={
|
||||
editing === 'new'
|
||||
? undefined
|
||||
: deck.columns.find((column) => column.id === editing.id)
|
||||
}
|
||||
onSave={saveColumn}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{removed ? (
|
||||
<div className="deck-undo" role="status">
|
||||
@@ -139,9 +234,10 @@ export function DeckPage() {
|
||||
) : null}
|
||||
{ready && deck.columns.length === 0 && editing === null ? (
|
||||
<div className="deck-empty">
|
||||
<h2>最初の観点を追加しましょう。</h2>
|
||||
<Icon name="columns" />
|
||||
<h2>調査のカラムを並べましょう</h2>
|
||||
<p>
|
||||
「日本語での反応」「発表元の投稿」など、調べたい切り口に名前と検索条件を付けてください。
|
||||
検索、ユーザー投稿、リスト。読みたい情報を選んで、ここに追加できます。
|
||||
</p>
|
||||
<button
|
||||
className="deck-primary"
|
||||
@@ -157,7 +253,8 @@ export function DeckPage() {
|
||||
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
|
||||
{deck.columns.map((column, index) => (
|
||||
<ResearchColumn
|
||||
key={column.id}
|
||||
key={`${deck.id}:${column.id}`}
|
||||
registry={registry}
|
||||
column={column}
|
||||
first={index === 0}
|
||||
last={index === deck.columns.length - 1}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog } from '#/components/dialog'
|
||||
import { Icon } from '#/components/icon'
|
||||
import type { Deck } from './model'
|
||||
|
||||
export function DeckSwitcher({
|
||||
decks,
|
||||
activeId,
|
||||
ready,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
}: {
|
||||
decks: Deck[]
|
||||
activeId: string
|
||||
ready: boolean
|
||||
onSelect: (id: string) => void
|
||||
onCreate: (title: string) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
return (
|
||||
<div className="deck-workspaces">
|
||||
<div className="deck-actions">
|
||||
<label htmlFor="deck-selection">デッキプロファイル</label>
|
||||
<select
|
||||
id="deck-selection"
|
||||
value={activeId}
|
||||
disabled={!ready}
|
||||
onChange={(event) => {
|
||||
onSelect(event.target.value)
|
||||
setCreating(false)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="workspace-create"
|
||||
aria-label="デッキを作成"
|
||||
title="デッキを作成"
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
setCreating(!creating)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
<span>デッキを作成</span>
|
||||
</button>
|
||||
<button
|
||||
className="workspace-delete"
|
||||
aria-label="デッキを削除"
|
||||
title="デッキを削除"
|
||||
type="button"
|
||||
disabled={!ready || decks.length <= 1}
|
||||
onClick={() => {
|
||||
setDeleting(!deleting)
|
||||
setCreating(false)
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
<span>デッキを削除</span>
|
||||
</button>
|
||||
</div>
|
||||
{creating ? (
|
||||
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
|
||||
<form
|
||||
className="deck-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const title = String(
|
||||
new FormData(event.currentTarget).get('title') ?? '',
|
||||
).trim()
|
||||
if (!title) return
|
||||
onCreate(title)
|
||||
setCreating(false)
|
||||
}}
|
||||
>
|
||||
<label htmlFor="new-deck-title">
|
||||
新しいデッキ名
|
||||
<input
|
||||
id="new-deck-title"
|
||||
name="title"
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">作成</button>
|
||||
<button type="button" onClick={() => setCreating(false)}>
|
||||
キャンセル
|
||||
</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
) : null}
|
||||
{deleting ? (
|
||||
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
|
||||
<div className="deck-undo">
|
||||
<p>このデッキとカラムの設定を削除します。</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
削除する
|
||||
</button>
|
||||
<button type="button" onClick={() => setDeleting(false)}>
|
||||
キャンセル
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+381
-142
@@ -1,61 +1,68 @@
|
||||
/* Hallmark · existing Garden tokens · research deck: horizontal columns
|
||||
* User-requested application layout; inherit the reader's typography and color.
|
||||
* pre-emit critique: P4 H4 E4 S5 R5 V4
|
||||
*/
|
||||
.app-deck .reader {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-deck button,
|
||||
.app-deck input,
|
||||
.app-deck select {
|
||||
font: inherit;
|
||||
}
|
||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||
.app-deck button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
min-height: 36px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--dur-short) var(--ease-out);
|
||||
}
|
||||
.app-deck button:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.app-deck button:active:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-deck button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.app-deck :focus-visible {
|
||||
outline: var(--rule-active) solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
.app-deck .icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: var(--space-xs);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.app-deck .deck-primary {
|
||||
color: var(--color-accent-ink);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.app-deck .deck-primary:hover:not(:disabled),
|
||||
.app-deck .deck-primary:active:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
color: var(--color-accent-ink);
|
||||
}
|
||||
.deck-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-sm);
|
||||
min-height: 58px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
.deck-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-heading h1 {
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-heading p {
|
||||
margin: var(--space-2xs) 0 0;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-actions {
|
||||
display: flex;
|
||||
@@ -67,131 +74,82 @@
|
||||
.deck-save-status,
|
||||
.deck-column-status {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.deck-save-status {
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
.deck-editor {
|
||||
margin-block: var(--space-lg);
|
||||
padding-block: var(--space-lg);
|
||||
border-block: var(--rule-hairline) solid var(--border);
|
||||
}
|
||||
.deck-editor h2,
|
||||
.deck-empty h2 {
|
||||
font-size: var(--text-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-editor-fields {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-editor label,
|
||||
.deck-rename label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-editor input:not([type="checkbox"]),
|
||||
.deck-editor select,
|
||||
.deck-rename input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
.deck-editor .deck-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.deck-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.deck-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-sm);
|
||||
margin-block: var(--space-md);
|
||||
}
|
||||
.deck-rename label {
|
||||
flex: 1 1 15rem;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.deck-undo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-block: var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-empty {
|
||||
max-width: 40rem;
|
||||
padding-block: var(--space-2xl);
|
||||
}
|
||||
.deck-empty p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: var(--space-lg);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-md) var(--space-xs);
|
||||
}
|
||||
.deck-board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: min(100%, 23rem);
|
||||
gap: var(--space-md);
|
||||
grid-auto-columns: 328px;
|
||||
justify-content: start;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
padding: var(--space-2xs) var(--space-2xs) var(--space-md);
|
||||
overflow-y: hidden;
|
||||
padding: 0 var(--space-xs) var(--space-xs);
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-color: var(--control-border) var(--canvas);
|
||||
}
|
||||
.deck-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: max(30rem, calc(100svh - 20rem));
|
||||
border: var(--rule-hairline) solid var(--border);
|
||||
background: var(--paper);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.deck-column-header {
|
||||
padding: var(--space-md);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.deck-column-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.deck-column-title > svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
.deck-column h2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-column-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column h2 {
|
||||
margin: var(--space-sm) 0;
|
||||
font-size: var(--text-md);
|
||||
.deck-column-label span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-query {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 4.5em;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-2xs) 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deck-column-status {
|
||||
margin-bottom: 0;
|
||||
margin: var(--space-2xs) 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.deck-column-results {
|
||||
@@ -199,27 +157,70 @@
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--control-border) var(--paper);
|
||||
}
|
||||
.column-menu {
|
||||
position: relative;
|
||||
}
|
||||
.column-menu summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.column-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.column-menu summary:hover,
|
||||
.column-menu[open] summary {
|
||||
background: var(--color-paper-3);
|
||||
}
|
||||
.column-menu-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 158px;
|
||||
padding: var(--space-2xs);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
|
||||
}
|
||||
.column-menu-panel button {
|
||||
justify-content: flex-start;
|
||||
border: 0;
|
||||
}
|
||||
.deck-message {
|
||||
padding: var(--space-md);
|
||||
color: var(--secondary);
|
||||
line-height: 1.7;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.research-post {
|
||||
padding: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
border-bottom: var(--rule-hairline) solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.research-post header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.research-post header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.research-post header img {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
.research-post strong {
|
||||
font-size: var(--text-sm);
|
||||
@@ -227,20 +228,22 @@
|
||||
.research-post-text,
|
||||
.research-post blockquote p {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
line-height: 1.55;
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
.research-post blockquote {
|
||||
margin: var(--space-md) 0;
|
||||
padding-left: var(--space-md);
|
||||
border-left: var(--rule-hairline) solid var(--border);
|
||||
margin: var(--space-sm) 0;
|
||||
padding: var(--space-sm);
|
||||
border: var(--rule-hairline) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.research-post footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
@@ -253,22 +256,258 @@
|
||||
width: 100%;
|
||||
max-height: 20rem;
|
||||
object-fit: contain;
|
||||
margin-block: var(--space-sm);
|
||||
margin-block: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@media (min-width: 52rem) {
|
||||
.app.app-deck {
|
||||
width: 100%;
|
||||
grid-template-columns: 11rem minmax(0, 1fr);
|
||||
.deck-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
|
||||
}
|
||||
.deck-dialog::backdrop {
|
||||
background: var(--color-overlay);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.dialog-heading h2,
|
||||
.deck-editor h2,
|
||||
.deck-empty h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.deck-editor {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.deck-editor-fields,
|
||||
.deck-source-fields {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.deck-source-fields {
|
||||
margin-block: var(--space-md);
|
||||
}
|
||||
.deck-editor label,
|
||||
.deck-rename label {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-editor input:not([type="checkbox"]),
|
||||
.deck-editor select,
|
||||
.deck-rename input,
|
||||
.deck-workspaces select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: var(--space-xs);
|
||||
border: var(--rule-hairline) solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
}
|
||||
.deck-editor .deck-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.deck-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.deck-rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.deck-rename label {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-error {
|
||||
color: var(--error);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reader > .deck-error {
|
||||
margin: 0;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
.deck-undo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.deck-undo p {
|
||||
margin: 0;
|
||||
}
|
||||
.deck-empty {
|
||||
margin: auto;
|
||||
max-width: 32rem;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.deck-empty p {
|
||||
color: var(--secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.deck-workspaces {
|
||||
min-width: 0;
|
||||
}
|
||||
.deck-workspaces > .deck-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
width: 100%;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
flex: 1;
|
||||
}
|
||||
.deck-list-choices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.app-deck .sidebar-add {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.deck-workspaces {
|
||||
flex: 1;
|
||||
}
|
||||
.app-deck .reader {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
.deck-workspaces > .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.deck-editor-fields {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(7rem, 0.5fr);
|
||||
.deck-workspaces > .deck-actions > label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
.deck-workspaces > .deck-actions > select {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
}
|
||||
.deck-workspaces > .deck-actions > button {
|
||||
flex: none;
|
||||
}
|
||||
.app-deck .sidebar-add {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
.sidebar-add span {
|
||||
display: none;
|
||||
}
|
||||
.deck-heading {
|
||||
min-height: 52px;
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
.deck-heading .deck-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.deck-board {
|
||||
grid-auto-columns: min(328px, 100%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-deck button {
|
||||
transition: none;
|
||||
@media (pointer: coarse) {
|
||||
.app-deck .icon-button,
|
||||
.column-menu summary {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
.deck-count {
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.research-post header time {
|
||||
display: block;
|
||||
color: var(--secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.deck-column-status {
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.workspace-create span,
|
||||
.workspace-delete span {
|
||||
display: none;
|
||||
}
|
||||
.app-deck .workspace-create,
|
||||
.app-deck .workspace-delete {
|
||||
width: 36px;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
}
|
||||
.sidebar-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2xs);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.app-deck .sidebar-columns button {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.sidebar-columns span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.research-post header > div {
|
||||
flex: 1;
|
||||
}
|
||||
.research-post header time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.research-post footer a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
.research-post footer svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.column-menu-panel .danger {
|
||||
color: var(--error);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.sidebar-columns {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type Deck, deckSchema, moveColumn } from './model'
|
||||
import { type Deck, deckSchema, moveColumn, workspaceSchema } from './model'
|
||||
|
||||
const deck: Deck = {
|
||||
version: 1,
|
||||
id: 'research',
|
||||
title: '調査',
|
||||
columns: ['a', 'b'].map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
profileName: 'main',
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'AI lang:ja',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
describe('deck definitions', () => {
|
||||
it('preserves native Twitter search conditions when serialized', () => {
|
||||
it('preserves native search and the bound relay profile when serialized', () => {
|
||||
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
|
||||
})
|
||||
it('rejects duplicate column identities and unsupported platforms', () => {
|
||||
it('normalizes user and list targets and rejects invalid targets', () => {
|
||||
const column = deck.columns[0]
|
||||
expect(
|
||||
deckSchema.parse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { platform: 'twitter', kind: 'user', target: '@alice' },
|
||||
},
|
||||
],
|
||||
}).columns[0]?.source,
|
||||
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
|
||||
expect(
|
||||
deckSchema.parse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'list',
|
||||
target: 'https://x.com/i/lists/123',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).columns[0]?.source,
|
||||
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects duplicate identities and missing profiles', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
@@ -30,13 +71,25 @@ describe('deck definitions', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
columns: [
|
||||
{ ...deck.columns[0], source: { platform: 'nostr', query: 'AI' } },
|
||||
],
|
||||
columns: [{ ...deck.columns[0], profileName: '' }],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
workspaceSchema.safeParse({
|
||||
version: 2,
|
||||
activeDeckId: 'missing',
|
||||
decks: [deck],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
workspaceSchema.safeParse({
|
||||
version: 2,
|
||||
activeDeckId: deck.id,
|
||||
decks: [deck, deck],
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('rejects queries that exceed the upstream limit after adding follows', () => {
|
||||
it('rejects queries exceeding the upstream limit after adding follows', () => {
|
||||
expect(
|
||||
deckSchema.safeParse({
|
||||
...deck,
|
||||
@@ -45,6 +98,7 @@ describe('deck definitions', () => {
|
||||
...deck.columns[0],
|
||||
source: {
|
||||
platform: 'twitter',
|
||||
kind: 'search',
|
||||
query: 'a'.repeat(512),
|
||||
product: 'Latest',
|
||||
following: true,
|
||||
@@ -54,7 +108,7 @@ describe('deck definitions', () => {
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
it('moves a column without changing its query or the previous deck', () => {
|
||||
it('moves a column without changing its conditions or the previous deck', () => {
|
||||
expect(moveColumn(deck, 'b', -1).columns).toEqual([
|
||||
deck.columns[1],
|
||||
deck.columns[0],
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { buildSearchQuery } from '#/features/posts/inputs'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
normalizeListTarget,
|
||||
normalizeUserTarget,
|
||||
} from '#/features/posts/inputs'
|
||||
|
||||
export const MAX_COLUMNS = 6
|
||||
|
||||
// Queries stay native to the provider. Add a discriminated-union member when
|
||||
// the next connector is implemented instead of inventing a shared search DSL.
|
||||
const columnSourceSchema = z
|
||||
const searchSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']),
|
||||
following: z.boolean(),
|
||||
@@ -27,17 +30,57 @@ const columnSourceSchema = z
|
||||
}
|
||||
})
|
||||
|
||||
function targetSchema(normalize: (target: string) => string) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.transform((target, ctx) => {
|
||||
try {
|
||||
return normalize(target)
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
error instanceof Error ? error.message : '対象を確認してください。',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const userSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('user'),
|
||||
target: targetSchema(normalizeUserTarget),
|
||||
})
|
||||
.strict()
|
||||
const listSourceSchema = z
|
||||
.object({
|
||||
platform: z.literal('twitter'),
|
||||
kind: z.literal('list'),
|
||||
target: targetSchema(normalizeListTarget),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const columnSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(80),
|
||||
source: columnSourceSchema,
|
||||
profileName: z.string().min(1),
|
||||
source: z.discriminatedUnion('kind', [
|
||||
searchSourceSchema,
|
||||
userSourceSchema,
|
||||
listSourceSchema,
|
||||
]),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const deckSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
id: z.string().min(1).max(128),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
columns: z.array(columnSchema).max(MAX_COLUMNS),
|
||||
})
|
||||
@@ -49,10 +92,41 @@ export const deckSchema = z
|
||||
'カラム ID が重複しています。',
|
||||
)
|
||||
|
||||
export const workspaceSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
activeDeckId: deckSchema.shape.id,
|
||||
decks: z.array(deckSchema).min(1),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(workspace) =>
|
||||
new Set(workspace.decks.map((deck) => deck.id)).size ===
|
||||
workspace.decks.length,
|
||||
'デッキ ID が重複しています。',
|
||||
)
|
||||
.refine(
|
||||
(workspace) =>
|
||||
workspace.decks.some((deck) => deck.id === workspace.activeDeckId),
|
||||
'選択したデッキが見つかりません。',
|
||||
)
|
||||
|
||||
export type Deck = z.infer<typeof deckSchema>
|
||||
export type DeckColumn = z.infer<typeof columnSchema>
|
||||
|
||||
export const EMPTY_DECK: Deck = { version: 1, title: '新しい調査', columns: [] }
|
||||
export type Workspace = z.infer<typeof workspaceSchema>
|
||||
export const EMPTY_DECK: Deck = {
|
||||
id: 'initial',
|
||||
title: '新しい調査',
|
||||
columns: [],
|
||||
}
|
||||
export const EMPTY_WORKSPACE: Workspace = {
|
||||
version: 2,
|
||||
activeDeckId: EMPTY_DECK.id,
|
||||
decks: [EMPTY_DECK],
|
||||
}
|
||||
export function newId() {
|
||||
return crypto.getRandomValues(new Uint32Array(4)).join('-')
|
||||
}
|
||||
|
||||
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
|
||||
const index = deck.columns.findIndex((column) => column.id === id)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Icon } from '#/components/icon'
|
||||
import { PostText } from '#/components/post-text'
|
||||
import type { ResearchPost } from '#/features/platforms/types'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
@@ -15,15 +18,22 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
width={36}
|
||||
height={36}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">{post.author.handle}</span>
|
||||
<span className="handle">
|
||||
@{post.author.handle.replace(/^@/, '')}
|
||||
</span>
|
||||
</div>
|
||||
{post.createdAt ? (
|
||||
<time dateTime={post.createdAt} title={post.createdAt}>
|
||||
{dateFormatter.format(new Date(post.createdAt))}
|
||||
</time>
|
||||
) : null}
|
||||
</header>
|
||||
<p className="research-post-text">
|
||||
<PostText text={post.text} />
|
||||
@@ -64,13 +74,8 @@ export function ResearchPostCard({ post }: { post: ResearchPost }) {
|
||||
</blockquote>
|
||||
) : null}
|
||||
<footer>
|
||||
{post.createdAt ? (
|
||||
<time dateTime={post.createdAt}>
|
||||
{dateFormatter.format(new Date(post.createdAt))}
|
||||
</time>
|
||||
) : null}
|
||||
<a href={post.url} target="_blank" rel="noreferrer noopener">
|
||||
元の投稿を開く
|
||||
元の投稿を開く <Icon name="arrow" />
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EMPTY_DECK } from './model'
|
||||
import { EMPTY_DECK, EMPTY_WORKSPACE } from './model'
|
||||
import { DECK_STORAGE_KEY, useDeck } from './use-deck'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('saved deck', () => {
|
||||
describe('saved decks', () => {
|
||||
it('restores saved conditions without overwriting them during mount', () => {
|
||||
const saved = { ...EMPTY_DECK, title: 'WebMCP調査' }
|
||||
const deck = { ...EMPTY_DECK, title: 'WebMCP調査' }
|
||||
const saved = { ...EMPTY_WORKSPACE, decks: [deck] }
|
||||
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(saved))
|
||||
const { result } = renderHook(useDeck)
|
||||
expect(result.current.ready).toBe(true)
|
||||
expect(result.current.deck).toEqual(saved)
|
||||
expect(result.current.deck).toEqual(deck)
|
||||
expect(JSON.parse(localStorage.getItem(DECK_STORAGE_KEY) ?? '')).toEqual(
|
||||
saved,
|
||||
)
|
||||
@@ -25,21 +25,42 @@ describe('saved deck', () => {
|
||||
expect(result.current.storageError).toContain('読み込めません')
|
||||
expect(localStorage.getItem(DECK_STORAGE_KEY)).toBe('{broken')
|
||||
})
|
||||
it('persists edits and restores them on remount', () => {
|
||||
const first = renderHook(useDeck)
|
||||
act(() =>
|
||||
first.result.current.save({ ...EMPTY_DECK, title: '保存した調査' }),
|
||||
)
|
||||
first.unmount()
|
||||
expect(renderHook(useDeck).result.current.deck.title).toBe('保存した調査')
|
||||
})
|
||||
it('reports failed writes instead of claiming that changes were saved', () => {
|
||||
it('reports failed writes while keeping changes usable in memory', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
const { result } = renderHook(useDeck)
|
||||
act(() => result.current.save({ ...EMPTY_DECK, title: '未保存' }))
|
||||
let persisted = true
|
||||
act(() => {
|
||||
persisted = result.current.save({ ...EMPTY_DECK, title: '未保存' })
|
||||
})
|
||||
expect(persisted).toBe(false)
|
||||
expect(result.current.deck.title).toBe('未保存')
|
||||
expect(result.current.storageError).toContain('保存できません')
|
||||
})
|
||||
it('keeps decks isolated through immediate create, edit, switch and reload', () => {
|
||||
const { result, unmount } = renderHook(useDeck)
|
||||
const firstId = result.current.deck.id
|
||||
let secondId = ''
|
||||
act(() => {
|
||||
result.current.create('第二の調査')
|
||||
secondId = result.current.getWorkspace().activeDeckId
|
||||
result.current.save({
|
||||
id: secondId,
|
||||
title: '変更した第二の調査',
|
||||
columns: [],
|
||||
})
|
||||
result.current.select(firstId)
|
||||
})
|
||||
expect(result.current.deck.title).toBe('新しい調査')
|
||||
expect(result.current.workspace.decks).toHaveLength(2)
|
||||
unmount()
|
||||
const restored = renderHook(useDeck)
|
||||
act(() => restored.result.current.select(secondId))
|
||||
expect(restored.result.current.deck.title).toBe('変更した第二の調査')
|
||||
act(() => restored.result.current.remove(secondId))
|
||||
expect(restored.result.current.deck.id).toBe(firstId)
|
||||
expect(() => restored.result.current.remove(firstId)).toThrow('最後')
|
||||
expect(() => restored.result.current.select('missing')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { type Deck, deckSchema, EMPTY_DECK } from './model'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type Deck,
|
||||
deckSchema,
|
||||
EMPTY_DECK,
|
||||
EMPTY_WORKSPACE,
|
||||
newId,
|
||||
type Workspace,
|
||||
workspaceSchema,
|
||||
} from './model'
|
||||
|
||||
export const DECK_STORAGE_KEY = 'twitter-lite-research-deck'
|
||||
|
||||
export function useDeck() {
|
||||
const [deck, setDeck] = useState<Deck>(EMPTY_DECK)
|
||||
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
|
||||
const current = useRef(workspace)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [storageError, setStorageError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DECK_STORAGE_KEY)
|
||||
if (saved !== null) setDeck(deckSchema.parse(JSON.parse(saved)))
|
||||
if (saved !== null) {
|
||||
const restored = workspaceSchema.parse(JSON.parse(saved))
|
||||
current.current = restored
|
||||
setWorkspace(restored)
|
||||
}
|
||||
} catch {
|
||||
setStorageError(
|
||||
'保存したデッキを読み込めませんでした。編集内容を保存すると、このブラウザのデッキを置き換えます。',
|
||||
@@ -20,9 +33,10 @@ export function useDeck() {
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
function save(next: Deck) {
|
||||
const validated = deckSchema.parse(next)
|
||||
setDeck(validated)
|
||||
function persist(next: Workspace) {
|
||||
const validated = workspaceSchema.parse(next)
|
||||
current.current = validated
|
||||
setWorkspace(validated)
|
||||
try {
|
||||
localStorage.setItem(DECK_STORAGE_KEY, JSON.stringify(validated))
|
||||
setStorageError(undefined)
|
||||
@@ -35,5 +49,56 @@ export function useDeck() {
|
||||
}
|
||||
}
|
||||
|
||||
return { deck, ready, storageError, save }
|
||||
function save(next: Deck) {
|
||||
const validated = deckSchema.parse(next)
|
||||
const previous = current.current
|
||||
const exists = previous.decks.some((deck) => deck.id === validated.id)
|
||||
return persist({
|
||||
...previous,
|
||||
activeDeckId: validated.id,
|
||||
decks: exists
|
||||
? previous.decks.map((deck) =>
|
||||
deck.id === validated.id ? validated : deck,
|
||||
)
|
||||
: [...previous.decks, validated],
|
||||
})
|
||||
}
|
||||
|
||||
function create(title: string) {
|
||||
return save({ id: newId(), title, columns: [] })
|
||||
}
|
||||
function select(id: string) {
|
||||
if (!current.current.decks.some((deck) => deck.id === id))
|
||||
throw new Error('デッキが見つかりません。')
|
||||
return persist({ ...current.current, activeDeckId: id })
|
||||
}
|
||||
function remove(id: string) {
|
||||
const previous = current.current
|
||||
if (!previous.decks.some((deck) => deck.id === id))
|
||||
throw new Error('デッキが見つかりません。')
|
||||
const decks = previous.decks.filter((deck) => deck.id !== id)
|
||||
const first = decks[0]
|
||||
if (!first) throw new Error('最後のデッキは削除できません。')
|
||||
return persist({
|
||||
...previous,
|
||||
decks,
|
||||
activeDeckId:
|
||||
previous.activeDeckId === id ? first.id : previous.activeDeckId,
|
||||
})
|
||||
}
|
||||
|
||||
const deck =
|
||||
workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ??
|
||||
EMPTY_DECK
|
||||
return {
|
||||
deck,
|
||||
workspace,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
create,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace: () => current.current,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
export function useWebMCPSupported() {
|
||||
const [supported, setSupported] = useState(false)
|
||||
useEffect(() => {
|
||||
setSupported(Boolean(document.modelContext))
|
||||
}, [])
|
||||
return supported
|
||||
}
|
||||
@@ -1,60 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { prepareDeck } from './webmcp-contracts'
|
||||
|
||||
const column = {
|
||||
title: '観点',
|
||||
profileName: 'main',
|
||||
source: { kind: 'search', query: 'AI' },
|
||||
}
|
||||
describe('deck tool input', () => {
|
||||
it('fills Twitter defaults and assigns independent IDs to new columns', () => {
|
||||
const deck = prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ title: '日本語', source: { query: 'WebMCP lang:ja' } },
|
||||
{ title: '海外', source: { query: 'WebMCP lang:en' } },
|
||||
],
|
||||
})
|
||||
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
|
||||
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
|
||||
'main',
|
||||
])
|
||||
expect(deck.columns[0]?.source).toEqual({
|
||||
platform: 'twitter',
|
||||
query: 'WebMCP lang:ja',
|
||||
kind: 'search',
|
||||
query: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
})
|
||||
expect(new Set(deck.columns.map((column) => column.id)).size).toBe(2)
|
||||
})
|
||||
it('keeps existing IDs and rejects duplicates before applying a deck', () => {
|
||||
const column = { id: 'existing', title: '観点', source: { query: 'AI' } }
|
||||
expect(
|
||||
prepareDeck({ title: '調査', columns: [column] }).columns[0]?.id,
|
||||
).toBe('existing')
|
||||
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
|
||||
).toBe(3)
|
||||
})
|
||||
it('keeps supplied identities and rejects duplicates', () => {
|
||||
const identified = { ...column, id: 'existing' }
|
||||
expect(
|
||||
prepareDeck(
|
||||
{ deckId: 'research', title: '調査', columns: [identified] },
|
||||
['main'],
|
||||
),
|
||||
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [column, column] }),
|
||||
prepareDeck({ title: '調査', columns: [identified, identified] }, [
|
||||
'main',
|
||||
]),
|
||||
).toThrow()
|
||||
})
|
||||
it('rejects unavailable profiles before applying a definition', () => {
|
||||
expect(() =>
|
||||
prepareDeck({ title: '調査', columns: [column] }, ['work']),
|
||||
).toThrow('Unknown relay profile')
|
||||
})
|
||||
it('accepts user and list columns bound to separate profiles', () => {
|
||||
const deck = prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ ...column, source: { kind: 'user', target: '@alice' } },
|
||||
{
|
||||
...column,
|
||||
profileName: 'work',
|
||||
source: { kind: 'list', target: '123' },
|
||||
},
|
||||
],
|
||||
},
|
||||
['main', 'work'],
|
||||
)
|
||||
expect(deck.columns.map((column) => column.profileName)).toEqual([
|
||||
'main',
|
||||
'work',
|
||||
])
|
||||
expect(deck.columns.map((column) => column.source.kind)).toEqual([
|
||||
'user',
|
||||
'list',
|
||||
])
|
||||
})
|
||||
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{ title: '観点', source: { platform: 'nostr', query: 'AI' } },
|
||||
],
|
||||
}),
|
||||
prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: { kind: 'search', platform: 'nostr', query: 'AI' },
|
||||
},
|
||||
],
|
||||
},
|
||||
['main'],
|
||||
),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
title: '観点',
|
||||
source: { query: 'a'.repeat(512), following: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
prepareDeck(
|
||||
{
|
||||
title: '調査',
|
||||
columns: [
|
||||
{
|
||||
...column,
|
||||
source: {
|
||||
kind: 'search',
|
||||
query: 'a'.repeat(512),
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
['main'],
|
||||
),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
prepareDeck({
|
||||
title: '調査',
|
||||
columns: Array.from({ length: 7 }, () => ({
|
||||
title: '観点',
|
||||
source: { query: 'AI' },
|
||||
})),
|
||||
}),
|
||||
prepareDeck(
|
||||
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
|
||||
['main'],
|
||||
),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,102 @@
|
||||
import { z } from 'zod'
|
||||
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
|
||||
import { InputError } from '#/features/posts/inputs'
|
||||
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
|
||||
|
||||
const source = columnSchema.shape.source
|
||||
const toolColumn = columnSchema.omit({ id: true }).extend({
|
||||
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
|
||||
id: columnSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'Keep an existing ID from get_deck when editing; omit for a new column.',
|
||||
'Keep an existing column ID when editing; omit for a new column.',
|
||||
),
|
||||
source: z
|
||||
.object({
|
||||
...source.shape,
|
||||
platform: source.shape.platform.default('twitter'),
|
||||
product: source.shape.product.default('Latest'),
|
||||
following: source.shape.following.default(false),
|
||||
})
|
||||
.strict(),
|
||||
source: z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('search'),
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Latest', 'Top']).default('Latest'),
|
||||
following: z.boolean().default(false),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('user'),
|
||||
target: z.string().min(1).max(256),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
platform: z.literal('twitter').default('twitter'),
|
||||
kind: z.literal('list'),
|
||||
target: z.string().min(1).max(256),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
})
|
||||
|
||||
export const emptyToolInput = z.object({}).strict()
|
||||
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
|
||||
export const getDeckInput = z
|
||||
.object({ deckId: deckSchema.shape.id.optional() })
|
||||
.strict()
|
||||
export const setDeckInput = z
|
||||
.object({
|
||||
title: deckSchema.shape.title.describe('The research topic or deck title.'),
|
||||
deckId: deckSchema.shape.id
|
||||
.optional()
|
||||
.describe(
|
||||
'Omit to create a deck; supply an existing deck ID to replace and activate it.',
|
||||
),
|
||||
title: deckSchema.shape.title,
|
||||
columns: z
|
||||
.array(toolColumn)
|
||||
.max(MAX_COLUMNS)
|
||||
.describe(
|
||||
'Complete ordered column list. Omitted existing columns are removed. Empty list clears the deck.',
|
||||
'Complete ordered columns, each bound to a relay profile from list_decks. Omitted existing columns are removed.',
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function prepareDeck(input: unknown) {
|
||||
export function prepareDeck(input: unknown, profiles: readonly string[]) {
|
||||
const parsed = setDeckInput.parse(input)
|
||||
return deckSchema.parse({
|
||||
version: 1,
|
||||
const deck = deckSchema.parse({
|
||||
id: parsed.deckId ?? newId(),
|
||||
title: parsed.title,
|
||||
columns: parsed.columns.map((column) => ({
|
||||
...column,
|
||||
id: column.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
|
||||
id: column.id ?? newId(),
|
||||
})),
|
||||
})
|
||||
for (const column of deck.columns) {
|
||||
if (!profiles.includes(column.profileName))
|
||||
throw new InputError(`Unknown relay profile: ${column.profileName}`)
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
export async function toolResult(run: () => unknown | Promise<unknown>) {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(await run()) },
|
||||
] as const,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
code:
|
||||
error instanceof z.ZodError || error instanceof InputError
|
||||
? 'invalid-input'
|
||||
: 'tool-error',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Tool execution failed.',
|
||||
retryable: false,
|
||||
}),
|
||||
},
|
||||
] as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,137 @@
|
||||
import { useRef } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import { emptyToolInput, toolResult } from '#/features/posts/webmcp-contracts'
|
||||
import { useWebMCPSupported } from '#/features/posts/webmcp-tools'
|
||||
import type { Deck } from './model'
|
||||
import { prepareDeck, setDeckInput } from './webmcp-contracts'
|
||||
import type { Deck, Workspace } from './model'
|
||||
import { useWebMCPSupported } from './use-webmcp-supported'
|
||||
import {
|
||||
deckIdInput,
|
||||
emptyToolInput,
|
||||
getDeckInput,
|
||||
prepareDeck,
|
||||
setDeckInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
export function useDeckTools({
|
||||
deck,
|
||||
ready,
|
||||
storageError,
|
||||
save,
|
||||
select,
|
||||
remove,
|
||||
getWorkspace,
|
||||
profiles,
|
||||
onApplied,
|
||||
}: {
|
||||
deck: Deck
|
||||
ready: boolean
|
||||
storageError?: string
|
||||
save: (deck: Deck) => boolean
|
||||
select: (id: string) => boolean
|
||||
remove: (id: string) => boolean
|
||||
getWorkspace: () => Workspace
|
||||
profiles: string[] | undefined
|
||||
onApplied: () => void
|
||||
}) {
|
||||
const supported = useWebMCPSupported()
|
||||
const current = useRef({ deck, storageError })
|
||||
current.current = { deck, storageError }
|
||||
|
||||
const enabled = supported && ready
|
||||
function applied(persisted: boolean) {
|
||||
onApplied()
|
||||
if (!persisted)
|
||||
throw new Error(
|
||||
'The deck was applied in this tab but could not be saved. Reloading will lose these changes.',
|
||||
)
|
||||
}
|
||||
useWebMCP({
|
||||
name: 'get_deck',
|
||||
name: 'list_decks',
|
||||
description:
|
||||
'Read the current research deck: title and ordered columns with IDs and native Twitter search conditions. No post fetch. Use this before editing with set_deck. Available on /deck only.',
|
||||
'List saved decks and their columns, activeDeckId and available relay profile names. Conditions are stored in this browser only; posts are not included.',
|
||||
inputSchema: emptyToolInput,
|
||||
enabled: supported && ready,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
emptyToolInput.parse(input)
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
deck: current.current.deck,
|
||||
storageError: current.current.storageError ?? null,
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
profiles: profiles ?? null,
|
||||
storageError: storageError ?? null,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
useWebMCP({
|
||||
name: 'get_deck',
|
||||
description:
|
||||
'Read one saved deck with column IDs, bound relay profiles and source definitions. Omit deckId for the active deck. No post fetch.',
|
||||
inputSchema: getDeckInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = getDeckInput.parse(input)
|
||||
const workspace = getWorkspace()
|
||||
const deck = workspace.decks.find(
|
||||
(deck) => deck.id === (deckId ?? workspace.activeDeckId),
|
||||
)
|
||||
if (!deck) throw new Error('Deck not found.')
|
||||
return { deck, storageError: storageError ?? null }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'set_deck',
|
||||
description:
|
||||
'Create or replace the entire visible research deck and save its conditions in this browser. Supply a title and up to six ordered Twitter search columns. Keep existing column IDs to edit them; omitted columns are removed. Closes unsaved editor forms. Only Twitter is connected. Returns the applied definition immediately; posts load asynchronously and may fail independently. Available on /deck only.',
|
||||
'Create a saved deck when deckId is omitted, or replace an existing deck by ID, then activate it. Up to six ordered columns: search, user timeline or list, each with an explicit relay profile from list_decks. Omitted columns are removed. Closes unsaved forms. Posts load asynchronously and can fail independently.',
|
||||
inputSchema: setDeckInput,
|
||||
enabled: supported && ready,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
// Validate the whole definition before modifying either storage or UI.
|
||||
const next = prepareDeck(input)
|
||||
const persisted = save(next)
|
||||
onApplied()
|
||||
const error = persisted
|
||||
? undefined
|
||||
: 'The deck was applied in this tab but could not be saved. Reloading will lose these changes.'
|
||||
current.current = { deck: next, storageError: error }
|
||||
if (error) throw new Error(error)
|
||||
const parsed = setDeckInput.parse(input)
|
||||
if (
|
||||
parsed.deckId &&
|
||||
!getWorkspace().decks.some((deck) => deck.id === parsed.deckId)
|
||||
)
|
||||
throw new Error('Deck not found.')
|
||||
if (!profiles) throw new Error('Relay profiles are not available yet.')
|
||||
const next = prepareDeck(parsed, profiles)
|
||||
applied(save(next))
|
||||
return { deck: next, persisted: true, posts: 'loading-asynchronously' }
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'select_deck',
|
||||
description:
|
||||
'Activate a saved deck by ID and save the selection in this browser. Closes unsaved forms; its columns load asynchronously.',
|
||||
inputSchema: deckIdInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = deckIdInput.parse(input)
|
||||
applied(select(deckId))
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
persisted: true,
|
||||
}
|
||||
}),
|
||||
})
|
||||
useWebMCP({
|
||||
name: 'delete_deck',
|
||||
description:
|
||||
'Permanently delete one saved deck and its column definitions from this browser. The last remaining deck cannot be deleted. Deleting the active deck selects the first remaining deck.',
|
||||
inputSchema: deckIdInput,
|
||||
enabled,
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { deckId } = deckIdInput.parse(input)
|
||||
applied(remove(deckId))
|
||||
const workspace = getWorkspace()
|
||||
return {
|
||||
decks: workspace.decks,
|
||||
activeDeckId: workspace.activeDeckId,
|
||||
persisted: true,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TwitterClient } from '@yuta/bird'
|
||||
import type { BirdReader } from './post-service'
|
||||
|
||||
const clients = new Map<string | undefined, TwitterClient>()
|
||||
const clients = new Map<string, TwitterClient>()
|
||||
|
||||
export function getBirdReader(profileName?: string): BirdReader {
|
||||
export function getBirdReader(profileName: string): BirdReader {
|
||||
const existing = clients.get(profileName)
|
||||
if (existing) return existing
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ListForm } from './list-form'
|
||||
import { SearchForm } from './search-form'
|
||||
import { UserForm } from './user-form'
|
||||
|
||||
const searchValues = {
|
||||
q: '',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
product: 'Latest' as const,
|
||||
following: false,
|
||||
}
|
||||
|
||||
describe('UserForm', () => {
|
||||
it('submits only after the user enters a target', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<UserForm initialTarget="" onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ハンドルまたはプロフィール URL'), {
|
||||
target: { value: '@yuta' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('yuta')
|
||||
})
|
||||
|
||||
it('explains invalid input beside the field', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<UserForm initialTarget="" onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'ハンドルまたはプロフィール URL を入力してください。',
|
||||
)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ListForm', () => {
|
||||
it('submits the list ID from a pasted list URL', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('リスト URL または ID'), {
|
||||
target: { value: 'https://x.com/i/lists/1234567890' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('1234567890')
|
||||
})
|
||||
|
||||
it('explains invalid input beside the field', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'リストの URL または ID を入力してください。',
|
||||
)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchForm', () => {
|
||||
it('uses a labelled grouping for ranking controls', () => {
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the selected ranking and follows filter', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('検索語'), {
|
||||
target: { value: ' AI lang:ja ' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('人気順'))
|
||||
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
|
||||
expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
q: 'AI lang:ja',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all',
|
||||
content: 'all',
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('submits conditions selected with the filter buttons', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('検索語'), {
|
||||
target: { value: 'TypeScript' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿者' }))
|
||||
fireEvent.change(screen.getByLabelText('投稿者'), {
|
||||
target: { value: '@tan_stack' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '言語' }))
|
||||
fireEvent.click(screen.getByLabelText('日本語'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿の種類' }))
|
||||
fireEvent.click(screen.getByLabelText('画像'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '除外' }))
|
||||
fireEvent.click(screen.getByLabelText('返信'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
...searchValues,
|
||||
q: 'TypeScript',
|
||||
from: '@tan_stack',
|
||||
lang: 'ja',
|
||||
content: 'images',
|
||||
excludeReplies: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes only the selected condition from its active chip', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<SearchForm
|
||||
initialValues={{ ...searchValues, q: 'AI', from: 'yuta', lang: 'ja' }}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
...searchValues,
|
||||
q: 'AI',
|
||||
lang: 'ja',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { normalizeListTarget } from '../inputs'
|
||||
|
||||
type Props = {
|
||||
initialTarget: string
|
||||
onSubmit: (target: string) => void
|
||||
}
|
||||
|
||||
export function ListForm({ initialTarget, onSubmit }: Props) {
|
||||
const [target, setTarget] = useState(initialTarget)
|
||||
const [error, setError] = useState('')
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const listId = normalizeListTarget(target)
|
||||
setError('')
|
||||
onSubmit(listId)
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="intent-form" onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>リスト URL または ID</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? 'list-error' : undefined}
|
||||
aria-invalid={Boolean(error)}
|
||||
name="target"
|
||||
value={target}
|
||||
onChange={(event) => setTarget(event.currentTarget.value)}
|
||||
placeholder="https://x.com/i/lists/1234567890 または ID"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="field-error" id="list-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<button type="submit">表示</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ListChoicesLoadError, useListChoices } from '../use-list-choices'
|
||||
|
||||
export function ListPicker({ selectedId }: { selectedId?: string }) {
|
||||
const query = useListChoices()
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||
<h2 id="list-picker-title">自分のリスト</h2>
|
||||
<p className="list-picker-state" role="status">
|
||||
リストを取得しています…
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
const error =
|
||||
query.error instanceof ListChoicesLoadError
|
||||
? query.error.detail
|
||||
: { message: 'リストを取得できませんでした。', retryable: true }
|
||||
return (
|
||||
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||
<h2 id="list-picker-title">自分のリスト</h2>
|
||||
<div className="list-picker-state" role="alert">
|
||||
<p>{error.message}</p>
|
||||
{error.retryable ? (
|
||||
<button onClick={() => void query.refetch()} type="button">
|
||||
再試行
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||
<h2 id="list-picker-title">自分のリスト</h2>
|
||||
{query.data.length === 0 ? (
|
||||
<p className="list-picker-state">リストはありません。</p>
|
||||
) : (
|
||||
<ul className="list-choices">
|
||||
{query.data.map((list) => (
|
||||
<li key={list.id}>
|
||||
<Link
|
||||
aria-current={selectedId === list.id ? 'page' : undefined}
|
||||
params={{ listId: list.id }}
|
||||
to="/i/lists/$listId"
|
||||
>
|
||||
<span>{list.name}</span>
|
||||
<small>
|
||||
{list.isPrivate ? '非公開 · ' : ''}
|
||||
{list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー
|
||||
</small>
|
||||
{list.description ? <p>{list.description}</p> : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Post } from '../types'
|
||||
import { PostCard } from './post-card'
|
||||
|
||||
const richPost: Post = {
|
||||
id: '123',
|
||||
text: '詳細 https://content.invalid/article',
|
||||
author: {
|
||||
username: 'yuta',
|
||||
name: 'Yuta',
|
||||
profileImageUrl: 'https://media.invalid/avatar.jpg',
|
||||
},
|
||||
createdAt: '2026-07-13T00:00:00.000Z',
|
||||
replyCount: 3,
|
||||
retweetCount: 4,
|
||||
likeCount: 5,
|
||||
media: [
|
||||
{
|
||||
type: 'photo',
|
||||
url: 'https://media.invalid/photo.jpg',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/video-poster.jpg',
|
||||
videoUrl: 'https://media.invalid/video.mp4',
|
||||
},
|
||||
{
|
||||
type: 'animated_gif',
|
||||
url: 'https://media.invalid/gif-poster.jpg',
|
||||
videoUrl: 'https://media.invalid/animation.mp4',
|
||||
},
|
||||
],
|
||||
article: { title: 'Article title', previewText: 'Preview' },
|
||||
quotedTweet: {
|
||||
id: '122',
|
||||
text: 'quoted',
|
||||
author: { username: 'other', name: 'Other' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('PostCard', () => {
|
||||
it('renders rich read-only content without mutation controls', () => {
|
||||
const { container } = render(<PostCard post={richPost} />)
|
||||
|
||||
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
||||
expect(screen.getByText('@yuta')).toBeInTheDocument()
|
||||
expect(container.querySelector('time')).toHaveAttribute(
|
||||
'datetime',
|
||||
richPost.createdAt,
|
||||
)
|
||||
expect(screen.getByRole('img', { name: '投稿画像' })).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('video')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
||||
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
|
||||
'src',
|
||||
'https://media.invalid/video.mp4',
|
||||
)
|
||||
expect(screen.getByText('Article title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('quoted')).toBeInTheDocument()
|
||||
expect(screen.getByText(/返信 3.*再投稿 4.*いいね 5/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses deliberate safe external links without linking the author', () => {
|
||||
render(<PostCard post={richPost} />)
|
||||
|
||||
const original = screen.getByRole('link', { name: '元の投稿を開く' })
|
||||
expect(original).toHaveAttribute('href', 'https://x.com/yuta/status/123')
|
||||
expect(original).toHaveAttribute('target', '_blank')
|
||||
expect(original).toHaveAttribute('rel', 'noreferrer noopener')
|
||||
|
||||
const textLink = screen.getByRole('link', {
|
||||
name: 'https://content.invalid/article',
|
||||
})
|
||||
expect(textLink).toHaveAttribute('target', '_blank')
|
||||
expect(textLink).toHaveAttribute('rel', 'noreferrer noopener')
|
||||
expect(screen.queryByRole('link', { name: 'Yuta' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Yuta').closest('a')).toBeNull()
|
||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('decodes HTML entities in the post body before linking URLs', () => {
|
||||
render(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
text: 'A > B & C 'D' https://example.com/?a=1&b=2',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText((_, element) =>
|
||||
Boolean(
|
||||
element?.classList.contains('post-text') &&
|
||||
element.textContent ===
|
||||
"A > B & C 'D' https://example.com/?a=1&b=2",
|
||||
),
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
|
||||
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
|
||||
})
|
||||
|
||||
it('links deliberately to the internal detail page', () => {
|
||||
render(<PostCard post={richPost} />)
|
||||
|
||||
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
|
||||
expect(detail).toHaveAttribute('href', '/yuta/status/123')
|
||||
expect(detail.parentElement).toHaveClass('post-actions')
|
||||
expect(
|
||||
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
|
||||
).toBe(detail.parentElement)
|
||||
})
|
||||
|
||||
it('marks the focal post without linking to its current page', () => {
|
||||
render(<PostCard current post={{ ...richPost, quotedTweet: undefined }} />)
|
||||
|
||||
expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull()
|
||||
expect(screen.getByRole('article')).toHaveClass('current-post')
|
||||
})
|
||||
|
||||
it('collapses a malformed optional timestamp without losing the post', () => {
|
||||
const { container } = render(
|
||||
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
||||
expect(container.querySelector('time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not try to play a poster image when Bird has no video URL', () => {
|
||||
const { container } = render(
|
||||
<PostCard
|
||||
post={{
|
||||
...richPost,
|
||||
media: [
|
||||
{
|
||||
type: 'video',
|
||||
url: 'https://media.invalid/video-poster.jpg',
|
||||
previewUrl: 'https://media.invalid/video-preview.jpg',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('video')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('img', { name: '動画のプレビュー' }),
|
||||
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { PostText } from '#/components/post-text'
|
||||
import type { Post } from '../types'
|
||||
import { PostMedia } from './post-media'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Tokyo',
|
||||
})
|
||||
|
||||
function formattedTime(createdAt?: string): string | undefined {
|
||||
if (!createdAt) return undefined
|
||||
const date = new Date(createdAt)
|
||||
return Number.isNaN(date.getTime()) ? undefined : dateFormatter.format(date)
|
||||
}
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
quoted = false,
|
||||
current = false,
|
||||
}: {
|
||||
post: Post
|
||||
quoted?: boolean
|
||||
current?: boolean
|
||||
}) {
|
||||
const time = formattedTime(post.createdAt)
|
||||
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
|
||||
|
||||
return (
|
||||
<article
|
||||
className={quoted ? 'post quote' : current ? 'post current-post' : 'post'}
|
||||
>
|
||||
<header className="post-header">
|
||||
{post.author.profileImageUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="avatar"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
src={post.author.profileImageUrl}
|
||||
width="40"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>{post.author.name}</strong>
|
||||
<span className="handle">@{post.author.username}</span>
|
||||
</div>
|
||||
{time && post.createdAt ? (
|
||||
<time dateTime={post.createdAt}>{time}</time>
|
||||
) : null}
|
||||
</header>
|
||||
<p className="post-text">
|
||||
<PostText text={post.text} />
|
||||
</p>
|
||||
{post.media ? <PostMedia media={post.media} /> : null}
|
||||
{post.article ? (
|
||||
<section className="article-card">
|
||||
<strong>{post.article.title}</strong>
|
||||
{post.article.previewText ? <p>{post.article.previewText}</p> : null}
|
||||
</section>
|
||||
) : null}
|
||||
{post.quotedTweet && !quoted ? (
|
||||
<PostCard post={post.quotedTweet} quoted />
|
||||
) : null}
|
||||
{!quoted ? (
|
||||
<footer className="post-footer">
|
||||
<span className="counts">
|
||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||
{post.likeCount ?? 0}
|
||||
</span>
|
||||
<span className="post-actions">
|
||||
{!current ? (
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Post } from '../types'
|
||||
import { PostLoadError } from '../use-post-feed'
|
||||
import { PostFeed } from './post-feed'
|
||||
|
||||
const usePostFeed = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-post-feed', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../use-post-feed')>()
|
||||
return { ...original, usePostFeed }
|
||||
})
|
||||
|
||||
const observe = vi.fn()
|
||||
const unobserve = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
let observerCallback: IntersectionObserverCallback = () => {}
|
||||
let observerOptions: IntersectionObserverInit | undefined
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
readonly root = null
|
||||
readonly rootMargin: string
|
||||
readonly thresholds: readonly number[] = []
|
||||
|
||||
constructor(
|
||||
callback: IntersectionObserverCallback,
|
||||
options?: IntersectionObserverInit,
|
||||
) {
|
||||
observerCallback = callback
|
||||
observerOptions = options
|
||||
this.rootMargin = options?.rootMargin ?? '0px'
|
||||
}
|
||||
|
||||
disconnect = disconnect
|
||||
observe = observe
|
||||
takeRecords = () => []
|
||||
unobserve = unobserve
|
||||
}
|
||||
|
||||
const post = (id: string): Post => ({
|
||||
id,
|
||||
text: `post-${id}`,
|
||||
author: { username: `user-${id}`, name: `User ${id}` },
|
||||
})
|
||||
|
||||
const queryResult = () => ({
|
||||
data: undefined,
|
||||
error: null,
|
||||
fetchNextPage: vi.fn().mockResolvedValue(undefined),
|
||||
hasNextPage: false,
|
||||
isError: false,
|
||||
isFetchNextPageError: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
|
||||
describe('PostFeed', () => {
|
||||
beforeEach(() => {
|
||||
usePostFeed.mockReset()
|
||||
usePostFeed.mockReturnValue(queryResult() as never)
|
||||
observe.mockReset()
|
||||
unobserve.mockReset()
|
||||
disconnect.mockReset()
|
||||
observerCallback = () => {}
|
||||
observerOptions = undefined
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
FakeIntersectionObserver as unknown as typeof IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('renders no feed state before a deliberate request', () => {
|
||||
render(<PostFeed request={undefined} />)
|
||||
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
expect(screen.queryByRole('article')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('announces the initial loading state', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
isPending: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'投稿を取得しています…',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a quiet empty state', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: { pages: [{ tweets: [] }], pageParams: [undefined] },
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
|
||||
expect(screen.getByText('条件に一致する投稿はありません。')).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('continues from an empty page when a next cursor exists', () => {
|
||||
const fetchNextPage = vi.fn(() => new Promise<never>(() => undefined))
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [{ tweets: [], nextCursor: 'next' }],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
fetchNextPage,
|
||||
hasNextPage: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
act(() => {
|
||||
observerCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByText('条件に一致する投稿はありません。'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deduplicates every page while preserving a quiet end state', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
|
||||
{ tweets: [post('2'), post('3')] },
|
||||
],
|
||||
pageParams: [undefined, 'next'],
|
||||
},
|
||||
} as never)
|
||||
|
||||
const { container } = render(
|
||||
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('article')).toHaveLength(3)
|
||||
expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument()
|
||||
expect(screen.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the focal post once above its deduplicated conversation', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
focalPost: post('2'),
|
||||
conversationId: '1',
|
||||
nextCursor: 'next',
|
||||
tweets: [post('1'), post('2')],
|
||||
},
|
||||
{
|
||||
conversationId: '1',
|
||||
tweets: [post('2'), post('3')],
|
||||
},
|
||||
],
|
||||
pageParams: [undefined, { cursor: 'next', conversationId: '1' }],
|
||||
},
|
||||
} as never)
|
||||
|
||||
const { container } = render(
|
||||
<PostFeed request={{ kind: 'thread', tweetId: '2' }} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('表示中の投稿')).toBeVisible()
|
||||
expect(screen.getAllByText('post-2')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('article')).toHaveLength(3)
|
||||
expect(container.querySelector('.current-post')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the focal post visible when there are no other posts', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
focalPost: post('2'),
|
||||
conversationId: '1',
|
||||
tweets: [post('2')],
|
||||
},
|
||||
],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
|
||||
|
||||
expect(screen.getByText('post-2')).toBeVisible()
|
||||
expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps the focal post and conversation across a later-page error', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
focalPost: post('2'),
|
||||
conversationId: '1',
|
||||
nextCursor: 'next',
|
||||
tweets: [post('1'), post('2')],
|
||||
},
|
||||
],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
error: new PostLoadError({
|
||||
code: 'upstream',
|
||||
message: 'X から投稿を取得できませんでした。',
|
||||
retryable: true,
|
||||
}),
|
||||
hasNextPage: true,
|
||||
isError: true,
|
||||
isFetchNextPageError: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
|
||||
|
||||
expect(screen.getByText('post-2')).toBeVisible()
|
||||
expect(screen.getByText('post-1')).toBeVisible()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'続きの投稿を取得できませんでした。',
|
||||
)
|
||||
})
|
||||
|
||||
it('retries an initial retryable error with refetch', () => {
|
||||
const refetch = vi.fn().mockResolvedValue(undefined)
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
error: new PostLoadError({
|
||||
code: 'upstream',
|
||||
message: '最初の取得に失敗しました。',
|
||||
retryable: true,
|
||||
}),
|
||||
isError: true,
|
||||
refetch,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'最初の取得に失敗しました。',
|
||||
)
|
||||
expect(refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not offer retry for a non-retryable initial error', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
error: new PostLoadError({
|
||||
code: 'invalid-input',
|
||||
message: '入力を確認してください。',
|
||||
retryable: false,
|
||||
}),
|
||||
isError: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'bad' }} />)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'入力を確認してください。',
|
||||
)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps loaded posts while retrying a later page', () => {
|
||||
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [{ tweets: [post('kept')], nextCursor: 'next' }],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
error: new PostLoadError({
|
||||
code: 'timeout',
|
||||
message: '取得がタイムアウトしました。',
|
||||
retryable: true,
|
||||
}),
|
||||
fetchNextPage,
|
||||
hasNextPage: true,
|
||||
isError: true,
|
||||
isFetchNextPageError: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
|
||||
|
||||
expect(screen.getByText('post-kept')).toBeVisible()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'続きの投稿を取得できませんでした。',
|
||||
)
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries a later page after an empty page with fetchNextPage', () => {
|
||||
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
|
||||
const refetch = vi.fn().mockResolvedValue(undefined)
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [{ tweets: [], nextCursor: 'next' }],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
error: new PostLoadError({
|
||||
code: 'timeout',
|
||||
message: '取得がタイムアウトしました。',
|
||||
retryable: true,
|
||||
}),
|
||||
fetchNextPage,
|
||||
hasNextPage: true,
|
||||
isError: true,
|
||||
isFetchNextPageError: true,
|
||||
refetch,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '再試行' }))
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'続きの投稿を取得できませんでした。',
|
||||
)
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('announces next-page loading', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'次の投稿を取得しています…',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not auto-fetch while a next page is already loading', () => {
|
||||
const fetchNextPage = vi.fn().mockResolvedValue(undefined)
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
fetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||
act(() => {
|
||||
observerCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-fetches once near the sentinel and disconnects on cleanup', () => {
|
||||
const fetchNextPage = vi.fn(() => new Promise<never>(() => undefined))
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
fetchNextPage,
|
||||
hasNextPage: true,
|
||||
} as never)
|
||||
|
||||
const { unmount } = render(
|
||||
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
|
||||
)
|
||||
act(() => {
|
||||
const entries = [{ isIntersecting: true } as IntersectionObserverEntry]
|
||||
observerCallback(entries, {} as IntersectionObserver)
|
||||
observerCallback(entries, {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(observerOptions).toEqual({ rootMargin: '600px 0px' })
|
||||
expect(observe).toHaveBeenCalledOnce()
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
|
||||
unmount()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
flattenConversationPages,
|
||||
flattenPostPages,
|
||||
focalPostFromPages,
|
||||
} from '../page'
|
||||
import type { Post } from '../types'
|
||||
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
|
||||
import { useFeedTools } from '../webmcp-tools'
|
||||
import { PostCard } from './post-card'
|
||||
|
||||
function FocalPost({ post }: { post: Post }) {
|
||||
return (
|
||||
<section aria-labelledby="focal-post-label" className="focal-post">
|
||||
<p className="focal-label" id="focal-post-label">
|
||||
表示中の投稿
|
||||
</p>
|
||||
<PostCard current post={post} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
const query = usePostFeed(request)
|
||||
useFeedTools(request, query)
|
||||
const sentinel = useRef<HTMLDivElement>(null)
|
||||
const pages = query.data?.pages ?? []
|
||||
const focalPost =
|
||||
request?.kind === 'thread' ? focalPostFromPages(pages) : undefined
|
||||
const posts = focalPost
|
||||
? flattenConversationPages(pages, focalPost.id)
|
||||
: flattenPostPages(pages)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!sentinel.current ||
|
||||
!query.hasNextPage ||
|
||||
query.isFetchNextPageError ||
|
||||
query.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let requested = false
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !requested) {
|
||||
requested = true
|
||||
void query.fetchNextPage({ cancelRefetch: false })
|
||||
}
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
)
|
||||
observer.observe(sentinel.current)
|
||||
return () => observer.disconnect()
|
||||
}, [
|
||||
query.fetchNextPage,
|
||||
query.hasNextPage,
|
||||
query.isFetchNextPageError,
|
||||
query.isFetchingNextPage,
|
||||
])
|
||||
|
||||
if (!request) return null
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<p className="state" role="status">
|
||||
投稿を取得しています…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (query.isError && !query.isFetchNextPageError && posts.length === 0) {
|
||||
const error =
|
||||
query.error instanceof PostLoadError
|
||||
? query.error.detail
|
||||
: { message: '投稿を取得できませんでした。', retryable: true }
|
||||
return (
|
||||
<div className="state" role="alert">
|
||||
<p>{error.message}</p>
|
||||
{error.retryable ? (
|
||||
<button onClick={() => void query.refetch()} type="button">
|
||||
再試行
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (posts.length === 0 && !query.hasNextPage) {
|
||||
return (
|
||||
<>
|
||||
{focalPost ? <FocalPost post={focalPost} /> : null}
|
||||
<p className="state">
|
||||
{focalPost
|
||||
? '会話にほかの投稿はありません。'
|
||||
: '条件に一致する投稿はありません。'}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{focalPost ? <FocalPost post={focalPost} /> : null}
|
||||
<section aria-live="polite" className="feed">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
<div aria-hidden="true" ref={sentinel} />
|
||||
{query.isFetchingNextPage ? (
|
||||
<div className="loading-rail" role="status">
|
||||
次の投稿を取得しています…
|
||||
</div>
|
||||
) : null}
|
||||
{query.isFetchNextPageError ? (
|
||||
<div className="state" role="alert">
|
||||
<p>続きの投稿を取得できませんでした。</p>
|
||||
<button onClick={() => void query.fetchNextPage()} type="button">
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.hasNextPage ? (
|
||||
<p className="state">これ以上の投稿はありません。</p>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Post } from '../types'
|
||||
|
||||
type Media = NonNullable<Post['media']>[number]
|
||||
|
||||
export function PostMedia({ media }: { media: Media[] }) {
|
||||
if (media.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="media-grid">
|
||||
{media.map((item) => {
|
||||
if (item.type === 'photo' || !item.videoUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
|
||||
className="media"
|
||||
height={item.height}
|
||||
key={item.url}
|
||||
loading="lazy"
|
||||
src={item.previewUrl ?? item.url}
|
||||
width={item.width}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
||||
<video
|
||||
className="media"
|
||||
controls
|
||||
key={item.videoUrl}
|
||||
loop={item.type === 'animated_gif'}
|
||||
poster={item.previewUrl ?? item.url}
|
||||
preload="metadata"
|
||||
src={item.videoUrl}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
|
||||
|
||||
export type SearchValues = AdvancedSearchQuery & {
|
||||
product: SearchProduct
|
||||
following: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialValues: SearchValues
|
||||
onSubmit: (values: SearchValues) => void
|
||||
}
|
||||
|
||||
type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude'
|
||||
|
||||
const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const
|
||||
const contentLabels = {
|
||||
all: 'すべて',
|
||||
images: '画像',
|
||||
videos: '動画',
|
||||
links: 'リンク',
|
||||
} as const
|
||||
|
||||
function localDate(daysAgo: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - daysAgo)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function SearchForm({ initialValues, onSubmit }: Props) {
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [openFilter, setOpenFilter] = useState<FilterName>()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const update = <Key extends keyof SearchValues>(
|
||||
key: Key,
|
||||
value: SearchValues[Key],
|
||||
) => setValues((current) => ({ ...current, [key]: value }))
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = {
|
||||
...values,
|
||||
q: values.q.trim(),
|
||||
from: values.from.trim(),
|
||||
}
|
||||
try {
|
||||
buildFilteredSearchQuery(normalized, normalized.following)
|
||||
setError('')
|
||||
onSubmit(normalized)
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFilter = (filter: FilterName) => {
|
||||
setOpenFilter((current) => (current === filter ? undefined : filter))
|
||||
}
|
||||
|
||||
const setRecentPeriod = (days: number) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: localDate(days - 1),
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
|
||||
let preview = '検索語または投稿者を入力すると表示されます。'
|
||||
try {
|
||||
preview = buildFilteredSearchQuery(values, values.following)
|
||||
} catch {
|
||||
// The submit error gives the actionable message; the preview stays quiet.
|
||||
}
|
||||
|
||||
const filterButtons: Array<{
|
||||
name: FilterName
|
||||
label: string
|
||||
active: boolean
|
||||
}> = [
|
||||
{ name: 'author', label: '投稿者', active: Boolean(values.from) },
|
||||
{
|
||||
name: 'period',
|
||||
label: '期間',
|
||||
active: Boolean(values.since || values.until),
|
||||
},
|
||||
{ name: 'language', label: '言語', active: values.lang !== 'all' },
|
||||
{
|
||||
name: 'content',
|
||||
label: '投稿の種類',
|
||||
active: values.content !== 'all',
|
||||
},
|
||||
{
|
||||
name: 'exclude',
|
||||
label: '除外',
|
||||
active: values.excludeReplies || values.excludeReposts,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<form className="intent-form search-form" onSubmit={submit}>
|
||||
<div className="field search-query">
|
||||
<label htmlFor="search-query">検索語</label>
|
||||
<input
|
||||
aria-describedby="search-query-help"
|
||||
autoComplete="off"
|
||||
id="search-query"
|
||||
name="q"
|
||||
value={values.q}
|
||||
onChange={(event) => update('q', event.currentTarget.value)}
|
||||
placeholder="キーワードや高度な検索式"
|
||||
/>
|
||||
<span className="field-help" id="search-query-help">
|
||||
演算子を直接入力することもできます。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset className="search-filter-buttons">
|
||||
<legend>条件を追加</legend>
|
||||
{filterButtons.map((filter) => (
|
||||
<button
|
||||
aria-controls={`search-filter-${filter.name}`}
|
||||
aria-expanded={openFilter === filter.name}
|
||||
className="filter-button"
|
||||
data-active={filter.active || undefined}
|
||||
key={filter.name}
|
||||
onClick={() => toggleFilter(filter.name)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
{filter.active ? <span aria-hidden="true">●</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{openFilter ? (
|
||||
<fieldset
|
||||
className="search-filter-panel"
|
||||
id={`search-filter-${openFilter}`}
|
||||
>
|
||||
<legend>
|
||||
{filterButtons.find((item) => item.name === openFilter)?.label}
|
||||
の条件
|
||||
</legend>
|
||||
{openFilter === 'author' ? (
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-author">投稿者</label>
|
||||
<input
|
||||
aria-describedby="search-author-help"
|
||||
autoComplete="off"
|
||||
id="search-author"
|
||||
name="from"
|
||||
onChange={(event) => update('from', event.currentTarget.value)}
|
||||
placeholder="@handle"
|
||||
value={values.from}
|
||||
/>
|
||||
<span className="field-help" id="search-author-help">
|
||||
@は省略できます。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'period' ? (
|
||||
<>
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>すぐ選ぶ</legend>
|
||||
<button onClick={() => setRecentPeriod(1)} type="button">
|
||||
今日
|
||||
</button>
|
||||
<button onClick={() => setRecentPeriod(7)} type="button">
|
||||
過去7日
|
||||
</button>
|
||||
<button onClick={() => setRecentPeriod(30)} type="button">
|
||||
過去30日
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: '',
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
すべて
|
||||
</button>
|
||||
</fieldset>
|
||||
<div className="date-fields">
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-since">開始日</label>
|
||||
<input
|
||||
id="search-since"
|
||||
name="since"
|
||||
onChange={(event) =>
|
||||
update('since', event.currentTarget.value)
|
||||
}
|
||||
type="date"
|
||||
value={values.since}
|
||||
/>
|
||||
<span className="field-help" />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-until">
|
||||
終了日(この日は含まない)
|
||||
</label>
|
||||
<input
|
||||
id="search-until"
|
||||
name="until"
|
||||
onChange={(event) =>
|
||||
update('until', event.currentTarget.value)
|
||||
}
|
||||
type="date"
|
||||
value={values.until}
|
||||
/>
|
||||
<span className="field-help" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'language' ? (
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>言語</legend>
|
||||
{(
|
||||
Object.keys(languageLabels) as Array<
|
||||
keyof typeof languageLabels
|
||||
>
|
||||
).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.lang === value}
|
||||
name="lang"
|
||||
onChange={() => update('lang', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{languageLabels[value]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'content' ? (
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>投稿の種類</legend>
|
||||
{(
|
||||
Object.keys(contentLabels) as Array<keyof typeof contentLabels>
|
||||
).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.content === value}
|
||||
name="content"
|
||||
onChange={() => update('content', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{contentLabels[value]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'exclude' ? (
|
||||
<fieldset className="search-exclusions">
|
||||
<legend>検索結果から除外</legend>
|
||||
<label className="check">
|
||||
<input
|
||||
checked={values.excludeReplies}
|
||||
onChange={(event) =>
|
||||
update('excludeReplies', event.currentTarget.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
返信
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
checked={values.excludeReposts}
|
||||
onChange={(event) =>
|
||||
update('excludeReposts', event.currentTarget.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
リポスト
|
||||
</label>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<ul aria-label="選択中の条件" className="active-filters">
|
||||
{values.from ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`投稿者 ${values.from} を解除`}
|
||||
onClick={() => update('from', '')}
|
||||
type="button"
|
||||
>
|
||||
{values.from.startsWith('@') ? values.from : `@${values.from}`}{' '}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.since || values.until ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="期間を解除"
|
||||
onClick={() =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: '',
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{values.since || '指定なし'} → {values.until || '現在'}{' '}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.lang !== 'all' ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`言語 ${languageLabels[values.lang]} を解除`}
|
||||
onClick={() => update('lang', 'all')}
|
||||
type="button"
|
||||
>
|
||||
{languageLabels[values.lang]} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.content !== 'all' ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`投稿の種類 ${contentLabels[values.content]} を解除`}
|
||||
onClick={() => update('content', 'all')}
|
||||
type="button"
|
||||
>
|
||||
{contentLabels[values.content]} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.excludeReplies ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="返信の除外を解除"
|
||||
onClick={() => update('excludeReplies', false)}
|
||||
type="button"
|
||||
>
|
||||
返信を除外 <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.excludeReposts ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="リポストの除外を解除"
|
||||
onClick={() => update('excludeReposts', false)}
|
||||
type="button"
|
||||
>
|
||||
リポストを除外 <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<details className="search-query-preview">
|
||||
<summary>検索式を確認</summary>
|
||||
<code>{preview}</code>
|
||||
</details>
|
||||
|
||||
{error ? (
|
||||
<p className="field-error search-error" id="query-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="segmented search-ranking">
|
||||
<legend>並び順</legend>
|
||||
{(['Top', 'Latest'] as const).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.product === value}
|
||||
name="product"
|
||||
onChange={() => update('product', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{value === 'Top' ? '人気順' : '最新順'}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label className="check search-following">
|
||||
<input
|
||||
checked={values.following}
|
||||
onChange={(event) => update('following', event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
<button className="search-submit" type="submit">
|
||||
検索
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { normalizeUserTarget } from '../inputs'
|
||||
|
||||
type Props = {
|
||||
initialTarget: string
|
||||
onSubmit: (target: string) => void
|
||||
}
|
||||
|
||||
export function UserForm({ initialTarget, onSubmit }: Props) {
|
||||
const [target, setTarget] = useState(initialTarget)
|
||||
const [error, setError] = useState('')
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const handle = normalizeUserTarget(target)
|
||||
setError('')
|
||||
onSubmit(handle)
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="intent-form" onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>ハンドルまたはプロフィール URL</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? 'target-error' : undefined}
|
||||
aria-invalid={Boolean(error)}
|
||||
name="target"
|
||||
value={target}
|
||||
onChange={(event) => setTarget(event.currentTarget.value)}
|
||||
placeholder="@handle または https://x.com/handle"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="field-error" id="target-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<button type="submit">表示</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAdvancedSearchQuery,
|
||||
buildFilteredSearchQuery,
|
||||
buildSearchQuery,
|
||||
listChoicesInputSchema,
|
||||
listPageInputSchema,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
postSearchRouteSchema,
|
||||
threadPageInputSchema,
|
||||
searchPageInputSchema,
|
||||
userPageInputSchema,
|
||||
} from './inputs'
|
||||
|
||||
describe('threadPageInputSchema', () => {
|
||||
it('accepts initial and complete continuation requests', () => {
|
||||
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
|
||||
tweetId: '123',
|
||||
})
|
||||
expect(
|
||||
threadPageInputSchema.parse({
|
||||
tweetId: '123',
|
||||
conversationId: '100',
|
||||
cursor: 'thread-next',
|
||||
}),
|
||||
).toEqual({
|
||||
tweetId: '123',
|
||||
conversationId: '100',
|
||||
cursor: 'thread-next',
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit relay profile binding', () => {
|
||||
it.each([
|
||||
{ tweetId: '' },
|
||||
{ tweetId: 'abc' },
|
||||
{ tweetId: '123', cursor: 'thread-next' },
|
||||
{ tweetId: '123', conversationId: '100' },
|
||||
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
|
||||
])('rejects an invalid thread request %#', (input) => {
|
||||
expect(() => threadPageInputSchema.parse(input)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTweetId', () => {
|
||||
it('keeps a decimal post ID', () => {
|
||||
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
|
||||
})
|
||||
|
||||
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
|
||||
expect(() => normalizeTweetId(input)).toThrow(
|
||||
'投稿 ID を確認してください。',
|
||||
[
|
||||
searchPageInputSchema,
|
||||
{ query: 'AI', product: 'Latest', following: false },
|
||||
],
|
||||
[listPageInputSchema, { target: '123' }],
|
||||
[userPageInputSchema, { target: 'yuta' }],
|
||||
[listChoicesInputSchema, {}],
|
||||
])('rejects missing or empty bindings for loader %#', (schema, input) => {
|
||||
expect(schema.safeParse(input).success).toBe(false)
|
||||
expect(schema.safeParse({ ...input, profileName: ' ' }).success).toBe(false)
|
||||
expect(schema.safeParse({ ...input, profileName: 'work' }).success).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -111,134 +87,3 @@ describe('buildSearchQuery', () => {
|
||||
expect(() => buildSearchQuery(' ', false)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAdvancedSearchQuery', () => {
|
||||
it('builds a query from button-friendly filters', () => {
|
||||
expect(
|
||||
buildAdvancedSearchQuery({
|
||||
q: 'TypeScript',
|
||||
from: '@tan_stack',
|
||||
since: '2026-08-04',
|
||||
until: '2026-08-11',
|
||||
lang: 'ja',
|
||||
content: 'images',
|
||||
excludeReplies: true,
|
||||
excludeReposts: true,
|
||||
}),
|
||||
).toBe(
|
||||
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an author search without keywords', () => {
|
||||
expect(
|
||||
buildAdvancedSearchQuery({
|
||||
q: '',
|
||||
from: 'tan_stack',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all',
|
||||
content: 'all',
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}),
|
||||
).toBe('from:tan_stack')
|
||||
})
|
||||
|
||||
it('rejects filters that have no keyword or author', () => {
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({
|
||||
q: '',
|
||||
from: '',
|
||||
since: '2026-08-04',
|
||||
until: '',
|
||||
lang: 'ja',
|
||||
content: 'all',
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}),
|
||||
).toThrow('検索語または投稿者を入力してください。')
|
||||
})
|
||||
|
||||
it('rejects an invalid author and reversed dates', () => {
|
||||
const base = {
|
||||
q: 'TypeScript',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }),
|
||||
).toThrow('投稿者のハンドルを確認してください。')
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({
|
||||
...base,
|
||||
since: '2026-08-11',
|
||||
until: '2026-08-10',
|
||||
}),
|
||||
).toThrow('終了日は開始日より後の日付を選んでください。')
|
||||
})
|
||||
|
||||
it('rejects nonexistent dates and a compiled query over 512 characters', () => {
|
||||
const base = {
|
||||
q: 'TypeScript',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }),
|
||||
).toThrow('開始日を確認してください。')
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }),
|
||||
).toThrow('検索語は 512 文字以内で入力してください。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFilteredSearchQuery', () => {
|
||||
it('includes the follows filter in the final query and enforces its length', () => {
|
||||
const input = {
|
||||
q: 'TypeScript',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'ja' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}
|
||||
|
||||
expect(buildFilteredSearchQuery(input, true)).toBe(
|
||||
'TypeScript lang:ja filter:follows',
|
||||
)
|
||||
expect(() =>
|
||||
buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true),
|
||||
).toThrow('検索語は 512 文字以内で入力してください。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('postSearchRouteSchema', () => {
|
||||
it('keeps omitted filter parameters optional', () => {
|
||||
expect(
|
||||
postSearchRouteSchema.parse({
|
||||
q: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
}),
|
||||
).toEqual({
|
||||
q: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,157 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||
const TWEET_ID = /^\d{1,32}$/
|
||||
const LIST_ID = /^\d{1,32}$/
|
||||
const tweetIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(TWEET_ID, '投稿 ID を確認してください。')
|
||||
const profileNameSchema = z.string().trim().min(1).max(256)
|
||||
|
||||
export class InputError extends Error {}
|
||||
|
||||
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
|
||||
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
|
||||
|
||||
type SearchLanguage = z.infer<typeof searchLanguageSchema>
|
||||
type SearchContent = z.infer<typeof searchContentSchema>
|
||||
|
||||
export type AdvancedSearchQuery = {
|
||||
q: string
|
||||
from: string
|
||||
since: string
|
||||
until: string
|
||||
lang: SearchLanguage
|
||||
content: SearchContent
|
||||
excludeReplies: boolean
|
||||
excludeReposts: boolean
|
||||
}
|
||||
|
||||
export const threadPageInputSchema = z.union([
|
||||
z.object({
|
||||
tweetId: tweetIdSchema,
|
||||
conversationId: z.undefined().optional(),
|
||||
cursor: z.undefined().optional(),
|
||||
}),
|
||||
z.object({
|
||||
tweetId: tweetIdSchema,
|
||||
conversationId: tweetIdSchema,
|
||||
cursor: z.string().min(1),
|
||||
}),
|
||||
])
|
||||
|
||||
export const userPageInputSchema = z.object({
|
||||
profileName: profileNameSchema,
|
||||
target: z.string().trim().min(1).max(256),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const listPageInputSchema = z.object({
|
||||
profileName: profileNameSchema,
|
||||
target: z.string().trim().min(1).max(256),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const listChoicesInputSchema = z.object({})
|
||||
export const listChoicesInputSchema = z.object({
|
||||
profileName: profileNameSchema,
|
||||
})
|
||||
|
||||
export const searchPageInputSchema = z.object({
|
||||
profileName: profileNameSchema,
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Top', 'Latest']),
|
||||
following: z.boolean(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const postSearchRouteSchema = z.object({
|
||||
q: z.string().catch(''),
|
||||
from: z
|
||||
.string()
|
||||
.refine((value) => !value || HANDLE.test(value.replace(/^@/, '')))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
since: z
|
||||
.string()
|
||||
.refine((value) => !value || isValidDate(value))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
until: z
|
||||
.string()
|
||||
.refine((value) => !value || isValidDate(value))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
lang: searchLanguageSchema.optional().catch(undefined),
|
||||
content: searchContentSchema.optional().catch(undefined),
|
||||
excludeReplies: z.boolean().optional().catch(undefined),
|
||||
excludeReposts: z.boolean().optional().catch(undefined),
|
||||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||
following: z.boolean().catch(false),
|
||||
})
|
||||
|
||||
function isValidDate(value: string): boolean {
|
||||
if (!ISO_DATE.test(value)) return false
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day))
|
||||
return (
|
||||
date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() + 1 === month &&
|
||||
date.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
function requireDate(value: string, label: string): string {
|
||||
if (!isValidDate(value)) {
|
||||
throw new InputError(`${label}を確認してください。`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeSearchAuthor(raw: string): string {
|
||||
const value = raw.trim().replace(/^@/, '')
|
||||
if (!HANDLE.test(value)) {
|
||||
throw new InputError('投稿者のハンドルを確認してください。')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string {
|
||||
const query = input.q.trim()
|
||||
const author = input.from.trim()
|
||||
if (!query && !author) {
|
||||
throw new InputError('検索語または投稿者を入力してください。')
|
||||
}
|
||||
|
||||
const parts = [query]
|
||||
if (author) parts.push(`from:${normalizeSearchAuthor(author)}`)
|
||||
|
||||
const since = input.since ? requireDate(input.since, '開始日') : ''
|
||||
const until = input.until ? requireDate(input.until, '終了日') : ''
|
||||
if (since && until && since >= until) {
|
||||
throw new InputError('終了日は開始日より後の日付を選んでください。')
|
||||
}
|
||||
if (since) parts.push(`since:${since}`)
|
||||
if (until) parts.push(`until:${until}`)
|
||||
if (input.lang !== 'all') parts.push(`lang:${input.lang}`)
|
||||
if (input.content !== 'all') parts.push(`filter:${input.content}`)
|
||||
if (input.excludeReplies) parts.push('-filter:replies')
|
||||
if (input.excludeReposts) parts.push('-filter:retweets')
|
||||
|
||||
return buildSearchQuery(parts.filter(Boolean).join(' '), false)
|
||||
}
|
||||
|
||||
export function buildFilteredSearchQuery(
|
||||
input: AdvancedSearchQuery,
|
||||
following: boolean,
|
||||
): string {
|
||||
return buildSearchQuery(buildAdvancedSearchQuery(input), following)
|
||||
}
|
||||
|
||||
export function normalizeTweetId(raw: string): string {
|
||||
const value = raw.trim()
|
||||
if (!TWEET_ID.test(value)) {
|
||||
throw new InputError('投稿 ID を確認してください。')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requireHandle(value: string): string {
|
||||
if (!HANDLE.test(value)) {
|
||||
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
flattenConversationPages,
|
||||
flattenPostPages,
|
||||
focalPostFromPages,
|
||||
} from './page'
|
||||
import type { Post, PostPage, ThreadPage } from './types'
|
||||
import { flattenPostPages } from './page'
|
||||
import type { Post, PostPage } from './types'
|
||||
|
||||
const post = (id: string): Post => ({
|
||||
id,
|
||||
@@ -22,29 +18,3 @@ describe('flattenPostPages', () => {
|
||||
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thread page helpers', () => {
|
||||
const focal = post('2')
|
||||
const pages: ThreadPage[] = [
|
||||
{
|
||||
focalPost: focal,
|
||||
conversationId: '1',
|
||||
nextCursor: 'next',
|
||||
tweets: [post('1'), focal],
|
||||
},
|
||||
{
|
||||
conversationId: '1',
|
||||
tweets: [focal, post('3')],
|
||||
},
|
||||
]
|
||||
|
||||
it('takes the focal post from the first page', () => {
|
||||
expect(focalPostFromPages(pages)).toEqual(focal)
|
||||
})
|
||||
|
||||
it('deduplicates the conversation and removes the focal post', () => {
|
||||
expect(
|
||||
flattenConversationPages(pages, focal.id).map(({ id }) => id),
|
||||
).toEqual(['1', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Post, PostPage, ThreadPage } from './types'
|
||||
import type { Post, PostPage } from './types'
|
||||
|
||||
export function flattenPostPages(pages: PostPage[]): Post[] {
|
||||
const seen = new Set<string>()
|
||||
@@ -10,17 +10,3 @@ export function flattenPostPages(pages: PostPage[]): Post[] {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function focalPostFromPages(
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
): Post | undefined {
|
||||
const first = pages[0]
|
||||
return first && 'focalPost' in first ? first.focalPost : undefined
|
||||
}
|
||||
|
||||
export function flattenConversationPages(
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
focalPostId: string,
|
||||
): Post[] {
|
||||
return flattenPostPages(pages).filter(({ id }) => id !== focalPostId)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type BirdReader,
|
||||
loadListChoices,
|
||||
loadListPage,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
@@ -24,27 +23,6 @@ const reader = (): BirdReader => ({
|
||||
},
|
||||
],
|
||||
}),
|
||||
getTweet: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
}),
|
||||
getThreadPaged: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [
|
||||
{
|
||||
id: '100',
|
||||
text: 'root',
|
||||
conversationId: '100',
|
||||
author: { username: 'root', name: 'Root' },
|
||||
},
|
||||
],
|
||||
nextCursor: 'thread-next',
|
||||
}),
|
||||
getUserIdByUsername: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, userId: '42' }),
|
||||
@@ -114,231 +92,6 @@ describe('loadListChoices', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadThreadPage', () => {
|
||||
it('resolves the focal post and fetches one root conversation page', async () => {
|
||||
const client = reader()
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(client.getTweet).toHaveBeenCalledWith('123')
|
||||
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
|
||||
maxPages: 1,
|
||||
pageDelayMs: 0,
|
||||
})
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
page: {
|
||||
focalPost: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
tweets: [
|
||||
{
|
||||
id: '100',
|
||||
text: 'root',
|
||||
conversationId: '100',
|
||||
author: { username: 'root', name: 'Root' },
|
||||
},
|
||||
],
|
||||
conversationId: '100',
|
||||
nextCursor: 'thread-next',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the carried root for exactly one continuation read', async () => {
|
||||
const client = reader()
|
||||
const result = await loadThreadPage(client, {
|
||||
tweetId: '123',
|
||||
conversationId: '100',
|
||||
cursor: 'thread-next',
|
||||
})
|
||||
|
||||
expect(client.getTweet).not.toHaveBeenCalled()
|
||||
expect(client.getThreadPaged).toHaveBeenCalledOnce()
|
||||
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
|
||||
cursor: 'thread-next',
|
||||
maxPages: 1,
|
||||
pageDelayMs: 0,
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
page: { conversationId: '100', focalPost: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose a continuation when the focal post has no replies', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
replyCount: 0,
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
page: { nextCursor: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'an empty final page',
|
||||
tweets: [],
|
||||
returnedCursor: 'thread-final',
|
||||
},
|
||||
{
|
||||
name: 'the same cursor again',
|
||||
tweets: [
|
||||
{
|
||||
id: '101',
|
||||
text: 'last reply',
|
||||
author: { username: 'reply', name: 'Reply' },
|
||||
},
|
||||
],
|
||||
returnedCursor: 'thread-next',
|
||||
},
|
||||
])('stops after $name', async ({ tweets, returnedCursor }) => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||
success: true,
|
||||
tweets,
|
||||
nextCursor: returnedCursor,
|
||||
})
|
||||
|
||||
const result = await loadThreadPage(client, {
|
||||
tweetId: '123',
|
||||
conversationId: '100',
|
||||
cursor: 'thread-next',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
page: { nextCursor: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
it('removes raw data from focal, conversation, and quotes', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
quotedTweet: {
|
||||
id: '90',
|
||||
text: 'quote',
|
||||
author: { username: 'quote', name: 'Quote' },
|
||||
_raw: { rest_id: 'private-quote' },
|
||||
},
|
||||
_raw: { rest_id: 'private-focal' },
|
||||
},
|
||||
})
|
||||
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [
|
||||
{
|
||||
id: '100',
|
||||
text: 'root',
|
||||
conversationId: '100',
|
||||
author: { username: 'root', name: 'Root' },
|
||||
_raw: { rest_id: 'private-thread' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain('private-')
|
||||
})
|
||||
|
||||
it('returns a safe non-retryable missing-post error', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Tweet not found: private relay detail',
|
||||
})
|
||||
|
||||
expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'post-not-found',
|
||||
message: '投稿が見つかりませんでした。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies malformed Bird conversation metadata as an upstream failure', async () => {
|
||||
const client = reader()
|
||||
const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9'
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: privateMetadata,
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'upstream',
|
||||
message: 'X から投稿を取得できませんでした。',
|
||||
retryable: true,
|
||||
},
|
||||
})
|
||||
expect(client.getTweet).toHaveBeenCalledWith('123')
|
||||
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Unexpected Bird failure:',
|
||||
expect.objectContaining({
|
||||
message: '投稿 ID を確認してください。',
|
||||
}),
|
||||
)
|
||||
expect(JSON.stringify(result)).not.toContain(privateMetadata)
|
||||
expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(
|
||||
privateMetadata,
|
||||
)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid ID without a Bird call', async () => {
|
||||
const client = reader()
|
||||
|
||||
expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-input',
|
||||
message: '投稿 ID を確認してください。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
expect(client.getTweet).not.toHaveBeenCalled()
|
||||
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadUserPage', () => {
|
||||
it('resolves a handle and fetches exactly one page', async () => {
|
||||
const client = reader()
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type {
|
||||
GetTweetResult,
|
||||
SearchProduct,
|
||||
SearchResult,
|
||||
TweetData,
|
||||
} from '@yuta/bird'
|
||||
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
InputError,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
} from './inputs'
|
||||
import type {
|
||||
@@ -19,8 +13,6 @@ import type {
|
||||
LoadResult,
|
||||
Post,
|
||||
SearchPageInput,
|
||||
ThreadLoadResult,
|
||||
ThreadPageInput,
|
||||
UserPageInput,
|
||||
} from './types'
|
||||
|
||||
@@ -45,15 +37,6 @@ type BirdListsResult =
|
||||
|
||||
export interface BirdReader {
|
||||
getLists(count?: number): Promise<BirdListsResult>
|
||||
getTweet(tweetId: string): Promise<GetTweetResult>
|
||||
getThreadPaged(
|
||||
tweetId: string,
|
||||
options: {
|
||||
cursor?: string
|
||||
maxPages: number
|
||||
pageDelayMs: number
|
||||
},
|
||||
): Promise<SearchResult>
|
||||
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
||||
getUserTweetsPaged(
|
||||
userId: string,
|
||||
@@ -87,12 +70,11 @@ const failure = (
|
||||
retryable: boolean,
|
||||
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
|
||||
|
||||
type FailureSubject = 'user' | 'post' | 'list'
|
||||
type FailureSubject = 'user' | 'list'
|
||||
|
||||
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||
user: () =>
|
||||
failure('user-not-found', 'ユーザーが見つかりませんでした。', false),
|
||||
post: () => failure('post-not-found', '投稿が見つかりませんでした。', false),
|
||||
list: () =>
|
||||
failure('list-not-found', 'リストが見つかりませんでした。', false),
|
||||
}
|
||||
@@ -100,7 +82,6 @@ const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||
user: () =>
|
||||
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false),
|
||||
post: () => failure('post-unavailable', 'この投稿は取得できません。', false),
|
||||
list: () =>
|
||||
failure('list-unavailable', 'このリストは取得できません。', false),
|
||||
}
|
||||
@@ -181,69 +162,9 @@ function resultPage(
|
||||
: upstreamFailure(result.error, subject)
|
||||
}
|
||||
|
||||
export async function loadThreadPage(
|
||||
reader: BirdReader,
|
||||
input: ThreadPageInput,
|
||||
): Promise<ThreadLoadResult> {
|
||||
try {
|
||||
const tweetId = normalizeTweetId(input.tweetId)
|
||||
let focalPost: Post | undefined
|
||||
let conversationId: string
|
||||
|
||||
if (input.cursor && input.conversationId) {
|
||||
conversationId = normalizeTweetId(input.conversationId)
|
||||
} else {
|
||||
const focal = await reader.getTweet(tweetId)
|
||||
if (!focal.success || !focal.tweet) {
|
||||
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
|
||||
}
|
||||
focalPost = publicPost(focal.tweet)
|
||||
try {
|
||||
conversationId = normalizeTweetId(
|
||||
focal.tweet.conversationId ?? focal.tweet.id,
|
||||
)
|
||||
} catch (error) {
|
||||
return upstreamFailure(error, 'post')
|
||||
}
|
||||
}
|
||||
|
||||
const result = await reader.getThreadPaged(conversationId, {
|
||||
...(input.cursor ? { cursor: input.cursor } : {}),
|
||||
maxPages: 1,
|
||||
pageDelayMs: 0,
|
||||
})
|
||||
if (!result.success) {
|
||||
return upstreamFailure(result.error, 'post')
|
||||
}
|
||||
const tweets = result.tweets ?? []
|
||||
const nextCursor =
|
||||
result.nextCursor &&
|
||||
tweets.length > 0 &&
|
||||
result.nextCursor !== input.cursor &&
|
||||
focalPost?.replyCount !== 0
|
||||
? result.nextCursor
|
||||
: undefined
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
page: {
|
||||
tweets: tweets.map(publicPost),
|
||||
focalPost,
|
||||
conversationId,
|
||||
nextCursor,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InputError) {
|
||||
return failure('invalid-input', error.message, false)
|
||||
}
|
||||
return upstreamFailure(error, 'post')
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUserPage(
|
||||
reader: BirdReader,
|
||||
input: UserPageInput,
|
||||
input: Omit<UserPageInput, 'profileName'>,
|
||||
): Promise<LoadResult> {
|
||||
try {
|
||||
const handle = normalizeUserTarget(input.target)
|
||||
@@ -271,7 +192,7 @@ export async function loadUserPage(
|
||||
|
||||
export async function loadListPage(
|
||||
reader: BirdReader,
|
||||
input: ListPageInput,
|
||||
input: Omit<ListPageInput, 'profileName'>,
|
||||
): Promise<LoadResult> {
|
||||
try {
|
||||
const listId = normalizeListTarget(input.target)
|
||||
@@ -295,7 +216,7 @@ export async function loadListPage(
|
||||
|
||||
export async function searchPage(
|
||||
reader: BirdReader,
|
||||
input: SearchPageInput,
|
||||
input: Omit<SearchPageInput, 'profileName'>,
|
||||
): Promise<LoadResult> {
|
||||
try {
|
||||
return resultPage(
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { ProfileUnavailableError } from '../profiles/errors'
|
||||
import {
|
||||
listChoicesInputSchema,
|
||||
listPageInputSchema,
|
||||
searchPageInputSchema,
|
||||
threadPageInputSchema,
|
||||
userPageInputSchema,
|
||||
} from './inputs'
|
||||
import {
|
||||
loadListChoices,
|
||||
loadListPage,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
import type { LoadFailure } from './types'
|
||||
|
||||
const configFailure = (diagnostic: unknown): LoadFailure => {
|
||||
if (diagnostic instanceof ProfileUnavailableError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'profile-unavailable',
|
||||
message: diagnostic.message,
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
console.error('Bird client configuration failed:', diagnostic)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -27,19 +36,20 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
|
||||
}
|
||||
}
|
||||
|
||||
async function reader() {
|
||||
const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([
|
||||
async function reader(profileName: string) {
|
||||
const [{ getBirdReader }, { requireProfileName }] = await Promise.all([
|
||||
import('./bird-client.server'),
|
||||
import('../profiles/profile-service.server'),
|
||||
])
|
||||
return getBirdReader(getSelectedProfileName())
|
||||
await requireProfileName(profileName)
|
||||
return getBirdReader(profileName)
|
||||
}
|
||||
|
||||
export const loadUserPosts = createServerFn({ method: 'GET' })
|
||||
.validator(userPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await loadUserPage(await reader(), data)
|
||||
return await loadUserPage(await reader(data.profileName), data)
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
@@ -49,7 +59,7 @@ export const loadListPosts = createServerFn({ method: 'GET' })
|
||||
.validator(listPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await loadListPage(await reader(), data)
|
||||
return await loadListPage(await reader(data.profileName), data)
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
@@ -57,9 +67,9 @@ export const loadListPosts = createServerFn({ method: 'GET' })
|
||||
|
||||
export const loadLists = createServerFn({ method: 'GET' })
|
||||
.validator(listChoicesInputSchema)
|
||||
.handler(async () => {
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await loadListChoices(await reader())
|
||||
return await loadListChoices(await reader(data.profileName))
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
@@ -69,17 +79,7 @@ export const searchPosts = createServerFn({ method: 'GET' })
|
||||
.validator(searchPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await searchPage(await reader(), data)
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
})
|
||||
|
||||
export const loadThreadPosts = createServerFn({ method: 'GET' })
|
||||
.validator(threadPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await loadThreadPage(await reader(), data)
|
||||
return await searchPage(await reader(data.profileName), data)
|
||||
} catch (error) {
|
||||
return configFailure(error)
|
||||
}
|
||||
|
||||
@@ -9,19 +9,13 @@ export type PostPage = {
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
export type ThreadPage = PostPage & {
|
||||
focalPost?: Post
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
type LoadErrorCode =
|
||||
| 'invalid-input'
|
||||
| 'list-not-found'
|
||||
| 'list-unavailable'
|
||||
| 'post-not-found'
|
||||
| 'post-unavailable'
|
||||
| 'user-not-found'
|
||||
| 'user-unavailable'
|
||||
| 'profile-unavailable'
|
||||
| 'relay-config'
|
||||
| 'timeout'
|
||||
| 'upstream'
|
||||
@@ -38,8 +32,6 @@ export type LoadResult<TPage extends PostPage = PostPage> =
|
||||
| { ok: true; page: TPage }
|
||||
| LoadFailure
|
||||
|
||||
export type ThreadLoadResult = LoadResult<ThreadPage>
|
||||
|
||||
export type ListChoice = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -50,29 +42,20 @@ export type ListChoice = {
|
||||
|
||||
export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure
|
||||
|
||||
export type ThreadPageInput =
|
||||
| {
|
||||
tweetId: string
|
||||
conversationId?: never
|
||||
cursor?: never
|
||||
}
|
||||
| {
|
||||
tweetId: string
|
||||
conversationId: string
|
||||
cursor: string
|
||||
}
|
||||
|
||||
export type UserPageInput = {
|
||||
profileName: string
|
||||
target: string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export type ListPageInput = {
|
||||
profileName: string
|
||||
target: string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export type SearchPageInput = {
|
||||
profileName: string
|
||||
query: string
|
||||
product: SearchProduct
|
||||
following: boolean
|
||||
|
||||
@@ -10,13 +10,13 @@ describe('createListChoicesOptions', () => {
|
||||
ok: true,
|
||||
lists: [{ id: '123', name: 'My List' }],
|
||||
})
|
||||
const options = createListChoicesOptions(loader)
|
||||
const options = createListChoicesOptions('main', loader)
|
||||
|
||||
await expect(options.queryFn()).resolves.toEqual([
|
||||
{ id: '123', name: 'My List' },
|
||||
])
|
||||
expect(loader).toHaveBeenCalledWith({ data: {} })
|
||||
expect(options.queryKey).toEqual(['list-choices'])
|
||||
expect(loader).toHaveBeenCalledWith({ data: { profileName: 'main' } })
|
||||
expect(options.queryKey).toEqual(['list-choices', 'main'])
|
||||
expect(options.retry).toBe(false)
|
||||
expect(options.refetchOnWindowFocus).toBe(false)
|
||||
expect(options.refetchOnReconnect).toBe(false)
|
||||
@@ -29,6 +29,7 @@ describe('createListChoicesOptions', () => {
|
||||
retryable: true,
|
||||
}
|
||||
const options = createListChoicesOptions(
|
||||
'main',
|
||||
vi.fn().mockResolvedValue({ ok: false, error }),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { loadLists } from './server-functions'
|
||||
import type { ListChoice, ListChoicesResult, LoadError } from './types'
|
||||
|
||||
type ListChoicesLoader = (options: {
|
||||
data: Record<string, never>
|
||||
data: { profileName: string }
|
||||
}) => Promise<ListChoicesResult>
|
||||
|
||||
export class ListChoicesLoadError extends Error {
|
||||
@@ -13,11 +13,14 @@ export class ListChoicesLoadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function createListChoicesOptions(loader: ListChoicesLoader) {
|
||||
export function createListChoicesOptions(
|
||||
profileName: string,
|
||||
loader: ListChoicesLoader,
|
||||
) {
|
||||
return {
|
||||
queryKey: ['list-choices'] as const,
|
||||
queryKey: ['list-choices', profileName] as const,
|
||||
queryFn: async (): Promise<ListChoice[]> => {
|
||||
const result = await loader({ data: {} })
|
||||
const result = await loader({ data: { profileName } })
|
||||
if (!result.ok) throw new ListChoicesLoadError(result.error)
|
||||
return result.lists
|
||||
},
|
||||
@@ -27,7 +30,10 @@ export function createListChoicesOptions(loader: ListChoicesLoader) {
|
||||
}
|
||||
}
|
||||
|
||||
export function useListChoices() {
|
||||
export function useListChoices(profileName: string) {
|
||||
const loader = useServerFn(loadLists)
|
||||
return useQuery(createListChoicesOptions(loader))
|
||||
return useQuery({
|
||||
...createListChoicesOptions(profileName, loader),
|
||||
enabled: Boolean(profileName),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createElement, type ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createPostFeedOptions,
|
||||
getPostFeedData,
|
||||
PostLoadError,
|
||||
usePostFeed,
|
||||
} from './use-post-feed'
|
||||
@@ -23,7 +22,6 @@ const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
|
||||
loadUser: vi.fn(),
|
||||
loadList: vi.fn(),
|
||||
search: vi.fn(),
|
||||
thread: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -34,7 +32,7 @@ describe('createPostFeedOptions', () => {
|
||||
page: { tweets: [], nextCursor: 'next' },
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: '@yuta' },
|
||||
{ profileName: 'main', kind: 'user', target: '@yuta' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
|
||||
@@ -42,7 +40,7 @@ describe('createPostFeedOptions', () => {
|
||||
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
expect(loadUser).toHaveBeenCalledWith({
|
||||
data: { target: '@yuta', cursor: 'cursor' },
|
||||
data: { profileName: 'main', target: '@yuta', cursor: 'cursor' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,7 +50,7 @@ describe('createPostFeedOptions', () => {
|
||||
page: { tweets: [], nextCursor: 'next' },
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'list', target: '1234567890' },
|
||||
{ profileName: 'main', kind: 'list', target: '1234567890' },
|
||||
loaders({ loadList }),
|
||||
)
|
||||
|
||||
@@ -60,17 +58,17 @@ describe('createPostFeedOptions', () => {
|
||||
|
||||
expect(loadList).toHaveBeenCalledOnce()
|
||||
expect(loadList).toHaveBeenCalledWith({
|
||||
data: { target: '1234567890', cursor: 'cursor' },
|
||||
data: { profileName: 'main', target: '1234567890', cursor: 'cursor' },
|
||||
})
|
||||
expect(options.queryKey).toEqual([
|
||||
'posts',
|
||||
{ kind: 'list', target: '1234567890' },
|
||||
{ profileName: 'main', kind: 'list', target: '1234567890' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry requests automatically', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ profileName: 'main', kind: 'user', target: 'yuta' },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
@@ -79,7 +77,7 @@ describe('createPostFeedOptions', () => {
|
||||
|
||||
it('does not refetch automatically when the window regains focus', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ profileName: 'main', kind: 'user', target: 'yuta' },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
@@ -88,7 +86,7 @@ describe('createPostFeedOptions', () => {
|
||||
|
||||
it('does not refetch automatically when the connection returns', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ profileName: 'main', kind: 'user', target: 'yuta' },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
@@ -97,7 +95,7 @@ describe('createPostFeedOptions', () => {
|
||||
|
||||
it('takes the next cursor only from the resolved page', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ profileName: 'main', kind: 'user', target: 'yuta' },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
@@ -110,6 +108,7 @@ describe('createPostFeedOptions', () => {
|
||||
it('includes every search control in the query key', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{
|
||||
profileName: 'main',
|
||||
kind: 'search',
|
||||
query: 'AI',
|
||||
product: 'Top',
|
||||
@@ -120,7 +119,13 @@ describe('createPostFeedOptions', () => {
|
||||
|
||||
expect(options.queryKey).toEqual([
|
||||
'posts',
|
||||
{ kind: 'search', query: 'AI', product: 'Top', following: true },
|
||||
{
|
||||
profileName: 'main',
|
||||
kind: 'search',
|
||||
query: 'AI',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -131,6 +136,7 @@ describe('createPostFeedOptions', () => {
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{
|
||||
profileName: 'main',
|
||||
kind: 'search',
|
||||
query: 'AI lang:ja',
|
||||
product: 'Top',
|
||||
@@ -144,6 +150,7 @@ describe('createPostFeedOptions', () => {
|
||||
expect(search).toHaveBeenCalledOnce()
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
data: {
|
||||
profileName: 'main',
|
||||
query: 'AI lang:ja',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
@@ -152,61 +159,6 @@ describe('createPostFeedOptions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the initial thread and carries its root with the next cursor', async () => {
|
||||
const thread = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
page: {
|
||||
tweets: [],
|
||||
focalPost: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
conversationId: '100',
|
||||
nextCursor: 'thread-next',
|
||||
},
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'thread', tweetId: '123' },
|
||||
loaders({ thread }),
|
||||
)
|
||||
|
||||
const page = await options.queryFn({ pageParam: undefined } as never)
|
||||
|
||||
expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } })
|
||||
expect(options.queryKey).toEqual([
|
||||
'posts',
|
||||
{ kind: 'thread', tweetId: '123' },
|
||||
])
|
||||
expect(options.getNextPageParam(page)).toEqual({
|
||||
cursor: 'thread-next',
|
||||
conversationId: '100',
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards a thread continuation page parameter intact', async () => {
|
||||
const thread = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
page: { tweets: [], conversationId: '100' },
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'thread', tweetId: '123' },
|
||||
loaders({ thread }),
|
||||
)
|
||||
|
||||
await options.queryFn({
|
||||
pageParam: { cursor: 'thread-next', conversationId: '100' },
|
||||
} as never)
|
||||
|
||||
expect(thread).toHaveBeenCalledWith({
|
||||
data: {
|
||||
tweetId: '123',
|
||||
cursor: 'thread-next',
|
||||
conversationId: '100',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a typed load failure for the feed', async () => {
|
||||
const error = {
|
||||
code: 'upstream' as const,
|
||||
@@ -214,7 +166,7 @@ describe('createPostFeedOptions', () => {
|
||||
retryable: true,
|
||||
}
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ profileName: 'main', kind: 'user', target: 'yuta' },
|
||||
loaders({
|
||||
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
|
||||
}),
|
||||
@@ -230,93 +182,33 @@ describe('createPostFeedOptions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPostFeedData', () => {
|
||||
it('reuses the visible feed without fetching a second copy', async () => {
|
||||
const loadUser = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, page: { tweets: [] } })
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
describe('profile-bound feed cache', () => {
|
||||
it('keeps cached posts and pagination isolated between bound profiles', async () => {
|
||||
const loadUser = vi.fn(async ({ data }) => ({
|
||||
ok: true as const,
|
||||
page: { tweets: [], nextCursor: `${data.profileName}-next` },
|
||||
}))
|
||||
const main = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta', profileName: 'main' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const work = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta', profileName: 'work' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const client = new QueryClient()
|
||||
const visible = await client.fetchInfiniteQuery(options)
|
||||
|
||||
expect(await getPostFeedData(client, options)).toEqual(visible)
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('waits for the active profile refresh instead of returning old cached posts', async () => {
|
||||
let release = () => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
await client.ensureInfiniteQueryData(main)
|
||||
expect(
|
||||
(await client.ensureInfiniteQueryData(work)).pages[0]?.nextCursor,
|
||||
).toBe('work-next')
|
||||
expect(
|
||||
(await client.ensureInfiniteQueryData(main)).pages[0]?.nextCursor,
|
||||
).toBe('main-next')
|
||||
expect(loadUser).toHaveBeenCalledTimes(2)
|
||||
await work.queryFn({ pageParam: 'work-next' })
|
||||
expect(loadUser).toHaveBeenLastCalledWith({
|
||||
data: { target: 'yuta', profileName: 'work', cursor: 'work-next' },
|
||||
})
|
||||
const freshPage = {
|
||||
tweets: [
|
||||
{
|
||||
id: 'new-profile',
|
||||
text: 'fresh',
|
||||
author: { username: 'new', name: 'New' },
|
||||
},
|
||||
],
|
||||
}
|
||||
const loadUser = vi.fn(async () => {
|
||||
await gate
|
||||
return { ok: true as const, page: freshPage }
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const client = new QueryClient()
|
||||
client.setQueryData(options.queryKey, {
|
||||
pages: [{ tweets: [] }],
|
||||
pageParams: [undefined],
|
||||
})
|
||||
await client.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
refetchType: 'none',
|
||||
})
|
||||
const refresh = client.fetchInfiniteQuery(options)
|
||||
const resolved = vi.fn()
|
||||
const tool = getPostFeedData(client, options).then(resolved)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(resolved).not.toHaveBeenCalled()
|
||||
release()
|
||||
await Promise.all([refresh, tool])
|
||||
expect(resolved).toHaveBeenCalledWith({
|
||||
pages: [freshPage],
|
||||
pageParams: [undefined],
|
||||
})
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('refreshes invalidated cached data before returning it', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
page: { tweets: [], nextCursor: 'fresh' },
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
const client = new QueryClient()
|
||||
client.setQueryData(options.queryKey, {
|
||||
pages: [{ tweets: [] }],
|
||||
pageParams: [undefined],
|
||||
})
|
||||
await client.invalidateQueries({
|
||||
queryKey: options.queryKey,
|
||||
refetchType: 'none',
|
||||
})
|
||||
|
||||
expect((await getPostFeedData(client, options)).pages[0]?.nextCursor).toBe(
|
||||
'fresh',
|
||||
)
|
||||
expect(loadUser).toHaveBeenCalledOnce()
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import {
|
||||
loadListPosts,
|
||||
loadThreadPosts,
|
||||
loadUserPosts,
|
||||
searchPosts,
|
||||
} from './server-functions'
|
||||
import { loadListPosts, loadUserPosts, searchPosts } from './server-functions'
|
||||
import type {
|
||||
ListPageInput,
|
||||
LoadError,
|
||||
LoadResult,
|
||||
PostPage,
|
||||
SearchPageInput,
|
||||
ThreadLoadResult,
|
||||
ThreadPage,
|
||||
ThreadPageInput,
|
||||
UserPageInput,
|
||||
} from './types'
|
||||
|
||||
export type FeedRequest =
|
||||
export type FeedRequest = { profileName: string } & (
|
||||
| { kind: 'user'; target: string }
|
||||
| { kind: 'list'; target: string }
|
||||
| {
|
||||
@@ -28,20 +20,12 @@ export type FeedRequest =
|
||||
product: SearchProduct
|
||||
following: boolean
|
||||
}
|
||||
| { kind: 'thread'; tweetId: string }
|
||||
|
||||
type ThreadPageParam = {
|
||||
cursor: string
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
type FeedPageParam = string | ThreadPageParam | undefined
|
||||
)
|
||||
|
||||
type Loaders = {
|
||||
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
|
||||
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
|
||||
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
||||
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
|
||||
}
|
||||
|
||||
export class PostLoadError extends Error {
|
||||
@@ -58,17 +42,18 @@ function unwrap<TPage extends PostPage>(result: LoadResult<TPage>): TPage {
|
||||
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
return {
|
||||
queryKey: ['posts', request] as const,
|
||||
initialPageParam: undefined as FeedPageParam,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
retry: false as const,
|
||||
refetchOnWindowFocus: false as const,
|
||||
refetchOnReconnect: false as const,
|
||||
queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
|
||||
queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
|
||||
if (request.kind === 'user') {
|
||||
return unwrap(
|
||||
await loaders.loadUser({
|
||||
data: {
|
||||
profileName: request.profileName,
|
||||
target: request.target,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
cursor: pageParam,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -77,80 +62,47 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
return unwrap(
|
||||
await loaders.loadList({
|
||||
data: {
|
||||
profileName: request.profileName,
|
||||
target: request.target,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (request.kind === 'search') {
|
||||
return unwrap(
|
||||
await loaders.search({
|
||||
data: {
|
||||
query: request.query,
|
||||
product: request.product,
|
||||
following: request.following,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
cursor: pageParam,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
return unwrap(
|
||||
await loaders.thread({
|
||||
data:
|
||||
typeof pageParam === 'object'
|
||||
? {
|
||||
tweetId: request.tweetId,
|
||||
cursor: pageParam.cursor,
|
||||
conversationId: pageParam.conversationId,
|
||||
}
|
||||
: { tweetId: request.tweetId },
|
||||
await loaders.search({
|
||||
data: {
|
||||
profileName: request.profileName,
|
||||
query: request.query,
|
||||
product: request.product,
|
||||
following: request.following,
|
||||
cursor: pageParam,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
getNextPageParam: (page: PostPage | ThreadPage) => {
|
||||
if (!page.nextCursor) return undefined
|
||||
return request.kind === 'thread' && 'conversationId' in page
|
||||
? {
|
||||
cursor: page.nextCursor,
|
||||
conversationId: page.conversationId,
|
||||
}
|
||||
: page.nextCursor
|
||||
},
|
||||
getNextPageParam: (page: PostPage) => page.nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePostFeedOptions() {
|
||||
function usePostFeedOptions() {
|
||||
const loadUser = useServerFn(loadUserPosts)
|
||||
const loadList = useServerFn(loadListPosts)
|
||||
const search = useServerFn(searchPosts)
|
||||
const thread = useServerFn(loadThreadPosts)
|
||||
return (request: FeedRequest) =>
|
||||
createPostFeedOptions(request, {
|
||||
loadUser,
|
||||
loadList,
|
||||
search,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPostFeedData(
|
||||
client: QueryClient,
|
||||
options: ReturnType<typeof createPostFeedOptions>,
|
||||
) {
|
||||
const state = client.getQueryState(options.queryKey)
|
||||
// Profile changes invalidate and refresh an existing feed. Wait for that
|
||||
// refresh instead of returning the previous profile's cached posts.
|
||||
return state?.fetchStatus === 'fetching' || state?.isInvalidated
|
||||
? client.fetchInfiniteQuery(options)
|
||||
: client.ensureInfiniteQueryData(options)
|
||||
}
|
||||
|
||||
export function usePostFeed(request: FeedRequest | undefined) {
|
||||
const options = usePostFeedOptions()
|
||||
const disabled = {
|
||||
kind: 'user',
|
||||
target: '',
|
||||
profileName: '',
|
||||
} satisfies FeedRequest
|
||||
|
||||
return useInfiniteQuery({
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Post, ThreadPage } from './types'
|
||||
import { type FeedRequest, PostLoadError } from './use-post-feed'
|
||||
import {
|
||||
feedResult,
|
||||
prepareSearch,
|
||||
readToolInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
const post = (id: string): Post => ({
|
||||
id,
|
||||
text: `Post ${id}`,
|
||||
author: { username: 'reader', name: 'Reader' },
|
||||
})
|
||||
|
||||
const request = { kind: 'user', target: 'reader' } satisfies FeedRequest
|
||||
|
||||
describe('WebMCP search input', () => {
|
||||
it('defaults to chronological search without adding filters', () => {
|
||||
expect(prepareSearch({ q: ' hello ' })).toMatchObject({
|
||||
search: { q: 'hello', product: 'Latest', following: false },
|
||||
request: {
|
||||
kind: 'search',
|
||||
query: 'hello',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('compiles the same author, date, content, and exclusion filters as the UI', () => {
|
||||
expect(
|
||||
prepareSearch({
|
||||
q: 'release',
|
||||
from: '@reader',
|
||||
since: '2026-09-01',
|
||||
until: '2026-09-24',
|
||||
lang: 'ja',
|
||||
content: 'links',
|
||||
excludeReplies: true,
|
||||
excludeReposts: true,
|
||||
product: 'Top',
|
||||
following: true,
|
||||
}).request,
|
||||
).toEqual({
|
||||
kind: 'search',
|
||||
query:
|
||||
'release from:reader since:2026-09-01 until:2026-09-24 lang:ja filter:links -filter:replies -filter:retweets filter:follows',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts an author as the deliberate search intent', () => {
|
||||
expect(prepareSearch({ from: 'reader' }).request.query).toBe('from:reader')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an impossible date', { q: 'hello', since: '2026-02-30' }],
|
||||
['an invalid date format', { q: 'hello', until: 'yesterday' }],
|
||||
[
|
||||
'an inverted period',
|
||||
{ q: 'hello', since: '2026-09-24', until: '2026-09-01' },
|
||||
],
|
||||
[
|
||||
'an empty period',
|
||||
{ q: 'hello', since: '2026-09-24', until: '2026-09-24' },
|
||||
],
|
||||
['missing search intent', {}],
|
||||
['whitespace-only intent', { q: ' ', from: ' ' }],
|
||||
['an unrecognized property', { q: 'hello', profile: 'other' }],
|
||||
])('rejects %s', (_description, input) => {
|
||||
expect(() => prepareSearch(input)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebMCP read input', () => {
|
||||
it('defaults to the first 20 loaded posts', () => {
|
||||
expect(readToolInput.parse({})).toEqual({ offset: 0, limit: 20 })
|
||||
})
|
||||
|
||||
it('accepts the maximum batch size', () => {
|
||||
expect(readToolInput.parse({ offset: 10, limit: 50 })).toEqual({
|
||||
offset: 10,
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ limit: 0 },
|
||||
{ limit: 51 },
|
||||
{ limit: 1.5 },
|
||||
{ limit: '20' },
|
||||
{ offset: -1 },
|
||||
{ offset: 0.5 },
|
||||
{ cursor: 'upstream-cursor' },
|
||||
])('rejects invalid pagination %j', (input) => {
|
||||
expect(readToolInput.safeParse(input).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebMCP feed output', () => {
|
||||
it('bounds post text and indicates when text was truncated', () => {
|
||||
const result = feedResult(request, [
|
||||
{
|
||||
tweets: [
|
||||
{ ...post('1'), text: 'a'.repeat(2001) },
|
||||
{ ...post('2'), text: 'b'.repeat(2000) },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.posts).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
author: { username: 'reader', name: 'Reader' },
|
||||
text: 'a'.repeat(2000),
|
||||
textTruncated: true,
|
||||
createdAt: undefined,
|
||||
url: 'https://x.com/reader/status/1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: { username: 'reader', name: 'Reader' },
|
||||
text: 'b'.repeat(2000),
|
||||
textTruncated: false,
|
||||
createdAt: undefined,
|
||||
url: 'https://x.com/reader/status/2',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates loaded pages before applying the requested offset and limit', () => {
|
||||
const result = feedResult(
|
||||
request,
|
||||
[
|
||||
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
|
||||
{ tweets: [post('2'), post('3'), post('4')] },
|
||||
],
|
||||
1,
|
||||
2,
|
||||
)
|
||||
|
||||
expect(result.posts.map(({ id }) => id)).toEqual(['2', '3'])
|
||||
expect(result).toMatchObject({
|
||||
loadedCount: 4,
|
||||
offset: 1,
|
||||
nextOffset: 3,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('includes the focal post once before the deduplicated conversation', () => {
|
||||
const focal = post('2')
|
||||
const pages: ThreadPage[] = [
|
||||
{
|
||||
focalPost: focal,
|
||||
conversationId: '1',
|
||||
tweets: [post('1'), focal],
|
||||
nextCursor: 'next',
|
||||
},
|
||||
{ conversationId: '1', tweets: [focal, post('3'), post('1')] },
|
||||
]
|
||||
|
||||
const result = feedResult({ kind: 'thread', tweetId: '2' }, pages)
|
||||
|
||||
expect(result.posts.map(({ id }) => id)).toEqual(['2', '1', '3'])
|
||||
expect(result.loadedCount).toBe(3)
|
||||
})
|
||||
|
||||
it('reports upstream continuation separately from remaining loaded posts', () => {
|
||||
const result = feedResult(request, [
|
||||
{ tweets: [post('1')], nextCursor: 'more-on-server' },
|
||||
])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
loadedCount: 1,
|
||||
nextOffset: null,
|
||||
hasMore: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty batch when the offset is beyond the loaded posts', () => {
|
||||
expect(
|
||||
feedResult(request, [{ tweets: [post('1')] }], 10, 20),
|
||||
).toMatchObject({
|
||||
posts: [],
|
||||
loadedCount: 1,
|
||||
offset: 10,
|
||||
nextOffset: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebMCP execution result', () => {
|
||||
it.each([
|
||||
true,
|
||||
false,
|
||||
])('preserves a load failure with retryable=%s', async (retryable) => {
|
||||
const detail = {
|
||||
code: 'upstream' as const,
|
||||
message: 'Relay unavailable',
|
||||
retryable,
|
||||
}
|
||||
|
||||
const result = await toolResult(() =>
|
||||
Promise.reject(new PostLoadError(detail)),
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: JSON.stringify(detail) }],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns invalid-input for schema validation failures', async () => {
|
||||
const result = await toolResult(() => readToolInput.parse({ limit: 100 }))
|
||||
|
||||
expect(result).toMatchObject({ isError: true })
|
||||
expect(JSON.parse(result.content[0].text)).toMatchObject({
|
||||
code: 'invalid-input',
|
||||
retryable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps successful empty results distinguishable from failures', async () => {
|
||||
const result = await toolResult(() => feedResult(request, [{ tweets: [] }]))
|
||||
|
||||
expect(result).not.toHaveProperty('isError')
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
request,
|
||||
posts: [],
|
||||
loadedCount: 0,
|
||||
offset: 0,
|
||||
nextOffset: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,146 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { buildFilteredSearchQuery, InputError } from './inputs'
|
||||
import {
|
||||
flattenConversationPages,
|
||||
flattenPostPages,
|
||||
focalPostFromPages,
|
||||
} from './page'
|
||||
import type { Post, PostPage, ThreadPage } from './types'
|
||||
import { type FeedRequest, PostLoadError } from './use-post-feed'
|
||||
|
||||
export const searchToolInput = z
|
||||
.object({
|
||||
q: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(512)
|
||||
.default('')
|
||||
.describe(
|
||||
'Search text; raw X search operators are supported. Provide q or from.',
|
||||
),
|
||||
from: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(16)
|
||||
.default('')
|
||||
.describe('Author handle, with or without @.'),
|
||||
since: z
|
||||
.string()
|
||||
.default('')
|
||||
.describe(
|
||||
'Inclusive start date, YYYY-MM-DD, using X search date semantics.',
|
||||
),
|
||||
until: z
|
||||
.string()
|
||||
.default('')
|
||||
.describe('Exclusive end date, YYYY-MM-DD; must be after since.'),
|
||||
lang: z.enum(['all', 'ja', 'en']).default('all'),
|
||||
content: z.enum(['all', 'images', 'videos', 'links']).default('all'),
|
||||
excludeReplies: z.boolean().default(false),
|
||||
excludeReposts: z.boolean().default(false),
|
||||
product: z
|
||||
.enum(['Latest', 'Top'])
|
||||
.default('Latest')
|
||||
.describe('Latest is chronological; Top uses X ranking.'),
|
||||
following: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Restrict to accounts followed by the active relay profile.'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const readToolInput = z
|
||||
.object({
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe('Offset into the currently loaded posts, starting at zero.'),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const emptyToolInput = z.object({}).strict()
|
||||
|
||||
export function prepareSearch(input: unknown) {
|
||||
const search = searchToolInput.parse(input)
|
||||
const request = {
|
||||
kind: 'search',
|
||||
query: buildFilteredSearchQuery(search, search.following),
|
||||
product: search.product,
|
||||
following: search.following,
|
||||
} satisfies FeedRequest
|
||||
return { search, request }
|
||||
}
|
||||
|
||||
function summarizePost(post: Post) {
|
||||
return {
|
||||
id: post.id,
|
||||
author: { username: post.author.username, name: post.author.name },
|
||||
text: post.text.slice(0, 2000),
|
||||
textTruncated: post.text.length > 2000,
|
||||
createdAt: post.createdAt,
|
||||
url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadedPosts(
|
||||
request: FeedRequest,
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
) {
|
||||
const focal =
|
||||
request.kind === 'thread' ? focalPostFromPages(pages) : undefined
|
||||
return focal
|
||||
? [focal, ...flattenConversationPages(pages, focal.id)]
|
||||
: flattenPostPages(pages)
|
||||
}
|
||||
|
||||
export function feedResult(
|
||||
request: FeedRequest,
|
||||
pages: Array<PostPage | ThreadPage>,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
) {
|
||||
const posts = loadedPosts(request, pages)
|
||||
const selected = posts.slice(offset, offset + limit)
|
||||
const nextOffset = offset + selected.length
|
||||
return {
|
||||
request,
|
||||
posts: selected.map(summarizePost),
|
||||
loadedCount: posts.length,
|
||||
offset,
|
||||
nextOffset: nextOffset < posts.length ? nextOffset : null,
|
||||
hasMore: Boolean(pages.at(-1)?.nextCursor),
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the tool's failure distinguishable from a successful empty result.
|
||||
export async function toolResult(run: () => unknown | Promise<unknown>) {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(await run()) },
|
||||
] as const,
|
||||
}
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof PostLoadError
|
||||
? error.detail
|
||||
: {
|
||||
code:
|
||||
error instanceof z.ZodError || error instanceof InputError
|
||||
? 'invalid-input'
|
||||
: 'tool-error',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Tool execution failed.',
|
||||
retryable: false,
|
||||
}
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: 'text' as const, text: JSON.stringify(detail) },
|
||||
] as const,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWebMCP } from 'usewebmcp'
|
||||
import {
|
||||
type FeedRequest,
|
||||
getPostFeedData,
|
||||
PostLoadError,
|
||||
type usePostFeed,
|
||||
usePostFeedOptions,
|
||||
} from './use-post-feed'
|
||||
import {
|
||||
emptyToolInput,
|
||||
feedResult,
|
||||
loadedPosts,
|
||||
prepareSearch,
|
||||
readToolInput,
|
||||
searchToolInput,
|
||||
toolResult,
|
||||
} from './webmcp-contracts'
|
||||
|
||||
export function useWebMCPSupported() {
|
||||
const [supported, setSupported] = useState(false)
|
||||
useEffect(() => {
|
||||
setSupported(Boolean(document.modelContext))
|
||||
}, [])
|
||||
return supported
|
||||
}
|
||||
|
||||
export function SearchTool() {
|
||||
const supported = useWebMCPSupported()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const options = usePostFeedOptions()
|
||||
const searching = useRef(false)
|
||||
|
||||
useWebMCP({
|
||||
name: 'search_posts',
|
||||
description:
|
||||
'Search X posts and return the first result slice. Updates the visible search page and its filters. Uses the active relay profile. Returns post text, authors, dates, source URLs and pagination information.',
|
||||
inputSchema: searchToolInput,
|
||||
enabled: supported,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
untrustedContentHint: true,
|
||||
},
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
const { search, request } = prepareSearch(input)
|
||||
if (searching.current)
|
||||
throw new Error('A search is already running. Wait for it to finish.')
|
||||
searching.current = true
|
||||
try {
|
||||
await router.navigate({ to: '/search', search })
|
||||
const href = router.buildLocation({ to: '/search', search }).href
|
||||
if (router.state.location.href !== href)
|
||||
throw new Error(
|
||||
'The page changed. Search again from the current page.',
|
||||
)
|
||||
// Join the UI request (or use its completed data), rather than fetch twice.
|
||||
const result = await getPostFeedData(queryClient, options(request))
|
||||
if (router.state.location.href !== href)
|
||||
throw new Error(
|
||||
'The page changed while searching. Read the current feed or search again.',
|
||||
)
|
||||
return feedResult(request, result.pages)
|
||||
} finally {
|
||||
searching.current = false
|
||||
}
|
||||
}),
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
export function useFeedTools(
|
||||
request: FeedRequest | undefined,
|
||||
query: ReturnType<typeof usePostFeed>,
|
||||
) {
|
||||
const supported = useWebMCPSupported()
|
||||
const generation = useRef(0)
|
||||
const requestKey = JSON.stringify(request)
|
||||
useEffect(() => {
|
||||
// Executions belong to the feed that was visible when they started.
|
||||
void requestKey
|
||||
generation.current += 1
|
||||
return () => {
|
||||
generation.current += 1
|
||||
}
|
||||
}, [requestKey])
|
||||
|
||||
useWebMCP({
|
||||
name: 'get_loaded_posts',
|
||||
description:
|
||||
'Read a slice of posts already loaded in the current timeline, search or conversation without a network request. Includes the selected conversation post. Returns active criteria, loading status, source URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation.',
|
||||
inputSchema: readToolInput,
|
||||
enabled: supported && Boolean(request),
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
untrustedContentHint: true,
|
||||
},
|
||||
execute: (input) =>
|
||||
toolResult(() => {
|
||||
const { offset, limit } = readToolInput.parse(input)
|
||||
if (!request)
|
||||
throw new Error('Open a timeline, search or conversation first.')
|
||||
return {
|
||||
...feedResult(request, query.data?.pages ?? [], offset, limit),
|
||||
status: query.isPending
|
||||
? 'loading'
|
||||
: query.isError
|
||||
? 'error'
|
||||
: 'ready',
|
||||
loading: query.isFetching,
|
||||
error:
|
||||
query.error instanceof PostLoadError ? query.error.detail : null,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
useWebMCP({
|
||||
name: 'load_more_posts',
|
||||
description:
|
||||
'Load one continuation page into the current feed and return only newly appended posts. Joins an in-progress scroll request. Returns nextOffset if additional loaded posts remain outside this response. At the end returns an empty posts array with hasMore false. Can retry a failed continuation.',
|
||||
inputSchema: emptyToolInput,
|
||||
enabled: supported && Boolean(request),
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
untrustedContentHint: true,
|
||||
},
|
||||
execute: (input) =>
|
||||
toolResult(async () => {
|
||||
emptyToolInput.parse(input)
|
||||
if (!request || query.isPending)
|
||||
throw new Error(
|
||||
'Wait for the initial feed to load before requesting more posts.',
|
||||
)
|
||||
if (query.isError && !query.isFetchNextPageError) throw query.error
|
||||
if (query.isFetching && !query.isFetchingNextPage)
|
||||
throw new Error(
|
||||
'The feed is refreshing. Wait before requesting more posts.',
|
||||
)
|
||||
const pages = query.data?.pages ?? []
|
||||
const offset = loadedPosts(request, pages).length
|
||||
if (!query.hasNextPage) return feedResult(request, pages, offset)
|
||||
const started = generation.current
|
||||
const result = await query.fetchNextPage({ cancelRefetch: false })
|
||||
if (generation.current !== started)
|
||||
throw new Error(
|
||||
'The feed changed while loading. Read the current feed before requesting more posts.',
|
||||
)
|
||||
if (result.isError) throw result.error
|
||||
return feedResult(request, result.data?.pages ?? [], offset)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class ProfileUnavailableError extends Error {}
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
deleteCookie,
|
||||
getCookie,
|
||||
setCookie,
|
||||
} from '@tanstack/react-start/server'
|
||||
import { z } from 'zod'
|
||||
import type { ProfileSettings } from './types'
|
||||
import { ProfileUnavailableError } from './errors'
|
||||
|
||||
const PROFILE_COOKIE = 'twitter-lite-profile'
|
||||
const profilesSchema = z.object({
|
||||
profiles: z.array(z.string().min(1)),
|
||||
})
|
||||
@@ -32,38 +26,15 @@ export async function fetchProfileNames(
|
||||
return profilesSchema.parse(await response.json()).profiles
|
||||
}
|
||||
|
||||
export function getSelectedProfileName(): string | undefined {
|
||||
return getCookie(PROFILE_COOKIE) || process.env.BIRD_PROFILE_NAME
|
||||
}
|
||||
|
||||
export async function loadProfileSettings(): Promise<ProfileSettings> {
|
||||
const profiles = await fetchProfileNames()
|
||||
const selectedProfile = getSelectedProfileName()
|
||||
return {
|
||||
profiles,
|
||||
...(selectedProfile && profiles.includes(selectedProfile)
|
||||
? { selectedProfile }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveProfileSelection(
|
||||
profileName: string | null,
|
||||
): Promise<void> {
|
||||
if (profileName === null) {
|
||||
deleteCookie(PROFILE_COOKIE, { path: '/' })
|
||||
return
|
||||
}
|
||||
|
||||
const profiles = await fetchProfileNames()
|
||||
if (!profiles.includes(profileName)) {
|
||||
throw new Error(`Unknown relay profile: ${profileName}`)
|
||||
}
|
||||
|
||||
setCookie(PROFILE_COOKIE, profileName, {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
export async function requireProfileName(profileName: string): Promise<void> {
|
||||
const profiles = await fetchProfileNames().catch(() => {
|
||||
throw new ProfileUnavailableError(
|
||||
'プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
|
||||
)
|
||||
})
|
||||
if (!profiles.includes(profileName)) {
|
||||
throw new ProfileUnavailableError(
|
||||
'このプロファイルは利用できません。カラムのプロファイルを選び直してください。',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fetchProfileNames } from './profile-service.server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ProfileUnavailableError } from './errors'
|
||||
import { fetchProfileNames, requireProfileName } from './profile-service.server'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('requireProfileName', () => {
|
||||
it('accepts a discovered profile and rejects a stale column binding', async () => {
|
||||
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => Response.json({ profiles: ['main', 'work'] })),
|
||||
)
|
||||
|
||||
await expect(requireProfileName('work')).resolves.toBeUndefined()
|
||||
await expect(requireProfileName('removed')).rejects.toBeInstanceOf(
|
||||
ProfileUnavailableError,
|
||||
)
|
||||
})
|
||||
|
||||
it('reports discovery failures without silently selecting another profile', async () => {
|
||||
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
)
|
||||
|
||||
await expect(requireProfileName('work')).rejects.toThrow(
|
||||
'プロファイルを確認できませんでした。',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchProfileNames', () => {
|
||||
it('loads profile names from the relay endpoint', async () => {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ProfileSwitcher } from './profile-switcher'
|
||||
|
||||
const serverFns = vi.hoisted(() => ({
|
||||
loadProfiles: vi.fn(),
|
||||
selectProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./server-functions', () => serverFns)
|
||||
vi.mock('@tanstack/react-start', () => ({
|
||||
useServerFn: (serverFn: unknown) => serverFn,
|
||||
}))
|
||||
|
||||
function renderSwitcher() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProfileSwitcher />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProfileSwitcher', () => {
|
||||
it('shows relay profiles and switches the active profile', async () => {
|
||||
serverFns.loadProfiles.mockResolvedValue({
|
||||
profiles: ['main', 'work'],
|
||||
selectedProfile: 'main',
|
||||
})
|
||||
serverFns.selectProfile.mockResolvedValue({ profileName: 'work' })
|
||||
renderSwitcher()
|
||||
|
||||
const select = await screen.findByLabelText('プロファイル')
|
||||
await waitFor(() => expect(select).toHaveValue('main'))
|
||||
|
||||
fireEvent.change(select, { target: { value: 'work' } })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(serverFns.selectProfile).toHaveBeenCalledWith({
|
||||
data: { profileName: 'work' },
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'切り替えました。',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a relay profiles failure without offering stale choices', async () => {
|
||||
serverFns.loadProfiles.mockRejectedValue(new Error('relay unavailable'))
|
||||
renderSwitcher()
|
||||
|
||||
const select = await screen.findByLabelText('プロファイル')
|
||||
await waitFor(() => expect(select).toBeDisabled())
|
||||
expect(select).toHaveTextContent('取得失敗')
|
||||
expect(select).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import { loadProfiles, selectProfile } from './server-functions'
|
||||
|
||||
export function ProfileSwitcher() {
|
||||
const queryClient = useQueryClient()
|
||||
const load = useServerFn(loadProfiles)
|
||||
const select = useServerFn(selectProfile)
|
||||
const profiles = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => load(),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const selection = useMutation({
|
||||
mutationFn: (profileName: string | null) =>
|
||||
select({ data: { profileName } }),
|
||||
onSuccess: () => queryClient.invalidateQueries(),
|
||||
})
|
||||
|
||||
const state = profiles.isError
|
||||
? 'error'
|
||||
: selection.isError
|
||||
? 'error'
|
||||
: selection.isSuccess
|
||||
? 'success'
|
||||
: profiles.isPending || selection.isPending
|
||||
? 'loading'
|
||||
: 'default'
|
||||
|
||||
return (
|
||||
<div className="profile-switcher" data-state={state}>
|
||||
<label htmlFor="relay-profile">プロファイル</label>
|
||||
<select
|
||||
aria-invalid={state === 'error' ? 'true' : undefined}
|
||||
disabled={profiles.isPending || profiles.isError || selection.isPending}
|
||||
id="relay-profile"
|
||||
onChange={(event) =>
|
||||
selection.mutate(
|
||||
event.target.value.length ? event.target.value : null,
|
||||
)
|
||||
}
|
||||
value={profiles.data?.selectedProfile ?? ''}
|
||||
>
|
||||
<option value="">
|
||||
{profiles.isPending
|
||||
? '読み込み中'
|
||||
: profiles.isError
|
||||
? '取得失敗'
|
||||
: '自動'}
|
||||
</option>
|
||||
{profiles.data?.profiles.map((profile) => (
|
||||
<option key={profile} value={profile}>
|
||||
{profile}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="profile-switcher-status"
|
||||
role={selection.isError ? 'alert' : 'status'}
|
||||
>
|
||||
{selection.isError
|
||||
? '切り替えに失敗しました。'
|
||||
: selection.isSuccess
|
||||
? '切り替えました。'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,8 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import type { ProfileSelection } from './types'
|
||||
|
||||
const profileSelectionSchema = z.object({
|
||||
profileName: z.string().min(1).nullable(),
|
||||
})
|
||||
|
||||
export const loadProfiles = createServerFn({ method: 'GET' }).handler(
|
||||
async () => {
|
||||
const { loadProfileSettings } = await import('./profile-service.server')
|
||||
return loadProfileSettings()
|
||||
const { fetchProfileNames } = await import('./profile-service.server')
|
||||
return { profiles: await fetchProfileNames() }
|
||||
},
|
||||
)
|
||||
|
||||
export const selectProfile = createServerFn({ method: 'POST' })
|
||||
.validator(profileSelectionSchema)
|
||||
.handler(async ({ data }): Promise<ProfileSelection> => {
|
||||
const { saveProfileSelection } = await import('./profile-service.server')
|
||||
await saveProfileSelection(data.profileName)
|
||||
return data
|
||||
})
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type ProfileSettings = {
|
||||
profiles: string[]
|
||||
selectedProfile?: string
|
||||
}
|
||||
|
||||
export type ProfileSelection = {
|
||||
profileName: string | null
|
||||
}
|
||||
+3
-115
@@ -9,127 +9,48 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as DeckRouteImport } from './routes/deck'
|
||||
import { Route as HandleRouteImport } from './routes/$handle'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as IListsRouteImport } from './routes/i.lists'
|
||||
import { Route as IListsListIdRouteImport } from './routes/i.lists_.$listId'
|
||||
import { Route as HandleStatusTweetIdRouteImport } from './routes/$handle_.status.$tweetId'
|
||||
|
||||
const SearchRoute = SearchRouteImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DeckRoute = DeckRouteImport.update({
|
||||
id: '/deck',
|
||||
path: '/deck',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HandleRoute = HandleRouteImport.update({
|
||||
id: '/$handle',
|
||||
path: '/$handle',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IListsRoute = IListsRouteImport.update({
|
||||
id: '/i/lists',
|
||||
path: '/i/lists',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IListsListIdRoute = IListsListIdRouteImport.update({
|
||||
id: '/i/lists_/$listId',
|
||||
path: '/i/lists/$listId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
|
||||
id: '/$handle_/status/$tweetId',
|
||||
path: '/$handle/status/$tweetId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$handle': typeof HandleRoute
|
||||
'/deck': typeof DeckRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/i/lists': typeof IListsRoute
|
||||
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||
'/i/lists/$listId': typeof IListsListIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$handle': typeof HandleRoute
|
||||
'/deck': typeof DeckRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/i/lists': typeof IListsRoute
|
||||
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||
'/i/lists/$listId': typeof IListsListIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$handle': typeof HandleRoute
|
||||
'/deck': typeof DeckRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/i/lists': typeof IListsRoute
|
||||
'/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||
'/i/lists_/$listId': typeof IListsListIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/$handle'
|
||||
| '/deck'
|
||||
| '/search'
|
||||
| '/i/lists'
|
||||
| '/$handle/status/$tweetId'
|
||||
| '/i/lists/$listId'
|
||||
fullPaths: '/' | '/deck'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/$handle'
|
||||
| '/deck'
|
||||
| '/search'
|
||||
| '/i/lists'
|
||||
| '/$handle/status/$tweetId'
|
||||
| '/i/lists/$listId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/$handle'
|
||||
| '/deck'
|
||||
| '/search'
|
||||
| '/i/lists'
|
||||
| '/$handle_/status/$tweetId'
|
||||
| '/i/lists_/$listId'
|
||||
to: '/' | '/deck'
|
||||
id: '__root__' | '/' | '/deck'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
HandleRoute: typeof HandleRoute
|
||||
DeckRoute: typeof DeckRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
IListsRoute: typeof IListsRoute
|
||||
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
|
||||
IListsListIdRoute: typeof IListsListIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/search': {
|
||||
id: '/search'
|
||||
path: '/search'
|
||||
fullPath: '/search'
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/deck': {
|
||||
id: '/deck'
|
||||
path: '/deck'
|
||||
@@ -137,13 +58,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DeckRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$handle': {
|
||||
id: '/$handle'
|
||||
path: '/$handle'
|
||||
fullPath: '/$handle'
|
||||
preLoaderRoute: typeof HandleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -151,38 +65,12 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/i/lists': {
|
||||
id: '/i/lists'
|
||||
path: '/i/lists'
|
||||
fullPath: '/i/lists'
|
||||
preLoaderRoute: typeof IListsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/i/lists_/$listId': {
|
||||
id: '/i/lists_/$listId'
|
||||
path: '/i/lists/$listId'
|
||||
fullPath: '/i/lists/$listId'
|
||||
preLoaderRoute: typeof IListsListIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$handle_/status/$tweetId': {
|
||||
id: '/$handle_/status/$tweetId'
|
||||
path: '/$handle/status/$tweetId'
|
||||
fullPath: '/$handle/status/$tweetId'
|
||||
preLoaderRoute: typeof HandleStatusTweetIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
HandleRoute: HandleRoute,
|
||||
DeckRoute: DeckRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
IListsRoute: IListsRoute,
|
||||
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
|
||||
IListsListIdRoute: IListsListIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { UserPage } from './index'
|
||||
|
||||
export const Route = createFileRoute('/$handle')({
|
||||
component: UserRoute,
|
||||
})
|
||||
|
||||
function UserRoute() {
|
||||
const { handle } = Route.useParams()
|
||||
return <UserPage handle={handle} />
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import {
|
||||
InputError,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
} from '#/features/posts/inputs'
|
||||
|
||||
export const Route = createFileRoute('/$handle_/status/$tweetId')({
|
||||
component: StatusRoute,
|
||||
})
|
||||
|
||||
function StatusRoute() {
|
||||
const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams()
|
||||
let tweetId: string | undefined
|
||||
let error: string | undefined
|
||||
|
||||
try {
|
||||
normalizeUserTarget(rawHandle)
|
||||
tweetId = normalizeTweetId(rawTweetId)
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof InputError ? cause.message : 'URL を確認してください。'
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<h1>会話</h1>
|
||||
<p className="intro">選んだ投稿と、その会話だけを表示します。</p>
|
||||
{error ? (
|
||||
<p className="state" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<PostFeed request={tweetId ? { kind: 'thread', tweetId } : undefined} />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { routeTree } from '../routeTree.gen'
|
||||
|
||||
const usePostFeed = vi.hoisted(() => vi.fn())
|
||||
const useListChoices = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../features/posts/use-post-feed')>()
|
||||
return { ...original, usePostFeed }
|
||||
})
|
||||
|
||||
vi.mock('../features/posts/use-list-choices', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../features/posts/use-list-choices')>()
|
||||
return { ...original, useListChoices }
|
||||
})
|
||||
|
||||
vi.mock('../features/profiles/profile-switcher', () => ({
|
||||
ProfileSwitcher: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./__root', async () => {
|
||||
const { createRootRouteWithContext, Outlet } = await import(
|
||||
'@tanstack/react-router'
|
||||
)
|
||||
return {
|
||||
Route: createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||
component: Outlet,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
async function renderRoute(path: string) {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
history: createMemoryHistory({ initialEntries: [path] }),
|
||||
context: { queryClient: new QueryClient() },
|
||||
})
|
||||
await router.load()
|
||||
render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('post feed route wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('scrollTo', vi.fn())
|
||||
usePostFeed.mockReset()
|
||||
useListChoices.mockReset()
|
||||
usePostFeed.mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isError: false,
|
||||
isFetchNextPageError: false,
|
||||
isFetchingNextPage: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
} as never)
|
||||
useListChoices.mockReturnValue({
|
||||
data: [{ id: '1234567890', name: 'My List', memberCount: 10 }],
|
||||
error: null,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('keeps an empty user URL disabled', async () => {
|
||||
await renderRoute('/')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
|
||||
).toBeVisible()
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('turns a manually entered user URL into a deliberate request', async () => {
|
||||
await renderRoute('/yuta')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'user',
|
||||
target: 'yuta',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an invalid user path local and idle', async () => {
|
||||
await renderRoute('/not-a-handle')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'ハンドルは英数字とアンダースコアで入力してください。',
|
||||
)
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('keeps an empty list URL disabled', async () => {
|
||||
await renderRoute('/i/lists')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
|
||||
).toBeVisible()
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('turns a manually entered list URL into a deliberate request', async () => {
|
||||
await renderRoute('/i/lists/1234567890')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'list',
|
||||
target: '1234567890',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('link', { name: 'リスト', current: 'page' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps an invalid list path local and idle', async () => {
|
||||
await renderRoute('/i/lists/not-a-list')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'リスト ID は数字で入力してください。',
|
||||
)
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('turns a selected authenticated list into a deliberate request', async () => {
|
||||
await renderRoute('/i/lists')
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'list',
|
||||
target: '1234567890',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards every manually entered search control', async () => {
|
||||
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'search',
|
||||
query: 'AI lang:ja filter:follows',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('compiles button-selected search conditions from the URL', async () => {
|
||||
await renderRoute(
|
||||
'/search?q=TypeScript&from=%40tan_stack&since=2026-08-04&until=2026-08-11&lang=ja&content=images&excludeReplies=true&excludeReposts=true&product=Latest&following=false',
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'search',
|
||||
query:
|
||||
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: '投稿者 @tan_stack を解除' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps contradictory date parameters local and idle', async () => {
|
||||
await renderRoute(
|
||||
'/search?q=TypeScript&since=2026-08-11&until=2026-08-10&product=Latest&following=false',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '期間' }))
|
||||
expect(await screen.findByLabelText('開始日')).toHaveValue('2026-08-11')
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('turns a decimal status path into a deliberate thread request', async () => {
|
||||
await renderRoute('/yuta/status/123')
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
|
||||
await waitFor(() =>
|
||||
expect(usePostFeed).toHaveBeenCalledWith({
|
||||
kind: 'thread',
|
||||
tweetId: '123',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'ユーザー', current: 'page' }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.queryByRole('link', { name: '検索', current: 'page' }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an invalid tweet path local and idle', async () => {
|
||||
await renderRoute('/yuta/status/not-a-tweet')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'投稿 ID を確認してください。',
|
||||
)
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('keeps an invalid status handle local and idle', async () => {
|
||||
await renderRoute('/not-a-handle/status/123')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'ハンドルは英数字とアンダースコアで入力してください。',
|
||||
)
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from '@tanstack/react-router'
|
||||
import { SearchTool } from '#/features/posts/webmcp-tools'
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
type RouterContext = { queryClient: QueryClient }
|
||||
@@ -34,12 +33,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
})
|
||||
|
||||
function RootContent() {
|
||||
return (
|
||||
<>
|
||||
<SearchTool />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { ListForm } from '#/features/posts/components/list-form'
|
||||
import { ListPicker } from '#/features/posts/components/list-picker'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import { InputError, normalizeListTarget } from '#/features/posts/inputs'
|
||||
|
||||
export const Route = createFileRoute('/i/lists')({
|
||||
component: ListIndexRoute,
|
||||
})
|
||||
|
||||
function ListIndexRoute() {
|
||||
return <ListPage />
|
||||
}
|
||||
|
||||
export function ListPage({ listId = '' }: { listId?: string }) {
|
||||
const navigate = useNavigate()
|
||||
let normalizedListId: string | undefined
|
||||
let error: string | undefined
|
||||
|
||||
if (listId) {
|
||||
try {
|
||||
normalizedListId = normalizeListTarget(listId)
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof InputError
|
||||
? cause.message
|
||||
: 'リスト ID を確認してください。'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell active="list">
|
||||
<h1 className="reader-title-long">どのリストを読みますか?</h1>
|
||||
<p className="intro">
|
||||
自分のリストを選ぶか、リストURLを入力してください。
|
||||
</p>
|
||||
<ListPicker selectedId={listId || undefined} />
|
||||
<ListForm
|
||||
initialTarget={listId}
|
||||
key={listId}
|
||||
onSubmit={(nextListId) =>
|
||||
navigate({
|
||||
to: '/i/lists/$listId',
|
||||
params: { listId: nextListId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="state" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<PostFeed
|
||||
request={
|
||||
normalizedListId
|
||||
? { kind: 'list', target: normalizedListId }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ListPage } from './i.lists'
|
||||
|
||||
export const Route = createFileRoute('/i/lists_/$listId')({
|
||||
component: ListRoute,
|
||||
})
|
||||
|
||||
function ListRoute() {
|
||||
const { listId } = Route.useParams()
|
||||
return <ListPage listId={listId} />
|
||||
}
|
||||
+3
-55
@@ -1,56 +1,4 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import { UserForm } from '#/features/posts/components/user-form'
|
||||
import { InputError, normalizeUserTarget } from '#/features/posts/inputs'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { DeckPage } from '#/features/decks/deck-page'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: UserIndexRoute,
|
||||
})
|
||||
|
||||
function UserIndexRoute() {
|
||||
return <UserPage />
|
||||
}
|
||||
|
||||
export function UserPage({ handle = '' }: { handle?: string }) {
|
||||
const navigate = useNavigate()
|
||||
let normalizedHandle: string | undefined
|
||||
let error: string | undefined
|
||||
|
||||
if (handle) {
|
||||
try {
|
||||
normalizedHandle = normalizeUserTarget(handle)
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof InputError
|
||||
? cause.message
|
||||
: 'ハンドルを確認してください。'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell active="user">
|
||||
<h1>誰の投稿を見ますか?</h1>
|
||||
<p className="intro">履歴やおすすめは表示しません。</p>
|
||||
<UserForm
|
||||
initialTarget={handle}
|
||||
key={handle}
|
||||
onSubmit={(nextHandle) =>
|
||||
navigate({ to: '/$handle', params: { handle: nextHandle } })
|
||||
}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="state" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<PostFeed
|
||||
request={
|
||||
normalizedHandle
|
||||
? { kind: 'user', target: normalizedHandle }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
export const Route = createFileRoute('/')({ component: DeckPage })
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import {
|
||||
SearchForm,
|
||||
type SearchValues,
|
||||
} from '#/features/posts/components/search-form'
|
||||
import {
|
||||
buildFilteredSearchQuery,
|
||||
postSearchRouteSchema,
|
||||
} from '#/features/posts/inputs'
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
validateSearch: (search) => postSearchRouteSchema.parse(search),
|
||||
component: SearchRoute,
|
||||
})
|
||||
|
||||
function compileQuery(values: SearchValues): string | undefined {
|
||||
if (!values.q && !values.from) return undefined
|
||||
try {
|
||||
return buildFilteredSearchQuery(values, values.following)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function SearchRoute() {
|
||||
const values = Route.useSearch()
|
||||
const navigate = Route.useNavigate()
|
||||
const formValues = {
|
||||
...values,
|
||||
from: values.from ?? '',
|
||||
since: values.since ?? '',
|
||||
until: values.until ?? '',
|
||||
lang: values.lang ?? 'all',
|
||||
content: values.content ?? 'all',
|
||||
excludeReplies: values.excludeReplies ?? false,
|
||||
excludeReposts: values.excludeReposts ?? false,
|
||||
} satisfies SearchValues
|
||||
const query = compileQuery(formValues)
|
||||
const formKey = [
|
||||
formValues.q,
|
||||
formValues.from,
|
||||
formValues.since,
|
||||
formValues.until,
|
||||
formValues.lang,
|
||||
formValues.content,
|
||||
formValues.excludeReplies,
|
||||
formValues.excludeReposts,
|
||||
formValues.product,
|
||||
formValues.following,
|
||||
].join(':')
|
||||
return (
|
||||
<AppShell active="search">
|
||||
<h1>何を確認しますか?</h1>
|
||||
<p className="intro">入力した条件だけを検索します。</p>
|
||||
<SearchForm
|
||||
initialValues={formValues}
|
||||
key={formKey}
|
||||
onSubmit={(search) => navigate({ search })}
|
||||
/>
|
||||
<PostFeed
|
||||
request={
|
||||
query
|
||||
? {
|
||||
kind: 'search',
|
||||
query,
|
||||
product: values.product,
|
||||
following: values.following,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
+65
-1386
File diff suppressed because it is too large
Load Diff
+34
-38
@@ -1,29 +1,28 @@
|
||||
/* Hallmark · macrostructure: Workbench · tone: soft · anchor hue: leaf-green */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */
|
||||
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
|
||||
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
|
||||
:root {
|
||||
--color-canvas: oklch(93% 0.018 125);
|
||||
--color-paper: oklch(98% 0.01 100);
|
||||
--color-paper-2: oklch(95% 0.018 115);
|
||||
--color-paper-3: oklch(90% 0.026 125);
|
||||
--color-ink: oklch(23% 0.025 145);
|
||||
--color-ink-2: oklch(32% 0.03 145);
|
||||
--color-muted: oklch(42% 0.026 145);
|
||||
--color-accent: oklch(38% 0.09 145);
|
||||
--color-accent-hover: oklch(31% 0.085 145);
|
||||
--color-accent-ink: oklch(98% 0.01 100);
|
||||
--color-rule: oklch(80% 0.025 125);
|
||||
--color-control-rule: oklch(55% 0.035 135);
|
||||
--color-focus: oklch(45% 0.15 65);
|
||||
--color-error: oklch(42% 0.13 25);
|
||||
--color-success: oklch(38% 0.1 145);
|
||||
--color-shadow: oklch(23% 0.025 145 / 10%);
|
||||
|
||||
--font-display:
|
||||
"YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif;
|
||||
--color-canvas: oklch(19% 0.021 250);
|
||||
--color-paper: oklch(24% 0.025 250);
|
||||
--color-paper-2: oklch(28% 0.027 250);
|
||||
--color-paper-3: oklch(33% 0.031 250);
|
||||
--color-ink: oklch(95% 0.005 250);
|
||||
--color-ink-2: oklch(85% 0.015 250);
|
||||
--color-muted: oklch(72% 0.025 250);
|
||||
--color-accent: oklch(72% 0.15 240);
|
||||
--color-accent-hover: oklch(79% 0.13 240);
|
||||
--color-accent-ink: oklch(18% 0.03 250);
|
||||
--color-rule: oklch(34% 0.023 250);
|
||||
--color-control-rule: oklch(47% 0.029 250);
|
||||
--color-focus: oklch(79% 0.13 240);
|
||||
--color-error: oklch(78% 0.13 20);
|
||||
--color-success: oklch(78% 0.13 155);
|
||||
--color-shadow: oklch(10% 0.015 250 / 45%);
|
||||
--color-overlay: oklch(10% 0.015 250 / 70%);
|
||||
--font-body:
|
||||
"Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans",
|
||||
"Noto Sans JP", sans-serif;
|
||||
--font-display: var(--font-body);
|
||||
--font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
|
||||
--space-3xs: 0.125rem;
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
@@ -33,26 +32,23 @@
|
||||
--space-xl: 2.5rem;
|
||||
--space-2xl: 4rem;
|
||||
--space-3xl: 6rem;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-xs: 0.8571rem;
|
||||
--text-sm: 1rem;
|
||||
--text-base: 1rem;
|
||||
--text-md: 1.25rem;
|
||||
--text-lg: 1.5625rem;
|
||||
--text-xl: 1.9531rem;
|
||||
--text-display-s: clamp(1.75rem, 7.4vw + 0.25rem, 2.75rem);
|
||||
--text-display-long: clamp(1.5rem, 5.5vw + 0.4rem, 2.75rem);
|
||||
|
||||
--text-md: 1.125rem;
|
||||
--text-lg: 1.25rem;
|
||||
--text-xl: 1.5rem;
|
||||
--text-display-s: 1.125rem;
|
||||
--text-display-long: 1.125rem;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--dur-instant: 100ms;
|
||||
--dur-short: 200ms;
|
||||
--dur-medium: 420ms;
|
||||
|
||||
--dur-short: 150ms;
|
||||
--dur-medium: 300ms;
|
||||
--rule-hairline: 1px;
|
||||
--rule-active: 2px;
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.625rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user