Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88730dbec9 | ||
|
|
766d13dfcc | ||
|
|
604c7f16e2 | ||
|
|
83992bd962 | ||
|
|
234f4354f9 |
@@ -1,14 +1,16 @@
|
||||
# Twitter Lite
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
- User timelines and raw X search syntax
|
||||
- Popular (`Top`) and chronological (`Latest`) search
|
||||
- Optional `filter:follows` search
|
||||
- Authenticated account list selection and list timelines from a deliberate URL or ID
|
||||
- Infinite cursor pagination with explicit retry
|
||||
- Deliberate post detail pages with the visible conversation
|
||||
- Infinite conversation loading with explicit continuation retry
|
||||
@@ -20,6 +22,15 @@ 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
|
||||
|
||||
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The committed `.npmrc`
|
||||
@@ -48,6 +59,7 @@ sent to the browser.
|
||||
```bash
|
||||
nix develop -c pnpm dev
|
||||
nix develop -c pnpm dev:tailscale
|
||||
nix develop -c pnpm knip
|
||||
nix develop -c pnpm test
|
||||
nix develop -c pnpm test:e2e
|
||||
nix develop -c pnpm test:live
|
||||
@@ -64,6 +76,38 @@ Development binds to `127.0.0.1` by default. `dev:tailscale` binds to
|
||||
only on a trusted network and obtain the Tailscale address with
|
||||
`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
|
||||
|
||||
Bird uses X's internal GraphQL operations through the configured relay. Query
|
||||
|
||||
@@ -13,8 +13,9 @@ same intentional infinite-scroll behavior as the existing user and search
|
||||
feeds.
|
||||
|
||||
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.
|
||||
post-detail route, now `/:handle/status/:tweetId`, and the narrowly scoped
|
||||
read-only server function, `loadThreadPosts`. The base design now incorporates
|
||||
both additions.
|
||||
|
||||
## Decisions
|
||||
|
||||
@@ -38,11 +39,13 @@ function, `loadThreadPosts`. The base design now incorporates both additions.
|
||||
Add the typed route:
|
||||
|
||||
```text
|
||||
/status/:tweetId
|
||||
/:handle/status/:tweetId
|
||||
```
|
||||
|
||||
`tweetId` is a non-empty decimal X post ID. Invalid IDs render a non-retryable
|
||||
input error and make no relay request.
|
||||
Generated links use the focal post author's normalized `handle`, and `tweetId`
|
||||
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
|
||||
footer. The existing external `元の投稿を開く` link remains available. The
|
||||
@@ -50,9 +53,9 @@ whole card is not clickable because cards can contain text links and media
|
||||
controls. Quoted cards retain their current compact presentation.
|
||||
|
||||
The status route uses the existing application shell. Neither `ユーザー` nor
|
||||
`検索` receives `aria-current` on this third route. The status page adds no
|
||||
third global navigation tab; it is reachable only from a deliberate post link
|
||||
or a manually entered URL.
|
||||
`検索` receives `aria-current` on the post-detail route. The status page adds
|
||||
no global navigation tab; it is reachable only from a deliberate post link or
|
||||
a manually entered URL.
|
||||
|
||||
## Bird and Relay Boundary
|
||||
|
||||
|
||||
@@ -11,15 +11,15 @@ 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;
|
||||
- follow a deliberate post link or manually enter a valid `/status/:tweetId` URL
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -35,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;
|
||||
- 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;
|
||||
- infinite cursor pagination;
|
||||
@@ -45,7 +46,7 @@ The product reduces ambient discovery rather than limiting how far a deliberate
|
||||
|
||||
- authentication and multi-user accounts;
|
||||
- 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;
|
||||
- user autocomplete and typeahead;
|
||||
- People and Media search products;
|
||||
@@ -105,14 +106,16 @@ TanStack Start supplies the browser/server boundary. Bird is imported only by se
|
||||
|
||||
### 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.
|
||||
- `/status/:tweetId` displays one selected post and its visible conversation.
|
||||
- `/` 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 or a valid `/status/:tweetId` route is reached through a deliberate post link or manually entered URL.
|
||||
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
|
||||
|
||||
@@ -206,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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -3,11 +3,57 @@
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { nixpkgs, ... }:
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
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-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
|
||||
};
|
||||
|
||||
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 {
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
twitter-lite = mkTwitterLite pkgs;
|
||||
in {
|
||||
inherit twitter-lite;
|
||||
default = twitter-lite;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
@@ -18,5 +64,65 @@
|
||||
"${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,10 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
export default {
|
||||
entry: [
|
||||
'playwright.config.ts',
|
||||
'tests/e2e/**/*.spec.ts',
|
||||
'tests/e2e/mock-relay.mjs',
|
||||
],
|
||||
playwright: false,
|
||||
} satisfies KnipConfig
|
||||
@@ -18,6 +18,7 @@
|
||||
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"knip": "knip",
|
||||
"format": "biome check --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -29,7 +30,7 @@
|
||||
"@tanstack/react-router": "1.170.17",
|
||||
"@tanstack/react-router-ssr-query": "1.167.1",
|
||||
"@tanstack/react-start": "1.168.27",
|
||||
"@yuta/bird": "0.10.0",
|
||||
"@yuta/bird": "0.10.1",
|
||||
"nitro": "3.0.260610-beta",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
@@ -38,7 +39,6 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@tanstack/router-cli": "1.167.18",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -47,6 +47,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"jsdom": "29.1.1",
|
||||
"knip": "6.27.0",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vitest": "4.1.10"
|
||||
|
||||
@@ -4,7 +4,7 @@ export function AppShell({
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
active?: 'user' | 'search'
|
||||
active?: 'user' | 'search' | 'list'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
@@ -12,11 +12,7 @@ export function AppShell({
|
||||
<header className="app-header">
|
||||
<p className="brand">TWITTER LITE</p>
|
||||
<nav aria-label="閲覧方法" className="tabs">
|
||||
<Link
|
||||
aria-current={active === 'user' ? 'page' : undefined}
|
||||
search={{ target: '' }}
|
||||
to="/user"
|
||||
>
|
||||
<Link aria-current={active === 'user' ? 'page' : undefined} to="/">
|
||||
ユーザー
|
||||
</Link>
|
||||
<Link
|
||||
@@ -26,6 +22,12 @@ export function AppShell({
|
||||
>
|
||||
検索
|
||||
</Link>
|
||||
<Link
|
||||
aria-current={active === 'list' ? 'page' : undefined}
|
||||
to="/i/lists"
|
||||
>
|
||||
リスト
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<section className="reader">{children}</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ListForm } from './list-form'
|
||||
import { SearchForm } from './search-form'
|
||||
import { UserForm } from './user-form'
|
||||
|
||||
@@ -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', () => {
|
||||
it('uses a labelled grouping for ranking controls', () => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ describe('PostCard', () => {
|
||||
|
||||
expect(
|
||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
||||
).toHaveAttribute('href', '/status/123')
|
||||
).toHaveAttribute('href', '/yuta/status/123')
|
||||
})
|
||||
|
||||
it('marks the focal post without linking to its current page', () => {
|
||||
|
||||
@@ -91,7 +91,9 @@ export function PostCard({
|
||||
{post.likeCount ?? 0}
|
||||
</span>
|
||||
{!current ? (
|
||||
<a href={`/status/${encodeURIComponent(post.id)}`}>
|
||||
<a
|
||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||
>
|
||||
詳細・スレッド
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSearchQuery,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
threadPageInputSchema,
|
||||
@@ -67,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', () => {
|
||||
it('keeps a deliberate query unchanged', () => {
|
||||
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 LIST_ID = /^\d{1,32}$/
|
||||
const tweetIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -28,6 +29,13 @@ export const userPageInputSchema = z.object({
|
||||
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({
|
||||
query: z.string().trim().min(1).max(512),
|
||||
product: z.enum(['Top', 'Latest']),
|
||||
@@ -35,10 +43,6 @@ export const searchPageInputSchema = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
export const userRouteSearchSchema = z.object({
|
||||
target: z.string().catch(''),
|
||||
})
|
||||
|
||||
export const postSearchRouteSchema = z.object({
|
||||
q: z.string().catch(''),
|
||||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||
@@ -88,6 +92,38 @@ export function normalizeUserTarget(raw: string): string {
|
||||
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 {
|
||||
const query = raw.trim()
|
||||
if (!query) {
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
type BirdReader,
|
||||
loadListChoices,
|
||||
loadListPage,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
|
||||
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: {
|
||||
@@ -43,6 +60,58 @@ const reader = (): BirdReader => ({
|
||||
tweets: [],
|
||||
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', () => {
|
||||
@@ -250,6 +319,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', () => {
|
||||
it('forwards Top and appends follows once', async () => {
|
||||
const client = reader()
|
||||
|
||||
@@ -7,10 +7,13 @@ import type {
|
||||
import {
|
||||
buildSearchQuery,
|
||||
InputError,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
} from './inputs'
|
||||
import type {
|
||||
ListChoicesResult,
|
||||
ListPageInput,
|
||||
LoadError,
|
||||
LoadFailure,
|
||||
LoadResult,
|
||||
@@ -27,7 +30,21 @@ type UserLookupResult = {
|
||||
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 {
|
||||
getLists(count?: number): Promise<BirdListsResult>
|
||||
getTweet(tweetId: string): Promise<GetTweetResult>
|
||||
getThreadPaged(
|
||||
tweetId: string,
|
||||
@@ -55,6 +72,13 @@ export interface BirdReader {
|
||||
maxPages: number
|
||||
},
|
||||
): Promise<SearchResult>
|
||||
getAllListTimeline(
|
||||
listId: string,
|
||||
options: {
|
||||
cursor?: string
|
||||
maxPages: number
|
||||
},
|
||||
): Promise<SearchResult>
|
||||
}
|
||||
|
||||
const failure = (
|
||||
@@ -63,9 +87,27 @@ const failure = (
|
||||
retryable: boolean,
|
||||
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
|
||||
|
||||
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: 'user' | 'post' = 'user',
|
||||
subject: FailureSubject = 'user',
|
||||
): LoadFailure {
|
||||
const message =
|
||||
diagnostic instanceof Error
|
||||
@@ -78,18 +120,10 @@ function upstreamFailure(
|
||||
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||
}
|
||||
if (lower.includes('not found')) {
|
||||
return subject === 'post'
|
||||
? failure('post-not-found', '投稿が見つかりませんでした。', false)
|
||||
: failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
||||
return notFoundFailure[subject]()
|
||||
}
|
||||
if (lower.includes('suspended') || lower.includes('protected')) {
|
||||
return subject === 'post'
|
||||
? failure('post-unavailable', 'この投稿は取得できません。', false)
|
||||
: failure(
|
||||
'user-unavailable',
|
||||
'このユーザーの投稿は取得できません。',
|
||||
false,
|
||||
)
|
||||
return unavailableFailure[subject]()
|
||||
}
|
||||
console.error('Unexpected Bird failure:', diagnostic)
|
||||
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
||||
@@ -104,7 +138,38 @@ function publicPost(tweet: TweetData): 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
|
||||
? {
|
||||
ok: true,
|
||||
@@ -113,7 +178,7 @@ function resultPage(result: SearchResult): LoadResult {
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
}
|
||||
: upstreamFailure(result.error)
|
||||
: upstreamFailure(result.error, subject)
|
||||
}
|
||||
|
||||
export async function loadThreadPage(
|
||||
@@ -196,6 +261,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(
|
||||
reader: BirdReader,
|
||||
input: SearchPageInput,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import {
|
||||
listChoicesInputSchema,
|
||||
listPageInputSchema,
|
||||
searchPageInputSchema,
|
||||
threadPageInputSchema,
|
||||
userPageInputSchema,
|
||||
} from './inputs'
|
||||
import { loadThreadPage, loadUserPage, searchPage } from './post-service'
|
||||
import {
|
||||
loadListChoices,
|
||||
loadListPage,
|
||||
loadThreadPage,
|
||||
loadUserPage,
|
||||
searchPage,
|
||||
} from './post-service'
|
||||
import type { LoadFailure } from './types'
|
||||
|
||||
const configFailure = (diagnostic: unknown): LoadFailure => {
|
||||
@@ -34,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' })
|
||||
.validator(searchPageInputSchema)
|
||||
.handler(async ({ data }) => {
|
||||
|
||||
@@ -14,8 +14,10 @@ export type ThreadPage = PostPage & {
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
export type LoadErrorCode =
|
||||
type LoadErrorCode =
|
||||
| 'invalid-input'
|
||||
| 'list-not-found'
|
||||
| 'list-unavailable'
|
||||
| 'post-not-found'
|
||||
| 'post-unavailable'
|
||||
| 'user-not-found'
|
||||
@@ -38,6 +40,16 @@ export type LoadResult<TPage extends PostPage = PostPage> =
|
||||
|
||||
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
|
||||
@@ -55,6 +67,11 @@ export type UserPageInput = {
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export type ListPageInput = {
|
||||
target: string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export type SearchPageInput = {
|
||||
query: string
|
||||
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))
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -44,6 +45,28 @@ 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', () => {
|
||||
const options = createPostFeedOptions(
|
||||
{ kind: 'user', target: 'yuta' },
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useServerFn } from '@tanstack/react-start'
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import { loadThreadPosts, loadUserPosts, searchPosts } from './server-functions'
|
||||
import {
|
||||
loadListPosts,
|
||||
loadThreadPosts,
|
||||
loadUserPosts,
|
||||
searchPosts,
|
||||
} from './server-functions'
|
||||
import type {
|
||||
ListPageInput,
|
||||
LoadError,
|
||||
LoadResult,
|
||||
PostPage,
|
||||
@@ -15,6 +21,7 @@ import type {
|
||||
|
||||
export type FeedRequest =
|
||||
| { kind: 'user'; target: string }
|
||||
| { kind: 'list'; target: string }
|
||||
| {
|
||||
kind: 'search'
|
||||
query: string
|
||||
@@ -32,6 +39,7 @@ type FeedPageParam = string | ThreadPageParam | undefined
|
||||
|
||||
type Loaders = {
|
||||
loadUser: (options: { data: UserPageInput }) => Promise<LoadResult>
|
||||
loadList: (options: { data: ListPageInput }) => Promise<LoadResult>
|
||||
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
||||
thread: (options: { data: ThreadPageInput }) => Promise<ThreadLoadResult>
|
||||
}
|
||||
@@ -65,6 +73,16 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (request.kind === 'list') {
|
||||
return unwrap(
|
||||
await loaders.loadList({
|
||||
data: {
|
||||
target: request.target,
|
||||
cursor: typeof pageParam === 'string' ? pageParam : undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (request.kind === 'search') {
|
||||
return unwrap(
|
||||
await loaders.search({
|
||||
@@ -104,6 +122,7 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) {
|
||||
|
||||
export function usePostFeed(request: FeedRequest | undefined) {
|
||||
const loadUser = useServerFn(loadUserPosts)
|
||||
const loadList = useServerFn(loadListPosts)
|
||||
const search = useServerFn(searchPosts)
|
||||
const thread = useServerFn(loadThreadPosts)
|
||||
const disabled = {
|
||||
@@ -114,6 +133,7 @@ export function usePostFeed(request: FeedRequest | undefined) {
|
||||
return useInfiniteQuery({
|
||||
...createPostFeedOptions(request ?? disabled, {
|
||||
loadUser,
|
||||
loadList,
|
||||
search,
|
||||
thread,
|
||||
}),
|
||||
|
||||
@@ -9,75 +9,107 @@
|
||||
// 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 UserRouteImport } from './routes/user'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as HandleRouteImport } from './routes/$handle'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as StatusTweetIdRouteImport } from './routes/status.$tweetId'
|
||||
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({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HandleRoute = HandleRouteImport.update({
|
||||
id: '/$handle',
|
||||
path: '/$handle',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StatusTweetIdRoute = StatusTweetIdRouteImport.update({
|
||||
id: '/status/$tweetId',
|
||||
path: '/status/$tweetId',
|
||||
const IListsRoute = IListsRouteImport.update({
|
||||
id: '/i/lists',
|
||||
path: '/i/lists',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IListsListIdRoute = IListsListIdRouteImport.update({
|
||||
id: '/i/lists_/$listId',
|
||||
path: '/i/lists/$listId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HandleStatusTweetIdRoute = HandleStatusTweetIdRouteImport.update({
|
||||
id: '/$handle_/status/$tweetId',
|
||||
path: '/$handle/status/$tweetId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$handle': typeof HandleRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
'/i/lists': typeof IListsRoute
|
||||
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||
'/i/lists/$listId': typeof IListsListIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$handle': typeof HandleRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
'/i/lists': typeof IListsRoute
|
||||
'/$handle/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||
'/i/lists/$listId': typeof IListsListIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$handle': typeof HandleRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/user': typeof UserRoute
|
||||
'/status/$tweetId': typeof StatusTweetIdRoute
|
||||
'/i/lists': typeof IListsRoute
|
||||
'/$handle_/status/$tweetId': typeof HandleStatusTweetIdRoute
|
||||
'/i/lists_/$listId': typeof IListsListIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/$handle'
|
||||
| '/search'
|
||||
| '/i/lists'
|
||||
| '/$handle/status/$tweetId'
|
||||
| '/i/lists/$listId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
id: '__root__' | '/' | '/search' | '/user' | '/status/$tweetId'
|
||||
to:
|
||||
| '/'
|
||||
| '/$handle'
|
||||
| '/search'
|
||||
| '/i/lists'
|
||||
| '/$handle/status/$tweetId'
|
||||
| '/i/lists/$listId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/$handle'
|
||||
| '/search'
|
||||
| '/i/lists'
|
||||
| '/$handle_/status/$tweetId'
|
||||
| '/i/lists_/$listId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
HandleRoute: typeof HandleRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
UserRoute: typeof UserRoute
|
||||
StatusTweetIdRoute: typeof StatusTweetIdRoute
|
||||
IListsRoute: typeof IListsRoute
|
||||
HandleStatusTweetIdRoute: typeof HandleStatusTweetIdRoute
|
||||
IListsListIdRoute: typeof IListsListIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/user': {
|
||||
id: '/user'
|
||||
path: '/user'
|
||||
fullPath: '/user'
|
||||
preLoaderRoute: typeof UserRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/search': {
|
||||
id: '/search'
|
||||
path: '/search'
|
||||
@@ -85,6 +117,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$handle': {
|
||||
id: '/$handle'
|
||||
path: '/$handle'
|
||||
fullPath: '/$handle'
|
||||
preLoaderRoute: typeof HandleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -92,11 +131,25 @@ 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
|
||||
'/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
|
||||
}
|
||||
}
|
||||
@@ -104,9 +157,11 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
HandleRoute: HandleRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
UserRoute: UserRoute,
|
||||
StatusTweetIdRoute: StatusTweetIdRoute,
|
||||
IListsRoute: IListsRoute,
|
||||
HandleStatusTweetIdRoute: HandleStatusTweetIdRoute,
|
||||
IListsListIdRoute: IListsListIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._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} />
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
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'
|
||||
import {
|
||||
InputError,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
} from '#/features/posts/inputs'
|
||||
|
||||
export const Route = createFileRoute('/status/$tweetId')({
|
||||
export const Route = createFileRoute('/$handle_/status/$tweetId')({
|
||||
component: StatusRoute,
|
||||
})
|
||||
|
||||
function StatusRoute() {
|
||||
const { tweetId: rawTweetId } = Route.useParams()
|
||||
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
|
||||
: '投稿 ID を確認してください。'
|
||||
cause instanceof InputError ? cause.message : 'URL を確認してください。'
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} 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 { routeTree } from '../routeTree.gen'
|
||||
|
||||
const usePostFeed = vi.hoisted(() => vi.fn())
|
||||
const useListChoices = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -16,6 +17,12 @@ vi.mock('../features/posts/use-post-feed', async (importOriginal) => {
|
||||
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 () => {
|
||||
const { createRootRouteWithContext, Outlet } = await import(
|
||||
'@tanstack/react-router'
|
||||
@@ -41,6 +48,7 @@ describe('post feed route wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('scrollTo', vi.fn())
|
||||
usePostFeed.mockReset()
|
||||
useListChoices.mockReset()
|
||||
usePostFeed.mockReturnValue({
|
||||
data: undefined,
|
||||
error: null,
|
||||
@@ -52,12 +60,19 @@ describe('post feed route wiring', () => {
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
} as never)
|
||||
useListChoices.mockReturnValue({
|
||||
data: [{ id: '1234567890', name: 'My List', memberCount: 10 }],
|
||||
error: null,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
} as never)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('keeps an empty user URL disabled', async () => {
|
||||
await renderRoute('/user?target=')
|
||||
await renderRoute('/')
|
||||
|
||||
expect(
|
||||
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 () => {
|
||||
await renderRoute('/user?target=yuta')
|
||||
await renderRoute('/yuta')
|
||||
|
||||
await waitFor(() =>
|
||||
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 () => {
|
||||
await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true')
|
||||
|
||||
@@ -90,7 +161,7 @@ describe('post feed route wiring', () => {
|
||||
})
|
||||
|
||||
it('turns a decimal status path into a deliberate thread request', async () => {
|
||||
await renderRoute('/status/123')
|
||||
await renderRoute('/yuta/status/123')
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '会話' })).toBeVisible()
|
||||
await waitFor(() =>
|
||||
@@ -107,12 +178,21 @@ describe('post feed route wiring', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an invalid status path local and idle', async () => {
|
||||
await renderRoute('/status/not-a-tweet')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>どのリストを読みますか?</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} />
|
||||
}
|
||||
@@ -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('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/user', search: { target: '' } })
|
||||
},
|
||||
component: UserIndexRoute,
|
||||
})
|
||||
|
||||
function UserIndexRoute() {
|
||||
return <UserPage />
|
||||
}
|
||||
|
||||
export function UserPage({ handle = '' }: { handle?: string }) {
|
||||
const navigate = useNavigate()
|
||||
let normalizedHandle: string | undefined
|
||||
let error: string | undefined
|
||||
|
||||
if (handle) {
|
||||
try {
|
||||
normalizedHandle = normalizeUserTarget(handle)
|
||||
} catch (cause) {
|
||||
error =
|
||||
cause instanceof InputError
|
||||
? cause.message
|
||||
: 'ハンドルを確認してください。'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell active="user">
|
||||
<h1>誰の投稿を見ますか?</h1>
|
||||
<p className="intro">履歴やおすすめは表示しません。</p>
|
||||
<UserForm
|
||||
initialTarget={handle}
|
||||
key={handle}
|
||||
onSubmit={(nextHandle) =>
|
||||
navigate({ to: '/$handle', params: { handle: nextHandle } })
|
||||
}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="state" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<PostFeed
|
||||
request={
|
||||
normalizedHandle
|
||||
? { kind: 'user', target: normalizedHandle }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -116,6 +116,73 @@ a {
|
||||
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 srgb, 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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -167,6 +234,7 @@ a {
|
||||
}
|
||||
|
||||
.intent-form button,
|
||||
.list-picker-state button,
|
||||
.state button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
@@ -457,6 +525,7 @@ video.media {
|
||||
}
|
||||
|
||||
.intent-form button:hover,
|
||||
.list-picker-state button:hover,
|
||||
.state button:hover {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
@@ -470,6 +539,11 @@ video.media {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.list-choices a:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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 = {
|
||||
homePage: HomePage
|
||||
listsPage: ListsPage
|
||||
searchPage: SearchPage
|
||||
threadPage: ThreadPage
|
||||
userPage: UserPage
|
||||
}
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
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'
|
||||
@@ -102,6 +102,8 @@ const server = createServer(async (request, response) => {
|
||||
'UserTweets',
|
||||
'TweetDetail',
|
||||
'SearchTimeline',
|
||||
'ListsManagementPageTimeline',
|
||||
'ListLatestTweetsTimeline',
|
||||
].find((name) => url.pathname.endsWith(`/${name}`))
|
||||
|
||||
if (!operation) {
|
||||
@@ -178,6 +180,94 @@ const server = createServer(async (request, response) => {
|
||||
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)) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Locator, test } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
|
||||
let consoleErrors: string[]
|
||||
let pageErrors: string[]
|
||||
@@ -17,68 +17,59 @@ test.afterEach(() => {
|
||||
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 ({
|
||||
homePage,
|
||||
page,
|
||||
userPage,
|
||||
}) => {
|
||||
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 homePage.goTo()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect(homePage.articlesLocator).toHaveCount(0)
|
||||
await expect(homePage.ambientLinksLocator).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
|
||||
await page.getByRole('button', { name: '表示' }).click()
|
||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(userPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-user.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('opens a card detail and infinitely loads its conversation', async ({
|
||||
test('selects an authenticated list and infinitely loads its timeline', async ({
|
||||
listsPage,
|
||||
page,
|
||||
}) => {
|
||||
await openReader(page, '/user?target=yuta')
|
||||
const sourceCard = page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: 'user page 1' })
|
||||
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
||||
await listsPage.goTo()
|
||||
await expect(listsPage.articlesLocator).toHaveCount(0)
|
||||
|
||||
await expect(page).toHaveURL(/\/status\/1001$/)
|
||||
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
||||
await expect(page.getByText('表示中の投稿')).toBeVisible()
|
||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(page).toHaveScreenshot('mist-thread.png', {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
@@ -87,95 +78,90 @@ test('opens a card detail and infinitely loads its conversation', async ({
|
||||
|
||||
test('resolves a reply root and retries only the failed continuation', async ({
|
||||
page,
|
||||
threadPage,
|
||||
}) => {
|
||||
await openReader(page, '/status/2001')
|
||||
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()
|
||||
const alert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(alert).toHaveCount(1)
|
||||
await expect(threadPage.continuationErrorLocator).toHaveCount(1)
|
||||
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||
|
||||
await alert.getByRole('button', { name: '再試行' }).click()
|
||||
await threadPage.retryContinuation()
|
||||
|
||||
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)
|
||||
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
|
||||
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||
await expect(threadPage.mutationButtonsLocator).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)
|
||||
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()
|
||||
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('retains the first page through a pagination error and retries explicitly', async ({
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await submitSearch(page, `retry-${testInfo.project.name}`)
|
||||
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()
|
||||
const laterPageAlert = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
await expect(laterPageAlert).toHaveCount(1)
|
||||
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 laterPageAlert.getByRole('button', { name: '再試行' }).click()
|
||||
await searchPage.retryContinuation()
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(laterPageAlert).toHaveCount(0)
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||
await searchPage.goTo()
|
||||
|
||||
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 searchPage.tabTo(
|
||||
searchPage.page.getByRole('link', { name: 'ユーザー' }),
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('button', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
page.getByRole('link', { name: mutationControlNames }),
|
||||
).toHaveCount(0)
|
||||
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 submitSearch(page, `slow-${testInfo.project.name}`)
|
||||
await searchPage.goTo()
|
||||
await expect(searchPage.articlesLocator).toHaveCount(0)
|
||||
await searchPage.search(`slow-${testInfo.project.name}`)
|
||||
|
||||
const rail = page.locator('.loading-rail')
|
||||
const rail = searchPage.loadingRailLocator
|
||||
await expect(rail).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -186,5 +172,5 @@ test('removes loading rail animation for reduced motion', async ({
|
||||
.toBe('none')
|
||||
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |