diff --git a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx index 25cd506a55366..05e2492647e31 100644 --- a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx @@ -11,10 +11,15 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useRef, useState } from "react"; -import { Linking, Pressable, ScrollView, TextInput, View } from "react-native"; +import { Linking, Pressable, ScrollView, TextInput, useWindowDimensions, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { MenuAction } from "@react-native-menu/menu"; + +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { AppText as Text } from "../../components/ui/AppText"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { Menu } from "../../components/ui/Menu"; import { ScreenScrollView } from "../../components/ui/ScreenScrollView"; import { environmentPresentations } from "../../state/presentation"; import { useEnvironmentQuery } from "../../state/query"; @@ -60,22 +65,17 @@ export function SettingsProviderAccountsRouteScreen() { ) : ( <> {(providersByEnvironment.get(environment.environmentId) ?? []) - .filter( - (provider) => - provider.setup?.canAuthenticate || - (provider.driver === "acpRegistry" && provider.installed), - ) - .map((provider) => ( + .filter(canManageProvider) + .map((provider, index) => ( 0} /> ))} {!(providersByEnvironment.get(environment.environmentId) ?? []).some( - (provider) => - provider.setup?.canAuthenticate || - (provider.driver === "acpRegistry" && provider.installed), + canManageProvider, ) ? ( Configure a provider with in-app sign-in in web or desktop Settings. @@ -92,12 +92,20 @@ export function SettingsProviderAccountsRouteScreen() { ); } +function canManageProvider(provider: ServerProvider) { + return Boolean( + provider.setup?.canAuthenticate || (provider.driver === "acpRegistry" && provider.installed), + ); +} + function ProviderAccount({ environment, provider, + divider, }: { readonly environment: SettingsTarget; readonly provider: ServerProvider; + readonly divider: boolean; }) { const environmentId = environment.environmentId; const instanceId = provider.instanceId; @@ -109,8 +117,9 @@ function ProviderAccount({ const complete = useAtomCommand(serverEnvironment.completeProviderAuth, options); const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, options); const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, options); + // Matches ListRowLayout: large text stacks the action below instead of squeezing the status. + const stacked = useWindowDimensions().fontScale >= 1.5; const [pending, setPending] = useState(false); - const [choosingMethod, setChoosingMethod] = useState(false); const pendingRef = useRef(false); const [error, setError] = useState(null); const [draft, setDraft] = useState({ id: "", values: {} as Record }); @@ -180,120 +189,238 @@ function ProviderAccount({ /> ); } - function chooseMethod() { - const methods = state?.methods ?? []; - if (methods.length <= 1) { - void run(() => start(target)); - return; - } - setChoosingMethod(true); + const name = provider.displayName ?? provider.driver; + const email = signedIn && !active && provider.enabled ? provider.auth.email?.trim() : undefined; + const methods = state?.methods ?? []; + // The status line says why a provider that is missing or turned off can't sign in. + const canSignIn = + !active && + !needsExternalSetup && + provider.setup?.canAuthenticate !== false && + provider.enabled && + provider.installed; + const signInDisabled = disabled || state === null; + const canSignOut = + !active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate); + const showCallback = + Boolean(url) && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction); + const status = + active || state?.phase === "failed" || state?.phase === "cancelled" + ? state.message + : !provider.enabled + ? "Turned off" + : signedIn + ? "Signed in" + : isDiscovering + ? "Discovering sign-in methods…" + : needsExternalSetup + ? "No in-app sign-in advertised. Follow the provider's docs to finish setup." + : !provider.installed + ? "Not installed" + : "Not signed in"; + // A provider with several sign-in methods picks one from a menu on the same control. + const methodActions: MenuAction[] = methods.map((method) => ({ + id: `method:${method.id}`, + title: method.name, + })); + function startSignIn(methodId?: string) { + void run(() => start(methodId ? { environmentId, input: { instanceId, methodId } } : target)); + } + function signOut() { + Dialog.alert( + "Sign out?", + `Running threads sharing this sign-in on ${environment.label} will stop. Thread history is kept.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign out", + style: "destructive", + onPress: () => { + void run(() => logout(target)); + }, + }, + ], + ); } + function onMenuAction(id: string) { + if (id === "signOut") signOut(); + // iOS keeps a disabled submenu's children selectable, so sign-in guards here too. + else if (signInDisabled) return; + else if (id.startsWith("method:")) startSignIn(id.slice("method:".length)); + else if (id === "change") startSignIn(); + } + const accountActions: MenuAction[] = [ + ...(canSignIn + ? [ + { + id: "change", + title: "Change account", + image: "person.crop.circle", + attributes: { disabled: signInDisabled }, + ...(methodActions.length > 1 + ? { + subactions: methodActions.map((action) => ({ + ...action, + attributes: { disabled: signInDisabled }, + })), + } + : {}), + }, + ] + : []), + ...(canSignOut + ? [ + { + id: "signOut", + title: "Sign out", + image: "rectangle.portrait.and.arrow.right", + attributes: { destructive: true, disabled: disabled || state === null }, + }, + ] + : []), + ]; + const trailing = pending ? ( + + + + ) : needsExternalSetup && provider.setup?.documentationUrl ? ( + { + void Linking.openURL(provider.setup!.documentationUrl!).catch(() => + setError("Could not open the provider docs."), + ); + }} + /> + ) : active && state?.flowId ? ( + { + void run(() => cancel({ environmentId, input: { instanceId, flowId: state.flowId! } })); + }} + /> + ) : signedIn && !active && accountActions.length > 0 ? ( + onMenuAction(nativeEvent.event)} + > + + + ) : canSignIn && !signedIn && methodActions.length > 1 && !signInDisabled ? ( + onMenuAction(nativeEvent.event)} + > + + + ) : canSignIn && !signedIn ? ( + startSignIn()} + /> + ) : null; return ( - - - - {provider.displayName ?? provider.driver} - - - {active || state?.phase === "failed" || state?.phase === "cancelled" - ? state.message - : signedIn - ? "Signed in." - : isDiscovering - ? "Discovering sign-in methods…" - : needsExternalSetup - ? "No in-app sign-in advertised. Follow the provider's docs to finish setup." - : "Connect this provider."} - - {signedIn && !active && provider.auth.email?.trim() ? ( - - ) : null} - {interaction?.type === "deviceCode" ? ( - - Enter code {interaction.userCode} on the sign-in page. - - ) : null} - {interaction?.type === "terminal" ? ( - <> - - - {interaction.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")} - - - {field("input", "Terminal response", true)} - { - void send({ type: "terminal", data: `${values.input ?? ""}\r` }).then((sent) => { - if (sent) setDraft({ id: "", values: {} }); - }); - }} - /> - - ) : null} - {interaction?.type === "credentials" ? ( - <> - {interaction.fields.map((entry) => ( - {field(entry.name, entry.label, entry.secret)} - ))} - { - void send({ type: "credentials", values }).then((sent) => { - if (sent) setDraft({ id: "", values: {} }); - }); - }} - /> - - ) : null} - {url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction) ? ( - <> - {field("callback", "Final localhost URL", false)} - { - if (!state?.flowId) return; - void run(() => - complete({ - environmentId, - input: { instanceId, flowId: state.flowId!, callbackUrl: values.callback! }, - }), - ).then((sent) => { - if (sent) setDraft({ id: "", values: {} }); - }); - }} - /> - - ) : null} - {error || auth.error ? ( - - {error ?? auth.error} - - ) : null} + + + + {trailing} - {choosingMethod && !active ? ( - - {state?.methods?.map((method) => ( - { - setChoosingMethod(false); - void run(() => - start({ environmentId, input: { instanceId, methodId: method.id } }), - ); - }} - /> - ))} - setChoosingMethod(false)} /> + {interaction?.type === "deviceCode" || + interaction?.type === "terminal" || + interaction?.type === "credentials" || + showCallback || + error || + auth.error ? ( + + {interaction?.type === "deviceCode" ? ( + + Enter code {interaction.userCode} on the sign-in page. + + ) : null} + {interaction?.type === "terminal" ? ( + <> + + + {interaction.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")} + + + {field("input", "Terminal response", true)} + { + void send({ type: "terminal", data: `${values.input ?? ""}\r` }).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + /> + + ) : null} + {interaction?.type === "credentials" ? ( + <> + {interaction.fields.map((entry) => ( + {field(entry.name, entry.label, entry.secret)} + ))} + { + void send({ type: "credentials", values }).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + /> + + ) : null} + {showCallback ? ( + <> + {field("callback", "Final localhost URL", false)} + { + if (!state?.flowId) return; + void run(() => + complete({ + environmentId, + input: { instanceId, flowId: state.flowId!, callbackUrl: values.callback! }, + }), + ).then((sent) => { + if (sent) setDraft({ id: "", values: {} }); + }); + }} + /> + + ) : null} + {error || auth.error ? ( + + {error ?? auth.error} + + ) : null} ) : null} {url ? ( @@ -314,72 +441,50 @@ function ProviderAccount({ }} /> ) : null} - {needsExternalSetup && provider.setup?.documentationUrl ? ( - { - void Linking.openURL(provider.setup!.documentationUrl!).catch(() => - setError("Could not open the provider docs."), - ); - }} - /> - ) : active && state?.flowId ? ( - { - void run(() => cancel({ environmentId, input: { instanceId, flowId: state.flowId! } })); - }} - /> - ) : !active && !needsExternalSetup && provider.setup?.canAuthenticate !== false ? ( - - ) : null} - {!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? ( - - Dialog.alert( - "Sign out?", - `Running threads sharing this sign-in on ${environment.label} will stop. Thread history is kept.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Sign out", - style: "destructive", - onPress: () => { - void run(() => logout(target)); - }, - }, - ], - ) - } - /> - ) : null} ); } -function ProviderAccountEmail({ email }: { readonly email: string }) { +function ProviderAccountSummary({ + name, + status, + email, +}: { + readonly name: string; + readonly status: string | null | undefined; + readonly email: string | undefined; +}) { const [revealed, setRevealed] = useState(false); + const content = ( + <> + + {name} + + + {email ? `${status} · ${revealed ? email : "••••••@••••••"}` : status} + + + ); + if (!email) { + return ( + + {content} + + ); + } return ( setRevealed((value) => !value)} - className="min-h-[44px] justify-center" + className="min-w-0 shrink grow gap-0.5" > - {revealed ? email : "••••••@••••••"} + {content} ); }