diff --git a/src/components/SessionMenu.tsx b/src/components/SessionMenu.tsx index 4d17820..7907727 100644 --- a/src/components/SessionMenu.tsx +++ b/src/components/SessionMenu.tsx @@ -5,8 +5,8 @@ import Link from 'next/link'; import { ChevronDown, LogIn, LogOut, Settings, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from './AtprotoSessionProvider'; import ScopeSelector from './oauth/ScopeSelector'; -import { rememberCurrentPathForReturn } from '@/lib/oauth/returnTo'; -import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; +import { useSignInFlow } from './oauth/useSignInFlow'; +import { useSessionProfile } from './useSessionProfile'; import { encodeRepo } from '@/utils/atproto/urls'; type Variant = 'compact' | 'inline' | 'pill'; @@ -21,25 +21,17 @@ type Variant = 'compact' | 'inline' | 'pill'; * - 'pill' — standalone (used in account-page header context) */ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant }) { - const { session, did, signIn, signOut, loading } = useAtprotoSession(); + const { session, did, signOut, loading } = useAtprotoSession(); const [open, setOpen] = useState(false); const [signInInput, setSignInInput] = useState(''); - const [step, setStep] = useState<'handle' | 'scopes'>('handle'); - const [pendingAccount, setPendingAccount] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - // Keyed by DID so switching accounts derives back to null on its own — - // no reset-setState in the effect, and no stale avatar flash. - const [profileEntry, setProfileEntry] = useState<{ did: string; profile: AppViewProfile | null } | null>(null); - const profile = did && profileEntry && profileEntry.did === did ? profileEntry.profile : null; + const { step, pendingAccount, busy, error, proceedToScopes, backToHandle, submitScopes, reset } = + useSignInFlow(); + const profile = useSessionProfile(did); const rootRef = useRef(null); function closePopover() { setOpen(false); - setStep('handle'); - setPendingAccount(''); - setBusy(false); - setError(null); + reset(); } // Close on outside click. @@ -54,18 +46,6 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant return () => document.removeEventListener('mousedown', onClick); }, [open]); - // Lazy-load profile so the menu can show avatar + display name. - useEffect(() => { - if (!did) return undefined; - let cancelled = false; - getProfile(did).then((p) => { - if (!cancelled) setProfileEntry({ did, profile: p }); - }); - return () => { - cancelled = true; - }; - }, [did]); - if (loading) return null; // ─── Signed out ───────────────────────────────────────────────────────── @@ -88,13 +68,7 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant onChange={setSignInInput} busy={busy} error={error} - onSubmit={() => { - const v = signInInput.trim(); - if (!v) return; - setError(null); - setPendingAccount(v); - setStep('scopes'); - }} + onSubmit={() => proceedToScopes(signInInput)} /> )} {open && step === 'scopes' && ( @@ -104,21 +78,8 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant account={pendingAccount} busy={busy} error={error} - onBack={() => { - setStep('handle'); - setError(null); - }} - onContinue={async (scopeString) => { - setBusy(true); - setError(null); - try { - rememberCurrentPathForReturn(); - await signIn(pendingAccount, scopeString); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } - }} + onBack={backToHandle} + onContinue={submitScopes} /> diff --git a/src/components/SessionPanel.tsx b/src/components/SessionPanel.tsx index 2cf1320..7f6efad 100644 --- a/src/components/SessionPanel.tsx +++ b/src/components/SessionPanel.tsx @@ -5,8 +5,8 @@ import Link from 'next/link'; import { ArrowLeft, LogIn, LogOut, Settings, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from './AtprotoSessionProvider'; import ScopeSelector from './oauth/ScopeSelector'; -import { rememberCurrentPathForReturn } from '@/lib/oauth/returnTo'; -import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; +import { useSignInFlow } from './oauth/useSignInFlow'; +import { useSessionProfile } from './useSessionProfile'; import { encodeRepo } from '@/utils/atproto/urls'; type Props = { @@ -21,8 +21,6 @@ type Props = { onSignInActiveChange?: (active: boolean) => void; }; -type SignInStep = 'idle' | 'handle' | 'scopes'; - /** * Inline session UI for the compact header's expanding menu panel. Renders * as a stack of compact-nav-link rows, NOT a dropdown (the parent panel @@ -33,16 +31,20 @@ type SignInStep = 'idle' | 'handle' | 'scopes'; * sign-out. */ export default function SessionPanel({ onNavigate, onSignInActiveChange }: Props) { - const { session, did, signIn, signOut, loading } = useAtprotoSession(); - // Keyed by DID so switching accounts derives back to null on its own — - // no reset-setState in the effect, and no stale avatar flash. - const [profileEntry, setProfileEntry] = useState<{ did: string; profile: AppViewProfile | null } | null>(null); - const profile = did && profileEntry && profileEntry.did === did ? profileEntry.profile : null; - const [signInStep, setSignInStep] = useState('idle'); + const { session, did, signOut, loading } = useAtprotoSession(); + const profile = useSessionProfile(did); + const { + step: signInStep, + setStep: setSignInStep, + pendingAccount, + busy, + error, + setError, + proceedToScopes, + backToHandle, + submitScopes, + } = useSignInFlow('idle'); const [signInValue, setSignInValue] = useState(''); - const [pendingAccount, setPendingAccount] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); // Mirror the flow's active/idle status up to the parent so it can // hide adjacent nav rows while the form is taking over the panel. @@ -52,17 +54,6 @@ export default function SessionPanel({ onNavigate, onSignInActiveChange }: Props onSignInActiveChange?.(signInStep !== 'idle'); }, [signInStep, onSignInActiveChange]); - useEffect(() => { - if (!did) return undefined; - let cancelled = false; - getProfile(did).then((p) => { - if (!cancelled) setProfileEntry({ did, profile: p }); - }); - return () => { - cancelled = true; - }; - }, [did]); - if (loading) return null; // ─── Signed out ───────────────────────────────────────────────────────── @@ -72,11 +63,7 @@ export default function SessionPanel({ onNavigate, onSignInActiveChange }: Props
{ e.preventDefault(); - const v = signInValue.trim(); - if (!v) return; - setError(null); - setPendingAccount(v); - setSignInStep('scopes'); + proceedToScopes(signInValue); }} style={{ display: 'flex', @@ -141,21 +128,8 @@ export default function SessionPanel({ onNavigate, onSignInActiveChange }: Props account={pendingAccount} busy={busy} error={error} - onBack={() => { - setSignInStep('handle'); - setError(null); - }} - onContinue={async (scopeString) => { - setBusy(true); - setError(null); - try { - rememberCurrentPathForReturn(); - await signIn(pendingAccount, scopeString); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } - }} + onBack={backToHandle} + onContinue={submitScopes} /> ); diff --git a/src/components/explore/SignInPanel.tsx b/src/components/explore/SignInPanel.tsx index 729f133..191423f 100644 --- a/src/components/explore/SignInPanel.tsx +++ b/src/components/explore/SignInPanel.tsx @@ -2,9 +2,8 @@ import { useState } from 'react'; import { LogIn } from 'lucide-react'; -import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; import ScopeSelector from '@/components/oauth/ScopeSelector'; -import { rememberCurrentPathForReturn } from '@/lib/oauth/returnTo'; +import { useSignInFlow } from '@/components/oauth/useSignInFlow'; /** * Compact sign-in form used inside the record view's action row. Accepts a @@ -12,12 +11,9 @@ import { rememberCurrentPathForReturn } from '@/lib/oauth/returnTo'; * flow that lets the user pick which permissions to grant. */ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) { - const { signIn } = useAtprotoSession(); const [value, setValue] = useState(defaultInput || ''); - const [step, setStep] = useState<'handle' | 'scopes'>('handle'); - const [pendingAccount, setPendingAccount] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const { step, pendingAccount, busy, error, proceedToScopes, backToHandle, submitScopes } = + useSignInFlow(); if (step === 'scopes') { return ( @@ -33,21 +29,8 @@ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) account={pendingAccount} busy={busy} error={error} - onBack={() => { - setStep('handle'); - setError(null); - }} - onContinue={async (scopeString) => { - setBusy(true); - setError(null); - try { - rememberCurrentPathForReturn(); - await signIn(pendingAccount, scopeString); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } - }} + onBack={backToHandle} + onContinue={submitScopes} /> ); @@ -57,11 +40,7 @@ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) { e.preventDefault(); - const v = value.trim(); - if (!v) return; - setError(null); - setPendingAccount(v); - setStep('scopes'); + proceedToScopes(value); }} style={{ display: 'flex', diff --git a/src/components/oauth/useSignInFlow.ts b/src/components/oauth/useSignInFlow.ts new file mode 100644 index 0000000..4f19e02 --- /dev/null +++ b/src/components/oauth/useSignInFlow.ts @@ -0,0 +1,83 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; +import { rememberCurrentPathForReturn } from '@/lib/oauth/returnTo'; + +/** + * The two-step OAuth sign-in flow: enter a handle/DID, pick which scopes to + * grant, then redirect out to the provider. Three surfaces render it — + * (nav dropdown), (compact header stack), and + * (record-view action row) — and each previously carried its own + * byte-identical copy of this state machine and the scope-submit handler. They + * share it here so the auth path lives in one place. + * + * The `idle` step exists for surfaces (SessionPanel) that show a collapsed + * entry point before the flow begins; surfaces that are always "open" start at + * `handle`. The handle input value itself stays with the caller, since each + * surface seeds and styles it differently. + */ +export type SignInStep = 'idle' | 'handle' | 'scopes'; + +export function useSignInFlow(initialStep: SignInStep = 'handle') { + const { signIn } = useAtprotoSession(); + const [step, setStep] = useState(initialStep); + const [pendingAccount, setPendingAccount] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // handle → scopes: stash the trimmed account and advance. A blank handle is + // a no-op, matching each inline form's `if (!v) return` guard. + const proceedToScopes = useCallback((rawValue: string) => { + const v = rawValue.trim(); + if (!v) return; + setError(null); + setPendingAccount(v); + setStep('scopes'); + }, []); + + // scopes → handle (the ScopeSelector "back" affordance). + const backToHandle = useCallback(() => { + setStep('handle'); + setError(null); + }, []); + + // Final step: remember where to return, then redirect out to the OAuth + // provider. On success the browser navigates away; on failure we clear busy + // and surface the message so the user can retry. + const submitScopes = useCallback( + async (scopeString: string) => { + setBusy(true); + setError(null); + try { + rememberCurrentPathForReturn(); + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }, + [pendingAccount, signIn], + ); + + // Return the flow to its resting `handle` step (used when a popover closes). + const reset = useCallback(() => { + setStep('handle'); + setPendingAccount(''); + setBusy(false); + setError(null); + }, []); + + return { + step, + setStep, + pendingAccount, + busy, + error, + setError, + proceedToScopes, + backToHandle, + submitScopes, + reset, + }; +} diff --git a/src/components/useSessionProfile.ts b/src/components/useSessionProfile.ts new file mode 100644 index 0000000..4b77283 --- /dev/null +++ b/src/components/useSessionProfile.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; + +/** + * Lazily load the AppView profile for the signed-in DID so the session UI can + * show an avatar + display name. Shared by and . + * + * The result is keyed by DID so switching accounts derives back to null on its + * own — no reset-setState in the effect, and no stale avatar flash from the + * previous account while the new profile loads. Returns null until the profile + * for the current DID has resolved. + */ +export function useSessionProfile(did: string | null): AppViewProfile | null { + const [profileEntry, setProfileEntry] = useState<{ + did: string; + profile: AppViewProfile | null; + } | null>(null); + const profile = did && profileEntry && profileEntry.did === did ? profileEntry.profile : null; + + useEffect(() => { + if (!did) return undefined; + let cancelled = false; + getProfile(did).then((p) => { + if (!cancelled) setProfileEntry({ did, profile: p }); + }); + return () => { + cancelled = true; + }; + }, [did]); + + return profile; +}