From 983d05a9dff67a166d86ae14769c8aacaef03e10 Mon Sep 17 00:00:00 2001 From: Patricio Vicens Date: Tue, 7 May 2024 14:17:25 -0300 Subject: [PATCH] add farcaster loader mobile, types, and hide modal for email --- apps/mobile/src/components/BottomSheetRow.tsx | 5 +- .../Farcaster/FarcasterAuthProvider.tsx | 58 +++++++++++++++---- .../components/Login/SignInBottomSheet.tsx | 41 +++++++++++-- apps/mobile/src/navigation/types.ts | 3 + .../src/screens/Login/Onboarding2FAScreen.tsx | 4 +- .../screens/Login/OnboardingEmailScreen.tsx | 14 ++++- .../Onboarding/OnboardingUsernameScreen.tsx | 5 +- .../multichain/FarcasterLoginView.tsx | 1 + .../useLoginOrRedirectToOnboarding.ts | 1 + apps/web/src/contexts/modal/AnimatedModal.tsx | 8 ++- .../hooks/api/users/useAuthPayloadQuery.ts | 6 ++ .../Onboarding/OnboardingAddUsernamePage.tsx | 17 +++++- .../shared/src/hooks/useAuthPayloadQuery.ts | 1 + packages/shared/src/hooks/useCreateUser.ts | 4 +- schema.graphql | 2 + 15 files changed, 146 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/components/BottomSheetRow.tsx b/apps/mobile/src/components/BottomSheetRow.tsx index eb6cfc8038..33a5a3e631 100644 --- a/apps/mobile/src/components/BottomSheetRow.tsx +++ b/apps/mobile/src/components/BottomSheetRow.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; import { GalleryElementTrackingProps } from '~/shared/contexts/AnalyticsContext'; @@ -15,6 +15,7 @@ type BottomSheetRowProps = { fontWeight?: 'Regular' | 'Bold'; rightIcon?: React.ReactNode; eventContext: GalleryElementTrackingProps['eventContext']; + isLoading?: boolean; }; export function BottomSheetRow({ @@ -26,6 +27,7 @@ export function BottomSheetRow({ fontWeight = 'Regular', rightIcon, eventContext, + isLoading, }: BottomSheetRowProps) { return ( {rightIcon && {rightIcon}} + {isLoading && } ); diff --git a/apps/mobile/src/components/Login/AuthProvider/Farcaster/FarcasterAuthProvider.tsx b/apps/mobile/src/components/Login/AuthProvider/Farcaster/FarcasterAuthProvider.tsx index 1a8cb9b988..0121e12d19 100644 --- a/apps/mobile/src/components/Login/AuthProvider/Farcaster/FarcasterAuthProvider.tsx +++ b/apps/mobile/src/components/Login/AuthProvider/Farcaster/FarcasterAuthProvider.tsx @@ -34,7 +34,11 @@ export function FarcasterAuthProvider({ children }: { children: ReactNode }) { return {children}; } -export function useLoginWithFarcaster() { +type FarcasterBottomSheetRowProps = { + setIsFarcasterLoading: (isLoading: boolean) => void; +}; + +export function useLoginWithFarcaster({ setIsFarcasterLoading }: FarcasterBottomSheetRowProps) { const { hideBottomSheetModal } = useBottomSheetModalActions(); const getUsersByWalletAddresses = useGetUsersByWalletAddressesImperatively(); @@ -53,6 +57,7 @@ export function useLoginWithFarcaster() { const handleFarcasterLoginError = useCallback( (error?: AuthClientError | Error) => { + setIsFarcasterLoading(false); const errorMessage = error?.message ?? 'unknown error'; pushToast({ message: `There was an error signing in with Farcaster: ${errorMessage}`, @@ -64,11 +69,12 @@ export function useLoginWithFarcaster() { }); hasRedirectedToWarpcast.current = false; }, - [pushToast, reportError] + [pushToast, reportError, setIsFarcasterLoading] ); const handleSuccess = useCallback( async (req: StatusAPIResponse) => { + setIsFarcasterLoading(false); try { if (!req.custody) { throw new Error('no custody address produced from farcaster'); @@ -115,6 +121,7 @@ export function useLoginWithFarcaster() { navigation.navigate('OnboardingEmail', { authMethod: 'Farcaster', authMechanism: createUserAuthMechanism, + farcasterUsername: req.username, }); return; } @@ -129,6 +136,7 @@ export function useLoginWithFarcaster() { pubKey: req.custody, chain: 'Ethereum' as Chain, }, + farcasterUsername: req.username || '', }, }; if (req.verifications?.[0]) { @@ -152,6 +160,7 @@ export function useLoginWithFarcaster() { hideBottomSheetModal(); await navigateToNotificationUpsellOrHomeScreen(navigation); } catch (e) { + setIsFarcasterLoading(false); if (e instanceof Error) { handleFarcasterLoginError(e); } @@ -163,6 +172,7 @@ export function useLoginWithFarcaster() { hideBottomSheetModal, login, navigation, + setIsFarcasterLoading, track, ] ); @@ -182,19 +192,44 @@ export function useLoginWithFarcaster() { nonce, }); - const initiateConnection = useCallback(async () => { - if (!isConnected) { - const { nonce } = await createNonce(); - setNonce(nonce); + const initiateConnection = useCallback( + async (shouldAttemptReconnect = false) => { + setIsFarcasterLoading(true); - if (isConnectError) { + if (shouldAttemptReconnect && !isSuccess && url) { reconnect(); + const { nonce } = await createNonce(); + setNonce(nonce); + signIn(); + Linking.openURL(url); return; } - await connect(); - } - }, [connect, createNonce, isConnectError, isConnected, reconnect]); + if (!isConnected) { + const { nonce } = await createNonce(); + setNonce(nonce); + + if (isConnectError) { + reconnect(); + setIsFarcasterLoading(false); + return; + } + + await connect(); + } + }, + [ + connect, + createNonce, + isConnectError, + isConnected, + isSuccess, + reconnect, + setIsFarcasterLoading, + signIn, + url, + ] + ); useEffect(() => { if (url && nonce.length && !isPolling && !isSuccess && !hasRedirectedToWarpcast.current) { @@ -208,5 +243,8 @@ export function useLoginWithFarcaster() { return { open: initiateConnection, + isSuccess, + isPolling, + isConnected, }; } diff --git a/apps/mobile/src/components/Login/SignInBottomSheet.tsx b/apps/mobile/src/components/Login/SignInBottomSheet.tsx index 2a5ed0b1e7..41d27ef128 100644 --- a/apps/mobile/src/components/Login/SignInBottomSheet.tsx +++ b/apps/mobile/src/components/Login/SignInBottomSheet.tsx @@ -1,6 +1,6 @@ import { useNavigation } from '@react-navigation/native'; -import { forwardRef, useCallback } from 'react'; -import { View } from 'react-native'; +import { forwardRef, useCallback, useEffect, useState } from 'react'; +import { AppState, View } from 'react-native'; import { EmailIcon } from 'src/icons/EmailIcon'; import { FarcasterOutlineIcon } from 'src/icons/FarcasterOutlineIcon'; import { QRCodeIcon } from 'src/icons/QRCodeIcon'; @@ -87,13 +87,46 @@ function SignInBottomSheet({ onQrCodePress, openManageWallet }: Props) { } function FarcasterBottomSheetRow() { - const { open: handleConnectFarcaster } = useLoginWithFarcaster(); + const [isFarcasterLoading, setIsFarcasterLoading] = useState(false); + const [attemptReconnect, setAttemptReconnect] = useState(false); + + const { + open: handleConnectFarcaster, + isSuccess, + isConnected, + isPolling, + } = useLoginWithFarcaster({ + setIsFarcasterLoading, + }); + + const [appState, setAppState] = useState(AppState.currentState); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (appState.match(/inactive|background/) && nextAppState === 'active') { + if (isPolling || (!isConnected && !isSuccess)) { + setAttemptReconnect(true); + } + setIsFarcasterLoading(false); + } + setAppState(nextAppState); + }); + + return () => { + subscription.remove(); + }; + }, [appState, isPolling, isConnected, isSuccess]); + + const handleConnectFarcasterPress = useCallback(() => { + handleConnectFarcaster(attemptReconnect); + }, [attemptReconnect, handleConnectFarcaster]); return ( } text="Farcaster" - onPress={handleConnectFarcaster} + isLoading={isFarcasterLoading} + onPress={handleConnectFarcasterPress} eventContext={contexts.Authentication} fontWeight="Bold" /> diff --git a/apps/mobile/src/navigation/types.ts b/apps/mobile/src/navigation/types.ts index 2aac104578..13302fef09 100644 --- a/apps/mobile/src/navigation/types.ts +++ b/apps/mobile/src/navigation/types.ts @@ -130,6 +130,7 @@ export type LoginStackNavigatorParamList = { OnboardingEmail: { authMethod: AuthMethodTitle; authMechanism?: AuthPayloadVariables; + farcasterUsername?: string; }; Onboarding2FA: { @@ -137,12 +138,14 @@ export type LoginStackNavigatorParamList = { email: string; authMechanism: AuthPayloadVariables; loginWithCode: LoginWithEmailHookResult['loginWithCode']; + farcasterUsername?: string; }; OnboardingUsername: { authMechanism: AuthPayloadVariables; email?: string; authMethod: AuthMethodTitle; + farcasterUsername?: string; }; OnboardingProfileBio: undefined; diff --git a/apps/mobile/src/screens/Login/Onboarding2FAScreen.tsx b/apps/mobile/src/screens/Login/Onboarding2FAScreen.tsx index b258b0219a..5410b9d189 100644 --- a/apps/mobile/src/screens/Login/Onboarding2FAScreen.tsx +++ b/apps/mobile/src/screens/Login/Onboarding2FAScreen.tsx @@ -28,7 +28,7 @@ function InnerOnboardingEmailScreen() { const navigation = useNavigation(); const route = useRoute>(); - const { authMethod, authMechanism, email, loginWithCode } = route.params; + const { authMethod, authMechanism, email, loginWithCode, farcasterUsername } = route.params; const { bottom } = useSafeAreaInsets(); @@ -91,6 +91,7 @@ function InnerOnboardingEmailScreen() { privyToken: token, }, email, + farcasterUsername, }); } } else { @@ -120,6 +121,7 @@ function InnerOnboardingEmailScreen() { loginWithCode, navigation, track, + farcasterUsername, ]); const handleBack = useCallback(() => { diff --git a/apps/mobile/src/screens/Login/OnboardingEmailScreen.tsx b/apps/mobile/src/screens/Login/OnboardingEmailScreen.tsx index 05fd6051be..ae697bdead 100644 --- a/apps/mobile/src/screens/Login/OnboardingEmailScreen.tsx +++ b/apps/mobile/src/screens/Login/OnboardingEmailScreen.tsx @@ -44,6 +44,7 @@ function InnerOnboardingEmailScreen() { const authMethod = route.params.authMethod; const authMechanism = route.params.authMechanism; + const farcasterUsername = route.params.farcasterUsername; const { bottom } = useSafeAreaInsets(); @@ -129,6 +130,7 @@ function InnerOnboardingEmailScreen() { authMechanism, loginWithCode, email, + farcasterUsername, }); } } catch (error) { @@ -141,7 +143,17 @@ function InnerOnboardingEmailScreen() { } finally { setIsLoggingIn(false); } - }, [authMechanism, authMethod, email, loginWithCode, navigation, reportError, sendCode, track]); + }, [ + authMechanism, + authMethod, + email, + loginWithCode, + navigation, + reportError, + sendCode, + track, + farcasterUsername, + ]); const handleBack = useCallback(() => { navigation.goBack(); diff --git a/apps/mobile/src/screens/Onboarding/OnboardingUsernameScreen.tsx b/apps/mobile/src/screens/Onboarding/OnboardingUsernameScreen.tsx index d049ee3627..8364981788 100644 --- a/apps/mobile/src/screens/Onboarding/OnboardingUsernameScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/OnboardingUsernameScreen.tsx @@ -75,8 +75,11 @@ function InnerOnboardingUsernameScreen() { const email = route.params.email; const authMethod = route.params.authMethod; const authMechanism = route.params.authMechanism; + const farcasterUsername = route.params.farcasterUsername; - const [username, setUsername] = useState(user?.username ?? ''); + const [username, setUsername] = useState( + farcasterUsername ? farcasterUsername : user?.username ?? '' + ); const [bio] = useState(''); // This cannot be derived from a "null" `usernameError` diff --git a/apps/web/src/components/WalletSelector/multichain/FarcasterLoginView.tsx b/apps/web/src/components/WalletSelector/multichain/FarcasterLoginView.tsx index 10f20de49e..6c6c736f56 100644 --- a/apps/web/src/components/WalletSelector/multichain/FarcasterLoginView.tsx +++ b/apps/web/src/components/WalletSelector/multichain/FarcasterLoginView.tsx @@ -115,6 +115,7 @@ export function FarcasterLoginView() { pubKey: primaryFarcasterAddress, chain: 'Ethereum' as Chain, }, + farcasterUsername: req.username || '', }, }; diff --git a/apps/web/src/components/WalletSelector/mutations/useLoginOrRedirectToOnboarding.ts b/apps/web/src/components/WalletSelector/mutations/useLoginOrRedirectToOnboarding.ts index 9e22ab5b88..35abbad327 100644 --- a/apps/web/src/components/WalletSelector/mutations/useLoginOrRedirectToOnboarding.ts +++ b/apps/web/src/components/WalletSelector/mutations/useLoginOrRedirectToOnboarding.ts @@ -111,6 +111,7 @@ export default function useLoginOrRedirectToOnboarding() { message: authMechanism.mechanism.neynar.message, signature: authMechanism.mechanism.neynar.signature, userFriendlyWalletName, + farcasterUsername: authMechanism.mechanism.neynar.farcasterUsername, }, }, '/onboarding/add-email' diff --git a/apps/web/src/contexts/modal/AnimatedModal.tsx b/apps/web/src/contexts/modal/AnimatedModal.tsx index ad9217c9eb..e65c98265c 100644 --- a/apps/web/src/contexts/modal/AnimatedModal.tsx +++ b/apps/web/src/contexts/modal/AnimatedModal.tsx @@ -1,3 +1,4 @@ +import { usePrivy } from '@privy-io/react-auth'; import { ReactElement, useCallback, useEffect, useMemo } from 'react'; import styled, { css, keyframes } from 'styled-components'; @@ -92,10 +93,12 @@ function AnimatedModal({ hideModal(); }, [onCloseOverride, hideModal]); + const { isModalOpen } = usePrivy(); + return ( <_ToggleFade isActive={isActive}> - + <_ToggleTranslate isActive={isActive}> @@ -172,7 +175,8 @@ const Overlay = styled.div` -webkit-backface-visibility: hidden; `; -const StyledModal = styled.div<{ isFullPage: boolean }>` +const StyledModal = styled.div<{ isFullPage: boolean; isPrivyModalOpened?: boolean }>` + opacity: ${(props) => (props.isPrivyModalOpened ? '0' : '1')}; position: fixed; top: 50%; left: 50%; diff --git a/apps/web/src/hooks/api/users/useAuthPayloadQuery.ts b/apps/web/src/hooks/api/users/useAuthPayloadQuery.ts index 7ef3222725..9289fbdecd 100644 --- a/apps/web/src/hooks/api/users/useAuthPayloadQuery.ts +++ b/apps/web/src/hooks/api/users/useAuthPayloadQuery.ts @@ -14,6 +14,10 @@ export function isEoaPayload(payload: AuthPayloadVariables): payload is EoaPaylo return payload.authMechanismType === 'eoa'; } +export function isNeynarPayload(payload: AuthPayloadVariables): payload is NeynarPayloadVariables { + return 'authMechanismType' in payload && payload.authMechanismType === 'neynar'; +} + export default function useAuthPayloadQuery(): AuthPayloadVariables | null { const { query } = useRouter(); @@ -71,6 +75,8 @@ export default function useAuthPayloadQuery(): AuthPayloadVariables | null { address: query.address, primaryAddress: query.primaryAddress, email: typeof query.email === 'string' ? query.email : undefined, + farcasterUsername: + typeof query.farcasterUsername === 'string' ? query.farcasterUsername : undefined, }; } diff --git a/apps/web/src/scenes/Onboarding/OnboardingAddUsernamePage.tsx b/apps/web/src/scenes/Onboarding/OnboardingAddUsernamePage.tsx index 4e971b3097..98e7eb4023 100644 --- a/apps/web/src/scenes/Onboarding/OnboardingAddUsernamePage.tsx +++ b/apps/web/src/scenes/Onboarding/OnboardingAddUsernamePage.tsx @@ -17,7 +17,7 @@ import { OnboardingFooter } from '~/components/Onboarding/OnboardingFooter'; import { useTrackCreateUserSuccess } from '~/contexts/analytics/authUtil'; import { OnboardingAddUsernamePageQuery } from '~/generated/OnboardingAddUsernamePageQuery.graphql'; import useSyncTokens from '~/hooks/api/tokens/useSyncTokens'; -import useAuthPayloadQuery from '~/hooks/api/users/useAuthPayloadQuery'; +import useAuthPayloadQuery, { isNeynarPayload } from '~/hooks/api/users/useAuthPayloadQuery'; import { alphanumericUnderscores, maxLength, @@ -54,15 +54,26 @@ export function OnboardingAddUsernamePage() { ); const user = query?.viewer?.user; + const authPayloadQuery = useAuthPayloadQuery(); + + const initialUsername = useMemo(() => { + if ( + authPayloadQuery && + isNeynarPayload(authPayloadQuery) && + authPayloadQuery.farcasterUsername + ) { + return authPayloadQuery.farcasterUsername; + } + return user?.username || ''; + }, [authPayloadQuery, user]); - const [username, setUsername] = useState(user?.username || ''); + const [username, setUsername] = useState(initialUsername); const debouncedUsername = useDebounce(username, 500); const [usernameError, setUsernameError] = useState(''); const isUsernameAvailableFetcher = useIsUsernameAvailableFetcher(); const createUser = useCreateUser(); const updateUser = useUpdateUser(); - const authPayloadQuery = useAuthPayloadQuery(); const trackCreateUserSuccess = useTrackCreateUserSuccess( authPayloadQuery?.userFriendlyWalletName ); diff --git a/packages/shared/src/hooks/useAuthPayloadQuery.ts b/packages/shared/src/hooks/useAuthPayloadQuery.ts index 986a24aec5..4bec419650 100644 --- a/packages/shared/src/hooks/useAuthPayloadQuery.ts +++ b/packages/shared/src/hooks/useAuthPayloadQuery.ts @@ -33,6 +33,7 @@ export type NeynarPayloadVariables = { privyToken?: string; email?: string; userFriendlyWalletName?: string; + farcasterUsername?: string; } & SignerVariables; export type AuthPayloadVariables = diff --git a/packages/shared/src/hooks/useCreateUser.ts b/packages/shared/src/hooks/useCreateUser.ts index 2167f6cfa1..b3bb317247 100644 --- a/packages/shared/src/hooks/useCreateUser.ts +++ b/packages/shared/src/hooks/useCreateUser.ts @@ -168,7 +168,8 @@ export function getAuthMechanismFromAuthPayload(authPayloadVariables: AuthPayloa }, }; } else if (authPayloadVariables.authMechanismType === 'neynar') { - const { address, nonce, message, signature, primaryAddress } = authPayloadVariables; + const { address, nonce, message, signature, primaryAddress, farcasterUsername } = + authPayloadVariables; authMechanism = { neynar: { nonce, @@ -178,6 +179,7 @@ export function getAuthMechanismFromAuthPayload(authPayloadVariables: AuthPayloa pubKey: address, chain: 'Ethereum', }, + farcasterUsername: farcasterUsername || '', }, }; if (primaryAddress) { diff --git a/schema.graphql b/schema.graphql index 0130bea5f7..f515f01e87 100644 --- a/schema.graphql +++ b/schema.graphql @@ -143,6 +143,7 @@ input AuthMechanism { oneTimeLoginToken: OneTimeLoginTokenAuth privy: PrivyAuth neynar: NeynarAuth + farcasterUsername: String } type AuthNonce { @@ -1504,6 +1505,7 @@ input NeynarAuth { that both primaryPubKey and the required custodyPubKey are owned by the same Neynar user """ primaryPubKey: ChainPubKeyInput + farcasterUsername: String! } interface Node {