diff --git a/lib/types/synced-metadata-types.js b/lib/types/synced-metadata-types.js --- a/lib/types/synced-metadata-types.js +++ b/lib/types/synced-metadata-types.js @@ -8,6 +8,7 @@ const syncedMetadataNames = Object.freeze({ CURRENT_USER_FID: 'current_user_fid', + CURRENT_USER_SUPPORTS_DCS: 'current_user_supports_dcs', STORE_VERSION: 'store_version', ENABLED_APPS: 'enabled_apps', GLOBAL_THEME_INFO: 'global_theme_info', diff --git a/lib/utils/farcaster-utils.js b/lib/utils/farcaster-utils.js --- a/lib/utils/farcaster-utils.js +++ b/lib/utils/farcaster-utils.js @@ -3,14 +3,15 @@ import invariant from 'invariant'; import * as React from 'react'; +import { useSelector, useDispatch } from './redux-utils.js'; import { setSyncedMetadataEntryActionType } from '../actions/synced-metadata-actions.js'; import { useUserIdentityCache } from '../components/user-identity-cache.react.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { syncedMetadataNames } from '../types/synced-metadata-types.js'; -import { useSelector, useDispatch } from '../utils/redux-utils.js'; const DISABLE_CONNECT_FARCASTER_ALERT = false; const NO_FID_METADATA = 'NONE'; +const NO_DCS_SUPPORT_METADATA = 'NONE'; function useCurrentUserFID(): ?string { // There is a distinction between null & undefined for the fid value. @@ -31,6 +32,25 @@ return currentUserFID; } +function useCurrentUserSupportsDCs(): ?boolean { + // There is a distinction between null & undefined for the fid DCs value. + // If the fid DCs is null this means that the user has decided NOT to set + // a Farcaster DCs association. If the fid DCs is undefined this means that + // the user has not yet been prompted to set a Farcaster DCs association. + const currentUserFIDDCs = useSelector( + state => + state.syncedMetadataStore.syncedMetadata[ + syncedMetadataNames.CURRENT_USER_SUPPORTS_DCS + ] ?? undefined, + ); + + if (currentUserFIDDCs === NO_DCS_SUPPORT_METADATA) { + return null; + } + + return currentUserFIDDCs === 'true'; +} + function useSetLocalFID(): (fid: ?string) => void { const dispatch = useDispatch(); const { invalidateCacheForUser } = useUserIdentityCache(); @@ -55,6 +75,31 @@ ); } +function useSetLocalCurrentUserSupportsDCs(): (connected: ?boolean) => void { + const dispatch = useDispatch(); + const { invalidateCacheForUser } = useUserIdentityCache(); + const currentUserID = useSelector(state => state.currentUserInfo?.id); + return React.useCallback( + (connected: ?boolean) => { + // If we're unsetting the DCs support, we should set it to + // NO_DCS_SUPPORT_METADATA to avoid prompting the user for it again + const connectionStatus = + connected === null ? NO_DCS_SUPPORT_METADATA : String(connected); + dispatch({ + type: setSyncedMetadataEntryActionType, + payload: { + name: syncedMetadataNames.CURRENT_USER_SUPPORTS_DCS, + data: connectionStatus, + }, + }); + if (currentUserID) { + invalidateCacheForUser(currentUserID); + } + }, + [dispatch, currentUserID, invalidateCacheForUser], + ); +} + function useLinkFID(): (fid: string) => Promise { const identityClientContext = React.useContext(IdentityClientContext); invariant(identityClientContext, 'identityClientContext should be set'); @@ -81,11 +126,34 @@ const { unlinkFarcasterAccount } = identityClient; const setLocalFID = useSetLocalFID(); + const setLocalDCsSupport = useSetLocalCurrentUserSupportsDCs(); return React.useCallback(async () => { await unlinkFarcasterAccount(); setLocalFID(null); - }, [setLocalFID, unlinkFarcasterAccount]); + setLocalDCsSupport(null); + }, [setLocalFID, setLocalDCsSupport, unlinkFarcasterAccount]); +} + +function useLinkFarcasterDCs(): ( + fid: string, + farcasterDCsToken: string, +) => Promise { + const identityClientContext = React.useContext(IdentityClientContext); + invariant(identityClientContext, 'identityClientContext should be set'); + + const { identityClient } = identityClientContext; + const { linkFarcasterDCsAccount } = identityClient; + + const setLocalDCsSupport = useSetLocalCurrentUserSupportsDCs(); + + return React.useCallback( + async (fid: string, farcasterDCsToken: string) => { + await linkFarcasterDCsAccount(fid, farcasterDCsToken); + setLocalDCsSupport(true); + }, + [setLocalDCsSupport, linkFarcasterDCsAccount], + ); } function createFarcasterDCsAuthMessage(fid: string, nonce: string): string { @@ -112,9 +180,13 @@ export { DISABLE_CONNECT_FARCASTER_ALERT, NO_FID_METADATA, + NO_DCS_SUPPORT_METADATA, useCurrentUserFID, + useCurrentUserSupportsDCs, useSetLocalFID, + useSetLocalCurrentUserSupportsDCs, useLinkFID, useUnlinkFID, + useLinkFarcasterDCs, createFarcasterDCsAuthMessage, }; 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 @@ -17,7 +17,10 @@ type LogOutResult, } from 'lib/types/account-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; -import { useSetLocalFID } from 'lib/utils/farcaster-utils.js'; +import { + useSetLocalCurrentUserSupportsDCs, + useSetLocalFID, +} from 'lib/utils/farcaster-utils.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; @@ -189,6 +192,7 @@ const dispatch = useDispatch(); const setLocalFID = useSetLocalFID(); + const setLocalDCsSupport = useSetLocalCurrentUserSupportsDCs(); const returnedFunc = React.useCallback( (input: RegistrationServerCallInput) => new Promise( @@ -232,6 +236,7 @@ }); } setLocalFID(farcasterID); + setLocalDCsSupport(!!farcasterDCsToken); if (siweBackupSecrets) { await commCoreModule.setSIWEBackupSecrets(siweBackupSecrets); } @@ -261,6 +266,7 @@ dispatch, identityRegisterEthereumAccount, identityRegisterUsernameAccount, + setLocalDCsSupport, setLocalFID, ], ); 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 @@ -6,7 +6,11 @@ import { useStyles } from '../themes/colors.js'; import FarcasterLogo from '../vectors/farcaster-logo.react.js'; -type TextType = 'connect' | 'disconnect' | 'connect_DC'; +type TextType = + | 'connect' + | 'disconnect' + | 'disconnect_or_connect_DC' + | 'connect_DC'; type Props = { +textType: TextType, @@ -27,6 +31,11 @@ bodyTexts: ['You can disconnect your Farcaster account at any time.'], displayLogo: true, }, + disconnect_or_connect_DC: { + headerText: 'Farcaster account', + bodyTexts: ['Your Farcaster account is connected.'], + displayLogo: true, + }, connect_DC: { headerText: 'Do you want to connect your Farcaster Direct Casts?', bodyTexts: [ diff --git a/native/profile/connect-farcaster-dcs.react.js b/native/profile/connect-farcaster-dcs.react.js new file mode 100644 --- /dev/null +++ b/native/profile/connect-farcaster-dcs.react.js @@ -0,0 +1,151 @@ +// @flow + +import * as React from 'react'; +import { ScrollView, View } from 'react-native'; + +import { + useCurrentUserFID, + useLinkFarcasterDCs, +} from 'lib/utils/farcaster-utils.js'; + +import RegistrationTextInput from '../account/registration/registration-text-input.react.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 { useStyles } from '../themes/colors.js'; +import Alert from '../utils/alert.js'; + +type Props = { + +onSuccess: () => void, + +onCancel: () => void, +}; + +function InnerConnectFarcasterDCs(props: Props): React.Node { + const { onSuccess, onCancel } = props; + + const [mnemonic, setMnemonic] = React.useState(null); + const [signingInProgress, setSigningInProgress] = React.useState(false); + + const scrollViewRef = + React.useRef>(null); + + const fid = useCurrentUserFID(); + const getAuthToken = useGetAuthToken(); + const linkDCs = useLinkFarcasterDCs(); + + const onConnect = React.useCallback(async () => { + if (!mnemonic || !fid) { + return; + } + + setSigningInProgress(true); + try { + const token = await getAuthToken(fid, mnemonic); + await linkDCs(fid, token); + onSuccess(); + } catch (e) { + Alert.alert( + 'Failed to connect', + 'Failed to connect to Farcaster Direct Casts. Please try again later.', + ); + } + setSigningInProgress(false); + }, [getAuthToken, linkDCs, mnemonic, fid, onSuccess]); + + const onChangeMnemonicText = React.useCallback((text: string) => { + setMnemonic(text); + }, []); + + const onInputFocus = React.useCallback(() => { + // Scroll to make the input fully visible when focused + setTimeout(() => { + if (scrollViewRef.current) { + scrollViewRef.current.scrollToEnd({ animated: true }); + } + }, 100); + }, []); + + let buttonVariant = 'enabled'; + if (!mnemonic) { + buttonVariant = 'disabled'; + } else if (signingInProgress) { + buttonVariant = 'loading'; + } + + const styles = useStyles(unboundStyles); + + return ( + + + + + + + + + + + + + + + ); +} + +const unboundStyles = { + container: { + flex: 1, + backgroundColor: 'panelBackground', + paddingBottom: 16, + }, + scrollView: { + flex: 1, + }, + scrollViewContent: { + flexGrow: 1, + }, + contentContainer: { + flex: 1, + padding: 16, + }, + inputContainer: { + marginTop: 16, + }, + buttonContainer: { + marginVertical: 8, + marginHorizontal: 16, + }, +}; + +function ConnectFarcasterDCs(props: Props): React.Node { + return ( + + + + ); +} + +export default ConnectFarcasterDCs; diff --git a/native/profile/farcaster-account-settings.react.js b/native/profile/farcaster-account-settings.react.js --- a/native/profile/farcaster-account-settings.react.js +++ b/native/profile/farcaster-account-settings.react.js @@ -1,14 +1,20 @@ // @flow import * as React from 'react'; -import { View } from 'react-native'; +import { ScrollView, View } from 'react-native'; -import { useCurrentUserFID, useUnlinkFID } from 'lib/utils/farcaster-utils.js'; +import { + useCurrentUserFID, + useCurrentUserSupportsDCs, + useUnlinkFID, +} from 'lib/utils/farcaster-utils.js'; +import { supportsFarcasterDCs } from 'lib/utils/services-utils.js'; +import ConnectFarcasterDCs from './connect-farcaster-dcs.react.js'; import type { ProfileNavigationProp } from './profile.react.js'; import FarcasterPrompt from '../components/farcaster-prompt.react.js'; -import FarcasterWebView from '../components/farcaster-web-view.react.js'; import type { FarcasterWebViewState } from '../components/farcaster-web-view.react.js'; +import FarcasterWebView from '../components/farcaster-web-view.react.js'; import PrimaryButton from '../components/primary-button.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; @@ -24,6 +30,7 @@ // eslint-disable-next-line no-unused-vars function FarcasterAccountSettings(props: Props): React.Node { const fid = useCurrentUserFID(); + const currentUserSupportsDCs = useCurrentUserSupportsDCs(); const styles = useStyles(unboundStyles); @@ -47,6 +54,7 @@ const [webViewState, setWebViewState] = React.useState('closed'); + const [showConnectDCs, setShowConnectDCs] = React.useState(false); const [isLoadingLinkFID, setIsLoadingLinkFID] = React.useState(false); @@ -70,59 +78,111 @@ setWebViewState('opening'); }, []); + const onPressConnectDCs = React.useCallback(() => { + setShowConnectDCs(true); + }, []); + + const onConnectDCsSuccess = React.useCallback(() => { + setShowConnectDCs(false); + }, []); + + const onConnectDCsCancel = React.useCallback(() => { + setShowConnectDCs(false); + }, []); + const disconnectButtonVariant = isLoadingUnlinkFID ? 'loading' : 'outline'; const connectButtonVariant = isLoadingLinkFID ? 'loading' : 'enabled'; - const button = React.useMemo(() => { + const buttons = React.useMemo(() => { if (fid) { - return ( + const buttonList = [ - ); + />, + ]; + + if (supportsFarcasterDCs && !currentUserSupportsDCs) { + buttonList.unshift( + , + ); + } + + return buttonList; } - return ( + return [ - ); + />, + ]; }, [ connectButtonVariant, disconnectButtonVariant, fid, + currentUserSupportsDCs, onPressConnectFarcaster, onPressDisconnect, + onPressConnectDCs, ]); - const farcasterPromptTextType = fid ? 'disconnect' : 'connect'; - const farcasterAccountSettings = React.useMemo( - () => ( + const farcasterPromptTextType = React.useMemo(() => { + if (!fid) { + return 'connect'; + } + if (supportsFarcasterDCs && !currentUserSupportsDCs) { + return 'disconnect_or_connect_DC'; + } + return 'disconnect'; + }, [fid, currentUserSupportsDCs]); + + return React.useMemo(() => { + if (showConnectDCs) { + return ( + + ); + } + + return ( - + - + + {buttons} - {button} - ), - [ - button, - farcasterPromptTextType, - onSuccess, - styles.buttonContainer, - styles.connectContainer, - styles.promptContainer, - webViewState, - ], - ); - - return farcasterAccountSettings; + ); + }, [ + buttons, + farcasterPromptTextType, + onConnectDCsCancel, + onConnectDCsSuccess, + onSuccess, + showConnectDCs, + styles.buttonContainer, + styles.connectContainer, + styles.promptContainer, + styles.scrollViewContentContainer, + webViewState, + ]); } const unboundStyles = { @@ -130,16 +190,18 @@ flex: 1, backgroundColor: 'panelBackground', paddingBottom: 16, + justifyContent: 'space-between', }, promptContainer: { - flex: 1, padding: 16, - justifyContent: 'space-between', }, buttonContainer: { marginVertical: 8, marginHorizontal: 16, }, + scrollViewContentContainer: { + padding: 16, + }, }; export default FarcasterAccountSettings;