Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d808381b | ||
|
|
c43278f785 | ||
|
|
1794255ebb | ||
|
|
24939e6f00 | ||
|
|
f5274195b9 | ||
|
|
8679a850e5 | ||
|
|
766d13dfcc | ||
|
|
604c7f16e2 | ||
|
|
83992bd962 | ||
|
|
234f4354f9 | ||
|
|
6a4f7ca068 | ||
|
|
d8827b6513 | ||
|
|
f82f6641b2 | ||
|
|
acc4fe841b | ||
|
|
fa26ce18d7 | ||
|
|
4efd709234 | ||
|
|
da72094e07 | ||
|
|
8e6ff70d81 | ||
|
|
1eb2733b38 | ||
|
|
45cee54cff |
@@ -1,5 +1,6 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
/.*/skills/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
.output/
|
.output/
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-07-26",
|
||||||
|
"macrostructure": "Workbench",
|
||||||
|
"theme": "Garden",
|
||||||
|
"enrichment": "none",
|
||||||
|
"brief": "Personal read-only research client with a soft, deliberate workspace"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"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": ["routes", "information architecture", "read-only behavior"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
|
||||||
@@ -1,40 +1,50 @@
|
|||||||
# Twitter Lite
|
# Twitter Lite
|
||||||
|
|
||||||
Twitter Lite is an intentional, read-only X reader. It stays empty until you
|
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||||
enter a user handle, profile URL, or search query.
|
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
|
## Scope
|
||||||
|
|
||||||
- User timelines and raw X search syntax
|
- User timelines and raw X search syntax
|
||||||
- Popular (`Top`) and chronological (`Latest`) search
|
- Popular (`Top`) and chronological (`Latest`) search
|
||||||
- Optional `filter:follows` search
|
- Optional `filter:follows` search
|
||||||
|
- Authenticated account list selection and list timelines from a deliberate URL or ID
|
||||||
- Infinite cursor pagination with explicit retry
|
- 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
|
- Read-only cards for text, media, quotes, articles, and quiet engagement counts
|
||||||
|
|
||||||
It intentionally has no home feed, recommendations, trends, notifications,
|
It intentionally has no home feed, recommendations, trends, notifications,
|
||||||
history, account directory, or write actions.
|
history, account directory, or write actions.
|
||||||
|
|
||||||
|
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
|
## Requirements and setup
|
||||||
|
|
||||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The package link also
|
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||||
requires a local Bird checkout at `../bird`, exactly at unpublished commit
|
routes the `@yuta` scope to the public Gitea Packages registry, where Bird
|
||||||
`81921226f099e7f7a2107be15fc9783218a6f400`.
|
`0.10.0` provides the required Top and Latest search interface.
|
||||||
|
|
||||||
A fresh clone of the remote Bird repository alone does **not** contain that
|
|
||||||
local Top/Latest commit yet. Obtain the supplied checkout; do not assume the
|
|
||||||
commit or a compatible package has been published.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ../bird
|
|
||||||
git rev-parse HEAD
|
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
|
||||||
nix develop -c pnpm run build:dist
|
|
||||||
|
|
||||||
cd ../twitter-lite
|
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
nix develop -c pnpm install --frozen-lockfile
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Nix development shell installs Hallmark's agent skill for the supported
|
||||||
|
local agent targets.
|
||||||
|
|
||||||
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set
|
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
|
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
|
||||||
values in the process environment before `start` or `test:live`:
|
values in the process environment before `start` or `test:live`:
|
||||||
@@ -52,6 +62,7 @@ sent to the browser.
|
|||||||
```bash
|
```bash
|
||||||
nix develop -c pnpm dev
|
nix develop -c pnpm dev
|
||||||
nix develop -c pnpm dev:tailscale
|
nix develop -c pnpm dev:tailscale
|
||||||
|
nix develop -c pnpm knip
|
||||||
nix develop -c pnpm test
|
nix develop -c pnpm test
|
||||||
nix develop -c pnpm test:e2e
|
nix develop -c pnpm test:e2e
|
||||||
nix develop -c pnpm test:live
|
nix develop -c pnpm test:live
|
||||||
@@ -60,15 +71,53 @@ nix develop -c pnpm start
|
|||||||
```
|
```
|
||||||
|
|
||||||
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
|
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
|
||||||
the dev shell. `test:live` is an explicit, opt-in smoke test that performs
|
the dev shell. Browser tests are organized under `e2e/`: page object models
|
||||||
exactly one read-only Top search using the configured relay.
|
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.
|
||||||
|
|
||||||
Development binds to `127.0.0.1` by default. `dev:tailscale` binds to
|
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
|
`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
|
only on a trusted network and obtain the Tailscale address with
|
||||||
`tailscale ip -4`.
|
`tailscale ip -4`.
|
||||||
|
|
||||||
|
## NixOS service
|
||||||
|
|
||||||
|
The flake provides both a production package and a NixOS module. Import the
|
||||||
|
module and configure the relay:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.twitter-lite.url =
|
||||||
|
"git+https://git.yutakobayashi.com/yuta/twitter-lite";
|
||||||
|
|
||||||
|
outputs = { nixpkgs, twitter-lite, ... }: {
|
||||||
|
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
modules = [
|
||||||
|
twitter-lite.nixosModules.default
|
||||||
|
{
|
||||||
|
services.twitter-lite = {
|
||||||
|
enable = true;
|
||||||
|
relayBaseUrl = "http://127.0.0.1:6900";
|
||||||
|
# profileName = "default";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Reliability
|
||||||
|
|
||||||
Bird uses X's internal GraphQL operations through the configured relay. Query
|
Bird uses X's internal GraphQL operations through the configured relay. Query
|
||||||
IDs and response shapes can change without notice.
|
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.
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!src/routeTree.gen.ts", "!!**/.output"]
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!src/routeTree.gen.ts",
|
||||||
|
"!!**/.output",
|
||||||
|
"!!**/test-results"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"formatter": { "enabled": true, "indentStyle": "space" },
|
"formatter": { "enabled": true, "indentStyle": "space" },
|
||||||
"linter": { "enabled": true, "rules": { "recommended": true } },
|
"linter": { "enabled": true, "rules": { "recommended": true } },
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
**Architecture:** Keep Bird unchanged and compose its existing `getTweet()` and `getThreadPaged()` methods behind one new TanStack Start GET server function. The first web page resolves the focal post to its conversation root and performs two Bird reads; continuation pages carry the root ID with the opaque cursor and perform one Bird read. Reuse the established TanStack Query pagination and PostCard rendering while keeping the focal post distinct and removing duplicates from the conversation list.
|
**Architecture:** Keep Bird unchanged and compose its existing `getTweet()` and `getThreadPaged()` methods behind one new TanStack Start GET server function. The first web page resolves the focal post to its conversation root and performs two Bird reads; continuation pages carry the root ID with the opaque cursor and perform one Bird read. Reuse the established TanStack Query pagination and PostCard rendering while keeping the focal post distinct and removing duplicates from the conversation list.
|
||||||
|
|
||||||
**Tech Stack:** Node.js 22, pnpm 11.9.0, TanStack Start/Router/Query, React 19, Zod 4, linked `@yuta/bird`, Vitest, Testing Library, Playwright, Nix Chromium, hand-written CSS.
|
**Tech Stack:** Node.js 22, pnpm 11.9.0, TanStack Start/Router/Query, React 19, Zod 4, published `@yuta/bird@0.10.0`, Vitest, Testing Library, Playwright, Nix Chromium, hand-written CSS.
|
||||||
|
|
||||||
## Global Constraints
|
## Global Constraints
|
||||||
|
|
||||||
- Bird remains clean at `81921226f099e7f7a2107be15fc9783218a6f400`; do not edit, commit, push, or publish it.
|
- Use the published `@yuta/[email protected]` interface; do not modify Bird as part of this feature.
|
||||||
- Use only Bird's existing `getTweet()` and `getThreadPaged()` methods and the existing `TweetDetail` relay operation.
|
- Use only Bird's existing `getTweet()` and `getThreadPaged()` methods and the existing `TweetDetail` relay operation.
|
||||||
- Preserve the current Bird ordering: chronological within each fetched page, with cursor pages appended in retrieval order.
|
- Preserve the current Bird ordering: chronological within each fetched page, with cursor pages appended in retrieval order.
|
||||||
- Do not add a dependency, database, cookie, history, recommendation, related-post feed, mutation control, generic Bird dispatcher, or generic relay proxy.
|
- Do not add a dependency, database, cookie, history, recommendation, related-post feed, mutation control, generic Bird dispatcher, or generic relay proxy.
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
- `src/features/posts/server-functions.ts` — expose only `loadThreadPosts` as the third read-only server function.
|
- `src/features/posts/server-functions.ts` — expose only `loadThreadPosts` as the third read-only server function.
|
||||||
- `src/features/posts/use-post-feed.ts` — carry `{ cursor, conversationId }` through TanStack infinite-query page parameters.
|
- `src/features/posts/use-post-feed.ts` — carry `{ cursor, conversationId }` through TanStack infinite-query page parameters.
|
||||||
- `src/features/posts/page.ts` — extract the focal post and remove it from the deduplicated conversation.
|
- `src/features/posts/page.ts` — extract the focal post and remove it from the deduplicated conversation.
|
||||||
- `src/features/posts/components/post-card.tsx` — add an intentional internal detail link and current-post state.
|
- `src/features/posts/components/post-card.tsx` — add an intentional internal detail anchor and current-post state.
|
||||||
- `src/features/posts/components/post-feed.tsx` — render the focal card above the reusable infinite conversation feed.
|
- `src/features/posts/components/post-feed.tsx` — render the focal card above the reusable infinite conversation feed.
|
||||||
- `src/components/app-shell.tsx` — support a third route with neither global tab selected.
|
- `src/components/app-shell.tsx` — support a third route with neither global tab selected.
|
||||||
- `src/routes/status.$tweetId.tsx` — typed detail route and invalid-ID state.
|
- `src/routes/status.$tweetId.tsx` — typed detail route and invalid-ID state.
|
||||||
@@ -841,25 +841,11 @@ git commit -m "feat: paginate tweet conversations"
|
|||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: thread `FeedRequest`, `focalPostFromPages()`, and `flattenConversationPages()` from Task 2.
|
- Consumes: thread `FeedRequest`, `focalPostFromPages()`, and `flattenConversationPages()` from Task 2.
|
||||||
- Produces: `/status/$tweetId`, internal `詳細・スレッド` links, neutral shell navigation, focal-card presentation, and a reusable thread feed with existing retry/observer behavior.
|
- Produces: `/status/$tweetId`, internal `詳細・スレッド` anchors, neutral shell navigation, focal-card presentation, and a reusable thread feed with existing retry/observer behavior.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing card behavior tests**
|
- [ ] **Step 1: Write failing card behavior tests**
|
||||||
|
|
||||||
At the top of both `src/features/posts/components/post-card.test.tsx` and `src/features/posts/components/post-feed.test.tsx`, import `type ReactNode` from React and add this focused Link mock so component tests do not require a full router:
|
Add to `src/features/posts/components/post-card.test.tsx`:
|
||||||
|
|
||||||
```ts
|
|
||||||
vi.mock('@tanstack/react-router', () => ({
|
|
||||||
Link: ({
|
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
params: { tweetId: string }
|
|
||||||
}) => <a href={`/status/${params.tweetId}`}>{children}</a>,
|
|
||||||
}))
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `vi` to the card test imports and add to `post-card.test.tsx`:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
it('links deliberately to the internal detail page', () => {
|
it('links deliberately to the internal detail page', () => {
|
||||||
@@ -988,7 +974,7 @@ Expected: FAIL because detail links, current state, and focal rendering do not e
|
|||||||
|
|
||||||
- [ ] **Step 4: Add intentional internal navigation to PostCard**
|
- [ ] **Step 4: Add intentional internal navigation to PostCard**
|
||||||
|
|
||||||
In `src/features/posts/components/post-card.tsx`, import `Link` from `@tanstack/react-router`, add `current = false` to the props, and set:
|
In `src/features/posts/components/post-card.tsx`, add `current = false` to the props and set:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
<article
|
<article
|
||||||
@@ -1002,9 +988,9 @@ Within the non-quoted footer, keep the counts and external link and add:
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
{!current ? (
|
{!current ? (
|
||||||
<Link params={{ tweetId: post.id }} to="/status/$tweetId">
|
<a href={`/status/${encodeURIComponent(post.id)}`}>
|
||||||
詳細・スレッド
|
詳細・スレッド
|
||||||
</Link>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1682,24 +1668,23 @@ export const searchPosts = createServerFn({ method: 'GET' })
|
|||||||
export const loadThreadPosts = createServerFn({ method: 'GET' })
|
export const loadThreadPosts = createServerFn({ method: 'GET' })
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Verify Bird stayed unchanged and local**
|
- [ ] **Step 5: Verify the published Bird dependency is self-contained**
|
||||||
|
|
||||||
Run from `../bird`:
|
Run from Twitter Lite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git rev-parse HEAD
|
rg -n '"@yuta/bird": "0.10.0"' package.json
|
||||||
git status --short --branch
|
rg -n '@yuta/[email protected]' pnpm-lock.yaml
|
||||||
git rev-list --count origin/main..HEAD
|
rg -n 'link:../bird' package.json pnpm-lock.yaml
|
||||||
git log --oneline origin/main..HEAD
|
readlink -f node_modules/@yuta/bird
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected:
|
Expected:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
81921226f099e7f7a2107be15fc9783218a6f400
|
package.json and pnpm-lock.yaml pin 0.10.0
|
||||||
## main...origin/main [ahead 1]
|
the local-link scan has zero matches
|
||||||
1
|
node_modules resolves inside the pnpm virtual store
|
||||||
8192122 feat: support ranked tweet search
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Perform the pre-commit documentation check**
|
- [ ] **Step 6: Perform the pre-commit documentation check**
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
> Subsequent extension: tweet detail and conversation loading are implemented
|
||||||
|
> by `2026-07-13-tweet-detail-thread.md` and specified by
|
||||||
|
> `../specs/2026-07-13-tweet-detail-thread-design.md`.
|
||||||
|
|
||||||
**Goal:** Build a localhost-only TanStack Start reader that loads user timelines and Top/Latest searches through a read-only Bird server boundary.
|
**Goal:** Build a localhost-only TanStack Start reader that loads user timelines and Top/Latest searches through a read-only Bird server boundary.
|
||||||
|
|
||||||
**Architecture:** First extend the sibling Bird repository with a typed search product option. Then build Twitter Lite as a TanStack Start application whose server functions call Bird, while TanStack Query owns cursor pagination and the browser renders the Mist Instrument interface.
|
**Architecture:** First extend the sibling Bird repository with a typed search product option. Then build Twitter Lite as a TanStack Start application whose server functions call Bird, while TanStack Query owns cursor pagination and the browser renders the Mist Instrument interface.
|
||||||
@@ -19,7 +23,7 @@
|
|||||||
- Load 20 posts per cursor page and continue automatically without a fixed page limit.
|
- Load 20 posts per cursor page and continue automatically without a fixed page limit.
|
||||||
- Use Mist Instrument tokens and no remote fonts; respect keyboard focus and `prefers-reduced-motion`.
|
- Use Mist Instrument tokens and no remote fonts; respect keyboard focus and `prefers-reduced-motion`.
|
||||||
- Work test-first for every behavior change and run the pre-commit documentation check in both repositories.
|
- Work test-first for every behavior change and run the pre-commit documentation check in both repositories.
|
||||||
- Do not push Bird or publish `@yuta/bird` without separate user authorization.
|
- Consume the separately authorized and published `@yuta/bird@0.10.0`; do not modify Bird from the app repository.
|
||||||
|
|
||||||
## File Map
|
## File Map
|
||||||
|
|
||||||
@@ -381,7 +385,7 @@ git commit -m "feat: support ranked tweet search"
|
|||||||
- Create: `tests/setup.ts`
|
- Create: `tests/setup.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: local `@yuta/bird` through `link:../bird`.
|
- Consumes: published `@yuta/bird@0.10.0` through Gitea Packages.
|
||||||
- Produces: `getRouter()` with an SSR-aware QueryClient.
|
- Produces: `getRouter()` with an SSR-aware QueryClient.
|
||||||
- Produces: a buildable TanStack Start/Nitro application at `http://127.0.0.1:3000`.
|
- Produces: a buildable TanStack Start/Nitro application at `http://127.0.0.1:3000`.
|
||||||
|
|
||||||
@@ -418,6 +422,12 @@ Run `nix flake lock` to generate `flake.lock`.
|
|||||||
|
|
||||||
- [ ] **Step 2: Add pinned package and tool configuration**
|
- [ ] **Step 2: Add pinned package and tool configuration**
|
||||||
|
|
||||||
|
Create `.npmrc`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
|
||||||
|
```
|
||||||
|
|
||||||
Create `package.json`:
|
Create `package.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -447,7 +457,7 @@ Create `package.json`:
|
|||||||
"@tanstack/react-router": "1.170.17",
|
"@tanstack/react-router": "1.170.17",
|
||||||
"@tanstack/react-router-ssr-query": "1.167.1",
|
"@tanstack/react-router-ssr-query": "1.167.1",
|
||||||
"@tanstack/react-start": "1.168.27",
|
"@tanstack/react-start": "1.168.27",
|
||||||
"@yuta/bird": "link:../bird",
|
"@yuta/bird": "0.10.0",
|
||||||
"nitro": "3.0.260610-beta",
|
"nitro": "3.0.260610-beta",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
@@ -2851,7 +2861,7 @@ git commit -m "feat: apply mist instrument interface"
|
|||||||
- Verify: `docs/superpowers/specs/2026-07-13-twitter-lite-design.md`
|
- Verify: `docs/superpowers/specs/2026-07-13-twitter-lite-design.md`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: the complete app and local Bird build.
|
- Consumes: the complete app and published Bird package.
|
||||||
- Produces: deterministic desktop/mobile browser coverage and an opt-in read-only live smoke test.
|
- Produces: deterministic desktop/mobile browser coverage and an opt-in read-only live smoke test.
|
||||||
|
|
||||||
- [ ] **Step 1: Add the browser configuration**
|
- [ ] **Step 1: Add the browser configuration**
|
||||||
@@ -3133,17 +3143,11 @@ handle, profile URL, or search query.
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. Obtain the supplied local
|
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||||
Bird checkout beside this repository at unpublished commit
|
routes `@yuta` packages to Gitea Packages, and `@yuta/bird@0.10.0` contains
|
||||||
`81921226f099e7f7a2107be15fc9783218a6f400`. A fresh remote clone alone does
|
the required Top/Latest interface.
|
||||||
not contain that Top/Latest commit yet.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ../bird
|
|
||||||
git rev-parse HEAD
|
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
|
||||||
nix develop -c pnpm run build:dist
|
|
||||||
cd ../twitter-lite
|
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
nix develop -c pnpm install --frozen-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ visible conversation around it. Additional conversation pages load through the
|
|||||||
same intentional infinite-scroll behavior as the existing user and search
|
same intentional infinite-scroll behavior as the existing user and search
|
||||||
feeds.
|
feeds.
|
||||||
|
|
||||||
This is an additive extension to
|
This document originally extended `2026-07-13-twitter-lite-design.md` with the
|
||||||
`2026-07-13-twitter-lite-design.md`. Where that document lists only two routes
|
post-detail route, now `/:handle/status/:tweetId`, and the narrowly scoped
|
||||||
and two server functions, this document adds one status route and one narrowly
|
read-only server function, `loadThreadPosts`. The base design now incorporates
|
||||||
scoped read-only server function.
|
both additions.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -39,11 +39,13 @@ scoped read-only server function.
|
|||||||
Add the typed route:
|
Add the typed route:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/status/:tweetId
|
/:handle/status/:tweetId
|
||||||
```
|
```
|
||||||
|
|
||||||
`tweetId` is a non-empty decimal X post ID. Invalid IDs render a non-retryable
|
Generated links use the focal post author's normalized `handle`, and `tweetId`
|
||||||
input error and make no relay request.
|
is a non-empty decimal X post ID. Both path parameters are validated, while the
|
||||||
|
tweet lookup remains ID-based. Invalid parameters render a non-retryable input
|
||||||
|
error and make no relay request.
|
||||||
|
|
||||||
Every non-quoted post card gains an internal `詳細・スレッド` link in its
|
Every non-quoted post card gains an internal `詳細・スレッド` link in its
|
||||||
footer. The existing external `元の投稿を開く` link remains available. The
|
footer. The existing external `元の投稿を開く` link remains available. The
|
||||||
@@ -51,9 +53,9 @@ whole card is not clickable because cards can contain text links and media
|
|||||||
controls. Quoted cards retain their current compact presentation.
|
controls. Quoted cards retain their current compact presentation.
|
||||||
|
|
||||||
The status route uses the existing application shell. Neither `ユーザー` nor
|
The status route uses the existing application shell. Neither `ユーザー` nor
|
||||||
`検索` receives `aria-current` on this third route. The status page adds no
|
`検索` receives `aria-current` on the post-detail route. The status page adds
|
||||||
third global navigation tab; it is reachable only from a deliberate post link
|
no global navigation tab; it is reachable only from a deliberate post link or
|
||||||
or a manually entered URL.
|
a manually entered URL.
|
||||||
|
|
||||||
## Bird and Relay Boundary
|
## Bird and Relay Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ Twitter Lite is a localhost-only reader for intentional access to X posts. It le
|
|||||||
|
|
||||||
- enter a handle or profile URL and read that user's posts;
|
- enter a handle or profile URL and read that user's posts;
|
||||||
- search posts in popular or chronological order;
|
- search posts in popular or chronological order;
|
||||||
- optionally restrict search to accounts followed by the authenticated relay profile; and
|
- optionally restrict search to accounts followed by the authenticated relay profile;
|
||||||
|
- follow a deliberate post link or manually enter a valid `/:handle/status/:tweetId` URL
|
||||||
|
to read its visible conversation; and
|
||||||
- continue through results with infinite scrolling.
|
- continue through results with infinite scrolling.
|
||||||
|
|
||||||
The product reduces ambient discovery rather than limiting how far a deliberate search may scroll. It does not show content until the user enters a target or query.
|
The product reduces ambient discovery rather than limiting how far a deliberate search may scroll. It shows content only after a submitted target or query, deliberate list navigation, deliberate post-link navigation, or a manually entered valid `/:handle/status/:tweetId` URL.
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
|
|
||||||
1. **Intent before content.** The initial screen is empty. Every result begins with a typed handle, URL, or query.
|
1. **Intent before content.** The initial screen is empty. Every result begins with a submitted target or query, deliberate list navigation, deliberate post-link navigation, or a manually entered valid `/:handle/status/:tweetId` URL.
|
||||||
2. **No ambient feed.** There is no home timeline, recommendation surface, trend list, notification screen, or recent-history list.
|
2. **No ambient feed.** There is no home timeline, recommendation surface, trend list, notification screen, or recent-history list.
|
||||||
3. **Reading only.** The app exposes no posting, reply, follow, like, repost, bookmark, or deletion operation.
|
3. **Reading only.** The app exposes no posting, reply, follow, like, repost, bookmark, or deletion operation.
|
||||||
4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist.
|
4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist.
|
||||||
@@ -33,6 +35,8 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
|||||||
- raw X search syntax;
|
- raw X search syntax;
|
||||||
- `Top` and `Latest` search products;
|
- `Top` and `Latest` search products;
|
||||||
- `filter:follows` search filtering;
|
- `filter:follows` search filtering;
|
||||||
|
- authenticated account list selection and list timelines;
|
||||||
|
- deliberate post detail pages with cursor-paginated visible conversations;
|
||||||
- photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird;
|
- photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird;
|
||||||
- infinite cursor pagination;
|
- infinite cursor pagination;
|
||||||
- explicit retry, empty, error, loading, and end states;
|
- explicit retry, empty, error, loading, and end states;
|
||||||
@@ -42,23 +46,22 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
|||||||
|
|
||||||
- authentication and multi-user accounts;
|
- authentication and multi-user accounts;
|
||||||
- databases, cookies, saved preferences, viewing history, and analytics;
|
- databases, cookies, saved preferences, viewing history, and analytics;
|
||||||
- home, notifications, trends, lists, bookmarks, and likes;
|
- home, notifications, trends, bookmarks, and likes;
|
||||||
- all X mutation operations;
|
- all X mutation operations;
|
||||||
- user autocomplete and typeahead;
|
- user autocomplete and typeahead;
|
||||||
- People and Media search products;
|
- People and Media search products;
|
||||||
|
- related-post feeds and automatic post discovery;
|
||||||
- a generic relay proxy;
|
- a generic relay proxy;
|
||||||
- deployment beyond a personal localhost process.
|
- deployment beyond a personal localhost process.
|
||||||
|
|
||||||
## Repositories and Delivery Sequence
|
## Repositories and Delivery Sequence
|
||||||
|
|
||||||
Work occurs in two repositories:
|
Work spans two repositories and the Gitea package registry:
|
||||||
|
|
||||||
1. Clone Bird from `https://git.yutakobayashi.com/yuta/bird` into the sibling path `../bird`.
|
1. Extend Bird's public search interface in `https://git.yutakobayashi.com/yuta/bird`.
|
||||||
2. Extend Bird's public search interface and commit the change locally.
|
2. Release the compatible interface as `@yuta/[email protected]` in Gitea Packages.
|
||||||
3. Consume the local Bird checkout from Twitter Lite with the pnpm dependency `link:../bird`.
|
3. Route the `@yuta` scope through the committed `.npmrc` and pin Twitter Lite to `@yuta/[email protected]`.
|
||||||
4. Build and verify Twitter Lite against the linked Bird package.
|
4. Build and verify Twitter Lite against the published package without a sibling Bird checkout.
|
||||||
|
|
||||||
The Bird commit is not pushed and no package is published without separate user authorization. Once a compatible Bird version is published, replacing the local link with that version is a separate maintenance task.
|
|
||||||
|
|
||||||
## Bird Library Change
|
## Bird Library Change
|
||||||
|
|
||||||
@@ -103,13 +106,16 @@ TanStack Start supplies the browser/server boundary. Bird is imported only by se
|
|||||||
|
|
||||||
### Routes and URL State
|
### Routes and URL State
|
||||||
|
|
||||||
- `/user?target=<handle-or-url>` displays the User tab.
|
- `/` displays the empty User tab without fetching posts.
|
||||||
|
- `/:handle` displays the User tab for the normalized handle.
|
||||||
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab.
|
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab.
|
||||||
- `/` redirects to `/user` without a target.
|
- `/i/lists` displays the authenticated account's lists.
|
||||||
|
- `/i/lists/:listId` displays one selected list timeline.
|
||||||
|
- `/:handle/status/:tweetId` displays one selected post and its visible conversation.
|
||||||
|
|
||||||
The target, query, product, and following flag are validated typed search parameters. Cursor state is internal to TanStack Query because it is transport state rather than a user-controlled view setting.
|
The handle, list ID, and tweet ID are validated typed path parameters. The query, product, and following flag are validated typed search parameters. Cursor state is internal to TanStack Query because it is transport state rather than a user-controlled view setting.
|
||||||
|
|
||||||
Route preloading never fetches posts. Data retrieval begins only when a valid submitted target or query is present.
|
Route preloading never fetches posts. Data retrieval begins only when a valid submitted handle or query is present, a list is deliberately selected, or a valid `/:handle/status/:tweetId` route is reached through a deliberate post link or manually entered URL.
|
||||||
|
|
||||||
### Server Boundary
|
### Server Boundary
|
||||||
|
|
||||||
@@ -125,7 +131,7 @@ new TwitterClient({
|
|||||||
|
|
||||||
`TWITTER_RELAY_BASE_URL` is required. `BIRD_PROFILE_NAME` is optional but must be passed explicitly because the Bird library does not read that environment variable itself.
|
`TWITTER_RELAY_BASE_URL` is required. `BIRD_PROFILE_NAME` is optional but must be passed explicitly because the Bird library does not read that environment variable itself.
|
||||||
|
|
||||||
Only two server functions are callable by the application:
|
Only three server functions are callable by the application:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type PostPage = {
|
type PostPage = {
|
||||||
@@ -144,9 +150,19 @@ searchPosts(input: {
|
|||||||
following: boolean
|
following: boolean
|
||||||
cursor?: string
|
cursor?: string
|
||||||
}): Promise<PostPage>
|
}): Promise<PostPage>
|
||||||
|
|
||||||
|
loadThreadPosts(input:
|
||||||
|
| { tweetId: string }
|
||||||
|
| { tweetId: string; conversationId: string; cursor: string }
|
||||||
|
): Promise<ThreadPage>
|
||||||
```
|
```
|
||||||
|
|
||||||
Neither function exposes the Bird client, arbitrary method names, arbitrary relay URLs, or mutation operations.
|
The status route and `loadThreadPosts` contract are specified in
|
||||||
|
`2026-07-13-tweet-detail-thread-design.md`. Conversation reads compose Bird's
|
||||||
|
existing `getTweet()` and `getThreadPaged()` methods; they add no Bird or relay
|
||||||
|
operation.
|
||||||
|
|
||||||
|
None of these functions exposes the Bird client, arbitrary method names, arbitrary relay URLs, or mutation operations.
|
||||||
|
|
||||||
### User Timeline Flow
|
### User Timeline Flow
|
||||||
|
|
||||||
@@ -174,6 +190,10 @@ The first request fetches 20 posts. An IntersectionObserver sentinel near the li
|
|||||||
|
|
||||||
Flattened pages are deduplicated by post ID while preserving API order. A failed later page leaves all previously loaded posts visible and presents one explicit Retry control. Requests do not retry automatically.
|
Flattened pages are deduplicated by post ID while preserving API order. A failed later page leaves all previously loaded posts visible and presents one explicit Retry control. Requests do not retry automatically.
|
||||||
|
|
||||||
|
User and search pages preserve API order. Conversation pages preserve Bird's
|
||||||
|
current order: chronological within a fetched page, with cursor pages appended
|
||||||
|
in retrieval order.
|
||||||
|
|
||||||
## Interface Design
|
## Interface Design
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
@@ -189,7 +209,9 @@ The selected layout is **Focused Tabs**:
|
|||||||
|
|
||||||
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.
|
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 updates the route's typed search parameters. Changing tabs does not preserve or surface the other tab's previous value.
|
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
|
||||||
|
does not preserve or surface the other tab's previous value.
|
||||||
|
|
||||||
### Visual Direction
|
### Visual Direction
|
||||||
|
|
||||||
@@ -239,6 +261,8 @@ External links open in a new tab with `rel="noreferrer noopener"`. The app never
|
|||||||
Server functions translate library failures into a small discriminated error model:
|
Server functions translate library failures into a small discriminated error model:
|
||||||
|
|
||||||
- `invalid-input`;
|
- `invalid-input`;
|
||||||
|
- `post-not-found`;
|
||||||
|
- `post-unavailable`;
|
||||||
- `user-not-found`;
|
- `user-not-found`;
|
||||||
- `user-unavailable`;
|
- `user-unavailable`;
|
||||||
- `relay-config`;
|
- `relay-config`;
|
||||||
@@ -316,7 +340,9 @@ Bird's README and changelog document the search product option. No AGENTS.md or
|
|||||||
The work is complete when:
|
The work is complete when:
|
||||||
|
|
||||||
1. Bird supports Top and Latest through its public library and CLI contracts while preserving Latest as the default.
|
1. Bird supports Top and Latest through its public library and CLI contracts while preserving Latest as the default.
|
||||||
2. Twitter Lite shows no posts before an explicit user submission.
|
2. Twitter Lite shows no posts before an explicit user action: submitting a
|
||||||
|
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.
|
3. A valid handle or profile URL loads that user's posts.
|
||||||
4. Search supports Top, Latest, and the follows filter.
|
4. Search supports Top, Latest, and the follows filter.
|
||||||
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright'
|
||||||
|
import { test as base } from '@playwright/test'
|
||||||
|
import { HomePage } from './models/HomePage'
|
||||||
|
import { ListsPage } from './models/ListsPage'
|
||||||
|
import { SearchPage } from './models/SearchPage'
|
||||||
|
import { ThreadPage } from './models/ThreadPage'
|
||||||
|
import { UserPage } from './models/UserPage'
|
||||||
|
|
||||||
|
type Fixtures = {
|
||||||
|
a11y: () => AxeBuilder
|
||||||
|
homePage: HomePage
|
||||||
|
listsPage: ListsPage
|
||||||
|
searchPage: SearchPage
|
||||||
|
threadPage: ThreadPage
|
||||||
|
userPage: UserPage
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<Fixtures>({
|
||||||
|
a11y: async ({ page }, use) => {
|
||||||
|
const makeAxeBuilder = () =>
|
||||||
|
new AxeBuilder({ page })
|
||||||
|
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||||
|
.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'
|
||||||
@@ -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 |
@@ -0,0 +1,469 @@
|
|||||||
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
|
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
|
||||||
|
const port = Number(portValue)
|
||||||
|
|
||||||
|
if (!portValue || !Number.isInteger(port) || port < 1) {
|
||||||
|
throw new Error('TWITTER_LITE_MOCK_RELAY_PORT must be a valid port')
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedRetryQueries = new Set()
|
||||||
|
const rootByTarget = new Map([
|
||||||
|
['1001', '1000'],
|
||||||
|
['2001', '2000'],
|
||||||
|
['3001', '3000'],
|
||||||
|
])
|
||||||
|
const targetByRoot = new Map([
|
||||||
|
['1000', '1001'],
|
||||||
|
['2000', '2001'],
|
||||||
|
['3000', '3001'],
|
||||||
|
])
|
||||||
|
const failedThreadRoots = new Set()
|
||||||
|
|
||||||
|
const isRecord = (value) =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
|
const tweetResult = (
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
username = 'yuta',
|
||||||
|
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
|
||||||
|
) => ({
|
||||||
|
rest_id: id,
|
||||||
|
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: {
|
||||||
|
rest_id: '42',
|
||||||
|
legacy: { screen_name: username, name: 'Yuta' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tweet = (id, text, username = 'yuta', options) => ({
|
||||||
|
entryId: `tweet-${id}`,
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: tweetResult(id, text, username, options),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const cursor = (value) => ({
|
||||||
|
entryId: 'cursor-bottom',
|
||||||
|
content: { cursorType: 'Bottom', value },
|
||||||
|
})
|
||||||
|
|
||||||
|
const send = (response, payload, status = 200, headers = {}) => {
|
||||||
|
response.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
...headers,
|
||||||
|
})
|
||||||
|
response.end(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (response, status, message, headers) =>
|
||||||
|
send(response, { errors: [{ message }] }, status, headers)
|
||||||
|
|
||||||
|
const variablesFrom = (url) => {
|
||||||
|
const encoded = url.searchParams.get('variables')
|
||||||
|
if (!encoded) throw new Error('variables are required')
|
||||||
|
const variables = JSON.parse(encoded)
|
||||||
|
if (!isRecord(variables)) throw new Error('variables must be an object')
|
||||||
|
return variables
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonBodyFrom = async (request) => {
|
||||||
|
let body = ''
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk
|
||||||
|
if (body.length > 1_000_000) throw new Error('request body is too large')
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(body)
|
||||||
|
if (!isRecord(parsed)) throw new Error('JSON body must be an object')
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = (entries) => ({ instructions: [{ entries }] })
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
|
||||||
|
const operation = [
|
||||||
|
'UserByScreenName',
|
||||||
|
'UserTweets',
|
||||||
|
'TweetDetail',
|
||||||
|
'SearchTimeline',
|
||||||
|
'ListsManagementPageTimeline',
|
||||||
|
'ListLatestTweetsTimeline',
|
||||||
|
].find((name) => url.pathname.endsWith(`/${name}`))
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
fail(response, 501, 'unsupported read operation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedMethod = operation === 'SearchTimeline' ? 'POST' : 'GET'
|
||||||
|
if (request.method !== expectedMethod) {
|
||||||
|
fail(response, 405, `expected ${expectedMethod}`, {
|
||||||
|
allow: expectedMethod,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.headers['x-profile-name'] !== 'e2e') {
|
||||||
|
fail(response, 403, 'invalid profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let variables
|
||||||
|
try {
|
||||||
|
variables = variablesFrom(url)
|
||||||
|
} catch {
|
||||||
|
fail(response, 400, 'malformed variables')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'UserByScreenName') {
|
||||||
|
if (
|
||||||
|
typeof variables.screen_name !== 'string' ||
|
||||||
|
variables.screen_name.length === 0
|
||||||
|
) {
|
||||||
|
fail(response, 400, 'screen_name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
rest_id: '42',
|
||||||
|
legacy: { screen_name: variables.screen_name, name: 'Yuta' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'UserTweets') {
|
||||||
|
const expectedCursor =
|
||||||
|
variables.cursor === undefined || variables.cursor === 'user-next'
|
||||||
|
if (
|
||||||
|
variables.userId !== '42' ||
|
||||||
|
variables.count !== 20 ||
|
||||||
|
!expectedCursor
|
||||||
|
) {
|
||||||
|
fail(response, 400, 'invalid user timeline variables')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const secondPage = variables.cursor === 'user-next'
|
||||||
|
const entries = secondPage
|
||||||
|
? [tweet('1010', 'user page 2')]
|
||||||
|
: [
|
||||||
|
tweet('1001', 'user page 1', 'yuta', {
|
||||||
|
conversationId: '1000',
|
||||||
|
}),
|
||||||
|
cursor('user-next'),
|
||||||
|
]
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
user: { result: { timeline: { timeline: timeline(entries) } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'ListsManagementPageTimeline') {
|
||||||
|
if (variables.count !== 100) {
|
||||||
|
fail(response, 400, 'invalid lists management variables')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const features = url.searchParams.get('features')
|
||||||
|
try {
|
||||||
|
if (!features || !isRecord(JSON.parse(features))) throw new Error()
|
||||||
|
} catch {
|
||||||
|
fail(response, 400, 'invalid lists management feature lock')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
viewer: {
|
||||||
|
list_management_timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
clientEventInfo: {
|
||||||
|
component: 'suggest_owned_subscribed_list',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item: {
|
||||||
|
itemContent: {
|
||||||
|
list: {
|
||||||
|
id_str: '1234567890',
|
||||||
|
name: 'My E2E List',
|
||||||
|
description: 'Accounts selected for testing',
|
||||||
|
member_count: 10,
|
||||||
|
mode: 'Public',
|
||||||
|
created_at: 1_704_067_200_000,
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '42',
|
||||||
|
core: {
|
||||||
|
screen_name: 'yuta',
|
||||||
|
name: 'Yuta',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: [{ message: 'optional banner field failed' }],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'ListLatestTweetsTimeline') {
|
||||||
|
const expectedCursor =
|
||||||
|
variables.cursor === undefined || variables.cursor === 'list-next'
|
||||||
|
if (
|
||||||
|
typeof variables.listId !== 'string' ||
|
||||||
|
!/^\d+$/.test(variables.listId) ||
|
||||||
|
variables.count !== 20 ||
|
||||||
|
!expectedCursor
|
||||||
|
) {
|
||||||
|
fail(response, 400, 'invalid list timeline variables')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const secondPage = variables.cursor === 'list-next'
|
||||||
|
const entries = secondPage
|
||||||
|
? [tweet('3010', 'list page 2', 'listed')]
|
||||||
|
: [tweet('3001', 'list page 1', 'listed'), cursor('list-next')]
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
list: { tweets_timeline: { timeline: timeline(entries) } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation === 'TweetDetail') {
|
||||||
|
const focalTweetId = variables.focalTweetId
|
||||||
|
if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) {
|
||||||
|
fail(response, 400, 'invalid focalTweetId')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const features = url.searchParams.get('features')
|
||||||
|
const fieldToggles = url.searchParams.get('fieldToggles')
|
||||||
|
try {
|
||||||
|
if (!features || !isRecord(JSON.parse(features))) throw new Error()
|
||||||
|
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
|
||||||
|
throw new Error()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fail(response, 400, 'invalid TweetDetail feature locks')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRoot = rootByTarget.get(focalTweetId)
|
||||||
|
if (targetRoot && variables.cursor === undefined) {
|
||||||
|
const noReplies = focalTweetId === '3001'
|
||||||
|
const text = noReplies
|
||||||
|
? 'no replies'
|
||||||
|
: focalTweetId === '1001'
|
||||||
|
? 'user page 1'
|
||||||
|
: 'retry focal'
|
||||||
|
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||||
|
conversationId: targetRoot,
|
||||||
|
inReplyTo: targetRoot,
|
||||||
|
replyCount: noReplies ? 0 : 1,
|
||||||
|
})
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
tweetResult: { result },
|
||||||
|
threaded_conversation_with_injections_v2: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
tweet(focalTweetId, text, 'focus', {
|
||||||
|
conversationId: targetRoot,
|
||||||
|
inReplyTo: targetRoot,
|
||||||
|
replyCount: noReplies ? 0 : 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = targetByRoot.get(focalTweetId)
|
||||||
|
if (!targetId) {
|
||||||
|
fail(response, 400, 'unsupported TweetDetail fixture')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expectedCursor = `thread-next:${focalTweetId}`
|
||||||
|
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
|
||||||
|
fail(response, 400, 'invalid thread cursor')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (variables.cursor && focalTweetId === '3000') {
|
||||||
|
fail(response, 500, 'zero-reply thread must not request a continuation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
variables.cursor &&
|
||||||
|
focalTweetId === '2000' &&
|
||||||
|
!failedThreadRoots.has(focalTweetId)
|
||||||
|
) {
|
||||||
|
failedThreadRoots.add(focalTweetId)
|
||||||
|
fail(response, 503, 'transient thread fixture failure')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = variables.cursor
|
||||||
|
? [
|
||||||
|
tweet(`${focalTweetId}3`, 'thread page 2', 'reply2', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
inReplyTo: targetId,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: focalTweetId === '3000'
|
||||||
|
? [
|
||||||
|
tweet(focalTweetId, 'thread root', 'root', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
}),
|
||||||
|
tweet(targetId, 'no replies', 'focus', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
inReplyTo: focalTweetId,
|
||||||
|
replyCount: 0,
|
||||||
|
}),
|
||||||
|
cursor(expectedCursor),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
tweet(focalTweetId, 'thread root', 'root', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
}),
|
||||||
|
tweet(
|
||||||
|
targetId,
|
||||||
|
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||||
|
'focus',
|
||||||
|
{
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
inReplyTo: focalTweetId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
inReplyTo: targetId,
|
||||||
|
}),
|
||||||
|
cursor(expectedCursor),
|
||||||
|
]
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
tweetResult: {
|
||||||
|
result: tweetResult(focalTweetId, 'thread root', 'root', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
threaded_conversation_with_injections_v2: {
|
||||||
|
instructions: [{ entries }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (variables.cursor && focalTweetId === '2000') {
|
||||||
|
failedThreadRoots.delete(focalTweetId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = request.headers['content-type'] ?? ''
|
||||||
|
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
||||||
|
fail(response, 400, 'SearchTimeline requires JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body
|
||||||
|
try {
|
||||||
|
body = await jsonBodyFrom(request)
|
||||||
|
} catch {
|
||||||
|
fail(response, 400, 'malformed JSON body')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawQuery = variables.rawQuery
|
||||||
|
const expectedCursor =
|
||||||
|
variables.cursor === undefined ||
|
||||||
|
variables.cursor === `search-next:${rawQuery}`
|
||||||
|
if (
|
||||||
|
typeof rawQuery !== 'string' ||
|
||||||
|
rawQuery.length === 0 ||
|
||||||
|
variables.count !== 20 ||
|
||||||
|
variables.querySource !== 'typed_query' ||
|
||||||
|
!['Top', 'Latest'].includes(variables.product) ||
|
||||||
|
!expectedCursor ||
|
||||||
|
!isRecord(body.features) ||
|
||||||
|
typeof body.queryId !== 'string' ||
|
||||||
|
body.queryId.length === 0 ||
|
||||||
|
Object.hasOwn(body, 'variables')
|
||||||
|
) {
|
||||||
|
fail(response, 400, 'invalid search request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondPage = variables.cursor !== undefined
|
||||||
|
if (
|
||||||
|
secondPage &&
|
||||||
|
rawQuery.startsWith('retry-') &&
|
||||||
|
!failedRetryQueries.has(rawQuery)
|
||||||
|
) {
|
||||||
|
failedRetryQueries.add(rawQuery)
|
||||||
|
fail(response, 503, 'transient fixture failure')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (secondPage && rawQuery.startsWith('slow-')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_200))
|
||||||
|
}
|
||||||
|
|
||||||
|
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
|
||||||
|
const label = `${variables.product} · ${follows}`
|
||||||
|
const id = encodeURIComponent(rawQuery)
|
||||||
|
const entries = secondPage
|
||||||
|
? [tweet(`s-${id}-2`, `${label} page 2`)]
|
||||||
|
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)]
|
||||||
|
|
||||||
|
send(response, {
|
||||||
|
data: {
|
||||||
|
search_by_raw_query: {
|
||||||
|
search_timeline: { timeline: timeline(entries) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, '127.0.0.1')
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
+78
-4
@@ -1,12 +1,84 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"agent-skills-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"home-manager": "home-manager",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783839654,
|
||||||
|
"narHash": "sha256-rn5DkeQ9PBaJTZl9juBtPnVawyN4B83tyz1VpiinQFo=",
|
||||||
|
"owner": "Kyure-A",
|
||||||
|
"repo": "agent-skills-nix",
|
||||||
|
"rev": "8326df98cf494dc515db99affa9ea9d2bd6666b6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Kyure-A",
|
||||||
|
"repo": "agent-skills-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hallmark": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780605762,
|
||||||
|
"narHash": "sha256-+yPIG1XdI6hhyOH48rd20+YlFrA9Gr416tPt1OoxfwQ=",
|
||||||
|
"owner": "Nutlope",
|
||||||
|
"repo": "hallmark",
|
||||||
|
"rev": "aeb42fb354ff4efa36ab475773a082315a3af2ce",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Nutlope",
|
||||||
|
"repo": "hallmark",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"agent-skills-nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783222121,
|
||||||
|
"narHash": "sha256-E/ElL373TO8lQ2aMvYyzN+k4xkVaUGoRqoa8AtM71tI=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "a1645f40777620c4bd2b6d854b290c2fc354a266",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783776592,
|
"lastModified": 1783224372,
|
||||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784796856,
|
||||||
|
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -18,7 +90,9 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"agent-skills-nix": "agent-skills-nix",
|
||||||
|
"hallmark": "hallmark",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,21 +2,156 @@
|
|||||||
description = "Intentional X reader";
|
description = "Intentional X reader";
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
inputs.agent-skills-nix.url = "github:Kyure-A/agent-skills-nix";
|
||||||
|
inputs.hallmark = {
|
||||||
|
url = "github:Nutlope/hallmark";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
|
||||||
outputs = { nixpkgs, ... }:
|
outputs = inputs@{ self, nixpkgs, ... }:
|
||||||
let
|
let
|
||||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||||
|
mkTwitterLite = pkgs:
|
||||||
|
pkgs.stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
|
pname = "twitter-lite";
|
||||||
|
version = "0.0.0";
|
||||||
|
src = self;
|
||||||
|
|
||||||
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
|
inherit (finalAttrs) pname version src;
|
||||||
|
pnpm = pkgs.pnpm_11;
|
||||||
|
fetcherVersion = 4;
|
||||||
|
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
makeWrapper
|
||||||
|
nodejs_22
|
||||||
|
pnpm_11
|
||||||
|
pnpmConfigHook
|
||||||
|
];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
pnpm build
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out/lib/twitter-lite
|
||||||
|
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"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta.mainProgram = "twitter-lite";
|
||||||
|
});
|
||||||
in {
|
in {
|
||||||
|
packages = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
twitter-lite = mkTwitterLite pkgs;
|
||||||
|
in {
|
||||||
|
inherit twitter-lite;
|
||||||
|
default = twitter-lite;
|
||||||
|
});
|
||||||
|
|
||||||
devShells = forAllSystems (system:
|
devShells = forAllSystems (system:
|
||||||
let pkgs = import nixpkgs { inherit system; };
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
agentLib = inputs.agent-skills-nix.lib.agent-skills;
|
||||||
|
sources.hallmark = {
|
||||||
|
path = inputs.hallmark;
|
||||||
|
subdir = "skills";
|
||||||
|
};
|
||||||
|
catalog = agentLib.discoverCatalog sources;
|
||||||
|
allowlist = agentLib.allowlistFor {
|
||||||
|
inherit catalog sources;
|
||||||
|
enableAll = true;
|
||||||
|
};
|
||||||
|
selection = agentLib.selectSkills {
|
||||||
|
inherit catalog allowlist sources;
|
||||||
|
skills = { };
|
||||||
|
};
|
||||||
|
bundle = agentLib.mkBundle { inherit pkgs selection; };
|
||||||
|
localTargets = builtins.mapAttrs (_: target:
|
||||||
|
target // {
|
||||||
|
enable = true;
|
||||||
|
}) agentLib.defaultLocalTargets;
|
||||||
in {
|
in {
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
packages = with pkgs; [ nodejs_22 pnpm chromium ];
|
||||||
|
shellHook = agentLib.mkShellHook {
|
||||||
|
inherit pkgs bundle;
|
||||||
|
targets = localTargets;
|
||||||
|
};
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
PLAYWRIGHT_CHROMIUM_EXECUTABLE =
|
||||||
"${pkgs.chromium}/bin/chromium";
|
"${pkgs.chromium}/bin/chromium";
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
nixosModules.default = { config, lib, pkgs, ... }:
|
||||||
|
let cfg = config.services.twitter-lite;
|
||||||
|
in {
|
||||||
|
options.services.twitter-lite = {
|
||||||
|
enable = lib.mkEnableOption "Twitter Lite";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = mkTwitterLite pkgs;
|
||||||
|
defaultText = lib.literalExpression "twitter-lite.packages.\${pkgs.system}.default";
|
||||||
|
description = "Twitter Lite package to run.";
|
||||||
|
};
|
||||||
|
|
||||||
|
relayBaseUrl = lib.mkOption {
|
||||||
|
type = lib.types.nonEmptyStr;
|
||||||
|
example = "http://127.0.0.1:6900";
|
||||||
|
description = "Base URL of the Twitter relay.";
|
||||||
|
};
|
||||||
|
|
||||||
|
profileName = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.nonEmptyStr;
|
||||||
|
default = null;
|
||||||
|
example = "default";
|
||||||
|
description = "Relay profile to use when the relay has multiple profiles.";
|
||||||
|
};
|
||||||
|
|
||||||
|
host = lib.mkOption {
|
||||||
|
type = lib.types.nonEmptyStr;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address on which Twitter Lite listens.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 3000;
|
||||||
|
description = "Port on which Twitter Lite listens.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
systemd.services.twitter-lite = {
|
||||||
|
description = "Twitter Lite";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
environment = {
|
||||||
|
HOST = cfg.host;
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
TWITTER_RELAY_BASE_URL = cfg.relayBaseUrl;
|
||||||
|
} // lib.optionalAttrs (cfg.profileName != null) {
|
||||||
|
BIRD_PROFILE_NAME = cfg.profileName;
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
DynamicUser = true;
|
||||||
|
ExecStart = lib.getExe cfg.package;
|
||||||
|
Restart = "on-failure";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { KnipConfig } from 'knip'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
entry: ['playwright.config.ts', 'e2e/**/*.test.ts', 'e2e/mock-relay.mjs'],
|
||||||
|
playwright: false,
|
||||||
|
} satisfies KnipConfig
|
||||||
+4
-2
@@ -18,6 +18,7 @@
|
|||||||
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
|
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
|
"knip": "knip",
|
||||||
"format": "biome check --write .",
|
"format": "biome check --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -29,16 +30,16 @@
|
|||||||
"@tanstack/react-router": "1.170.17",
|
"@tanstack/react-router": "1.170.17",
|
||||||
"@tanstack/react-router-ssr-query": "1.167.1",
|
"@tanstack/react-router-ssr-query": "1.167.1",
|
||||||
"@tanstack/react-start": "1.168.27",
|
"@tanstack/react-start": "1.168.27",
|
||||||
"@yuta/bird": "link:../bird",
|
"@yuta/bird": "0.10.1",
|
||||||
"nitro": "3.0.260610-beta",
|
"nitro": "3.0.260610-beta",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.11.1",
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@playwright/test": "1.61.1",
|
"@playwright/test": "1.61.1",
|
||||||
"@tanstack/router-cli": "1.167.18",
|
|
||||||
"@testing-library/dom": "10.4.1",
|
"@testing-library/dom": "10.4.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
"@testing-library/react": "16.3.2",
|
"@testing-library/react": "16.3.2",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "6.0.3",
|
"@vitejs/plugin-react": "6.0.3",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
|
"knip": "6.27.0",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.1.4",
|
"vite": "8.1.4",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if (!chromiumExecutable) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
use: {
|
use: {
|
||||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
|
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node e2e/mock-relay.mjs`,
|
||||||
port: relayPort,
|
port: relayPort,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
Generated
+591
-152
File diff suppressed because it is too large
Load Diff
@@ -4,19 +4,15 @@ export function AppShell({
|
|||||||
active,
|
active,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
active: 'user' | 'search'
|
active?: 'user' | 'search' | 'list'
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<p className="brand">TWITTER LITE</p>
|
<p className="brand">Twitter Lite</p>
|
||||||
<nav aria-label="閲覧方法" className="tabs">
|
<nav aria-label="閲覧方法" className="tabs">
|
||||||
<Link
|
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||||
aria-current={active === 'user' ? 'page' : undefined}
|
|
||||||
search={{ target: '' }}
|
|
||||||
to="/user"
|
|
||||||
>
|
|
||||||
ユーザー
|
ユーザー
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -26,6 +22,12 @@ export function AppShell({
|
|||||||
>
|
>
|
||||||
検索
|
検索
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
aria-current={active === 'list' ? 'page' : undefined}
|
||||||
|
to="/i/lists"
|
||||||
|
>
|
||||||
|
リスト
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<section className="reader">{children}</section>
|
<section className="reader">{children}</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ListForm } from './list-form'
|
||||||
import { SearchForm } from './search-form'
|
import { SearchForm } from './search-form'
|
||||||
import { UserForm } from './user-form'
|
import { UserForm } from './user-form'
|
||||||
|
|
||||||
@@ -29,6 +30,32 @@ describe('UserForm', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ListForm', () => {
|
||||||
|
it('submits the list ID from a pasted list URL', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('リスト URL または ID'), {
|
||||||
|
target: { value: 'https://x.com/i/lists/1234567890' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('1234567890')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explains invalid input beside the field', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<ListForm initialTarget="" onSubmit={onSubmit} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '表示' }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||||
|
'リストの URL または ID を入力してください。',
|
||||||
|
)
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SearchForm', () => {
|
describe('SearchForm', () => {
|
||||||
it('uses a labelled grouping for ranking controls', () => {
|
it('uses a labelled grouping for ranking controls', () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { type FormEvent, useState } from 'react'
|
||||||
|
import { normalizeListTarget } from '../inputs'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialTarget: string
|
||||||
|
onSubmit: (target: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListForm({ initialTarget, onSubmit }: Props) {
|
||||||
|
const [target, setTarget] = useState(initialTarget)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
try {
|
||||||
|
const listId = normalizeListTarget(target)
|
||||||
|
setError('')
|
||||||
|
onSubmit(listId)
|
||||||
|
} catch (cause) {
|
||||||
|
setError(
|
||||||
|
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="intent-form" onSubmit={submit}>
|
||||||
|
<label className="field">
|
||||||
|
<span>リスト URL または ID</span>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
aria-describedby={error ? 'list-error' : undefined}
|
||||||
|
aria-invalid={Boolean(error)}
|
||||||
|
name="target"
|
||||||
|
value={target}
|
||||||
|
onChange={(event) => setTarget(event.currentTarget.value)}
|
||||||
|
placeholder="https://x.com/i/lists/1234567890 または ID"
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<span className="field-error" id="list-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<button type="submit">表示</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ListChoicesLoadError, useListChoices } from '../use-list-choices'
|
||||||
|
|
||||||
|
export function ListPicker({ selectedId }: { selectedId?: string }) {
|
||||||
|
const query = useListChoices()
|
||||||
|
|
||||||
|
if (query.isPending) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||||
|
<h2 id="list-picker-title">自分のリスト</h2>
|
||||||
|
<p className="list-picker-state" role="status">
|
||||||
|
リストを取得しています…
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
const error =
|
||||||
|
query.error instanceof ListChoicesLoadError
|
||||||
|
? query.error.detail
|
||||||
|
: { message: 'リストを取得できませんでした。', retryable: true }
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||||
|
<h2 id="list-picker-title">自分のリスト</h2>
|
||||||
|
<div className="list-picker-state" role="alert">
|
||||||
|
<p>{error.message}</p>
|
||||||
|
{error.retryable ? (
|
||||||
|
<button onClick={() => void query.refetch()} type="button">
|
||||||
|
再試行
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="list-picker-title" className="list-picker">
|
||||||
|
<h2 id="list-picker-title">自分のリスト</h2>
|
||||||
|
{query.data.length === 0 ? (
|
||||||
|
<p className="list-picker-state">リストはありません。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-choices">
|
||||||
|
{query.data.map((list) => (
|
||||||
|
<li key={list.id}>
|
||||||
|
<Link
|
||||||
|
aria-current={selectedId === list.id ? 'page' : undefined}
|
||||||
|
params={{ listId: list.id }}
|
||||||
|
to="/i/lists/$listId"
|
||||||
|
>
|
||||||
|
<span>{list.name}</span>
|
||||||
|
<small>
|
||||||
|
{list.isPrivate ? '非公開 · ' : ''}
|
||||||
|
{list.memberCount?.toLocaleString('ja-JP') ?? 0} メンバー
|
||||||
|
</small>
|
||||||
|
{list.description ? <p>{list.description}</p> : null}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ describe('PostCard', () => {
|
|||||||
expect(container.querySelectorAll('video')).toHaveLength(2)
|
expect(container.querySelectorAll('video')).toHaveLength(2)
|
||||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
||||||
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
||||||
|
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://media.invalid/video.mp4',
|
||||||
|
)
|
||||||
expect(screen.getByText('Article title')).toBeInTheDocument()
|
expect(screen.getByText('Article title')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||||
expect(screen.getByText('quoted')).toBeInTheDocument()
|
expect(screen.getByText('quoted')).toBeInTheDocument()
|
||||||
@@ -80,6 +84,48 @@ describe('PostCard', () => {
|
|||||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('decodes HTML entities in the post body before linking URLs', () => {
|
||||||
|
render(
|
||||||
|
<PostCard
|
||||||
|
post={{
|
||||||
|
...richPost,
|
||||||
|
text: 'A > B & C 'D' https://example.com/?a=1&b=2',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText((_, element) =>
|
||||||
|
Boolean(
|
||||||
|
element?.classList.contains('post-text') &&
|
||||||
|
element.textContent ===
|
||||||
|
"A > B & C 'D' https://example.com/?a=1&b=2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
|
||||||
|
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links deliberately to the internal detail page', () => {
|
||||||
|
render(<PostCard post={richPost} />)
|
||||||
|
|
||||||
|
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
|
||||||
|
expect(detail).toHaveAttribute('href', '/yuta/status/123')
|
||||||
|
expect(detail.parentElement).toHaveClass('post-actions')
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
|
||||||
|
).toBe(detail.parentElement)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the focal post without linking to its current page', () => {
|
||||||
|
render(<PostCard current post={{ ...richPost, quotedTweet: undefined }} />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull()
|
||||||
|
expect(screen.getByRole('article')).toHaveClass('current-post')
|
||||||
|
})
|
||||||
|
|
||||||
it('collapses a malformed optional timestamp without losing the post', () => {
|
it('collapses a malformed optional timestamp without losing the post', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
|
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
|
||||||
@@ -88,4 +134,26 @@ describe('PostCard', () => {
|
|||||||
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
||||||
expect(container.querySelector('time')).not.toBeInTheDocument()
|
expect(container.querySelector('time')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not try to play a poster image when Bird has no video URL', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PostCard
|
||||||
|
post={{
|
||||||
|
...richPost,
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
type: 'video',
|
||||||
|
url: 'https://media.invalid/video-poster.jpg',
|
||||||
|
previewUrl: 'https://media.invalid/video-preview.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('img', { name: '動画のプレビュー' }),
|
||||||
|
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,19 +3,39 @@ import type { Post } from '../types'
|
|||||||
import { PostMedia } from './post-media'
|
import { PostMedia } from './post-media'
|
||||||
|
|
||||||
const URL = /(https?:\/\/[^\s]+)/g
|
const URL = /(https?:\/\/[^\s]+)/g
|
||||||
|
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||||
|
const NAMED_ENTITIES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
''': "'",
|
||||||
|
'>': '>',
|
||||||
|
'<': '<',
|
||||||
|
'"': '"',
|
||||||
|
}
|
||||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
timeZone: 'Asia/Tokyo',
|
timeZone: 'Asia/Tokyo',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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[] {
|
function linkedText(text: string): ReactNode[] {
|
||||||
|
const decoded = decodeHtmlEntities(text)
|
||||||
const parts: ReactNode[] = []
|
const parts: ReactNode[] = []
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
for (const match of text.matchAll(URL)) {
|
for (const match of decoded.matchAll(URL)) {
|
||||||
const href = match[0]
|
const href = match[0]
|
||||||
const start = match.index
|
const start = match.index
|
||||||
if (start > cursor) parts.push(text.slice(cursor, start))
|
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||||
parts.push(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
|
|||||||
)
|
)
|
||||||
cursor = start + href.length
|
cursor = start + href.length
|
||||||
}
|
}
|
||||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +61,19 @@ function formattedTime(createdAt?: string): string | undefined {
|
|||||||
export function PostCard({
|
export function PostCard({
|
||||||
post,
|
post,
|
||||||
quoted = false,
|
quoted = false,
|
||||||
|
current = false,
|
||||||
}: {
|
}: {
|
||||||
post: Post
|
post: Post
|
||||||
quoted?: boolean
|
quoted?: boolean
|
||||||
|
current?: boolean
|
||||||
}) {
|
}) {
|
||||||
const time = formattedTime(post.createdAt)
|
const time = formattedTime(post.createdAt)
|
||||||
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
|
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={quoted ? 'post quote' : 'post'}>
|
<article
|
||||||
|
className={quoted ? 'post quote' : current ? 'post current-post' : 'post'}
|
||||||
|
>
|
||||||
<header className="post-header">
|
<header className="post-header">
|
||||||
{post.author.profileImageUrl ? (
|
{post.author.profileImageUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -86,9 +110,18 @@ export function PostCard({
|
|||||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||||
{post.likeCount ?? 0}
|
{post.likeCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
<span className="post-actions">
|
||||||
元の投稿を開く
|
{!current ? (
|
||||||
</a>
|
<a
|
||||||
|
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||||
|
>
|
||||||
|
詳細・スレッド
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||||
|
元の投稿を開く
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -154,6 +154,90 @@ describe('PostFeed', () => {
|
|||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the focal post once above its deduplicated conversation', () => {
|
||||||
|
usePostFeed.mockReturnValue({
|
||||||
|
...queryResult(),
|
||||||
|
data: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
focalPost: post('2'),
|
||||||
|
conversationId: '1',
|
||||||
|
nextCursor: 'next',
|
||||||
|
tweets: [post('1'), post('2')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
conversationId: '1',
|
||||||
|
tweets: [post('2'), post('3')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageParams: [undefined, { cursor: 'next', conversationId: '1' }],
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PostFeed request={{ kind: 'thread', tweetId: '2' }} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('表示中の投稿')).toBeVisible()
|
||||||
|
expect(screen.getAllByText('post-2')).toHaveLength(1)
|
||||||
|
expect(screen.getAllByRole('article')).toHaveLength(3)
|
||||||
|
expect(container.querySelector('.current-post')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the focal post visible when there are no other posts', () => {
|
||||||
|
usePostFeed.mockReturnValue({
|
||||||
|
...queryResult(),
|
||||||
|
data: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
focalPost: post('2'),
|
||||||
|
conversationId: '1',
|
||||||
|
tweets: [post('2')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageParams: [undefined],
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('post-2')).toBeVisible()
|
||||||
|
expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the focal post and conversation across a later-page error', () => {
|
||||||
|
usePostFeed.mockReturnValue({
|
||||||
|
...queryResult(),
|
||||||
|
data: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
focalPost: post('2'),
|
||||||
|
conversationId: '1',
|
||||||
|
nextCursor: 'next',
|
||||||
|
tweets: [post('1'), post('2')],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageParams: [undefined],
|
||||||
|
},
|
||||||
|
error: new PostLoadError({
|
||||||
|
code: 'upstream',
|
||||||
|
message: 'X から投稿を取得できませんでした。',
|
||||||
|
retryable: true,
|
||||||
|
}),
|
||||||
|
hasNextPage: true,
|
||||||
|
isError: true,
|
||||||
|
isFetchNextPageError: true,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('post-2')).toBeVisible()
|
||||||
|
expect(screen.getByText('post-1')).toBeVisible()
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||||
|
'続きの投稿を取得できませんでした。',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('retries an initial retryable error with refetch', () => {
|
it('retries an initial retryable error with refetch', () => {
|
||||||
const refetch = vi.fn().mockResolvedValue(undefined)
|
const refetch = vi.fn().mockResolvedValue(undefined)
|
||||||
usePostFeed.mockReturnValue({
|
usePostFeed.mockReturnValue({
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { flattenPostPages } from '../page'
|
import {
|
||||||
|
flattenConversationPages,
|
||||||
|
flattenPostPages,
|
||||||
|
focalPostFromPages,
|
||||||
|
} from '../page'
|
||||||
|
import type { Post } from '../types'
|
||||||
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
|
import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed'
|
||||||
import { PostCard } from './post-card'
|
import { PostCard } from './post-card'
|
||||||
|
|
||||||
|
function FocalPost({ post }: { post: Post }) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="focal-post-label" className="focal-post">
|
||||||
|
<p className="focal-label" id="focal-post-label">
|
||||||
|
表示中の投稿
|
||||||
|
</p>
|
||||||
|
<PostCard current post={post} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||||
const query = usePostFeed(request)
|
const query = usePostFeed(request)
|
||||||
const sentinel = useRef<HTMLDivElement>(null)
|
const sentinel = useRef<HTMLDivElement>(null)
|
||||||
const posts = query.data ? flattenPostPages(query.data.pages) : []
|
const pages = query.data?.pages ?? []
|
||||||
|
const focalPost =
|
||||||
|
request?.kind === 'thread' ? focalPostFromPages(pages) : undefined
|
||||||
|
const posts = focalPost
|
||||||
|
? flattenConversationPages(pages, focalPost.id)
|
||||||
|
: flattenPostPages(pages)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -62,31 +83,43 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (posts.length === 0 && !query.hasNextPage) {
|
if (posts.length === 0 && !query.hasNextPage) {
|
||||||
return <p className="state">条件に一致する投稿はありません。</p>
|
return (
|
||||||
|
<>
|
||||||
|
{focalPost ? <FocalPost post={focalPost} /> : null}
|
||||||
|
<p className="state">
|
||||||
|
{focalPost
|
||||||
|
? '会話にほかの投稿はありません。'
|
||||||
|
: '条件に一致する投稿はありません。'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-live="polite" className="feed">
|
<>
|
||||||
{posts.map((post) => (
|
{focalPost ? <FocalPost post={focalPost} /> : null}
|
||||||
<PostCard key={post.id} post={post} />
|
<section aria-live="polite" className="feed">
|
||||||
))}
|
{posts.map((post) => (
|
||||||
<div aria-hidden="true" ref={sentinel} />
|
<PostCard key={post.id} post={post} />
|
||||||
{query.isFetchingNextPage ? (
|
))}
|
||||||
<div className="loading-rail" role="status">
|
<div aria-hidden="true" ref={sentinel} />
|
||||||
次の投稿を取得しています…
|
{query.isFetchingNextPage ? (
|
||||||
</div>
|
<div className="loading-rail" role="status">
|
||||||
) : null}
|
次の投稿を取得しています…
|
||||||
{query.isFetchNextPageError ? (
|
</div>
|
||||||
<div className="state" role="alert">
|
) : null}
|
||||||
<p>続きの投稿を取得できませんでした。</p>
|
{query.isFetchNextPageError ? (
|
||||||
<button onClick={() => void query.fetchNextPage()} type="button">
|
<div className="state" role="alert">
|
||||||
再試行
|
<p>続きの投稿を取得できませんでした。</p>
|
||||||
</button>
|
<button onClick={() => void query.fetchNextPage()} type="button">
|
||||||
</div>
|
再試行
|
||||||
) : null}
|
</button>
|
||||||
{!query.hasNextPage ? (
|
</div>
|
||||||
<p className="state">これ以上の投稿はありません。</p>
|
) : null}
|
||||||
) : null}
|
{!query.hasNextPage ? (
|
||||||
</section>
|
<p className="state">これ以上の投稿はありません。</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="media-grid">
|
<div className="media-grid">
|
||||||
{media.map((item) =>
|
{media.map((item) => {
|
||||||
item.type === 'photo' ? (
|
if (item.type === 'photo' || !item.videoUrl) {
|
||||||
<img
|
return (
|
||||||
alt="投稿画像"
|
<img
|
||||||
className="media"
|
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
|
||||||
height={item.height}
|
className="media"
|
||||||
key={item.url}
|
height={item.height}
|
||||||
loading="lazy"
|
key={item.url}
|
||||||
src={item.url}
|
loading="lazy"
|
||||||
width={item.width}
|
src={item.previewUrl ?? item.url}
|
||||||
/>
|
width={item.width}
|
||||||
) : (
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
||||||
<video
|
<video
|
||||||
className="media"
|
className="media"
|
||||||
controls
|
controls
|
||||||
key={item.videoUrl ?? item.url}
|
key={item.videoUrl}
|
||||||
loop={item.type === 'animated_gif'}
|
loop={item.type === 'animated_gif'}
|
||||||
poster={item.previewUrl ?? item.url}
|
poster={item.previewUrl ?? item.url}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
src={item.videoUrl ?? item.url}
|
src={item.videoUrl}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { buildSearchQuery, normalizeUserTarget } from './inputs'
|
import {
|
||||||
|
buildSearchQuery,
|
||||||
|
normalizeListTarget,
|
||||||
|
normalizeTweetId,
|
||||||
|
normalizeUserTarget,
|
||||||
|
threadPageInputSchema,
|
||||||
|
} from './inputs'
|
||||||
|
|
||||||
|
describe('threadPageInputSchema', () => {
|
||||||
|
it('accepts initial and complete continuation requests', () => {
|
||||||
|
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
|
||||||
|
tweetId: '123',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
threadPageInputSchema.parse({
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ tweetId: '' },
|
||||||
|
{ tweetId: 'abc' },
|
||||||
|
{ tweetId: '123', cursor: 'thread-next' },
|
||||||
|
{ tweetId: '123', conversationId: '100' },
|
||||||
|
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
|
||||||
|
])('rejects an invalid thread request %#', (input) => {
|
||||||
|
expect(() => threadPageInputSchema.parse(input)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeTweetId', () => {
|
||||||
|
it('keeps a decimal post ID', () => {
|
||||||
|
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
|
||||||
|
expect(() => normalizeTweetId(input)).toThrow(
|
||||||
|
'投稿 ID を確認してください。',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('normalizeUserTarget', () => {
|
describe('normalizeUserTarget', () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -21,6 +68,28 @@ describe('normalizeUserTarget', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('normalizeListTarget', () => {
|
||||||
|
it.each([
|
||||||
|
['1234567890', '1234567890'],
|
||||||
|
[' https://x.com/i/lists/1234567890 ', '1234567890'],
|
||||||
|
['https://twitter.com/i/lists/1234567890/', '1234567890'],
|
||||||
|
])('normalizes %s', (input, expected) => {
|
||||||
|
expect(normalizeListTarget(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'not-a-list',
|
||||||
|
'12345',
|
||||||
|
'https://example.com/i/lists/1234567890',
|
||||||
|
'https://x.com/tan_stack/lists',
|
||||||
|
'https://x.com/i/lists/1234567890/members',
|
||||||
|
'https://x.com/i/lists/not-a-list',
|
||||||
|
])('rejects %s', (input) => {
|
||||||
|
expect(() => normalizeListTarget(input)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('buildSearchQuery', () => {
|
describe('buildSearchQuery', () => {
|
||||||
it('keeps a deliberate query unchanged', () => {
|
it('keeps a deliberate query unchanged', () => {
|
||||||
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
||||||
|
|||||||
@@ -2,14 +2,40 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||||
|
const TWEET_ID = /^\d{1,32}$/
|
||||||
|
const LIST_ID = /^\d{1,32}$/
|
||||||
|
const tweetIdSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(TWEET_ID, '投稿 ID を確認してください。')
|
||||||
|
|
||||||
export class InputError extends Error {}
|
export class InputError extends Error {}
|
||||||
|
|
||||||
|
export const threadPageInputSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
tweetId: tweetIdSchema,
|
||||||
|
conversationId: z.undefined().optional(),
|
||||||
|
cursor: z.undefined().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
tweetId: tweetIdSchema,
|
||||||
|
conversationId: tweetIdSchema,
|
||||||
|
cursor: z.string().min(1),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
export const userPageInputSchema = z.object({
|
export const userPageInputSchema = z.object({
|
||||||
target: z.string().trim().min(1).max(256),
|
target: z.string().trim().min(1).max(256),
|
||||||
cursor: z.string().min(1).optional(),
|
cursor: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const listPageInputSchema = z.object({
|
||||||
|
target: z.string().trim().min(1).max(256),
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const listChoicesInputSchema = z.object({})
|
||||||
|
|
||||||
export const searchPageInputSchema = z.object({
|
export const searchPageInputSchema = z.object({
|
||||||
query: z.string().trim().min(1).max(512),
|
query: z.string().trim().min(1).max(512),
|
||||||
product: z.enum(['Top', 'Latest']),
|
product: z.enum(['Top', 'Latest']),
|
||||||
@@ -17,16 +43,20 @@ export const searchPageInputSchema = z.object({
|
|||||||
cursor: z.string().min(1).optional(),
|
cursor: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const userRouteSearchSchema = z.object({
|
|
||||||
target: z.string().catch(''),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const postSearchRouteSchema = z.object({
|
export const postSearchRouteSchema = z.object({
|
||||||
q: z.string().catch(''),
|
q: z.string().catch(''),
|
||||||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||||
following: z.boolean().catch(false),
|
following: z.boolean().catch(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function normalizeTweetId(raw: string): string {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!TWEET_ID.test(value)) {
|
||||||
|
throw new InputError('投稿 ID を確認してください。')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
function requireHandle(value: string): string {
|
function requireHandle(value: string): string {
|
||||||
if (!HANDLE.test(value)) {
|
if (!HANDLE.test(value)) {
|
||||||
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
||||||
@@ -62,6 +92,38 @@ export function normalizeUserTarget(raw: string): string {
|
|||||||
return requireHandle(segments[0] ?? '')
|
return requireHandle(segments[0] ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireListId(value: string): string {
|
||||||
|
if (!LIST_ID.test(value)) {
|
||||||
|
throw new InputError('リスト ID は数字で入力してください。')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeListTarget(raw: string): string {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!value) {
|
||||||
|
throw new InputError('リストの URL または ID を入力してください。')
|
||||||
|
}
|
||||||
|
if (!value.includes('://')) {
|
||||||
|
return requireListId(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(value)
|
||||||
|
} catch {
|
||||||
|
throw new InputError('リスト URL の形式を確認してください。')
|
||||||
|
}
|
||||||
|
if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) {
|
||||||
|
throw new InputError('x.com または twitter.com の URL を入力してください。')
|
||||||
|
}
|
||||||
|
const segments = url.pathname.split('/').filter(Boolean)
|
||||||
|
if (segments.length !== 3 || segments[0] !== 'i' || segments[1] !== 'lists') {
|
||||||
|
throw new InputError('リストの URL を入力してください。')
|
||||||
|
}
|
||||||
|
return requireListId(segments[2] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSearchQuery(raw: string, following: boolean): string {
|
export function buildSearchQuery(raw: string, following: boolean): string {
|
||||||
const query = raw.trim()
|
const query = raw.trim()
|
||||||
if (!query) {
|
if (!query) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { flattenPostPages } from './page'
|
import {
|
||||||
import type { Post, PostPage } from './types'
|
flattenConversationPages,
|
||||||
|
flattenPostPages,
|
||||||
|
focalPostFromPages,
|
||||||
|
} from './page'
|
||||||
|
import type { Post, PostPage, ThreadPage } from './types'
|
||||||
|
|
||||||
const post = (id: string): Post => ({
|
const post = (id: string): Post => ({
|
||||||
id,
|
id,
|
||||||
@@ -18,3 +22,29 @@ describe('flattenPostPages', () => {
|
|||||||
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
|
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('thread page helpers', () => {
|
||||||
|
const focal = post('2')
|
||||||
|
const pages: ThreadPage[] = [
|
||||||
|
{
|
||||||
|
focalPost: focal,
|
||||||
|
conversationId: '1',
|
||||||
|
nextCursor: 'next',
|
||||||
|
tweets: [post('1'), focal],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
conversationId: '1',
|
||||||
|
tweets: [focal, post('3')],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('takes the focal post from the first page', () => {
|
||||||
|
expect(focalPostFromPages(pages)).toEqual(focal)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates the conversation and removes the focal post', () => {
|
||||||
|
expect(
|
||||||
|
flattenConversationPages(pages, focal.id).map(({ id }) => id),
|
||||||
|
).toEqual(['1', '3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Post, PostPage } from './types'
|
import type { Post, PostPage, ThreadPage } from './types'
|
||||||
|
|
||||||
export function flattenPostPages(pages: PostPage[]): Post[] {
|
export function flattenPostPages(pages: PostPage[]): Post[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -10,3 +10,17 @@ export function flattenPostPages(pages: PostPage[]): Post[] {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function focalPostFromPages(
|
||||||
|
pages: Array<PostPage | ThreadPage>,
|
||||||
|
): Post | undefined {
|
||||||
|
const first = pages[0]
|
||||||
|
return first && 'focalPost' in first ? first.focalPost : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenConversationPages(
|
||||||
|
pages: Array<PostPage | ThreadPage>,
|
||||||
|
focalPostId: string,
|
||||||
|
): Post[] {
|
||||||
|
return flattenPostPages(pages).filter(({ id }) => id !== focalPostId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,50 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { type BirdReader, loadUserPage, searchPage } from './post-service'
|
import {
|
||||||
|
type BirdReader,
|
||||||
|
loadListChoices,
|
||||||
|
loadListPage,
|
||||||
|
loadThreadPage,
|
||||||
|
loadUserPage,
|
||||||
|
searchPage,
|
||||||
|
} from './post-service'
|
||||||
|
|
||||||
const reader = (): BirdReader => ({
|
const reader = (): BirdReader => ({
|
||||||
|
getLists: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
lists: [
|
||||||
|
{
|
||||||
|
id: '1234567890',
|
||||||
|
name: 'My List',
|
||||||
|
description: 'Useful accounts',
|
||||||
|
memberCount: 10,
|
||||||
|
subscriberCount: 4,
|
||||||
|
isPrivate: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
owner: { id: '42', username: 'private', name: 'Private' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getTweet: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweet: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
conversationId: '100',
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getThreadPaged: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '100',
|
||||||
|
text: 'root',
|
||||||
|
conversationId: '100',
|
||||||
|
author: { username: 'root', name: 'Root' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: 'thread-next',
|
||||||
|
}),
|
||||||
getUserIdByUsername: vi
|
getUserIdByUsername: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ success: true, userId: '42' }),
|
.mockResolvedValue({ success: true, userId: '42' }),
|
||||||
@@ -17,6 +60,283 @@ const reader = (): BirdReader => ({
|
|||||||
tweets: [],
|
tweets: [],
|
||||||
nextCursor: 'search-next',
|
nextCursor: 'search-next',
|
||||||
}),
|
}),
|
||||||
|
getAllListTimeline: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [
|
||||||
|
{ id: '7', text: 'listed', author: { username: 'bird', name: 'Bird' } },
|
||||||
|
],
|
||||||
|
nextCursor: 'list-next',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadListChoices', () => {
|
||||||
|
it('returns only the fields needed by the list picker', async () => {
|
||||||
|
const client = reader()
|
||||||
|
|
||||||
|
expect(await loadListChoices(client)).toEqual({
|
||||||
|
ok: true,
|
||||||
|
lists: [
|
||||||
|
{
|
||||||
|
id: '1234567890',
|
||||||
|
name: 'My List',
|
||||||
|
description: 'Useful accounts',
|
||||||
|
memberCount: 10,
|
||||||
|
isPrivate: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(client.getLists).toHaveBeenCalledWith(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a safe retryable error without leaking Bird details', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getLists).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'PRIVATE_LIST_DIAGNOSTIC',
|
||||||
|
})
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await loadListChoices(client)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'upstream',
|
||||||
|
message: 'X から投稿を取得できませんでした。',
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(result)).not.toContain('PRIVATE_LIST_DIAGNOSTIC')
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadThreadPage', () => {
|
||||||
|
it('resolves the focal post and fetches one root conversation page', async () => {
|
||||||
|
const client = reader()
|
||||||
|
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||||
|
|
||||||
|
expect(client.getTweet).toHaveBeenCalledWith('123')
|
||||||
|
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
|
||||||
|
maxPages: 1,
|
||||||
|
pageDelayMs: 0,
|
||||||
|
})
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
focalPost: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
conversationId: '100',
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
},
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '100',
|
||||||
|
text: 'root',
|
||||||
|
conversationId: '100',
|
||||||
|
author: { username: 'root', name: 'Root' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversationId: '100',
|
||||||
|
nextCursor: 'thread-next',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the carried root for exactly one continuation read', async () => {
|
||||||
|
const client = reader()
|
||||||
|
const result = await loadThreadPage(client, {
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(client.getTweet).not.toHaveBeenCalled()
|
||||||
|
expect(client.getThreadPaged).toHaveBeenCalledOnce()
|
||||||
|
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
|
||||||
|
cursor: 'thread-next',
|
||||||
|
maxPages: 1,
|
||||||
|
pageDelayMs: 0,
|
||||||
|
})
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
page: { conversationId: '100', focalPost: undefined },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose a continuation when the focal post has no replies', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweet: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
conversationId: '100',
|
||||||
|
replyCount: 0,
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
page: { nextCursor: undefined },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'an empty final page',
|
||||||
|
tweets: [],
|
||||||
|
returnedCursor: 'thread-final',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'the same cursor again',
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '101',
|
||||||
|
text: 'last reply',
|
||||||
|
author: { username: 'reply', name: 'Reply' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnedCursor: 'thread-next',
|
||||||
|
},
|
||||||
|
])('stops after $name', async ({ tweets, returnedCursor }) => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets,
|
||||||
|
nextCursor: returnedCursor,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await loadThreadPage(client, {
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
page: { nextCursor: undefined },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes raw data from focal, conversation, and quotes', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweet: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
conversationId: '100',
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
quotedTweet: {
|
||||||
|
id: '90',
|
||||||
|
text: 'quote',
|
||||||
|
author: { username: 'quote', name: 'Quote' },
|
||||||
|
_raw: { rest_id: 'private-quote' },
|
||||||
|
},
|
||||||
|
_raw: { rest_id: 'private-focal' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '100',
|
||||||
|
text: 'root',
|
||||||
|
conversationId: '100',
|
||||||
|
author: { username: 'root', name: 'Root' },
|
||||||
|
_raw: { rest_id: 'private-thread' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||||
|
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a safe non-retryable missing-post error', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'Tweet not found: private relay detail',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'post-not-found',
|
||||||
|
message: '投稿が見つかりませんでした。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies malformed Bird conversation metadata as an upstream failure', async () => {
|
||||||
|
const client = reader()
|
||||||
|
const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9'
|
||||||
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweet: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
conversationId: privateMetadata,
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'upstream',
|
||||||
|
message: 'X から投稿を取得できませんでした。',
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(client.getTweet).toHaveBeenCalledWith('123')
|
||||||
|
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
'Unexpected Bird failure:',
|
||||||
|
expect.objectContaining({
|
||||||
|
message: '投稿 ID を確認してください。',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(JSON.stringify(result)).not.toContain(privateMetadata)
|
||||||
|
expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(
|
||||||
|
privateMetadata,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
errorSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid ID without a Bird call', async () => {
|
||||||
|
const client = reader()
|
||||||
|
|
||||||
|
expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'invalid-input',
|
||||||
|
message: '投稿 ID を確認してください。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(client.getTweet).not.toHaveBeenCalled()
|
||||||
|
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loadUserPage', () => {
|
describe('loadUserPage', () => {
|
||||||
@@ -57,6 +377,101 @@ describe('loadUserPage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('loadListPage', () => {
|
||||||
|
it('resolves a list URL and fetches exactly one page', async () => {
|
||||||
|
const client = reader()
|
||||||
|
const result = await loadListPage(client, {
|
||||||
|
target: 'https://x.com/i/lists/1234567890',
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(client.getAllListTimeline).toHaveBeenCalledWith('1234567890', {
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
maxPages: 1,
|
||||||
|
})
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
text: 'listed',
|
||||||
|
author: { username: 'bird', name: 'Bird' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: 'list-next',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid target without a Bird call', async () => {
|
||||||
|
const client = reader()
|
||||||
|
|
||||||
|
expect(await loadListPage(client, { target: 'not-a-list' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'invalid-input',
|
||||||
|
message: 'リスト ID は数字で入力してください。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(client.getAllListTimeline).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a safe not-found error', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getAllListTimeline).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'List not found: private relay detail',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await loadListPage(client, { target: '1234567890' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'list-not-found',
|
||||||
|
message: 'リストが見つかりませんでした。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a safe unavailable error for a protected list', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getAllListTimeline).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'List is protected: private relay detail',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await loadListPage(client, { target: '1234567890' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'list-unavailable',
|
||||||
|
message: 'このリストは取得できません。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes raw response data from posts', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getAllListTimeline).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
text: 'listed',
|
||||||
|
author: { username: 'bird', name: 'Bird' },
|
||||||
|
_raw: { rest_id: 'private-list-detail' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await loadListPage(client, { target: '1234567890' })
|
||||||
|
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('searchPage', () => {
|
describe('searchPage', () => {
|
||||||
it('forwards Top and appends follows once', async () => {
|
it('forwards Top and appends follows once', async () => {
|
||||||
const client = reader()
|
const client = reader()
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
|
|
||||||
import { buildSearchQuery, InputError, normalizeUserTarget } from './inputs'
|
|
||||||
import type {
|
import type {
|
||||||
|
GetTweetResult,
|
||||||
|
SearchProduct,
|
||||||
|
SearchResult,
|
||||||
|
TweetData,
|
||||||
|
} from '@yuta/bird'
|
||||||
|
import {
|
||||||
|
buildSearchQuery,
|
||||||
|
InputError,
|
||||||
|
normalizeListTarget,
|
||||||
|
normalizeTweetId,
|
||||||
|
normalizeUserTarget,
|
||||||
|
} from './inputs'
|
||||||
|
import type {
|
||||||
|
ListChoicesResult,
|
||||||
|
ListPageInput,
|
||||||
LoadError,
|
LoadError,
|
||||||
|
LoadFailure,
|
||||||
LoadResult,
|
LoadResult,
|
||||||
Post,
|
Post,
|
||||||
SearchPageInput,
|
SearchPageInput,
|
||||||
|
ThreadLoadResult,
|
||||||
|
ThreadPageInput,
|
||||||
UserPageInput,
|
UserPageInput,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -14,7 +30,30 @@ type UserLookupResult = {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BirdListsResult =
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
lists?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
memberCount?: number
|
||||||
|
isPrivate?: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
| { success: false; error?: string }
|
||||||
|
|
||||||
export interface BirdReader {
|
export interface BirdReader {
|
||||||
|
getLists(count?: number): Promise<BirdListsResult>
|
||||||
|
getTweet(tweetId: string): Promise<GetTweetResult>
|
||||||
|
getThreadPaged(
|
||||||
|
tweetId: string,
|
||||||
|
options: {
|
||||||
|
cursor?: string
|
||||||
|
maxPages: number
|
||||||
|
pageDelayMs: number
|
||||||
|
},
|
||||||
|
): Promise<SearchResult>
|
||||||
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
||||||
getUserTweetsPaged(
|
getUserTweetsPaged(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -33,15 +72,43 @@ export interface BirdReader {
|
|||||||
maxPages: number
|
maxPages: number
|
||||||
},
|
},
|
||||||
): Promise<SearchResult>
|
): Promise<SearchResult>
|
||||||
|
getAllListTimeline(
|
||||||
|
listId: string,
|
||||||
|
options: {
|
||||||
|
cursor?: string
|
||||||
|
maxPages: number
|
||||||
|
},
|
||||||
|
): Promise<SearchResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = (
|
const failure = (
|
||||||
code: LoadError['code'],
|
code: LoadError['code'],
|
||||||
message: string,
|
message: string,
|
||||||
retryable: boolean,
|
retryable: boolean,
|
||||||
): LoadResult => ({ ok: false, error: { code, message, retryable } })
|
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
|
||||||
|
|
||||||
function upstreamFailure(diagnostic: unknown = ''): LoadResult {
|
type FailureSubject = 'user' | 'post' | 'list'
|
||||||
|
|
||||||
|
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||||
|
user: () =>
|
||||||
|
failure('user-not-found', 'ユーザーが見つかりませんでした。', false),
|
||||||
|
post: () => failure('post-not-found', '投稿が見つかりませんでした。', false),
|
||||||
|
list: () =>
|
||||||
|
failure('list-not-found', 'リストが見つかりませんでした。', false),
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
|
||||||
|
user: () =>
|
||||||
|
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false),
|
||||||
|
post: () => failure('post-unavailable', 'この投稿は取得できません。', false),
|
||||||
|
list: () =>
|
||||||
|
failure('list-unavailable', 'このリストは取得できません。', false),
|
||||||
|
}
|
||||||
|
|
||||||
|
function upstreamFailure(
|
||||||
|
diagnostic: unknown = '',
|
||||||
|
subject: FailureSubject = 'user',
|
||||||
|
): LoadFailure {
|
||||||
const message =
|
const message =
|
||||||
diagnostic instanceof Error
|
diagnostic instanceof Error
|
||||||
? diagnostic.message
|
? diagnostic.message
|
||||||
@@ -53,14 +120,10 @@ function upstreamFailure(diagnostic: unknown = ''): LoadResult {
|
|||||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
}
|
}
|
||||||
if (lower.includes('not found')) {
|
if (lower.includes('not found')) {
|
||||||
return failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
return notFoundFailure[subject]()
|
||||||
}
|
}
|
||||||
if (lower.includes('suspended') || lower.includes('protected')) {
|
if (lower.includes('suspended') || lower.includes('protected')) {
|
||||||
return failure(
|
return unavailableFailure[subject]()
|
||||||
'user-unavailable',
|
|
||||||
'このユーザーの投稿は取得できません。',
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
console.error('Unexpected Bird failure:', diagnostic)
|
console.error('Unexpected Bird failure:', diagnostic)
|
||||||
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
||||||
@@ -75,7 +138,38 @@ function publicPost(tweet: TweetData): Post {
|
|||||||
return post
|
return post
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultPage(result: SearchResult): LoadResult {
|
export async function loadListChoices(
|
||||||
|
reader: BirdReader,
|
||||||
|
): Promise<ListChoicesResult> {
|
||||||
|
try {
|
||||||
|
const result = await reader.getLists(100)
|
||||||
|
if (!result.success) {
|
||||||
|
return upstreamFailure(result.error, 'list')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
lists: (result.lists ?? []).map((list) => ({
|
||||||
|
id: list.id,
|
||||||
|
name: list.name,
|
||||||
|
...(list.description ? { description: list.description } : {}),
|
||||||
|
...(list.memberCount === undefined
|
||||||
|
? {}
|
||||||
|
: { memberCount: list.memberCount }),
|
||||||
|
...(list.isPrivate === undefined ? {} : { isPrivate: list.isPrivate }),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
|
}
|
||||||
|
return upstreamFailure(error, 'list')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultPage(
|
||||||
|
result: SearchResult,
|
||||||
|
subject: FailureSubject = 'user',
|
||||||
|
): LoadResult {
|
||||||
return result.success
|
return result.success
|
||||||
? {
|
? {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -84,7 +178,67 @@ function resultPage(result: SearchResult): LoadResult {
|
|||||||
nextCursor: result.nextCursor,
|
nextCursor: result.nextCursor,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: upstreamFailure(result.error)
|
: upstreamFailure(result.error, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadPage(
|
||||||
|
reader: BirdReader,
|
||||||
|
input: ThreadPageInput,
|
||||||
|
): Promise<ThreadLoadResult> {
|
||||||
|
try {
|
||||||
|
const tweetId = normalizeTweetId(input.tweetId)
|
||||||
|
let focalPost: Post | undefined
|
||||||
|
let conversationId: string
|
||||||
|
|
||||||
|
if (input.cursor && input.conversationId) {
|
||||||
|
conversationId = normalizeTweetId(input.conversationId)
|
||||||
|
} else {
|
||||||
|
const focal = await reader.getTweet(tweetId)
|
||||||
|
if (!focal.success || !focal.tweet) {
|
||||||
|
return upstreamFailure(focal.error ?? 'Tweet not found', 'post')
|
||||||
|
}
|
||||||
|
focalPost = publicPost(focal.tweet)
|
||||||
|
try {
|
||||||
|
conversationId = normalizeTweetId(
|
||||||
|
focal.tweet.conversationId ?? focal.tweet.id,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error, 'post')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await reader.getThreadPaged(conversationId, {
|
||||||
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
||||||
|
maxPages: 1,
|
||||||
|
pageDelayMs: 0,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
return upstreamFailure(result.error, 'post')
|
||||||
|
}
|
||||||
|
const tweets = result.tweets ?? []
|
||||||
|
const nextCursor =
|
||||||
|
result.nextCursor &&
|
||||||
|
tweets.length > 0 &&
|
||||||
|
result.nextCursor !== input.cursor &&
|
||||||
|
focalPost?.replyCount !== 0
|
||||||
|
? result.nextCursor
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
tweets: tweets.map(publicPost),
|
||||||
|
focalPost,
|
||||||
|
conversationId,
|
||||||
|
nextCursor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InputError) {
|
||||||
|
return failure('invalid-input', error.message, false)
|
||||||
|
}
|
||||||
|
return upstreamFailure(error, 'post')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserPage(
|
export async function loadUserPage(
|
||||||
@@ -115,6 +269,30 @@ export async function loadUserPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadListPage(
|
||||||
|
reader: BirdReader,
|
||||||
|
input: ListPageInput,
|
||||||
|
): Promise<LoadResult> {
|
||||||
|
try {
|
||||||
|
const listId = normalizeListTarget(input.target)
|
||||||
|
return resultPage(
|
||||||
|
await reader.getAllListTimeline(listId, {
|
||||||
|
cursor: input.cursor,
|
||||||
|
maxPages: 1,
|
||||||
|
}),
|
||||||
|
'list',
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
|
}
|
||||||
|
if (error instanceof InputError) {
|
||||||
|
return failure('invalid-input', error.message, false)
|
||||||
|
}
|
||||||
|
return upstreamFailure(error, 'list')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
reader: BirdReader,
|
reader: BirdReader,
|
||||||
input: SearchPageInput,
|
input: SearchPageInput,
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
import { searchPageInputSchema, userPageInputSchema } from './inputs'
|
import {
|
||||||
import { loadUserPage, searchPage } from './post-service'
|
listChoicesInputSchema,
|
||||||
import type { LoadResult } from './types'
|
listPageInputSchema,
|
||||||
|
searchPageInputSchema,
|
||||||
|
threadPageInputSchema,
|
||||||
|
userPageInputSchema,
|
||||||
|
} from './inputs'
|
||||||
|
import {
|
||||||
|
loadListChoices,
|
||||||
|
loadListPage,
|
||||||
|
loadThreadPage,
|
||||||
|
loadUserPage,
|
||||||
|
searchPage,
|
||||||
|
} from './post-service'
|
||||||
|
import type { LoadFailure } from './types'
|
||||||
|
|
||||||
const configFailure = (diagnostic: unknown): LoadResult => {
|
const configFailure = (diagnostic: unknown): LoadFailure => {
|
||||||
console.error('Bird client configuration failed:', diagnostic)
|
console.error('Bird client configuration failed:', diagnostic)
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -30,6 +42,26 @@ export const loadUserPosts = createServerFn({ method: 'GET' })
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const loadListPosts = createServerFn({ method: 'GET' })
|
||||||
|
.validator(listPageInputSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
try {
|
||||||
|
return await loadListPage(await reader(), data)
|
||||||
|
} catch (error) {
|
||||||
|
return configFailure(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadLists = createServerFn({ method: 'GET' })
|
||||||
|
.validator(listChoicesInputSchema)
|
||||||
|
.handler(async () => {
|
||||||
|
try {
|
||||||
|
return await loadListChoices(await reader())
|
||||||
|
} catch (error) {
|
||||||
|
return configFailure(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const searchPosts = createServerFn({ method: 'GET' })
|
export const searchPosts = createServerFn({ method: 'GET' })
|
||||||
.validator(searchPageInputSchema)
|
.validator(searchPageInputSchema)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
@@ -39,3 +71,13 @@ export const searchPosts = createServerFn({ method: 'GET' })
|
|||||||
return configFailure(error)
|
return configFailure(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const loadThreadPosts = createServerFn({ method: 'GET' })
|
||||||
|
.validator(threadPageInputSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
try {
|
||||||
|
return await loadThreadPage(await reader(), data)
|
||||||
|
} catch (error) {
|
||||||
|
return configFailure(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,8 +9,17 @@ export type PostPage = {
|
|||||||
nextCursor?: string
|
nextCursor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadErrorCode =
|
export type ThreadPage = PostPage & {
|
||||||
|
focalPost?: Post
|
||||||
|
conversationId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadErrorCode =
|
||||||
| 'invalid-input'
|
| 'invalid-input'
|
||||||
|
| 'list-not-found'
|
||||||
|
| 'list-unavailable'
|
||||||
|
| 'post-not-found'
|
||||||
|
| 'post-unavailable'
|
||||||
| 'user-not-found'
|
| 'user-not-found'
|
||||||
| 'user-unavailable'
|
| 'user-unavailable'
|
||||||
| 'relay-config'
|
| 'relay-config'
|
||||||
@@ -23,15 +32,46 @@ export type LoadError = {
|
|||||||
retryable: boolean
|
retryable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadResult =
|
export type LoadFailure = { ok: false; error: LoadError }
|
||||||
| { ok: true; page: PostPage }
|
|
||||||
| { ok: false; error: LoadError }
|
export type LoadResult<TPage extends PostPage = PostPage> =
|
||||||
|
| { ok: true; page: TPage }
|
||||||
|
| LoadFailure
|
||||||
|
|
||||||
|
export type ThreadLoadResult = LoadResult<ThreadPage>
|
||||||
|
|
||||||
|
export type ListChoice = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
memberCount?: number
|
||||||
|
isPrivate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListChoicesResult = { ok: true; lists: ListChoice[] } | LoadFailure
|
||||||
|
|
||||||
|
export type ThreadPageInput =
|
||||||
|
| {
|
||||||
|
tweetId: string
|
||||||
|
conversationId?: never
|
||||||
|
cursor?: never
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tweetId: string
|
||||||
|
conversationId: string
|
||||||
|
cursor: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UserPageInput = {
|
export type UserPageInput = {
|
||||||
target: string
|
target: string
|
||||||
cursor?: string
|
cursor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ListPageInput = {
|
||||||
|
target: string
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SearchPageInput = {
|
export type SearchPageInput = {
|
||||||
query: string
|
query: string
|
||||||
product: SearchProduct
|
product: SearchProduct
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
createListChoicesOptions,
|
||||||
|
ListChoicesLoadError,
|
||||||
|
} from './use-list-choices'
|
||||||
|
|
||||||
|
describe('createListChoicesOptions', () => {
|
||||||
|
it('loads the authenticated list choices once without automatic retries', async () => {
|
||||||
|
const loader = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
lists: [{ id: '123', name: 'My List' }],
|
||||||
|
})
|
||||||
|
const options = createListChoicesOptions(loader)
|
||||||
|
|
||||||
|
await expect(options.queryFn()).resolves.toEqual([
|
||||||
|
{ id: '123', name: 'My List' },
|
||||||
|
])
|
||||||
|
expect(loader).toHaveBeenCalledWith({ data: {} })
|
||||||
|
expect(options.queryKey).toEqual(['list-choices'])
|
||||||
|
expect(options.retry).toBe(false)
|
||||||
|
expect(options.refetchOnWindowFocus).toBe(false)
|
||||||
|
expect(options.refetchOnReconnect).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a typed load failure for the picker', async () => {
|
||||||
|
const error = {
|
||||||
|
code: 'upstream' as const,
|
||||||
|
message: '取得できませんでした。',
|
||||||
|
retryable: true,
|
||||||
|
}
|
||||||
|
const options = createListChoicesOptions(
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, error }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const request = options.queryFn()
|
||||||
|
await expect(request).rejects.toBeInstanceOf(ListChoicesLoadError)
|
||||||
|
await expect(request).rejects.toMatchObject({ detail: error })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
|
import { loadLists } from './server-functions'
|
||||||
|
import type { ListChoice, ListChoicesResult, LoadError } from './types'
|
||||||
|
|
||||||
|
type ListChoicesLoader = (options: {
|
||||||
|
data: Record<string, never>
|
||||||
|
}) => Promise<ListChoicesResult>
|
||||||
|
|
||||||
|
export class ListChoicesLoadError extends Error {
|
||||||
|
constructor(readonly detail: LoadError) {
|
||||||
|
super(detail.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createListChoicesOptions(loader: ListChoicesLoader) {
|
||||||
|
return {
|
||||||
|
queryKey: ['list-choices'] as const,
|
||||||
|
queryFn: async (): Promise<ListChoice[]> => {
|
||||||
|
const result = await loader({ data: {} })
|
||||||
|
if (!result.ok) throw new ListChoicesLoadError(result.error)
|
||||||
|
return result.lists
|
||||||
|
},
|
||||||
|
retry: false as const,
|
||||||
|
refetchOnWindowFocus: false as const,
|
||||||
|
refetchOnReconnect: false as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListChoices() {
|
||||||
|
const loader = useServerFn(loadLists)
|
||||||
|
return useQuery(createListChoicesOptions(loader))
|
||||||
|
}
|
||||||
@@ -16,6 +16,16 @@ vi.mock('@tanstack/react-start', async (importOriginal) => {
|
|||||||
return { ...original, useServerFn }
|
return { ...original, useServerFn }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type FeedLoaders = Parameters<typeof createPostFeedOptions>[1]
|
||||||
|
|
||||||
|
const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
|
||||||
|
loadUser: vi.fn(),
|
||||||
|
loadList: vi.fn(),
|
||||||
|
search: vi.fn(),
|
||||||
|
thread: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
describe('createPostFeedOptions', () => {
|
describe('createPostFeedOptions', () => {
|
||||||
it('forwards one user cursor in the server function payload', async () => {
|
it('forwards one user cursor in the server function payload', async () => {
|
||||||
const loadUser = vi.fn().mockResolvedValue({
|
const loadUser = vi.fn().mockResolvedValue({
|
||||||
@@ -24,7 +34,7 @@ describe('createPostFeedOptions', () => {
|
|||||||
})
|
})
|
||||||
const options = createPostFeedOptions(
|
const options = createPostFeedOptions(
|
||||||
{ kind: 'user', target: '@yuta' },
|
{ kind: 'user', target: '@yuta' },
|
||||||
{ loadUser, search: vi.fn() },
|
loaders({ loadUser }),
|
||||||
)
|
)
|
||||||
|
|
||||||
await options.queryFn({ pageParam: 'cursor' } as never)
|
await options.queryFn({ pageParam: 'cursor' } as never)
|
||||||
@@ -35,10 +45,32 @@ describe('createPostFeedOptions', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('forwards one list cursor in the server function payload', async () => {
|
||||||
|
const loadList = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
page: { tweets: [], nextCursor: 'next' },
|
||||||
|
})
|
||||||
|
const options = createPostFeedOptions(
|
||||||
|
{ kind: 'list', target: '1234567890' },
|
||||||
|
loaders({ loadList }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await options.queryFn({ pageParam: 'cursor' } as never)
|
||||||
|
|
||||||
|
expect(loadList).toHaveBeenCalledOnce()
|
||||||
|
expect(loadList).toHaveBeenCalledWith({
|
||||||
|
data: { target: '1234567890', cursor: 'cursor' },
|
||||||
|
})
|
||||||
|
expect(options.queryKey).toEqual([
|
||||||
|
'posts',
|
||||||
|
{ kind: 'list', target: '1234567890' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('does not retry requests automatically', () => {
|
it('does not retry requests automatically', () => {
|
||||||
const options = createPostFeedOptions(
|
const options = createPostFeedOptions(
|
||||||
{ kind: 'user', target: 'yuta' },
|
{ kind: 'user', target: 'yuta' },
|
||||||
{ loadUser: vi.fn(), search: vi.fn() },
|
loaders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(options.retry).toBe(false)
|
expect(options.retry).toBe(false)
|
||||||
@@ -47,7 +79,7 @@ describe('createPostFeedOptions', () => {
|
|||||||
it('does not refetch automatically when the window regains focus', () => {
|
it('does not refetch automatically when the window regains focus', () => {
|
||||||
const options = createPostFeedOptions(
|
const options = createPostFeedOptions(
|
||||||
{ kind: 'user', target: 'yuta' },
|
{ kind: 'user', target: 'yuta' },
|
||||||
{ loadUser: vi.fn(), search: vi.fn() },
|
loaders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(options.refetchOnWindowFocus).toBe(false)
|
expect(options.refetchOnWindowFocus).toBe(false)
|
||||||
@@ -56,7 +88,7 @@ describe('createPostFeedOptions', () => {
|
|||||||
it('does not refetch automatically when the connection returns', () => {
|
it('does not refetch automatically when the connection returns', () => {
|
||||||
const options = createPostFeedOptions(
|
const options = createPostFeedOptions(
|
||||||
{ kind: 'user', target: 'yuta' },
|
{ kind: 'user', target: 'yuta' },
|
||||||
{ loadUser: vi.fn(), search: vi.fn() },
|
loaders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(options.refetchOnReconnect).toBe(false)
|
expect(options.refetchOnReconnect).toBe(false)
|
||||||
@@ -65,7 +97,7 @@ describe('createPostFeedOptions', () => {
|
|||||||
it('takes the next cursor only from the resolved page', () => {
|
it('takes the next cursor only from the resolved page', () => {
|
||||||
const options = createPostFeedOptions(
|
const options = createPostFeedOptions(
|
||||||
{ kind: 'user', target: 'yuta' },
|
{ kind: 'user', target: 'yuta' },
|
||||||
{ loadUser: vi.fn(), search: vi.fn() },
|
loaders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -82,7 +114,7 @@ describe('createPostFeedOptions', () => {
|
|||||||
product: 'Top',
|
product: 'Top',
|
||||||
following: true,
|
following: true,
|
||||||
},
|
},
|
||||||
{ loadUser: vi.fn(), search: vi.fn() },
|
loaders(),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(options.queryKey).toEqual([
|
expect(options.queryKey).toEqual([
|
||||||
@@ -103,7 +135,7 @@ describe('createPostFeedOptions', () => {
|
|||||||
product: 'Top',
|
product: 'Top',
|
||||||
following: true,
|
following: true,
|
||||||
},
|
},
|
||||||
{ loadUser: vi.fn(), search },
|
loaders({ search }),
|
||||||
)
|
)
|
||||||
|
|
||||||
await options.queryFn({ pageParam: 'search-cursor' } as never)
|
await options.queryFn({ pageParam: 'search-cursor' } as never)
|
||||||
@@ -119,6 +151,61 @@ describe('createPostFeedOptions', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads the initial thread and carries its root with the next cursor', async () => {
|
||||||
|
const thread = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
tweets: [],
|
||||||
|
focalPost: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
},
|
||||||
|
conversationId: '100',
|
||||||
|
nextCursor: 'thread-next',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const options = createPostFeedOptions(
|
||||||
|
{ kind: 'thread', tweetId: '123' },
|
||||||
|
loaders({ thread }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const page = await options.queryFn({ pageParam: undefined } as never)
|
||||||
|
|
||||||
|
expect(thread).toHaveBeenCalledWith({ data: { tweetId: '123' } })
|
||||||
|
expect(options.queryKey).toEqual([
|
||||||
|
'posts',
|
||||||
|
{ kind: 'thread', tweetId: '123' },
|
||||||
|
])
|
||||||
|
expect(options.getNextPageParam(page)).toEqual({
|
||||||
|
cursor: 'thread-next',
|
||||||
|
conversationId: '100',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards a thread continuation page parameter intact', async () => {
|
||||||
|
const thread = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
page: { tweets: [], conversationId: '100' },
|
||||||
|
})
|
||||||
|
const options = createPostFeedOptions(
|
||||||
|
{ kind: 'thread', tweetId: '123' },
|
||||||
|
loaders({ thread }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await options.queryFn({
|
||||||
|
pageParam: { cursor: 'thread-next', conversationId: '100' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
expect(thread).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
tweetId: '123',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
conversationId: '100',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves a typed load failure for the feed', async () => {
|
it('preserves a typed load failure for the feed', async () => {
|
||||||
const error = {
|
const error = {
|
||||||
code: 'upstream' as const,
|
code: 'upstream' as const,
|
||||||
@@ -127,10 +214,9 @@ describe('createPostFeedOptions', () => {
|
|||||||
}
|
}
|
||||||
const options = createPostFeedOptions(
|
const options = createPostFeedOptions(
|
||||||
{ kind: 'user', target: 'yuta' },
|
{ kind: 'user', target: 'yuta' },
|
||||||
{
|
loaders({
|
||||||
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
|
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
|
||||||
search: vi.fn(),
|
}),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const request = options.queryFn({ pageParam: undefined } as never)
|
const request = options.queryFn({ pageParam: undefined } as never)
|
||||||
|
|||||||
@@ -1,27 +1,47 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
import { useServerFn } from '@tanstack/react-start'
|
import { useServerFn } from '@tanstack/react-start'
|
||||||
import type { SearchProduct } from '@yuta/bird'
|
import type { SearchProduct } from '@yuta/bird'
|
||||||
import { loadUserPosts, searchPosts } from './server-functions'
|
import {
|
||||||
|
loadListPosts,
|
||||||
|
loadThreadPosts,
|
||||||
|
loadUserPosts,
|
||||||
|
searchPosts,
|
||||||
|
} from './server-functions'
|
||||||
import type {
|
import type {
|
||||||
|
ListPageInput,
|
||||||
LoadError,
|
LoadError,
|
||||||
LoadResult,
|
LoadResult,
|
||||||
PostPage,
|
PostPage,
|
||||||
SearchPageInput,
|
SearchPageInput,
|
||||||
|
ThreadLoadResult,
|
||||||
|
ThreadPage,
|
||||||
|
ThreadPageInput,
|
||||||
UserPageInput,
|
UserPageInput,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export type FeedRequest =
|
export type FeedRequest =
|
||||||
| { kind: 'user'; target: string }
|
| { kind: 'user'; target: string }
|
||||||
|
| { kind: 'list'; target: string }
|
||||||
| {
|
| {
|
||||||
kind: 'search'
|
kind: 'search'
|
||||||
query: string
|
query: string
|
||||||
product: SearchProduct
|
product: SearchProduct
|
||||||
following: boolean
|
following: boolean
|
||||||
}
|
}
|
||||||
|
| { kind: 'thread'; tweetId: string }
|
||||||
|
|
||||||
|
type ThreadPageParam = {
|
||||||
|
cursor: string
|
||||||
|
conversationId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedPageParam = string | ThreadPageParam | undefined
|
||||||
|
|
||||||
type Loaders = {
|
type Loaders = {
|
||||||
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
|
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
|
||||||
|
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
|
||||||
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
||||||
|
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PostLoadError extends Error {
|
export class PostLoadError extends Error {
|
||||||
@@ -30,7 +50,7 @@ export class PostLoadError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrap(result: LoadResult): PostPage {
|
function unwrap<TPage extends PostPage>(result: LoadResult<TPage>): TPage {
|
||||||
if (!result.ok) throw new PostLoadError(result.error)
|
if (!result.ok) throw new PostLoadError(result.error)
|
||||||
return result.page
|
return result.page
|
||||||
}
|
}
|
||||||
@@ -38,41 +58,85 @@ function unwrap(result: LoadResult): PostPage {
|
|||||||
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||||
return {
|
return {
|
||||||
queryKey: ['posts', request] as const,
|
queryKey: ['posts', request] as const,
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as FeedPageParam,
|
||||||
retry: false as const,
|
retry: false as const,
|
||||||
refetchOnWindowFocus: false as const,
|
refetchOnWindowFocus: false as const,
|
||||||
refetchOnReconnect: false as const,
|
refetchOnReconnect: false as const,
|
||||||
queryFn: async ({ pageParam }: { pageParam: string | undefined }) =>
|
queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
|
||||||
request.kind === 'user'
|
if (request.kind === 'user') {
|
||||||
? unwrap(
|
return unwrap(
|
||||||
await loaders.loadUser({
|
await loaders.loadUser({
|
||||||
data: { target: request.target, cursor: pageParam },
|
data: {
|
||||||
}),
|
target: request.target,
|
||||||
)
|
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||||
: unwrap(
|
},
|
||||||
await loaders.search({
|
}),
|
||||||
data: {
|
)
|
||||||
query: request.query,
|
}
|
||||||
product: request.product,
|
if (request.kind === 'list') {
|
||||||
following: request.following,
|
return unwrap(
|
||||||
cursor: pageParam,
|
await loaders.loadList({
|
||||||
},
|
data: {
|
||||||
}),
|
target: request.target,
|
||||||
),
|
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||||
getNextPageParam: (page: PostPage) => page.nextCursor,
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (request.kind === 'search') {
|
||||||
|
return unwrap(
|
||||||
|
await loaders.search({
|
||||||
|
data: {
|
||||||
|
query: request.query,
|
||||||
|
product: request.product,
|
||||||
|
following: request.following,
|
||||||
|
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return unwrap(
|
||||||
|
await loaders.thread({
|
||||||
|
data:
|
||||||
|
typeof pageParam === 'object'
|
||||||
|
? {
|
||||||
|
tweetId: request.tweetId,
|
||||||
|
cursor: pageParam.cursor,
|
||||||
|
conversationId: pageParam.conversationId,
|
||||||
|
}
|
||||||
|
: { tweetId: request.tweetId },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
getNextPageParam: (page: PostPage | ThreadPage) => {
|
||||||
|
if (!page.nextCursor) return undefined
|
||||||
|
return request.kind === 'thread' && 'conversationId' in page
|
||||||
|
? {
|
||||||
|
cursor: page.nextCursor,
|
||||||
|
conversationId: page.conversationId,
|
||||||
|
}
|
||||||
|
: page.nextCursor
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePostFeed(request: FeedRequest | undefined) {
|
export function usePostFeed(request: FeedRequest | undefined) {
|
||||||
const loadUser = useServerFn(loadUserPosts)
|
const loadUser = useServerFn(loadUserPosts)
|
||||||
|
const loadList = useServerFn(loadListPosts)
|
||||||
const search = useServerFn(searchPosts)
|
const search = useServerFn(searchPosts)
|
||||||
|
const thread = useServerFn(loadThreadPosts)
|
||||||
const disabled = {
|
const disabled = {
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
target: '',
|
target: '',
|
||||||
} satisfies FeedRequest
|
} satisfies FeedRequest
|
||||||
|
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
...createPostFeedOptions(request ?? disabled, { loadUser, search }),
|
...createPostFeedOptions(request ?? disabled, {
|
||||||
|
loadUser,
|
||||||
|
loadList,
|
||||||
|
search,
|
||||||
|
thread,
|
||||||
|
}),
|
||||||
enabled: request !== undefined,
|
enabled: request !== undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-21
@@ -9,65 +9,107 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as UserRouteImport } from './routes/user'
|
|
||||||
import { Route as SearchRouteImport } from './routes/search'
|
import { Route as SearchRouteImport } from './routes/search'
|
||||||
|
import { Route as HandleRouteImport } from './routes/$handle'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as IListsRouteImport } from './routes/i.lists'
|
||||||
|
import { Route as IListsListIdRouteImport } from './routes/i.lists_.$listId'
|
||||||
|
import { Route as HandleStatusTweetIdRouteImport } from './routes/$handle_.status.$tweetId'
|
||||||
|
|
||||||
const UserRoute = UserRouteImport.update({
|
|
||||||
id: '/user',
|
|
||||||
path: '/user',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const SearchRoute = SearchRouteImport.update({
|
const SearchRoute = SearchRouteImport.update({
|
||||||
id: '/search',
|
id: '/search',
|
||||||
path: '/search',
|
path: '/search',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const HandleRoute = HandleRouteImport.update({
|
||||||
|
id: '/$handle',
|
||||||
|
path: '/$handle',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const IListsRoute = IListsRouteImport.update({
|
||||||
|
id: '/i/lists',
|
||||||
|
path: '/i/lists',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IListsListIdRoute = IListsListIdRouteImport.update({
|
||||||
|
id: '/i/lists_/$listId',
|
||||||
|
path: '/i/lists/$listId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
|
||||||
|
id: '/$handle_/status/$tweetId',
|
||||||
|
path: '/$handle/status/$tweetId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/$handle': typeof HandleRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/user': typeof UserRoute
|
'/i/lists': typeof IListsRoute
|
||||||
|
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||||
|
'/i/lists/$listId': typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/$handle': typeof HandleRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/user': typeof UserRoute
|
'/i/lists': typeof IListsRoute
|
||||||
|
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||||
|
'/i/lists/$listId': typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/$handle': typeof HandleRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/user': typeof UserRoute
|
'/i/lists': typeof IListsRoute
|
||||||
|
'/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||||
|
'/i/lists_/$listId': typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/search' | '/user'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/$handle'
|
||||||
|
| '/search'
|
||||||
|
| '/i/lists'
|
||||||
|
| '/$handle/status/$tweetId'
|
||||||
|
| '/i/lists/$listId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/search' | '/user'
|
to:
|
||||||
id: '__root__' | '/' | '/search' | '/user'
|
| '/'
|
||||||
|
| '/$handle'
|
||||||
|
| '/search'
|
||||||
|
| '/i/lists'
|
||||||
|
| '/$handle/status/$tweetId'
|
||||||
|
| '/i/lists/$listId'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/$handle'
|
||||||
|
| '/search'
|
||||||
|
| '/i/lists'
|
||||||
|
| '/$handle_/status/$tweetId'
|
||||||
|
| '/i/lists_/$listId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
HandleRoute: typeof HandleRoute
|
||||||
SearchRoute: typeof SearchRoute
|
SearchRoute: typeof SearchRoute
|
||||||
UserRoute: typeof UserRoute
|
IListsRoute: typeof IListsRoute
|
||||||
|
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
|
||||||
|
IListsListIdRoute: typeof IListsListIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/user': {
|
|
||||||
id: '/user'
|
|
||||||
path: '/user'
|
|
||||||
fullPath: '/user'
|
|
||||||
preLoaderRoute: typeof UserRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/search': {
|
'/search': {
|
||||||
id: '/search'
|
id: '/search'
|
||||||
path: '/search'
|
path: '/search'
|
||||||
@@ -75,6 +117,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SearchRouteImport
|
preLoaderRoute: typeof SearchRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/$handle': {
|
||||||
|
id: '/$handle'
|
||||||
|
path: '/$handle'
|
||||||
|
fullPath: '/$handle'
|
||||||
|
preLoaderRoute: typeof HandleRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -82,13 +131,37 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/i/lists': {
|
||||||
|
id: '/i/lists'
|
||||||
|
path: '/i/lists'
|
||||||
|
fullPath: '/i/lists'
|
||||||
|
preLoaderRoute: typeof IListsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/i/lists_/$listId': {
|
||||||
|
id: '/i/lists_/$listId'
|
||||||
|
path: '/i/lists/$listId'
|
||||||
|
fullPath: '/i/lists/$listId'
|
||||||
|
preLoaderRoute: typeof IListsListIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/$handle_/status/$tweetId': {
|
||||||
|
id: '/$handle_/status/$tweetId'
|
||||||
|
path: '/$handle/status/$tweetId'
|
||||||
|
fullPath: '/$handle/status/$tweetId'
|
||||||
|
preLoaderRoute: typeof HandleStatusTweetIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
HandleRoute: HandleRoute,
|
||||||
SearchRoute: SearchRoute,
|
SearchRoute: SearchRoute,
|
||||||
UserRoute: UserRoute,
|
IListsRoute: IListsRoute,
|
||||||
|
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
|
||||||
|
IListsListIdRoute: IListsListIdRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { UserPage } from './index'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/$handle')({
|
||||||
|
component: UserRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function UserRoute() {
|
||||||
|
const { handle } = Route.useParams()
|
||||||
|
return <UserPage handle={handle} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
|
import {
|
||||||
|
InputError,
|
||||||
|
normalizeTweetId,
|
||||||
|
normalizeUserTarget,
|
||||||
|
} from '#/features/posts/inputs'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/$handle_/status/$tweetId')({
|
||||||
|
component: StatusRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function StatusRoute() {
|
||||||
|
const { handle: rawHandle, tweetId: rawTweetId } = Route.useParams()
|
||||||
|
let tweetId: string | undefined
|
||||||
|
let error: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
normalizeUserTarget(rawHandle)
|
||||||
|
tweetId = normalizeTweetId(rawTweetId)
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof InputError ? cause.message : 'URL を確認してください。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<h1>会話</h1>
|
||||||
|
<p className="intro">選んだ投稿と、その会話だけを表示します。</p>
|
||||||
|
{error ? (
|
||||||
|
<p className="state" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<PostFeed request={tweetId ? { kind: 'thread', tweetId } : undefined} />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
createRouter,
|
createRouter,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { routeTree } from '../routeTree.gen'
|
import { routeTree } from '../routeTree.gen'
|
||||||
|
|
||||||
const usePostFeed = vi.hoisted(() => vi.fn())
|
const usePostFeed = vi.hoisted(() => vi.fn())
|
||||||
|
const useListChoices = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
||||||
const original =
|
const original =
|
||||||
@@ -16,6 +17,12 @@ vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
|||||||
return { ...original, usePostFeed }
|
return { ...original, usePostFeed }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('../features/posts/use-list-choices', async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<typeof import('../features/posts/use-list-choices')>()
|
||||||
|
return { ...original, useListChoices }
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('./__root', async () => {
|
vi.mock('./__root', async () => {
|
||||||
const { createRootRouteWithContext, Outlet } = await import(
|
const { createRootRouteWithContext, Outlet } = await import(
|
||||||
'@tanstack/react-router'
|
'@tanstack/react-router'
|
||||||
@@ -41,6 +48,7 @@ describe('post feed route wiring', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('scrollTo', vi.fn())
|
vi.stubGlobal('scrollTo', vi.fn())
|
||||||
usePostFeed.mockReset()
|
usePostFeed.mockReset()
|
||||||
|
useListChoices.mockReset()
|
||||||
usePostFeed.mockReturnValue({
|
usePostFeed.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -52,12 +60,19 @@ describe('post feed route wiring', () => {
|
|||||||
isPending: false,
|
isPending: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
} as never)
|
} as never)
|
||||||
|
useListChoices.mockReturnValue({
|
||||||
|
data: [{ id: '1234567890', name: 'My List', memberCount: 10 }],
|
||||||
|
error: null,
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
} as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
it('keeps an empty user URL disabled', async () => {
|
it('keeps an empty user URL disabled', async () => {
|
||||||
await renderRoute('/user?target=')
|
await renderRoute('/')
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
|
await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }),
|
||||||
@@ -66,7 +81,7 @@ describe('post feed route wiring', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('turns a manually entered user URL into a deliberate request', async () => {
|
it('turns a manually entered user URL into a deliberate request', async () => {
|
||||||
await renderRoute('/user?target=yuta')
|
await renderRoute('/yuta')
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(usePostFeed).toHaveBeenCalledWith({
|
expect(usePostFeed).toHaveBeenCalledWith({
|
||||||
@@ -76,6 +91,62 @@ describe('post feed route wiring', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid user path local and idle', async () => {
|
||||||
|
await renderRoute('/not-a-handle')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'ハンドルは英数字とアンダースコアで入力してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an empty list URL disabled', async () => {
|
||||||
|
await renderRoute('/i/lists')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: 'どのリストを読みますか?' }),
|
||||||
|
).toBeVisible()
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns a manually entered list URL into a deliberate request', async () => {
|
||||||
|
await renderRoute('/i/lists/1234567890')
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith({
|
||||||
|
kind: 'list',
|
||||||
|
target: '1234567890',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('link', { name: 'リスト', current: 'page' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid list path local and idle', async () => {
|
||||||
|
await renderRoute('/i/lists/not-a-list')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'リスト ID は数字で入力してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns a selected authenticated list into a deliberate request', async () => {
|
||||||
|
await renderRoute('/i/lists')
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole('link', { name: /My List.*10 メンバー/ }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith({
|
||||||
|
kind: 'list',
|
||||||
|
target: '1234567890',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('forwards every manually entered search control', async () => {
|
it('forwards every manually entered search control', async () => {
|
||||||
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
|
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
|
||||||
|
|
||||||
@@ -88,4 +159,40 @@ describe('post feed route wiring', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('turns a decimal status path into a deliberate thread request', async () => {
|
||||||
|
await renderRoute('/yuta/status/123')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith({
|
||||||
|
kind: 'thread',
|
||||||
|
tweetId: '123',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('link', { name: 'ユーザー', current: 'page' }),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('link', { name: '検索', current: 'page' }),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid tweet path local and idle', async () => {
|
||||||
|
await renderRoute('/yuta/status/not-a-tweet')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'投稿 ID を確認してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an invalid status handle local and idle', async () => {
|
||||||
|
await renderRoute('/not-a-handle/status/123')
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'ハンドルは英数字とアンダースコアで入力してください。',
|
||||||
|
)
|
||||||
|
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
name: 'viewport',
|
name: 'viewport',
|
||||||
content: 'width=device-width, initial-scale=1',
|
content: 'width=device-width, initial-scale=1',
|
||||||
},
|
},
|
||||||
|
{ name: 'referrer', content: 'no-referrer' },
|
||||||
{ title: 'Twitter Lite' },
|
{ title: 'Twitter Lite' },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { ListForm } from '#/features/posts/components/list-form'
|
||||||
|
import { ListPicker } from '#/features/posts/components/list-picker'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
|
import { InputError, normalizeListTarget } from '#/features/posts/inputs'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/i/lists')({
|
||||||
|
component: ListIndexRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ListIndexRoute() {
|
||||||
|
return <ListPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListPage({ listId = '' }: { listId?: string }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
let normalizedListId: string | undefined
|
||||||
|
let error: string | undefined
|
||||||
|
|
||||||
|
if (listId) {
|
||||||
|
try {
|
||||||
|
normalizedListId = normalizeListTarget(listId)
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof InputError
|
||||||
|
? cause.message
|
||||||
|
: 'リスト ID を確認してください。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell active="list">
|
||||||
|
<h1 className="reader-title-long">どのリストを読みますか?</h1>
|
||||||
|
<p className="intro">
|
||||||
|
自分のリストを選ぶか、リストURLを入力してください。
|
||||||
|
</p>
|
||||||
|
<ListPicker selectedId={listId || undefined} />
|
||||||
|
<ListForm
|
||||||
|
initialTarget={listId}
|
||||||
|
key={listId}
|
||||||
|
onSubmit={(nextListId) =>
|
||||||
|
navigate({
|
||||||
|
to: '/i/lists/$listId',
|
||||||
|
params: { listId: nextListId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p className="state" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<PostFeed
|
||||||
|
request={
|
||||||
|
normalizedListId
|
||||||
|
? { kind: 'list', target: normalizedListId }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { ListPage } from './i.lists'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/i/lists_/$listId')({
|
||||||
|
component: ListRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ListRoute() {
|
||||||
|
const { listId } = Route.useParams()
|
||||||
|
return <ListPage listId={listId} />
|
||||||
|
}
|
||||||
+53
-4
@@ -1,7 +1,56 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
|
import { UserForm } from '#/features/posts/components/user-form'
|
||||||
|
import { InputError, normalizeUserTarget } from '#/features/posts/inputs'
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
beforeLoad: () => {
|
component: UserIndexRoute,
|
||||||
throw redirect({ to: '/user', search: { target: '' } })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function UserIndexRoute() {
|
||||||
|
return <UserPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserPage({ handle = '' }: { handle?: string }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
let normalizedHandle: string | undefined
|
||||||
|
let error: string | undefined
|
||||||
|
|
||||||
|
if (handle) {
|
||||||
|
try {
|
||||||
|
normalizedHandle = normalizeUserTarget(handle)
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof InputError
|
||||||
|
? cause.message
|
||||||
|
: 'ハンドルを確認してください。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell active="user">
|
||||||
|
<h1>誰の投稿を見ますか?</h1>
|
||||||
|
<p className="intro">履歴やおすすめは表示しません。</p>
|
||||||
|
<UserForm
|
||||||
|
initialTarget={handle}
|
||||||
|
key={handle}
|
||||||
|
onSubmit={(nextHandle) =>
|
||||||
|
navigate({ to: '/$handle', params: { handle: nextHandle } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p className="state" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<PostFeed
|
||||||
|
request={
|
||||||
|
normalizedHandle
|
||||||
|
? { kind: 'user', target: normalizedHandle }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { AppShell } from '#/components/app-shell'
|
|
||||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
|
||||||
import { UserForm } from '#/features/posts/components/user-form'
|
|
||||||
import { userRouteSearchSchema } from '#/features/posts/inputs'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/user')({
|
|
||||||
validateSearch: (search) => userRouteSearchSchema.parse(search),
|
|
||||||
component: UserRoute,
|
|
||||||
})
|
|
||||||
|
|
||||||
function UserRoute() {
|
|
||||||
const { target } = Route.useSearch()
|
|
||||||
const navigate = Route.useNavigate()
|
|
||||||
return (
|
|
||||||
<AppShell active="user">
|
|
||||||
<h1>誰の投稿を見ますか?</h1>
|
|
||||||
<p className="intro">履歴やおすすめは表示しません。</p>
|
|
||||||
<UserForm
|
|
||||||
initialTarget={target}
|
|
||||||
key={target}
|
|
||||||
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
|
|
||||||
/>
|
|
||||||
<PostFeed request={target ? { kind: 'user', target } : undefined} />
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+703
-19
@@ -1,18 +1,25 @@
|
|||||||
|
/* Hallmark · genre: editorial · macrostructure: Workbench · theme: Garden · enrichment: none
|
||||||
|
* tone: soft · anchor: leaf-green · nav: N3 · footer: none (app shell)
|
||||||
|
* contrast: pass (40–41) · slop: pass (42–49) · mobile: pass (34, 49–57)
|
||||||
|
*/
|
||||||
|
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */
|
||||||
|
@import "../tokens.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--canvas: #dfe7e9;
|
--canvas: var(--color-canvas);
|
||||||
--surface: #f8faf9;
|
--surface: var(--color-paper-2);
|
||||||
--paper: #fff;
|
--paper: var(--color-paper);
|
||||||
--ink: #1e3238;
|
--ink: var(--color-ink);
|
||||||
--secondary: #506970;
|
--secondary: var(--color-muted);
|
||||||
--accent: #255f6e;
|
--accent: var(--color-accent);
|
||||||
--accent-hover: #194d59;
|
--accent-hover: var(--color-accent-hover);
|
||||||
--border: #c1cfd2;
|
--border: var(--color-rule);
|
||||||
--control-border: #748b91;
|
--control-border: var(--color-control-rule);
|
||||||
--error: #8a3034;
|
--error: var(--color-error);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: Inter, "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
font-family: var(--font-body);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +29,14 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
overflow-x: clip;
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -40,9 +49,19 @@ a {
|
|||||||
text-underline-offset: 0.18em;
|
text-underline-offset: 0.18em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 3px solid var(--accent);
|
outline: var(--rule-active) solid var(--color-focus);
|
||||||
outline-offset: 3px;
|
outline-offset: var(--space-2xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
@@ -53,7 +72,7 @@ a {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 1.15rem;
|
border-radius: 1.15rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 1.2rem 3rem rgb(30 50 56 / 10%);
|
box-shadow: 0 var(--space-lg) var(--space-2xl) var(--color-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
@@ -116,6 +135,73 @@ a {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-picker {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker-state {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker-state p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices {
|
||||||
|
display: grid;
|
||||||
|
max-height: 22rem;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices a {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices a[aria-current="page"] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in oklch, var(--accent) 7%, var(--paper));
|
||||||
|
box-shadow: inset 3px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices span {
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices small {
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.intent-form {
|
.intent-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -124,7 +210,7 @@ a {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
|
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -167,6 +253,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intent-form button,
|
.intent-form button,
|
||||||
|
.list-picker-state button,
|
||||||
.state button {
|
.state button {
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
padding: 0.65rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
@@ -254,6 +341,20 @@ a {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focal-post {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focal-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
.feed {
|
.feed {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
@@ -268,6 +369,11 @@ a {
|
|||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-post {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.post-header {
|
.post-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
@@ -314,7 +420,7 @@ a {
|
|||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
|
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-grid > :only-child {
|
.media-grid > :only-child {
|
||||||
@@ -325,7 +431,7 @@ a {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 32rem;
|
max-height: 32rem;
|
||||||
background: color-mix(in srgb, var(--canvas) 45%, var(--surface));
|
background: color-mix(in oklch, var(--canvas) 45%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
img.media {
|
img.media {
|
||||||
@@ -361,6 +467,7 @@ video.media {
|
|||||||
.post-footer {
|
.post-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem 1rem;
|
gap: 0.75rem 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -415,7 +522,7 @@ video.media {
|
|||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
content: "";
|
content: "";
|
||||||
animation: rail 1.1s ease-in-out infinite;
|
animation: rail 1.1s var(--ease-in-out) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rail {
|
@keyframes rail {
|
||||||
@@ -437,6 +544,7 @@ video.media {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intent-form button:hover,
|
.intent-form button:hover,
|
||||||
|
.list-picker-state button:hover,
|
||||||
.state button:hover {
|
.state button:hover {
|
||||||
border-color: var(--accent-hover);
|
border-color: var(--accent-hover);
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
@@ -450,6 +558,11 @@ video.media {
|
|||||||
border-color: var(--accent-hover);
|
border-color: var(--accent-hover);
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-choices a:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 42rem) {
|
@media (max-width: 42rem) {
|
||||||
@@ -491,7 +604,7 @@ video.media {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-grid {
|
.media-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-footer {
|
.post-footer {
|
||||||
@@ -509,3 +622,574 @@ video.media {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Workbench layout: the routes are tools on a quiet side rail. */
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100svh;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: grid;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
padding: var(--space-lg) var(--space-md) 0;
|
||||||
|
border-bottom: var(--rule-hairline) solid var(--border);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader > .reader-title-long {
|
||||||
|
font-size: var(--text-display-long);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs a {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-sm) var(--space-xs);
|
||||||
|
border-bottom: var(--rule-active) solid transparent;
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
color var(--dur-short) var(--ease-out),
|
||||||
|
background-color var(--dur-short) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs a[aria-current="page"] {
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-xl) var(--space-md) var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader > h1 {
|
||||||
|
max-width: 18ch;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-display-s);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
max-width: 46ch;
|
||||||
|
margin: var(--space-sm) 0 var(--space-xl);
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form {
|
||||||
|
gap: var(--space-md);
|
||||||
|
align-items: end;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border: 0;
|
||||||
|
border-block: var(--rule-hairline) solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
gap: 0;
|
||||||
|
grid-template-rows: auto auto minmax(1lh, auto);
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span:first-child {
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: var(--rule-hairline) solid var(--control-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: var(--rule-active) solid transparent;
|
||||||
|
outline-offset: var(--space-3xs);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
transition: background-color var(--dur-short) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus-visible {
|
||||||
|
border-color: var(--control-border);
|
||||||
|
outline-color: var(--color-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[aria-invalid="true"] {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:disabled {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--color-paper-3);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
min-height: 1lh;
|
||||||
|
margin-top: var(--space-2xs);
|
||||||
|
color: var(--error);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form button,
|
||||||
|
.list-picker-state button,
|
||||||
|
.state button {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
border: var(--rule-hairline) solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-short) var(--ease-out),
|
||||||
|
color var(--dur-short) var(--ease-out),
|
||||||
|
transform var(--dur-instant) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form button:active,
|
||||||
|
.list-picker-state button:active,
|
||||||
|
.state button:active {
|
||||||
|
transform: translateY(var(--rule-hairline));
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
button[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[aria-busy="true"] {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-state="error"] {
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[data-state="success"] {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented legend {
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: var(--rule-hairline) solid var(--control-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-short) var(--ease-out),
|
||||||
|
color var(--dur-short) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:has(input:checked) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--color-accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:has(input:focus-visible) {
|
||||||
|
outline: var(--rule-active) solid var(--color-focus);
|
||||||
|
outline-offset: var(--space-3xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:active {
|
||||||
|
background: var(--color-paper-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:has(input:disabled) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border: 0;
|
||||||
|
border-block: var(--rule-hairline) solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker h2 {
|
||||||
|
margin: 0 0 var(--space-md);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-style: normal;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-picker-state {
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices a {
|
||||||
|
gap: var(--space-2xs);
|
||||||
|
padding: var(--space-md) var(--space-sm);
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--rule-hairline) solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices li:first-child a {
|
||||||
|
border-top: var(--rule-hairline) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices a[aria-current="page"] {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--color-paper-3);
|
||||||
|
box-shadow: inset var(--rule-active) 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices small,
|
||||||
|
.list-choices p {
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focal-post,
|
||||||
|
.feed {
|
||||||
|
gap: 0;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focal-label {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed {
|
||||||
|
border-top: var(--rule-hairline) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: var(--rule-hairline) solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-post {
|
||||||
|
padding-inline: var(--space-lg);
|
||||||
|
border: var(--rule-hairline) solid var(--accent);
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle,
|
||||||
|
.post-header time {
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-text {
|
||||||
|
margin: var(--space-md) 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card,
|
||||||
|
.quote {
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding: var(--space-md) 0 var(--space-md) var(--space-lg);
|
||||||
|
border: 0;
|
||||||
|
border-left: var(--rule-active) solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card p {
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
gap: var(--space-sm) var(--space-lg);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer a {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm) var(--space-lg);
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
margin: var(--space-xl) 0 0;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-rail {
|
||||||
|
padding: var(--space-md);
|
||||||
|
color: var(--secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-rail::after {
|
||||||
|
height: var(--rule-active);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.tabs a:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form button:hover,
|
||||||
|
.list-picker-state button:hover,
|
||||||
|
.state button:hover {
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--color-accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:hover {
|
||||||
|
border-color: var(--control-border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented label:has(input:checked):hover {
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--color-accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-choices a:hover {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--color-paper-3);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.intent-form {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form:has(.segmented) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form:has(.segmented) .field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-form:not(:has(.segmented)) > button {
|
||||||
|
align-self: start;
|
||||||
|
margin-top: calc(1lh + var(--space-xs));
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header time {
|
||||||
|
grid-column: auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-footer {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 52rem) {
|
||||||
|
body {
|
||||||
|
padding-block: var(--space-lg);
|
||||||
|
padding-inline: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||||
|
width: min(74rem, 100%);
|
||||||
|
min-height: calc(100svh - (var(--space-lg) * 2));
|
||||||
|
margin: 0 auto;
|
||||||
|
border: var(--rule-hairline) solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 var(--space-lg) var(--space-2xl) var(--color-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-lg);
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
align-self: start;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
border-right: var(--rule-hairline) solid var(--border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--radius-lg) 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs a {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-inline: var(--space-md);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: var(--rule-active) solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs a[aria-current="page"] {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
grid-column: 2;
|
||||||
|
max-width: none;
|
||||||
|
padding: var(--space-2xl) clamp(var(--space-xl), 6vw, var(--space-2xl))
|
||||||
|
var(--space-3xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tabs a,
|
||||||
|
.field input,
|
||||||
|
.intent-form button,
|
||||||
|
.list-picker-state button,
|
||||||
|
.state button,
|
||||||
|
.segmented label {
|
||||||
|
transition-duration: var(--dur-instant);
|
||||||
|
transition-property: opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
import { createServer } from 'node:http'
|
|
||||||
|
|
||||||
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
|
|
||||||
const port = Number(portValue)
|
|
||||||
|
|
||||||
if (!portValue || !Number.isInteger(port) || port < 1) {
|
|
||||||
throw new Error('TWITTER_LITE_MOCK_RELAY_PORT must be a valid port')
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedRetryQueries = new Set()
|
|
||||||
|
|
||||||
const isRecord = (value) =>
|
|
||||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
|
|
||||||
const tweet = (id, text, username = 'yuta') => ({
|
|
||||||
entryId: `tweet-${id}`,
|
|
||||||
content: {
|
|
||||||
itemContent: {
|
|
||||||
tweet_results: {
|
|
||||||
result: {
|
|
||||||
rest_id: id,
|
|
||||||
legacy: {
|
|
||||||
full_text: text,
|
|
||||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
|
||||||
reply_count: 1,
|
|
||||||
retweet_count: 2,
|
|
||||||
favorite_count: 3,
|
|
||||||
conversation_id_str: id,
|
|
||||||
},
|
|
||||||
core: {
|
|
||||||
user_results: {
|
|
||||||
result: {
|
|
||||||
rest_id: '42',
|
|
||||||
legacy: { screen_name: username, name: 'Yuta' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const cursor = (value) => ({
|
|
||||||
entryId: 'cursor-bottom',
|
|
||||||
content: { cursorType: 'Bottom', value },
|
|
||||||
})
|
|
||||||
|
|
||||||
const send = (response, payload, status = 200, headers = {}) => {
|
|
||||||
response.writeHead(status, {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
...headers,
|
|
||||||
})
|
|
||||||
response.end(JSON.stringify(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail = (response, status, message, headers) =>
|
|
||||||
send(response, { errors: [{ message }] }, status, headers)
|
|
||||||
|
|
||||||
const variablesFrom = (url) => {
|
|
||||||
const encoded = url.searchParams.get('variables')
|
|
||||||
if (!encoded) throw new Error('variables are required')
|
|
||||||
const variables = JSON.parse(encoded)
|
|
||||||
if (!isRecord(variables)) throw new Error('variables must be an object')
|
|
||||||
return variables
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonBodyFrom = async (request) => {
|
|
||||||
let body = ''
|
|
||||||
for await (const chunk of request) {
|
|
||||||
body += chunk
|
|
||||||
if (body.length > 1_000_000) throw new Error('request body is too large')
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(body)
|
|
||||||
if (!isRecord(parsed)) throw new Error('JSON body must be an object')
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeline = (entries) => ({ instructions: [{ entries }] })
|
|
||||||
|
|
||||||
const server = createServer(async (request, response) => {
|
|
||||||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
|
|
||||||
const operation = ['UserByScreenName', 'UserTweets', 'SearchTimeline'].find(
|
|
||||||
(name) => url.pathname.endsWith(`/${name}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!operation) {
|
|
||||||
fail(response, 501, 'unsupported read operation')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedMethod = operation === 'SearchTimeline' ? 'POST' : 'GET'
|
|
||||||
if (request.method !== expectedMethod) {
|
|
||||||
fail(response, 405, `expected ${expectedMethod}`, {
|
|
||||||
allow: expectedMethod,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (request.headers['x-profile-name'] !== 'e2e') {
|
|
||||||
fail(response, 403, 'invalid profile')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let variables
|
|
||||||
try {
|
|
||||||
variables = variablesFrom(url)
|
|
||||||
} catch {
|
|
||||||
fail(response, 400, 'malformed variables')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation === 'UserByScreenName') {
|
|
||||||
if (
|
|
||||||
typeof variables.screen_name !== 'string' ||
|
|
||||||
variables.screen_name.length === 0
|
|
||||||
) {
|
|
||||||
fail(response, 400, 'screen_name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
send(response, {
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
rest_id: '42',
|
|
||||||
legacy: { screen_name: variables.screen_name, name: 'Yuta' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation === 'UserTweets') {
|
|
||||||
const expectedCursor =
|
|
||||||
variables.cursor === undefined || variables.cursor === 'user-next'
|
|
||||||
if (
|
|
||||||
variables.userId !== '42' ||
|
|
||||||
variables.count !== 20 ||
|
|
||||||
!expectedCursor
|
|
||||||
) {
|
|
||||||
fail(response, 400, 'invalid user timeline variables')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const secondPage = variables.cursor === 'user-next'
|
|
||||||
const entries = secondPage
|
|
||||||
? [tweet('u2', 'user page 2')]
|
|
||||||
: [tweet('u1', 'user page 1'), cursor('user-next')]
|
|
||||||
send(response, {
|
|
||||||
data: {
|
|
||||||
user: { result: { timeline: { timeline: timeline(entries) } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = request.headers['content-type'] ?? ''
|
|
||||||
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
|
||||||
fail(response, 400, 'SearchTimeline requires JSON')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let body
|
|
||||||
try {
|
|
||||||
body = await jsonBodyFrom(request)
|
|
||||||
} catch {
|
|
||||||
fail(response, 400, 'malformed JSON body')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawQuery = variables.rawQuery
|
|
||||||
const expectedCursor =
|
|
||||||
variables.cursor === undefined ||
|
|
||||||
variables.cursor === `search-next:${rawQuery}`
|
|
||||||
if (
|
|
||||||
typeof rawQuery !== 'string' ||
|
|
||||||
rawQuery.length === 0 ||
|
|
||||||
variables.count !== 20 ||
|
|
||||||
variables.querySource !== 'typed_query' ||
|
|
||||||
!['Top', 'Latest'].includes(variables.product) ||
|
|
||||||
!expectedCursor ||
|
|
||||||
!isRecord(body.features) ||
|
|
||||||
typeof body.queryId !== 'string' ||
|
|
||||||
body.queryId.length === 0 ||
|
|
||||||
Object.hasOwn(body, 'variables')
|
|
||||||
) {
|
|
||||||
fail(response, 400, 'invalid search request')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondPage = variables.cursor !== undefined
|
|
||||||
if (
|
|
||||||
secondPage &&
|
|
||||||
rawQuery.startsWith('retry-') &&
|
|
||||||
!failedRetryQueries.has(rawQuery)
|
|
||||||
) {
|
|
||||||
failedRetryQueries.add(rawQuery)
|
|
||||||
fail(response, 503, 'transient fixture failure')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (secondPage && rawQuery.startsWith('slow-')) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_200))
|
|
||||||
}
|
|
||||||
|
|
||||||
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
|
|
||||||
const label = `${variables.product} · ${follows}`
|
|
||||||
const id = encodeURIComponent(rawQuery)
|
|
||||||
const entries = secondPage
|
|
||||||
? [tweet(`s-${id}-2`, `${label} page 2`)]
|
|
||||||
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)]
|
|
||||||
|
|
||||||
send(response, {
|
|
||||||
data: {
|
|
||||||
search_by_raw_query: {
|
|
||||||
search_timeline: { timeline: timeline(entries) },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(port, '127.0.0.1')
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { expect, type Locator, test } from '@playwright/test'
|
|
||||||
|
|
||||||
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([])
|
|
||||||
})
|
|
||||||
|
|
||||||
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
|
|
||||||
const mutationControlNames = /投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
|
|
||||||
|
|
||||||
async function submitSearch(
|
|
||||||
page: import('@playwright/test').Page,
|
|
||||||
query: string,
|
|
||||||
) {
|
|
||||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
|
||||||
await page.getByLabel('検索語').fill(query)
|
|
||||||
await page.getByRole('button', { name: '検索' }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openReader(page: import('@playwright/test').Page, path: string) {
|
|
||||||
await page.goto(path)
|
|
||||||
await page.waitForLoadState('networkidle')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tabTo(page: import('@playwright/test').Page, target: Locator) {
|
|
||||||
await page.keyboard.press('Tab')
|
|
||||||
await expect(target).toBeFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
test('requires intent and infinitely loads a user timeline', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await openReader(page, '/')
|
|
||||||
await expect(page).toHaveURL(/\/user\?target=$/)
|
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
|
||||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
|
|
||||||
await page.getByRole('button', { name: '表示' }).click()
|
|
||||||
|
|
||||||
await expect(page.getByText('user page 1')).toBeVisible()
|
|
||||||
await expect(page.getByText('user page 2')).toBeVisible()
|
|
||||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
|
||||||
await expect(page).toHaveScreenshot('mist-user.png', {
|
|
||||||
animations: 'disabled',
|
|
||||||
fullPage: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('searches Top posts from followed accounts', async ({ page }) => {
|
|
||||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
|
||||||
await page.getByLabel('検索語').fill('AI lang:ja')
|
|
||||||
await page.getByLabel('人気順').check()
|
|
||||||
await page.getByLabel('フォロー中のみ').check()
|
|
||||||
await expect(page.getByLabel('フォロー中のみ')).toBeChecked()
|
|
||||||
await page.getByRole('button', { name: '検索' }).click()
|
|
||||||
|
|
||||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('retains the first page through a pagination error and retries explicitly', async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await submitSearch(page, `retry-${testInfo.project.name}`)
|
|
||||||
|
|
||||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
|
||||||
const laterPageAlert = page
|
|
||||||
.getByRole('alert')
|
|
||||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
|
||||||
await expect(laterPageAlert).toHaveCount(1)
|
|
||||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
|
||||||
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
|
|
||||||
|
|
||||||
await laterPageAlert.getByRole('button', { name: '再試行' }).click()
|
|
||||||
|
|
||||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
|
||||||
await expect(laterPageAlert).toHaveCount(0)
|
|
||||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
|
||||||
|
|
||||||
await tabTo(page, page.getByRole('link', { name: 'ユーザー' }))
|
|
||||||
await tabTo(page, page.getByRole('link', { name: '検索' }))
|
|
||||||
await tabTo(page, page.getByLabel('検索語'))
|
|
||||||
const ranking = page.getByLabel('最新順')
|
|
||||||
await expect(ranking).toBeChecked()
|
|
||||||
await tabTo(page, ranking)
|
|
||||||
await tabTo(page, page.getByLabel('フォロー中のみ'))
|
|
||||||
await tabTo(page, page.getByRole('button', { name: '検索' }))
|
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: mutationControlNames }),
|
|
||||||
).toHaveCount(0)
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: mutationControlNames }),
|
|
||||||
).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('removes loading rail animation for reduced motion', async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
||||||
await submitSearch(page, `slow-${testInfo.project.name}`)
|
|
||||||
|
|
||||||
const rail = page.locator('.loading-rail')
|
|
||||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
|
||||||
})
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/* Hallmark · macrostructure: Workbench · tone: soft · anchor hue: leaf-green */
|
||||||
|
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5 */
|
||||||
|
:root {
|
||||||
|
--color-canvas: oklch(93% 0.018 125);
|
||||||
|
--color-paper: oklch(98% 0.01 100);
|
||||||
|
--color-paper-2: oklch(95% 0.018 115);
|
||||||
|
--color-paper-3: oklch(90% 0.026 125);
|
||||||
|
--color-ink: oklch(23% 0.025 145);
|
||||||
|
--color-ink-2: oklch(32% 0.03 145);
|
||||||
|
--color-muted: oklch(42% 0.026 145);
|
||||||
|
--color-accent: oklch(38% 0.09 145);
|
||||||
|
--color-accent-hover: oklch(31% 0.085 145);
|
||||||
|
--color-accent-ink: oklch(98% 0.01 100);
|
||||||
|
--color-rule: oklch(80% 0.025 125);
|
||||||
|
--color-control-rule: oklch(55% 0.035 135);
|
||||||
|
--color-focus: oklch(45% 0.15 65);
|
||||||
|
--color-error: oklch(42% 0.13 25);
|
||||||
|
--color-success: oklch(38% 0.1 145);
|
||||||
|
--color-shadow: oklch(23% 0.025 145 / 10%);
|
||||||
|
|
||||||
|
--font-display:
|
||||||
|
"YuMincho", "Hiragino Mincho ProN", "Noto Serif JP", ui-serif, serif;
|
||||||
|
--font-body:
|
||||||
|
"Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", ui-sans-serif, sans-serif;
|
||||||
|
|
||||||
|
--space-3xs: 0.125rem;
|
||||||
|
--space-2xs: 0.25rem;
|
||||||
|
--space-xs: 0.5rem;
|
||||||
|
--space-sm: 0.75rem;
|
||||||
|
--space-md: 1rem;
|
||||||
|
--space-lg: 1.5rem;
|
||||||
|
--space-xl: 2.5rem;
|
||||||
|
--space-2xl: 4rem;
|
||||||
|
--space-3xl: 6rem;
|
||||||
|
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-md: 1.25rem;
|
||||||
|
--text-lg: 1.5625rem;
|
||||||
|
--text-xl: 1.9531rem;
|
||||||
|
--text-display-s: clamp(1.75rem, 7.4vw + 0.25rem, 2.75rem);
|
||||||
|
--text-display-long: clamp(1.5rem, 5.5vw + 0.4rem, 2.75rem);
|
||||||
|
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--dur-instant: 100ms;
|
||||||
|
--dur-short: 200ms;
|
||||||
|
--dur-medium: 420ms;
|
||||||
|
|
||||||
|
--rule-hairline: 1px;
|
||||||
|
--rule-active: 2px;
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.625rem;
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "e2e/**/*.ts"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./tests/setup.ts'],
|
setupFiles: ['./tests/setup.ts'],
|
||||||
exclude: [...configDefaults.exclude, 'tests/e2e/**'],
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user