diff --git a/lib/components/farcaster-data-handler.react.js b/lib/components/farcaster-data-handler.react.js index 21a185246..e75b7c921 100644 --- a/lib/components/farcaster-data-handler.react.js +++ b/lib/components/farcaster-data-handler.react.js @@ -1,99 +1,99 @@ // @flow import * as React from 'react'; import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from '../actions/relationship-actions.js'; -import { FIDContext } from '../components/fid-provider.react.js'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import { cookieSelector } from '../selectors/keyserver-selectors.js'; import { isLoggedIn } from '../selectors/user-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { relationshipActions } from '../types/relationship-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; +import { useCurrentUserFID } from '../utils/farcaster-utils.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; function FarcasterDataHandler(): React.Node { const isActive = useSelector(state => state.lifecycleState !== 'background'); const hasCurrentUserInfo = useSelector(isLoggedIn); const cookie = useSelector(cookieSelector(authoritativeKeyserverID())); const hasUserCookie = !!(cookie && cookie.startsWith('user=')); const loggedIn = hasCurrentUserInfo && hasUserCookie; const neynarClient = React.useContext(NeynarClientContext)?.client; const identityServiceClient = React.useContext(IdentityClientContext); const getFarcasterUsers = identityServiceClient?.identityClient.getFarcasterUsers; const dispatchActionPromise = useDispatchActionPromise(); const updateRelationships = useLegacyAshoatKeyserverCall( serverUpdateRelationships, ); const createThreadsAndRobotextForFarcasterMutuals = React.useCallback( (userIDs: $ReadOnlyArray) => updateRelationships({ action: relationshipActions.FARCASTER_MUTUAL, userIDs, }), [updateRelationships], ); const userInfos = useSelector(state => state.userStore.userInfos); - const fid = React.useContext(FIDContext)?.fid; + const fid = useCurrentUserFID(); const prevCanQueryRef = React.useRef(); React.useEffect(() => { if (!usingCommServicesAccessToken) { return; } const canQuery = isActive && !!fid && loggedIn; if (canQuery === prevCanQueryRef.current) { return; } prevCanQueryRef.current = canQuery; if (!loggedIn || !isActive || !fid || !neynarClient || !getFarcasterUsers) { return; } void (async () => { const followerFIDs = await neynarClient.fetchFriendFIDs(fid); const commFCUsers = await getFarcasterUsers(followerFIDs); const commUserIDs = commFCUsers.map(({ userID }) => userID); const newCommUserIDs = commUserIDs.filter(userID => !userInfos[userID]); if (newCommUserIDs.length === 0) { return; } void dispatchActionPromise( updateRelationshipsActionTypes, createThreadsAndRobotextForFarcasterMutuals(newCommUserIDs), ); })(); }, [ isActive, fid, loggedIn, neynarClient, getFarcasterUsers, userInfos, dispatchActionPromise, createThreadsAndRobotextForFarcasterMutuals, ]); return null; } export { FarcasterDataHandler }; diff --git a/lib/utils/farcaster-utils.js b/lib/utils/farcaster-utils.js new file mode 100644 index 000000000..714283a45 --- /dev/null +++ b/lib/utils/farcaster-utils.js @@ -0,0 +1,15 @@ +// @flow + +import { syncedMetadataNames } from '../types/synced-metadata-types.js'; +import { useSelector } from '../utils/redux-utils.js'; + +function useCurrentUserFID(): ?string { + return useSelector( + state => + state.syncedMetadataStore.syncedMetadata[ + syncedMetadataNames.CURRENT_USER_FID + ] ?? null, + ); +} + +export { useCurrentUserFID }; diff --git a/native/account/registration/registration-server-call.js b/native/account/registration/registration-server-call.js index a683048cc..230a51b02 100644 --- a/native/account/registration/registration-server-call.js +++ b/native/account/registration/registration-server-call.js @@ -1,333 +1,334 @@ // @flow -import invariant from 'invariant'; import * as React from 'react'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; +import { setSyncedMetadataEntryActionType } from 'lib/actions/synced-metadata-actions.js'; import { keyserverRegisterActionTypes, keyserverRegister, useIdentityPasswordRegister, identityRegisterActionTypes, } from 'lib/actions/user-actions.js'; -import { FIDContext } from 'lib/components/fid-provider.react.js'; import type { LogInStartingPayload } from 'lib/types/account-types.js'; +import { syncedMetadataNames } from 'lib/types/synced-metadata-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; import type { RegistrationServerCallInput, UsernameAccountSelection, AvatarData, } from './registration-types.js'; import { useNativeSetUserAvatar, useUploadSelectedMedia, } from '../../avatars/avatar-hooks.js'; import { useSelector } from '../../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../../selectors/account-selectors.js'; import { AppOutOfDateAlertDetails, UsernameReservedAlertDetails, UsernameTakenAlertDetails, UnknownErrorAlertDetails, } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; import { setNativeCredentials } from '../native-credentials.js'; import { useLegacySIWEServerCall, useIdentityWalletRegisterCall, } from '../siwe-hooks.js'; // We can't just do everything in one async callback, since the server calls // would get bound to Redux state from before the registration. The registration // flow has multiple steps where critical Redux state is changed, where // subsequent steps depend on accessing the updated Redux state. // To address this, we break the registration process up into multiple steps. // When each step completes we update the currentStep state, and we have Redux // selectors that trigger useEffects for subsequent steps when relevant data // starts to appear in Redux. type CurrentStep = | { +step: 'inactive' } | { +step: 'waiting_for_registration_call', +avatarData: ?AvatarData, +resolve: () => void, +reject: Error => void, }; const inactiveStep = { step: 'inactive' }; function useRegistrationServerCall(): RegistrationServerCallInput => Promise { const [currentStep, setCurrentStep] = React.useState(inactiveStep); // STEP 1: ACCOUNT REGISTRATION const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector); const dispatchActionPromise = useDispatchActionPromise(); const callKeyserverRegister = useLegacyAshoatKeyserverCall(keyserverRegister); const callIdentityPasswordRegister = useIdentityPasswordRegister(); - const fidContext = React.useContext(FIDContext); - invariant(fidContext, 'FIDContext is missing'); - const { setFID } = fidContext; - const identityRegisterUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, farcasterID: ?string, ) => { const identityRegisterPromise = (async () => { try { const result = await callIdentityPasswordRegister( accountSelection.username, accountSelection.password, farcasterID, ); await setNativeCredentials({ username: accountSelection.username, password: accountSelection.password, }); return result; } catch (e) { if (e.message === 'username reserved') { Alert.alert( UsernameReservedAlertDetails.title, UsernameReservedAlertDetails.message, ); } else if (e.message === 'username already exists') { Alert.alert( UsernameTakenAlertDetails.title, UsernameTakenAlertDetails.message, ); } else if (e.message === 'Unsupported version') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); } throw e; } })(); void dispatchActionPromise( identityRegisterActionTypes, identityRegisterPromise, ); await identityRegisterPromise; }, [callIdentityPasswordRegister, dispatchActionPromise], ); const keyserverRegisterUsernameAccount = React.useCallback( async ( accountSelection: UsernameAccountSelection, keyserverURL: string, ) => { const extraInfo = await logInExtraInfo(); const keyserverRegisterPromise = (async () => { try { const result = await callKeyserverRegister( { ...extraInfo, username: accountSelection.username, password: accountSelection.password, }, { urlPrefixOverride: keyserverURL, }, ); await setNativeCredentials({ username: result.currentUserInfo.username, password: accountSelection.password, }); return result; } catch (e) { if (e.message === 'username_reserved') { Alert.alert( UsernameReservedAlertDetails.title, UsernameReservedAlertDetails.message, ); } else if (e.message === 'username_taken') { Alert.alert( UsernameTakenAlertDetails.title, UsernameTakenAlertDetails.message, ); } else if (e.message === 'client_version_unsupported') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); } throw e; } })(); void dispatchActionPromise( keyserverRegisterActionTypes, keyserverRegisterPromise, undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); await keyserverRegisterPromise; }, [logInExtraInfo, callKeyserverRegister, dispatchActionPromise], ); const legacySiweServerCall = useLegacySIWEServerCall(); const identityWalletRegisterCall = useIdentityWalletRegisterCall(); const dispatch = useDispatch(); const returnedFunc = React.useCallback( (input: RegistrationServerCallInput) => new Promise( // eslint-disable-next-line no-async-promise-executor async (resolve, reject) => { try { if (currentStep.step !== 'inactive') { return; } const { accountSelection, avatarData, keyserverURL, farcasterID } = input; if ( accountSelection.accountType === 'username' && !usingCommServicesAccessToken ) { await keyserverRegisterUsernameAccount( accountSelection, keyserverURL, ); } else if (accountSelection.accountType === 'username') { await identityRegisterUsernameAccount( accountSelection, farcasterID, ); } else if (!usingCommServicesAccessToken) { try { await legacySiweServerCall(accountSelection, { urlPrefixOverride: keyserverURL, }); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); throw e; } } else { try { await identityWalletRegisterCall({ address: accountSelection.address, message: accountSelection.message, signature: accountSelection.signature, fid: farcasterID, }); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, ); throw e; } } dispatch({ type: setURLPrefix, payload: keyserverURL, }); - setFID(farcasterID); + dispatch({ + type: setSyncedMetadataEntryActionType, + payload: { + name: syncedMetadataNames.CURRENT_USER_FID, + data: farcasterID, + }, + }); setCurrentStep({ step: 'waiting_for_registration_call', avatarData, resolve, reject, }); } catch (e) { reject(e); } }, ), [ currentStep, keyserverRegisterUsernameAccount, identityRegisterUsernameAccount, legacySiweServerCall, dispatch, identityWalletRegisterCall, - setFID, ], ); // STEP 2: SETTING AVATAR const uploadSelectedMedia = useUploadSelectedMedia(); const nativeSetUserAvatar = useNativeSetUserAvatar(); const hasCurrentUserInfo = useSelector( state => !!state.currentUserInfo && !state.currentUserInfo.anonymous, ); const avatarBeingSetRef = React.useRef(false); React.useEffect(() => { if ( !hasCurrentUserInfo || currentStep.step !== 'waiting_for_registration_call' || avatarBeingSetRef.current ) { return; } avatarBeingSetRef.current = true; const { avatarData, resolve } = currentStep; void (async () => { try { if (!avatarData) { return; } let updateUserAvatarRequest; if (!avatarData.needsUpload) { ({ updateUserAvatarRequest } = avatarData); } else { const { mediaSelection } = avatarData; updateUserAvatarRequest = await uploadSelectedMedia(mediaSelection); if (!updateUserAvatarRequest) { return; } } await nativeSetUserAvatar(updateUserAvatarRequest); } finally { dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); setCurrentStep(inactiveStep); avatarBeingSetRef.current = false; resolve(); } })(); }, [ currentStep, hasCurrentUserInfo, uploadSelectedMedia, nativeSetUserAvatar, dispatch, ]); return returnedFunc; } export { useRegistrationServerCall }; diff --git a/native/components/connect-farcaster-alert-handler.react.js b/native/components/connect-farcaster-alert-handler.react.js index 8dd5cbac1..7900c18ff 100644 --- a/native/components/connect-farcaster-alert-handler.react.js +++ b/native/components/connect-farcaster-alert-handler.react.js @@ -1,69 +1,69 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; -import { FIDContext } from 'lib/components/fid-provider.react.js'; import { cookieSelector } from 'lib/selectors/keyserver-selectors.js'; import { alertTypes, type RecordAlertActionPayload, } from 'lib/types/alert-types.js'; import { authoritativeKeyserverID } from 'lib/utils/authoritative-keyserver.js'; +import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; import { shouldSkipConnectFarcasterAlert } from 'lib/utils/push-alerts.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import { ConnectFarcasterBottomSheetRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; function ConnectFarcasterAlertHandler(): React.Node { const { navigate } = useNavigation(); const isActive = useSelector(state => state.lifecycleState !== 'background'); const currentUserID = useSelector(state => state.currentUserInfo?.id); const cookie = useSelector(cookieSelector(authoritativeKeyserverID())); const hasUserCookie = !!(cookie && cookie.startsWith('user=')); const loggedIn = !!currentUserID && hasUserCookie; - const fid = React.useContext(FIDContext)?.fid; + const fid = useCurrentUserFID(); const connectFarcasterAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.CONNECT_FARCASTER], ); const dispatch = useDispatch(); React.useEffect(() => { if ( !loggedIn || !isActive || !!fid || shouldSkipConnectFarcasterAlert(connectFarcasterAlertInfo) ) { return; } void (async () => { await sleep(1000); navigate(ConnectFarcasterBottomSheetRouteName); const payload: RecordAlertActionPayload = { alertType: alertTypes.CONNECT_FARCASTER, time: Date.now(), }; dispatch({ type: recordAlertActionType, payload, }); })(); }, [connectFarcasterAlertInfo, dispatch, fid, isActive, loggedIn, navigate]); return null; } export default ConnectFarcasterAlertHandler; diff --git a/native/components/connect-farcaster-bottom-sheet.react.js b/native/components/connect-farcaster-bottom-sheet.react.js index ccfa1ba03..65ec39d1c 100644 --- a/native/components/connect-farcaster-bottom-sheet.react.js +++ b/native/components/connect-farcaster-bottom-sheet.react.js @@ -1,108 +1,124 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { FIDContext } from 'lib/components/fid-provider.react.js'; +import { setSyncedMetadataEntryActionType } from 'lib/actions/synced-metadata-actions.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; +import { syncedMetadataNames } from 'lib/types/synced-metadata-types.js'; +import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import FarcasterPrompt from './farcaster-prompt.react.js'; import FarcasterWebView, { type FarcasterWebViewState, } from './farcaster-web-view.react.js'; import RegistrationButton from '../account/registration/registration-button.react.js'; import { BottomSheetContext } from '../bottom-sheet/bottom-sheet-provider.react.js'; import BottomSheet from '../bottom-sheet/bottom-sheet.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; const bottomSheetPaddingTop = 32; const farcasterPromptHeight = 350; const marginBottom = 40; const buttonHeight = 48; type Props = { +navigation: RootNavigationProp<'ConnectFarcasterBottomSheet'>, +route: NavigationRoute<'ConnectFarcasterBottomSheet'>, }; function ConnectFarcasterBottomSheet(props: Props): React.Node { const { navigation } = props; + const dispatch = useDispatch(); + + const fid = useCurrentUserFID(); + + const onSuccess = React.useCallback( + (newFID: string) => { + dispatch({ + type: setSyncedMetadataEntryActionType, + payload: { + name: syncedMetadataNames.CURRENT_USER_FID, + data: newFID, + }, + }); + }, + [dispatch], + ); + const { goBack } = navigation; const bottomSheetRef = React.useRef(null); - const fidContext = React.useContext(FIDContext); - invariant(fidContext, 'fidContext is missing'); - const { fid, setFID } = fidContext; - const bottomSheetContext = React.useContext(BottomSheetContext); invariant(bottomSheetContext, 'bottomSheetContext should be set'); const { setContentHeight } = bottomSheetContext; const insets = useSafeAreaInsets(); React.useLayoutEffect(() => { setContentHeight( bottomSheetPaddingTop + farcasterPromptHeight + marginBottom + buttonHeight + insets.bottom, ); }, [insets.bottom, setContentHeight]); const [webViewState, setWebViewState] = React.useState('closed'); const isAppForegrounded = useIsAppForegrounded(); React.useEffect(() => { if (fid && isAppForegrounded) { bottomSheetRef.current?.close(); } }, [fid, isAppForegrounded]); const onPressConnect = React.useCallback(() => { setWebViewState('opening'); }, []); const connectButtonVariant = webViewState === 'opening' ? 'loading' : 'enabled'; const connectFarcasterBottomSheet = React.useMemo( () => ( - + ), - [connectButtonVariant, goBack, onPressConnect, setFID, webViewState], + [connectButtonVariant, goBack, onPressConnect, onSuccess, webViewState], ); return connectFarcasterBottomSheet; } const styles = StyleSheet.create({ container: { flex: 1, paddingHorizontal: 16, }, promptContainer: { marginBottom: 40, }, }); export default ConnectFarcasterBottomSheet; diff --git a/native/profile/farcaster-account-settings.react.js b/native/profile/farcaster-account-settings.react.js index 21b041371..642c1b405 100644 --- a/native/profile/farcaster-account-settings.react.js +++ b/native/profile/farcaster-account-settings.react.js @@ -1,114 +1,129 @@ // @flow -import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; -import { FIDContext } from 'lib/components/fid-provider.react.js'; +import { + setSyncedMetadataEntryActionType, + clearSyncedMetadataEntryActionType, +} from 'lib/actions/synced-metadata-actions.js'; +import { syncedMetadataNames } from 'lib/types/synced-metadata-types.js'; +import { useCurrentUserFID } from 'lib/utils/farcaster-utils.js'; +import { useDispatch } from 'lib/utils/redux-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import RegistrationButton from '../account/registration/registration-button.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 type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { +navigation: ProfileNavigationProp<'FarcasterAccountSettings'>, +route: NavigationRoute<'FarcasterAccountSettings'>, }; // eslint-disable-next-line no-unused-vars function FarcasterAccountSettings(props: Props): React.Node { - const fidContext = React.useContext(FIDContext); - invariant(fidContext, 'FIDContext is missing'); + const dispatch = useDispatch(); - const { fid, setFID } = fidContext; + const fid = useCurrentUserFID(); const styles = useStyles(unboundStyles); const onPressDisconnect = React.useCallback(() => { - setFID(null); - }, [setFID]); + dispatch({ + type: clearSyncedMetadataEntryActionType, + payload: { + name: syncedMetadataNames.CURRENT_USER_FID, + }, + }); + }, [dispatch]); const [webViewState, setWebViewState] = React.useState('closed'); const onSuccess = React.useCallback( (newFID: string) => { setWebViewState('closed'); - setFID(newFID); + dispatch({ + type: setSyncedMetadataEntryActionType, + payload: { + name: syncedMetadataNames.CURRENT_USER_FID, + data: newFID, + }, + }); }, - [setFID], + [dispatch], ); const onPressConnectFarcaster = React.useCallback(() => { setWebViewState('opening'); }, []); const connectButtonVariant = webViewState === 'opening' ? 'loading' : 'enabled'; const button = React.useMemo(() => { if (fid) { return ( ); } return ( ); }, [connectButtonVariant, fid, onPressConnectFarcaster, onPressDisconnect]); const farcasterAccountSettings = React.useMemo( () => ( {button} ), [ button, fid, onSuccess, styles.buttonContainer, styles.connectContainer, styles.promptContainer, webViewState, ], ); return farcasterAccountSettings; } const unboundStyles = { connectContainer: { flex: 1, backgroundColor: 'panelBackground', paddingBottom: 16, }, promptContainer: { flex: 1, padding: 16, justifyContent: 'space-between', }, buttonContainer: { marginVertical: 8, marginHorizontal: 16, }, }; export default FarcasterAccountSettings;