diff --git a/native/account/registration/avatar-selection.react.js b/native/account/registration/avatar-selection.react.js index f1eba5c06..147081ded 100644 --- a/native/account/registration/avatar-selection.react.js +++ b/native/account/registration/avatar-selection.react.js @@ -1,185 +1,199 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; -import type { - CoolOrNerdMode, - AccountSelection, - AvatarData, +import { + type CoolOrNerdMode, + type AccountSelection, + type AvatarData, + ensAvatarSelection, } from './registration-types.js'; import { EditUserAvatarContext, type UserAvatarSelection, } from '../../avatars/edit-user-avatar-provider.react.js'; import EditUserAvatar from '../../avatars/edit-user-avatar.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; export type AvatarSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverUsername: string, +accountSelection: AccountSelection, }, }; -const ensDefaultSelection = { - needsUpload: false, - updateUserAvatarRequest: { type: 'ens' }, - clientAvatar: { type: 'ens' }, -}; - type Props = { +navigation: RegistrationNavigationProp<'AvatarSelection'>, +route: NavigationRoute<'AvatarSelection'>, }; function AvatarSelection(props: Props): React.Node { const { userSelections } = props.route.params; const { accountSelection } = userSelections; - const username = + const usernameOrETHAddress = accountSelection.accountType === 'username' ? accountSelection.username : accountSelection.address; + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { cachedSelections, setCachedSelections, register } = + registrationContext; + const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { setRegistrationMode } = editUserAvatarContext; const prefetchedAvatarURI = accountSelection.accountType === 'ethereum' ? accountSelection.avatarURI : undefined; - const [avatarData, setAvatarData] = React.useState( - prefetchedAvatarURI ? ensDefaultSelection : undefined, - ); + let initialAvatarData = cachedSelections.avatarData; + if (!initialAvatarData && prefetchedAvatarURI) { + initialAvatarData = ensAvatarSelection; + } + + const [avatarData, setAvatarData] = + React.useState(initialAvatarData); const setClientAvatarFromSelection = React.useCallback( (selection: UserAvatarSelection) => { if (selection.needsUpload) { - setAvatarData({ + const newAvatarData = { ...selection, clientAvatar: { type: 'image', uri: selection.mediaSelection.uri, }, - }); + }; + setAvatarData(newAvatarData); + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + avatarData: newAvatarData, + })); } else if (selection.updateUserAvatarRequest.type !== 'remove') { const clientRequest = selection.updateUserAvatarRequest; invariant( clientRequest.type !== 'image', 'image avatars need to be uploaded', ); - setAvatarData({ + const newAvatarData = { ...selection, clientAvatar: clientRequest, - }); + }; + setAvatarData(newAvatarData); + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + avatarData: newAvatarData, + })); } else { setAvatarData(undefined); + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + avatarData: undefined, + })); } }, - [], + [setCachedSelections], ); const [registrationInProgress, setRegistrationInProgress] = React.useState(false); React.useEffect(() => { if (registrationInProgress) { return undefined; } setRegistrationMode({ registrationMode: 'on', successCallback: setClientAvatarFromSelection, }); return () => { setRegistrationMode({ registrationMode: 'off' }); }; }, [ registrationInProgress, setRegistrationMode, setClientAvatarFromSelection, ]); - const registrationContext = React.useContext(RegistrationContext); - invariant(registrationContext, 'registrationContext should be set'); - const { register } = registrationContext; - const onProceed = React.useCallback(async () => { setRegistrationInProgress(true); try { await register({ ...userSelections, avatarData, }); } finally { setRegistrationInProgress(false); } }, [register, userSelections, avatarData]); const clientAvatar = avatarData?.clientAvatar; const userInfoOverride = React.useMemo( () => ({ - username, + username: usernameOrETHAddress, avatar: clientAvatar, }), - [username, clientAvatar], + [usernameOrETHAddress, clientAvatar], ); const styles = useStyles(unboundStyles); return ( Pick an avatar ); } const unboundStyles = { scrollViewContentContainer: { paddingHorizontal: 0, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, paddingHorizontal: 16, }, stagedAvatarSection: { marginTop: 16, backgroundColor: 'panelForeground', paddingVertical: 24, alignItems: 'center', }, editUserAvatar: { alignItems: 'center', justifyContent: 'center', }, }; export default AvatarSelection; diff --git a/native/account/registration/connect-ethereum.react.js b/native/account/registration/connect-ethereum.react.js index 55bb4f93d..f072884f4 100644 --- a/native/account/registration/connect-ethereum.react.js +++ b/native/account/registration/connect-ethereum.react.js @@ -1,276 +1,303 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { exactSearchUser, exactSearchUserActionTypes, } from 'lib/actions/user-actions.js'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { SIWEResult } from 'lib/types/siwe-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; +import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; -import type { CoolOrNerdMode } from './registration-types.js'; +import { + type CoolOrNerdMode, + ensAvatarSelection, +} from './registration-types.js'; import { type NavigationRoute, ExistingEthereumAccountRouteName, UsernameSelectionRouteName, AvatarSelectionRouteName, } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import EthereumLogoDark from '../../vectors/ethereum-logo-dark.react.js'; import SIWEPanel from '../siwe-panel.react.js'; const exactSearchUserLoadingStatusSelector = createLoadingStatusSelector( exactSearchUserActionTypes, ); export type ConnectEthereumParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverUsername: string, }, }; type PanelState = 'closed' | 'opening' | 'open' | 'closing'; type Props = { +navigation: RegistrationNavigationProp<'ConnectEthereum'>, +route: NavigationRoute<'ConnectEthereum'>, }; function ConnectEthereum(props: Props): React.Node { const { params } = props.route; const { userSelections } = props.route.params; + + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { setCachedSelections } = registrationContext; + const isNerdMode = userSelections.coolOrNerdMode === 'nerd'; const styles = useStyles(unboundStyles); let body; if (!isNerdMode) { body = ( Connecting your Ethereum wallet allows you to use your ENS name and avatar in the app. You’ll also be able to log in with your wallet instead of a password. ); } else { body = ( <> Connecting your Ethereum wallet has three benefits: {'1. '} Your peers will be able to cryptographically verify that your Comm account is associated with your Ethereum wallet. {'2. '} You’ll be able to use your ENS name and avatar in the app. {'3. '} You can choose to skip setting a password, and to log in with your Ethereum wallet instead. ); } const [panelState, setPanelState] = React.useState('closed'); const openPanel = React.useCallback(() => { setPanelState('opening'); }, []); const onPanelClosed = React.useCallback(() => { setPanelState('closed'); }, []); const onPanelClosing = React.useCallback(() => { setPanelState('closing'); }, []); const siwePanelSetLoading = React.useCallback( (loading: boolean) => { if (panelState === 'closing' || panelState === 'closed') { return; } setPanelState(loading ? 'opening' : 'open'); }, [panelState], ); const { navigate } = props.navigation; const onSkip = React.useCallback(() => { navigate<'UsernameSelection'>({ name: UsernameSelectionRouteName, params, }); }, [navigate, params]); const exactSearchUserCall = useServerCall(exactSearchUser); const dispatchActionPromise = useDispatchActionPromise(); const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const onSuccessfulWalletSignature = React.useCallback( async (result: SIWEResult) => { const searchPromise = exactSearchUserCall(result.address); dispatchActionPromise(exactSearchUserActionTypes, searchPromise); // We want to figure out if the user has an ENS avatar now // so that we can default to the ENS avatar in AvatarSelection const avatarURIPromise = (async () => { if (!ensCache) { return null; } return await ensCache.getAvatarURIForAddress(result.address); })(); const { userInfo } = await searchPromise; if (userInfo) { navigate<'ExistingEthereumAccount'>({ name: ExistingEthereumAccountRouteName, params: result, }); return; } const avatarURI = await avatarURIPromise; + const ethereumAccount = { + accountType: 'ethereum', + ...result, + avatarURI, + }; + + setCachedSelections(oldUserSelections => { + const base = { + ...oldUserSelections, + ethereumAccount, + }; + if (base.avatarData || !avatarURI) { + return base; + } + return { + ...base, + avatarData: ensAvatarSelection, + }; + }); + const newUserSelections = { ...userSelections, - accountSelection: { - accountType: 'ethereum', - ...result, - avatarURI, - }, + accountSelection: ethereumAccount, }; navigate<'AvatarSelection'>({ name: AvatarSelectionRouteName, params: { userSelections: newUserSelections }, }); }, [ userSelections, exactSearchUserCall, dispatchActionPromise, + setCachedSelections, navigate, ensCache, ], ); let siwePanel; if (panelState !== 'closed') { siwePanel = ( ); } const exactSearchUserCallLoading = useSelector( state => exactSearchUserLoadingStatusSelector(state) === 'loading', ); const connectButtonVariant = exactSearchUserCallLoading || panelState === 'opening' ? 'loading' : 'enabled'; return ( <> Do you want to connect an Ethereum wallet? {body} {siwePanel} ); } const unboundStyles = { scrollViewContentContainer: { flexGrow: 1, }, header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, ethereumLogoContainer: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', }, list: { paddingBottom: 16, }, listItem: { flexDirection: 'row', }, listItemNumber: { fontFamily: 'Arial', fontWeight: 'bold', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', }, listItemContent: { fontFamily: 'Arial', flexShrink: 1, fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', }, }; export default ConnectEthereum; diff --git a/native/account/registration/cool-or-nerd-mode-selection.react.js b/native/account/registration/cool-or-nerd-mode-selection.react.js index 72927db88..688ded4fe 100644 --- a/native/account/registration/cool-or-nerd-mode-selection.react.js +++ b/native/account/registration/cool-or-nerd-mode-selection.react.js @@ -1,131 +1,145 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; +import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import { RegistrationTile, RegistrationTileHeader, } from './registration-tile.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import { type NavigationRoute, KeyserverSelectionRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; type Props = { +navigation: RegistrationNavigationProp<'CoolOrNerdModeSelection'>, +route: NavigationRoute<'CoolOrNerdModeSelection'>, }; function CoolOrNerdModeSelection(props: Props): React.Node { + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { cachedSelections, setCachedSelections } = registrationContext; + const [currentSelection, setCurrentSelection] = - React.useState(); + React.useState(cachedSelections.coolOrNerdMode); + const selectCool = React.useCallback(() => { setCurrentSelection('cool'); - }, []); + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + coolOrNerdMode: 'cool', + })); + }, [setCachedSelections]); const selectNerd = React.useCallback(() => { setCurrentSelection('nerd'); - }, []); + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + coolOrNerdMode: 'nerd', + })); + }, [setCachedSelections]); const { navigate } = props.navigation; const onSubmit = React.useCallback(() => { invariant( currentSelection, 'Button should be disabled if currentSelection is not set', ); navigate<'KeyserverSelection'>({ name: KeyserverSelectionRouteName, params: { userSelections: { coolOrNerdMode: currentSelection } }, }); }, [navigate, currentSelection]); const buttonState = currentSelection ? 'enabled' : 'disabled'; const styles = useStyles(unboundStyles); return ( To begin, choose your fighter Do you want Comm to choose reasonable defaults for you, or do you want to see all the options and make the decisions yourself? This setting will affect behavior throughout the app, but you can change it later in your settings. 🤓 Nerd mode We present more options and talk through their security and privacy implications in detail. 😎 Cool mode We select reasonable defaults for you. ); } const unboundStyles = { header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, tileTitleText: { flex: 1, fontSize: 18, color: 'panelForegroundLabel', }, tileBody: { fontFamily: 'Arial', fontSize: 13, color: 'panelForegroundSecondaryLabel', }, emojiIcon: { fontSize: 32, bottom: 1, marginRight: 5, color: 'black', }, }; export default CoolOrNerdModeSelection; diff --git a/native/account/registration/keyserver-selection.react.js b/native/account/registration/keyserver-selection.react.js index ce2c6e7f2..88eed0971 100644 --- a/native/account/registration/keyserver-selection.react.js +++ b/native/account/registration/keyserver-selection.react.js @@ -1,167 +1,187 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; +import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import RegistrationTextInput from './registration-text-input.react.js'; import { RegistrationTile, RegistrationTileHeader, } from './registration-tile.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import CommIcon from '../../components/comm-icon.react.js'; import { type NavigationRoute, ConnectEthereumRouteName, } from '../../navigation/route-names.js'; import { useStyles, useColors } from '../../themes/colors.js'; type Selection = 'ashoat' | 'custom'; export type KeyserverSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, }, }; type Props = { +navigation: RegistrationNavigationProp<'KeyserverSelection'>, +route: NavigationRoute<'KeyserverSelection'>, }; -// eslint-disable-next-line no-unused-vars function KeyserverSelection(props: Props): React.Node { - const [customKeyserver, setCustomKeyserver] = React.useState(''); + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { cachedSelections, setCachedSelections } = registrationContext; + + const initialKeyserverUsername = cachedSelections.keyserverUsername; + const [customKeyserver, setCustomKeyserver] = React.useState( + initialKeyserverUsername === 'ashoat' ? '' : initialKeyserverUsername, + ); const customKeyserverTextInputRef = React.useRef(); - const [currentSelection, setCurrentSelection] = React.useState(); + let initialSelection; + if (initialKeyserverUsername === 'ashoat') { + initialSelection = 'ashoat'; + } else if (initialKeyserverUsername) { + initialSelection = 'custom'; + } + + const [currentSelection, setCurrentSelection] = + React.useState(initialSelection); const selectAshoat = React.useCallback(() => { setCurrentSelection('ashoat'); customKeyserverTextInputRef.current?.blur(); }, []); const customKeyserverEmpty = !customKeyserver; const selectCustom = React.useCallback(() => { setCurrentSelection('custom'); if (customKeyserverEmpty) { customKeyserverTextInputRef.current?.focus(); } }, [customKeyserverEmpty]); const onCustomKeyserverFocus = React.useCallback(() => { setCurrentSelection('custom'); }, []); let keyserverUsername; if (currentSelection === 'ashoat') { keyserverUsername = 'ashoat'; } else if (currentSelection === 'custom' && customKeyserver) { keyserverUsername = customKeyserver; } const buttonState = keyserverUsername ? 'enabled' : 'disabled'; const { navigate } = props.navigation; const { coolOrNerdMode } = props.route.params.userSelections; const onSubmit = React.useCallback(() => { if (!keyserverUsername) { return; } + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + keyserverUsername, + })); navigate<'ConnectEthereum'>({ name: ConnectEthereumRouteName, params: { userSelections: { coolOrNerdMode, keyserverUsername } }, }); - }, [navigate, coolOrNerdMode, keyserverUsername]); + }, [navigate, coolOrNerdMode, keyserverUsername, setCachedSelections]); const styles = useStyles(unboundStyles); const colors = useColors(); return ( Select a keyserver to join Chat communities on Comm are hosted on keyservers, which are user-operated backends. Keyservers allow Comm to offer strong privacy guarantees without sacrificing functionality. ashoat Ashoat is Comm’s founder, and his keyserver currently hosts most of the communities on Comm. Enter a keyserver ); } const unboundStyles = { header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, body: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'panelForegroundSecondaryLabel', paddingBottom: 16, }, tileTitleText: { flex: 1, fontSize: 18, color: 'panelForegroundLabel', }, tileBody: { fontFamily: 'Arial', fontSize: 13, color: 'panelForegroundSecondaryLabel', }, cloud: { marginRight: 8, }, }; export default KeyserverSelection; diff --git a/native/account/registration/password-selection.react.js b/native/account/registration/password-selection.react.js index b94568d19..4f9fb5502 100644 --- a/native/account/registration/password-selection.react.js +++ b/native/account/registration/password-selection.react.js @@ -1,225 +1,248 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import sleep from 'lib/utils/sleep.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; +import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import RegistrationTextInput from './registration-text-input.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import { type NavigationRoute, AvatarSelectionRouteName, } from '../../navigation/route-names.js'; import { useStyles } from '../../themes/colors.js'; import type { KeyPressEvent } from '../../types/react-native.js'; export type PasswordSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverUsername: string, +username: string, }, }; type PasswordError = 'passwords_dont_match' | 'empty_password'; type Props = { +navigation: RegistrationNavigationProp<'PasswordSelection'>, +route: NavigationRoute<'PasswordSelection'>, }; function PasswordSelection(props: Props): React.Node { - const [password, setPassword] = React.useState(''); - const [confirmPassword, setConfirmPassword] = React.useState(''); + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { cachedSelections, setCachedSelections } = registrationContext; + + const [password, setPassword] = React.useState( + cachedSelections.password ?? '', + ); + const [confirmPassword, setConfirmPassword] = React.useState( + cachedSelections.password ?? '', + ); const passwordsMatch = password === confirmPassword; const passwordIsEmpty = password === ''; const [passwordError, setPasswordError] = React.useState(); const potentiallyClearErrors = React.useCallback(() => { if (!passwordsMatch || passwordIsEmpty) { return false; } setPasswordError(null); return true; }, [passwordsMatch, passwordIsEmpty]); const checkPasswordValidity = React.useCallback(() => { if (!passwordsMatch) { setPasswordError('passwords_dont_match'); return false; } else if (passwordIsEmpty) { setPasswordError('empty_password'); return false; } return potentiallyClearErrors(); }, [passwordsMatch, passwordIsEmpty, potentiallyClearErrors]); const { userSelections } = props.route.params; const { navigate } = props.navigation; const onProceed = React.useCallback(() => { if (!checkPasswordValidity()) { return; } const { coolOrNerdMode, keyserverUsername, username } = userSelections; const newUserSelections = { coolOrNerdMode, keyserverUsername, accountSelection: { accountType: 'username', username, password, }, }; + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + password, + })); navigate<'AvatarSelection'>({ name: AvatarSelectionRouteName, params: { userSelections: newUserSelections }, }); - }, [checkPasswordValidity, userSelections, password, navigate]); + }, [ + checkPasswordValidity, + userSelections, + password, + setCachedSelections, + navigate, + ]); const styles = useStyles(unboundStyles); let errorText; if (passwordError === 'passwords_dont_match') { errorText = ( Passwords don’t match ); } else if (passwordError === 'empty_password') { errorText = Password cannot be empty; } const confirmPasswordInputRef = React.useRef(); const focusConfirmPasswordInput = React.useCallback(() => { confirmPasswordInputRef.current?.focus(); }, []); const iosPasswordBeingAutoFilled = React.useRef(false); const confirmPasswordEmpty = confirmPassword.length === 0; const onPasswordKeyPress = React.useCallback( (event: KeyPressEvent) => { const { key } = event.nativeEvent; // On iOS, paste doesn't trigger onKeyPress, but password autofill does // Password autofill calls onKeyPress with `key` set to the whole password if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && confirmPasswordEmpty ) { iosPasswordBeingAutoFilled.current = true; } }, [confirmPasswordEmpty], ); const passwordInputRef = React.useRef(); const passwordLength = password.length; const onChangePasswordInput = React.useCallback( (input: string) => { setPassword(input); if (iosPasswordBeingAutoFilled.current) { // On iOS, paste doesn't trigger onKeyPress, but password autofill does iosPasswordBeingAutoFilled.current = false; setConfirmPassword(input); passwordInputRef.current?.blur(); } else if ( Platform.OS === 'android' && input.length - passwordLength > 1 && confirmPasswordEmpty ) { // On Android, password autofill doesn't trigger onKeyPress. Instead we // rely on observing when the password field changes by more than one // character at a time. This means we treat paste the same way as // password autofill setConfirmPassword(input); passwordInputRef.current?.blur(); } }, [passwordLength, confirmPasswordEmpty], ); + const shouldAutoFocus = React.useRef(!cachedSelections.password); + /* eslint-disable react-hooks/rules-of-hooks */ if (Platform.OS === 'android') { // It's okay to call this hook conditionally because // the condition is guaranteed to never change React.useEffect(() => { (async () => { await sleep(250); - passwordInputRef.current?.focus(); + if (shouldAutoFocus.current) { + passwordInputRef.current?.focus(); + } })(); }, []); } /* eslint-enable react-hooks/rules-of-hooks */ + const autoFocus = Platform.OS !== 'android' && shouldAutoFocus.current; + return ( Pick a password {errorText} ); } const unboundStyles = { header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, error: { marginTop: 16, }, errorText: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'redText', }, confirmPassword: { marginTop: 16, }, }; export default PasswordSelection; diff --git a/native/account/registration/registration-context-provider.react.js b/native/account/registration/registration-context-provider.react.js index 9cd2d0b6e..a485e9381 100644 --- a/native/account/registration/registration-context-provider.react.js +++ b/native/account/registration/registration-context-provider.react.js @@ -1,27 +1,35 @@ // @flow import * as React from 'react'; import { RegistrationContext } from './registration-context.js'; import { useRegistrationServerCall } from './registration-server-call.js'; +import type { CachedUserSelections } from './registration-types.js'; + +const emptyObj: CachedUserSelections = Object.freeze({}); type Props = { +children: React.Node, }; function RegistrationContextProvider(props: Props): React.Node { + const [cachedSelections, setCachedSelections] = + React.useState(emptyObj); + const registrationServerCall = useRegistrationServerCall(); const contextValue = React.useMemo( () => ({ register: registrationServerCall, + cachedSelections, + setCachedSelections, }), - [registrationServerCall], + [registrationServerCall, cachedSelections], ); return ( {props.children} ); } export { RegistrationContextProvider }; diff --git a/native/account/registration/registration-context.js b/native/account/registration/registration-context.js index 92ac217fa..dca030e7d 100644 --- a/native/account/registration/registration-context.js +++ b/native/account/registration/registration-context.js @@ -1,14 +1,21 @@ // @flow import * as React from 'react'; -import type { RegistrationServerCallInput } from './registration-types.js'; +import type { SetState } from 'lib/types/hook-types.js'; + +import type { + RegistrationServerCallInput, + CachedUserSelections, +} from './registration-types.js'; export type RegistrationContextType = { +register: RegistrationServerCallInput => Promise, + +cachedSelections: CachedUserSelections, + +setCachedSelections: SetState, }; const RegistrationContext: React.Context = React.createContext(); export { RegistrationContext }; diff --git a/native/account/registration/registration-types.js b/native/account/registration/registration-types.js index 71c9189f3..5d07932e9 100644 --- a/native/account/registration/registration-types.js +++ b/native/account/registration/registration-types.js @@ -1,45 +1,60 @@ // @flow import type { UpdateUserAvatarRequest, ClientAvatar, } from 'lib/types/avatar-types.js'; import type { NativeMediaSelection } from 'lib/types/media-types.js'; import type { SIWEResult } from 'lib/types/siwe-types.js'; export type CoolOrNerdMode = 'cool' | 'nerd'; export type EthereumAccountSelection = { +accountType: 'ethereum', ...SIWEResult, +avatarURI: ?string, }; export type UsernameAccountSelection = { +accountType: 'username', +username: string, +password: string, }; export type AccountSelection = | EthereumAccountSelection | UsernameAccountSelection; export type AvatarData = | { +needsUpload: true, +mediaSelection: NativeMediaSelection, +clientAvatar: ClientAvatar, } | { +needsUpload: false, +updateUserAvatarRequest: UpdateUserAvatarRequest, +clientAvatar: ClientAvatar, }; export type RegistrationServerCallInput = { +coolOrNerdMode: CoolOrNerdMode, +keyserverUsername: string, +accountSelection: AccountSelection, +avatarData: ?AvatarData, }; + +export type CachedUserSelections = { + +coolOrNerdMode?: CoolOrNerdMode, + +keyserverUsername?: string, + +username?: string, + +password?: string, + +avatarData?: ?AvatarData, + +ethereumAccount?: EthereumAccountSelection, +}; + +export const ensAvatarSelection: AvatarData = { + needsUpload: false, + updateUserAvatarRequest: { type: 'ens' }, + clientAvatar: { type: 'ens' }, +}; diff --git a/native/account/registration/username-selection.react.js b/native/account/registration/username-selection.react.js index e8e4e1bd5..8b08a2612 100644 --- a/native/account/registration/username-selection.react.js +++ b/native/account/registration/username-selection.react.js @@ -1,201 +1,215 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import { exactSearchUser, exactSearchUserActionTypes, } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { validUsernameRegex } from 'lib/shared/account-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import RegistrationButtonContainer from './registration-button-container.react.js'; import RegistrationButton from './registration-button.react.js'; import RegistrationContainer from './registration-container.react.js'; import RegistrationContentContainer from './registration-content-container.react.js'; +import { RegistrationContext } from './registration-context.js'; import type { RegistrationNavigationProp } from './registration-navigator.react.js'; import RegistrationTextInput from './registration-text-input.react.js'; import type { CoolOrNerdMode } from './registration-types.js'; import { type NavigationRoute, PasswordSelectionRouteName, } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; const exactSearchUserLoadingStatusSelector = createLoadingStatusSelector( exactSearchUserActionTypes, ); export type UsernameSelectionParams = { +userSelections: { +coolOrNerdMode: CoolOrNerdMode, +keyserverUsername: string, }, }; type UsernameError = 'username_invalid' | 'username_taken'; type Props = { +navigation: RegistrationNavigationProp<'UsernameSelection'>, +route: NavigationRoute<'UsernameSelection'>, }; function UsernameSelection(props: Props): React.Node { - const [username, setUsername] = React.useState(''); + const registrationContext = React.useContext(RegistrationContext); + invariant(registrationContext, 'registrationContext should be set'); + const { cachedSelections, setCachedSelections } = registrationContext; + + const [username, setUsername] = React.useState( + cachedSelections.username ?? '', + ); const validUsername = username.search(validUsernameRegex) > -1; const [usernameError, setUsernameError] = React.useState(); const checkUsernameValidity = React.useCallback(() => { if (!validUsername) { setUsernameError('username_invalid'); return false; } setUsernameError(null); return true; }, [validUsername]); const exactSearchUserCall = useServerCall(exactSearchUser); const dispatchActionPromise = useDispatchActionPromise(); const { navigate } = props.navigation; const { userSelections } = props.route.params; const onProceed = React.useCallback(async () => { if (!checkUsernameValidity()) { return; } const searchPromise = exactSearchUserCall(username); dispatchActionPromise(exactSearchUserActionTypes, searchPromise); const { userInfo } = await searchPromise; if (userInfo) { setUsernameError('username_taken'); return; } setUsernameError(undefined); + setCachedSelections(oldUserSelections => ({ + ...oldUserSelections, + username, + })); navigate<'PasswordSelection'>({ name: PasswordSelectionRouteName, params: { userSelections: { ...userSelections, username, }, }, }); }, [ checkUsernameValidity, username, exactSearchUserCall, dispatchActionPromise, + setCachedSelections, navigate, userSelections, ]); const exactSearchUserCallLoading = useSelector( state => exactSearchUserLoadingStatusSelector(state) === 'loading', ); let buttonVariant = 'disabled'; if (exactSearchUserCallLoading) { buttonVariant = 'loading'; } else if (validUsername) { buttonVariant = 'enabled'; } const styles = useStyles(unboundStyles); let errorText; if (usernameError === 'username_invalid') { errorText = ( <> Usernames must: {'1. '} Be at least one character long. {'2. '} Start with either a letter or a number. {'3. '} Contain only letters, numbers, or the characters “-” and “_”. ); } else if (usernameError === 'username_taken') { errorText = ( Username taken. Please try another one ); } + const shouldAutoFocus = React.useRef(!cachedSelections.username); return ( Pick a username {errorText} ); } const unboundStyles = { header: { fontSize: 24, color: 'panelForegroundLabel', paddingBottom: 16, }, error: { marginTop: 16, }, errorText: { fontFamily: 'Arial', fontSize: 15, lineHeight: 20, color: 'redText', }, listItem: { flexDirection: 'row', }, listItemNumber: { fontWeight: 'bold', }, listItemContent: { flexShrink: 1, }, }; export default UsernameSelection;