diff --git a/src/components/icons/providers/Blacksky.tsx b/src/components/icons/providers/Blacksky.tsx new file mode 100644 index 000000000..aad58cf7a --- /dev/null +++ b/src/components/icons/providers/Blacksky.tsx @@ -0,0 +1,15 @@ +import {createMultiPathSVG} from '#/components/icons/TEMPLATE' + +/* + * viewBox minY is shifted up so the glyph sits slightly lower in its tile + * (the top flourishes otherwise read as top-heavy in a round button). + */ +export const BlackskyIcon = createMultiPathSVG({ + paths: [ + 'M148.846 144.562C148.846 159.75 161.158 172.062 176.346 172.062H207.012V185.865H176.346C161.158 185.865 148.846 198.177 148.846 213.365V243.045H136.029V213.365C136.029 198.177 123.717 185.865 108.529 185.865H77.8633V172.062H108.529C123.717 172.062 136.029 159.75 136.029 144.562V113.896H148.846V144.562Z', + 'M170.946 31.8766C160.207 42.616 160.207 60.0281 170.946 70.7675L192.631 92.4516L182.871 102.212L161.186 80.5275C150.447 69.7881 133.035 69.7881 122.296 80.5275L101.309 101.514L92.2456 92.4509L113.232 71.4642C123.972 60.7248 123.972 43.3128 113.232 32.5733L91.5488 10.8899L101.309 1.12988L122.993 22.814C133.732 33.5533 151.144 33.5534 161.884 22.814L183.568 1.12988L192.631 10.1925L170.946 31.8766Z', + 'M79.0525 75.3259C75.1216 89.9962 83.8276 105.076 98.498 109.006L128.119 116.943L124.547 130.275L94.9267 122.338C80.2564 118.407 65.1772 127.113 61.2463 141.784L53.5643 170.453L41.1837 167.136L48.8654 138.467C52.7963 123.797 44.0902 108.718 29.4199 104.787L-0.201172 96.8497L3.37124 83.5173L32.9923 91.4542C47.6626 95.3851 62.7419 86.679 66.6728 72.0088L74.6098 42.3877L86.9895 45.7048L79.0525 75.3259Z', + 'M218.413 71.4229C222.344 86.093 237.423 94.7992 252.094 90.8683L281.715 82.9313L285.287 96.2628L255.666 104.2C240.995 108.131 232.29 123.21 236.22 137.88L243.902 166.55L231.522 169.867L223.841 141.198C219.91 126.528 204.831 117.822 190.16 121.753L160.539 129.69L156.967 116.357L186.588 108.42C201.258 104.49 209.964 89.4103 206.033 74.74L198.096 45.1189L210.476 41.8018L218.413 71.4229Z', + ], + viewBox: '0 -18 285 285', +}) diff --git a/src/components/icons/providers/Bluesky.tsx b/src/components/icons/providers/Bluesky.tsx new file mode 100644 index 000000000..9c43f56fd --- /dev/null +++ b/src/components/icons/providers/Bluesky.tsx @@ -0,0 +1,6 @@ +import {createSinglePathSVG} from '#/components/icons/TEMPLATE' + +export const BlueskyIcon = createSinglePathSVG({ + path: 'M111.8 62.2C170.2 105.9 233 194.7 256 242.4c23-47.6 85.8-136.4 144.2-180.2c42.1-31.6 110.3-56 110.3 21.8c0 15.5-8.9 130.5-14.1 149.2C478.2 298 412 314.6 353.1 304.5c102.9 17.5 129.1 75.5 72.5 133.5c-107.4 110.2-154.3-27.6-166.3-62.9l0 0c-1.7-4.9-2.6-7.8-3.3-7.8s-1.6 3-3.3 7.8l0 0c-12 35.3-59 173.1-166.3 62.9c-56.5-58-30.4-116 72.5-133.5C100 314.6 33.8 298 15.7 233.1C10.4 214.4 1.5 99.4 1.5 83.9c0-77.8 68.2-53.4 110.3-21.8z', + viewBox: '0 0 512 512', +}) diff --git a/src/components/icons/providers/Eurosky.tsx b/src/components/icons/providers/Eurosky.tsx new file mode 100644 index 000000000..28ddb2bc7 --- /dev/null +++ b/src/components/icons/providers/Eurosky.tsx @@ -0,0 +1,6 @@ +import {createSinglePathSVG} from '#/components/icons/TEMPLATE' + +export const EuroskyIcon = createSinglePathSVG({ + path: 'M62.648 32.6363V29.7902H53.1194C34.2617 29.5447 33.6784 24.4289 32.7471 9.44843V0H29.901V9.44843C28.9697 24.4289 28.4795 29.5447 9.62175 29.7902H0V32.6363H9.62175C24.9301 33.6663 29.5103 33.3917 29.901 51.6058V62.6216H32.7471V51.5809C33.1378 33.3667 37.8111 33.6663 53.1194 32.6363L62.648 32.6363Z', + viewBox: '0 0 63 63', +}) diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 0709825f6..75e52ad28 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,38 +1,51 @@ -import {useEffect, useReducer, useState} from 'react' -import {AppState, type AppStateStatus, View} from 'react-native' -import ReactNativeDeviceAttest from 'react-native-device-attest' +import {useEffect, useState} from 'react' +import {Pressable, View} from 'react-native' import {KeyboardAvoidingView} from 'react-native-keyboard-controller' -import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' -import {AppBskyGraphStarterpack} from '@atproto/api' -import {tokens} from '@bsky.app/alf' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {Trans, useLingui} from '@lingui/react/macro' import {FEEDBACK_FORM_URL} from '#/lib/constants' -import {logger} from '#/logger' -import {useServiceQuery} from '#/state/queries/service' -import {useStarterPackQuery} from '#/state/queries/starter-packs' -import {useActiveStarterPack} from '#/state/shell/landing' +import * as persisted from '#/state/persisted' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' +import {oauthSignupErrorMessage} from '#/screens/Signup/oauthSignupUtils' import { - initialState, - reducer, - SignupContext, - SignupStep, - useSubmitSignup, -} from '#/screens/Signup/state' -import {StepCaptcha} from '#/screens/Signup/StepCaptcha' -import {StepHandle} from '#/screens/Signup/StepHandle' -import {StepInfo} from '#/screens/Signup/StepInfo' -import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' + normalizePdsUrl, + SIGNUP_PROVIDERS, + type SignupProvider, + type SignupProviderId, +} from '#/screens/Signup/providers' +import {useStartOauthSignup} from '#/screens/Signup/startOauthSignup' +import {atoms as a, native, tokens, useBreakpoints, useTheme, web} from '#/alf' +import * as Admonition from '#/components/Admonition' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' +import {Button, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' -import {LinearGradientBackground} from '#/components/LinearGradientBackground' -import {InlineLinkText} from '#/components/Link' -import {ScreenTransition} from '#/components/ScreenTransition' +import * as TextField from '#/components/forms/TextField' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {useAnalytics} from '#/analytics' -import {GCP_PROJECT_ID, IS_ANDROID} from '#/env' -import * as bsky from '#/types/bsky' + +const inviteStatusPromise: Promise> = (async () => { + const results: Record = {} + await Promise.allSettled( + SIGNUP_PROVIDERS.filter(p => p.service && !p.custom).map(async p => { + try { + const res = await fetch( + `${p.service}/xrpc/com.atproto.server.describeServer`, + ) + if (res.ok) { + const data = (await res.json()) as {inviteCodeRequired?: boolean} + results[p.id] = !!data.inviteCodeRequired + } + } catch { + // ignore unreachable providers + } + }), + ) + return results +})() export function Signup({ onPressBack, @@ -41,234 +54,386 @@ export function Signup({ onPressBack: () => void onPressSignIn: () => void }) { - const ax = useAnalytics() const {t: l} = useLingui() const t = useTheme() - const [state, dispatch] = useReducer(reducer, { - ...initialState, - analytics: ax, - }) const {gtMobile} = useBreakpoints() - const submit = useSubmitSignup() - - useEffect(() => { - dispatch({ - type: 'setAnalytics', - value: ax, - }) - }, [ax]) + const startOauthSignup = useStartOauthSignup() - const activeStarterPack = useActiveStarterPack() - const { - data: starterPack, - isFetching: isFetchingStarterPack, - isError: isErrorStarterPack, - } = useStarterPackQuery({ - uri: activeStarterPack?.uri, - }) + const [showCustomInput, setShowCustomInput] = useState(false) + const [customService, setCustomService] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [inviteStatus, setInviteStatus] = useState>({}) - // eslint-disable-next-line react/hook-use-state - const [isFetchedAtMount] = useState(starterPack != null) - const showStarterPackCard = - activeStarterPack?.uri && !isFetchingStarterPack && starterPack - - const { - data: serviceInfo, - isFetching, - isError, - refetch, - } = useServiceQuery(state.serviceUrl) + const providerNames: Record = { + bluesky: l`Bluesky`, + blacksky: l`Blacksky`, + eurosky: l`Eurosky`, + custom: l`Enter a PDS address`, + } useEffect(() => { - if (isFetching) { - dispatch({type: 'setIsLoading', value: true}) - } else if (!isFetching) { - dispatch({type: 'setIsLoading', value: false}) - } - }, [isFetching]) + void inviteStatusPromise.then(setInviteStatus) + }, []) - useEffect(() => { - if (isError) { - dispatch({type: 'setServiceDescription', value: undefined}) - dispatch({ - type: 'setError', - value: l`Unable to contact your service. Please check your Internet connection.`, + const startWithService = async (serviceUrl: string) => { + setLoading(true) + setError(null) + try { + await startOauthSignup(serviceUrl) + } catch (e: unknown) { + const message = oauthSignupErrorMessage(e, { + network: l`Unable to contact your service. Please check your Internet connection.`, + generic: l`Could not connect to this provider. Please try again.`, }) - } else if (serviceInfo) { - dispatch({type: 'setServiceDescription', value: serviceInfo}) - dispatch({type: 'setError', value: ''}) + setError(message) + setLoading(false) } - }, [l, serviceInfo, isError]) + } - useEffect(() => { - if (state.pendingSubmit) { - if (!state.pendingSubmit.mutableProcessed) { - // OK to mutate assuming it's never read in render. - // eslint-disable-next-line react-hooks/immutability, react-compiler/react-compiler - state.pendingSubmit.mutableProcessed = true - void submit(state, dispatch) - } + const onSelectProvider = (provider: SignupProvider) => { + if (loading) return + if (provider.custom) { + setShowCustomInput(true) + setError(null) + return } - }, [state, dispatch, submit]) - - // Track app backgrounding during signup - useEffect(() => { - const subscription = AppState.addEventListener( - 'change', - (nextAppState: AppStateStatus) => { - if (nextAppState === 'background') { - dispatch({type: 'incrementBackgroundCount'}) - } - }, - ) + void startWithService(provider.service) + } - return () => subscription.remove() - }, []) + const onSubmitCustom = () => { + if (loading) return + const serviceUrl = normalizePdsUrl(customService) + if (!serviceUrl) return - // On Android, warmup the Play Integrity API on the signup screen so it is ready by the time we get to the gate screen. - useEffect(() => { - if (!IS_ANDROID) { - return + const history = persisted.get('pdsAddressHistory') || [] + if (!history.includes(serviceUrl)) { + void persisted.write('pdsAddressHistory', [ + serviceUrl, + ...history.slice(0, 4), + ]) } - ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(err => - logger.error(err), - ) - }, []) + + void startWithService(serviceUrl) + } return ( - - - - - {showStarterPackCard && - bsky.dangerousIsType( - starterPack.record, - AppBskyGraphStarterpack.isRecord, - ) ? ( - - - - {starterPack.record.name} - - - {starterPack.feeds?.length ? ( - - You'll follow the suggested users and feeds once you - finish creating your account! - - ) : ( - - You'll follow the suggested users once you finish - creating your account! - - )} - - - - ) : null} - - - + + + + + + {loading ? ( + Connecting… + ) : showCustomInput ? ( + Enter a PDS address + ) : ( + Choose a provider + )} + + {!loading && showCustomInput && ( + - - - - Step {state.activeStep + 1} of{' '} - {state.serviceDescription && - !state.serviceDescription.phoneVerificationRequired - ? '2' - : '3'} - - - - {state.activeStep === SignupStep.INFO ? ( - The Atmosphere ✨ - ) : state.activeStep === SignupStep.HANDLE ? ( - Choose your username - ) : ( - Complete the challenge - )} - - - - {state.activeStep === SignupStep.INFO ? ( - void refetch()} - /> - ) : state.activeStep === SignupStep.HANDLE ? ( - - ) : ( - - )} + + Enter the address of the Personal Data Server + that will host your account. You’ll finish + creating your account through that provider. + + + )} + {!loading && !showCustomInput && ( + + + Witchsky is a Bluesky client. If you already have an + account,{' '} + { + onPressSignIn() + })}> + sign in + {' '} + instead of creating a new one. Otherwise, choose a + provider to host your account! You can always switch + to a different provider later, or even host your own. + + + )} + - + {loading ? ( + + + + ) : showCustomInput ? ( + { + setShowCustomInput(false) + setError(null) + }} + onSubmit={onSubmitCustom} + /> + ) : ( + + {error && ( + + {error} + + )} - - - - + {SIGNUP_PROVIDERS.map(provider => { + const name = providerNames[provider.id] + const Icon = provider.Icon + return ( + onSelectProvider(provider)} + style={({pressed, hovered}) => [ + a.flex_row, + a.align_center, + a.gap_md, + a.p_md, + a.rounded_md, + t.atoms.bg_contrast_25, + (pressed || hovered) && t.atoms.bg_contrast_50, + web({cursor: 'pointer'}), ]}> - Having trouble?{' '} - - Open a Tangled Issue - - - - + + {Icon ? ( + + ) : ( + + )} + + + {name} + + {inviteStatus[provider.id] ? ( + + + Invite + + + ) : null} + + + ) + })} - - + + )} + + {!showCustomInput && ( + + + + )} + + + + + + + + Having trouble?{' '} + + Open an issue + + + + - - - + + + ) } + +function CustomPdsForm({ + customService, + setCustomService, + error, + onBack, + onSubmit, +}: { + customService: string + setCustomService: (v: string) => void + error: string | null + onBack: () => void + onSubmit: () => void +}) { + const {t: l} = useLingui() + const [inputKey, setInputKey] = useState(0) + const pdsAddressHistory = persisted.get('pdsAddressHistory') || [] + + return ( + + + + + + + {pdsAddressHistory.length > 0 && ( + + {pdsAddressHistory.map(uri => ( + + ))} + + )} + + + {error && ( + {error} + )} + + + + + + + + + + + ) +} diff --git a/src/screens/Signup/oauthSignupUtils.ts b/src/screens/Signup/oauthSignupUtils.ts new file mode 100644 index 000000000..ddf1b5b7c --- /dev/null +++ b/src/screens/Signup/oauthSignupUtils.ts @@ -0,0 +1,20 @@ +import {cleanError, isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' + +export function oauthSignupErrorMessage( + e: unknown, + messages: {network: string; generic: string}, +): string | null { + const errMsg = String(e) + if (errMsg.includes('cancelled') || errMsg.includes('dismiss')) { + return null + } + if (isNetworkError(e)) { + logger.warn('Failed to start OAuth signup due to network error', { + error: errMsg, + }) + return messages.network + } + logger.warn('Failed to start OAuth signup', {error: errMsg}) + return cleanError(errMsg) || messages.generic +} diff --git a/src/screens/Signup/providers.ts b/src/screens/Signup/providers.ts new file mode 100644 index 000000000..7333fb630 --- /dev/null +++ b/src/screens/Signup/providers.ts @@ -0,0 +1,56 @@ +import {type ComponentType} from 'react' + +import {BSKY_SERVICE} from '#/lib/constants' +import {type Props as IconProps} from '#/components/icons/common' +import {BlackskyIcon} from '#/components/icons/providers/Blacksky' +import {BlueskyIcon} from '#/components/icons/providers/Bluesky' +import {EuroskyIcon} from '#/components/icons/providers/Eurosky' + +export type SignupProviderId = 'bluesky' | 'blacksky' | 'eurosky' | 'custom' + +export type SignupProvider = { + id: SignupProviderId + service: string + Icon: ComponentType | null + custom?: boolean +} + +export const SIGNUP_PROVIDERS: SignupProvider[] = [ + { + id: 'bluesky', + service: BSKY_SERVICE, + Icon: BlueskyIcon, + }, + { + id: 'blacksky', + service: 'https://blacksky.app', + Icon: BlackskyIcon, + }, + { + id: 'eurosky', + service: 'https://eurosky.social', + Icon: EuroskyIcon, + }, + { + id: 'custom', + service: '', + custom: true, + Icon: null, + }, +] + +/** + * Normalize a custom PDS address into a full service URL. + */ +export function normalizePdsUrl(input: string): string { + let url = input.trim().toLowerCase() + if (!url) return '' + if (!url.startsWith('http://') && !url.startsWith('https://')) { + if (url === 'localhost' || url.startsWith('localhost:')) { + url = `http://${url}` + } else { + url = `https://${url}` + } + } + return url +} diff --git a/src/screens/Signup/startOauthSignup.ts b/src/screens/Signup/startOauthSignup.ts new file mode 100644 index 000000000..bc47d8465 --- /dev/null +++ b/src/screens/Signup/startOauthSignup.ts @@ -0,0 +1,34 @@ +import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' +import {useSessionApi} from '#/state/session' +import {getNativeOAuthClient} from '#/state/session/oauth-native-client' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' + +/** + * Starts an OAuth authorization flow with `prompt: create` against the given + * PDS so the user can create an account on that provider's account UI. + */ +export function useStartOauthSignup() { + const {login} = useSessionApi() + const requestNotificationsPermission = useRequestNotificationsPermission() + const {setShowLoggedOut, clearRequestedAccount} = useLoggedOutViewControls() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() + + return async (serviceUrl: string) => { + const client = getNativeOAuthClient() + const session = await client.signIn(serviceUrl, {prompt: 'create'}) + await login( + { + service: '', + identifier: '', + password: '', + oauthSession: session, + }, + 'LoginForm', + ) + setShowLoggedOut(false) + clearRequestedAccount() + setHasCheckedForStarterPack(true) + void requestNotificationsPermission('Login') + } +} diff --git a/src/screens/Signup/startOauthSignup.web.ts b/src/screens/Signup/startOauthSignup.web.ts new file mode 100644 index 000000000..90b60cad6 --- /dev/null +++ b/src/screens/Signup/startOauthSignup.web.ts @@ -0,0 +1,16 @@ +import {getWebOAuthClient} from '#/state/session/oauth-web-client' +import {saveOAuthReturnUrl} from '#/state/session/oauth-web-return-url' + +/** + * Starts an OAuth authorization flow with `prompt: create` against the given + * PDS so the user can create an account on that provider's account UI. + * + * Redirects the browser to the authorization server. + */ +export function useStartOauthSignup() { + return async (serviceUrl: string) => { + saveOAuthReturnUrl() + const client = getWebOAuthClient() + await client.signIn(serviceUrl, {prompt: 'create'}) + } +}