diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 163ebf4fc..5e25d7a53 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -1,11 +1,5 @@ -import {useCallback, useRef, useState} from 'react' -import { - ActivityIndicator, - Keyboard, - Pressable, - type TextInput, - View, -} from 'react-native' +import {useRef, useState} from 'react' +import {Keyboard, Pressable, type TextInput, View} from 'react-native' import { ComAtprotoServerCreateSession, type ComAtprotoServerDescribeServer, @@ -16,18 +10,24 @@ import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' -import {isValidDomain} from '#/lib/strings/url-helpers' +import {isBlueskyHostedUrl, toNiceHostingUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' -import {useSessionApi} from '#/state/session' +import { + type HostingProviderState, + useHostingProvider, +} from '#/state/queries/pds-detection' +import {useSession, useSessionApi} from '#/state/session' import {getNativeOAuthClient} from '#/state/session/oauth-native-client' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {atoms as a, native, tokens, useBreakpoints, useTheme} from '#/alf' import * as Admonition from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {HostingProvider} from '#/components/forms/HostingProvider' +import {useDialogControl} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' +import {Envelope_Stroke2_Corner0_Rounded as EmailIcon} from '#/components/icons/Envelope' import {Eye_Stroke2_Corner0_Rounded as EyeIcon} from '#/components/icons/Eye' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' @@ -36,7 +36,10 @@ import {createStaticClick, InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {IS_IOS, IS_NATIVE} from '#/env' +import {AppServerButton} from './components/AppServerDialog' +import {ConfirmHostingProviderDialog} from './components/ConfirmHostingProviderDialog' import {HandleAutocompleteInput} from './components/HandleAutocompleteInput' +import {HostingProviderDialog} from './components/HostingProviderDialog' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -51,15 +54,14 @@ export const LoginForm = ({ setError, setServiceUrl, onPressRetryConnect, + onPressBack, onPressForgotPassword, onAttemptSuccess, onAttemptFailed, - debouncedResolveService, - isResolvingService, onPressCreateAccount, }: { error: string - serviceUrl?: string | undefined + serviceUrl: string serviceDescription: ServiceDescription | undefined initialHandle: string setError: (v: string) => void @@ -69,8 +71,6 @@ export const LoginForm = ({ onPressForgotPassword: () => void onAttemptSuccess: () => void onAttemptFailed: () => void - debouncedResolveService: (identifier: string) => void - isResolvingService: boolean onPressCreateAccount: () => void }) => { const t = useTheme() @@ -148,6 +148,7 @@ export const LoginForm = ({ isProcessing={isProcessing} setIsProcessing={setIsProcessing} onAttemptSuccess={onAttemptSuccess} + onPressBack={onPressBack} /> ) : ( void onAttemptSuccess: () => void + onPressBack: () => void }) { const {t: l} = useLingui() const {login} = useSessionApi() @@ -223,12 +224,11 @@ function OAuthLoginFields({ onAttemptSuccess() setShowLoggedOut(false) setHasCheckedForStarterPack(true) - requestNotificationsPermission('Login') - } catch (e: any) { - const errMsg = e.toString() + void requestNotificationsPermission('Login') + } catch (e: unknown) { + const errMsg = String(e) setIsProcessing(false) if (errMsg.includes('cancelled') || errMsg.includes('dismiss')) { - // User cancelled the browser auth flow return } if (isNetworkError(e)) { @@ -249,6 +249,7 @@ function OAuthLoginFields({ return ( <> + Account @@ -256,12 +257,13 @@ function OAuthLoginFields({ { identifierValueRef.current = v }} - onSubmit={onPressNext} + onSubmit={() => void onPressNext()} /> {error && ( @@ -274,7 +276,7 @@ function OAuthLoginFields({ accessibilityHint={l`Opens your authorization server to sign in`} color="primary" size="large" - onPress={onPressNext}> + onPress={() => void onPressNext()}> Login @@ -293,106 +295,75 @@ function LegacyLoginFields({ setError, setServiceUrl, onPressRetryConnect, + onPressBack, onPressForgotPassword, onAttemptSuccess, onAttemptFailed, - debouncedResolveService, - isResolvingService, isProcessing, setIsProcessing, onPressCreateAccount, }: { error: string - serviceUrl?: string | undefined + serviceUrl: string serviceDescription: ServiceDescription | undefined initialHandle: string setError: (v: string) => void setServiceUrl: (v: string) => void onPressRetryConnect: () => void + onPressBack: () => void onPressForgotPassword: () => void onAttemptSuccess: () => void onAttemptFailed: () => void - debouncedResolveService: (identifier: string) => void - isResolvingService: boolean isProcessing: boolean setIsProcessing: (v: boolean) => void onPressCreateAccount: () => void }) { const t = useTheme() - const {t: l} = useLingui() - const {gtMobile} = useBreakpoints() - const {login} = useSessionApi() - const requestNotificationsPermission = useRequestNotificationsPermission() - const {setShowLoggedOut} = useLoggedOutViewControls() - const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() - const [errorField, setErrorField] = useState< 'none' | 'identifier' | 'password' | '2fa' >('none') const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) + const [showResolveError, setShowResolveError] = useState(false) const identifierValueRef = useRef(initialHandle || '') const passwordValueRef = useRef('') + const [identifier, setIdentifier] = useState(initialHandle || '') + const [identifierFocused, setIdentifierFocused] = useState(false) const [authFactorToken, setAuthFactorToken] = useState('') const identifierRef = useRef(null) const passwordRef = useRef(null) const hasFocusedOnce = useRef(false) const [hasPassword, setHasPassword] = useState(false) const [revealPassword, setRevealPassword] = useState(false) + const {t: l} = useLingui() + const {login} = useSessionApi() + const {accounts} = useSession() + const requestNotificationsPermission = useRequestNotificationsPermission() + const {setShowLoggedOut} = useLoggedOutViewControls() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() + const serverInputControl = useDialogControl() + const confirmHostingProviderControl = useDialogControl() + const [pendingLogin, setPendingLogin] = useState<{ + service: string + fullIdent: string + passwordLength: number + } | null>(null) + const hostingProvider = useHostingProvider({ + identifier, + defaultService: serviceUrl, + }) + const {gtMobile} = useBreakpoints() - const onPressSelectService = useCallback(() => { - Keyboard.dismiss() - }, []) - - const onPressNext = async () => { - if (isProcessing || isResolvingService || serviceUrl === undefined) return - Keyboard.dismiss() - setError('') - setErrorField('none') + const showUnresolvedError = + hostingProvider.state.status === 'unresolved' && !identifierFocused - const identifier = identifierValueRef.current.toLowerCase().trim() + const attemptLogin = async (service: string, fullIdent: string) => { const password = passwordValueRef.current - - if (!identifier) { - setError(l`Please enter your username`) - setErrorField('identifier') - return - } - - if (!password) { - setError(l`Please enter your password`) - setErrorField('password') - return - } - setIsProcessing(true) try { - let fullIdent = identifier - if ( - !identifier.includes('@') && - !identifier.includes('.') && - !identifier.startsWith('did:') && - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullIdent.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullIdent = createFullHandle( - identifier, - serviceDescription.availableUserDomains[0], - ) - } - } - - // TODO remove double login await login( { - service: serviceUrl, + service, identifier: fullIdent, password, authFactorToken: authFactorToken.trim(), @@ -440,40 +411,124 @@ function LegacyLoginFields({ } } + const onPressNext = async () => { + if (isProcessing) return + Keyboard.dismiss() + setError('') + setErrorField('none') + setShowResolveError(false) + + const identifier = identifierValueRef.current.toLowerCase().trim() + const password = passwordValueRef.current + + if (!identifier) { + setError(l`Please enter your username`) + setErrorField('identifier') + return + } + + if (!password) { + setError(l`Please enter your password`) + setErrorField('password') + return + } + + setIsProcessing(true) + + let fullIdent = identifier + if ( + !identifier.includes('@') && + !identifier.includes('.') && + !identifier.startsWith('did:') && + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullIdent.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullIdent = createFullHandle( + identifier, + serviceDescription.availableUserDomains[0], + ) + } + } + + let service: string + let did: string | null + try { + ;({service, did} = await hostingProvider.resolveService(identifier)) + } catch (err) { + logger.debug('Failed to resolve hosting provider', {error: String(err)}) + setIsProcessing(false) + setShowResolveError(true) + return + } + + const isKnownAccount = + did != null && accounts.some(account => account.did === did) + const needsConfirmation = + !isBlueskyHostedUrl(service) && + hostingProvider.state.status !== 'overridden' && + !isKnownAccount + + if (needsConfirmation) { + setIsProcessing(false) + setPendingLogin({service, fullIdent, passwordLength: password.length}) + confirmHostingProviderControl.open() + return + } + + await attemptLogin(service, fullIdent) + } + return ( <> + { + hostingProvider.override(url) + setServiceUrl(url) + }} + onSelectAutomatic={() => { + hostingProvider.clearOverride() + setServiceUrl(DEFAULT_SERVICE) + }} + /> + { + if (pendingLogin) { + void attemptLogin(pendingLogin.service, pendingLogin.fullIdent) + } + }} + /> + - Hosting provider - {isResolvingService && ( - - )} - - - - - - - Account + Username or email - - + + { identifierValueRef.current = v - const id = v.trim() - if (id) { - if ( - id.startsWith('did:') || - (!id.includes('@') && isValidDomain(id)) - ) { - debouncedResolveService(id) - } - } + setIdentifier(v) if (errorField) setErrorField('none') + if (showResolveError) setShowResolveError(false) }} + onFocus={() => setIdentifierFocused(true)} + onBlur={() => setIdentifierFocused(false)} onSubmitEditing={() => { passwordRef.current?.focus() }} @@ -503,6 +553,27 @@ function LegacyLoginFields({ accessibilityHint={l`Enter the username or email address you used when you created your account`} /> + {showUnresolvedError && ( + + + We couldn't find an account with that username. Please check that + you've typed it correctly, or{' '} + serverInputControl.open())}> + set your hosting provider manually + + . + + + )} @@ -553,6 +624,7 @@ function LegacyLoginFields({ onPress={() => setRevealPassword(r => !r)} /> + {!isAuthFactorTokenNeeded && ( + + + serverInputControl.open()} + /> + + + )} {!serviceDescription && error ? ( - ) : !serviceDescription && serviceUrl !== undefined ? ( + ) : !serviceDescription ? ( + ) +} diff --git a/src/screens/Login/LoginForm.web.tsx b/src/screens/Login/LoginForm.web.tsx index e1bfcddb2..beaf8cc00 100644 --- a/src/screens/Login/LoginForm.web.tsx +++ b/src/screens/Login/LoginForm.web.tsx @@ -1,39 +1,44 @@ -import {useCallback, useRef, useState} from 'react' -import { - ActivityIndicator, - Keyboard, - Pressable, - type TextInput, - View, -} from 'react-native' +import {useRef, useState} from 'react' +import {Keyboard, Pressable, type TextInput, View} from 'react-native' import { ComAtprotoServerCreateSession, type ComAtprotoServerDescribeServer, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' +import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' -import {isValidDomain} from '#/lib/strings/url-helpers' +import {isBlueskyHostedUrl, toNiceHostingUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' -import {useSessionApi} from '#/state/session' +import { + type HostingProviderState, + useHostingProvider, +} from '#/state/queries/pds-detection' +import {useSession, useSessionApi} from '#/state/session' import {getWebOAuthClient} from '#/state/session/oauth-web-client' import {saveOAuthReturnUrl} from '#/state/session/oauth-web-return-url' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {atoms as a, native, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {atoms as a, native, tokens, useBreakpoints, useTheme} from '#/alf' import * as Admonition from '#/components/Admonition' -import {HostingProvider} from '#/components/forms/HostingProvider' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' -import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' -import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' -import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' +import {Envelope_Stroke2_Corner0_Rounded as EmailIcon} from '#/components/icons/Envelope' +import {Eye_Stroke2_Corner0_Rounded as EyeIcon} from '#/components/icons/Eye' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' +import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as TicketIcon} from '#/components/icons/Ticket' +import {createStaticClick, InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {AppServerButton} from './components/AppServerDialog' +import {ConfirmHostingProviderDialog} from './components/ConfirmHostingProviderDialog' import {HandleAutocompleteInput} from './components/HandleAutocompleteInput' +import {HostingProviderDialog} from './components/HostingProviderDialog' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -43,7 +48,7 @@ type LoginMode = 'oauth' | 'legacy' /** * Web-specific LoginForm with two tabs: * - OAuth (default): handle-only flow, redirects to PDS authorization server - * - Legacy sign-in: username + password, for operations that may not support OAuth + * - Legacy sign-in: username + password with upstream hosting autodetection */ export const LoginForm = ({ error, @@ -57,11 +62,9 @@ export const LoginForm = ({ onPressForgotPassword, onAttemptSuccess, onAttemptFailed, - debouncedResolveService, - isResolvingService, }: { error: string - serviceUrl?: string | undefined + serviceUrl: string serviceDescription: ServiceDescription | undefined initialHandle: string setError: (v: string) => void @@ -71,8 +74,6 @@ export const LoginForm = ({ onPressForgotPassword: () => void onAttemptSuccess: () => void onAttemptFailed: () => void - debouncedResolveService: (identifier: string) => void - isResolvingService: boolean }) => { const t = useTheme() const [mode, setMode] = useState('oauth') @@ -163,8 +164,6 @@ export const LoginForm = ({ onPressForgotPassword={onPressForgotPassword} onAttemptSuccess={onAttemptSuccess} onAttemptFailed={onAttemptFailed} - debouncedResolveService={debouncedResolveService} - isResolvingService={isResolvingService} isProcessing={isProcessing} setIsProcessing={setIsProcessing} /> @@ -188,7 +187,7 @@ function OAuthLoginFields({ setIsProcessing: (v: boolean) => void onPressBack: () => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const identifierValueRef = useRef(initialHandle || '') const onPressNext = async () => { @@ -199,7 +198,7 @@ function OAuthLoginFields({ const identifier = identifierValueRef.current.trim() if (!identifier) { - setError(_(msg`Please enter your username or handle`)) + setError(l`Please enter your username or handle`) return } @@ -210,17 +209,15 @@ function OAuthLoginFields({ const client = getWebOAuthClient() await client.signIn(identifier) // Browser will redirect to authorization server - } catch (e: any) { - const errMsg = e.toString() + } catch (e: unknown) { + const errMsg = String(e) setIsProcessing(false) if (isNetworkError(e)) { logger.warn('Failed to start OAuth sign-in due to network error', { error: errMsg, }) setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), + l`Unable to contact your service. Please check your Internet connection.`, ) } else { logger.warn('Failed to start OAuth sign-in', {error: errMsg}) @@ -231,25 +228,27 @@ function OAuthLoginFields({ return ( <> + Account { identifierValueRef.current = v }} - onSubmit={onPressNext} + onSubmit={() => void onPressNext()} /> {error && {error}} - - + {!isAuthFactorTokenNeeded && ( + + )} {isAuthFactorTokenNeeded && ( @@ -534,10 +615,10 @@ function LegacyLoginFields({ 2FA Confirmation - + void onPressNext()} editable={!isProcessing} - accessibilityHint={_( - msg`Input the code which has been emailed to you`, - )} + accessibilityHint={l`Input the code which has been emailed to you`} style={{ textTransform: authFactorToken === '' ? 'none' : 'uppercase', }} @@ -566,22 +645,64 @@ function LegacyLoginFields({ )} - {error && {error}} - - + + {!showUnresolvedError && + (showResolveError ? ( + + + + + + + We couldn’t verify your hosting provider. Check your + internet connection, or{' '} + serverInputControl.open())}> + set your hosting provider manually + + . + + + + + + ) : ( + error && ( + {error} + ) + ))} + + + {gtMobile && ( + <> + + + serverInputControl.open()} + /> + + + )} {!serviceDescription && error ? ( - ) : !serviceDescription && serviceUrl !== undefined ? ( + ) : !serviceDescription ? ( ) : ( )} + + {!gtMobile && ( + serverInputControl.open()} + /> + )} ) } + +function RevealPasswordButton({ + active, + hasPassword, + onPress, +}: { + active: boolean + hasPassword: boolean + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const context = TextField.useTextFieldContext() + + const Icon = !active ? EyeSlashIcon : EyeIcon + + if (!hasPassword && !context.focused) return null + + return ( + + + + ) +} + +function HostingProviderIndicator({ + state, + onPress, +}: { + state: HostingProviderState + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const {gtMobile} = useBreakpoints() + + return ( + + ) +} diff --git a/src/screens/Login/components/AppServerDialog.tsx b/src/screens/Login/components/AppServerDialog.tsx new file mode 100644 index 000000000..58b2d452b --- /dev/null +++ b/src/screens/Login/components/AppServerDialog.tsx @@ -0,0 +1,502 @@ +import {useCallback, useImperativeHandle, useRef, useState} from 'react' +import {Keyboard, View} from 'react-native' +import {isDid} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import { + APPVIEW_PRESETS, + type AppViewPresetId, + didWebFromAppViewUrl, + getActiveAppViewPreset, + getActiveAppViewTitle, + normalizeAppViewUrl, + useCustomAppViewDid, + useCustomAppViewUrl, + useSetAppViewSelection, +} from '#/state/preferences/custom-appview-did' +import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' +import {RestartRequiredPrompt} from '#/state/preferences/restart-required-prompt' +import {findService, useDidDocument} from '#/state/queries/resolve-identity' +import {useSession, useSessionApi} from '#/state/session' +import {getNativeOAuthClient} from '#/state/session/oauth-native-client' +import {saveOAuthReturnUrl} from '#/state/session/oauth-web-return-url' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {atoms as a, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' +import * as TextField from '#/components/forms/TextField' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' +import {IS_NATIVE, IS_WEB} from '#/env' + +type AppViewSelection = {did: string | undefined; url: string | undefined} + +type DialogInnerRef = { + getFormState: () => AppViewSelection | 'invalid' | null +} + +/** + * How confirming a change takes effect. + * - `login`: save only; applied when the next agent is created at sign-in + * - `reauth`: OAuth account must sign in again + * - `restart`: legacy account needs an app restart + */ +export type AppServerApplyMode = 'login' | 'reauth' | 'restart' + +/** + * Top-of-form App server control (same layout as the old Hosting provider + * button). Shows the active AppView title and opens the preset/custom dialog. + */ +export function AppServerButton({onOpenDialog}: {onOpenDialog?: () => void}) { + const t = useTheme() + const {t: l} = useLingui() + const [did] = useCustomAppViewDid() + const [url] = useCustomAppViewUrl() + const control = Dialog.useDialogControl() + const title = getActiveAppViewTitle(did, url) + + return ( + <> + + + App server + + + + + + ) +} + +/** + * Compact header control showing the active App server name. Opens the switcher + * dialog; applying a change reauthenticates (OAuth) or restarts (legacy). + */ +export function AppServerHeaderControl() { + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + const {login, logoutCurrentAccount} = useSessionApi() + const {requestSwitchToAccount} = useLoggedOutViewControls() + const [did] = useCustomAppViewDid() + const [url] = useCustomAppViewUrl() + const control = Dialog.useDialogControl() + const restartPromptControl = Prompt.usePromptControl() + const title = getActiveAppViewTitle(did, url) + const isOauth = !!currentAccount?.isOauthSession + const applyMode: AppServerApplyMode = isOauth ? 'reauth' : 'restart' + + const onApply = useCallback(async () => { + if (!currentAccount) return + + if (applyMode === 'restart') { + restartPromptControl.open() + return + } + + // OAuth: re-run authorization so the new AppView proxy is attached. + try { + if (IS_WEB) { + const {getWebOAuthClient} = await import( + '#/state/session/oauth-web-client' + ) + saveOAuthReturnUrl() + const client = getWebOAuthClient() + await client.signIn(currentAccount.handle) + return + } + + if (IS_NATIVE) { + const client = getNativeOAuthClient() + const session = await client.signIn(currentAccount.handle) + await login( + { + service: '', + identifier: '', + password: '', + oauthSession: session, + }, + 'Settings', + ) + } + } catch (e: unknown) { + const errMsg = String(e) + if (errMsg.includes('cancelled') || errMsg.includes('dismiss')) { + return + } + logger.warn('App server reauth failed', { + error: isNetworkError(e) ? errMsg : cleanError(errMsg), + }) + logoutCurrentAccount('Settings') + requestSwitchToAccount({requestedAccount: currentAccount.did}) + } + }, [ + applyMode, + currentAccount, + login, + logoutCurrentAccount, + requestSwitchToAccount, + restartPromptControl, + ]) + + return ( + <> + + + + void onApply()} + /> + + + ) +} + +export function AppServerDialog({ + control, + applyMode = 'login', + onApply, +}: { + control: Dialog.DialogOuterProps['control'] + applyMode?: AppServerApplyMode + /** + * Called after a changed selection is saved, when `applyMode` is not `login`. + */ + onApply?: () => void +}) { + const formRef = useRef(null) + const confirmedRef = useRef(false) + const [did] = useCustomAppViewDid() + const [url] = useCustomAppViewUrl() + const setAppViewSelection = useSetAppViewSelection() + const [preset, setPreset] = useState(() => + getActiveAppViewPreset(did, url), + ) + const [customUrl, setCustomUrl] = useState(() => + getActiveAppViewPreset(did, url) === 'custom' ? (url ?? '') : '', + ) + + const resetLocalState = useCallback(() => { + setPreset(getActiveAppViewPreset(did, url)) + setCustomUrl( + getActiveAppViewPreset(did, url) === 'custom' ? (url ?? '') : '', + ) + }, [did, url]) + + const onClose = useCallback(() => { + const result = formRef.current?.getFormState() + const shouldApply = + applyMode === 'login' || confirmedRef.current === true + confirmedRef.current = false + + if (!shouldApply || !result || result === 'invalid') { + resetLocalState() + return + } + + const nextDid = result.did + const nextUrl = result.url + const changed = nextDid !== did || nextUrl !== (url ?? undefined) + if (!changed) return + + setAppViewSelection({did: nextDid, url: nextUrl}) + if (applyMode !== 'login') { + onApply?.() + } + }, [ + applyMode, + did, + url, + setAppViewSelection, + onApply, + resetLocalState, + ]) + + return ( + + + { + confirmedRef.current = true + control.close() + }} + /> + + ) +} + +function AppServerDialogInner({ + formRef, + preset, + setPreset, + customUrl, + setCustomUrl, + applyMode, + onConfirm, +}: { + formRef: React.Ref + preset: AppViewPresetId + setPreset: (preset: AppViewPresetId) => void + customUrl: string + setCustomUrl: (url: string) => void + applyMode: AppServerApplyMode + onConfirm: () => void +}) { + const {t: l} = useLingui() + const t = useTheme() + const enableSquareButtons = useEnableSquareButtons() + + const normalizedCustomUrl = normalizeAppViewUrl(customUrl) + const derivedDid = + preset === 'custom' ? didWebFromAppViewUrl(normalizedCustomUrl) : undefined + const doc = useDidDocument({did: derivedDid ?? ''}) + const bskyAppViewService = + doc.data && findService(doc.data, '#bsky_appview', 'BskyAppView') + + const customIsValid = + preset !== 'custom' || + (!!normalizedCustomUrl && + !!derivedDid && + isDid(derivedDid) && + !!bskyAppViewService?.serviceEndpoint) + + useImperativeHandle( + formRef, + () => ({ + getFormState: () => { + if (preset === 'bluesky') { + return {did: undefined, url: undefined} + } + if (preset === 'blacksky') { + return { + did: APPVIEW_PRESETS.blacksky.did, + url: APPVIEW_PRESETS.blacksky.url, + } + } + if (!customIsValid || !derivedDid) { + return 'invalid' + } + return { + did: derivedDid, + url: bskyAppViewService?.serviceEndpoint || normalizedCustomUrl, + } + }, + }), + [ + preset, + customIsValid, + derivedDid, + bskyAppViewService?.serviceEndpoint, + normalizedCustomUrl, + ], + ) + + const confirmLabel = + applyMode === 'reauth' + ? l`Sign in again` + : applyMode === 'restart' + ? l`Restart` + : l`Done` + + return ( + + + + Choose your app server + + + + + {l`Bluesky`} + + + {l`Blacksky`} + + + {l`Custom`} + + + + {preset === 'custom' && ( + + + AppView URL + + + + + + {!!normalizedCustomUrl && !!derivedDid && doc.isLoading && ( + + + + Looking up AppView… + + + )} + {!!normalizedCustomUrl && + !!derivedDid && + !doc.isLoading && + !bskyAppViewService?.serviceEndpoint && ( + + + + Couldn’t find a #bsky_appview service at this URL. Check + the address and try again. + + + + )} + + )} + + + + The app server (AppView) provides posts, feeds, profiles, search, + notifications, and mutes. Your account still lives with your + hosting provider. + + + + + + + + ) +} diff --git a/src/screens/Login/components/ConfirmHostingProviderDialog.tsx b/src/screens/Login/components/ConfirmHostingProviderDialog.tsx index 3a3e40a36..e5b760a30 100644 --- a/src/screens/Login/components/ConfirmHostingProviderDialog.tsx +++ b/src/screens/Login/components/ConfirmHostingProviderDialog.tsx @@ -2,6 +2,7 @@ import {Fragment} from 'react' import {View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' +import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -63,12 +64,15 @@ function DialogInner({ const control = Dialog.useDialogContext() const {t: l} = useLingui() const t = useTheme() + const enableSquareButtons = useEnableSquareButtons() return ( + style={web([ + {maxWidth: 400, borderRadius: enableSquareButtons ? 18 : 36}, + ])}> ( persisted.get('pdsAddressHistory') || [], @@ -147,7 +149,9 @@ function DialogInner({ + style={web([ + {maxWidth: 400, borderRadius: enableSquareButtons ? 18 : 36}, + ])}> acc.did === requestedAccountSwitchTo, ) - const [isResolvingService, setIsResolvingService] = useState(false) - const [error, setError] = useState('') - const [serviceUrl, setServiceUrl] = useState( - requestedAccount?.service, + const [error, setError] = useState('') + const [serviceUrl, setServiceUrl] = useState( + requestedAccount?.service || DEFAULT_SERVICE, ) const [initialHandle, setInitialHandle] = useState( requestedAccount?.handle || '', @@ -84,7 +77,7 @@ export const Login = ({ data: serviceDescription, error: serviceError, refetch: refetchService, - } = useServiceQuery(serviceUrl ?? '') + } = useServiceQuery(serviceUrl) const onSelectAccount = (account?: SessionAccount) => { if (account?.service) { @@ -111,6 +104,8 @@ export const Login = ({ error: String(serviceError), }) ax.metric('signin:hostingProviderFailedResolution', {}) + } else { + setError('') } }, [serviceError, serviceUrl, l, ax]) @@ -122,46 +117,6 @@ export const Login = ({ } }, []) - const resolveIdentity = useCallback(async (identifier: string) => { - setIsResolvingService(true) - - try { - const identity = await resolveIdentityUsingAppView(identifier) - const did = identity.did as Did - const pdsUrl = getPdsServiceUrlFromIdentityInfo(identity) - - if (!pdsUrl) { - throw new Error(`No PDS service found in DID document for ${did}`) - } - - if (pdsUrl.endsWith('.bsky.network')) { - setServiceUrl('https://bsky.social') - } else { - setServiceUrl(pdsUrl) - } - } catch (err) { - logger.error( - `Service auto-resolution failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } finally { - setIsResolvingService(false) - } - }, []) - - const debouncedResolveService = useMemo( - () => debounce(resolveIdentity, 400), - [resolveIdentity], - ) - const onPressRetryConnect = useCallback(() => { - void refetchService() - }, [refetchService]) - const onDebouncedResolveService = useCallback( - (identifier: string) => { - void debouncedResolveService(identifier) - }, - [debouncedResolveService], - ) - const onPressForgotPassword = () => { gotoForm(Forms.ForgotPassword) ax.metric('signin:forgotPasswordPressed', {}) @@ -210,9 +165,7 @@ export const Login = ({ setServiceUrl={setServiceUrl} onPressBack={goBack} onPressForgotPassword={onPressForgotPassword} - onPressRetryConnect={onPressRetryConnect} - debouncedResolveService={onDebouncedResolveService} - isResolvingService={isResolvingService} + onPressRetryConnect={() => void refetchService()} onPressCreateAccount={onPressCreateAccount} /> ) @@ -235,7 +188,7 @@ export const Login = ({ content = ( gotoForm(Forms.PasswordUpdated)} diff --git a/src/screens/Settings/RunesSettings/InfrastructureSettings.tsx b/src/screens/Settings/RunesSettings/InfrastructureSettings.tsx index afbbe476f..d779e2514 100644 --- a/src/screens/Settings/RunesSettings/InfrastructureSettings.tsx +++ b/src/screens/Settings/RunesSettings/InfrastructureSettings.tsx @@ -1,19 +1,13 @@ import {useState} from 'react' import {View} from 'react-native' -import {isDid} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' -import {APPVIEW_DID_PROXY} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import * as persisted from '#/state/persisted' import { useConstellationInstance, useSetConstellationInstance, } from '#/state/preferences/constellation-instance' -import { - useCustomAppViewDid, - useSetCustomAppViewDid, -} from '#/state/preferences/custom-appview-did' import { useImageCdnHost, useSetImageCdnHost, @@ -22,15 +16,12 @@ import { usePlcDirectory, useSetPlcDirectory, } from '#/state/preferences/plc-directory' -import {RestartRequiredPrompt} from '#/state/preferences/restart-required-prompt' import { useLibreTranslateInstance, useSetLibreTranslateInstance, useSetTranslationServicePreference, useTranslationServicePreference, } from '#/state/preferences/translation-service-preference' -import {findService, useDidDocument} from '#/state/queries/resolve-identity' -import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -60,10 +51,6 @@ export function RunesInfrastructureSettingsScreen() { const constellationInstance = useConstellationInstance() const setConstellationInstanceControl = Dialog.useDialogControl() - const [customAppViewDid] = useCustomAppViewDid() - const setCustomAppViewDidControl = Dialog.useDialogControl() - const restartPromptControl = Dialog.useDialogControl() - return ( @@ -199,32 +186,12 @@ export function RunesInfrastructureSettingsScreen() { - - - - - - {`Custom AppView DID`} - - setCustomAppViewDidControl.open()} - /> - - - { - restartPromptControl.open() - }} - /> - ) } @@ -293,138 +260,6 @@ function ConstellationInstanceDialog({ ) } -function CustomAppViewDidDialog({ - control, - onRestartRequired, -}: { - control: Dialog.DialogControlProps - onRestartRequired: () => void -}) { - const pal = usePalette('default') - const {t: l} = useLingui() - - const [customAppViewDid] = useCustomAppViewDid() - const [did, setDid] = useState(customAppViewDid ?? '') - const setCustomAppViewDid = useSetCustomAppViewDid() - - const doc = useDidDocument({did}) - const bskyAppViewService = - doc.data && findService(doc.data, '#bsky_appview', 'BskyAppView') - - const submit = () => { - if (did.length === 0) { - control.close(() => { - setCustomAppViewDid(undefined) - onRestartRequired() - }) - return - } - if (!bskyAppViewService?.serviceEndpoint) return - control.close(() => { - setCustomAppViewDid(did) - onRestartRequired() - }) - } - - return ( - setDid(customAppViewDid ?? '')}> - - - - - Custom AppView Proxy DID - - - - - - - {did && !isDid(did) && ( - - - - )} - - {did && (did.includes('#') || did.includes('?')) && ( - - - - )} - - {doc.isError && ( - - - - )} - - {doc.data && - !bskyAppViewService && - (doc.data as {message?: string}).message && ( - - - - )} - - {doc.data && !bskyAppViewService && ( - - - - )} - - {bskyAppViewService && ( - - {JSON.stringify(bskyAppViewService, null, 2)} - - )} - - - - - - - - - - ) -} - function LibreTranslateInstanceDialog({ control, }: { diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index f0a067bb0..8821003c0 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -27,8 +27,12 @@ import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' -import {useAgent} from '#/state/session' -import {type SessionAccount, useSession, useSessionApi} from '#/state/session' +import { + type SessionAccount, + useAgent, + useSession, + useSessionApi, +} from '#/state/session' import {pdsAgent} from '#/state/session/agent' import { type AccountSortOption, @@ -39,6 +43,7 @@ import {useOnboardingDispatch} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {UserAvatar} from '#/view/com/util/UserAvatar' +import {AppServerHeaderControl} from '#/screens/Login/components/AppServerDialog' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' @@ -180,7 +185,7 @@ export function SettingsScreen({}: Props) { Settings - + diff --git a/src/state/preferences/custom-appview-did.tsx b/src/state/preferences/custom-appview-did.tsx index c4ee0ba4f..fc8e01988 100644 --- a/src/state/preferences/custom-appview-did.tsx +++ b/src/state/preferences/custom-appview-did.tsx @@ -1,8 +1,54 @@ import {useCallback} from 'react' import {isDid} from '@atproto/api' +import { + PUBLIC_APPVIEW_DID, + PUBLIC_BSKY_SERVICE, +} from '#/lib/constants' import {device, useStorage} from '#/storage' +export type AppViewPresetId = 'bluesky' | 'blacksky' | 'custom' + +export type AppViewPreset = { + id: AppViewPresetId + /** + * Display title shown on the App server control. + */ + title: string + /** + * AppView service URL used for guest browsing and as the preset's identity. + */ + url: string + /** + * DID used for `atproto-proxy` (`#bsky_appview`). `undefined` means the + * build/runtime default (Bluesky). + */ + did: string | undefined +} + +/** + * Named AppView presets offered on the sign-in screen. Bluesky clears any + * override so the build defaults apply; Blacksky points at their public + * AppView DID/URL. + */ +export const APPVIEW_PRESETS: Record< + Exclude, + AppViewPreset +> = { + bluesky: { + id: 'bluesky', + title: 'Bluesky', + url: PUBLIC_BSKY_SERVICE, + did: undefined, + }, + blacksky: { + id: 'blacksky', + title: 'Blacksky', + url: 'https://api.blacksky.community', + did: 'did:web:api.blacksky.community', + }, +} + export function useCustomAppViewDid() { const [customAppViewDid = undefined, setCustomAppViewDid] = useStorage( device, @@ -12,6 +58,15 @@ export function useCustomAppViewDid() { return [customAppViewDid, setCustomAppViewDid] as const } +export function useCustomAppViewUrl() { + const [customAppViewUrl = undefined, setCustomAppViewUrl] = useStorage( + device, + ['customAppViewUrl'], + ) + + return [customAppViewUrl, setCustomAppViewUrl] as const +} + export function useSetCustomAppViewDid() { const [, setCustomAppViewDid] = useCustomAppViewDid() @@ -23,6 +78,23 @@ export function useSetCustomAppViewDid() { ) } +/** + * Persist a selected AppView. Clears both fields for the Bluesky default; + * otherwise stores the proxy DID and (for guest browsing) the service URL. + */ +export function useSetAppViewSelection() { + const [, setCustomAppViewDid] = useCustomAppViewDid() + const [, setCustomAppViewUrl] = useCustomAppViewUrl() + + return useCallback( + (selection: {did: string | undefined; url: string | undefined}) => { + setCustomAppViewDid(selection.did) + setCustomAppViewUrl(selection.url) + }, + [setCustomAppViewDid, setCustomAppViewUrl], + ) +} + export function readCustomAppViewDidUri() { const maybeDid = device.get(['customAppViewDid']) if (!maybeDid || !isDid(maybeDid)) { @@ -31,3 +103,106 @@ export function readCustomAppViewDidUri() { return `${maybeDid}#bsky_appview` } + +/** + * Guest-agent service URL override. Falls back to `PUBLIC_BSKY_SERVICE` when + * unset (Bluesky default). + */ +export function readCustomAppViewUrl() { + return device.get(['customAppViewUrl']) || undefined +} + +/** + * Resolve the active preset from stored DID/URL for UI defaults. + */ +export function getActiveAppViewPreset( + did: string | undefined, + url: string | undefined, +): AppViewPresetId { + if (!did && !url) { + return 'bluesky' + } + + const normalizedUrl = normalizeAppViewUrl(url) + if ( + did === APPVIEW_PRESETS.blacksky.did || + normalizedUrl === normalizeAppViewUrl(APPVIEW_PRESETS.blacksky.url) + ) { + return 'blacksky' + } + + if ( + (!did || did === PUBLIC_APPVIEW_DID) && + (!normalizedUrl || + normalizedUrl === normalizeAppViewUrl(APPVIEW_PRESETS.bluesky.url)) + ) { + return 'bluesky' + } + + return 'custom' +} + +/** + * Human-readable title for the active AppView selection. + */ +export function getActiveAppViewTitle( + did: string | undefined, + url: string | undefined, +): string { + const preset = getActiveAppViewPreset(did, url) + if (preset === 'bluesky') { + return APPVIEW_PRESETS.bluesky.title + } + if (preset === 'blacksky') { + return APPVIEW_PRESETS.blacksky.title + } + if (url) { + try { + return new URL(normalizeAppViewUrl(url)).host + } catch { + return url + } + } + if (did) { + return did.replace(/^did:web:/, '') + } + return APPVIEW_PRESETS.bluesky.title +} + +/** + * Normalize a typed AppView URL: trim, lowercase host path, add https when + * missing. Localhost keeps http. + */ +export function normalizeAppViewUrl(url: string | undefined): string { + if (!url) return '' + let next = url.trim() + if (!next) return '' + if (!next.startsWith('http://') && !next.startsWith('https://')) { + if (next === 'localhost' || next.startsWith('localhost:')) { + next = `http://${next}` + } else { + next = `https://${next}` + } + } + try { + const parsed = new URL(next) + // Drop trailing slash for stable comparisons / storage. + return parsed.origin + (parsed.pathname === '/' ? '' : parsed.pathname) + } catch { + return next + } +} + +/** + * Derive a did:web from an AppView URL hostname (e.g. + * `https://api.example.com` → `did:web:api.example.com`). + */ +export function didWebFromAppViewUrl(url: string): string | undefined { + try { + const host = new URL(normalizeAppViewUrl(url)).host + if (!host) return undefined + return `did:web:${host}` + } catch { + return undefined + } +} diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 3e7f4f668..70c864dc8 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -32,7 +32,10 @@ import { import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {features} from '#/analytics' import {emitNetworkConfirmed, emitNetworkLost} from '../events' -import {readCustomAppViewDidUri} from '../preferences/custom-appview-did' +import { + readCustomAppViewDidUri, + readCustomAppViewUrl, +} from '../preferences/custom-appview-did' import {addSessionErrorLog} from './logging' import { configureModerationForAccount, @@ -46,7 +49,9 @@ export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}` export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests - const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE}) + const agent = new BskyAppAgent({ + service: readCustomAppViewUrl() || PUBLIC_BSKY_SERVICE, + }) const proxyDid = readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY agent.configureProxy(proxyDid as ProxyHeaderValue) diff --git a/src/state/session/oauth-agent.ts b/src/state/session/oauth-agent.ts index 46e0ead5a..e5a4b960e 100644 --- a/src/state/session/oauth-agent.ts +++ b/src/state/session/oauth-agent.ts @@ -5,9 +5,14 @@ import { } from '@atproto/api' import {type OAuthSession} from '@atproto/oauth-client-browser' -import {BLUESKY_PROXY_HEADER, BSKY_SERVICE} from '#/lib/constants' +import { + APPVIEW_DID_PROXY, + BLUESKY_PROXY_HEADER, + BSKY_SERVICE, +} from '#/lib/constants' import {logger} from '#/logger' -import {sessionAccountToSession} from './agent' +import {readCustomAppViewDidUri} from '#/state/preferences/custom-appview-did' +import {type ProxyHeaderValue, sessionAccountToSession} from './agent' import {configureModerationForAccount} from './moderation' import {restoreOAuthSession} from './oauth-client-adapter' import {type SessionAccount} from './types' @@ -157,7 +162,9 @@ export class OauthBskyAppAgent extends Agent { this.session = sessionAccountToSession(account) this._serviceUrl = new URL(account.service) this._pdsUrl = account.pdsUrl ? new URL(account.pdsUrl) : undefined - this.configureProxy(BLUESKY_PROXY_HEADER.get()) + const proxyDid = + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY + this.configureProxy(proxyDid as ProxyHeaderValue) await Promise.all([gates, moderation]) diff --git a/src/state/session/oauth-web-client.native.ts b/src/state/session/oauth-web-client.native.ts new file mode 100644 index 000000000..30cbbf23c --- /dev/null +++ b/src/state/session/oauth-web-client.native.ts @@ -0,0 +1,8 @@ +/** + * Web-only OAuth client. Native builds use oauth-native-client instead. + * This stub prevents Metro from bundling @atproto/oauth-client-browser + * (and its jose/node:crypto dependency chain) into native apps. + */ +export function getWebOAuthClient(): never { + throw new Error('getWebOAuthClient is only available on web') +} \ No newline at end of file diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 273d68727..0559e6f87 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -73,6 +73,11 @@ export type Device = { */ inviteFriendsThemeKey?: InviteThemeKey customAppViewDid: string | undefined + /** + * Optional AppView service URL paired with `customAppViewDid`. Used for + * guest browsing and for displaying the active App server on sign-in. + */ + customAppViewUrl: string | undefined hiddenAccountsElsewhere?: string[] settingsAccountSwitcherSortBy?: | 'alphabetical'