diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -934,6 +934,7 @@ siweMessage: string, siweSignature: string, fid: ?string, + farcasterDCsToken: ?string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; diff --git a/lib/types/siwe-types.js b/lib/types/siwe-types.js --- a/lib/types/siwe-types.js +++ b/lib/types/siwe-types.js @@ -128,6 +128,7 @@ +message: string, +signature: string, +fid?: ?string, + +farcasterDCsToken?: ?string, }; export const SIWEMessageTypes = Object.freeze({ diff --git a/lib/utils/services-utils.js b/lib/utils/services-utils.js --- a/lib/utils/services-utils.js +++ b/lib/utils/services-utils.js @@ -14,6 +14,8 @@ // an authoritative keyserver for things like DMs. const relyingOnAuthoritativeKeyserver = true; +const supportsFarcasterDCs = false; + // If this returns true, then we're using the login 2.0, which means that a user // can either restore an account (primary login) or log in using the QR code // (secondary login). @@ -64,4 +66,5 @@ httpResponseIsInvalidCSAT, errorMessageIsInvalidCSAT, fullBackupSupport, + supportsFarcasterDCs, }; diff --git a/native/.eslintrc.json b/native/.eslintrc.json --- a/native/.eslintrc.json +++ b/native/.eslintrc.json @@ -2,6 +2,9 @@ "env": { "react-native/react-native": true }, + "globals": { + "URLSearchParams": "readonly" + }, "plugins": ["react-native"], "rules": { "react-native/no-unused-styles": 2, diff --git a/native/account/registration/auth-navigator.react.js b/native/account/registration/auth-navigator.react.js --- a/native/account/registration/auth-navigator.react.js +++ b/native/account/registration/auth-navigator.react.js @@ -25,6 +25,7 @@ } from './auth-router.js'; import AvatarSelection from './avatar-selection.react.js'; import ConnectEthereum from './connect-ethereum.react.js'; +import { ConnectFarcasterDCs } from './connect-farcaster-dcs.react.js'; import ConnectFarcaster from './connect-farcaster.react.js'; import CoolOrNerdModeSelection from './cool-or-nerd-mode-selection.react.js'; import EmojiAvatarSelection from './emoji-avatar-selection.react.js'; @@ -59,6 +60,7 @@ RestoreBackupScreenRouteName, RestoreBackupErrorScreenRouteName, RestoreSIWEBackupRouteName, + ConnectFarcasterDCsRouteName, } from '../../navigation/route-names.js'; import QRCodeScreen from '../qr-code-screen.react.js'; import RestoreBackupErrorScreen from '../restore-backup-error-screen.react.js'; @@ -234,6 +236,10 @@ name={RestoreSIWEBackupRouteName} component={RestoreSIWEBackup} /> + ); } diff --git a/native/account/registration/avatar-selection.react.js b/native/account/registration/avatar-selection.react.js --- a/native/account/registration/avatar-selection.react.js +++ b/native/account/registration/avatar-selection.react.js @@ -41,6 +41,7 @@ +accountSelection: AccountSelection, +farcasterID: ?string, +farcasterAvatarURL: ?string, + +farcasterDCsToken: ?string, }, }; diff --git a/native/account/registration/connect-ethereum.react.js b/native/account/registration/connect-ethereum.react.js --- a/native/account/registration/connect-ethereum.react.js +++ b/native/account/registration/connect-ethereum.react.js @@ -48,6 +48,7 @@ +keyserverURL?: ?string, +farcasterID: ?string, +farcasterAvatarURL: ?string, + +farcasterDCsToken: ?string, }, }; diff --git a/native/account/registration/connect-farcaster-dcs.react.js b/native/account/registration/connect-farcaster-dcs.react.js new file mode 100644 --- /dev/null +++ b/native/account/registration/connect-farcaster-dcs.react.js @@ -0,0 +1,246 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import type { AuthNavigationProp } from './auth-navigator.react.js'; +import { siweNonceExpired } from './ethereum-utils.js'; +import { RegistrationContext } from './registration-context.js'; +import RegistrationTextInput from './registration-text-input.react.js'; +import type { CoolOrNerdMode } from './registration-types.js'; +import FarcasterPrompt from '../../components/farcaster-prompt.react.js'; +import PrimaryButton from '../../components/primary-button.react.js'; +import { FarcasterAuthContextProvider } from '../../farcaster-auth/farcaster-auth-context-provider.react.js'; +import { useGetAuthToken } from '../../farcaster-auth/farcaster-auth-utils.js'; +import type { NavigationRoute } from '../../navigation/route-names.js'; +import { + AvatarSelectionRouteName, + ConnectEthereumRouteName, +} from '../../navigation/route-names.js'; +import { useStyles } from '../../themes/colors.js'; +import Alert from '../../utils/alert.js'; +import AuthButtonContainer from '../auth-components/auth-button-container.react.js'; +import AuthContainer from '../auth-components/auth-container.react.js'; +import AuthContentContainer from '../auth-components/auth-content-container.react.js'; + +export type ConnectFarcasterDCsParams = { + +userSelections: { + +coolOrNerdMode?: ?CoolOrNerdMode, + +keyserverURL?: ?string, + +farcasterID: string, + +farcasterAvatarURL: ?string, + }, +}; + +type Props = { + +navigation: AuthNavigationProp<'ConnectFarcasterDCs'>, + +route: NavigationRoute<'ConnectFarcasterDCs'>, +}; + +function InnerConnectFarcasterDCs(props: Props): React.Node { + const { navigation, route } = props; + + const { navigate } = navigation; + const userSelections = route.params?.userSelections; + + const [mnemonic, setMnemonic] = React.useState(null); + + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { + cachedSelections, + setCachedSelections, + skipEthereumLoginOnce, + setSkipEthereumLoginOnce, + } = registrationContext; + + const { ethereumAccount } = cachedSelections; + const goToNextStep = React.useCallback( + (farcasterDCsToken?: ?string) => { + invariant( + !ethereumAccount || ethereumAccount.nonceTimestamp, + 'nonceTimestamp must be set after connecting to Ethereum account', + ); + const nonceExpired = + ethereumAccount && + ethereumAccount.nonceTimestamp && + siweNonceExpired(ethereumAccount.nonceTimestamp); + if (nonceExpired) { + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + ethereumAccount: undefined, + })); + } + + if (!skipEthereumLoginOnce || !ethereumAccount || nonceExpired) { + navigate<'ConnectEthereum'>({ + name: ConnectEthereumRouteName, + params: { + userSelections: { + ...userSelections, + farcasterDCsToken, + }, + }, + }); + return; + } + + const newUserSelections = { + ...userSelections, + accountSelection: ethereumAccount, + farcasterDCsToken, + }; + setSkipEthereumLoginOnce(false); + navigate<'AvatarSelection'>({ + name: AvatarSelectionRouteName, + params: { userSelections: newUserSelections }, + }); + }, + [ + ethereumAccount, + navigate, + setCachedSelections, + setSkipEthereumLoginOnce, + skipEthereumLoginOnce, + userSelections, + ], + ); + + const onSkip = React.useCallback(() => { + if (cachedSelections.farcasterDCsToken) { + setCachedSelections(({ farcasterDCsToken, ...rest }) => rest); + } + goToNextStep(); + }, [cachedSelections.farcasterDCsToken, goToNextStep, setCachedSelections]); + + const getAuthToken = useGetAuthToken(); + const [signingInProgress, setSigningInProgress] = React.useState(false); + const onConnect = React.useCallback(async () => { + if (!mnemonic) { + goToNextStep(); + return; + } + + setSigningInProgress(true); + try { + const token = await getAuthToken(userSelections.farcasterID, mnemonic); + setCachedSelections(oldUserSelections => ({ + farcasterDCsToken: token, + ...oldUserSelections, + })); + goToNextStep(token); + } catch (e) { + Alert.alert( + 'Failed to connect', + 'Failed to connect to Farcaster Direct Casts. Please try again later.', + ); + } + setSigningInProgress(false); + }, [ + getAuthToken, + goToNextStep, + mnemonic, + setCachedSelections, + userSelections.farcasterID, + ]); + + let buttonVariant = 'enabled'; + if (!mnemonic || cachedSelections.farcasterDCsToken) { + buttonVariant = 'disabled'; + } else if (signingInProgress) { + buttonVariant = 'loading'; + } + + const onUseAlreadyConnectedAccount = React.useCallback(() => { + goToNextStep(cachedSelections.farcasterDCsToken); + }, [cachedSelections.farcasterDCsToken, goToNextStep]); + const alreadyConnected = !!cachedSelections.farcasterDCsToken; + let alreadyConnectedButton = null; + if (alreadyConnected) { + alreadyConnectedButton = ( + + ); + } + + const onChangeText = React.useCallback( + (text: string) => { + setMnemonic(text); + setCachedSelections(({ farcasterDCsToken, ...rest }) => rest); + }, + [setCachedSelections], + ); + + const styles = useStyles(unboundStyles); + return React.useMemo( + () => ( + + + + + + + {alreadyConnectedButton} + + + + + ), + [ + alreadyConnectedButton, + buttonVariant, + mnemonic, + onChangeText, + onConnect, + onSkip, + signingInProgress, + styles.scrollViewContentContainer, + ], + ); +} + +const unboundStyles = { + scrollViewContentContainer: { + flexGrow: 1, + }, + description: { + fontFamily: 'Arial', + fontSize: 15, + lineHeight: 20, + color: 'panelForegroundSecondaryLabel', + paddingBottom: 16, + }, +}; + +function ConnectFarcasterDCs(props: Props): React.Node { + return ( + + + + ); +} + +export { ConnectFarcasterDCs }; diff --git a/native/account/registration/connect-farcaster.react.js b/native/account/registration/connect-farcaster.react.js --- a/native/account/registration/connect-farcaster.react.js +++ b/native/account/registration/connect-farcaster.react.js @@ -7,6 +7,7 @@ import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; import type { BaseFCAvatarInfo } from 'lib/utils/farcaster-helpers.js'; +import { supportsFarcasterDCs } from 'lib/utils/services-utils.js'; import type { AuthNavigationProp } from './auth-navigator.react.js'; import { siweNonceExpired } from './ethereum-utils.js'; @@ -64,6 +65,21 @@ const goToNextStep = React.useCallback( (fid?: ?string, farcasterAvatarURL: ?string) => { setWebViewState('closed'); + + if (fid && supportsFarcasterDCs) { + navigate<'ConnectFarcasterDCs'>({ + name: 'ConnectFarcasterDCs', + params: { + userSelections: { + ...userSelections, + farcasterID: fid, + farcasterAvatarURL: farcasterAvatarURL, + }, + }, + }); + return; + } + invariant( !ethereumAccount || ethereumAccount.nonceTimestamp, 'nonceTimestamp must be set after connecting to Ethereum account', @@ -87,6 +103,7 @@ ...userSelections, farcasterID: fid, farcasterAvatarURL: farcasterAvatarURL, + farcasterDCsToken: null, }, }, }); @@ -98,6 +115,7 @@ farcasterID: fid, accountSelection: ethereumAccount, farcasterAvatarURL: farcasterAvatarURL, + farcasterDCsToken: null, }; setSkipEthereumLoginOnce(false); navigate<'AvatarSelection'>({ @@ -116,14 +134,20 @@ ); const onSkip = React.useCallback(() => { - if (cachedSelections.farcasterID || cachedSelections.farcasterAvatarURL) { + if ( + cachedSelections.farcasterID || + cachedSelections.farcasterAvatarURL || + cachedSelections.farcasterDCsToken + ) { setCachedSelections( - ({ farcasterID, farcasterAvatarURL, ...rest }) => rest, + ({ farcasterID, farcasterAvatarURL, farcasterDCsToken, ...rest }) => + rest, ); } goToNextStep(); }, [ cachedSelections.farcasterAvatarURL, + cachedSelections.farcasterDCsToken, cachedSelections.farcasterID, goToNextStep, setCachedSelections, diff --git a/native/account/registration/password-selection.react.js b/native/account/registration/password-selection.react.js --- a/native/account/registration/password-selection.react.js +++ b/native/account/registration/password-selection.react.js @@ -27,7 +27,8 @@ +keyserverURL?: ?string, +farcasterID: ?string, +username: string, - farcasterAvatarURL: ?string, + +farcasterAvatarURL: ?string, + +farcasterDCsToken: ?string, }, }; diff --git a/native/account/registration/registration-server-call.js b/native/account/registration/registration-server-call.js --- a/native/account/registration/registration-server-call.js +++ b/native/account/registration/registration-server-call.js @@ -150,6 +150,7 @@ farcasterID: ?string, onNonceExpired: () => mixed, onAlertAcknowledged: ?() => mixed, + farcasterDCsToken: ?string, ) => { try { await identityWalletRegisterCall({ @@ -157,6 +158,7 @@ message: accountSelection.message, signature: accountSelection.signature, fid: farcasterID, + farcasterDCsToken, }); } catch (e) { const messageForException = getMessageForException(e); @@ -203,6 +205,7 @@ clearCachedSelections, onNonceExpired, onAlertAcknowledged, + farcasterDCsToken, } = input; if (accountSelection.accountType === 'username') { await identityRegisterUsernameAccount( @@ -216,6 +219,7 @@ farcasterID, onNonceExpired, onAlertAcknowledged, + farcasterDCsToken, ); } if (passedKeyserverURL) { diff --git a/native/account/registration/registration-terms.react.js b/native/account/registration/registration-terms.react.js --- a/native/account/registration/registration-terms.react.js +++ b/native/account/registration/registration-terms.react.js @@ -32,6 +32,7 @@ +avatarData: ?AvatarData, +siweBackupSecrets?: ?SignedMessage, +farcasterAvatarURL: ?string, + +farcasterDCsToken: ?string, }, }; @@ -63,8 +64,13 @@ const { navigation } = props; const { reconnectEthereum } = navigation; - const { coolOrNerdMode, keyserverURL, farcasterID, farcasterAvatarURL } = - userSelections; + const { + coolOrNerdMode, + keyserverURL, + farcasterID, + farcasterAvatarURL, + farcasterDCsToken, + } = userSelections; const navigateToConnectEthereum = React.useCallback(() => { reconnectEthereum({ userSelections: { @@ -72,6 +78,7 @@ keyserverURL, farcasterID, farcasterAvatarURL, + farcasterDCsToken, }, }); }, [ @@ -80,6 +87,7 @@ keyserverURL, farcasterID, farcasterAvatarURL, + farcasterDCsToken, ]); const onNonceExpired = React.useCallback(() => { setCachedSelections(oldUserSelections => ({ diff --git a/native/account/registration/registration-types.js b/native/account/registration/registration-types.js --- a/native/account/registration/registration-types.js +++ b/native/account/registration/registration-types.js @@ -48,6 +48,7 @@ +clearCachedSelections: () => void, +onNonceExpired: () => mixed, +onAlertAcknowledged?: () => mixed, + +farcasterDCsToken: ?string, }; export type CachedUserSelections = { @@ -60,6 +61,7 @@ +farcasterID?: string, +siweBackupSecrets?: ?SignedMessage, +farcasterAvatarURL?: ?string, + +farcasterDCsToken?: ?string, }; export const ensAvatarSelection: AvatarData = { diff --git a/native/account/registration/siwe-backup-message-creation.react.js b/native/account/registration/siwe-backup-message-creation.react.js --- a/native/account/registration/siwe-backup-message-creation.react.js +++ b/native/account/registration/siwe-backup-message-creation.react.js @@ -141,6 +141,7 @@ +accountSelection: AccountSelection, +avatarData: ?AvatarData, +farcasterAvatarURL: ?string, + +farcasterDCsToken: ?string, }, }; diff --git a/native/account/registration/username-selection.react.js b/native/account/registration/username-selection.react.js --- a/native/account/registration/username-selection.react.js +++ b/native/account/registration/username-selection.react.js @@ -28,6 +28,7 @@ +keyserverURL?: ?string, +farcasterID: ?string, +farcasterAvatarURL: ?string, + +farcasterDCsToken: ?string, }, }; diff --git a/native/account/siwe-hooks.js b/native/account/siwe-hooks.js --- a/native/account/siwe-hooks.js +++ b/native/account/siwe-hooks.js @@ -13,12 +13,13 @@ const identityWalletRegister = useIdentityWalletRegister(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( - async ({ address, message, signature, fid }) => { + async ({ address, message, signature, fid, farcasterDCsToken }) => { const siwePromise = identityWalletRegister( address, message, signature, fid, + farcasterDCsToken, ); void dispatchActionPromise(identityRegisterActionTypes, siwePromise); diff --git a/native/components/farcaster-prompt.react.js b/native/components/farcaster-prompt.react.js --- a/native/components/farcaster-prompt.react.js +++ b/native/components/farcaster-prompt.react.js @@ -1,58 +1,82 @@ // @flow import * as React from 'react'; -import { View, Text } from 'react-native'; +import { Text, View } from 'react-native'; import { useStyles } from '../themes/colors.js'; import FarcasterLogo from '../vectors/farcaster-logo.react.js'; -type TextType = 'connect' | 'disconnect'; +type TextType = 'connect' | 'disconnect' | 'connect_DC'; type Props = { +textType: TextType, }; +const prompts = { + connect: { + headerText: 'Do you want to connect your Farcaster account?', + bodyTexts: [ + 'Connecting your Farcaster account lets us bootstrap your social ' + + 'graph. We’ll also surface communities based on your Farcaster ' + + 'channels.', + ], + displayLogo: true, + }, + disconnect: { + headerText: 'Disconnect from Farcaster', + bodyTexts: ['You can disconnect your Farcaster account at any time.'], + displayLogo: true, + }, + connect_DC: { + headerText: 'Do you want to connect your Farcaster Direct Casts?', + bodyTexts: [ + 'If you share your Farcaster custody mnemonic below, you’ll be able to ' + + 'send and receive Direct Cast messages using Comm.', + 'You can find it in the Farcaster app within Settings → Advanced → ' + + 'Show Farcaster recovery phrase.', + 'Your mnemonic phrase is only used locally and is not sent to our ' + + 'servers.', + ], + displayLogo: false, + }, +}; + function FarcasterPrompt(props: Props): React.Node { const { textType } = props; - let headerText; - if (textType === 'disconnect') { - headerText = 'Disconnect from Farcaster'; - } else { - headerText = 'Do you want to connect your Farcaster account?'; - } + const { headerText, bodyTexts, displayLogo } = prompts[textType]; - let bodyText; - if (textType === 'disconnect') { - bodyText = 'You can disconnect your Farcaster account at any time.'; - } else { - bodyText = - 'Connecting your Farcaster account lets us bootstrap your social ' + - 'graph. We’ll also surface communities based on your Farcaster ' + - 'channels.'; + const styles = useStyles(unboundStyles); + + let farcasterLogo = null; + if (displayLogo) { + farcasterLogo = ( + + + + ); } - const styles = useStyles(unboundStyles); - const farcasterPrompt = React.useMemo( + const bodyText = React.useMemo( + () => + bodyTexts.map((text, id) => ( + + {text} + + )), + [bodyTexts, styles.body], + ); + + return React.useMemo( () => ( <> {headerText} - {bodyText} - - - + {bodyText} + {farcasterLogo} ), - [ - bodyText, - headerText, - styles.body, - styles.farcasterLogoContainer, - styles.header, - ], + [bodyText, farcasterLogo, headerText, styles.header], ); - - return farcasterPrompt; } const unboundStyles = { diff --git a/native/farcaster-auth/farcaster-auth-utils.js b/native/farcaster-auth/farcaster-auth-utils.js new file mode 100644 --- /dev/null +++ b/native/farcaster-auth/farcaster-auth-utils.js @@ -0,0 +1,41 @@ +// @flow + +import * as React from 'react'; + +import { useSignFarcasterAuthMessage } from 'lib/components/farcaster-auth-context.js'; + +function useGetAuthToken(): ( + fid: string, + walletMnemonic: string, +) => Promise { + const signAuthMessage = useSignFarcasterAuthMessage(); + + return React.useCallback( + async (fid: string, walletMnemonic: string) => { + const nonceResponse = await fetch( + 'https://client.farcaster.xyz/v2/get-dc-nonce', + ); + const nonceData = await nonceResponse.json(); + const nonce = nonceData.result.nonce; + + const signResult = await signAuthMessage({ + nonce, + fid, + walletMnemonic, + }); + + const params = new URLSearchParams({ + message: signResult.message, + signature: signResult.signature, + }); + const tokenResponse = await fetch( + `https://client.farcaster.xyz/v2/get-dc-auth-token?${params.toString()}`, + ); + const tokenData = await tokenResponse.json(); + return tokenData.result.token; + }, + [signAuthMessage], + ); +} + +export { useGetAuthToken }; diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -8,6 +8,7 @@ import type { ConnectSecondaryDeviceParams } from '../account/qr-auth/connect-secondary-device.react.js'; import type { AvatarSelectionParams } from '../account/registration/avatar-selection.react.js'; import type { ConnectEthereumParams } from '../account/registration/connect-ethereum.react.js'; +import type { ConnectFarcasterDCsParams } from '../account/registration/connect-farcaster-dcs.react.js'; import type { ConnectFarcasterParams } from '../account/registration/connect-farcaster.react.js'; import type { EmojiAvatarSelectionParams } from '../account/registration/emoji-avatar-selection.react.js'; import type { ExistingEthereumAccountParams } from '../account/registration/existing-ethereum-account.react.js'; @@ -142,6 +143,7 @@ export const RestoreSIWEBackupRouteName = 'RestoreSIWEBackup'; export const ExistingEthereumAccountRouteName = 'ExistingEthereumAccount'; export const ConnectFarcasterRouteName = 'ConnectFarcaster'; +export const ConnectFarcasterDCsRouteName = 'ConnectFarcasterDCs'; export const UsernameSelectionRouteName = 'UsernameSelection'; export const CommunityCreationRouteName = 'CommunityCreation'; export const CommunityConfigurationRouteName = 'CommunityConfiguration'; @@ -327,6 +329,7 @@ +ConnectEthereum: ConnectEthereumParams, +ExistingEthereumAccount: ExistingEthereumAccountParams, +ConnectFarcaster: ConnectFarcasterParams, + +ConnectFarcasterDCs: ConnectFarcasterDCsParams, +CreateSIWEBackupMessage: CreateSIWEBackupMessageParams, +UsernameSelection: UsernameSelectionParams, +PasswordSelection: PasswordSelectionParams,