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
+2
View File
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router'
import { ProfileSwitcher } from '#/features/profiles/profile-switcher'
export function AppShell({
active,
@@ -11,6 +12,7 @@ export function AppShell({
<main className="app">
<header className="app-header">
<p className="brand">Twitter Lite</p>
<ProfileSwitcher />
<nav aria-label="閲覧方法" className="tabs">
<Link
aria-current={active === 'search' ? 'page' : undefined}
+8 -4
View File
@@ -1,13 +1,17 @@
import { TwitterClient } from '@yuta/bird'
import type { BirdReader } from './post-service'
let client: TwitterClient | undefined
const clients = new Map<string | undefined, TwitterClient>()
export function getBirdReader(): BirdReader {
client ??= new TwitterClient({
export function getBirdReader(profileName?: string): BirdReader {
const existing = clients.get(profileName)
if (existing) return existing
const client = new TwitterClient({
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
profileName: process.env.BIRD_PROFILE_NAME,
profileName,
timeoutMs: 20_000,
})
clients.set(profileName, client)
return client
}
+5 -2
View File
@@ -28,8 +28,11 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
}
async function reader() {
const { getBirdReader } = await import('./bird-client.server')
return getBirdReader()
const [{ getBirdReader }, { getSelectedProfileName }] = await Promise.all([
import('./bird-client.server'),
import('../profiles/profile-service.server'),
])
return getBirdReader(getSelectedProfileName())
}
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 }
})
vi.mock('../features/profiles/profile-switcher', () => ({
ProfileSwitcher: () => null,
}))
vi.mock('./__root', async () => {
const { createRootRouteWithContext, Outlet } = await import(
'@tanstack/react-router'
+75 -1
View File
@@ -639,8 +639,9 @@ video.media {
.app-header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8rem, 9.5rem);
align-items: start;
gap: var(--space-lg);
gap: var(--space-lg) var(--space-md);
padding: var(--space-lg) var(--space-md) 0;
border-bottom: var(--rule-hairline) solid var(--border);
background: var(--paper);
@@ -665,6 +666,7 @@ video.media {
.tabs {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
}
@@ -689,6 +691,72 @@ video.media {
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 {
width: 100%;
min-width: 0;
@@ -1055,6 +1123,10 @@ button[data-state="success"] {
color: var(--accent-hover);
}
.profile-switcher select:not(:disabled):hover {
background: var(--color-paper-2);
}
.field input:hover {
background: var(--surface);
}
@@ -1146,6 +1218,7 @@ button[data-state="success"] {
.app-header {
position: sticky;
top: var(--space-lg);
grid-template-columns: minmax(0, 1fr);
grid-row: 1;
grid-column: 1;
align-self: start;
@@ -1157,6 +1230,7 @@ button[data-state="success"] {
}
.tabs {
grid-column: 1;
grid-template-columns: minmax(0, 1fr);
}