Author SHA1 Message Date
yuta 01d808381b Update Nix flake inputs 2026-07-26 15:35:27 +09:00
yuta c43278f785 Fix reader media and thread behavior 2026-07-26 15:31:29 +09:00
190 changed files with 4191 additions and 24614 deletions
+1 -10
View File
@@ -1,11 +1,2 @@
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
TWITTER_LITE_ORIGIN=https://your-host.your-tailnet.ts.net
TWITTER_LITE_ALLOWED_LOGIN=your-tailscale-login
TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite
# Required when connecting Mastodon; keep this runtime file outside Git.
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
# Optional home Codex research prototype. Start pnpm codex:serve separately.
# TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
BIRD_PROFILE_NAME=
-1
View File
@@ -8,4 +8,3 @@ dist/
node_modules/
playwright-report/
test-results/
.data/
-8
View File
@@ -1,12 +1,4 @@
[
{
"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",
+6 -7
View File
@@ -1,10 +1,9 @@
{
"date": "2026-09-24",
"font_stack": "System sans; tokens.css",
"palette": "Dark navy and blue OKLCH tokens",
"motion": "motion-cut",
"spacing": "named token scale",
"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",
"framework": "React 19 + TanStack Start + Vite 8",
"preserve": ["deck data model", "profile binding", "WebMCP contracts"],
"override": "User requested TweetDeck-like full UI redesign instead of Garden reader"
"preserve": ["routes", "information architecture", "read-only behavior"]
}
+54 -200
View File
@@ -1,40 +1,41 @@
# Twitter Lite
Twitter Lite is a personal research deck for Twitter and Mastodon. Create
multiple named decks and arrange up to six columns per deck. Each column is
bound to a connection account, so platforms and multiple accounts work side by side.
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.
## Scope
- Multiple deck profiles with creation, selection, renaming, and deletion
- Twitter search, user timeline, and list columns with independent relay accounts
- Mastodon OAuth, multiple accounts, search, user, list, and hashtag columns
- 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
- Platform logos in column headers distinguish Twitter and Mastodon at a glance
- SQLite-backed shared decks, revision conflicts, and device-local active selection
- Temporary views for AI exploration, with explicit save and temporary copies
- Server-only encrypted Mastodon credentials and configurable Tailscale owner access
- Read-only cards with original-post links, text, media, and quotes
- Experimental WebMCP tools to manage decks and read or paginate their columns
- Prototype: chat with a resident home Codex beside a live deck, reuse existing
decks and write cited Markdown research reports on the host
- Saved chat history with deck, account and citation restoration across devices
and backend restarts
- User timelines and raw X search syntax
- Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search
- Authenticated account list selection and list timelines from a deliberate URL or ID
- Infinite cursor pagination with explicit retry
- Deliberate post detail pages with the visible conversation
- Infinite conversation loading with explicit continuation retry
- Read-only cards for text, media, quotes, articles, and quiet engagement counts
Both `/` and `/deck` open the deck workspace. The separate reader, search,
list, user-profile, and conversation routes have been removed. Original-post
links on post cards open their source site; SNS reply threads are not rendered
inside the app.
It intentionally has no home feed, recommendations, trends, notifications,
history, account directory, or write actions.
Bluesky/Threads/Nostr connectors are not implemented yet. An external browser
agent can also create and read decks through WebMCP.
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.
- `/:handle` shows a user timeline.
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` shows search results.
- `/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.
## 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.
routes the `@yuta` scope to the public Gitea Packages registry, where Bird
`0.10.0` provides the required Top and Latest search interface.
```bash
nix develop -c pnpm install --frozen-lockfile
@@ -42,192 +43,48 @@ cp .env.example .env.local
```
The Nix development shell installs Hallmark's agent skill for the supported
local agent targets. Set the runtime configuration in `.env.local` for Vite
development, or export it before running the production server. In addition to
the relay URL, configure the Serve origin, owner login, and an absolute SQLite
path from `.env.example`. Mastodon needs an allowed instance and a runtime
encryption key file; see [storage, OAuth and restore](docs/storage-and-oauth.md).
local agent targets.
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
values in the process environment before `start` or `test:live`:
```bash
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
export BIRD_PROFILE_NAME=
```
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
stable connection IDs. The connection metadata resolves Twitter IDs to relay
profiles; Mastodon credentials never go to the browser.
Both values are read by server-only code at runtime; neither value nor Bird is
sent to the browser.
## Commands
```bash
nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale
nix develop -c pnpm generate:e2e-openapi
nix develop -c pnpm knip
nix develop -c pnpm test
nix develop -c pnpm test:e2e
nix develop -c pnpm test:live
nix develop -c pnpm build
nix develop -c pnpm start
nix develop -c pnpm db:generate
nix develop -c pnpm codex:serve
```
## Home Codex research prototype
`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. `test:live` is an explicit, opt-in smoke test that performs exactly
one read-only Top search using the configured relay.
Run `codex login` as the host user, then keep `pnpm codex:serve` running separately
from the web server. It starts a resident app-server at `ws://127.0.0.1:4500`
using that user's existing Codex login. The launcher passes a small environment
allowlist, without the app's database, relay or SNS secret settings. Configured
MCP servers are disabled for this process without changing the user's settings.
The prototype was developed against Codex CLI 0.156.1; its WebSocket and dynamic
tool APIs are experimental.
Configure the web app with these values in `.env.local` (absolute report path):
```dotenv
TWITTER_LITE_CODEX_URL=ws://127.0.0.1:4500
TWITTER_LITE_CODEX_MODEL=gpt-6-astra
TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
```
Use the chat on the left while browsing horizontally scrollable deck columns on
the right. Messages continue the same Codex thread and include the currently
open deck as context. The account selector controls which connections Codex can
use. It can inspect saved deck definitions and reuse them as temporary views,
as well as create new views. `list_lists(connectionId)` discovers existing
Twitter and Mastodon lists for a selected connected account; returned list IDs
can be reused in list columns. It uses the same catalog as the manual editor
(Twitter requests up to 100 lists; list discovery is not paginated).
The default prompt asks Codex to inspect existing decks and the selected
accounts' lists before planning new searches, then read relevant list columns
for evidence. Follow-ups can reuse catalogs already fetched in the conversation.
Manual deck creation and column editing remain
available. The backend handles tools even when the browser is closed.
Use **新しいチャット** to leave the current conversation and start a fresh Codex
thread with your next message. The open deck and host reports remain available.
If a turn is running, stop it first. The conversation reset is shared across
open devices. **チャット履歴** reopens saved conversations with their account
selection, deck and citations; the next message resumes the same Codex thread.
SQLite stores conversation snapshots and the active conversation selection.
Each turn is limited to 6 columns, 12 upstream requests shared between list
discovery and post retrieval (at most 20 posts per fetch), and 15 minutes.
The prototype accepts up to 100 messages per backend process.
SSE pushes conversation messages, tool activity and generated deck updates to
open browsers, without polling.
Codex replies render as Markdown, including tables, lists and code. Clicking a
cited post link scrolls to its card and briefly highlights it. Loaded cards take
priority; otherwise the app shows the post captured during research in a column
with matching source and account settings, creating a temporary citation deck
when needed. These captured posts are labeled and do not change feed pagination
or reveal sensitive content automatically. Other links still open externally;
Ctrl/Cmd-click on a citation also opens the original site.
Citation metadata stays with the saved conversation across reconnects,
follow-up messages and server restarts. Markdown HTML is not executed and
Markdown images are rendered as links.
Reopening the page receives the latest state and restores the temporary deck.
Later updates modify the same temporary view; they do not overwrite saved decks
or switch away from another deck you are reading. Use the existing save button
to persist a deck. The agent and visible columns currently fetch posts separately.
Codex writes `<report root>/<run ID>/report.md` using its workspace file tools.
The UI displays its host path after validating the file. A conversation or deck
edit can finish without producing a report. Citation quality is model output,
not independently verified. Report contents remain on the host filesystem;
the app DB stores the conversation, thread ID, deck context and citation metadata.
Browser disconnection does not cancel research; reconnecting restores the latest
state through `/api/research/events`, under the same owner access checks as the
rest of the app. After a backend restart, unfinished turns appear as interrupted;
sending another message resumes the same thread. Before starting that message,
the backend checks the resumed thread and interrupts any orphaned active turn.
It does not automatically rerun interrupted requests. Files already written remain.
Cancellation is explicit. The launcher and report directory must
run under a user able to write those files. NixOS DynamicUser service integration
and dedicated service credentials are outside this prototype.
`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.
`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.
## Deck workspace
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.
Create a deck for an investigation, then add columns for each perspective.
Choose a connected account and one of its supported sources. Account
changes affect only the edited column. Matching source conditions and accounts
share the query cache; different profiles never share posts or cursors.
Saved decks live on the server and refresh across devices on focus and while
visible. Conflicting revisions are rejected. Only the active deck is rendered
and fetched; posts and cursors are not archived. The active selection stays
local to the device.
AI-created decks open as temporary views reconstructed from their saved chat
on another device or after reload. Save a view to add it to the independent deck
library, or make a temporary copy of a saved deck to experiment.
Other unsaved temporary edits disappear on reload or navigation, including OAuth redirects.
Existing version-2 browser decks have an explicit import action; invalid older
data is left untouched. See [the deck model and persistence contract](docs/research-decks.md).
## WebMCP
A WebMCP-enabled browser exposes `list_connections`, `list_decks`, `get_deck`,
`set_deck`, `save_deck`, `select_deck`, `delete_deck`, `get_column_posts`, and
`load_more_column` on both deck routes. Discover connection IDs first.
`set_deck` creates a temporary view when `deckId` is omitted. `save_deck`
persists it. Replacing or deleting a saved deck requires its `expectedRevision`.
Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the
local app. Use the
[Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension)
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 and `dev:tailscale` both bind to `127.0.0.1`. 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.
Set `TWITTER_LITE_ORIGIN` to the exact public HTTPS origin (no trailing slash).
The default `TWITTER_LITE_AUTH_MODE=tailscale` requires
`TWITTER_LITE_ALLOWED_LOGIN` to be your Tailscale login. The app requires
Serve's `Tailscale-User-Login` header and rejects other users. Keep the backend
on localhost: the trusted Serve proxy supplies identity. Tagged clients do not
provide user identity. Missing configuration fails closed; direct browser access
to localhost does not supply the required identity. Playwright supplies an
explicit fixture identity to its isolated test server.
For a private, tailnet-only reverse proxy such as Traefik, explicitly set
`TWITTER_LITE_AUTH_MODE=none` to disable application identity checks. This mode
does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers;
anyone who can reach that proxy can use the app and its connected accounts and
Codex. Bind the backend to loopback or its Tailscale address and restrict proxy
access to the tailnet.
Both modes require the exact configured `Origin` for state-changing requests,
including chat and deck mutations. Missing origin or an unknown auth mode
fails closed. Mastodon OAuth uses the configured public origin for its callback.
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`.
## NixOS service
The flake provides a production package and a NixOS module:
The flake provides both a production package and a NixOS module. Import the
module and configure the relay:
```nix
{
@@ -243,10 +100,7 @@ The flake provides a production package and a NixOS module:
services.twitter-lite = {
enable = true;
relayBaseUrl = "http://127.0.0.1:6900";
publicOrigin = "https://home.example-tailnet.ts.net";
allowedLogin = "your-tailscale-login";
mastodonOrigins = [ "https://fedi.yutakobayashi.com" ];
credentialKeyFile = "/var/lib/secrets/twitter-lite-key";
# profileName = "default";
};
}
];
@@ -255,15 +109,15 @@ The flake provides a production package and a NixOS module:
}
```
The service listens on `127.0.0.1:3000`. Set
`services.twitter-lite.port` to change the port; the host remains loopback.
The module reserves `/var/lib/twitter-lite` with mode 0700 for persistent state.
`credentialKeyFile` can reference a runtime secret file for SNS credentials;
systemd loads it as a credential, separate from the database and Nix store.
Profiles are selected in column definitions, not service options.
The package can also be built directly with `nix build`.
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`.
## 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.
@@ -1,288 +0,0 @@
# Home-agent research
## Product boundary
The user starts a research task from the deck workspace. A home agent chooses
sources, opens a temporary deck, reads posts and writes a cited Markdown report
on the host. Twitter Lite provides the connected-account and deck tools; the
agent owns research and report writing.
SQLite stores conversation snapshots and the selected conversation alongside
saved deck definitions and account credentials. Reports remain host Markdown;
their contents are not copied into the DB. Adding a temporary deck to the saved
deck library remains an explicit user action.
## Proposed first implementation
1. Add a topic and connected-account selection form to the deck workspace.
2. Start one home Codex task with a dedicated output directory.
3. Supply tools for connection discovery, temporary deck creation and bounded
post retrieval using the existing validators and platform services.
4. Save conversation state and the latest generated deck in SQLite. SSE
sends the current snapshot on connection and pushes subsequent changes.
Reopened pages restore the temporary view; later definitions update the same
view. Closing that view does not delete the report or stop the agent.
5. Have the agent write `report.md` in its per-task directory with original post
URLs, collection limits and failed sources. Show progress and the output
location in the app. No report-history UI or scheduled execution yet.
Following the user's WebSocket question, the proposed transport is a persistent
`codex app-server --listen ws://127.0.0.1:4500` connected to the Node backend,
with dynamic tools handled by that backend. WebSocket transport is currently
experimental in the official documentation. Native browser WebMCP is not a remote
MCP endpoint: reuse application operations rather than assuming that host Codex
can directly invoke a user's browser tools. No externally exposed Codex socket
is necessary.
## Decisions
- Confirmed: use home Codex, rather than a separate OpenAI API integration.
- Confirmed: report artifacts are host Markdown files written by the agent.
- Prototyping: resident app-server over loopback WebSocket, using the existing
Codex login and model `gpt-6-astra`. No NixOS service migration in this step.
- Configure a dedicated report root and Codex authentication store. Preserve
refreshed authentication for the service; do not put credentials in prompts
or copy application secrets into the child environment.
- Keep manual execution bounded and cancellable. After restart, unfinished
conversations restore as interrupted. The next user message resumes the same
Codex thread after stopping any surviving orphan turn. No automatic rerun or
durable job queue is added.
## Conversation workspace
The next iteration places a persistent chat beside the horizontally scrollable
deck. The browser displays user messages, public Codex agent messages and tool
activity from app-server events. Each follow-up continues the existing thread
and supplies the deck currently open in that browser. Internal reasoning is not
part of this transcript.
The agent can list and inspect saved decks within the selected account scope,
then reuse their columns in a temporary view. Saved definitions remain unchanged
until the user saves a view. Existing manual creation, selection, column editing
and saving stay available alongside the chat.
`list_lists(connectionId)` also exposes the manual editor's Twitter and Mastodon
list catalogs. Discovery is read-only and checks the selected connected account
before contacting either provider. Returned list IDs can be used directly in
list-column sources. Discovery and post retrieval share the per-turn budget of
12 upstream calls, including failures. Twitter requests up to 100 entries through
the existing catalog service; discovery has no pagination yet.
For Twitter, the agent prefers the selected connected account named `account2`
for new columns and retrieval, resolving its ID through `list_connections`.
It uses another selected account when `account2` is unavailable, lacks access
(for example to a private list), or the user explicitly requests it. Existing
columns retain their account bindings.
The default agent instructions require this discovery at the start of research,
before planning new searches. Relevant existing lists become candidate sources;
their posts are retrieved through the regular column tool. Catalogs already read
in the conversation can be reused, and explicit user instructions take priority.
A conversational turn need not produce a research report: discussing a query or
editing a deck is a valid completed turn. Reports, when requested, remain host
Markdown files. SSE snapshots include the conversation and latest generated
deck so reopening a page restores both without restarting work or polling.
The chat header can reset an idle conversation. The next message starts a new
Codex thread while retaining the browser's current deck as context. Reset pushes
an empty conversation to every subscriber, preserves host reports and does not
delete deck definitions. Active turns must be stopped first; a stale reset must
not clear a newer conversation started on another device. The history selector
can reopen previous conversations while idle, restoring the generated deck (or
last supplied deck context), selected accounts and citations. Selection is shared
through SSE. Stale selections are rejected just like stale resets.
`research_sessions` stores the full public conversation snapshot and summary
metadata; `research_state` stores the selected conversation, including an explicit
blank selection for new chat. State is written before starting Codex and before
publishing updates. DB write failures stop research and surface a save error.
On restore, formerly active sessions become interrupted. `thread/resume` restores
Codex's persisted dynamic tools. Recovery suppresses old turn notifications and
tool calls, interrupts a still-running old turn, then starts the new message.
## Markdown and source navigation
Assistant messages use react-markdown with remark-gfm. Public tool messages and
user input remain plain text. Markdown links retain their original URLs; a
regular click on a known post navigates within the deck, while modified clicks
and unrelated URLs retain external navigation.
Successful agent post fetches capture the exact column definition and a public
post projection in the saved conversation. The projection excludes raw
provider responses, HTML, media and account credentials. References are retained
across turns, updated per source/account/post identity and retained in chat history.
The agent is instructed to cite exact returned post URLs in Markdown.
Navigation prefers a loaded card, then a workspace column with the same source
and connection. Missing sources open a one-column temporary deck. An unloaded
reference is displayed as a labeled research-time post without altering the
query cache or pagination. Cards receive focus, scroll into view and briefly
highlight; reduced-motion settings are respected and sensitive content stays
collapsed.
## Verification
- Protocol tests for dynamic-tool requests, failures, cancellation and process
exit, with no real model usage in ordinary tests.
- Tool tests for selected connection scope, source validation, pagination limits
and temporary-only deck creation.
- Browser test: start task, show generated temporary deck, retain manual save,
display progress and completion or failure.
- Opt-in real home Codex run: retrieve real posts and verify a readable report
exists at the reported host location with original-source links.
## Deployment boundary
The currently used UM790-Pro runs the development server. The existing dotnix
Twitter Lite service is configured on B450M-Pro4 with obsolete options and a
different public route. Do not apply that configuration to this task implicitly.
Service migration and backup scheduling need a concrete host configuration;
they are separate from validating this first agent workflow.
## References
- [Codex App Server](https://learn.chatgpt.com/docs/app-server)
- [Codex authentication](https://learn.chatgpt.com/docs/auth)
- [Existing WebMCP contract](../webmcp-prototype.md)
## Web-to-resident-Codex examples inspected
Research on 2026-09-24; README and implementation inspection only, not runtime
validation or adoption of these projects.
- [Redex](https://github.com/ladnir/redex/tree/a8032c49d1c9cc79db9679ad06b94a72defe3ec7):
its Python HTTP/SSE bridge connects to a standalone Codex app-server through
WebSocket. `src/redex/app_server.py` initializes the connection and sends
`thread/resume` then `turn/start`; `src/redex/bridge.py` has a long-lived
`LiveEventHub` separate from browser SSE connections. Desktop shared-runtime
integration in this project's README requires its Codex fork; standalone
WebSocket usage does not. The bridge suppresses the Origin header on its
server-side connection because Codex rejects browser-style Origin headers.
- [Pedregoneric/codex-webui](https://github.com/Pedregoneric/codex-webui/blob/5cbc718004c77657812f7151ae708e68cc6b398c/server.js):
a small personal/Tailscale-oriented example. One `CodexBridge` owns a long-lived
stdio child. Browser SSE close removes that subscriber, without killing Codex;
gateway shutdown does kill the child. This demonstrates that browser lifetime
independence is not itself a WebSocket feature. Its authentication is separate
from the existing Tailscale identity boundary used by Twitter Lite.
- [OpenAI's Codex Web architecture](https://openai.com/index/unlocking-the-codex-harness/):
the February 2026 engineering article describes HTTP/SSE from browser to
backend, and a worker maintaining a long-lived app-server connection. The
backend owns task state so browser disconnection does not stop work. This is an
architectural reference, not evidence of today's exact hosted implementation.
- [nathan-chappell/codex_web_ui](https://github.com/nathan-chappell/codex_web_ui/tree/79be149c427040d7fbb94246d841f705ab53ce9e):
the launcher starts a detached app-server on a private Unix socket. Its
TypeScript bridge performs a WebSocket upgrade over that socket. Browser
commands use HTTP RPC and events use SSE. Disconnecting an SSE subscriber does
not stop the bridge or Codex. Relevant files: `bin/codex-web-ui.js`,
`server/codexBridge.ts`, `server/appApi.ts`, `server/eventHub.ts`.
- [seo-rii/codex-webui](https://github.com/seo-rii/codex-webui/tree/2619d78c55c6095aa8b9a7a1412fd0c7abf5e564):
the Rust gateway connects directly over Unix-socket WebSocket in current code,
despite older docs mentioning a proxy. It separates browser disconnect from
gateway-restart handoff and normal shutdown. Request-ID deduplication avoids
executing replayed mutations twice. Its dynamic-tool response handling is a
useful reference, but we did not find custom SNS-style tool registration to
reuse. Relevant files: `backend/src/codex_app_server.rs`,
`backend/src/ws_transport_support.rs`, `backend/src/runtime_request_support.rs`.
The implementation should distinguish three events: browser disconnection,
Twitter Lite backend restart, and Codex process restart. A WebSocket connection
alone does not guarantee recovery across any of them. Agent tool requests should
be handled by the backend so research can continue without the initiating tab.
## Prototype verification
- Implemented a loopback WebSocket client, background run controller, bounded
account/deck/post tools, and a sidebar research dialog. No schema migrations.
- 261 unit/component tests passed (one existing opt-in live test skipped).
- Relevant Playwright coverage: 17 desktop cases, 6 mobile WebMCP cases, and
11 mobile deck/research cases passed. The new sidebar action initially
overlapped another mobile control; it now uses the existing compact pattern.
- Typecheck, lint, knip, and production build passed.
- A real Codex 0.156.1 / gpt-6-astra run started from the browser. The browser
was closed, reopened, and observed the task continuing to completion.
Local verification supplied the trusted Serve identity and origin headers;
the tagged host cannot authenticate as the owner through Serve itself.
- Codex generated two temporary columns, retrieved 20 Twitter posts and zero
Mastodon results, and wrote a Japanese report with original post links and
explicit limits on interpreting the empty Mastodon search. The report is
under the ignored `.data/research/<run ID>/report.md` directory.
- Reopening the run's generated deck produced two temporary columns. Saved
user decks were not replaced, and the report was not inserted into SQLite.
- After replacing polling with SSE, six desktop/mobile E2E cases passed for
initial snapshots, reconnect snapshots, owner checks and the unconfigured UI.
- A second real run pushed a one-column deck to the page. After closing and
reopening the browser, the latest deck restored automatically and grew to two
columns through SSE. Codex then completed `report.md`. Same-view updates,
preserving saved decks, ordered asynchronous updates and late start responses
are also covered by component/controller tests.
- The actual Codex launcher was restarted successfully with configured MCP
servers disabled; WebSocket `config/read` confirmed all seven disabled. Nix
wrapper config overrides must precede the `app-server` subcommand.
## Conversation workspace verification
- 270 unit/component tests and 40 desktop/mobile Playwright cases passed.
Typecheck, lint, knip and production build passed.
- Browser coverage includes conversation/deck restoration from SSE snapshots,
manual deck editing and saving, narrow viewports and existing WebMCP tools.
Readiness checks wait for usable controls rather than network idleness because
the event stream intentionally stays connected.
- A real three-message Codex conversation called `list_decks`, reused a manually
created column from the current-deck context, and edited that column after the
browser was closed and reopened. The app-server thread ID and generated deck
ID stayed unchanged; the deck advanced from version 1 to 2.
- The live database had no saved decks, which Codex reported correctly. Saved
deck discovery/reuse with populated records is covered by isolated tool tests.
The live check did not add or change saved deck records or create a report.
## List discovery verification
- 279 unit/component tests passed (one opt-in live test skipped), with typecheck,
lint and production build passing. Tool tests cover both platforms, selected
account scope, empty lists, upstream failures, shared request limits and using
discovered IDs to create and fetch list columns.
- A real browser-initiated Codex run discovered 54 lists for Twitter account1
and zero for Mastodon, then opened a temporary list column. Reopening the page
restored that deck and the conversation. Twitter account2 initially returned
an upstream `Dependency: Unspecified` error; a direct retry through the same
Bird catalog API returned three lists. A follow-up message in the same Codex
conversation then retried `list_lists` successfully and also reported three.
## New-chat verification
- 284 unit/component tests and eight desktop/mobile research E2E cases passed,
along with typecheck, lint and production build.
- A real Codex check on a separate development server reset a completed chat
across two browser pages, cleared the draft and retained the current deck.
The next message used a different thread ID, contained only the new user
message and received the current deck context. The separate server avoided
interrupting research already running in the public workspace.
## Markdown citation verification
- The full suite passed 306 unit/component tests, with one opt-in test skipped.
Typecheck, lint, knip and production build passed. Follow-up card tests verify
re-scrolling when a captured post is replaced by its loaded feed result.
- 42 desktop/mobile E2E cases passed, including Markdown tables and emphasis,
navigating to an offscreen column, captured-post display, focus/highlight,
accessibility and existing manual deck/WebMCP operations.
- A real Codex run on a separate development server fetched 20 Twitter posts,
cited two in a Markdown response, and navigated from a citation to its card.
Closing and reopening the page preserved working citation navigation.
- react-markdown 10.1.0 and remark-gfm 4.0.1 were added with the user's approval.
The pnpm dependency hash was updated and the x86_64-linux Nix package built.
## Conversation persistence verification
- SQLite repository tests reopen file-backed databases and verify conversation,
deck, account, citation and active-selection restoration, plus transaction rollback.
- Runner tests recreate the service against the same database, resume its thread,
reject stale history switches, interrupt orphan turns and reject late orphan
events. Failed writes prevent new execution or stop further tool calls.
- 52 desktop/mobile E2E cases passed. The final focused research suite passed
120 tests; typecheck, lint, knip and Nix packaging passed.
- An actual Codex run captured 20 Twitter posts. A fresh Node process restored
that conversation and used the same Codex thread and dynamic tools to add a
second column, retaining all citations. Separate desktop/mobile browser
contexts restored the saved chat, navigated citations, shared history selection
through SSE and retained blank-new-chat selection across reload.
@@ -1,141 +0,0 @@
# Mastodon複数アカウント・デッキ端末間共有の実装計画
状態: 機能実装・検証済み。2026-09-24に現行コード・公式仕様・Elkを調査。Drizzle ORM 0.45.3+Drizzle Kit 0.31.11+better-sqlite3 13.0.3と、Tailscale Serveの本人情報による利用制限を採用。実機確認の範囲と運用上の引き継ぎは以下に記録する。
## 実装・検証記録(2026-09-24)
- T0〜T6の機能を実装。ユーザーのブラウザでTailscale経由のMastodon認可・callback復帰を確認。
- サーバーはタグ付き端末なので、本人loginはSelf.UserIDから推測せず実際のユーザー情報で設定。タグ付き端末からの本人情報なしアクセスは引き続き拒否。
- 単体・統合202件、Playwrightの共有デッキ/WebMCP/アクセス制限38件と接続管理/callback4件を検証。
- 別ブラウザから保存済み認可を再利用し、実Mastodonを20件→40件へページ送り。同じ一時ビューでTwitter20件を取得。ブラウザエラーなし。確認用ビューは永続保存していない。
- Nixパッケージ単体で起動・3マイグレーション・DB書込・バックアップを確認。依存hash更新済み。
- 実アカウントでの接続はMastodon1件。同一インスタンスの複数アカウント・別インスタンス・再接続・解除・401競合・429はテストで検証し、実アカウントを増減させる破壊的試験は行っていない。
- NixOSサービスの実機デプロイは行わず、モジュール・永続ディレクトリ・credential設定と運用手順を用意。試用用devサーバーを稼働。
## 前提と到達点
- 自分専用。自宅の1台でアプリを稼働し、複数の自分の端末からTailscale ServeのHTTPSで利用する。
- 同じデッキにTwitterとMastodonを並べ、各カラムに接続アカウントを固定できる。
- 端末AでOAuth接続とデッキ作成を済ませれば、端末Bでも同じ接続・デッキを使える。
- デッキ定義・カラム順序・接続一覧を共有する。開いているデッキ、スクロール位置、編集中のフォームは端末ローカルとする案。
- AIが調査ごとに作るデッキは一時ビューとして開き、必要なものだけ保存済みデッキにする。一時ビューは端末間共有の対象外。
- 投稿の収集アーカイブ、AIサマリー、オフライン編集、他人との共同編集はこの増分に含めない。
## 推奨構成
```mermaid
flowchart LR
A[PC / スマホ] --> B[Tailscale Serve HTTPS]
B --> C[Twitter Lite / localhost]
C --> D[(SQLite / 自宅のローカルディスク)]
C --> E[Twitter Safe Relay]
C --> F[Mastodon各インスタンス]
```
SQLiteを採用する案。端末が増えても、各端末がSQLを実行するのではなく、同じアプリサーバーへアクセスする。デッキ設定とOAuth情報の小さな更新には単一サーバーのSQLiteで始められると判断する。DBファイルを端末間コピーしたり、NAS上のファイルを複数サーバーから直接開いたりしない。
| 候補 | 今回の評価 |
| --- | --- |
| SQLite | 推奨。別DBサービス不要。ローカルディスク、短いトランザクション、マイグレーション、復元試験を用意する |
| PostgreSQL | 複数アプリサーバーや大量の並列収集へ進む時に再評価。現時点では運用対象が増える |
| ブラウザDB+同期基盤 | 採用しない。今回必要なのはオンラインで同じサーバー状態を読むこと。オフライン同期エンジンを導入する必要はない |
SQLiteは同時書き込みが1つという制約がある。複数サーバー・高い書き込み並列度ではclient/server DBを検討する。[SQLiteの用途](https://www.sqlite.org/whentouse.html)
## 認証と保存の境界
アプリの利用許可とSNSアカウントのOAuthを分ける。最初は単一の私用ワークスペースとし、アプリ内のユーザー登録・パスワード管理は作らない。
Tailscale Serveの本人情報を利用する場合は、自分のloginを許可し、バックエンドをlocalhostに束縛する。現在の試用用 `0.0.0.0` 起動を、そのまま本人情報ヘッダーを信頼する本番構成に持ち込まない。タグ付き端末のアクセスには通常のユーザー情報ヘッダーが付かないため、最初の実機確認に含める。Serveからの経路、利用許可、変更リクエストのOrigin検証を共通化する。[Tailscale Serve identity headers](https://tailscale.com/docs/features/tailscale-serve#identity-headers)
OAuth callbackは固定のHTTPS URLにする。認可後に戻る主体はブラウザなので、その端末がtailnetへ到達できる構成を実機検証する。callbackのためにFunnelでアプリ全体を公開する方針は取らない。
## 保存モデル案
| 保存対象 | 主な内容 |
| --- | --- |
| `decks` | ID、名前、revision、更新日時 |
| `deck_columns` | ID、deck ID、並び順、connection ID、名前、platform固有のsource JSON |
| `connections` | ID、platform、接続先origin、外部アカウントIDまたはrelay profile参照、表示名、接続状態 |
| `connection_credentials` | connection ID、暗号化したアクセストークン、鍵の識別子。通常の接続一覧とは分離 |
| `oauth_apps` | インスタンスorigin、callback・scope構成、client ID、暗号化したclient secret |
| `oauth_attempts` | 短時間有効なstate、開始ブラウザとの束縛、接続先、PKCE verifier、期限、一度限りの消費状態 |
単一利用者なので、この段階ではusers/organizations/roles等のテーブルを作らない。
カラムの現行 `profileName` を `connectionId` へ変更する。Twitterはconnectionにrelay profile名を保持し、トークンは引き続きrelay側で管理する。Mastodonは接続インスタンスoriginと `verify_credentials` のaccount IDで重複を判定する。表示handleを主キーにしない。同じインスタンスに別アカウントを追加でき、再接続時には既存のconnection IDを維持する。
DBには秘密を暗号化して保存し、暗号鍵はDB外の実行時credentialファイルから読む。鍵やtokenをNix store・Git・ブラウザ・SSR payload・WebMCPに含めない。DBと鍵の両方が揃って復元できる手順を用意する。
## PR単位のタスク
| ID | タスク | 依存 | 完了条件 |
| --- | --- | --- | --- |
| T0 | 実行環境とアクセス経路を固定 | なし | 本番HTTPS origin/callback候補を決め、PC・スマホから同じ利用者として接続。Serve以外からのヘッダー偽装を許さない構成を確認。対象Mastodonのバージョン・OAuthメタデータも調べる |
| T1 | SQLiteと永続ディレクトリを導入 | T0 | 現行Node/Nixで動くdriver・migration方式を実証。`StateDirectory`等でDBを永続化。再起動・アプリ更新後も残り、バックアップから復元できる。依存追加時はflakeのpnpm hash更新まで行う |
| T2 | connectionモデルへTwitterを移行 | T1 | relay profile一覧から接続を作り、カラム・取得・キャッシュ・ページ送りがconnection IDを使う。2つのTwitter接続を混ぜずに並列表示。不明・削除済みの接続はエラーとして残す |
| T3 | デッキをサーバー保存し端末間共有 | T1,T2 | PCで作ったデッキが別ブラウザコンテキストに表示。revision競合で上書きを拒否。WebMCPも同じ保存処理を使う。既存localStorageからの明示インポートを提供し、重複取り込みと既存DBの破壊を防ぐ |
| T4 | Mastodon OAuthとトークン保管 | T0,T1,T2 | 接続先登録→認可→callback→本人確認→暗号化保存。同一インスタンス2アカウント・別インスタンス・再接続・解除が動く。拒否/state不一致・期限切れ・再利用を検証 |
| T5 | Mastodon取得と投稿正規化 | T4 | ユーザー投稿・リスト・ハッシュタグのカラムを実装し、全文検索を別の能力として扱う。CW・sensitiveメディア・boost・HTML本文を安全に表示。元投稿URLと取得インスタンスのローカルIDを区別 |
| T6 | 複数接続UIとWebMCPを統合 | T3,T5 | 接続管理から追加・再接続・解除。カラムは接続に応じたsourceを選べる。Twitter/Mastodonを同じデッキに並べ、AIも接続一覧を発見して作成・取得できる |
| T7 | 2端末・障害・運用の通し検証 | T3,T4,T6 | 端末AのOAuth接続を端末Bで再認可せず利用。編集競合・接続失効・429・再起動・DB復元の検証。UI/API/WebMCP/ログにトークンが出ないことを確認 |
各PRに必要な単体・統合テストを含める。T7までテストを先送りしない。
推奨順序: `T0 → T1 → T2 → T3` で既存Twitterの端末間共有を先に完成。T3とT4は保存モデル確定後に並列作業可能。その後 `T4 → T5 → T6 → T7`。
### T3: 同期の具体的な範囲
- 保存済みデッキはDBを正本にし、クライアントキャッシュへ読み込む。保存中・保存失敗を区別し、サーバーが受理していない編集を「保存済み」と表示しない。
- デッキ単位のrevisionを比較して、名前・カラム・順序を1トランザクションで更新。同じrevisionへの2回目の更新は競合として返す。
- 初期案はフォーカス復帰時と表示中の軽い定期再取得。SSE/WebSocket/CRDTは導入しない。編集中の内容は自動更新で消さず、競合時に再読み込みを案内する。
- 選択中デッキIDは端末ローカル。PCで別デッキを開いたためにスマホの画面まで勝手に切り替わる挙動を避ける。別端末から削除されたデッキを表示中なら、残りのデッキへ移動し通知する。
- `get_deck`等はrevisionを返し、既存デッキを置換する `set_deck` は期待revisionを指定する。投稿読取ツールにはDB内の秘密を含めない。
- localStorageインポートは一度限りの移行機能。DB完成後にlocalStorageへ書き戻す二重運用はしない。
### T3/T6: AIが作る一時ビューと保存
- 一時ビューと保存済みデッキは同じカラム定義・描画・接続参照を使う。別のプラットフォーム抽象や投稿取得経路は作らない。
- AIによる新規作成は既定で一時ビュー。タブ内のメモリで保持し、DBへは書かない。複数の一時ビューを切り替えられ、UIに「一時」と「デッキとして保存」を表示する。再読み込みやタブを閉じると失われることを画面で明示する。
- 保存操作は新規デッキをサーバーに作成する。成功後にその一時ビューを保存済みデッキへ置き換え、他の端末にも見えるようにする。失敗時は一時ビューを残し、再試行できる。二重クリック・同じ保存要求の再送で重複デッキを作らない。
- 保存済みデッキから一時コピーを作って、AIが検索語・カラムを組み替えて試せる。元デッキは変更しない。初期実装では保存先は新規デッキとし、元デッキへのマージ機能は作らない。
- WebMCPは一時ビューの作成・読取・編集・破棄と、明示的な保存を扱う。既存の保存済みデッキ更新には引き続き期待revisionを要求する。ツールの結果は一時か保存済みかを明示し、保存先を推測させない。
- 一時ビューにも通常のカラム数制限と接続の検証を適用する。投稿内容や認証情報の保存は行わず、保存するのは名前・カラム・検索条件等の定義のみ。
- 検証: AIの一時作成でDB件数が増えない、ページ送りできる、複数ビューを切り替えられる、保存後は別ブラウザから取得できる、保存失敗で内容を失わない、保存再送が重複しない、一時コピーの編集で元デッキを変更しない。
### T4: OAuthの具体的な範囲
- 初期対象はMastodon 4.3以降を提案。Authorization Code+PKCE S256とstateを使い、旧版互換の分岐は作らない。Mastodonはconfidential clientを前提にしているためclient secretも必要。[OAuth仕様](https://docs.joinmastodon.org/spec/oauth/)
- `POST /api/v1/apps`でインスタンスごとに登録。callback・scope構成と合わせて再利用する。[アプリ登録API](https://docs.joinmastodon.org/methods/apps/)
- 閲覧用scopeに絞る。候補は `read:accounts read:statuses read:lists read:search`。実装したカラムのAPI要件と照合する。同一インスタンスへの別アカウント追加は `force_login=true` を使う。[OAuth API](https://docs.joinmastodon.org/methods/oauth/)
- state/verifierは開始ブラウザに束縛し、期限付き・一度限りで検証。token交換と `verify_credentials` はサーバーで実施し、戻り先URLにtokenを含めない。[本人確認API](https://docs.joinmastodon.org/methods/accounts/#verify-account-credentials)
- 接続解除・401からの再接続を実装。通常のMastodon tokenは自動失効しないため、汎用refresh token基盤を先に作らない。[OAuth tokens](https://docs.joinmastodon.org/api/oauth-tokens/)
- 接続先入力をサーバーがfetchするため、origin・DNS解決先・redirectを検証する。初期は設定したインスタンスの許可リストに限定する案。OAuthやページ送りを家のLANへの汎用HTTP転送にしない。
### T5/T6: プラットフォーム抽象化
- 共通化するのはconnection参照、取得結果、エラー、継続ページの境界。全SNSへTwitterのTop/Latestや検索構文を要求しない。
- source schemaはplatformとkindで分岐。MastodonのリストID・アカウントIDは接続インスタンスの文脈を持つ。接続変更時はリストを選び直す。
- 投稿の同一性にはcanonical URIを使い、取得先のローカルstatus IDはAPI操作用に保持する。boostのwrapperと元投稿を区別する。
- HTML本文は許可する要素・URLを制限して処理。CW・sensitiveを初期表示で尊重し、全文を単に既存のtextへ詰めない。
- Mastodonの全文検索はインスタンスの検索環境次第。明示的な検索エラーと正常な空レスポンスを区別する。ただし4.5.3では検索バックエンド無効時も空配列を返すため、0件だけで全文検索への対応可否は判定できない。画面にも検索範囲が接続先の設定に依存することを表示する。[検索API](https://docs.joinmastodon.org/methods/search/)、[4.5.3の検索処理](https://github.com/mastodon/mastodon/blob/v4.5.3/app/services/search_service.rb)
- ホームタイムライン、通知、Streamingは最初のカラムが動いてから別タスク。既存の調査カラムと手動ページ送りを先に完成する。
## Elkから参考にする範囲
調査対象commit: `8a90074fca9f316a0c71f7249b1a31f21829a987`。
- インスタンス別のOAuth app登録・再利用: [server/utils/shared.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/utils/shared.ts)
- 認可URLと複数アカウント追加: [server/api/[server]/login.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/api/%5Bserver%5D/login.ts)
- アカウント本人確認・インスタンスと表示ドメインの区別: [app/composables/users.ts](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/composables/users.ts)
ElkはOAuth app情報をサーバーに持つが、ユーザーtokenはcallback URLを経由してブラウザへ渡し、IndexedDBの `elk-users` に保存する。今回のサーバーtoken保管・端末間同期は別実装にする。[callback](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/server/api/%5Bserver%5D/oauth/%5Borigin%5D.ts)、[ユーザー保存](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/plugins/0.setup-users.ts)
Elkのグローバルなcurrent accountの切り替えも、そのままカラムごとの並列取得には使わない。各カラムはconnection IDから独立したリクエスト文脈を得る。[Mastodonクライアント](https://github.com/elk-zone/elk/blob/8a90074fca9f316a0c71f7249b1a31f21829a987/app/composables/masto/masto.ts)
## 後続タスク
- Bluesky: 接続・認可方式を別途調査し、MastodonのOAuth仕様を流用しない。
- Threads: アプリ登録・利用可能な読み取り権限・検索範囲を実機確認してからスコープを決める。
- Nostr: relay集合・署名/鍵の扱いを別に設計し、OAuthに無理に合わせない。
- AIサマリー/並列収集: 投稿snapshotと取得日時・クエリ・接続文脈を保存する別のデータモデルを追加する。今回は設定同期のDBに投稿アーカイブを混ぜない。
-136
View File
@@ -1,136 +0,0 @@
# Research decks
## Workspace
Both `/` and `/deck` render the deck-only application. A dark sidebar switches
decks, adds columns, and jumps to a column. Up to six ordered columns scroll
independently; only the active deck mounts and fetches its columns. On mobile,
the sidebar becomes a compact top bar. Native dialogs handle creation and
editing, with Escape and focus restoration. Column menus offer editing,
ordering, and deletion; refresh and pagination are manual.
Twitter and Mastodon can appear together, with different accounts in each
column. Original-post links open their source site. Separate reader, search,
list, user-profile, and conversation pages are not provided.
## Definitions and connections
`src/features/decks/model.ts` validates deck IDs, titles, ordered columns, and
unique column IDs within each deck. Each column contains `id`, `title`,
`connectionId`, and a platform-specific `source`.
| Platform | Source kind | Conditions |
| --- | --- | --- |
| Twitter | `search` | Native `query`, `product` (`Top`/`Latest`), `following` |
| Twitter | `user` | `target`: handle or X/Twitter profile URL |
| Twitter | `list` | `target`: numeric list ID or X/Twitter list URL |
| Mastodon | `search` | `query`; results depend on the instance's search configuration |
| Mastodon | `user` | `target`: account handle or the connected instance's numeric account ID |
| Mastodon | `list` | `target`: numeric list ID for the connected account |
| Mastodon | `hashtag` | `target`: tag without `#`, containing letters, numbers, or underscores |
`connectionId` is the account binding, distinct from a deck's name. Twitter
connections resolve to profiles discovered from the configured relay.
Mastodon connections identify accounts by instance origin and remote account
ID; OAuth tokens stay encrypted on the server. The connection manager supports
adding, reconnecting, and disconnecting Mastodon accounts. See
[storage and OAuth](storage-and-oauth.md) for configuration and recovery.
The editor discovers lists for the selected account. Changing accounts clears
target-based conditions so an instance-local ID is not reused accidentally.
Searches can retain conditions between accounts on the same platform. There is
no global account selector or fallback to another account. Missing or
disconnected bindings produce errors.
The complete source and connection ID form query-cache identity. Equal
conditions on the same connection share pages; different connections retain
separate results and cursors. Deck synchronization does not poll post feeds.
## Implementation boundaries
- `column-editor.tsx` owns the title, connection binding, and final submission.
- `column-source-editor.tsx` dispatches by platform and defines rebinding rules.
- `twitter-source-editor.tsx` and `mastodon-source-editor.tsx` own each
platform's source selection, fields, and account-specific list discovery.
- `use-research-feed.ts` dispatches requests and keys the cache by connection
and source. Server functions resolve credentials and fetch upstream pages.
- `platforms/types.ts` defines normalized `ResearchPost` and `ResearchPage`
records consumed by cards and column tools, without raw provider responses
or credentials.
Twitter posts use `twitter:<id>` keys. Mastodon posts use canonical status URIs
for keys and retain the fetched instance's native status ID separately. Boosts
keep the wrapper identity and identify the boosting account. Mastodon HTML is
sanitized on the server; cards honor content warnings and sensitive media.
## Saved decks and temporary views
SQLite is authoritative for saved definitions. Creating a named deck through
the UI saves it; subsequent saved-deck edits and deletions use a revision check
in a transaction. A stale revision is rejected. Save failures remain visible
and do not report unsaved edits as persisted.
Saved decks refresh on focus and every five seconds while visible. Refresh is
held while an editor is open. Active selection is a local preference under
`twitter-lite-active-deck`; selecting a deck does not switch another device's
view. Deleting the active deck selects a remaining one. When none remain, the
app opens an empty temporary view.
WebMCP-created views are temporary by default. Temporary copies of saved decks
also remain in this tab's memory. Editing them does not write to SQLite. The
explicit save action replaces a temporary view with a shared saved deck,
preserving its ID for idempotent retries. Failed saves retain temporary
content. Temporary views disappear on reload or navigation, including OAuth
redirects. Only definitions are saved: posts, cursors, and scroll positions
are not archived.
Research chats separately retain the agent-generated deck and last supplied
deck context. Reopening a saved chat reconstructs a temporary view, including
on another device or after a server restart. Chat citations retain a limited
post projection for source navigation; this does not persist the deck's feed
cache, pagination cursors, scroll positions or unsent manual edits.
The UI offers an explicit, one-time import of valid version-2 data from
`twitter-lite-research-deck` in localStorage. It resolves old relay profile
names to connection IDs and creates new saved deck IDs transactionally. A
server marker prevents repeated imports; different content after the first
import is rejected. The browser copy is removed only after success. Invalid
or unsupported data is left untouched. HTTP and HTTPS have separate browser
storage, but authorized devices read the same server-backed decks.
## WebMCP
Use `list_connections` to discover bindings, then `set_deck` to create a
temporary view. This example uses IDs returned by discovery:
```json
{
"title": "WebMCPの反応",
"columns": [
{
"title": "Twitter",
"connectionId": "twitter-connection-id",
"source": { "platform": "twitter", "kind": "search", "query": "WebMCP lang:ja" }
},
{
"title": "Mastodon",
"connectionId": "mastodon-connection-id",
"source": { "platform": "mastodon", "kind": "hashtag", "target": "WebMCP" }
}
]
}
```
Read the returned deck ID and call `save_deck` only when the view should be
shared. Replacing a saved deck requires its `deckId` and `expectedRevision`;
include every column to retain. Post loading is asynchronous and can fail
independently of saving. See [all tool contracts](webmcp-prototype.md).
## Verification
Unit tests exercise source normalization, account-specific caches, OAuth,
credential storage, revisions, import, and temporary-view behavior. Playwright
uses an isolated SQLite database and mock relay through real server functions
to exercise shared decks across browser contexts, editing, persistence,
pagination, and native WebMCP. Deterministic automated tests do not require
live SNS credentials.
-102
View File
@@ -1,102 +0,0 @@
# Shared storage and Mastodon OAuth
Twitter Lite runs as a single personal server behind Tailscale Serve. The
backend binds to loopback and accepts only the configured Tailscale login.
Browser requests that change state must have the configured Origin. OAuth
callbacks also pass the owner check; the browser must be able to reach the
tailnet HTTPS address after Mastodon authorization.
## Runtime configuration
| Variable | Value |
| --- | --- |
| `TWITTER_LITE_ORIGIN` | Exact Serve HTTPS origin, without a trailing slash |
| `TWITTER_LITE_ALLOWED_LOGIN` | Owner's Tailscale login |
| `TWITTER_LITE_DB_PATH` | Absolute path to the SQLite database on local disk |
| `TWITTER_LITE_MASTODON_ORIGINS` | Comma-separated approved HTTPS instance origins |
| `TWITTER_LITE_CREDENTIAL_KEY_FILE` | Runtime file containing 32 random bytes encoded as base64 |
The credential key is required for Mastodon, but not for Twitter-only use.
Generate it once, keep it outside Git and the Nix store, and retain it when
updating the application. For example, with an existing private directory:
```sh
umask 077
nix develop -c node --input-type=module -e 'import {randomBytes} from "node:crypto"; import {writeFileSync} from "node:fs"; writeFileSync("/absolute/private/credential-key", randomBytes(32).toString("base64") + "\n", {flag: "wx", mode: 0o600})'
```
The command refuses to replace an existing file. Losing the key makes saved
SNS credentials unreadable. A separate protected backup of the key is needed
alongside database backups.
## Database and migrations
Drizzle ORM 0.45.3, Drizzle Kit 0.31.11 and better-sqlite3 13.0.3 are pinned.
The driver ships native prebuilds; dependency install scripts remain disabled.
Both the actual Nix Node runtime and the built Nix package have been exercised
with SQLite operations and the packaged backup command.
`drizzle/` contains generated SQL and metadata. `pnpm db:generate` generates
SQL and bundles it into TypeScript for the server. Commit both artifacts with
schema changes. The server applies pending migrations under an immediate
transaction before serving an authorized application request. It enables WAL,
foreign keys and a five-second busy timeout. Deployment does not depend on a
particular working directory or a separate migration command.
Deck definitions, ordered columns, connection metadata, OAuth applications
and short-lived OAuth attempts live in SQLite. Tokens, client secrets and
PKCE verifiers are authenticated encrypted envelopes in separate fields.
Their associated data binds each secret to its record and purpose. Public
connection responses never select those fields.
## Backups and restore
Use the live SQLite backup API instead of copying only the main file while WAL
is active. The destination must be an absolute path that does not already
exist; backups are mode 0600 and pass a SQLite integrity check.
```sh
TWITTER_LITE_DB_PATH=/absolute/workspace.sqlite \
nix develop -c pnpm db:backup /absolute/backups/workspace-2026-09-24.sqlite
```
The Nix package exposes the same operation as `twitter-lite-backup`:
```sh
TWITTER_LITE_DB_PATH=/var/lib/twitter-lite/workspace.sqlite \
twitter-lite-backup /absolute/backups/workspace-2026-09-24.sqlite
```
Run it as an identity that can read the database and write the backup directory.
On NixOS the application uses `DynamicUser`, `StateDirectory=twitter-lite` and
mode 0700. The runtime key is passed with systemd `LoadCredential`.
For restore, stop the service first. Preserve the current state directory as
a separate recovery copy, then restore the verified backup as
`workspace.sqlite` in a clean state directory with the service's ownership and
permissions. Do not leave old `-wal` or `-shm` sidecars beside a restored main
database. Restore the matching credential key separately, then start the
service and verify decks and account access. Do not attempt to restore a newer
schema into an older application version.
## OAuth and instance support
Initial support targets Mastodon 4.3+ with PKCE S256. The first configured
instance, `https://fedi.yutakobayashi.com`, reported 4.5.3 and S256 on 2026-09-24.
Only configured HTTPS origins with public DNS addresses are accepted; server
requests pin the resolved address and refuse redirects.
Each instance/callback/scope combination has an OAuth application. Authorization
uses `read:accounts read:statuses read:lists read:search`, PKCE, a ten-minute
one-use state and an HttpOnly browser-binding cookie. Tokens are exchanged and
account identity is verified on the server. The callback URL never contains an
access token. Same-instance accounts remain separate; reconnecting preserves
the connection ID only for the same account.
An expired token marks its connection unavailable until reconnected. Disconnect
first revokes the token using the original OAuth application, then removes the
local credential while retaining the connection reference used by saved decks.
Search results depend on the instance's backend and indexing. A successful
empty response does not prove full-text search is enabled: Mastodon 4.5.3 also
returns empty status results when its search backend is disabled.
@@ -175,14 +175,10 @@ None of these functions exposes the Bird client, arbitrary method names, arbitra
### Search Flow
1. Keep the raw query and structured author, period, language, content, and
exclusion controls in typed URL search parameters.
2. Compile selected controls into a deterministic advanced search expression
using `from:`, `since:`, `until:`, `lang:`, and `filter:` operators.
3. Trim and validate the compiled query, including its 512-character limit.
4. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
5. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
6. Return normalized tweets and the next cursor.
1. Trim and validate the raw query.
2. Append `filter:follows` when the following toggle is enabled and the operator is not already present.
3. Fetch one page with `getAllSearchResults(query, { product, cursor, maxPages: 1 })`.
4. Return normalized tweets and the next cursor.
Advanced X search grammar remains available because the raw query is otherwise passed unchanged.
@@ -211,11 +207,7 @@ The selected layout is **Focused Tabs**:
- no content on first load;
- results directly below the submitted controls.
The User tab contains one target input and a Display button. The Search tab
contains a query input; buttons that open inline author, period, language,
content-type, and exclusion controls; removable active-condition chips; a
collapsible compiled-query preview; a Popular/Latest segmented control; a
Following only checkbox; and a Search button.
The User tab contains one target input and a Display button. The Search tab contains a query input, a Popular/Latest segmented control, a Following only checkbox, and a Search button.
Submitting a user target normalizes it to a handle and navigates to `/:handle`.
Submitting a search updates the route's typed search parameters. Changing tabs
@@ -352,9 +344,7 @@ The work is complete when:
target or query, following a post detail link, or manually opening a valid
status URL.
3. A valid handle or profile URL loads that user's posts.
4. Search supports Top, Latest, the follows filter, and button-driven author,
period, language, content-type, reply, and repost conditions while preserving
raw advanced search syntax.
4. Search supports Top, Latest, and the follows filter.
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
6. Relay credentials and Bird remain server-only.
7. No excluded discovery or mutation feature is reachable.
-126
View File
@@ -1,126 +0,0 @@
# WebMCP prototype
## Registration
The deck workspace exposes nine React-owned tools on `/` and `/deck`.
`usewebmcp` handles native browser registration and cleanup. Tools become
available after saved decks load. Connection discovery may still be pending;
`list_connections` then returns `connections: null`.
Registration uses `document.modelContext`; there is no polyfill or external
MCP transport. Unsupported browsers retain the manual interface. All server
operations use the same access controls and persistence rules as the UI.
## Workspace tools
| Tool | Input | Behavior |
| --- | --- | --- |
| `list_connections` | `{}` | Return connection IDs, platforms, origins, account IDs, display names, and states; never credentials |
| `list_decks` | `{}` | Return saved decks and this tab's temporary views, `activeDeckId`, and `storageError` |
| `get_deck` | Optional `deckId` | Read the named or active view, including `persisted` and saved `revision` |
| `set_deck` | Optional `deckId` and `expectedRevision`, required `title` and `columns` | Without an ID, create a temporary view; with an ID, replace and activate that existing view |
| `save_deck` | `deckId` | Explicitly persist a temporary view; an already saved deck is unchanged |
| `select_deck` | `deckId` | Activate a view; selection remains device-local |
| `delete_deck` | `deckId`, optional `expectedRevision` | Discard a temporary view or delete a saved deck for all devices |
Read tools return the workspace's current client snapshot, not a fresh server
request. Saved definitions refresh on focus and while visible, except during
editing. Replacing or deleting a saved deck requires `expectedRevision` from
the definition being edited. A stale revision fails without overwriting the
server. Use the UI's reload action after a conflict when a fresh definition
is needed.
`set_deck` accepts at most six columns, each with a title, `connectionId` from
discovery, and a source. Connections must be connected and match the source
platform. Optional column IDs preserve identity; omitted IDs are generated.
Include every retained column: omitted columns are removed, and an empty array
clears the view. A supplied deck ID must already exist.
Twitter sources support `search`, `user`, and `list`. `platform` defaults to
`twitter`; search requires `query` and defaults `product` to `Latest` and
`following` to false. User/list targets accept handles or profile URLs and
numeric list IDs or list URLs, respectively.
Mastodon requires `platform: "mastodon"` and supports `search` (`query`),
`user` (handle or instance-local account ID), `list` (account-local numeric
list ID), and `hashtag` (tag without `#`). It does not accept Twitter ranking
or following controls. Search availability and coverage depend on the
instance; an empty successful result does not prove full-text support.
## Mutation results and persistence
`set_deck` returns `deck`, its actual `persisted` flag, and
`posts: "loading-asynchronously"`. A new view exists only in tab memory until
`save_deck` succeeds. Temporary views disappear on reload or navigation,
including OAuth redirects. Saving keeps the ID, allowing an identical create
request to be retried without duplicate decks. Failed saves keep temporary
content; rejected updates do not replace the accepted saved definition.
Successful mutations close unsaved editor forms. Deleting the active view
selects a remaining one; deleting the last opens an empty temporary view.
There is no tool-level deletion undo. Column requests may fail independently
after a definition is applied or saved. Existing browser decks are imported
only through the explicit UI import action, not through a tool side effect.
## Column tools
`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 view are available;
select the view and allow it to render first.
`load_more_column` accepts `columnId` and fetches or retries one continuation
using the column's connection. Wait for initial loading or refresh to finish.
Concurrent pagination joins the existing request. If the view or column
changes during pagination, execution fails rather than returning results under
the changed identity. At the end, no posts are appended and `hasMore` is false.
Both return:
- `column`: ID, title, connection ID, and source
- `status`: `loading`, `ready`, or `error`, with `loading` and `error` details
- `posts`: normalized records with original URLs, text, author, and available
media, quotes, content warnings, or boost information
- `loadedCount`, `offset`, and `nextOffset` for slices of deduplicated posts
- `hasMore`: whether the feed has an upstream continuation
Continuation returns up to 20 newly appended posts. Use `nextOffset` with
`get_column_posts` for additional already loaded records; use
`load_more_column` for another upstream page. These are cache snapshots, not
archived evidence.
## Errors and annotations
Results are JSON in an MCP text content block. Execution failures set
`isError: true` with `code`, `message`, and `retryable: false`. Schema and
connection-validation failures use `invalid-input`; other execution failures
use `tool-error`. Column snapshots expose upstream errors separately in their
`error` field. Empty successful queries are not errors.
`list_connections`, `list_decks`, `get_deck`, and `get_column_posts` have
`readOnlyHint: true`. `delete_deck` has `destructiveHint: true`; tools returning
external posts mark them untrusted. Annotations describe behavior and do not
grant authorization. No tool posts to or modifies an SNS account.
## Browser setup and verification
```sh
nix develop -c pnpm test
nix develop -c pnpm typecheck
nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts
```
E2E tests enable Chromium WebMCP/testing flags and invoke actual registered
tools through `navigator.modelContextTesting`. Mock relay responses pass
through real server functions and isolated database state.
For interactive testing, enable `chrome://flags/#enable-webmcp-testing`,
restart Chrome, and use Model Context Tool Inspector. Reload after changing
browser support. Native WebMCP requires a secure context; use the configured
Tailscale Serve HTTPS origin for remote access. Development also requires
allowing its exact hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`.
The app's owner check still applies; direct localhost access does not supply
Serve identity. See [runtime access configuration](storage-and-oauth.md).
No production origin-trial token or external MCP-client bridge is configured.
Browser cancellation does not guarantee cancellation of a shared feed request.
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'sqlite',
schema: './src/features/storage/schema.ts',
out: './drizzle',
})
-66
View File
@@ -1,66 +0,0 @@
CREATE TABLE `connection_credentials` (
`connection_id` text PRIMARY KEY NOT NULL,
`encrypted_token` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `connections` (
`id` text PRIMARY KEY NOT NULL,
`platform` text NOT NULL,
`origin` text NOT NULL,
`account_id` text,
`relay_profile` text,
`display_name` text NOT NULL,
`status` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `connections_account` ON `connections` (`platform`,`origin`,`account_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `connections_relay_profile` ON `connections` (`origin`,`relay_profile`);--> statement-breakpoint
CREATE TABLE `deck_columns` (
`id` text NOT NULL,
`deck_id` text NOT NULL,
`position` integer NOT NULL,
`connection_id` text NOT NULL,
`title` text NOT NULL,
`source` text NOT NULL,
PRIMARY KEY(`deck_id`, `id`),
FOREIGN KEY (`deck_id`) REFERENCES `decks`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE restrict,
CONSTRAINT "deck_columns_valid_position" CHECK("deck_columns"."position" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `deck_columns_position` ON `deck_columns` (`deck_id`,`position`);--> statement-breakpoint
CREATE TABLE `decks` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`revision` integer DEFAULT 1 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT "decks_positive_revision" CHECK("decks"."revision" >= 1)
);
--> statement-breakpoint
CREATE TABLE `oauth_apps` (
`id` text PRIMARY KEY NOT NULL,
`origin` text NOT NULL,
`redirect_uri` text NOT NULL,
`scopes` text NOT NULL,
`client_id` text NOT NULL,
`encrypted_client_secret` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_apps_configuration` ON `oauth_apps` (`origin`,`redirect_uri`,`scopes`);--> statement-breakpoint
CREATE TABLE `oauth_attempts` (
`state_hash` text PRIMARY KEY NOT NULL,
`browser_hash` text NOT NULL,
`app_id` text NOT NULL,
`encrypted_verifier` text NOT NULL,
`connection_id` text,
`expires_at` integer NOT NULL,
`consumed_at` integer,
FOREIGN KEY (`app_id`) REFERENCES `oauth_apps`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`connection_id`) REFERENCES `connections`(`id`) ON UPDATE no action ON DELETE cascade
);
-1
View File
@@ -1 +0,0 @@
ALTER TABLE `connection_credentials` ADD `app_id` text REFERENCES oauth_apps(id);
-6
View File
@@ -1,6 +0,0 @@
CREATE TABLE `legacy_imports` (
`id` text PRIMARY KEY NOT NULL,
`payload_hash` text NOT NULL,
`deck_ids` text NOT NULL,
`created_at` integer NOT NULL
);
-14
View File
@@ -1,14 +0,0 @@
CREATE TABLE `research_sessions` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`status` text NOT NULL,
`updated_at` integer NOT NULL,
`snapshot` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `research_state` (
`id` integer PRIMARY KEY NOT NULL,
`active_session_id` text,
FOREIGN KEY (`active_session_id`) REFERENCES `research_sessions`(`id`) ON UPDATE no action ON DELETE set null,
CONSTRAINT "research_state_singleton" CHECK("research_state"."id" = 1)
);
-424
View File
@@ -1,424 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "42951f9a-6025-4e73-9155-764d25514ba8",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"connection_credentials": {
"name": "connection_credentials",
"columns": {
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"encrypted_token": {
"name": "encrypted_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"connection_credentials_connection_id_connections_id_fk": {
"name": "connection_credentials_connection_id_connections_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"connections": {
"name": "connections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"relay_profile": {
"name": "relay_profile",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connections_account": {
"name": "connections_account",
"columns": ["platform", "origin", "account_id"],
"isUnique": true
},
"connections_relay_profile": {
"name": "connections_relay_profile",
"columns": ["origin", "relay_profile"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deck_columns": {
"name": "deck_columns",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_id": {
"name": "deck_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"deck_columns_position": {
"name": "deck_columns_position",
"columns": ["deck_id", "position"],
"isUnique": true
}
},
"foreignKeys": {
"deck_columns_deck_id_decks_id_fk": {
"name": "deck_columns_deck_id_decks_id_fk",
"tableFrom": "deck_columns",
"tableTo": "decks",
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_columns_connection_id_connections_id_fk": {
"name": "deck_columns_connection_id_connections_id_fk",
"tableFrom": "deck_columns",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_columns_deck_id_id_pk": {
"columns": ["deck_id", "id"],
"name": "deck_columns_deck_id_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {
"deck_columns_valid_position": {
"name": "deck_columns_valid_position",
"value": "\"deck_columns\".\"position\" >= 0"
}
}
},
"decks": {
"name": "decks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"decks_positive_revision": {
"name": "decks_positive_revision",
"value": "\"decks\".\"revision\" >= 1"
}
}
},
"oauth_apps": {
"name": "oauth_apps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"redirect_uri": {
"name": "redirect_uri",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scopes": {
"name": "scopes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_client_secret": {
"name": "encrypted_client_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_apps_configuration": {
"name": "oauth_apps_configuration",
"columns": ["origin", "redirect_uri", "scopes"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_attempts": {
"name": "oauth_attempts",
"columns": {
"state_hash": {
"name": "state_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"browser_hash": {
"name": "browser_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_verifier": {
"name": "encrypted_verifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consumed_at": {
"name": "consumed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"oauth_attempts_app_id_oauth_apps_id_fk": {
"name": "oauth_attempts_app_id_oauth_apps_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"oauth_attempts_connection_id_connections_id_fk": {
"name": "oauth_attempts_connection_id_connections_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-440
View File
@@ -1,440 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1455820b-8aec-4fcb-9790-c7d9f1fd908c",
"prevId": "42951f9a-6025-4e73-9155-764d25514ba8",
"tables": {
"connection_credentials": {
"name": "connection_credentials",
"columns": {
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"encrypted_token": {
"name": "encrypted_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"connection_credentials_connection_id_connections_id_fk": {
"name": "connection_credentials_connection_id_connections_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"connection_credentials_app_id_oauth_apps_id_fk": {
"name": "connection_credentials_app_id_oauth_apps_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"connections": {
"name": "connections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"relay_profile": {
"name": "relay_profile",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connections_account": {
"name": "connections_account",
"columns": ["platform", "origin", "account_id"],
"isUnique": true
},
"connections_relay_profile": {
"name": "connections_relay_profile",
"columns": ["origin", "relay_profile"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deck_columns": {
"name": "deck_columns",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_id": {
"name": "deck_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"deck_columns_position": {
"name": "deck_columns_position",
"columns": ["deck_id", "position"],
"isUnique": true
}
},
"foreignKeys": {
"deck_columns_deck_id_decks_id_fk": {
"name": "deck_columns_deck_id_decks_id_fk",
"tableFrom": "deck_columns",
"tableTo": "decks",
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_columns_connection_id_connections_id_fk": {
"name": "deck_columns_connection_id_connections_id_fk",
"tableFrom": "deck_columns",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_columns_deck_id_id_pk": {
"columns": ["deck_id", "id"],
"name": "deck_columns_deck_id_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {
"deck_columns_valid_position": {
"name": "deck_columns_valid_position",
"value": "\"deck_columns\".\"position\" >= 0"
}
}
},
"decks": {
"name": "decks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"decks_positive_revision": {
"name": "decks_positive_revision",
"value": "\"decks\".\"revision\" >= 1"
}
}
},
"oauth_apps": {
"name": "oauth_apps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"redirect_uri": {
"name": "redirect_uri",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scopes": {
"name": "scopes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_client_secret": {
"name": "encrypted_client_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_apps_configuration": {
"name": "oauth_apps_configuration",
"columns": ["origin", "redirect_uri", "scopes"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_attempts": {
"name": "oauth_attempts",
"columns": {
"state_hash": {
"name": "state_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"browser_hash": {
"name": "browser_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_verifier": {
"name": "encrypted_verifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consumed_at": {
"name": "consumed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"oauth_attempts_app_id_oauth_apps_id_fk": {
"name": "oauth_attempts_app_id_oauth_apps_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"oauth_attempts_connection_id_connections_id_fk": {
"name": "oauth_attempts_connection_id_connections_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-478
View File
@@ -1,478 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ebfb67e2-a014-4d40-89e3-30c9b55cf047",
"prevId": "1455820b-8aec-4fcb-9790-c7d9f1fd908c",
"tables": {
"connection_credentials": {
"name": "connection_credentials",
"columns": {
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"encrypted_token": {
"name": "encrypted_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"connection_credentials_connection_id_connections_id_fk": {
"name": "connection_credentials_connection_id_connections_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"connection_credentials_app_id_oauth_apps_id_fk": {
"name": "connection_credentials_app_id_oauth_apps_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"connections": {
"name": "connections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"relay_profile": {
"name": "relay_profile",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connections_account": {
"name": "connections_account",
"columns": ["platform", "origin", "account_id"],
"isUnique": true
},
"connections_relay_profile": {
"name": "connections_relay_profile",
"columns": ["origin", "relay_profile"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deck_columns": {
"name": "deck_columns",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_id": {
"name": "deck_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"deck_columns_position": {
"name": "deck_columns_position",
"columns": ["deck_id", "position"],
"isUnique": true
}
},
"foreignKeys": {
"deck_columns_deck_id_decks_id_fk": {
"name": "deck_columns_deck_id_decks_id_fk",
"tableFrom": "deck_columns",
"tableTo": "decks",
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_columns_connection_id_connections_id_fk": {
"name": "deck_columns_connection_id_connections_id_fk",
"tableFrom": "deck_columns",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_columns_deck_id_id_pk": {
"columns": ["deck_id", "id"],
"name": "deck_columns_deck_id_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {
"deck_columns_valid_position": {
"name": "deck_columns_valid_position",
"value": "\"deck_columns\".\"position\" >= 0"
}
}
},
"decks": {
"name": "decks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"decks_positive_revision": {
"name": "decks_positive_revision",
"value": "\"decks\".\"revision\" >= 1"
}
}
},
"legacy_imports": {
"name": "legacy_imports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload_hash": {
"name": "payload_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_ids": {
"name": "deck_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_apps": {
"name": "oauth_apps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"redirect_uri": {
"name": "redirect_uri",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scopes": {
"name": "scopes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_client_secret": {
"name": "encrypted_client_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_apps_configuration": {
"name": "oauth_apps_configuration",
"columns": ["origin", "redirect_uri", "scopes"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_attempts": {
"name": "oauth_attempts",
"columns": {
"state_hash": {
"name": "state_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"browser_hash": {
"name": "browser_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_verifier": {
"name": "encrypted_verifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consumed_at": {
"name": "consumed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"oauth_attempts_app_id_oauth_apps_id_fk": {
"name": "oauth_attempts_app_id_oauth_apps_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"oauth_attempts_connection_id_connections_id_fk": {
"name": "oauth_attempts_connection_id_connections_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-562
View File
@@ -1,562 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d021bc5b-2422-467a-ab92-2493da6e642a",
"prevId": "ebfb67e2-a014-4d40-89e3-30c9b55cf047",
"tables": {
"connection_credentials": {
"name": "connection_credentials",
"columns": {
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"encrypted_token": {
"name": "encrypted_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"connection_credentials_connection_id_connections_id_fk": {
"name": "connection_credentials_connection_id_connections_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"connection_credentials_app_id_oauth_apps_id_fk": {
"name": "connection_credentials_app_id_oauth_apps_id_fk",
"tableFrom": "connection_credentials",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"connections": {
"name": "connections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"relay_profile": {
"name": "relay_profile",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connections_account": {
"name": "connections_account",
"columns": ["platform", "origin", "account_id"],
"isUnique": true
},
"connections_relay_profile": {
"name": "connections_relay_profile",
"columns": ["origin", "relay_profile"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"deck_columns": {
"name": "deck_columns",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_id": {
"name": "deck_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"deck_columns_position": {
"name": "deck_columns_position",
"columns": ["deck_id", "position"],
"isUnique": true
}
},
"foreignKeys": {
"deck_columns_deck_id_decks_id_fk": {
"name": "deck_columns_deck_id_decks_id_fk",
"tableFrom": "deck_columns",
"tableTo": "decks",
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_columns_connection_id_connections_id_fk": {
"name": "deck_columns_connection_id_connections_id_fk",
"tableFrom": "deck_columns",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_columns_deck_id_id_pk": {
"columns": ["deck_id", "id"],
"name": "deck_columns_deck_id_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {
"deck_columns_valid_position": {
"name": "deck_columns_valid_position",
"value": "\"deck_columns\".\"position\" >= 0"
}
}
},
"decks": {
"name": "decks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"decks_positive_revision": {
"name": "decks_positive_revision",
"value": "\"decks\".\"revision\" >= 1"
}
}
},
"legacy_imports": {
"name": "legacy_imports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload_hash": {
"name": "payload_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deck_ids": {
"name": "deck_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_apps": {
"name": "oauth_apps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"redirect_uri": {
"name": "redirect_uri",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scopes": {
"name": "scopes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_client_secret": {
"name": "encrypted_client_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_apps_configuration": {
"name": "oauth_apps_configuration",
"columns": ["origin", "redirect_uri", "scopes"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_attempts": {
"name": "oauth_attempts",
"columns": {
"state_hash": {
"name": "state_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"browser_hash": {
"name": "browser_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"app_id": {
"name": "app_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_verifier": {
"name": "encrypted_verifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connection_id": {
"name": "connection_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consumed_at": {
"name": "consumed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"oauth_attempts_app_id_oauth_apps_id_fk": {
"name": "oauth_attempts_app_id_oauth_apps_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "oauth_apps",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"oauth_attempts_connection_id_connections_id_fk": {
"name": "oauth_attempts_connection_id_connections_id_fk",
"tableFrom": "oauth_attempts",
"tableTo": "connections",
"columnsFrom": ["connection_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"research_sessions": {
"name": "research_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"snapshot": {
"name": "snapshot",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"research_state": {
"name": "research_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"research_state_active_session_id_research_sessions_id_fk": {
"name": "research_state_active_session_id_research_sessions_id_fk",
"tableFrom": "research_state",
"tableTo": "research_sessions",
"columnsFrom": ["active_session_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"research_state_singleton": {
"name": "research_state_singleton",
"value": "\"research_state\".\"id\" = 1"
}
}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-34
View File
@@ -1,34 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1790234301369,
"tag": "0000_aspiring_bloodstorm",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1790234518415,
"tag": "0001_many_fat_cobra",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1790234736939,
"tag": "0002_fine_master_chief",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1790242505709,
"tag": "0003_romantic_scrambler",
"breakpoints": true
}
]
}
+12
View File
@@ -0,0 +1,12 @@
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([])
})
+24
View File
@@ -0,0 +1,24 @@
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([])
})
+24
View File
@@ -0,0 +1,24 @@
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([])
})
+12
View File
@@ -0,0 +1,12 @@
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([])
})
+12
View File
@@ -0,0 +1,12 @@
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([])
})
+23 -27
View File
@@ -1,38 +1,34 @@
import { basename, dirname, isAbsolute } from 'node:path'
import AxeBuilder from '@axe-core/playwright'
import { test as base } from '@playwright/test'
import { openDatabase } from '../src/features/storage/database.server'
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'
export const test = base.extend<{
type Fixtures = {
a11y: () => AxeBuilder
resetDecks: undefined
}>({
resetDecks: [
// biome-ignore lint/correctness/noEmptyPattern: Playwright requires destructured fixture arguments.
async ({}, use) => {
const path = process.env.TWITTER_LITE_E2E_DB_PATH
if (
!path ||
!isAbsolute(path) ||
!basename(dirname(path)).startsWith('twitter-lite-e2e-') ||
basename(path) !== 'workspace.sqlite'
)
throw new Error('Isolated E2E database is required.')
const database = openDatabase(path).$client
database.pragma('foreign_keys = ON')
database.exec('DELETE FROM decks; DELETE FROM legacy_imports;')
database.close()
await use(undefined)
},
{ auto: true },
],
homePage: HomePage
listsPage: ListsPage
searchPage: SearchPage
threadPage: ThreadPage
userPage: UserPage
}
export const test = base.extend<Fixtures>({
a11y: async ({ page }, use) => {
await use(() =>
const makeAxeBuilder = () =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['meta-viewport']),
)
.disableRules(['meta-viewport'])
await use(makeAxeBuilder)
},
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'
-35
View File
@@ -1,35 +0,0 @@
import type { TweetLegacy } from '../generated/twitter-openapi/client.ts'
interface TweetLegacyFixtureOptions {
conversationId?: string
inReplyTo?: string
replyCount?: number
}
export const tweetLegacyFixture = (
id: string,
text: string,
{
conversationId = id,
inReplyTo,
replyCount = 1,
}: TweetLegacyFixtureOptions = {},
): TweetLegacy => ({
bookmark_count: 0,
bookmarked: false,
conversation_id_str: conversationId,
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
display_text_range: [0, text.length],
favorite_count: 3,
favorited: false,
full_text: text,
id_str: id,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
is_quote_status: false,
lang: 'ja',
quote_count: 0,
reply_count: replyCount,
retweet_count: 2,
retweeted: false,
user_id_str: '42',
})
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
import { expect, test } from '../fixtures'
test('owner can open the app but missing or foreign identity is forbidden', async ({
request,
}) => {
expect((await request.get('/')).status()).toBe(200)
expect(
(
await request.get('/', { headers: { 'Tailscale-User-Login': '' } })
).status(),
).toBe(403)
expect(
(
await request.get('/', {
headers: { 'Tailscale-User-Login': '[email protected]' },
})
).status(),
).toBe(403)
})
test('mutations require exact Origin even with same-origin Fetch Metadata', async ({
request,
}) => {
expect((await request.post('/')).status()).toBe(403)
expect(
(
await request.post('/', {
headers: {
Origin: 'https://other.invalid',
'Sec-Fetch-Site': 'same-origin',
},
})
).status(),
).toBe(403)
})
test('server functions also reject foreign identity and cross-site calls', async ({
page,
request,
}) => {
const serverRequest = page.waitForRequest((request) =>
request.url().includes('/_serverFn/'),
)
await page.goto('/')
const url = (await serverRequest).url()
expect(
(
await request.get(url, {
headers: {
'Tailscale-User-Login': '[email protected]',
'Sec-Fetch-Site': 'same-origin',
},
})
).status(),
).toBe(403)
expect(
(
await request.get(url, {
headers: { 'Sec-Fetch-Site': 'cross-site' },
})
).status(),
).toBe(403)
})
-34
View File
@@ -1,34 +0,0 @@
import { expect, test } from '../fixtures'
test('opens account management after hydration without inheriting real OAuth configuration', async ({
page,
}) => {
await page.goto('/')
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
await expect(manage).toBeEnabled()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Mastodonで認可する' }),
).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused()
})
test('rejects an unbound OAuth callback without contacting an instance', async ({
page,
}) => {
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code')
await expect(page).toHaveURL(/\/\?mastodon=failed$/)
await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。',
)
await expect(
page.getByRole('button', { name: '接続アカウントを管理' }),
).toBeEnabled()
})
-372
View File
@@ -1,372 +0,0 @@
import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures'
async function addColumn(
page: Page,
title: string,
query: string,
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
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption({ label: 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
.getByRole('region', { name: title, exact: true })
.locator('article')
.first(),
).toBeVisible()
}
test.beforeEach(async ({ page }) => {
await page.goto('/')
await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
).toBeEnabled()
await page
.getByRole('button', { name: 'デッキとして保存', exact: true })
.click()
await expect(page.getByText('サーバーに保存・端末間で共有')).toHaveCount(1)
})
test('binds identical searches to independent profiles through paging, editing and reload', async ({
page,
}) => {
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(
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('combobox', { name: '接続プロファイル', exact: true })
.selectOption({ label: 'e2e' })
await page.getByRole('button', { name: '変更を保存' }).click()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount(
0,
)
await page.reload()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(first.locator('article')).toHaveCount(1)
})
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')
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: '続きを読み込む' }).click()
await expect(failed.getByRole('alert')).toBeVisible()
await expect(failed.locator('article')).toHaveCount(1)
await other.getByRole('button', { name: '続きを読み込む' }).click()
await expect(other.locator('article')).toHaveCount(2)
await failed.getByRole('button', { name: '再試行' }).click()
await expect(failed.locator('article')).toHaveCount(2)
})
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', 'alternate')
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user')
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
expect(
await page.evaluate(
() => 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('navigation', { name: 'カラム一覧' }),
).toHaveCount(0)
await expect(
page.getByRole('heading', { name: '公式アカウント', exact: true }),
).toBeVisible()
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'),
fullPage: true,
})
})
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'),
)
await page.reload()
await expect(page.getByRole('alert')).toContainText('読み込めませんでした')
expect(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe('{broken')
})
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
page,
}) => {
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption({ label: 'e2e' })
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({ label: '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.getByRole('button', { name: '追加して検索' }).click()
await expect(
page
.getByRole('region', { name: '購読リスト', exact: true })
.getByText('list page 1', { exact: true }),
).toBeVisible()
})
test('keeps an open draft through remote edits and rejects its stale save', async ({
page,
browser,
baseURL,
}) => {
const other = await browser.newContext({
baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
})
try {
const device = await other.newPage()
await device.goto('/')
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前')
await device.getByRole('button', { name: '名前を変更' }).click()
await device.getByLabel('調査テーマ・デッキ名').fill('別の端末の変更')
await device.getByRole('button', { name: '名前を保存' }).click()
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue(
'編集中の名前',
)
await page.getByRole('button', { name: '名前を保存' }).click()
await expect(page.getByRole('alert')).toContainText(
'別の端末で変更されました',
)
await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue(
'編集中の名前',
)
await page.keyboard.press('Escape')
await page.getByRole('button', { name: '最新のデッキを再読み込み' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
} finally {
await other.close()
}
})
test('imports old browser decks explicitly and avoids duplicate imports after retry', async ({
page,
}) => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [
{
id: 'old',
title: '以前の調査',
columns: [
{
id: 'a',
title: '以前の観点',
profileName: 'e2e',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
],
},
],
}
await page.evaluate(
(legacy) =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
legacy,
)
await page.reload()
const selector = page.getByLabel('デッキプロファイル', { exact: true })
await expect(selector.locator('option')).toHaveCount(1)
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' })
await expect(
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await page.evaluate(
(legacy) =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify(legacy),
),
legacy,
)
await page.reload()
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await expect(
page.getByRole('button', { name: '旧デッキを取り込む' }),
).toHaveCount(0)
await expect(selector.locator('option')).toHaveCount(2)
})
+192
View File
@@ -0,0 +1,192 @@
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('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('link', { name: 'ユーザー' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
await searchPage.tabTo(searchPage.queryInputLocator)
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.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

-266
View File
@@ -1,266 +0,0 @@
import type { DeckColumn } from '../../src/features/decks/model'
import type { ResearchRun } from '../../src/features/research/model'
import { expect, test } from '../fixtures'
test('pushes the current research snapshot on each SSE connection', async ({
page,
}) => {
await page.goto('/')
const snapshots = await page.evaluate(async () => {
const connect = () =>
new Promise<unknown>((resolve, reject) => {
const events = new EventSource('/api/research/events')
const timer = setTimeout(() => {
events.close()
reject(new Error('SSE timed out'))
}, 5000)
events.onmessage = (event) => {
clearTimeout(timer)
events.close()
resolve(JSON.parse(event.data))
}
events.onerror = () => {
clearTimeout(timer)
events.close()
reject(new Error('SSE failed'))
}
})
return [await connect(), await connect()]
})
expect(snapshots).toEqual([
{ configured: false, run: null, history: [] },
{ configured: false, run: null, history: [] },
])
})
test('restores the conversation and growing temporary deck from reconnect snapshots', async ({
page,
}) => {
await page.goto('/')
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', {
name: '接続プロファイル',
exact: true,
})
await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape')
const run: ResearchRun = {
id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
threadId: 'conversation-test',
topic: '既存の観点を使って調査して',
status: 'running',
startedAt: Date.now(),
deckVersion: 1,
citations: [],
message: '既存の検索条件を再利用しています。',
messages: [
{ id: 'user-1', role: 'user', text: '既存の観点を使って調査して' },
{ id: 'tool-1', role: 'tool', text: 'list_decks: 完了' },
{
id: 'assistant-1',
role: 'assistant',
text: '既存の検索条件を再利用しています。',
},
],
deck: {
id: 'generated-plan',
title: '会話からの調査',
columns: [
{
id: 'one',
title: '最初の観点',
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
],
},
}
await page.route('**/api/research/events', (route) =>
route.fulfill({
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}),
)
await page.reload()
const conversation = page.getByRole('log', { name: '調査の会話' })
await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('list_decks: 完了')
await expect(conversation).toContainText('既存の検索条件を再利用しています。')
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
run.deckVersion = 2
run.status = 'complete'
run.messages.push({
id: 'assistant-2',
role: 'assistant',
text: '比較する観点を追加しました。',
})
run.deck?.columns.push({
id: 'two',
title: '比較の観点',
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP lang:ja',
product: 'Latest',
following: false,
},
})
await page.reload()
await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('比較する観点を追加しました。')
await expect(page.locator('.deck-column h2')).toHaveText([
'最初の観点',
'比較の観点',
])
await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
).toBeEnabled()
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく')
await expect(
page.getByRole('button', { name: '送信', exact: true }),
).toBeEnabled()
})
test('rejects a different tailnet owner at the SSE endpoint', async ({
request,
}) => {
const response = await request.get('/api/research/events', {
headers: { 'Tailscale-User-Login': '[email protected]' },
})
expect(response.status()).toBe(403)
})
test('renders Markdown and reveals an unloaded citation in an offscreen deck column', async ({
page,
a11y,
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', {
name: '接続プロファイル',
exact: true,
})
await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape')
const source: DeckColumn = {
id: 'citation-column-2',
title: '引用の観点',
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: 'citation-query-2',
product: 'Latest',
following: false,
},
}
const columns: DeckColumn[] = ['概要', '比較'].map((title, index) => ({
id: `citation-column-${index}`,
title,
connectionId,
source: {
platform: 'twitter',
kind: 'search',
query: `citation-query-${index}`,
product: 'Latest',
following: false,
},
}))
columns.push(source)
const run: ResearchRun = {
id: '973ae084-18ed-4674-8899-c75851e51d1b',
topic: '引用を確認',
status: 'complete',
startedAt: Date.now(),
deckVersion: 1,
message: '',
deck: { id: 'citation-deck', title: '引用付きの調査', columns },
messages: [
{
id: 'answer',
role: 'assistant',
text: '## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。',
},
],
citations: [
{
column: source,
post: {
key: 'twitter:123456789',
nativeId: '123456789',
platform: 'twitter',
url: 'https://twitter.com/author/status/123456789',
text: 'AIが調査時に取得した引用本文',
author: { name: '引用元の著者', handle: 'author' },
},
},
],
}
await page.route('**/api/research/events', (route) =>
route.fulfill({
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
}),
)
await page.reload()
const chat = page.getByRole('log', { name: '調査の会話' })
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
await expect(
chat.locator('strong').filter({ hasText: '重要な発見' }),
).toBeVisible()
await expect(chat.getByRole('table')).toBeVisible()
await expect(
page.getByText('AIが調査時に取得した引用本文', { exact: true }),
).toHaveCount(0)
await chat.getByRole('link', { name: '根拠の投稿' }).click()
const card = page.locator(
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
)
await expect(card).toBeVisible()
await expect(card).toHaveClass(/citation-highlight/)
await expect(card).toBeFocused()
await expect(card).toContainText('調査時に取得した投稿')
await expect(card).toContainText('AIが調査時に取得した引用本文')
expect(
await page.locator('.deck-board').evaluate((element) => element.scrollLeft),
).toBeGreaterThan(0)
expect(page.context().pages()).toHaveLength(1)
expect((await a11y().analyze()).violations).toEqual([])
})
test('explains unconfigured Codex and prevents starting research without affecting the deck', async ({
page,
a11y,
}) => {
await page.goto('/')
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
const originalTitle = await heading.textContent()
const panel = page.getByRole('complementary', { name: '調査チャット' })
await expect(panel).toBeVisible()
await expect(
panel.getByText('自宅のCodexへの接続が設定されていません。'),
).toBeVisible()
await expect(
panel.getByRole('button', { name: '送信', exact: true }),
).toBeDisabled()
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
const accessibility = await a11y().analyze()
expect(accessibility.violations).toEqual([])
await expect(heading).toHaveText(originalTitle ?? '')
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('手動の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await expect(heading).toHaveText('手動の調査')
await expect(panel).toBeVisible()
})
-332
View File
@@ -1,332 +0,0 @@
import type { Page } from '@playwright/test'
import Database from 'better-sqlite3'
import { expect, test } from '../fixtures'
type NativeTesting = {
listTools(): { name: string }[]
executeTool(name: string, input: string): Promise<string | null>
}
type ToolResult = {
content: { type: string; text: string }[]
isError?: boolean
}
test.use({
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
args: ['--enable-blink-features=WebMCP,WebMCPTesting'],
},
})
async function toolNames(page: Page) {
return page.evaluate(() =>
(
navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting
.listTools()
.map((tool) => tool.name)
.sort(),
)
}
async function executeTool(
page: Page,
name: string,
input: Record<string, unknown> = {},
): Promise<ToolResult> {
const result = await page.evaluate(
({ name, input }) =>
(
navigator as Navigator & { modelContextTesting: NativeTesting }
).modelContextTesting.executeTool(name, JSON.stringify(input)),
{ name, input },
)
expect(result).not.toBeNull()
return JSON.parse(result ?? '{}')
}
function decoded(result: ToolResult) {
return JSON.parse(result.content[0]?.text ?? '{}')
}
const column = (title: string, connectionId: string) => ({
title,
connectionId,
source: { kind: 'search', query: 'WebMCP' },
})
function savedCount() {
const db = new Database(process.env.TWITTER_LITE_E2E_DB_PATH ?? '', {
readonly: true,
})
try {
return db.prepare('SELECT count(*) AS count FROM decks').get()
} finally {
db.close()
}
}
let accounts: Record<string, string>
let pageErrors: string[]
test.beforeEach(async ({ page }) => {
pageErrors = []
page.on('pageerror', (error) => pageErrors.push(error.message))
await page.goto('/')
await expect
.poll(() => toolNames(page))
.toEqual([
'delete_deck',
'get_column_posts',
'get_deck',
'list_connections',
'list_decks',
'load_more_column',
'save_deck',
'select_deck',
'set_deck',
])
await expect
.poll(
async () =>
decoded(await executeTool(page, 'list_connections')).connections
?.length,
)
.toBe(2)
accounts = Object.fromEntries(
decoded(await executeTool(page, 'list_connections')).connections.map(
(connection: { displayName: string; id: string }) => [
connection.displayName,
connection.id,
],
),
)
})
test.afterEach(() => expect(pageErrors).toEqual([]))
test('creates temporary research, edits it, persists explicitly and reopens it on another device', async ({
page,
browser,
baseURL,
}) => {
const created = await executeTool(page, 'set_deck', {
title: 'WebMCPの反応',
columns: [
{ ...column('日本語', accounts.e2e ?? ''), id: 'new' },
column('別の接続', accounts.alternate ?? ''),
],
})
expect(created.isError).not.toBe(true)
const deck = decoded(created).deck
expect(deck.persisted).toBe(false)
expect(savedCount()).toEqual({ count: 0 })
await expect(
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await page
.getByRole('region', { name: '日本語', exact: true })
.locator('summary')
.click()
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '編集', exact: true })
.click()
const replaced = await executeTool(page, 'set_deck', {
deckId: deck.id,
title: '更新した調査',
columns: [deck.columns[1]],
})
expect(replaced.isError).not.toBe(true)
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(savedCount()).toEqual({ count: 0 })
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 1 })
const other = await browser.newContext({
baseURL,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
})
try {
const device = await other.newPage()
await device.goto('/')
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査',
)
await expect(
device.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
} finally {
await other.close()
}
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'更新した調査',
)
})
test('requires revisions for saved mutations and rejects stale overwrites', async ({
page,
}) => {
const deck = decoded(
await executeTool(page, 'set_deck', { title: '保存済み', columns: [] }),
).deck
await executeTool(page, 'save_deck', { deckId: deck.id })
expect(
(
await executeTool(page, 'set_deck', {
deckId: deck.id,
title: '変更',
columns: [],
})
).isError,
).toBe(true)
expect(
(
await executeTool(page, 'set_deck', {
deckId: deck.id,
expectedRevision: 1,
title: '変更',
columns: [],
})
).isError,
).not.toBe(true)
expect(
(
await executeTool(page, 'set_deck', {
deckId: deck.id,
expectedRevision: 1,
title: '古い変更',
columns: [],
})
).isError,
).toBe(true)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('変更')
expect(
(
await executeTool(page, 'delete_deck', {
deckId: deck.id,
expectedRevision: 1,
})
).isError,
).toBe(true)
expect(
(
await executeTool(page, 'delete_deck', {
deckId: deck.id,
expectedRevision: 2,
})
).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 0 })
})
test('keeps temporary views independent and loses only unsaved views on reload', async ({
page,
}) => {
const first = decoded(
await executeTool(page, 'set_deck', { title: '一時の調査A', columns: [] }),
).deck
const second = decoded(
await executeTool(page, 'set_deck', { title: '一時の調査B', columns: [] }),
).deck
await executeTool(page, 'select_deck', { deckId: first.id })
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'一時の調査A',
)
await executeTool(page, 'delete_deck', { deckId: second.id })
expect(
decoded(await executeTool(page, 'list_decks')).decks.some(
(deck: { id: string }) => deck.id === second.id,
),
).toBe(false)
expect(savedCount()).toEqual({ count: 0 })
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('新しい調査')
})
test('rejects invalid input without changing the active view', async ({
page,
}) => {
await executeTool(page, 'set_deck', {
title: '残す調査',
columns: [column('残す観点', accounts.e2e ?? '')],
})
const duplicate = { ...column('不正', accounts.e2e ?? ''), id: 'duplicate' }
for (const columns of [
[duplicate, duplicate],
[column('不明', 'missing')],
[
{
...column('不正', accounts.e2e ?? ''),
source: { kind: 'list', target: 'invalid' },
},
],
]) {
expect(
(await executeTool(page, 'set_deck', { title: '変更しない', columns }))
.isError,
).toBe(true)
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
}
expect(savedCount()).toEqual({ count: 0 })
})
test('keeps failed temporary saves visible and allows retry without duplicate decks', async ({
page,
}) => {
const deck = decoded(
await executeTool(page, 'set_deck', { title: '未保存の調査', columns: [] }),
).deck
await page.route('**/_serverFn/**', (route) =>
route.request().method() === 'POST' ? route.abort() : route.continue(),
)
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).toBe(true)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'未保存の調査',
)
expect(decoded(await executeTool(page, 'get_deck')).deck.persisted).toBe(
false,
)
await page.unroute('**/_serverFn/**')
expect(
(await executeTool(page, 'save_deck', { deckId: deck.id })).isError,
).not.toBe(true)
expect(savedCount()).toEqual({ count: 1 })
})
test('reads and pages bound accounts without mixing sibling columns', async ({
page,
}) => {
const deck = decoded(
await executeTool(page, 'set_deck', {
title: '接続を比較',
columns: [
column('通常', accounts.e2e ?? ''),
column('別の接続', accounts.alternate ?? ''),
],
}),
).deck
await expect(
page.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
const id = deck.columns[1].id
const loaded = decoded(
await executeTool(page, 'get_column_posts', { columnId: id }),
)
expect(loaded.column.connectionId).toBe(accounts.alternate)
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
'alternate · Latest · all page 1',
])
const more = decoded(
await executeTool(page, 'load_more_column', { columnId: id }),
)
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 executeTool(page, 'set_deck', { title: '別の一時ビュー', columns: [] })
expect(
(await executeTool(page, 'get_column_posts', { columnId: id })).isError,
).toBe(true)
})
+13 -21
View File
@@ -1,5 +1,4 @@
import { createServer } from 'node:http'
import { tweetLegacyFixture } from './fixtures/twitter.ts'
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
const port = Number(portValue)
@@ -31,11 +30,15 @@ const tweetResult = (
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({
rest_id: id,
legacy: tweetLegacyFixture(id, text, {
conversationId,
inReplyTo,
replyCount,
}),
legacy: {
full_text: text,
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
reply_count: replyCount,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: conversationId,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
},
core: {
user_results: {
result: {
@@ -96,11 +99,6 @@ const timeline = (entries) => ({ instructions: [{ entries }] })
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
if (request.method === 'GET' && url.pathname === '/profiles') {
send(response, { profiles: ['e2e', 'alternate'] })
return
}
const operation = [
'UserByScreenName',
'UserTweets',
@@ -122,8 +120,7 @@ const server = createServer(async (request, response) => {
})
return
}
const profileName = request.headers['x-profile-name']
if (profileName !== 'e2e' && profileName !== 'alternate') {
if (request.headers['x-profile-name'] !== 'e2e') {
fail(response, 403, 'invalid profile')
return
}
@@ -169,12 +166,10 @@ const server = createServer(async (request, response) => {
return
}
const secondPage = variables.cursor === 'user-next'
const firstPageText =
profileName === 'alternate' ? 'alternate profile page 1' : 'user page 1'
const entries = secondPage
? [tweet('1010', 'user page 2')]
: [
tweet('1001', firstPageText, 'yuta', {
tweet('1001', 'user page 1', 'yuta', {
conversationId: '1000',
}),
cursor('user-next'),
@@ -218,10 +213,7 @@ const server = createServer(async (request, response) => {
itemContent: {
list: {
id_str: '1234567890',
name:
profileName === 'alternate'
? 'Alternate List'
: 'My E2E List',
name: 'My E2E List',
description: 'Accounts selected for testing',
member_count: 10,
mode: 'Public',
@@ -459,7 +451,7 @@ const server = createServer(async (request, response) => {
}
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
const label = `${profileName === 'alternate' ? 'alternate · ' : ''}${variables.product} · ${follows}`
const label = `${variables.product} · ${follows}`
const id = encodeURIComponent(rawQuery)
const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)]
+49
View File
@@ -0,0 +1,49 @@
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()
}
}
+22
View File
@@ -0,0 +1,22 @@
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()
}
}
+11
View File
@@ -0,0 +1,11 @@
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()
}
}
+35
View File
@@ -0,0 +1,35 @@
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()
}
}
+17
View File
@@ -0,0 +1,17 @@
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}`)
}
}
+15
View File
@@ -0,0 +1,15 @@
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()
}
}
Generated
+3 -3
View File
@@ -74,11 +74,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
+12 -35
View File
@@ -22,7 +22,7 @@
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11;
fetcherVersion = 4;
hash = "sha256-dV9yHImzYzkwE5ZK75zWLkpJ5+V0Rvqg8/zC0zRw76I=";
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
};
nativeBuildInputs = with pkgs; [
@@ -44,8 +44,6 @@
cp -r .output/. $out/lib/twitter-lite/
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite \
--add-flags "$out/lib/twitter-lite/server/index.mjs"
makeWrapper ${nixpkgs.lib.getExe pkgs.nodejs_22} $out/bin/twitter-lite-backup \
--add-flags "$out/lib/twitter-lite/server/tools/backup-database.js"
runHook postInstall
'';
@@ -115,29 +113,17 @@
description = "Base URL of the Twitter relay.";
};
publicOrigin = lib.mkOption {
type = lib.types.nonEmptyStr;
example = "https://home.example-tailnet.ts.net";
description = "Exact Tailscale Serve HTTPS origin, without a trailing slash.";
};
allowedLogin = lib.mkOption {
type = lib.types.nonEmptyStr;
description = "Tailscale login allowed to access this personal workspace.";
};
credentialKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
profileName = lib.mkOption {
type = lib.types.nullOr lib.types.nonEmptyStr;
default = null;
example = "/var/lib/secrets/twitter-lite-key";
description = "Runtime file containing the base64-encoded 32-byte credential encryption key. Never put this key in the Nix store.";
example = "default";
description = "Relay profile to use when the relay has multiple profiles.";
};
mastodonOrigins = lib.mkOption {
type = lib.types.listOf lib.types.nonEmptyStr;
default = [ ];
example = [ "https://mastodon.social" ];
description = "Allowed Mastodon HTTPS origins, without trailing slashes.";
host = lib.mkOption {
type = lib.types.nonEmptyStr;
default = "127.0.0.1";
description = "Address on which Twitter Lite listens.";
};
port = lib.mkOption {
@@ -153,23 +139,14 @@
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HOST = "127.0.0.1";
HOST = cfg.host;
PORT = toString cfg.port;
TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl;
TWITTER_LITE_ORIGIN = cfg.publicOrigin;
TWITTER_LITE_ALLOWED_LOGIN = cfg.allowedLogin;
TWITTER_LITE_DB_PATH = "/var/lib/twitter-lite/workspace.sqlite";
TWITTER_LITE_MASTODON_ORIGINS = lib.concatStringsSep "," cfg.mastodonOrigins;
} // lib.optionalAttrs (cfg.credentialKeyFile != null) {
TWITTER_LITE_CREDENTIAL_KEY_FILE = "%d/credential-key";
} // lib.optionalAttrs (cfg.profileName != null) {
BIRD_PROFILE_NAME = cfg.profileName;
};
serviceConfig = {
DynamicUser = true;
StateDirectory = "twitter-lite";
StateDirectoryMode = "0700";
UMask = "0077";
LoadCredential = lib.optional (cfg.credentialKeyFile != null)
"credential-key:${cfg.credentialKeyFile}";
ExecStart = lib.getExe cfg.package;
Restart = "on-failure";
};
-75
View File
@@ -1,75 +0,0 @@
import { defineConfig, defineTransformer } from 'orval'
const selectedTag = 'twitter-lite-e2e'
const selectedOperationIds = new Set([
'getUserByScreenName',
'getUserTweets',
'getTweetDetail',
'getSearchTimeline',
'getListLatestTweetsTimeline',
])
const selectE2eOperations = defineTransformer((document) => {
const foundOperationIds = new Set<string>()
for (const pathItem of Object.values(document.paths ?? {})) {
if (!pathItem || typeof pathItem !== 'object') continue
for (const candidate of Object.values(pathItem)) {
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate) ||
!('operationId' in candidate)
) {
continue
}
const operation = candidate as {
operationId?: string
tags?: string[]
}
if (
operation.operationId &&
selectedOperationIds.has(operation.operationId)
) {
foundOperationIds.add(operation.operationId)
operation.tags = [...(operation.tags ?? []), selectedTag]
}
}
}
const missingOperationIds = [...selectedOperationIds].filter(
(operationId) => !foundOperationIds.has(operationId),
)
if (missingOperationIds.length > 0) {
throw new Error(
`twitter-openapi is missing selected operations: ${missingOperationIds.join(', ')}`,
)
}
return document
})
export default defineConfig({
twitterOpenApi: {
input: {
target:
'https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml',
filters: {
mode: 'include',
tags: [selectedTag],
},
override: {
transformer: selectE2eOperations,
},
},
output: {
clean: true,
client: 'fetch',
mode: 'single',
target: './e2e/generated/twitter-openapi/client.ts',
},
},
})
+2 -25
View File
@@ -6,27 +6,15 @@
"engines": {
"node": ">=22.12.0"
},
"knip": {
"ignoreBinaries": [
"codex"
],
"ignore": [
"e2e/generated/**"
]
},
"imports": {
"#/*": "./src/*"
},
"scripts": {
"codex:serve": "node scripts/serve-codex.mjs",
"db:generate": "drizzle-kit generate && node scripts/bundle-migrations.mjs && biome format --write src/features/storage/migrations.generated.ts",
"db:backup": "tsx scripts/backup-database.ts",
"dev": "vite dev --host 127.0.0.1 --port 3000",
"dev:tailscale": "vite dev --host 127.0.0.1 --port 3000",
"generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings",
"dev:tailscale": "vite dev --host 0.0.0.0 --port 3000",
"generate-routes": "node scripts/generate-routes.mjs",
"check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts",
"build": "vite build && node scripts/build-tools.mjs",
"build": "vite build",
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
@@ -43,15 +31,9 @@
"@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.27",
"@yuta/bird": "0.10.1",
"better-sqlite3": "13.0.3",
"drizzle-orm": "0.45.3",
"nitro": "3.0.260610-beta",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.7",
"usewebmcp": "5.1.0",
"zod": "4.4.3"
},
"devDependencies": {
@@ -61,17 +43,12 @@
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/better-sqlite3": "9.6.0",
"@types/node": "26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/sanitize-html": "2.16.1",
"@vitejs/plugin-react": "6.0.3",
"drizzle-kit": "0.31.11",
"jsdom": "29.1.1",
"knip": "6.27.0",
"orval": "8.24.0",
"tsx": "4.23.12",
"typescript": "7.0.2",
"vite": "8.1.4",
"vitest": "4.1.10"
+3 -11
View File
@@ -1,15 +1,8 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineConfig, devices } from '@playwright/test'
const appPort = 4173
const relayPort = 6911
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
const databasePath =
process.env.TWITTER_LITE_E2E_DB_PATH ??
join(mkdtempSync(join(tmpdir(), 'twitter-lite-e2e-')), 'workspace.sqlite')
process.env.TWITTER_LITE_E2E_DB_PATH = databasePath
if (!chromiumExecutable) {
throw new Error(
@@ -23,21 +16,20 @@ export default defineConfig({
workers: 1,
use: {
baseURL: `http://127.0.0.1:${appPort}`,
extraHTTPHeaders: { 'Tailscale-User-Login': '[email protected]' },
launchOptions: { executablePath: chromiumExecutable },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: [
{
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} pnpm exec tsx e2e/mock-relay.mjs`,
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node e2e/mock-relay.mjs`,
port: relayPort,
reuseExistingServer: false,
timeout: 120_000,
},
{
command: `TWITTER_LITE_CODEX_URL= TWITTER_LITE_REPORT_ROOT= TWITTER_LITE_CODEX_MODEL= TWITTER_LITE_MASTODON_ORIGINS= TWITTER_LITE_CREDENTIAL_KEY_FILE= TWITTER_LITE_DB_PATH=${databasePath} TWITTER_LITE_ORIGIN=http://127.0.0.1:${appPort} [email protected] TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
port: appPort,
command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
url: `http://127.0.0.1:${appPort}`,
reuseExistingServer: false,
timeout: 120_000,
},
+37 -2845
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,3 +1,2 @@
allowBuilds:
better-sqlite3: false
esbuild: true
-13
View File
@@ -1,13 +0,0 @@
import { backupDatabase } from '../src/features/storage/backup.server'
const source = process.env.TWITTER_LITE_DB_PATH
const destination = process.argv[2]
if (!source || !destination || process.argv.length !== 3) {
console.error(
'Usage: TWITTER_LITE_DB_PATH=/absolute/app.db pnpm db:backup /absolute/backup.db',
)
process.exitCode = 1
} else {
await backupDatabase(source, destination)
console.log(`Backup verified: ${destination}`)
}
-10
View File
@@ -1,10 +0,0 @@
import { build } from 'vite'
await build({
configFile: false,
build: {
ssr: 'scripts/backup-database.ts',
outDir: '.output/server/tools',
rollupOptions: { external: ['better-sqlite3'] },
},
})
-10
View File
@@ -1,10 +0,0 @@
import { writeFileSync } from 'node:fs'
import { readMigrationFiles } from 'drizzle-orm/migrator'
const migrations = readMigrationFiles({
migrationsFolder: new URL('../drizzle', import.meta.url).pathname,
})
writeFileSync(
new URL('../src/features/storage/migrations.generated.ts', import.meta.url),
`// Generated by pnpm db:generate. Do not edit.\nexport const migrations = ${JSON.stringify(migrations, null, 2)}\n`,
)
-94
View File
@@ -1,94 +0,0 @@
import { spawn, spawnSync } from 'node:child_process'
import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
// This process is independent of Vite / the web backend. Keep it running while
// restarting the app. Codex owns its own login and refreshes that login normally.
const reportRoot = resolve(
process.env.TWITTER_LITE_REPORT_ROOT || '.data/research',
)
const endpoint = new URL(
process.env.TWITTER_LITE_CODEX_URL || 'ws://127.0.0.1:4500',
)
if (
endpoint.protocol !== 'ws:' ||
endpoint.hostname !== '127.0.0.1' ||
endpoint.username ||
endpoint.password
) {
throw new Error('The prototype Codex endpoint must be ws://127.0.0.1:<port>.')
}
await mkdir(reportRoot, { recursive: true, mode: 0o700 })
const env = {
HOME: homedir(),
PATH: process.env.PATH,
CODEX_HOME: process.env.CODEX_HOME || resolve(homedir(), '.codex'),
}
for (const name of [
'LANG',
'LC_ALL',
'SSL_CERT_FILE',
'SSL_CERT_DIR',
'NIX_SSL_CERT_FILE',
'TMPDIR',
]) {
if (process.env[name]) env[name] = process.env[name]
}
// Enumerate the effective config, including CLI-wrapper and project settings.
// Keep this output private: MCP transport settings can contain credentials.
const configured = spawnSync('codex', ['mcp', 'list', '--json'], {
cwd: reportRoot,
env,
encoding: 'utf8',
timeout: 15_000,
maxBuffer: 4 * 1024 * 1024,
})
let mcpOverrides
try {
if (configured.error || configured.status !== 0) throw new Error()
const servers = JSON.parse(configured.stdout)
if (!Array.isArray(servers)) throw new Error()
mcpOverrides = servers.flatMap((server) => {
if (
typeof server?.name !== 'string' ||
!/^[A-Za-z0-9_-]+$/.test(server.name)
)
throw new Error()
return ['-c', `mcp_servers.${server.name}.enabled=false`]
})
} catch {
throw new Error(
'Could not inspect Codex MCP configuration safely. Check codex mcp list locally.',
)
}
const child = spawn(
'codex',
[
// Keep overrides before the subcommand so they merge with wrapper settings.
'-c',
'shell_environment_policy.inherit="none"',
'-c',
'features.multi_agent=false',
'-c',
'features.hooks=false',
'-c',
'web_search="disabled"',
...mcpOverrides,
'app-server',
'--listen',
endpoint.origin,
],
{ cwd: reportRoot, env, stdio: 'inherit' },
)
child.on('error', () => {
console.error(
'Could not start Codex. Install codex and run codex login first.',
)
process.exitCode = 1
})
child.on('exit', (code) => {
process.exitCode = code ?? 1
})
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, () => child.kill(signal))
+27 -12
View File
@@ -1,21 +1,36 @@
import { Link } from '@tanstack/react-router'
export function AppShell({
sidebar,
researchChat,
active,
children,
}: {
sidebar: React.ReactNode
researchChat: React.ReactNode
active?: 'user' | 'search' | 'list'
children: React.ReactNode
}) {
return (
<main className="app app-deck">
{researchChat}
<section className="reader">
<section className="deck-toolbar" aria-label="デッキ管理">
{sidebar}
</section>
{children}
</section>
<main className="app">
<header className="app-header">
<p className="brand">Twitter Lite</p>
<nav aria-label="閲覧方法" className="tabs">
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
ユーザー
</Link>
<Link
aria-current={active === 'search' ? 'page' : undefined}
search={{ q: '', product: 'Latest', following: false }}
to="/search"
>
検索
</Link>
<Link
aria-current={active === 'list' ? 'page' : undefined}
to="/i/lists"
>
リスト
</Link>
</nav>
</header>
<section className="reader">{children}</section>
</main>
)
}
-51
View File
@@ -1,51 +0,0 @@
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>
)
}
-32
View File
@@ -1,32 +0,0 @@
const paths = {
columns: 'M3 4h7v16H3z M14 4h7v16h-7z',
hashtag: 'M10 3 8 21 M17 3l-2 18 M4 9h16 M3 15h16',
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>
)
}
-49
View File
@@ -1,49 +0,0 @@
import type { ReactNode } from 'react'
const URL = /(https?:\/\/[^\s]+)/g
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
const NAMED_ENTITIES: Record<string, string> = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
function decodeHtmlEntities(text: string): string {
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
if (!decimal && !hexadecimal) {
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
}
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
})
}
function linkedText(text: string): ReactNode[] {
const decoded = decodeHtmlEntities(text)
const parts: ReactNode[] = []
let cursor = 0
for (const match of decoded.matchAll(URL)) {
const href = match[0]
const start = match.index
if (start > cursor) parts.push(decoded.slice(cursor, start))
parts.push(
<a
href={href}
key={`link-${start}`}
rel="noreferrer noopener"
target="_blank"
>
{href}
</a>,
)
cursor = start + href.length
}
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
return parts
}
export function PostText({ text }: { text: string }) {
return <>{linkedText(text)}</>
}
-45
View File
@@ -1,45 +0,0 @@
type AccessConfig = { origin: string } & (
| { mode: 'tailscale'; allowedLogin: string }
| { mode: 'none' }
)
export function readAccessConfig(): AccessConfig | null {
const origin = process.env.TWITTER_LITE_ORIGIN
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? 'tailscale'
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
if (!origin || (mode !== 'tailscale' && mode !== 'none')) return null
try {
const url = new URL(origin)
const secure = url.protocol === 'https:'
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
if ((!secure && !local) || url.origin !== origin) return null
if (mode === 'none') return { origin, mode }
if (!allowedLogin?.trim()) return null
return { origin, mode, allowedLogin }
} catch {
return null
}
}
/** Use loopback behind Serve for identity, or a private network for mode none. */
export function checkAccess(
request: Request,
config: AccessConfig | null,
): Response | null {
if (!config) {
return new Response('Access configuration is required.', { status: 503 })
}
if (
config.mode === 'tailscale' &&
request.headers.get('Tailscale-User-Login') !== config.allowedLogin
) {
return new Response('Forbidden', { status: 403 })
}
if (
!['GET', 'HEAD', 'OPTIONS'].includes(request.method) &&
request.headers.get('Origin') !== config.origin
) {
return new Response('Forbidden', { status: 403 })
}
return null
}
-171
View File
@@ -1,171 +0,0 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { checkAccess, readAccessConfig } from './policy.server'
const config = {
mode: 'tailscale' as const,
origin: 'https://deck.invalid',
allowedLogin: '[email protected]',
}
beforeEach(() => vi.stubEnv('TWITTER_LITE_AUTH_MODE', undefined))
afterEach(() => vi.unstubAllEnvs())
describe('access boundary', () => {
it.each([
undefined,
'tailscale',
])('requires an owner in identity mode: %s', (mode) => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode)
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
})
it.each([
'',
'off',
'NONE',
])('fails closed for an unknown auth mode: %s', (mode) => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode)
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(
checkAccess(new Request(config.origin), readAccessConfig())?.status,
).toBe(503)
})
it('allows private-proxy navigation without an identity only when explicitly configured', () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none')
vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
const access = readAccessConfig()
expect(access).toEqual({ origin: config.origin, mode: 'none' })
expect(checkAccess(new Request(config.origin), access)).toBeNull()
})
it('still requires a configured origin without identity authentication', () => {
vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none')
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
expect(readAccessConfig()).toBeNull()
})
it.each([
'POST',
'PUT',
'PATCH',
'DELETE',
])('requires exact Origin without identity authentication for %s', (method) => {
const access = { origin: config.origin, mode: 'none' as const }
expect(
checkAccess(new Request(config.origin, { method }), access)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: {
Origin: 'https://other.invalid',
'Sec-Fetch-Site': 'same-origin',
},
}),
access,
)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { Origin: config.origin },
}),
access,
),
).toBeNull()
})
it('fails closed when the deployment is not configured', () => {
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
expect(readAccessConfig()).toBeNull()
expect(checkAccess(new Request(config.origin), null)?.status).toBe(503)
})
it.each([
'',
'https://deck.invalid/path',
'http://deck.invalid',
'not a URL',
])('rejects an invalid configured origin: %s', (origin) => {
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(readAccessConfig()).toBeNull()
})
it.each([
'https://deck.invalid',
'http://127.0.0.1:4173',
])('accepts an explicit deployment origin: %s', (origin) => {
vi.stubEnv('TWITTER_LITE_ORIGIN', origin)
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin)
expect(readAccessConfig()).toEqual({ ...config, origin })
})
it.each([
undefined,
'[email protected]',
'[email protected], [email protected]',
])('rejects absent, foreign, or ambiguous identities: %s', (login) => {
const headers = new Headers()
if (login) headers.set('Tailscale-User-Login', login)
expect(
checkAccess(new Request(config.origin, { headers }), config)?.status,
).toBe(403)
})
it('permits owner navigation back from an OAuth provider without Origin', () => {
const request = new Request(
`${config.origin}/oauth/mastodon/callback?code=code`,
{
headers: {
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'cross-site',
},
},
)
expect(checkAccess(request, config)).toBeNull()
})
it.each([
'POST',
'PUT',
'PATCH',
'DELETE',
])('requires exact Origin for %s even with same-origin Fetch Metadata', (method) => {
const headers = {
'Tailscale-User-Login': config.allowedLogin,
'Sec-Fetch-Site': 'same-origin',
}
expect(
checkAccess(new Request(config.origin, { method, headers }), config)
?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: 'https://other.invalid' },
}),
config,
)?.status,
).toBe(403)
expect(
checkAccess(
new Request(config.origin, {
method,
headers: { ...headers, Origin: config.origin },
}),
config,
),
).toBeNull()
})
})
@@ -1,73 +0,0 @@
.connection-manager {
display: grid;
gap: 1rem;
}
.connection-manager-description,
.connection-manager-note {
margin: 0;
color: #aab8c2;
font-size: 0.875rem;
}
.connection-manager-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.75rem;
}
.connection-manager-list > li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-block: 0.75rem;
border-bottom: 1px solid #38444d;
}
.connection-manager-account {
display: grid;
gap: 0.25rem;
min-width: 0;
overflow-wrap: anywhere;
}
.connection-manager-account > span {
font-size: 0.8rem;
color: #aab8c2;
}
.connection-manager-account > .connection-manager-status-connected {
color: #71d6ad;
}
.connection-manager-account > .connection-manager-status-expired {
color: #f2c66d;
}
.connection-manager-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.connection-manager-add {
display: grid;
gap: 0.75rem;
}
.connection-manager-add h3 {
margin: 0;
font-size: 1rem;
}
.connection-manager select {
width: 100%;
min-width: 0;
background: #15202b;
color: #e7e9ea;
border: 1px solid #536471;
border-radius: 0.25rem;
padding: 0.6rem;
}
.connection-manager button {
min-height: 2.5rem;
}
@media (max-width: 480px) {
.connection-manager-list > li {
align-items: flex-start;
flex-direction: column;
}
}
@@ -1,139 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { ConnectionManager } from './connection-manager'
import type { Connection } from './model'
const actions = vi.hoisted(() => ({
load: vi.fn(),
authorize: vi.fn(),
disconnect: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('../mastodon/server-functions', () => ({
loadMastodonInstances: actions.load,
startMastodonOAuth: actions.authorize,
disconnectMastodonAccount: actions.disconnect,
}))
const account: Connection = {
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
}
beforeEach(() => {
vi.resetAllMocks()
actions.load.mockResolvedValue({
origins: ['https://mastodon.invalid', 'https://second.invalid'],
})
})
function show(
connections: Connection[] = [account],
onChanged = vi.fn(),
hasTemporaryDecks = false,
) {
return render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ConnectionManager
connections={connections}
onChanged={onChanged}
hasTemporaryDecks={hasTemporaryDecks}
/>
</QueryClientProvider>,
)
}
it('shows per-account status and warns about temporary decks without blocking actions', async () => {
show(
[
account,
{ ...account, id: 'expired', displayName: '@expired', status: 'expired' },
],
vi.fn(),
true,
)
expect(screen.getByText('接続済み')).toBeVisible()
expect(screen.getByText('再接続が必要')).toBeVisible()
expect(screen.getByText(/一時デッキは消えます/)).toBeVisible()
expect(
await screen.findByRole('button', { name: 'Mastodonで認可する' }),
).toBeEnabled()
})
it('uses the selected instance and reports a safe error when authorization fails', async () => {
actions.authorize.mockRejectedValue(
new Error('private server credential detail'),
)
show()
fireEvent.change(await screen.findByLabelText('Mastodonサーバー'), {
target: { value: 'https://second.invalid' },
})
fireEvent.click(screen.getByRole('button', { name: 'Mastodonで認可する' }))
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' },
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'認可を開始できませんでした',
)
expect(screen.queryByText(/private server/)).toBeNull()
expect(
screen.getByRole('button', { name: 'Mastodonで認可する' }),
).toBeEnabled()
})
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
actions.authorize.mockRejectedValue(new Error('offline'))
show()
await screen.findByLabelText('Mastodonサーバー')
fireEvent.click(screen.getByRole('button', { name: '@firstを再接続' }))
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: account.origin, connectionId: account.id },
}),
)
expect(await screen.findByRole('alert')).toBeVisible()
})
it('refreshes the account list after successful disconnect', async () => {
const onChanged = vi.fn().mockResolvedValue(undefined)
actions.disconnect.mockResolvedValue({ disconnected: true })
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
expect(actions.disconnect).toHaveBeenCalledWith({
data: { connectionId: account.id },
})
})
it('keeps account controls available after a failed disconnect', async () => {
actions.disconnect.mockRejectedValue(new Error('private token'))
const onChanged = vi.fn()
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'接続を解除できませんでした',
)
expect(onChanged).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: '@firstの接続を解除' }),
).toBeEnabled()
})
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
await screen.findByLabelText('Mastodonサーバー')
expect(screen.queryByRole('button', { name: '@firstを再接続' })).toBeNull()
expect(
screen.queryByRole('button', { name: '@firstの接続を解除' }),
).toBeNull()
})
@@ -1,177 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { useId, useState } from 'react'
import {
disconnectMastodonAccount,
loadMastodonInstances,
startMastodonOAuth,
} from '../mastodon/server-functions'
import type { Connection } from './model'
import './connection-manager.css'
const statusNames = {
connected: '接続済み',
disconnected: '未接続',
expired: '再接続が必要',
}
export function ConnectionManager({
connections,
onChanged,
hasTemporaryDecks = false,
}: {
connections: Connection[]
onChanged: () => unknown
hasTemporaryDecks?: boolean
}) {
const fetchInstances = useServerFn(loadMastodonInstances)
const authorize = useServerFn(startMastodonOAuth)
const disconnect = useServerFn(disconnectMastodonAccount)
const instances = useQuery({
queryKey: ['mastodon-instances'],
queryFn: () => fetchInstances(),
retry: false,
})
const [selected, setSelected] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const selectId = useId()
const origin = selected || instances.data?.origins[0] || ''
async function connect(origin: string, connectionId?: string) {
setBusy(true)
setError(undefined)
try {
const result = await authorize({
data: { origin, ...(connectionId ? { connectionId } : {}) },
})
window.location.assign(result.authorizationUrl)
} catch {
setError(
'Mastodonの認可を開始できませんでした。接続先を確認して再試行してください。',
)
} finally {
setBusy(false)
}
}
async function remove(connectionId: string) {
setBusy(true)
setError(undefined)
try {
await disconnect({ data: { connectionId } })
try {
await onChanged()
} catch {
setError(
'接続は解除しましたが、一覧を更新できませんでした。画面を再読み込みしてください。',
)
}
} catch {
setError('接続を解除できませんでした。時間をおいて再試行してください。')
} finally {
setBusy(false)
}
}
return (
<section className="connection-manager" aria-label="接続アカウント管理">
<p className="connection-manager-description">
追加したアカウントは、どの端末のカラムからも選べます。
</p>
{error && <p role="alert">{error}</p>}
{connections.length === 0 ? (
<p>接続アカウントはまだありません。</p>
) : (
<ul className="connection-manager-list">
{connections.map((connection) => (
<li key={connection.id}>
<div className="connection-manager-account">
<strong>{connection.displayName}</strong>
<span>
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{new URL(connection.origin).hostname}
</span>
<span
className={`connection-manager-status connection-manager-status-${connection.status}`}
>
{statusNames[connection.status]}
</span>
</div>
{connection.platform === 'mastodon' && (
<div className="connection-manager-actions">
<button
type="button"
disabled={busy}
aria-label={`${connection.displayName}を再接続`}
onClick={() =>
void connect(connection.origin, connection.id)
}
>
再接続
</button>
{connection.status !== 'disconnected' && (
<button
type="button"
disabled={busy}
aria-label={`${connection.displayName}の接続を解除`}
onClick={() => void remove(connection.id)}
>
接続解除
</button>
)}
</div>
)}
</li>
))}
</ul>
)}
<form
className="connection-manager-add"
onSubmit={(event) => {
event.preventDefault()
if (origin) void connect(origin)
}}
>
<h3>Mastodonアカウントを追加</h3>
{instances.isPending ? (
<p role="status">接続先を確認しています…</p>
) : instances.isError ? (
<p role="alert">
接続先を取得できませんでした。
<button type="button" onClick={() => void instances.refetch()}>
再試行
</button>
</p>
) : instances.data.origins.length === 0 ? (
<p>利用するMastodonサーバーがまだ設定されていません。</p>
) : (
<>
<label htmlFor={selectId}>Mastodonサーバー</label>
<select
id={selectId}
value={origin}
onChange={(event) => setSelected(event.target.value)}
disabled={busy}
>
{instances.data.origins.map((value) => (
<option key={value} value={value}>
{new URL(value).hostname}
</option>
))}
</select>
<button type="submit" disabled={busy || !origin}>
Mastodonで認可する
</button>
</>
)}
{hasTemporaryDecks && (
<p className="connection-manager-note">
認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。
</p>
)}
{busy && <p role="status">処理しています…</p>}
</form>
</section>
)
}
@@ -1,97 +0,0 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto'
import { readFileSync } from 'node:fs'
import { z } from 'zod'
const envelopeSchema = z
.object({
version: z.literal(1),
keyId: z.string(),
iv: z.string(),
tag: z.string(),
ciphertext: z.string(),
})
.strict()
function decodeBase64(value: string): Buffer {
const result = Buffer.from(value, 'base64')
if (result.toString('base64') !== value) throw new Error('Invalid encoding')
return result
}
function loadKey() {
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.')
let encoded: string
try {
encoded = readFileSync(path, 'utf8').trim()
} catch {
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.')
}
let key: Buffer
try {
key = decodeBase64(encoded)
if (key.length !== 32) throw new Error('Invalid key length')
} catch {
throw new Error(
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.',
)
}
return {
key,
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32),
}
}
function associatedData(context: string, keyId: string) {
if (!context) throw new Error('A credential record and purpose are required.')
return Buffer.from(
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
)
}
/** Context must identify both record and purpose; persist the returned opaque JSON. */
export function encryptCredential(plaintext: string, context: string): string {
const { key, keyId } = loadKey()
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
cipher.setAAD(associatedData(context, keyId))
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
return JSON.stringify({
version: 1,
keyId,
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
})
}
export function decryptCredential(serialized: string, context: string): string {
const { key, keyId } = loadKey()
try {
const envelope = envelopeSchema.parse(JSON.parse(serialized))
if (envelope.keyId !== keyId) throw new Error('Different key')
const iv = decodeBase64(envelope.iv)
const tag = decodeBase64(envelope.tag)
if (iv.length !== 12 || tag.length !== 16)
throw new Error('Invalid envelope')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAAD(associatedData(context, keyId))
decipher.setAuthTag(tag)
return Buffer.concat([
decipher.update(decodeBase64(envelope.ciphertext)),
decipher.final(),
]).toString('utf8')
} catch {
throw new Error(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
}
}
@@ -1,126 +0,0 @@
// @vitest-environment node
import { randomBytes } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { decryptCredential, encryptCredential } from './credentials.server'
let directory: string
let keyPath: string
const context = 'connection:first:access-token'
const secret = 'sensitive-token-秘密'
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-credentials-'))
keyPath = join(directory, 'key')
writeFileSync(keyPath, `${randomBytes(32).toString('base64')}\n`, {
mode: 0o600,
})
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', keyPath)
})
afterEach(() => {
vi.unstubAllEnvs()
rmSync(directory, { recursive: true, force: true })
})
it('round-trips credentials with distinct randomized ciphertext and no plaintext', () => {
const first = encryptCredential(secret, context)
const second = encryptCredential(secret, context)
expect(first).not.toBe(second)
expect(first).not.toContain(secret)
expect(first).not.toContain(Buffer.from(secret).toString('base64'))
expect(JSON.parse(first)).toMatchObject({
version: 1,
keyId: expect.stringMatching(/^[a-f0-9]{32}$/),
})
expect(decryptCredential(first, context)).toBe(secret)
expect(decryptCredential(second, context)).toBe(secret)
})
it.each([
'connection:second:access-token',
'oauth-app:first:client-secret',
])('rejects ciphertext moved to another record or purpose: %s', (otherContext) => {
const stored = encryptCredential(secret, context)
expect(() => decryptCredential(stored, otherContext)).toThrow(
'Credential could not be decrypted.',
)
})
it.each([
'iv',
'tag',
'ciphertext',
'keyId',
'version',
])('rejects tampering with %s without disclosing the secret', (field) => {
const envelope = JSON.parse(encryptCredential(secret, context))
envelope[field] = 'tampered'
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted. Check the stored credential and encryption key.',
)
})
it('rejects a validly encoded altered authentication tag', () => {
const envelope = JSON.parse(encryptCredential(secret, context))
const tag = Buffer.from(envelope.tag, 'base64')
tag[0] = (tag[0] ?? 0) ^ 1
envelope.tag = tag.toString('base64')
expect(() => decryptCredential(JSON.stringify(envelope), context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires the original key after restart or restore', () => {
const originalKey = readFileSync(keyPath)
const stored = encryptCredential(secret, context)
writeFileSync(keyPath, randomBytes(32).toString('base64'))
expect(() => decryptCredential(stored, context)).toThrow(
'Credential could not be decrypted.',
)
writeFileSync(keyPath, originalKey)
expect(decryptCredential(stored, context)).toBe(secret)
})
it.each([
'not-json',
'{}',
'{"version":2}',
])('rejects malformed or unsupported stored credentials: %s', (serialized) => {
expect(() => decryptCredential(serialized, context)).toThrow(
'Credential could not be decrypted.',
)
})
it('requires an explicitly configured credential key', () => {
vi.stubEnv('TWITTER_LITE_CREDENTIAL_KEY_FILE', '')
expect(() => encryptCredential(secret, context)).toThrow(
'TWITTER_LITE_CREDENTIAL_KEY_FILE is required.',
)
})
it('reports an unreadable key without including the path or plaintext', () => {
rmSync(keyPath)
expect(() => encryptCredential(secret, context)).toThrow(
'Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.',
)
})
it.each([
'',
'this is not base64',
Buffer.alloc(16).toString('base64'),
])('rejects invalid key material', (encoded) => {
writeFileSync(keyPath, encoded)
expect(() => encryptCredential(secret, context)).toThrow(
'must contain 32 random bytes encoded as base64.',
)
})
it('refuses encryption without record binding', () => {
expect(() => encryptCredential(secret, '')).toThrow(
'A credential record and purpose are required.',
)
})
-12
View File
@@ -1,12 +0,0 @@
import { z } from 'zod'
const connectionSchema = z.object({
id: z.string().min(1),
platform: z.enum(['twitter', 'mastodon']),
origin: z.string().url(),
accountId: z.string().nullable(),
displayName: z.string(),
status: z.enum(['connected', 'disconnected', 'expired']),
})
export type Connection = z.infer<typeof connectionSchema>
@@ -1,125 +0,0 @@
import { randomUUID } from 'node:crypto'
import { asc, eq } from 'drizzle-orm'
import { ProfileUnavailableError } from '../profiles/errors'
import { fetchProfileNames } from '../profiles/profile-service.server'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections } from '../storage/schema'
import type { Connection } from './model'
function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL
if (!configured)
throw new ProfileUnavailableError(
'TWITTER_RELAY_BASE_URL を設定してください。',
)
return new URL(configured).origin
}
async function syncTwitterConnections(
database: AppDatabase,
fetchProfiles: typeof fetchProfileNames,
) {
const origin = relayOrigin()
const profiles = new Set(
await fetchProfiles(origin).catch(() => {
throw new ProfileUnavailableError(
'接続プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
)
}),
)
database.transaction((tx) => {
const existing = tx
.select()
.from(connections)
.where(eq(connections.platform, 'twitter'))
.all()
const now = Date.now()
for (const connection of existing) {
const status =
connection.origin === origin &&
connection.relayProfile !== null &&
profiles.has(connection.relayProfile)
? 'connected'
: 'disconnected'
if (connection.status !== status) {
tx.update(connections)
.set({ status, updatedAt: now })
.where(eq(connections.id, connection.id))
.run()
}
}
const known = new Set(
existing
.filter((connection) => connection.origin === origin)
.map((connection) => connection.relayProfile),
)
for (const profile of profiles) {
if (known.has(profile)) continue
tx.insert(connections)
.values({
id: randomUUID(),
platform: 'twitter',
origin,
relayProfile: profile,
displayName: profile,
status: 'connected',
createdAt: now,
updatedAt: now,
})
.run()
}
})
return origin
}
const publicFields = {
id: connections.id,
platform: connections.platform,
origin: connections.origin,
accountId: connections.accountId,
displayName: connections.displayName,
status: connections.status,
}
export async function listConnections(
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<{ connections: Connection[]; relayError?: string }> {
let relayError: string | undefined
try {
await syncTwitterConnections(database, fetchProfiles)
} catch (error) {
if (!(error instanceof ProfileUnavailableError)) throw error
relayError = error.message
}
const saved = database
.select(publicFields)
.from(connections)
.orderBy(asc(connections.createdAt), asc(connections.id))
.all()
return { connections: saved, ...(relayError ? { relayError } : {}) }
}
export async function requireTwitterConnection(
id: string,
database = getDatabase(),
fetchProfiles = fetchProfileNames,
): Promise<string> {
const origin = await syncTwitterConnections(database, fetchProfiles)
const connection = database
.select()
.from(connections)
.where(eq(connections.id, id))
.get()
if (
connection?.platform !== 'twitter' ||
connection.origin !== origin ||
connection.status !== 'connected' ||
!connection.relayProfile
) {
throw new ProfileUnavailableError(
'この接続は利用できません。カラムの接続アカウントを確認してください。',
)
}
return connection.relayProfile
}
-157
View File
@@ -1,157 +0,0 @@
// @vitest-environment node
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connectionCredentials, connections } from '../storage/schema'
import { listConnections, requireTwitterConnection } from './repository.server'
let database: AppDatabase
beforeEach(() => {
database = openDatabase(':memory:')
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://relay.invalid/')
})
afterEach(() => {
database.$client.close()
vi.unstubAllEnvs()
})
it('retains stable distinct IDs for multiple relay profiles across repeated discovery', async () => {
const fetchProfiles = vi.fn().mockResolvedValue(['first', 'second', 'first'])
const { connections: first } = await listConnections(database, fetchProfiles)
const { connections: second } = await listConnections(database, fetchProfiles)
expect(first).toHaveLength(2)
expect(new Set(first.map((connection) => connection.id)).size).toBe(2)
expect(second).toEqual(first)
expect(
await requireTwitterConnection(first[0]?.id ?? '', database, fetchProfiles),
).toBe(first[0]?.displayName)
expect(fetchProfiles).toHaveBeenCalledWith('https://relay.invalid')
})
it('preserves unavailable bindings and reconnects the original ID when a profile returns', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
const id = original[0]?.id ?? ''
expect((await listConnections(database, async () => [])).connections).toEqual(
[expect.objectContaining({ id, status: 'disconnected' })],
)
await expect(
requireTwitterConnection(id, database, async () => []),
).rejects.toThrow('この接続は利用できません')
expect(
(await listConnections(database, async () => ['first'])).connections,
).toEqual(original)
})
it('does not reuse an old relay binding for the same profile at another origin', async () => {
const { connections: original } = await listConnections(
database,
async () => ['first'],
)
vi.stubEnv('TWITTER_RELAY_BASE_URL', 'https://another-relay.invalid')
const { connections: current } = await listConnections(database, async () => [
'first',
])
expect(current).toHaveLength(2)
expect(current).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: original[0]?.id, status: 'disconnected' }),
expect.objectContaining({
origin: 'https://another-relay.invalid',
status: 'connected',
}),
]),
)
await expect(
requireTwitterConnection(original[0]?.id ?? '', database, async () => [
'first',
]),
).rejects.toThrow('この接続は利用できません')
})
it('does not convert a relay outage into persisted account removal', async () => {
const original = await listConnections(database, async () => ['first'])
const unavailable = vi
.fn()
.mockRejectedValue(new Error('private network detail'))
const failed = await listConnections(database, unavailable)
expect(failed.connections).toEqual(original.connections)
expect(failed.relayError).toContain(
'Relay への接続を確認して再試行してください。',
)
expect(database.select().from(connections).get()?.status).toBe('connected')
expect(await listConnections(database, async () => ['first'])).toEqual(
original,
)
})
it('returns public metadata for all platforms without joining or exposing credentials', async () => {
database
.insert(connections)
.values({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
database
.insert(connectionCredentials)
.values({
connectionId: 'mastodon-account',
encryptedToken: 'stored-secret-envelope',
updatedAt: 1,
})
.run()
const { connections: discovered } = await listConnections(
database,
async () => ['first'],
)
expect(discovered[0]).toEqual({
id: 'mastodon-account',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '42',
displayName: '@owner',
status: 'connected',
})
expect(Object.keys(discovered[1] ?? {}).sort()).toEqual([
'accountId',
'displayName',
'id',
'origin',
'platform',
'status',
])
expect(JSON.stringify(discovered)).not.toContain('stored-secret-envelope')
const outage = await listConnections(database, async () => {
throw new Error('offline')
})
expect(outage.connections).toEqual(discovered)
expect(outage.relayError).toBeDefined()
await expect(
requireTwitterConnection('mastodon-account', database, async () => [
'first',
]),
).rejects.toThrow('この接続は利用できません')
expect(
database
.select()
.from(connections)
.where(eq(connections.id, 'mastodon-account'))
.get()?.status,
).toBe('connected')
})
it('rejects an unknown connection without treating its ID as a relay profile', async () => {
await expect(
requireTwitterConnection('first', database, async () => ['first']),
).rejects.toThrow('この接続は利用できません')
})
@@ -1,8 +0,0 @@
import { createServerFn } from '@tanstack/react-start'
export const loadConnections = createServerFn({ method: 'GET' }).handler(
async () => {
const { listConnections } = await import('./repository.server')
return listConnections()
},
)
-177
View File
@@ -1,177 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import type { Connection } from '../connections/model'
import { ColumnEditor } from './column-editor'
const lists = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('#/features/platforms/mastodon-server-functions', () => ({
loadMastodonLists: lists,
}))
vi.mock('#/features/posts/use-list-choices', () => ({
useListChoices: () => ({
isPending: false,
isError: false,
data: [],
refetch: vi.fn(),
}),
}))
const connections: Connection[] = [
{
id: 'twitter',
platform: 'twitter',
origin: 'https://relay.invalid',
accountId: null,
displayName: 'Twitter account',
status: 'connected',
},
{
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
},
{
id: 'mastodon-second',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '2',
displayName: '@second',
status: 'connected',
},
]
beforeEach(() => {
lists
.mockReset()
.mockImplementation(
async ({ data }: { data: { connectionId: string } }) => ({
lists: [
{
id: data.connectionId === 'mastodon-first' ? '10' : '20',
name: data.connectionId,
isPrivate: true,
},
],
}),
)
})
function show() {
const onSave = vi.fn().mockResolvedValue(true)
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<ColumnEditor
connections={connections}
onSave={onSave}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('カラム名'), {
target: { value: '調査' },
})
return onSave
}
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-first' },
})
expect(screen.queryByLabelText('表示順')).toBeNull()
expect(screen.queryByLabelText('フォロー中のみ')).toBeNull()
expect(screen.getByText(/結果が0件でも/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'mastodon-first',
source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' },
}),
),
)
})
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
target: { value: 'hashtag' },
})
fireEvent.change(screen.getByLabelText('ハッシュタグ'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
}),
),
)
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'twitter' },
})
expect(screen.getByLabelText('カラムの種類')).toHaveValue('search')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull()
})
it('loads lists for each account and clears the selected list when the binding changes', async () => {
show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
target: { value: 'list' },
})
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('リスト')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
target: { value: 'mastodon-second' },
})
expect(screen.getByLabelText('リスト')).toHaveValue('')
fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }),
)
expect(screen.getByLabelText('リスト')).toHaveValue('20')
expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' },
})
})
it('preserves title and Twitter search options after a rejected save', async () => {
const save = show()
save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitterの検索条件'), {
target: { value: 'from:owner WebMCP' },
})
fireEvent.change(screen.getByLabelText('表示順'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue(
'from:owner WebMCP',
)
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
})
-133
View File
@@ -1,133 +0,0 @@
import { useId, useState } from 'react'
import type { Connection } from '#/features/connections/model'
import {
ColumnSourceEditor,
defaultColumnSource,
rebindColumnSource,
} from './column-source-editor'
import { columnSchema, type DeckColumn } from './model'
export function ColumnEditor({
column,
connections,
onSave,
onCancel,
}: {
column?: DeckColumn
connections: Connection[]
onSave: (column: DeckColumn) => Promise<boolean>
onCancel: () => void
}) {
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const formId = useId()
const [error, setError] = useState<string>()
const [connectionId, setConnectionId] = useState(
column?.connectionId ?? available[0]?.id ?? '',
)
const [source, setSource] = useState(
column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'),
)
const validConnection = available.some(
(connection) => connection.id === connectionId,
)
return (
<form
className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
onSubmit={async (event) => {
event.preventDefault()
setError(undefined)
const data = new FormData(event.currentTarget)
const parsed = columnSchema.safeParse({
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
connectionId,
source,
})
if (!parsed.success || !validConnection) {
setError(
parsed.success
? '接続プロファイルを選択してください。'
: parsed.error.issues[0]?.message,
)
return
}
await onSave(parsed.data)
}}
>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
カラム名
<input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
placeholder="例:発表元の投稿"
required
/>
</label>
<label htmlFor={`${formId}-profile`}>
接続プロファイル
<select
id={`${formId}-profile`}
value={connectionId}
required
onChange={(event) => {
const connection = available.find(
(value) => value.id === event.target.value,
)
if (!connection) return
setConnectionId(connection.id)
setSource(rebindColumnSource(source, connection.platform))
}}
>
<option value="" disabled>
選択してください
</option>
{connectionId && !validConnection && (
<option value={connectionId} disabled>
{connectionId}(利用不可)
</option>
)}
{available.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
</option>
))}
</select>
</label>
</div>
<ColumnSourceEditor
source={source}
connectionId={validConnection ? connectionId : ''}
onChange={setSource}
/>
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
</p>
{error && (
<p role="alert" className="deck-error">
{error}
</p>
)}
<div className="deck-actions">
<button
type="submit"
className="deck-primary"
disabled={!validConnection}
>
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
キャンセル
</button>
</div>
</form>
)
}
@@ -1,69 +0,0 @@
import type { ReactElement } from 'react'
import type { Connection } from '../connections/model'
import {
defaultMastodonSource,
MastodonSourceEditor,
} from './mastodon-source-editor'
import type { DeckColumn } from './model'
import {
defaultTwitterSource,
TwitterSourceEditor,
} from './twitter-source-editor'
type Source = DeckColumn['source']
export function defaultColumnSource(platform: Connection['platform']): Source {
switch (platform) {
case 'twitter':
return defaultTwitterSource()
case 'mastodon':
return defaultMastodonSource()
}
}
/** Account-local targets never carry across bindings; compatible searches may. */
export function rebindColumnSource(
source: Source,
platform: Connection['platform'],
): Source {
if (source.platform === platform) {
return source.kind === 'search' ? source : { ...source, target: '' }
}
switch (platform) {
case 'twitter':
return defaultTwitterSource(
source.kind === 'hashtag' ? 'search' : source.kind,
)
case 'mastodon':
return defaultMastodonSource(source.kind)
}
}
export function ColumnSourceEditor({
source,
connectionId,
onChange,
}: {
source: Source
connectionId: string
onChange: (source: Source) => void
}): ReactElement {
switch (source.platform) {
case 'twitter':
return (
<TwitterSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'mastodon':
return (
<MastodonSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
}
}
-117
View File
@@ -1,117 +0,0 @@
import { useEffect, useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { z } from 'zod'
import {
flattenResearchPages,
type useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
type ColumnFeed = {
column: DeckColumn
query: ReturnType<typeof useResearchFeed>
}
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof useResearchFeed>,
) {
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 = flattenResearchPages(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,
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 = flattenResearchPages(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)
}),
})
}
-115
View File
@@ -1,115 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import type { CitationTarget } from '../research/use-citation-navigation'
import { ResearchColumn } from './deck-column'
const feed = vi.hoisted(() => ({ use: vi.fn(), next: vi.fn() }))
vi.mock('../platforms/use-research-feed', async (importOriginal) => ({
...(await importOriginal<typeof import('../platforms/use-research-feed')>()),
useResearchFeed: feed.use,
}))
vi.mock('./research-post-card', () => ({
ResearchPostCard: ({
post,
citationSnapshot,
}: {
post: ResearchPost
citationSnapshot?: boolean
}) => (
<article>
{post.text}
{citationSnapshot ? '調査時に取得した投稿' : ''}
</article>
),
}))
const citation: CitationTarget = {
deckId: 'deck',
request: 1,
column: {
id: 'column',
title: 'Research',
connectionId: 'account',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
post: {
key: 'twitter:123',
platform: 'twitter',
nativeId: '123',
url: 'https://x.com/alice/status/123',
text: 'Snapshot',
author: { name: 'Alice', handle: 'alice' },
},
}
it('temporarily renders missing evidence without inserting it into feed pages or changing pagination', () => {
const page = Object.freeze({
posts: Object.freeze([]),
nextCursor: 'original-cursor',
})
feed.use.mockReturnValue({
data: { pages: [page] },
hasNextPage: true,
fetchNextPage: feed.next,
})
render(
<ResearchColumn
column={citation.column}
citation={citation}
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByRole('article')).toHaveTextContent(
'Snapshot調査時に取得した投稿',
)
expect(page.posts).toHaveLength(0)
expect(page.nextCursor).toBe('original-cursor')
fireEvent.click(screen.getByRole('button', { name: '続きを読み込む' }))
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
})
it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias', () => {
feed.use.mockReturnValue({
data: {
pages: [
{
posts: [
{
...citation.post,
url: 'https://mobile.twitter.com/alice/status/123',
text: 'Live',
},
],
},
],
},
})
render(
<ResearchColumn
column={citation.column}
citation={citation}
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('article')).toHaveTextContent('Live')
expect(
screen.queryByText(/Snapshot|調査時に取得した投稿/),
).not.toBeInTheDocument()
})
-192
View File
@@ -1,192 +0,0 @@
import { Icon } from '#/components/icon'
import {
flattenResearchPages,
useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import { citationUrlKey } from '#/features/research/citation-url'
import type { CitationTarget } from '#/features/research/use-citation-navigation'
import { PlatformIcon } from '../platforms/platform-icon'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
import { describeSource } from './source-description'
export function ResearchColumn({
column,
connectionLabel,
onEdit,
onRemove,
onMove,
first,
last,
registry,
citation,
}: {
column: DeckColumn
connectionLabel?: string
onEdit: () => void
onRemove: () => void
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
registry: ColumnRegistry
citation?: CitationTarget
}) {
const query = useResearchFeed(column)
useRegisterColumn(registry, column, query)
const posts = flattenResearchPages(query.data?.pages ?? [])
const citedKey = citation ? citationUrlKey(citation.post.url) : null
const visiblePosts =
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
? [citation.post, ...posts]
: posts
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source)
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
<div className="deck-column-title">
<PlatformIcon platform={column.source.platform} />
<h2 id={headingId}>{column.title}</h2>
<button
className="icon-button"
aria-label="更新"
title="更新"
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
>
<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={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
>
{connectionLabel ?? '接続が見つかりません'}
</span>
<span className="deck-source-label">
<Icon name={column.source.kind} />
{sourceDescription.label}
</span>
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
: `${posts.length}件を読み込み済み`}
</p>
</header>
<section
className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
tabIndex={0}
aria-label={`${column.title}の投稿`}
>
{visiblePosts.map((post) => (
<ResearchPostCard
key={post.key}
post={post}
citationRequest={
citation && citationUrlKey(post.url) === citedKey
? citation.request
: undefined
}
citationSnapshot={citation?.post === post && !posts.includes(post)}
/>
))}
{query.isError ? (
<div className="deck-message" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button
type="button"
disabled={query.isFetching}
onClick={() =>
void (query.isFetchNextPageError
? query.fetchNextPage({ cancelRefetch: false })
: query.refetch())
}
>
再試行
</button>
) : null}
</div>
) : null}
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
<p className="deck-message">条件に一致する投稿はありません。</p>
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message">
<button
type="button"
disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
>
続きを読み込む
</button>
</div>
) : null}
{!query.isPending &&
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
<p className="deck-message">これ以上の投稿はありません。</p>
) : null}
</section>
</section>
)
}
-413
View File
@@ -1,413 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useLocation } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { ConnectionManager } from '#/features/connections/connection-manager'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel'
import {
sameCitationSource,
useCitationNavigation,
} from '#/features/research/use-citation-navigation'
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 oauthResult = useLocation({
select: (location) =>
new URLSearchParams(location.searchStr).get('mastodon'),
})
const registry = useRef<ColumnRegistry>(new Map()).current
const researchDeckBindings = useRef(new Map<string, string>()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [managingConnections, setManagingConnections] = useState(false)
const [switcherEditing, setSwitcherEditing] = useState(false)
const {
deck,
workspace,
ready,
saving,
persist,
createTemporary,
refresh,
importLegacy,
legacyAvailable,
storageError,
save,
create,
select,
remove,
getWorkspace,
} = useDeck(editing !== null || renaming || switcherEditing)
const citationNavigation = useCitationNavigation({
registry,
getWorkspace,
select,
createTemporary,
})
const fetchProfiles = useServerFn(loadConnections)
const profiles = useQuery({
queryKey: ['connections'],
queryFn: () => fetchProfiles(),
retry: false,
})
useColumnTools(registry, ready)
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
useDeckTools({
ready,
saving,
storageError,
save,
select,
remove,
getWorkspace,
createTemporary,
persist,
connections: profiles.data?.connections,
onApplied: clearEditors,
})
function clearEditors() {
setEditing(null)
setRenaming(false)
setRemoved(undefined)
}
async function saveColumn(column: DeckColumn) {
const saved = await save({
...deck,
columns:
editing === 'new'
? [...deck.columns, column]
: deck.columns.map((current) =>
current.id === column.id ? column : current,
),
})
if (saved) setEditing(null)
return saved
}
return (
<AppShell
researchChat={
<ResearchPanel
onNavigateCitation={citationNavigation.navigate}
ready={ready}
contextDeck={{
id: deck.id,
title: deck.title,
columns: deck.columns,
}}
connections={profiles.data?.connections ?? []}
onOpenDeck={async (plan, activate = false) => {
await syncResearchDeck(plan, activate, researchDeckBindings, {
getWorkspace,
createTemporary,
save,
select,
})
if (activate) clearEditors()
}}
/>
}
sidebar={
<>
<DeckSwitcher
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready && !saving}
onEditingChange={setSwitcherEditing}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
}}
/>
<button
className="deck-primary sidebar-add"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<button
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</button>
</>
}
>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<button
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</button>
) : (
<button
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</button>
)}
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</button>
</div>
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<button
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<button type="button" onClick={() => void profiles.refetch()}>
再試行
</button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
<button
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</button>
</p>
) : null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await 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 ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
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">
<span>「{removed.column.title}」を削除しました。</span>
<button
type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
</button>
</div>
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<Icon name="columns" />
<h2>調べたいことから、デッキを作りましょう</h2>
<p>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
</p>
<button
className="deck-primary"
type="button"
onClick={() => setEditing('new')}
>
手動でカラムを追加
</button>
</div>
) : null}
{ready && deck.columns.length > 0 ? (
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
citation={
citationNavigation.target?.deckId === deck.id &&
citationNavigation.target.column.id === column.id &&
sameCitationSource(citationNavigation.target.column, column)
? citationNavigation.target
: undefined
}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (typeof editing === 'object' && editing?.id === column.id)
setEditing(null)
}}
/>
))}
</section>
) : null}
</AppShell>
)
}
-128
View File
@@ -1,128 +0,0 @@
import { useEffect, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import type { DeckView } from './model'
export function DeckSwitcher({
decks,
activeId,
ready,
onSelect,
onCreate,
onDelete,
onEditingChange,
}: {
decks: DeckView[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean>
onEditingChange: (editing: boolean) => void
}) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
onEditingChange(creating || deleting)
return () => onEditingChange(false)
}, [creating, deleting, onEditingChange])
return (
<div className="deck-workspaces">
<div className="deck-actions">
<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}
{deck.persisted ? '' : '(一時)'}
</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}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
</button>
</div>
{creating ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await 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={async () => {
if (await onDelete()) setDeleting(false)
}}
>
削除する
</button>
<button type="button" onClick={() => setDeleting(false)}>
キャンセル
</button>
</div>
</Dialog>
) : null}
</div>
)
}
-495
View File
@@ -1,495 +0,0 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.app-deck button {
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(--surface);
cursor: pointer;
white-space: nowrap;
}
.app-deck button:hover:not(:disabled) {
background: var(--color-paper-3);
}
.app-deck button:active:not(:disabled) {
color: var(--accent);
}
.app-deck button:disabled {
opacity: 0.45;
cursor: default;
}
.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: center;
justify-content: space-between;
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: 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;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
}
.deck-help,
.deck-save-status,
.deck-column-status {
color: var(--secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.deck-save-status {
margin: 0;
padding: 0 var(--space-md) var(--space-xs);
}
.deck-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 328px;
justify-content: start;
flex: 1;
min-height: 0;
gap: var(--space-xs);
overflow-x: auto;
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;
background: var(--paper);
scroll-snap-align: start;
}
.deck-column-header {
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 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;
gap: var(--space-xs);
color: var(--secondary);
font-size: var(--text-xs);
}
.deck-column-label span {
min-width: 0;
overflow-wrap: anywhere;
}
.deck-source-label {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
}
.deck-source-label svg {
width: 14px;
height: 14px;
}
.deck-query {
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: var(--space-2xs) 0 0;
font-variant-numeric: tabular-nums;
}
.deck-column-results {
min-height: 0;
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);
font-size: var(--text-sm);
line-height: 1.6;
}
.research-post {
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-xs);
}
.research-post header > div {
min-width: 0;
}
.research-post header img {
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong {
font-size: var(--text-sm);
}
.research-post-text,
.research-post blockquote p {
white-space: pre-wrap;
line-height: 1.55;
font-size: var(--text-sm);
margin: var(--space-xs) 0;
}
.research-post blockquote {
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-xs);
margin-top: var(--space-sm);
color: var(--secondary);
font-size: var(--text-xs);
}
.research-post a {
color: var(--accent);
text-underline-offset: 0.2em;
}
.research-media {
display: block;
width: 100%;
max-height: 20rem;
object-fit: contain;
margin-block: var(--space-xs);
border-radius: var(--radius-sm);
}
.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;
}
.deck-workspaces > .deck-actions {
flex-wrap: nowrap;
gap: var(--space-2xs);
}
.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 (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);
}
}
.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);
}
-80
View File
@@ -1,80 +0,0 @@
import { createHash, randomUUID } from 'node:crypto'
import { and, eq } from 'drizzle-orm'
import { getDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { legacyWorkspaceSchema } from './legacy-import'
import {
createDeck,
DeckPersistenceError,
listDecks,
} from './repository.server'
export function importLegacyWorkspace(
input: unknown,
database = getDatabase(),
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin,
) {
const legacy = legacyWorkspaceSchema.parse(input)
const payloadHash = createHash('sha256')
.update(JSON.stringify(legacy.decks))
.digest('hex')
return database.transaction(
() => {
const marker = database
.select()
.from(legacyImports)
.where(eq(legacyImports.id, 'local-storage-v2'))
.get()
if (marker) {
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。',
)
return listDecks(database)
}
const available = database
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'twitter'),
eq(connections.origin, relayOrigin),
eq(connections.status, 'connected'),
),
)
.all()
const imported = legacy.decks.map((deck) =>
createDeck(
{
...deck,
id: randomUUID(),
columns: deck.columns.map(({ profileName, ...column }) => {
const connection = available.find(
(connection) => connection.relayProfile === profileName,
)
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`,
)
return { ...column, connectionId: connection.id }
}),
},
database,
),
)
database
.insert(legacyImports)
.values({
id: 'local-storage-v2',
payloadHash,
deckIds: imported.map((deck) => deck.id),
createdAt: Date.now(),
})
.run()
return listDecks(database)
},
{ behavior: 'immediate' },
)
}
-98
View File
@@ -1,98 +0,0 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { importLegacyWorkspace } from './import.server'
import { createDeck, listDecks } from './repository.server'
let database: AppDatabase
const legacy = {
version: 2,
activeDeckId: 'same-id',
decks: [
{
id: 'same-id',
title: 'Legacy',
columns: [
{
id: 'a',
title: 'Account',
profileName: 'old-profile',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
},
],
},
],
}
beforeEach(() => {
database = openDatabase(':memory:')
database
.insert(connections)
.values({
id: 'new-connection-id',
platform: 'twitter',
origin: 'https://relay.invalid',
relayProfile: 'old-profile',
displayName: 'Old account',
status: 'connected',
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => {
const existing = createDeck(
{ id: 'same-id', title: 'Existing server deck', columns: [] },
database,
)
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(result).toHaveLength(2)
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
const imported = result.find((deck) => deck.title === 'Legacy')
expect(imported?.id).not.toBe('same-id')
expect(imported?.columns[0]).toEqual({
id: 'a',
title: 'Account',
connectionId: 'new-connection-id',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
})
expect(
importLegacyWorkspace(legacy, database, 'https://relay.invalid'),
).toEqual(result)
})
it('blocks a different browser snapshot after the one-time import', () => {
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] },
database,
'https://relay.invalid',
),
).toThrow('旧デッキはすでに取り込み済みです')
expect(listDecks(database)).toEqual(result)
})
it('rolls back all imported decks and marker when any profile is unavailable', () => {
const second = {
...legacy.decks[0],
id: 'second',
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }],
}
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [...legacy.decks, second] },
database,
'https://relay.invalid',
),
).toThrow('対応する接続がありません')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
-32
View File
@@ -1,32 +0,0 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck'
const legacyColumn = columnSchema
.omit({ connectionId: true })
.extend({ profileName: z.string().min(1) })
const legacyDeck = z
.object({
id: deckSchema.shape.id,
title: deckSchema.shape.title,
columns: z.array(legacyColumn).max(MAX_COLUMNS),
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
)
export const legacyWorkspaceSchema = z
.object({
version: z.literal(2),
activeDeckId: z.string(),
decks: z.array(legacyDeck).min(1).max(100),
})
.strict()
.refine(
(value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'デッキ ID が重複しています。',
)
@@ -1,230 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react'
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source'
type EditorProps<T extends MastodonSource = MastodonSource> = {
source: T
onChange: (source: MastodonSource) => void
}
export function defaultMastodonSource(
kind: MastodonSource['kind'] = 'search',
): MastodonSource {
switch (kind) {
case 'search':
return { platform: 'mastodon', kind, query: '' }
case 'user':
return { platform: 'mastodon', kind, target: '' }
case 'list':
return { platform: 'mastodon', kind, target: '' }
case 'hashtag':
return { platform: 'mastodon', kind, target: '' }
}
}
export function MastodonSourceEditor({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
return (
<>
<label htmlFor={id}>
カラムの種類
<select
id={id}
value={source.kind}
onChange={(event) =>
onChange(
defaultMastodonSource(
event.target.value as MastodonSource['kind'],
),
)
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
<option value="hashtag">ハッシュタグ</option>
</select>
</label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</>
)
}
function MastodonSourceFields({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <MastodonSearchEditor source={source} onChange={onChange} />
case 'user':
return <MastodonUserEditor source={source} onChange={onChange} />
case 'list':
return (
<MastodonListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'hashtag':
return <MastodonHashtagEditor source={source} onChange={onChange} />
}
}
function MastodonSearchEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
Mastodonの検索条件
<input
id={id}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP"
required
/>
</label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
</p>
</div>
)
}
function MastodonUserEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ユーザー
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="user@server またはアカウントID"
maxLength={256}
required
/>
</label>
</div>
)
}
function MastodonHashtagEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ハッシュタグ
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="# を除いたタグ名"
maxLength={128}
required
/>
</label>
</div>
)
}
function MastodonListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & {
connectionId: string
}) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
リスト
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="このアカウントのリストID"
maxLength={32}
required
/>
</label>
{connectionId && (
<MastodonListChoices
connectionId={connectionId}
onSelect={(target) => onChange({ ...source, target })}
/>
)}
</div>
)
}
function MastodonListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
}) {
const fetchLists = useServerFn(loadMastodonLists)
const lists = useQuery({
queryKey: ['mastodon-lists', connectionId],
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section className="deck-list-choices" aria-label="このアカウントのリスト">
{lists.data.lists.length === 0 ? (
<p>このアカウントのリストはありません。</p>
) : (
lists.data.lists.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
</button>
))
)}
</section>
)
}
-105
View File
@@ -1,105 +0,0 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
const deck: Deck = {
id: 'research',
title: '調査',
columns: ['a', 'b'].map((id) => ({
id,
title: id,
connectionId: 'main',
source: {
platform: 'twitter',
kind: 'search',
query: 'AI lang:ja',
product: 'Latest',
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native search and the bound relay profile when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
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,
columns: [deck.columns[0], deck.columns[0]],
}).success,
).toBe(false)
expect(
deckSchema.safeParse({
...deck,
columns: [{ ...deck.columns[0], connectionId: '' }],
}).success,
).toBe(false)
})
it('rejects queries exceeding the upstream limit after adding follows', () => {
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...deck.columns[0],
source: {
platform: 'twitter',
kind: 'search',
query: 'a'.repeat(512),
product: 'Latest',
following: true,
},
},
],
}).success,
).toBe(false)
})
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],
])
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b'])
expect(moveColumn(deck, 'a', -1)).toBe(deck)
})
})
-124
View File
@@ -1,124 +0,0 @@
import { z } from 'zod'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import {
buildSearchQuery,
normalizeListTarget,
normalizeUserTarget,
} from '#/features/posts/inputs'
export const MAX_COLUMNS = 6
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(),
})
.strict()
.superRefine((source, ctx) => {
try {
buildSearchQuery(source.query, source.following)
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error
? error.message
: '検索条件を確認してください。',
})
}
})
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),
connectionId: z.string().min(1),
source: z.union([
z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
mastodonSourceSchema,
]),
})
.strict()
export const deckSchema = z
.object({
id: z.string().min(1).max(128),
title: z.string().trim().min(1).max(120),
columns: z.array(columnSchema).max(MAX_COLUMNS),
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
)
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] }
export const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
activeDeckId: EMPTY_DECK.id,
decks: [{ ...EMPTY_DECK, persisted: false }],
}
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)
const target = index + direction
if (index < 0 || target < 0 || target >= deck.columns.length) return deck
const columns = [...deck.columns]
const [column] = columns.splice(index, 1)
if (column) columns.splice(target, 0, column)
return { ...deck, columns }
}
-36
View File
@@ -1,36 +0,0 @@
import { z } from 'zod'
import { deckSchema } from './model'
export const savedDeckDefinitionSchema = deckSchema
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
revision: z.number().int().positive(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
})
export const replaceDeckInputSchema = z
.object({
deck: savedDeckDefinitionSchema,
expectedRevision: z.number().int().positive(),
})
.strict()
export const deleteDeckInputSchema = z
.object({
id: deckSchema.shape.id,
expectedRevision: z.number().int().positive(),
})
.strict()
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>
export type SavedDeck = z.infer<typeof savedDeckSchema>
export type DeckPersistenceFailure = {
ok: false
error: {
code:
| 'conflict'
| 'not-found'
| 'connection-unavailable'
| 'invalid-input'
| 'storage-unavailable'
message: string
}
}
-184
View File
@@ -1,184 +0,0 @@
import { and, asc, eq } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections, deckColumns, decks } from '../storage/schema'
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
type SavedDeck,
type SavedDeckDefinition,
savedDeckDefinitionSchema,
savedDeckSchema,
} from './persistence-model'
export class DeckPersistenceError extends Error {
constructor(
readonly code: DeckPersistenceFailure['error']['code'],
message: string,
) {
super(message)
}
}
const conflict = () =>
new DeckPersistenceError(
'conflict',
'別の端末で変更されました。最新のデッキを読み直してください。',
)
const missing = () =>
new DeckPersistenceError(
'not-found',
'このデッキは削除されたか、見つかりません。',
)
export function loadDeck(
id: string,
database = getDatabase(),
): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get()
if (!record) return null
const columns = database
.select({
id: deckColumns.id,
title: deckColumns.title,
connectionId: deckColumns.connectionId,
source: deckColumns.source,
})
.from(deckColumns)
.where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position))
.all()
return savedDeckSchema.parse({ ...record, columns })
}
export function listDecks(database = getDatabase()): SavedDeck[] {
return database.transaction(() =>
database
.select({ id: decks.id })
.from(decks)
.orderBy(asc(decks.createdAt), asc(decks.id))
.all()
.map(({ id }) => {
const deck = loadDeck(id, database)
if (!deck) throw missing()
return deck
}),
)
}
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
for (const column of deck.columns) {
const connection = database
.select({ platform: connections.platform, status: connections.status })
.from(connections)
.where(eq(connections.id, column.connectionId))
.get()
if (
!connection ||
connection.platform !== column.source.platform ||
connection.status !== 'connected'
) {
throw new DeckPersistenceError(
'connection-unavailable',
'カラムの接続アカウントが利用できません。接続を確認してください。',
)
}
}
}
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run()
if (deck.columns.length)
database
.insert(deckColumns)
.values(
deck.columns.map((column, position) => ({
...column,
deckId: deck.id,
position,
})),
)
.run()
}
export function createDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input)
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (existing) {
const original = savedDeckDefinitionSchema.parse({
id: existing.id,
title: existing.title,
columns: existing.columns,
})
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict()
return existing
}
validateConnections(deck, database)
const now = Date.now()
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now }
database
.insert(decks)
.values({
id: deck.id,
title: deck.title,
revision: 1,
createdAt: now,
updatedAt: now,
})
.run()
writeColumns(deck, database)
return result
},
{ behavior: 'immediate' },
)
}
export function replaceDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (!existing) throw missing()
if (existing.revision !== expectedRevision) throw conflict()
validateConnections(deck, database)
const updatedAt = Date.now()
const result = database
.update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
writeColumns(deck, database)
return {
...deck,
revision: expectedRevision + 1,
createdAt: existing.createdAt,
updatedAt,
}
},
{ behavior: 'immediate' },
)
}
export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input)
return database.transaction(
() => {
if (!loadDeck(id, database)) throw missing()
const result = database
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
return id
},
{ behavior: 'immediate' },
)
}
-214
View File
@@ -1,214 +0,0 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
deckColumns,
} from '../storage/schema'
import type { SavedDeckDefinition } from './persistence-model'
import {
createDeck,
deleteDeck,
listDecks,
loadDeck,
replaceDeck,
} from './repository.server'
let directory: string
let database: AppDatabase
let second: AppDatabase
const deck: SavedDeckDefinition = {
id: 'request-123',
title: 'Research',
columns: [
{
id: 'first',
title: 'First',
connectionId: 'twitter-one',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
{
id: 'second',
title: 'Second',
connectionId: 'twitter-two',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
},
],
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-'))
database = openDatabase(join(directory, 'app.db'))
second = openDatabase(join(directory, 'app.db'))
database
.insert(connections)
.values(
['twitter-one', 'twitter-two'].map((id) => ({
id,
platform: 'twitter' as const,
origin: 'https://relay.invalid',
relayProfile: id,
displayName: id,
status: 'connected' as const,
createdAt: 1,
updatedAt: 1,
})),
)
.run()
})
afterEach(() => {
second.$client.close()
database.$client.close()
rmSync(directory, { recursive: true })
})
it('shares deck order and per-column account bindings with a separate database client', () => {
const saved = createDeck(deck, database)
expect(saved).toMatchObject({ ...deck, revision: 1 })
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(listDecks(second)).toEqual([saved])
})
it('makes retried creation idempotent but rejects different content using the same ID', () => {
const saved = createDeck(deck, database)
expect(createDeck(deck, second)).toEqual(saved)
expect(() =>
createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('別の端末で変更されました')
expect(listDecks(database)).toEqual([saved])
})
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => {
const initial = createDeck(deck, database)
const reversed = {
...deck,
title: 'Changed',
columns: [...deck.columns].reverse(),
}
const saved = replaceDeck(
{ deck: reversed, expectedRevision: initial.revision },
database,
)
expect(saved).toMatchObject({
...reversed,
revision: 2,
createdAt: initial.createdAt,
})
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'別の端末で変更されました',
)
expect(loadDeck(deck.id, second)).toEqual(saved)
})
it('clears all columns and prevents stale deletion from another device', () => {
createDeck(deck, database)
const saved = replaceDeck(
{ deck: { ...deck, columns: [] }, expectedRevision: 1 },
database,
)
expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('別の端末で変更されました')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
})
it('cascades deleted deck columns and reports already-deleted decks', () => {
createDeck(deck, database)
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'このデッキは削除されたか',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('このデッキは削除されたか')
})
it.each([
'disconnected',
'expired',
] as const)('rejects %s connections without partially saving a deck', (status) => {
const initial = createDeck(deck, database)
database
.update(connections)
.set({ status })
.where(eq(connections.id, 'twitter-two'))
.run()
expect(() =>
replaceDeck(
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('カラムの接続アカウントが利用できません')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
it('rejects a connection from another platform', () => {
database
.update(connections)
.set({ platform: 'mastodon' })
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
)
expect(listDecks(database)).toEqual([])
})
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
)
expect(listDecks(database)).toEqual([])
})
it('rejects duplicate column IDs without changing the saved version', () => {
const saved = createDeck(deck, database)
expect(() =>
replaceDeck(
{
deck: { ...deck, columns: [deck.columns[0], deck.columns[0]] },
expectedRevision: 1,
},
database,
),
).toThrow('カラム ID が重複しています')
expect(loadDeck(deck.id, database)).toEqual(saved)
})
it('returns only deck definitions and revision metadata, never credential data', () => {
database
.insert(connectionCredentials)
.values({
connectionId: 'twitter-one',
encryptedToken: 'secret-envelope',
updatedAt: 1,
})
.run()
createDeck(deck, database)
const loaded = listDecks(database)
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
'columns',
'createdAt',
'id',
'revision',
'title',
'updatedAt',
])
expect(JSON.stringify(loaded)).not.toContain('secret-envelope')
expect(JSON.stringify(loaded)).not.toContain('relayProfile')
})
@@ -1,87 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import { ResearchPostCard } from './research-post-card'
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('focuses and briefly highlights a cited card without revealing sensitive content', () => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', () => ({ matches: true }))
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
sensitive: true,
}
const { rerender } = render(<ResearchPostCard post={post} />)
const article = screen.getByRole('article')
const scroll = vi.fn()
article.scrollIntoView = scroll
rerender(
<ResearchPostCard post={post} citationRequest={1} citationSnapshot />,
)
expect(article).toHaveFocus()
expect(article).toHaveClass('citation-highlight')
expect(scroll).toHaveBeenCalledWith({
block: 'center',
inline: 'center',
behavior: 'instant',
})
expect(screen.getByText('調査時に取得した投稿')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '内容を表示' })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus()
expect(screen.queryByText('調査時に取得した投稿')).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(2500)
})
expect(article).not.toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} citationRequest={2} />)
expect(article).toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} />)
expect(article).not.toHaveClass('citation-highlight')
})
it('waits for reveal before mounting CW content or sensitive media', () => {
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
html: '<p>Hidden <strong>content</strong></p>',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
sensitive: true,
boostedBy: { name: 'Bob', handle: '[email protected]' },
media: [
{
type: 'photo',
url: 'https://social.invalid/image.png',
alt: 'Hidden image',
},
],
}
render(<ResearchPostCard post={post} />)
expect(screen.getByText('Bob がブースト')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '内容を表示' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: '内容を隠す' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
})
-147
View File
@@ -1,147 +0,0 @@
import { useEffect, useRef, useState } from 'react'
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', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo',
})
export function ResearchPostCard({
post,
citationRequest,
citationSnapshot,
}: {
post: ResearchPost
citationRequest?: number
citationSnapshot?: boolean
}) {
const [revealed, setRevealed] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const article = useRef<HTMLElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: Re-scroll when a snapshot moves to its live feed position.
useEffect(() => {
if (citationRequest === undefined) {
setHighlighted(false)
return
}
setHighlighted(true)
article.current?.focus({ preventScroll: true })
article.current?.scrollIntoView({
block: 'center',
inline: 'center',
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? 'instant'
: 'smooth',
})
const timer = setTimeout(() => setHighlighted(false), 2500)
return () => clearTimeout(timer)
}, [citationRequest, citationSnapshot])
const concealed = Boolean(post.contentWarning || post.sensitive)
return (
<article
ref={article}
tabIndex={-1}
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
data-post-url={post.url}
>
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
) : null}
<header>
{post.author.avatarUrl ? (
<img
src={post.author.avatarUrl}
alt=""
width={36}
height={36}
loading="lazy"
/>
) : null}
<div>
<strong>{post.author.name}</strong>
<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>
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<button
type="button"
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? '内容を隠す' : '内容を表示'}
</button>
</div>
) : null}
{!concealed || revealed ? (
<>
{post.html !== undefined ? (
<div
className="research-post-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }}
/>
) : (
<p className="research-post-text">
<PostText text={post.text} />
</p>
)}
{post.media?.map((media) =>
media.type === 'photo' ? (
<img
className="research-media"
key={media.url}
src={media.url}
alt={media.alt ?? ''}
loading="lazy"
/>
) : (
<a
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
>
動画を開く
</a>
),
)}
{post.quotedPost ? (
<blockquote>
<strong>{post.quotedPost.author.name}</strong>
<p>
<PostText text={post.quotedPost.text} />
</p>
<a
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
引用元を開く
</a>
</blockquote>
) : null}
</>
) : null}
<footer>
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く <Icon name="arrow" />
</a>
</footer>
</article>
)
}
-60
View File
@@ -1,60 +0,0 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { legacyWorkspaceSchema } from './legacy-import'
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
savedDeckDefinitionSchema,
} from './persistence-model'
async function run<T>(
operation: (
repository: typeof import('./repository.server'),
) => T | Promise<T>,
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
const repository = await import('./repository.server')
try {
return { ok: true, data: await operation(repository) }
} catch (error) {
if (error instanceof repository.DeckPersistenceError)
return { ok: false, error: { code: error.code, message: error.message } }
if (error instanceof z.ZodError)
return {
ok: false,
error: {
code: 'invalid-input',
message: 'デッキの入力内容を確認してください。',
},
}
return {
ok: false,
error: {
code: 'storage-unavailable',
message: 'デッキを保存・取得できませんでした。再試行してください。',
},
}
}
}
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() =>
run((repository) => repository.listDecks()),
)
export const createSavedDeck = createServerFn({ method: 'POST' })
.validator(savedDeckDefinitionSchema)
.handler(({ data }) => run((repository) => repository.createDeck(data)))
export const replaceSavedDeck = createServerFn({ method: 'POST' })
.validator(replaceDeckInputSchema)
.handler(({ data }) => run((repository) => repository.replaceDeck(data)))
export const deleteSavedDeck = createServerFn({ method: 'POST' })
.validator(deleteDeckInputSchema)
.handler(({ data }) => run((repository) => repository.deleteDeck(data)))
export const importBrowserDecks = createServerFn({ method: 'POST' })
.validator(legacyWorkspaceSchema)
.handler(({ data }) =>
run(async () => {
const { importLegacyWorkspace } = await import('./import.server')
return importLegacyWorkspace(data)
}),
)
-37
View File
@@ -1,37 +0,0 @@
import type { DeckColumn } from './model'
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
export function describeSource(source: DeckColumn['source']): {
label: string
query: string
} {
switch (source.platform) {
case 'twitter':
switch (source.kind) {
case 'search':
return {
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`,
query: source.query,
}
case 'user':
return { label: 'ユーザー投稿', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
}
break
case 'mastodon':
switch (source.kind) {
case 'search':
return { label: '全文検索', query: source.query }
case 'user':
return { label: 'ユーザー投稿', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
case 'hashtag':
return { label: 'ハッシュタグ', query: `#${source.target}` }
}
break
}
const exhaustive: never = source
return exhaustive
}
@@ -1,223 +0,0 @@
import { type ReactElement, useId } from 'react'
import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model'
type Source = Extract<DeckColumn['source'], { platform: 'twitter' }>
type EditorProps<T extends Source = Source> = {
source: T
onChange: (source: Source) => void
}
export function defaultTwitterSource(kind: Source['kind'] = 'search'): Source {
switch (kind) {
case 'search':
return {
platform: 'twitter',
kind,
query: '',
product: 'Latest',
following: false,
}
case 'user':
return { platform: 'twitter', kind, target: '' }
case 'list':
return { platform: 'twitter', kind, target: '' }
}
}
export function TwitterSourceEditor({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
return (
<>
<label htmlFor={id}>
カラムの種類
<select
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
</>
)
}
function TwitterSourceFields({
source,
connectionId,
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <TwitterSearchEditor source={source} onChange={onChange} />
case 'user':
return <TwitterUserEditor source={source} onChange={onChange} />
case 'list':
return (
<TwitterListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
}
}
function TwitterSearchEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'search' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={`${id}-query`}>
Twitterの検索条件
<input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${id}-product`}>
表示順
<select
id={`${id}-product`}
value={source.product}
onChange={(event) =>
onChange({
...source,
product: event.target.value as 'Latest' | 'Top',
})
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
checked={source.following}
onChange={(event) =>
onChange({ ...source, following: event.target.checked })
}
/>
フォロー中のみ
</label>
</div>
)
}
function TwitterUserEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'user' }>>) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
ユーザー
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="@handle またはプロフィールURL"
maxLength={256}
required
/>
</label>
</div>
)
}
function TwitterListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<Source, { kind: 'list' }>> & { connectionId: string }) {
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
リスト
<input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="リストのURLまたはID"
maxLength={256}
required
/>
</label>
{connectionId && (
<TwitterListChoices
connectionId={connectionId}
onSelect={(target) => onChange({ ...source, target })}
/>
)}
</div>
)
}
function TwitterListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
}) {
const lists = useListChoices(connectionId)
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
再試行
</button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
</button>
))
)}
</section>
)
}
-303
View File
@@ -1,303 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { LEGACY_STORAGE_KEY } from './legacy-import'
import type { SavedDeck } from './persistence-model'
import { ACTIVE_DECK_KEY, useDeck } from './use-deck'
const server = vi.hoisted(() => ({
load: vi.fn(),
create: vi.fn(),
replace: vi.fn(),
remove: vi.fn(),
import: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
loadSavedDecks: server.load,
createSavedDeck: server.create,
replaceSavedDeck: server.replace,
deleteSavedDeck: server.remove,
importBrowserDecks: server.import,
}))
const saved: SavedDeck = {
id: 'saved',
title: 'Shared research',
columns: [],
revision: 1,
createdAt: 1,
updatedAt: 1,
}
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
server.load.mockResolvedValue({ ok: true, data: [] })
server.create.mockImplementation(async ({ data }) => ({
ok: true,
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
}))
})
it('creates and edits temporary views without writing shared decks', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('AI research')
})
const id = result.current.deck.id
await act(async () => {
await result.current.save({ id, title: 'Revised research', columns: [] })
})
expect(result.current.deck).toMatchObject({
title: 'Revised research',
persisted: false,
})
expect(server.create).not.toHaveBeenCalled()
expect(server.replace).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
await act(async () => {
await result.current.persist(id)
})
expect(result.current.deck).toMatchObject({
id,
persisted: true,
revision: 1,
})
expect(server.create).toHaveBeenCalledTimes(1)
await act(async () => {
await result.current.persist(id)
})
expect(server.create).toHaveBeenCalledTimes(1)
})
it('keeps a temporary view on failed save and retries with the same identity', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Keep this')
})
const id = result.current.deck.id
server.create.mockResolvedValueOnce({
ok: false,
error: { message: 'Server disk unavailable' },
})
await act(async () => {
expect(await result.current.persist(id)).toBe(false)
})
expect(result.current.deck).toMatchObject({
id,
title: 'Keep this',
persisted: false,
})
expect(result.current.storageError).toBe('Server disk unavailable')
await act(async () => {
expect(await result.current.persist(id)).toBe(true)
})
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([
id,
id,
])
})
it('retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
server.replace.mockResolvedValue({
ok: false,
error: { message: 'Revision conflict' },
})
await act(async () => {
expect(await result.current.save({ ...saved, title: 'My draft' })).toBe(
false,
)
})
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 })
expect(result.current.storageError).toBe('Revision conflict')
expect(server.replace).toHaveBeenCalledWith({
data: {
deck: { id: saved.id, title: 'My draft', columns: [] },
expectedRevision: 1,
},
})
})
it('refreshes on focus but holds remote updates while a form is being edited', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result, rerender } = renderHook(({ editing }) => useDeck(editing), {
initialProps: { editing: false },
})
await waitFor(() => expect(result.current.ready).toBe(true))
rerender({ editing: true })
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Remote edit', revision: 2 }],
})
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(server.load).toHaveBeenCalledTimes(1)
expect(result.current.deck.title).toBe(saved.title)
rerender({ editing: false })
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(result.current.deck).toMatchObject({
title: 'Remote edit',
revision: 2,
})
})
it('keeps the active deck local and copies saved content into a separate temporary view', async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: 'second', title: 'Second' }],
})
localStorage.setItem(ACTIVE_DECK_KEY, 'second')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.deck.id).toBe('second'))
act(() => {
result.current.createTemporary('A copy', result.current.deck.columns)
})
const copy = result.current.deck
expect(copy.persisted).toBe(false)
expect(copy.id).not.toBe('second')
await act(async () => {
await result.current.save({ ...copy, title: 'Changed copy' })
})
expect(
result.current.workspace.decks.find((deck) => deck.id === 'second')?.title,
).toBe('Second')
expect(server.replace).not.toHaveBeenCalled()
})
it('does not import old browser data without an explicit action', async () => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [{ id: 'old', title: 'Old deck', columns: [] }],
}
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy))
server.import.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.legacyAvailable).toBe(true)
expect(server.import).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy))
await act(async () => {
await result.current.importLegacy()
})
expect(server.import).toHaveBeenCalledWith({ data: legacy })
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
})
it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('読み込めませんでした')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
it('recognizes a committed save after a lost response without duplicating its temporary view', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Lost response')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(
result.current.workspace.decks.filter((deck) => deck.id === view.id),
).toHaveLength(1)
expect(result.current.deck.persisted).toBe(true)
})
it('preserves a divergent temporary edit when a lost-response save appears remotely', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
act(() => {
result.current.createTemporary('Before request')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
await act(async () => {
await result.current.persist(view.id)
})
await act(async () => {
await result.current.save({ ...view, title: 'Edited after failure' })
})
server.load.mockResolvedValue({
ok: true,
data: [
{
id: view.id,
title: view.title,
columns: view.columns,
revision: 1,
createdAt: 1,
updatedAt: 1,
},
],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck).toMatchObject({
title: 'Edited after failure',
persisted: false,
})
expect(result.current.deck.id).not.toBe(view.id)
expect(
result.current.workspace.decks.find((deck) => deck.id === view.id),
).toMatchObject({ title: 'Before request', persisted: true })
})
it('ignores an older refresh response after a newer request has completed', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
let resolveOlder: (value: unknown) => void = () => {}
server.load.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve
}),
)
act(() => {
void result.current.refresh()
})
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Newest', revision: 3 }],
})
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck.revision).toBe(3)
await act(async () => {
resolveOlder({
ok: true,
data: [{ ...saved, title: 'Older', revision: 2 }],
})
})
expect(result.current.deck).toMatchObject({ title: 'Newest', revision: 3 })
})
-335
View File
@@ -1,335 +0,0 @@
import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from './legacy-import'
import {
type Deck,
type DeckView,
deckSchema,
EMPTY_DECK,
EMPTY_WORKSPACE,
newId,
type Workspace,
} from './model'
import type { SavedDeck } from './persistence-model'
import {
createSavedDeck,
deleteSavedDeck,
importBrowserDecks,
loadSavedDecks,
replaceSavedDeck,
} from './server-functions'
export const ACTIVE_DECK_KEY = 'twitter-lite-active-deck'
function asView(deck: SavedDeck): DeckView {
return {
id: deck.id,
title: deck.title,
columns: deck.columns,
revision: deck.revision,
persisted: true,
}
}
function definition(deck: Deck): Deck {
return deckSchema.parse({
id: deck.id,
title: deck.title,
columns: deck.columns,
})
}
export function useDeck(editing = false) {
const services = {
load: useServerFn(loadSavedDecks),
create: useServerFn(createSavedDeck),
replace: useServerFn(replaceSavedDeck),
remove: useServerFn(deleteSavedDeck),
import: useServerFn(importBrowserDecks),
}
const servicesRef = useRef(services)
servicesRef.current = services
const editingRef = useRef(editing)
editingRef.current = editing
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const initialized = useRef(false)
const [storageError, setStorageError] = useState<string>()
const [saving, setSaving] = useState(false)
const busy = useRef(false)
const generation = useRef(0)
const refreshSequence = useRef(0)
const [legacyAvailable, setLegacyAvailable] = useState(false)
const apply = useCallback((next: Workspace) => {
current.current = next
setWorkspace(next)
try {
localStorage.setItem(ACTIVE_DECK_KEY, next.activeDeckId)
} catch {
/* Selection is a device-local preference, not saved deck data. */
}
}, [])
const reconcile = useCallback(
(saved: SavedDeck[]) => {
let preferred = current.current.activeDeckId
let temporary = current.current.decks.filter((deck) => !deck.persisted)
if (!initialized.current)
temporary = saved.length
? []
: [{ ...EMPTY_DECK, id: newId(), persisted: false }]
temporary = temporary.flatMap((view) => {
const accepted = saved.find((deck) => deck.id === view.id)
if (!accepted) return [view]
if (
JSON.stringify(definition(accepted)) ===
JSON.stringify(definition(view))
)
return []
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'保存済みの内容と異なる編集を、一時コピーとして残しました。',
)
return [{ ...view, id }]
})
const all = [...saved.map(asView), ...temporary]
if (!all.length)
all.push({ ...EMPTY_DECK, id: newId(), persisted: false })
if (!initialized.current) {
try {
preferred = localStorage.getItem(ACTIVE_DECK_KEY) ?? preferred
} catch {
/* Optional local preference. */
}
}
const activeDeckId = all.some((deck) => deck.id === preferred)
? preferred
: (all[0]?.id ?? '')
if (initialized.current && activeDeckId !== preferred)
setStorageError('表示中のデッキが別の端末で削除されました。')
initialized.current = true
apply({ activeDeckId, decks: all })
},
[apply],
)
const refresh = useCallback(
async (force = false) => {
if (busy.current || (!force && editingRef.current)) return false
const requestGeneration = generation.current
const requestSequence = ++refreshSequence.current
try {
const result = await servicesRef.current.load()
if (
requestGeneration !== generation.current ||
requestSequence !== refreshSequence.current ||
busy.current ||
(!force && editingRef.current)
)
return false
if (!result.ok) throw new Error(result.error.message)
if (force) setStorageError(undefined)
reconcile(result.data)
setReady(true)
return true
} catch {
if (
requestSequence !== refreshSequence.current ||
requestGeneration !== generation.current
)
return false
setStorageError(
'保存済みデッキを読み込めませんでした。再試行してください。',
)
return false
}
},
[reconcile],
)
useEffect(() => {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
legacyWorkspaceSchema.parse(JSON.parse(legacy))
setLegacyAvailable(true)
}
} catch {
setStorageError(
'旧デッキを読み込めませんでした。ブラウザの保存内容は変更していません。',
)
}
void refresh()
const onFocus = () => {
if (document.visibilityState === 'visible') void refresh()
}
const timer = setInterval(onFocus, 5000)
window.addEventListener('focus', onFocus)
return () => {
clearInterval(timer)
window.removeEventListener('focus', onFocus)
}
}, [refresh])
async function mutate<T>(
operation: () => Promise<T>,
accept: (value: T) => void,
) {
if (busy.current) {
setStorageError('保存処理が完了するまでお待ちください。')
return false
}
busy.current = true
generation.current += 1
setSaving(true)
try {
const result = await operation()
accept(result)
setStorageError(undefined)
return true
} catch (error) {
setStorageError(
error instanceof Error
? error.message
: '保存できませんでした。再試行してください。',
)
return false
} finally {
busy.current = false
setSaving(false)
}
}
function upsert(view: DeckView) {
const previous = current.current
apply({
activeDeckId: view.id,
decks: previous.decks.some((deck) => deck.id === view.id)
? previous.decks.map((deck) => (deck.id === view.id ? view : deck))
: [...previous.decks, view],
})
}
async function save(next: Deck, expectedRevision?: number) {
const parsed = definition(next)
const existing = current.current.decks.find((deck) => deck.id === parsed.id)
if (!existing?.persisted) {
if (busy.current) return false
generation.current += 1
upsert({ ...parsed, persisted: false })
return true
}
return mutate(
async () => {
const result = await servicesRef.current.replace({
data: {
deck: parsed,
expectedRevision: expectedRevision ?? existing.revision ?? 0,
},
})
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => upsert(asView(result)),
)
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
apply({ ...current.current, activeDeckId: id })
return true
}
function createTemporary(title: string, columns: Deck['columns'] = []) {
const view = {
...definition({ id: newId(), title, columns }),
persisted: false,
}
upsert(view)
return view
}
async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
if (view.persisted) return true
return mutate(
async () => {
const result = await servicesRef.current.create({
data: definition(view),
})
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => upsert(asView(result)),
)
}
async function create(title: string) {
const view = createTemporary(title)
return persist(view.id)
}
async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。')
const accept = () => {
const previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id)
if (!decks.length)
decks.push({ ...EMPTY_DECK, id: newId(), persisted: false })
apply({
activeDeckId:
previous.activeDeckId === id
? (decks[0]?.id ?? '')
: previous.activeDeckId,
decks,
})
}
if (!view.persisted) {
if (busy.current) return false
accept()
return true
}
return mutate(async () => {
const result = await servicesRef.current.remove({
data: { id, expectedRevision: expectedRevision ?? view.revision ?? 0 },
})
if (!result.ok) throw new Error(result.error.message)
}, accept)
}
async function importLegacy() {
return mutate(
async () => {
const data = legacyWorkspaceSchema.parse(
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? 'null'),
)
const result = await servicesRef.current.import({ data })
if (!result.ok) throw new Error(result.error.message)
return result.data
},
(result) => {
reconcile(result)
setLegacyAvailable(false)
try {
localStorage.removeItem(LEGACY_STORAGE_KEY)
} catch {
/* Server marker prevents duplicate imports on retry. */
}
},
)
}
return {
deck: workspace.decks.find(
(deck) => deck.id === workspace.activeDeckId,
) ?? { ...EMPTY_DECK, persisted: false },
workspace,
ready,
saving,
storageError,
legacyAvailable,
save,
create,
createTemporary,
persist,
select,
remove,
refresh,
importLegacy,
getWorkspace: () => current.current,
}
}
@@ -1,8 +0,0 @@
import { useEffect, useState } from 'react'
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
}
-140
View File
@@ -1,140 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Connection } from '#/features/connections/model'
import { prepareDeck } from './webmcp-contracts'
const account = (id: string): Connection => ({
id,
platform: 'twitter',
origin: 'https://relay.invalid',
accountId: null,
displayName: id,
status: 'connected',
})
const column = {
title: '観点',
connectionId: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
account('main'),
])
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
kind: 'search',
query: 'AI',
product: 'Latest',
following: false,
})
expect(
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] },
[account('main')],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
expect(() =>
prepareDeck({ title: '調査', columns: [identified, identified] }, [
account('main'),
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, [account('work')]),
).toThrow('Unavailable connection')
})
it('accepts user and list columns bound to separate profiles', () => {
const deck = prepareDeck(
{
title: '調査',
columns: [
{ ...column, source: { kind: 'user', target: '@alice' } },
{
...column,
connectionId: 'work',
source: { kind: 'list', target: '123' },
},
],
},
[account('main'), account('work')],
)
expect(deck.columns.map((column) => column.connectionId)).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: [
{
...column,
source: { kind: 'search', platform: 'nostr', query: 'AI' },
},
],
},
[account('main')],
),
).toThrow()
expect(() =>
prepareDeck(
{
title: '調査',
columns: [
{
...column,
source: {
kind: 'search',
query: 'a'.repeat(512),
following: true,
},
},
],
},
[account('main')],
),
).toThrow()
expect(() =>
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
[account('main')],
),
).toThrow()
})
})
it('builds a mixed-platform deck with explicit account bindings', () => {
const deck = prepareDeck(
{
title: 'Mixed',
columns: [
column,
{
title: 'Mastodon tag',
connectionId: 'masto',
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
},
],
},
[account('main'), { ...account('masto'), platform: 'mastodon' }],
)
expect(
deck.columns.map((column) => [column.connectionId, column.source.platform]),
).toEqual([
['main', 'twitter'],
['masto', 'mastodon'],
])
})
-128
View File
@@ -1,128 +0,0 @@
import { z } from 'zod'
import type { Connection } from '#/features/connections/model'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing column ID when editing; omit for a new column.',
),
source: z.union([
mastodonSourceSchema,
z.discriminatedUnion('kind', [
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('search'),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']).default('Latest'),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('user'),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('list'),
target: z.string().min(1).max(256),
})
.strict(),
]),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
export const deleteDeckToolInput = deckIdInput
.extend({ expectedRevision: z.number().int().positive().optional() })
.strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
export const setDeckInput = z
.object({
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.',
),
expectedRevision: z
.number()
.int()
.positive()
.optional()
.describe(
'Required when replacing a saved deck; omit for a temporary view.',
),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.',
),
})
.strict()
export function prepareDeck(
input: unknown,
connections: readonly Connection[],
) {
const parsed = setDeckInput.parse(input)
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
columns: parsed.columns.map((column) => ({
...column,
id: column.id ?? newId(),
})),
})
for (const column of deck.columns) {
if (
!connections.some(
(connection) =>
connection.id === column.connectionId &&
connection.platform === column.source.platform &&
connection.status === 'connected',
)
)
throw new InputError(`Unavailable connection: ${column.connectionId}`)
}
return deck
}
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,
}
}
}

Some files were not shown because too many files have changed in this diff Show More