Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4f7ca068 | ||
|
|
d8827b6513 | ||
|
|
f82f6641b2 | ||
|
|
acc4fe841b | ||
|
|
fa26ce18d7 | ||
|
|
4efd709234 | ||
|
|
da72094e07 | ||
|
|
8e6ff70d81 | ||
|
|
1eb2733b38 | ||
|
|
45cee54cff |
@@ -0,0 +1 @@
|
||||
@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
|
||||
@@ -1,7 +1,8 @@
|
||||
# Twitter Lite
|
||||
|
||||
Twitter Lite is an intentional, read-only X reader. It stays empty until you
|
||||
enter a user handle, profile URL, or search query.
|
||||
Twitter Lite is an intentional, read-only X reader. It shows content only after
|
||||
you enter a user handle, profile URL, or search query, follow a deliberate post
|
||||
detail link, or manually open a valid `/status/:tweetId` URL.
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -9,28 +10,23 @@ enter a user handle, profile URL, or search query.
|
||||
- Popular (`Top`) and chronological (`Latest`) search
|
||||
- Optional `filter:follows` search
|
||||
- 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
|
||||
|
||||
It intentionally has no home feed, recommendations, trends, notifications,
|
||||
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.
|
||||
|
||||
## Requirements and setup
|
||||
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The package link also
|
||||
requires a local Bird checkout at `../bird`, exactly at unpublished commit
|
||||
`81921226f099e7f7a2107be15fc9783218a6f400`.
|
||||
|
||||
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.
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||
routes the `@yuta` scope to the public Gitea Packages registry, where Bird
|
||||
`0.10.0` provides the required Top and Latest search interface.
|
||||
|
||||
```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
|
||||
cp .env.example .env.local
|
||||
```
|
||||
@@ -72,3 +68,7 @@ only on a trusted network and obtain the Tailscale address with
|
||||
|
||||
Bird uses X's internal GraphQL operations through the configured relay. Query
|
||||
IDs and response shapes can change without notice.
|
||||
|
||||
Conversation pages use Bird's current per-page chronological ordering. Keeping
|
||||
X's original ranked branch order is deferred until Bird exposes that order
|
||||
without expanding the relay surface.
|
||||
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!src/routeTree.gen.ts", "!!**/.output"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!src/routeTree.gen.ts",
|
||||
"!!**/.output",
|
||||
"!!**/test-results"
|
||||
]
|
||||
},
|
||||
"formatter": { "enabled": true, "indentStyle": "space" },
|
||||
"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.
|
||||
|
||||
**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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -31,7 +31,7 @@
|
||||
- `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/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/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.
|
||||
@@ -841,25 +841,11 @@ git commit -m "feat: paginate tweet conversations"
|
||||
|
||||
**Interfaces:**
|
||||
- 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**
|
||||
|
||||
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:
|
||||
|
||||
```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`:
|
||||
Add to `src/features/posts/components/post-card.test.tsx`:
|
||||
|
||||
```ts
|
||||
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**
|
||||
|
||||
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
|
||||
<article
|
||||
@@ -1002,9 +988,9 @@ Within the non-quoted footer, keep the counts and external link and add:
|
||||
|
||||
```tsx
|
||||
{!current ? (
|
||||
<Link params={{ tweetId: post.id }} to="/status/$tweetId">
|
||||
<a href={`/status/${encodeURIComponent(post.id)}`}>
|
||||
詳細・スレッド
|
||||
</Link>
|
||||
</a>
|
||||
) : null}
|
||||
```
|
||||
|
||||
@@ -1682,24 +1668,23 @@ export const searchPosts = 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
|
||||
git rev-parse HEAD
|
||||
git status --short --branch
|
||||
git rev-list --count origin/main..HEAD
|
||||
git log --oneline origin/main..HEAD
|
||||
rg -n '"@yuta/bird": "0.10.0"' package.json
|
||||
rg -n '@yuta/[email protected]' pnpm-lock.yaml
|
||||
rg -n 'link:../bird' package.json pnpm-lock.yaml
|
||||
readlink -f node_modules/@yuta/bird
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
81921226f099e7f7a2107be15fc9783218a6f400
|
||||
## main...origin/main [ahead 1]
|
||||
1
|
||||
8192122 feat: support ranked tweet search
|
||||
package.json and pnpm-lock.yaml pin 0.10.0
|
||||
the local-link scan has zero matches
|
||||
node_modules resolves inside the pnpm virtual store
|
||||
```
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
> 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.
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -381,7 +385,7 @@ git commit -m "feat: support ranked tweet search"
|
||||
- Create: `tests/setup.ts`
|
||||
|
||||
**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: 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**
|
||||
|
||||
Create `.npmrc`:
|
||||
|
||||
```ini
|
||||
@yuta:registry=https://git.yutakobayashi.com/api/packages/yuta/npm/
|
||||
```
|
||||
|
||||
Create `package.json`:
|
||||
|
||||
```json
|
||||
@@ -447,7 +457,7 @@ Create `package.json`:
|
||||
"@tanstack/react-router": "1.170.17",
|
||||
"@tanstack/react-router-ssr-query": "1.167.1",
|
||||
"@tanstack/react-start": "1.168.27",
|
||||
"@yuta/bird": "link:../bird",
|
||||
"@yuta/bird": "0.10.0",
|
||||
"nitro": "3.0.260610-beta",
|
||||
"react": "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`
|
||||
|
||||
**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.
|
||||
|
||||
- [ ] **Step 1: Add the browser configuration**
|
||||
@@ -3133,17 +3143,11 @@ handle, profile URL, or search query.
|
||||
|
||||
## Setup
|
||||
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. Obtain the supplied local
|
||||
Bird checkout beside this repository at unpublished commit
|
||||
`81921226f099e7f7a2107be15fc9783218a6f400`. A fresh remote clone alone does
|
||||
not contain that Top/Latest commit yet.
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||
routes `@yuta` packages to Gitea Packages, and `@yuta/bird@0.10.0` contains
|
||||
the required Top/Latest interface.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@@ -12,10 +12,9 @@ visible conversation around it. Additional conversation pages load through the
|
||||
same intentional infinite-scroll behavior as the existing user and search
|
||||
feeds.
|
||||
|
||||
This is an additive extension to
|
||||
`2026-07-13-twitter-lite-design.md`. Where that document lists only two routes
|
||||
and two server functions, this document adds one status route and one narrowly
|
||||
scoped read-only server function.
|
||||
This document originally extended `2026-07-13-twitter-lite-design.md` with the
|
||||
third route, `/status/:tweetId`, and the third narrowly scoped read-only server
|
||||
function, `loadThreadPosts`. The base design now incorporates both additions.
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
@@ -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;
|
||||
- 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 `/status/:tweetId` URL
|
||||
to read its visible conversation; and
|
||||
- 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 post-link navigation, or a manually entered valid `/status/:tweetId` URL.
|
||||
|
||||
## 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 post-link navigation, or a manually entered valid `/status/:tweetId` URL.
|
||||
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.
|
||||
4. **No account directory.** Handles are unrestricted, but there is no autocomplete, suggested account list, or saved allowlist.
|
||||
@@ -33,6 +35,7 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
||||
- raw X search syntax;
|
||||
- `Top` and `Latest` search products;
|
||||
- `filter:follows` search filtering;
|
||||
- deliberate post detail pages with cursor-paginated visible conversations;
|
||||
- photos, videos, animated GIFs, quoted posts, article previews, and engagement counts when supplied by Bird;
|
||||
- infinite cursor pagination;
|
||||
- explicit retry, empty, error, loading, and end states;
|
||||
@@ -46,19 +49,18 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
||||
- all X mutation operations;
|
||||
- user autocomplete and typeahead;
|
||||
- People and Media search products;
|
||||
- related-post feeds and automatic post discovery;
|
||||
- a generic relay proxy;
|
||||
- deployment beyond a personal localhost process.
|
||||
|
||||
## 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`.
|
||||
2. Extend Bird's public search interface and commit the change locally.
|
||||
3. Consume the local Bird checkout from Twitter Lite with the pnpm dependency `link:../bird`.
|
||||
4. Build and verify Twitter Lite against the linked Bird package.
|
||||
|
||||
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.
|
||||
1. Extend Bird's public search interface in `https://git.yutakobayashi.com/yuta/bird`.
|
||||
2. Release the compatible interface as `@yuta/[email protected]` in Gitea Packages.
|
||||
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 published package without a sibling Bird checkout.
|
||||
|
||||
## Bird Library Change
|
||||
|
||||
@@ -105,11 +107,12 @@ TanStack Start supplies the browser/server boundary. Bird is imported only by se
|
||||
|
||||
- `/user?target=<handle-or-url>` displays the User tab.
|
||||
- `/search?q=<query>&product=<Top|Latest>&following=<boolean>` displays the Search tab.
|
||||
- `/status/:tweetId` displays one selected post and its visible conversation.
|
||||
- `/` redirects to `/user` without a target.
|
||||
|
||||
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.
|
||||
|
||||
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 target or query is present or a valid `/status/:tweetId` route is reached through a deliberate post link or manually entered URL.
|
||||
|
||||
### Server Boundary
|
||||
|
||||
@@ -125,7 +128,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.
|
||||
|
||||
Only two server functions are callable by the application:
|
||||
Only three server functions are callable by the application:
|
||||
|
||||
```ts
|
||||
type PostPage = {
|
||||
@@ -144,9 +147,19 @@ searchPosts(input: {
|
||||
following: boolean
|
||||
cursor?: string
|
||||
}): 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
|
||||
|
||||
@@ -174,6 +187,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.
|
||||
|
||||
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
|
||||
|
||||
### Layout
|
||||
@@ -239,6 +256,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:
|
||||
|
||||
- `invalid-input`;
|
||||
- `post-not-found`;
|
||||
- `post-unavailable`;
|
||||
- `user-not-found`;
|
||||
- `user-unavailable`;
|
||||
- `relay-config`;
|
||||
@@ -316,7 +335,9 @@ Bird's README and changelog document the search product option. No AGENTS.md or
|
||||
The work is complete when:
|
||||
|
||||
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.
|
||||
4. Search supports Top, Latest, and the follows filter.
|
||||
5. Infinite scrolling continues cursor pages without a fixed limit and without duplicate posts.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
"@tanstack/react-router": "1.170.17",
|
||||
"@tanstack/react-router-ssr-query": "1.167.1",
|
||||
"@tanstack/react-start": "1.168.27",
|
||||
"@yuta/bird": "link:../bird",
|
||||
"@yuta/bird": "0.10.0",
|
||||
"nitro": "3.0.260610-beta",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
|
||||
Generated
+24
-2
@@ -21,8 +21,8 @@ importers:
|
||||
specifier: 1.168.27
|
||||
version: 1.168.27([email protected]([email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected]))
|
||||
'@yuta/bird':
|
||||
specifier: link:../bird
|
||||
version: link:../bird
|
||||
specifier: 0.10.0
|
||||
version: 0.10.0
|
||||
nitro:
|
||||
specifier: 3.0.260610-beta
|
||||
version: 3.0.260610-beta([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected]))
|
||||
@@ -810,6 +810,10 @@ packages:
|
||||
'@vitest/[email protected]':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
'@yuta/[email protected]':
|
||||
resolution: {integrity: sha512-qU/zveaouKVEvEGsXxK4f1djgY7BLhi//NvyCMobEE93T+kzVFHD3wLbW9Yiz1hVqnsRIad5RtCYXeYz8gVMgg==, tarball: https://git.yutakobayashi.com/api/packages/yuta/npm/%40yuta%2Fbird/-/0.10.0/bird-0.10.0.tgz}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -878,6 +882,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
@@ -1113,6 +1121,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -2445,6 +2457,12 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@yuta/[email protected]':
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
json5: 2.2.3
|
||||
kleur: 4.1.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2508,6 +2526,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2663,6 +2683,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export function AppShell({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active: 'user' | 'search'
|
||||
active?: 'user' | 'search'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -80,6 +80,21 @@ describe('PostCard', () => {
|
||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('links deliberately to the internal detail page', () => {
|
||||
render(<PostCard post={richPost} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
||||
).toHaveAttribute('href', '/status/123')
|
||||
})
|
||||
|
||||
it('marks the focal post without linking to its current page', () => {
|
||||
render(<PostCard current post={{ ...richPost, quotedTweet: undefined }} />)
|
||||
|
||||
expect(screen.queryByRole('link', { name: '詳細・スレッド' })).toBeNull()
|
||||
expect(screen.getByRole('article')).toHaveClass('current-post')
|
||||
})
|
||||
|
||||
it('collapses a malformed optional timestamp without losing the post', () => {
|
||||
const { container } = render(
|
||||
<PostCard post={{ ...richPost, createdAt: 'not-a-date' }} />,
|
||||
|
||||
@@ -41,15 +41,19 @@ function formattedTime(createdAt?: string): string | undefined {
|
||||
export function PostCard({
|
||||
post,
|
||||
quoted = false,
|
||||
current = false,
|
||||
}: {
|
||||
post: Post
|
||||
quoted?: boolean
|
||||
current?: boolean
|
||||
}) {
|
||||
const time = formattedTime(post.createdAt)
|
||||
const original = `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`
|
||||
|
||||
return (
|
||||
<article className={quoted ? 'post quote' : 'post'}>
|
||||
<article
|
||||
className={quoted ? 'post quote' : current ? 'post current-post' : 'post'}
|
||||
>
|
||||
<header className="post-header">
|
||||
{post.author.profileImageUrl ? (
|
||||
<img
|
||||
@@ -86,6 +90,11 @@ export function PostCard({
|
||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||
{post.likeCount ?? 0}
|
||||
</span>
|
||||
{!current ? (
|
||||
<a href={`/status/${encodeURIComponent(post.id)}`}>
|
||||
詳細・スレッド
|
||||
</a>
|
||||
) : null}
|
||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||
元の投稿を開く
|
||||
</a>
|
||||
|
||||
@@ -154,6 +154,90 @@ describe('PostFeed', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the focal post once above its deduplicated conversation', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
focalPost: post('2'),
|
||||
conversationId: '1',
|
||||
nextCursor: 'next',
|
||||
tweets: [post('1'), post('2')],
|
||||
},
|
||||
{
|
||||
conversationId: '1',
|
||||
tweets: [post('2'), post('3')],
|
||||
},
|
||||
],
|
||||
pageParams: [undefined, { cursor: 'next', conversationId: '1' }],
|
||||
},
|
||||
} as never)
|
||||
|
||||
const { container } = render(
|
||||
<PostFeed request={{ kind: 'thread', tweetId: '2' }} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('表示中の投稿')).toBeVisible()
|
||||
expect(screen.getAllByText('post-2')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('article')).toHaveLength(3)
|
||||
expect(container.querySelector('.current-post')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the focal post visible when there are no other posts', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
focalPost: post('2'),
|
||||
conversationId: '1',
|
||||
tweets: [post('2')],
|
||||
},
|
||||
],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
|
||||
|
||||
expect(screen.getByText('post-2')).toBeVisible()
|
||||
expect(screen.getByText('会話にほかの投稿はありません。')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps the focal post and conversation across a later-page error', () => {
|
||||
usePostFeed.mockReturnValue({
|
||||
...queryResult(),
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
focalPost: post('2'),
|
||||
conversationId: '1',
|
||||
nextCursor: 'next',
|
||||
tweets: [post('1'), post('2')],
|
||||
},
|
||||
],
|
||||
pageParams: [undefined],
|
||||
},
|
||||
error: new PostLoadError({
|
||||
code: 'upstream',
|
||||
message: 'X から投稿を取得できませんでした。',
|
||||
retryable: true,
|
||||
}),
|
||||
hasNextPage: true,
|
||||
isError: true,
|
||||
isFetchNextPageError: true,
|
||||
} as never)
|
||||
|
||||
render(<PostFeed request={{ kind: 'thread', tweetId: '2' }} />)
|
||||
|
||||
expect(screen.getByText('post-2')).toBeVisible()
|
||||
expect(screen.getByText('post-1')).toBeVisible()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'続きの投稿を取得できませんでした。',
|
||||
)
|
||||
})
|
||||
|
||||
it('retries an initial retryable error with refetch', () => {
|
||||
const refetch = vi.fn().mockResolvedValue(undefined)
|
||||
usePostFeed.mockReturnValue({
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
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 { PostCard } from './post-card'
|
||||
|
||||
function FocalPost({ post }: { post: Post }) {
|
||||
return (
|
||||
<section aria-labelledby="focal-post-label" className="focal-post">
|
||||
<p className="focal-label" id="focal-post-label">
|
||||
表示中の投稿
|
||||
</p>
|
||||
<PostCard current post={post} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
const query = usePostFeed(request)
|
||||
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(() => {
|
||||
if (
|
||||
@@ -62,31 +83,43 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) {
|
||||
)
|
||||
}
|
||||
if (posts.length === 0 && !query.hasNextPage) {
|
||||
return <p className="state">条件に一致する投稿はありません。</p>
|
||||
return (
|
||||
<>
|
||||
{focalPost ? <FocalPost post={focalPost} /> : null}
|
||||
<p className="state">
|
||||
{focalPost
|
||||
? '会話にほかの投稿はありません。'
|
||||
: '条件に一致する投稿はありません。'}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-live="polite" className="feed">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
<div aria-hidden="true" ref={sentinel} />
|
||||
{query.isFetchingNextPage ? (
|
||||
<div className="loading-rail" role="status">
|
||||
次の投稿を取得しています…
|
||||
</div>
|
||||
) : null}
|
||||
{query.isFetchNextPageError ? (
|
||||
<div className="state" role="alert">
|
||||
<p>続きの投稿を取得できませんでした。</p>
|
||||
<button onClick={() => void query.fetchNextPage()} type="button">
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.hasNextPage ? (
|
||||
<p className="state">これ以上の投稿はありません。</p>
|
||||
) : null}
|
||||
</section>
|
||||
<>
|
||||
{focalPost ? <FocalPost post={focalPost} /> : null}
|
||||
<section aria-live="polite" className="feed">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
<div aria-hidden="true" ref={sentinel} />
|
||||
{query.isFetchingNextPage ? (
|
||||
<div className="loading-rail" role="status">
|
||||
次の投稿を取得しています…
|
||||
</div>
|
||||
) : null}
|
||||
{query.isFetchNextPageError ? (
|
||||
<div className="state" role="alert">
|
||||
<p>続きの投稿を取得できませんでした。</p>
|
||||
<button onClick={() => void query.fetchNextPage()} type="button">
|
||||
再試行
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!query.hasNextPage ? (
|
||||
<p className="state">これ以上の投稿はありません。</p>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSearchQuery, normalizeUserTarget } from './inputs'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
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', () => {
|
||||
it.each([
|
||||
|
||||
@@ -2,9 +2,27 @@ import { z } from 'zod'
|
||||
|
||||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||
const TWEET_ID = /^\d{1,32}$/
|
||||
const tweetIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(TWEET_ID, '投稿 ID を確認してください。')
|
||||
|
||||
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({
|
||||
target: z.string().trim().min(1).max(256),
|
||||
cursor: z.string().min(1).optional(),
|
||||
@@ -27,6 +45,14 @@ export const postSearchRouteSchema = z.object({
|
||||
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 {
|
||||
if (!HANDLE.test(value)) {
|
||||
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { flattenPostPages } from './page'
|
||||
import type { Post, PostPage } from './types'
|
||||
import {
|
||||
flattenConversationPages,
|
||||
flattenPostPages,
|
||||
focalPostFromPages,
|
||||
} from './page'
|
||||
import type { Post, PostPage, ThreadPage } from './types'
|
||||
|
||||
const post = (id: string): Post => ({
|
||||
id,
|
||||
@@ -18,3 +22,29 @@ describe('flattenPostPages', () => {
|
||||
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thread page helpers', () => {
|
||||
const focal = post('2')
|
||||
const pages: ThreadPage[] = [
|
||||
{
|
||||
focalPost: focal,
|
||||
conversationId: '1',
|
||||
nextCursor: 'next',
|
||||
tweets: [post('1'), focal],
|
||||
},
|
||||
{
|
||||
conversationId: '1',
|
||||
tweets: [focal, post('3')],
|
||||
},
|
||||
]
|
||||
|
||||
it('takes the focal post from the first page', () => {
|
||||
expect(focalPostFromPages(pages)).toEqual(focal)
|
||||
})
|
||||
|
||||
it('deduplicates the conversation and removes the focal post', () => {
|
||||
expect(
|
||||
flattenConversationPages(pages, focal.id).map(({ id }) => id),
|
||||
).toEqual(['1', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Post, PostPage } from './types'
|
||||
import type { Post, PostPage, ThreadPage } from './types'
|
||||
|
||||
export function flattenPostPages(pages: PostPage[]): Post[] {
|
||||
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,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { type BirdReader, loadUserPage, searchPage } from './post-service'
|
||||
import {
|
||||
type BirdReader,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
|
||||
const reader = (): BirdReader => ({
|
||||
getTweet: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
}),
|
||||
getThreadPaged: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [
|
||||
{
|
||||
id: '100',
|
||||
text: 'root',
|
||||
conversationId: '100',
|
||||
author: { username: 'root', name: 'Root' },
|
||||
},
|
||||
],
|
||||
nextCursor: 'thread-next',
|
||||
}),
|
||||
getUserIdByUsername: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, userId: '42' }),
|
||||
@@ -19,6 +45,173 @@ const reader = (): BirdReader => ({
|
||||
}),
|
||||
})
|
||||
|
||||
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('removes raw data from focal, conversation, and quotes', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: '100',
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
quotedTweet: {
|
||||
id: '90',
|
||||
text: 'quote',
|
||||
author: { username: 'quote', name: 'Quote' },
|
||||
_raw: { rest_id: 'private-quote' },
|
||||
},
|
||||
_raw: { rest_id: 'private-focal' },
|
||||
},
|
||||
})
|
||||
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||
success: true,
|
||||
tweets: [
|
||||
{
|
||||
id: '100',
|
||||
text: 'root',
|
||||
conversationId: '100',
|
||||
author: { username: 'root', name: 'Root' },
|
||||
_raw: { rest_id: 'private-thread' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain('private-')
|
||||
})
|
||||
|
||||
it('returns a safe non-retryable missing-post error', async () => {
|
||||
const client = reader()
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Tweet not found: private relay detail',
|
||||
})
|
||||
|
||||
expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'post-not-found',
|
||||
message: '投稿が見つかりませんでした。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies malformed Bird conversation metadata as an upstream failure', async () => {
|
||||
const client = reader()
|
||||
const privateMetadata = 'FINAL_PRIVATE_CONVERSATION_MARKER_71c9'
|
||||
vi.mocked(client.getTweet).mockResolvedValue({
|
||||
success: true,
|
||||
tweet: {
|
||||
id: '123',
|
||||
text: 'focal',
|
||||
conversationId: privateMetadata,
|
||||
author: { username: 'focus', name: 'Focus' },
|
||||
},
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'upstream',
|
||||
message: 'X から投稿を取得できませんでした。',
|
||||
retryable: true,
|
||||
},
|
||||
})
|
||||
expect(client.getTweet).toHaveBeenCalledWith('123')
|
||||
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Unexpected Bird failure:',
|
||||
expect.objectContaining({
|
||||
message: '投稿 ID を確認してください。',
|
||||
}),
|
||||
)
|
||||
expect(JSON.stringify(result)).not.toContain(privateMetadata)
|
||||
expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(
|
||||
privateMetadata,
|
||||
)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an invalid ID without a Bird call', async () => {
|
||||
const client = reader()
|
||||
|
||||
expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-input',
|
||||
message: '投稿 ID を確認してください。',
|
||||
retryable: false,
|
||||
},
|
||||
})
|
||||
expect(client.getTweet).not.toHaveBeenCalled()
|
||||
expect(client.getThreadPaged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadUserPage', () => {
|
||||
it('resolves a handle and fetches exactly one page', async () => {
|
||||
const client = reader()
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
|
||||
import { buildSearchQuery, InputError, normalizeUserTarget } from './inputs'
|
||||
import type {
|
||||
GetTweetResult,
|
||||
SearchProduct,
|
||||
SearchResult,
|
||||
TweetData,
|
||||
} from '@yuta/bird'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
InputError,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
} from './inputs'
|
||||
import type {
|
||||
LoadError,
|
||||
LoadFailure,
|
||||
LoadResult,
|
||||
Post,
|
||||
SearchPageInput,
|
||||
ThreadLoadResult,
|
||||
ThreadPageInput,
|
||||
UserPageInput,
|
||||
} from './types'
|
||||
|
||||
@@ -15,6 +28,15 @@ type UserLookupResult = {
|
||||
}
|
||||
|
||||
export interface BirdReader {
|
||||
getTweet(tweetId: string): Promise<GetTweetResult>
|
||||
getThreadPaged(
|
||||
tweetId: string,
|
||||
options: {
|
||||
cursor?: string
|
||||
maxPages: number
|
||||
pageDelayMs: number
|
||||
},
|
||||
): Promise<SearchResult>
|
||||
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
||||
getUserTweetsPaged(
|
||||
userId: string,
|
||||
@@ -39,9 +61,12 @@ const failure = (
|
||||
code: LoadError['code'],
|
||||
message: string,
|
||||
retryable: boolean,
|
||||
): LoadResult => ({ ok: false, error: { code, message, retryable } })
|
||||
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
|
||||
|
||||
function upstreamFailure(diagnostic: unknown = ''): LoadResult {
|
||||
function upstreamFailure(
|
||||
diagnostic: unknown = '',
|
||||
subject: 'user' | 'post' = 'user',
|
||||
): LoadFailure {
|
||||
const message =
|
||||
diagnostic instanceof Error
|
||||
? diagnostic.message
|
||||
@@ -53,14 +78,18 @@ function upstreamFailure(diagnostic: unknown = ''): LoadResult {
|
||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||
}
|
||||
if (lower.includes('not found')) {
|
||||
return failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
||||
return subject === 'post'
|
||||
? failure('post-not-found', '投稿が見つかりませんでした。', false)
|
||||
: failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
||||
}
|
||||
if (lower.includes('suspended') || lower.includes('protected')) {
|
||||
return failure(
|
||||
'user-unavailable',
|
||||
'このユーザーの投稿は取得できません。',
|
||||
false,
|
||||
)
|
||||
return subject === 'post'
|
||||
? failure('post-unavailable', 'この投稿は取得できません。', false)
|
||||
: failure(
|
||||
'user-unavailable',
|
||||
'このユーザーの投稿は取得できません。',
|
||||
false,
|
||||
)
|
||||
}
|
||||
console.error('Unexpected Bird failure:', diagnostic)
|
||||
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
||||
@@ -87,6 +116,58 @@ function resultPage(result: SearchResult): LoadResult {
|
||||
: upstreamFailure(result.error)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
page: {
|
||||
tweets: (result.tweets ?? []).map(publicPost),
|
||||
focalPost,
|
||||
conversationId,
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InputError) {
|
||||
return failure('invalid-input', error.message, false)
|
||||
}
|
||||
return upstreamFailure(error, 'post')
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUserPage(
|
||||
reader: BirdReader,
|
||||
input: UserPageInput,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { searchPageInputSchema, userPageInputSchema } from './inputs'
|
||||
import { loadUserPage, searchPage } from './post-service'
|
||||
import type { LoadResult } from './types'
|
||||
import {
|
||||
searchPageInputSchema,
|
||||
threadPageInputSchema,
|
||||
userPageInputSchema,
|
||||
} from './inputs'
|
||||
import { 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)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -39,3 +43,13 @@ export const searchPosts = createServerFn({ method: 'GET' })
|
||||
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,15 @@ export type PostPage = {
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
export type ThreadPage = PostPage & {
|
||||
focalPost?: Post
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
export type LoadErrorCode =
|
||||
| 'invalid-input'
|
||||
| 'post-not-found'
|
||||
| 'post-unavailable'
|
||||
| 'user-not-found'
|
||||
| 'user-unavailable'
|
||||
| 'relay-config'
|
||||
@@ -23,9 +30,25 @@ export type LoadError = {
|
||||
retryable: boolean
|
||||
}
|
||||
|
||||
export type LoadResult =
|
||||
| { ok: true; page: PostPage }
|
||||
| { ok: false; error: LoadError }
|
||||
export type LoadFailure = { ok: false; error: LoadError }
|
||||
|
||||
export type LoadResult<TPage extends PostPage = PostPage> =
|
||||
| { ok: true; page: TPage }
|
||||
| LoadFailure
|
||||
|
||||
export type ThreadLoadResult = LoadResult<ThreadPage>
|
||||
|
||||
export type ThreadPageInput =
|
||||
| {
|
||||
tweetId: string
|
||||
conversationId?: never
|
||||
cursor?: never
|
||||
}
|
||||
| {
|
||||
tweetId: string
|
||||
conversationId: string
|
||||
cursor: string
|
||||
}
|
||||
|
||||
export type UserPageInput = {
|
||||
target: string
|
||||
|
||||
@@ -16,6 +16,15 @@ vi.mock('@tanstack/react-start', async (importOriginal) => {
|
||||
return { ...original, useServerFn }
|
||||
})
|
||||
|
||||
type FeedLoaders = Parameters<typeof createPostFeedOptions>[1]
|
||||
|
||||
const loaders = (overrides: Partial<FeedLoaders> = {}): FeedLoaders => ({
|
||||
loadUser: vi.fn(),
|
||||
search: vi.fn(),
|
||||
thread: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('createPostFeedOptions', () => {
|
||||
it('forwards one user cursor in the server function payload', async () => {
|
||||
const loadUser = vi.fn().mockResolvedValue({
|
||||
@@ -24,7 +33,7 @@ describe('createPostFeedOptions', () => {
|
||||
})
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: '@yuta' },
|
||||
{ loadUser, search: vi.fn() },
|
||||
loaders({ loadUser }),
|
||||
)
|
||||
|
||||
await options.queryFn({ pageParam: 'cursor' } as never)
|
||||
@@ -38,7 +47,7 @@ describe('createPostFeedOptions', () => {
|
||||
it('does not retry requests automatically', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ loadUser: vi.fn(), search: vi.fn() },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
expect(options.retry).toBe(false)
|
||||
@@ -47,7 +56,7 @@ describe('createPostFeedOptions', () => {
|
||||
it('does not refetch automatically when the window regains focus', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ loadUser: vi.fn(), search: vi.fn() },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
expect(options.refetchOnWindowFocus).toBe(false)
|
||||
@@ -56,7 +65,7 @@ describe('createPostFeedOptions', () => {
|
||||
it('does not refetch automatically when the connection returns', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ loadUser: vi.fn(), search: vi.fn() },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
expect(options.refetchOnReconnect).toBe(false)
|
||||
@@ -65,7 +74,7 @@ describe('createPostFeedOptions', () => {
|
||||
it('takes the next cursor only from the resolved page', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{ loadUser: vi.fn(), search: vi.fn() },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
expect(
|
||||
@@ -82,7 +91,7 @@ describe('createPostFeedOptions', () => {
|
||||
product: 'Top',
|
||||
following: true,
|
||||
},
|
||||
{ loadUser: vi.fn(), search: vi.fn() },
|
||||
loaders(),
|
||||
)
|
||||
|
||||
expect(options.queryKey).toEqual([
|
||||
@@ -103,7 +112,7 @@ describe('createPostFeedOptions', () => {
|
||||
product: 'Top',
|
||||
following: true,
|
||||
},
|
||||
{ loadUser: vi.fn(), search },
|
||||
loaders({ search }),
|
||||
)
|
||||
|
||||
await options.queryFn({ pageParam: 'search-cursor' } as never)
|
||||
@@ -119,6 +128,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 () => {
|
||||
const error = {
|
||||
code: 'upstream' as const,
|
||||
@@ -127,10 +191,9 @@ describe('createPostFeedOptions', () => {
|
||||
}
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
{
|
||||
loaders({
|
||||
loadUser: vi.fn().mockResolvedValue({ ok: false, error }),
|
||||
search: vi.fn(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const request = options.queryFn({ pageParam: undefined } as never)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import { loadUserPosts, searchPosts } from './server-functions'
|
||||
import { loadThreadPosts, loadUserPosts, searchPosts } from './server-functions'
|
||||
import type {
|
||||
LoadError,
|
||||
LoadResult,
|
||||
PostPage,
|
||||
SearchPageInput,
|
||||
ThreadLoadResult,
|
||||
ThreadPage,
|
||||
ThreadPageInput,
|
||||
UserPageInput,
|
||||
} from './types'
|
||||
|
||||
@@ -18,10 +21,19 @@ export type FeedRequest =
|
||||
product: SearchProduct
|
||||
following: boolean
|
||||
}
|
||||
| { kind: 'thread'; tweetId: string }
|
||||
|
||||
type ThreadPageParam = {
|
||||
cursor: string
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
type FeedPageParam = string | ThreadPageParam | undefined
|
||||
|
||||
type Loaders = {
|
||||
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
|
||||
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
||||
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
|
||||
}
|
||||
|
||||
export class PostLoadError extends Error {
|
||||
@@ -30,7 +42,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)
|
||||
return result.page
|
||||
}
|
||||
@@ -38,41 +50,73 @@ function unwrap(result: LoadResult): PostPage {
|
||||
export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
return {
|
||||
queryKey: ['posts', request] as const,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
initialPageParam: undefined as FeedPageParam,
|
||||
retry: false as const,
|
||||
refetchOnWindowFocus: false as const,
|
||||
refetchOnReconnect: false as const,
|
||||
queryFn: async ({ pageParam }: { pageParam: string | undefined }) =>
|
||||
request.kind === 'user'
|
||||
? unwrap(
|
||||
await loaders.loadUser({
|
||||
data: { target: request.target, cursor: pageParam },
|
||||
}),
|
||||
)
|
||||
: unwrap(
|
||||
await loaders.search({
|
||||
data: {
|
||||
query: request.query,
|
||||
product: request.product,
|
||||
following: request.following,
|
||||
cursor: pageParam,
|
||||
},
|
||||
}),
|
||||
),
|
||||
getNextPageParam: (page: PostPage) => page.nextCursor,
|
||||
queryFn: async ({ pageParam }: { pageParam: FeedPageParam }) => {
|
||||
if (request.kind === 'user') {
|
||||
return unwrap(
|
||||
await loaders.loadUser({
|
||||
data: {
|
||||
target: request.target,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (request.kind === 'search') {
|
||||
return unwrap(
|
||||
await loaders.search({
|
||||
data: {
|
||||
query: request.query,
|
||||
product: request.product,
|
||||
following: request.following,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
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) {
|
||||
const loadUser = useServerFn(loadUserPosts)
|
||||
const search = useServerFn(searchPosts)
|
||||
const thread = useServerFn(loadThreadPosts)
|
||||
const disabled = {
|
||||
kind: 'user',
|
||||
target: '',
|
||||
} satisfies FeedRequest
|
||||
|
||||
return useInfiniteQuery({
|
||||
...createPostFeedOptions(request ?? disabled, { loadUser, search }),
|
||||
...createPostFeedOptions(request ?? disabled, {
|
||||
loadUser,
|
||||
search,
|
||||
thread,
|
||||
}),
|
||||
enabled: request !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
+21
-3
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UserRouteImport } from './routes/user'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId'
|
||||
|
||||
const UserRoute = UserRouteImport.update({
|
||||
id: '/user',
|
||||
@@ -28,35 +29,44 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StatusTweetIdRoute = StatusTweetIdRouteImport.update({
|
||||
id: '/status/$tweetId',
|
||||
path: '/status/$tweetId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/search' | '/user'
|
||||
fullPaths: '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/search' | '/user'
|
||||
id: '__root__' | '/' | '/search' | '/user'
|
||||
to: '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
id: '__root__' | '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
UserRoute: typeof UserRoute
|
||||
StatusTweetIdRoute: typeof StatusTweetIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -82,6 +92,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/status/$tweetId': {
|
||||
id: '/status/$tweetId'
|
||||
path: '/status/$tweetId'
|
||||
fullPath: '/status/$tweetId'
|
||||
preLoaderRoute: typeof StatusTweetIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
UserRoute: UserRoute,
|
||||
StatusTweetIdRoute: StatusTweetIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -88,4 +88,31 @@ describe('post feed route wiring', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('turns a decimal status path into a deliberate thread request', async () => {
|
||||
await renderRoute('/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 status path local and idle', async () => {
|
||||
await renderRoute('/status/not-a-tweet')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'投稿 ID を確認してください。',
|
||||
)
|
||||
expect(usePostFeed).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppShell } from '#/components/app-shell'
|
||||
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||
import { InputError, normalizeTweetId } from '#/features/posts/inputs'
|
||||
|
||||
export const Route = createFileRoute('/status/$tweetId')({
|
||||
component: StatusRoute,
|
||||
})
|
||||
|
||||
function StatusRoute() {
|
||||
const { tweetId: rawTweetId } = Route.useParams()
|
||||
let tweetId: string | undefined
|
||||
let error: string | undefined
|
||||
|
||||
try {
|
||||
tweetId = normalizeTweetId(rawTweetId)
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof InputError
|
||||
? cause.message
|
||||
: '投稿 ID を確認してください。'
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -254,6 +254,20 @@ a {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
@@ -268,6 +282,11 @@ a {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.current-post {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
@@ -361,6 +380,7 @@ video.media {
|
||||
.post-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
+159
-25
@@ -8,34 +8,51 @@ if (!portValue || !Number.isInteger(port) || port < 1) {
|
||||
}
|
||||
|
||||
const failedRetryQueries = new Set()
|
||||
const rootByTarget = new Map([
|
||||
['1001', '1000'],
|
||||
['2001', '2000'],
|
||||
])
|
||||
const targetByRoot = new Map([
|
||||
['1000', '1001'],
|
||||
['2000', '2001'],
|
||||
])
|
||||
const failedThreadRoots = new Set()
|
||||
|
||||
const isRecord = (value) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const tweet = (id, text, username = 'yuta') => ({
|
||||
const tweetResult = (
|
||||
id,
|
||||
text,
|
||||
username = 'yuta',
|
||||
{ conversationId = id, inReplyTo } = {},
|
||||
) => ({
|
||||
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: 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: {
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
result: tweetResult(id, text, username, options),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -80,9 +97,12 @@ 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}`),
|
||||
)
|
||||
const operation = [
|
||||
'UserByScreenName',
|
||||
'UserTweets',
|
||||
'TweetDetail',
|
||||
'SearchTimeline',
|
||||
].find((name) => url.pathname.endsWith(`/${name}`))
|
||||
|
||||
if (!operation) {
|
||||
fail(response, 501, 'unsupported read operation')
|
||||
@@ -143,8 +163,13 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
const secondPage = variables.cursor === 'user-next'
|
||||
const entries = secondPage
|
||||
? [tweet('u2', 'user page 2')]
|
||||
: [tweet('u1', 'user page 1'), cursor('user-next')]
|
||||
? [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) } } },
|
||||
@@ -153,6 +178,115 @@ const server = createServer(async (request, response) => {
|
||||
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 text = focalTweetId === '1001' ? 'user page 1' : 'retry focal'
|
||||
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
})
|
||||
send(response, {
|
||||
data: {
|
||||
tweetResult: { result },
|
||||
threaded_conversation_with_injections_v2: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
tweet(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
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 === '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,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
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')
|
||||
|
||||
@@ -62,6 +62,53 @@ test('requires intent and infinitely loads a user timeline', async ({
|
||||
})
|
||||
})
|
||||
|
||||
test('opens a card detail and infinitely loads its conversation', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/user?target=yuta')
|
||||
const sourceCard = page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: 'user page 1' })
|
||||
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/status\/1001$/)
|
||||
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
||||
await expect(page.getByText('表示中の投稿')).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(page.getByText('これ以上の投稿はありません。')).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,
|
||||
}) => {
|
||||
await openReader(page, '/status/2001')
|
||||
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
await expect(page.getByText('thread root')).toBeVisible()
|
||||
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||
const alert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(alert).toHaveCount(1)
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
|
||||
await alert.getByRole('button', { name: '再試行' }).click()
|
||||
|
||||
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||
await expect(alert).toHaveCount(0)
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 50 KiB |
Reference in New Issue
Block a user