Add relay profile switching

This commit is contained in:
2026-07-31 21:53:43 +09:00
parent 67ee53c305
commit 079aaa1664
18 changed files with 413 additions and 12 deletions
+6 -3
View File
@@ -11,6 +11,7 @@ deliberate post detail link, or manually open a valid
- Popular (`Top`) and chronological (`Latest`) search - Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search - Optional `filter:follows` search
- Authenticated account list selection and list timelines from a deliberate URL or ID - Authenticated account list selection and list timelines from a deliberate URL or ID
- Relay profile switching from the profiles exposed by the relay
- Infinite cursor pagination with explicit retry - Infinite cursor pagination with explicit retry
- Deliberate post detail pages with the visible conversation - Deliberate post detail pages with the visible conversation
- Infinite conversation loading with explicit continuation retry - Infinite conversation loading with explicit continuation retry
@@ -45,9 +46,11 @@ cp .env.example .env.local
The Nix development shell installs Hallmark's agent skill for the supported The Nix development shell installs Hallmark's agent skill for the supported
local agent targets. local agent targets.
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development.
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same `BIRD_PROFILE_NAME` optionally selects the initial relay profile; the header
values in the process environment before `start` or `test:live`: selector can switch between the profiles returned by the relay's `/profiles`
endpoint. Export the same values in the process environment before `start` or
`test:live`:
```bash ```bash
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
+36
View File
@@ -43,6 +43,39 @@ test('requires intent and infinitely loads a user timeline', async ({
}) })
}) })
test('switches the relay profile and reloads visible content', async ({
homePage,
page,
}) => {
await homePage.goTo()
const profile = page.getByLabel('プロファイル')
await expect(profile).toHaveValue('e2e')
await homePage.displayUser('@yuta')
await expect(page.getByText('user page 1')).toBeVisible()
await profile.selectOption('alternate')
await expect(page.getByRole('status')).toHaveText('切り替えました。')
await expect(page.getByText('alternate profile page 1')).toBeVisible()
})
test('keeps the profile selector within narrow viewports', async ({
homePage,
page,
}) => {
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
await homePage.goTo()
await expect(page.getByLabel('プロファイル')).toBeVisible()
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
),
).toBe(false)
}
})
test('selects an authenticated list and infinitely loads its timeline', async ({ test('selects an authenticated list and infinitely loads its timeline', async ({
listsPage, listsPage,
page, page,
@@ -152,6 +185,9 @@ test('keeps keyboard focus intentional and discovery or mutation controls absent
}) => { }) => {
await searchPage.goTo() await searchPage.goTo()
await searchPage.tabTo(
searchPage.page.getByRole('combobox', { name: 'プロファイル' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' })) await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
await searchPage.tabTo( await searchPage.tabTo(
searchPage.page.getByRole('link', { name: 'ユーザー' }), searchPage.page.getByRole('link', { name: 'ユーザー' }),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+10 -2
View File
@@ -99,6 +99,11 @@ const timeline = (entries) => ({ instructions: [{ entries }] })
const server = createServer(async (request, response) => { const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`) const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
if (request.method === 'GET' && url.pathname === '/profiles') {
send(response, { profiles: ['e2e', 'alternate'] })
return
}
const operation = [ const operation = [
'UserByScreenName', 'UserByScreenName',
'UserTweets', 'UserTweets',
@@ -120,7 +125,8 @@ const server = createServer(async (request, response) => {
}) })
return return
} }
if (request.headers['x-profile-name'] !== 'e2e') { const profileName = request.headers['x-profile-name']
if (profileName !== 'e2e' && profileName !== 'alternate') {
fail(response, 403, 'invalid profile') fail(response, 403, 'invalid profile')
return return
} }
@@ -166,10 +172,12 @@ const server = createServer(async (request, response) => {
return return
} }
const secondPage = variables.cursor === 'user-next' const secondPage = variables.cursor === 'user-next'
const firstPageText =
profileName === 'alternate' ? 'alternate profile page 1' : 'user page 1'
const entries = secondPage const entries = secondPage
? [tweet('1010', 'user page 2')] ? [tweet('1010', 'user page 2')]
: [ : [
tweet('1001', 'user page 1', 'yuta', { tweet('1001', firstPageText, 'yuta', {
conversationId: '1000', conversationId: '1000',
}), }),
cursor('user-next'), cursor('user-next'),
+2
View File
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { ProfileSwitcher } from '#/features/profiles/profile-switcher'
export function AppShell({ export function AppShell({
active, active,
@@ -11,6 +12,7 @@ export function AppShell({
<main className="app"> <main className="app">
<header className="app-header"> <header className="app-header">
<p className="brand">Twitter Lite</p> <p className="brand">Twitter Lite</p>
<ProfileSwitcher />
<nav aria-label="閲覧方法" className="tabs"> <nav aria-label="閲覧方法" className="tabs">
<Link <Link
aria-current={active === 'search' ? 'page' : undefined} aria-current={active === 'search' ? 'page' : undefined}
+8 -4
View File
@@ -1,13 +1,17 @@
import { TwitterClient } from '@yuta/bird' import { TwitterClient } from '@yuta/bird'
import type { BirdReader } from './post-service' import type { BirdReader } from './post-service'
let client: TwitterClient | undefined const clients = new Map<string | undefined, TwitterClient>()
export function getBirdReader(): BirdReader { export function getBirdReader(profileName?: string): BirdReader {
client ??= new TwitterClient({ const existing = clients.get(profileName)
if (existing) return existing
const client = new TwitterClient({
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
profileName: process.env.BIRD_PROFILE_NAME, profileName,
timeoutMs: 20_000, timeoutMs: 20_000,
}) })
clients.set(profileName, client)
return client return client
} }
+5 -2
View File
@@ -28,8 +28,11 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
} }
async function reader() { async function reader() {
const { getBirdReader } = await import('./bird-client.server') const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([
return getBirdReader() import('./bird-client.server'),
import('../profiles/profile-service.server'),
])
return getBirdReader(getSelectedProfileName())
} }
export const loadUserPosts = createServerFn({ method: 'GET' }) export const loadUserPosts = createServerFn({ method: 'GET' })
@@ -0,0 +1,69 @@
import {
deleteCookie,
getCookie,
setCookie,
} from '@tanstack/react-start/server'
import { z } from 'zod'
import type { ProfileSettings } from './types'
const PROFILE_COOKIE = 'twitter-lite-profile'
const profilesSchema = z.object({
profiles: z.array(z.string().min(1)),
})
function relayBaseUrl() {
const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
if (!relayBaseUrl) {
throw new Error('TWITTER_RELAY_BASE_URL is required')
}
return relayBaseUrl
}
export async function fetchProfileNames(
baseUrl = relayBaseUrl(),
request: typeof fetch = fetch,
): Promise<string[]> {
const response = await request(new URL('/profiles', baseUrl), {
signal: AbortSignal.timeout(20_000),
})
if (!response.ok) {
throw new Error(`Profiles request failed with HTTP ${response.status}`)
}
return profilesSchema.parse(await response.json()).profiles
}
export function getSelectedProfileName(): string | undefined {
return getCookie(PROFILE_COOKIE) || process.env.BIRD_PROFILE_NAME
}
export async function loadProfileSettings(): Promise<ProfileSettings> {
const profiles = await fetchProfileNames()
const selectedProfile = getSelectedProfileName()
return {
profiles,
...(selectedProfile && profiles.includes(selectedProfile)
? { selectedProfile }
: {}),
}
}
export async function saveProfileSelection(
profileName: string | null,
): Promise<void> {
if (profileName === null) {
deleteCookie(PROFILE_COOKIE, { path: '/' })
return
}
const profiles = await fetchProfileNames()
if (!profiles.includes(profileName)) {
throw new Error(`Unknown relay profile: ${profileName}`)
}
setCookie(PROFILE_COOKIE, profileName, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365,
path: '/',
sameSite: 'lax',
})
}
@@ -0,0 +1,36 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import { fetchProfileNames } from './profile-service.server'
describe('fetchProfileNames', () => {
it('loads profile names from the relay endpoint', async () => {
const request = vi.fn(async () =>
Response.json({ profiles: ['main', 'work'] }),
)
await expect(
fetchProfileNames('https://relay.invalid/api', request),
).resolves.toEqual(['main', 'work'])
expect(request).toHaveBeenCalledWith(
new URL('https://relay.invalid/profiles'),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})
it('rejects malformed relay responses', async () => {
const request = vi.fn(async () => Response.json({ profiles: [42] }))
await expect(
fetchProfileNames('https://relay.invalid', request),
).rejects.toThrow()
})
it('rejects unsuccessful relay responses', async () => {
const request = vi.fn(async () => new Response(null, { status: 503 }))
await expect(
fetchProfileNames('https://relay.invalid', request),
).rejects.toThrow('Profiles request failed with HTTP 503')
})
})
@@ -0,0 +1,60 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ProfileSwitcher } from './profile-switcher'
const serverFns = vi.hoisted(() => ({
loadProfiles: vi.fn(),
selectProfile: vi.fn(),
}))
vi.mock('./server-functions', () => serverFns)
vi.mock('@tanstack/react-start', () => ({
useServerFn: (serverFn: unknown) => serverFn,
}))
function renderSwitcher() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={queryClient}>
<ProfileSwitcher />
</QueryClientProvider>,
)
}
describe('ProfileSwitcher', () => {
it('shows relay profiles and switches the active profile', async () => {
serverFns.loadProfiles.mockResolvedValue({
profiles: ['main', 'work'],
selectedProfile: 'main',
})
serverFns.selectProfile.mockResolvedValue({ profileName: 'work' })
renderSwitcher()
const select = await screen.findByLabelText('プロファイル')
await waitFor(() => expect(select).toHaveValue('main'))
fireEvent.change(select, { target: { value: 'work' } })
await waitFor(() =>
expect(serverFns.selectProfile).toHaveBeenCalledWith({
data: { profileName: 'work' },
}),
)
expect(await screen.findByRole('status')).toHaveTextContent(
'切り替えました。',
)
})
it('reports a relay profiles failure without offering stale choices', async () => {
serverFns.loadProfiles.mockRejectedValue(new Error('relay unavailable'))
renderSwitcher()
const select = await screen.findByLabelText('プロファイル')
await waitFor(() => expect(select).toBeDisabled())
expect(select).toHaveTextContent('取得失敗')
expect(select).toHaveAttribute('aria-invalid', 'true')
})
})
@@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { loadProfiles, selectProfile } from './server-functions'
export function ProfileSwitcher() {
const queryClient = useQueryClient()
const load = useServerFn(loadProfiles)
const select = useServerFn(selectProfile)
const profiles = useQuery({
queryKey: ['profiles'],
queryFn: () => load(),
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const selection = useMutation({
mutationFn: (profileName: string | null) =>
select({ data: { profileName } }),
onSuccess: () => queryClient.invalidateQueries(),
})
const state = profiles.isError
? 'error'
: selection.isError
? 'error'
: selection.isSuccess
? 'success'
: profiles.isPending || selection.isPending
? 'loading'
: 'default'
return (
<div className="profile-switcher" data-state={state}>
<label htmlFor="relay-profile">プロファイル</label>
<select
aria-invalid={state === 'error' ? 'true' : undefined}
disabled={profiles.isPending || profiles.isError || selection.isPending}
id="relay-profile"
onChange={(event) =>
selection.mutate(
event.target.value.length ? event.target.value : null,
)
}
value={profiles.data?.selectedProfile ?? ''}
>
<option value="">
{profiles.isPending
? '読み込み中'
: profiles.isError
? '取得失敗'
: '自動'}
</option>
{profiles.data?.profiles.map((profile) => (
<option key={profile} value={profile}>
{profile}
</option>
))}
</select>
<span
aria-live="polite"
className="profile-switcher-status"
role={selection.isError ? 'alert' : 'status'}
>
{selection.isError
? '切り替えに失敗しました。'
: selection.isSuccess
? '切り替えました。'
: ''}
</span>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import type { ProfileSelection } from './types'
const profileSelectionSchema = z.object({
profileName: z.string().min(1).nullable(),
})
export const loadProfiles = createServerFn({ method: 'GET' }).handler(
async () => {
const { loadProfileSettings } = await import('./profile-service.server')
return loadProfileSettings()
},
)
export const selectProfile = createServerFn({ method: 'POST' })
.validator(profileSelectionSchema)
.handler(async ({ data }): Promise<ProfileSelection> => {
const { saveProfileSelection } = await import('./profile-service.server')
await saveProfileSelection(data.profileName)
return data
})
+8
View File
@@ -0,0 +1,8 @@
export type ProfileSettings = {
profiles: string[]
selectedProfile?: string
}
export type ProfileSelection = {
profileName: string | null
}
+4
View File
@@ -23,6 +23,10 @@ vi.mock('../features/posts/use-list-choices', async (importOriginal) => {
return { ...original, useListChoices } return { ...original, useListChoices }
}) })
vi.mock('../features/profiles/profile-switcher', () => ({
ProfileSwitcher: () => null,
}))
vi.mock('./__root', async () => { vi.mock('./__root', async () => {
const { createRootRouteWithContext, Outlet } = await import( const { createRootRouteWithContext, Outlet } = await import(
'@tanstack/react-router' '@tanstack/react-router'
+75 -1
View File
@@ -639,8 +639,9 @@ video.media {
.app-header { .app-header {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8rem, 9.5rem);
align-items: start; align-items: start;
gap: var(--space-lg); gap: var(--space-lg) var(--space-md);
padding: var(--space-lg) var(--space-md) 0; padding: var(--space-lg) var(--space-md) 0;
border-bottom: var(--rule-hairline) solid var(--border); border-bottom: var(--rule-hairline) solid var(--border);
background: var(--paper); background: var(--paper);
@@ -665,6 +666,7 @@ video.media {
.tabs { .tabs {
display: grid; display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0; gap: 0;
} }
@@ -689,6 +691,72 @@ video.media {
font-weight: 700; font-weight: 700;
} }
/* Hallmark · component: select · genre: editorial · theme: Garden
* states: default · hover · focus · active · disabled · loading · error · success
* contrast: pass
*/
.profile-switcher {
position: relative;
display: grid;
width: 100%;
min-width: 0;
gap: var(--space-2xs);
}
.profile-switcher label {
color: var(--secondary);
font-size: var(--text-xs);
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.profile-switcher select {
width: 100%;
min-height: 2.75rem;
padding: var(--space-xs) 1.8rem var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
outline: var(--rule-active) solid transparent;
outline-offset: var(--space-3xs);
background: var(--paper);
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: var(--text-sm);
text-overflow: ellipsis;
}
.profile-switcher select:active {
background: var(--color-paper-3);
}
.profile-switcher select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.profile-switcher[data-state="error"] select {
border-color: var(--error);
}
.profile-switcher[data-state="success"] select {
border-color: var(--color-success);
}
.profile-switcher-status {
position: absolute;
inset-block-start: 100%;
inset-inline-end: 0;
color: var(--error);
font-size: var(--text-xs);
white-space: nowrap;
}
.profile-switcher[data-state="success"] .profile-switcher-status {
color: var(--color-success);
}
.reader { .reader {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -1055,6 +1123,10 @@ button[data-state="success"] {
color: var(--accent-hover); color: var(--accent-hover);
} }
.profile-switcher select:not(:disabled):hover {
background: var(--color-paper-2);
}
.field input:hover { .field input:hover {
background: var(--surface); background: var(--surface);
} }
@@ -1146,6 +1218,7 @@ button[data-state="success"] {
.app-header { .app-header {
position: sticky; position: sticky;
top: var(--space-lg); top: var(--space-lg);
grid-template-columns: minmax(0, 1fr);
grid-row: 1; grid-row: 1;
grid-column: 1; grid-column: 1;
align-self: start; align-self: start;
@@ -1157,6 +1230,7 @@ button[data-state="success"] {
} }
.tabs { .tabs {
grid-column: 1;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }