diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 313775f8356..d94c9ce0279 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -1585,6 +1585,21 @@ "body": "Use PIN layout shown on your device to find the location to press on the PIN pad.", "button": "Unlock" }, + "currentPin": { + "header": "Enter Your Current PIN", + "body": "Confirm the PIN you use today, then you can choose a new one.", + "button": "Continue" + }, + "newPin": { + "header": "Create New PIN", + "body": "Use the PIN layout shown on your device to choose a new PIN.", + "button": "Continue" + }, + "newPinConfirm": { + "header": "Confirm New PIN", + "body": "Re-enter your new PIN using the layout shown on your device.", + "button": "Confirm" + }, "settings": { "menuLabels": { "bootloader": "Bootloader", @@ -1613,7 +1628,9 @@ "timeout": "Your device goes to sleep after 10 minutes of inactivity by default. However, you can change this.", "buttonPrompt": "Press and hold the button on your KeepKey to change the device %{setting}.", "updateSuccess": "%{setting} successfully updated!", - "updateFailed": "An error occurred whilst updating the %{setting}.", + "updateFailed": "%{setting} update failed", + "passphraseEnabled": "Passphrase protection enabled. You'll be asked for a passphrase each time you connect.", + "passphraseDisabled": "Passphrase protection disabled", "timeoutDuration": "%{minutes} minutes" }, "alerts": { diff --git a/src/components/Layout/Header/NavBar/DrawerWalletHeader.tsx b/src/components/Layout/Header/NavBar/DrawerWalletHeader.tsx index acfad409ba2..682fe84bcb0 100644 --- a/src/components/Layout/Header/NavBar/DrawerWalletHeader.tsx +++ b/src/components/Layout/Header/NavBar/DrawerWalletHeader.tsx @@ -1,27 +1,14 @@ -import { CloseIcon, RepeatIcon } from '@chakra-ui/icons' -import { - Flex, - Icon, - IconButton, - Menu, - MenuButton, - MenuDivider, - MenuGroup, - MenuItem, - MenuList, - Text, -} from '@chakra-ui/react' +import { Flex, Icon, IconButton, Menu, MenuButton, MenuList, Text } from '@chakra-ui/react' import type { FC } from 'react' import { memo, useCallback, useMemo } from 'react' import { FiArrowLeft } from 'react-icons/fi' -import { TbDots, TbEdit, TbEyeOff, TbHistory, TbSettings } from 'react-icons/tb' +import { TbDots, TbEdit, TbHistory, TbSettings } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' -import { WalletImage } from './WalletImage' +import { DrawerWalletMenu } from './DrawerWalletMenu' import { QRCodeIcon } from '@/components/Icons/QRCode' -import { SUPPORTED_WALLETS } from '@/context/WalletProvider/config' import type { InitialState } from '@/context/WalletProvider/WalletProvider' import { useNewConversation } from '@/features/agenticChat/hooks/useNewConversation' import { useModal } from '@/hooks/useModal/useModal' @@ -34,10 +21,12 @@ import { useAppDispatch, useAppSelector } from '@/state/store' const settingsIcon = const dotsIcon = -const eyeOffIcon = const qrCodeIcon = const historyIcon = const newChatIcon = +// 'full' is 100% of the popper, which sizes to content, so cap against the viewport instead +const menuMaxWidth = { base: 'calc(100vw - 1rem)', md: 'xs' } +const menuMinWidth = { base: 0, md: 'xs' } type DrawerHeaderProps = { walletInfo: InitialState['walletInfo'] @@ -116,19 +105,6 @@ export const DrawerWalletHeader: FC = memo( navigate('/manage-hidden-assets') }, [navigate]) - const repeatIcon = useMemo(() => , []) - const closeIcon = useMemo(() => , []) - - const ConnectMenuComponent = useMemo( - () => connectedType && SUPPORTED_WALLETS[connectedType]?.connectedMenuComponent, - [connectedType], - ) - - const walletImageIcon = useMemo( - () => , - [walletInfo, maybeMipdProvider?.info], - ) - const actionButtons = useMemo(() => { if (isChatOpen) { return ( @@ -192,7 +168,7 @@ export const DrawerWalletHeader: FC = memo( size='md' onClick={handleSettingsClick} /> - + = memo( icon={dotsIcon} size='md' /> - - - - - {label} - - - - - - {ConnectMenuComponent && } - - - {translate('manageHiddenAssets.title')} - - - - {translate('connectWallet.menu.switchWallet')} - - - {translate('connectWallet.menu.disconnect')} - - + + diff --git a/src/components/Layout/Header/NavBar/DrawerWalletMenu.tsx b/src/components/Layout/Header/NavBar/DrawerWalletMenu.tsx new file mode 100644 index 00000000000..8cdb6d8f01d --- /dev/null +++ b/src/components/Layout/Header/NavBar/DrawerWalletMenu.tsx @@ -0,0 +1,153 @@ +import { ChevronRightIcon, CloseIcon, RepeatIcon } from '@chakra-ui/icons' +import { Flex, Icon, MenuDivider, MenuGroup, MenuItem, Text } from '@chakra-ui/react' +import { AnimatePresence } from 'framer-motion' +import type { ComponentProps } from 'react' +import { useCallback, useMemo } from 'react' +import { TbEyeOff } from 'react-icons/tb' +import { useTranslate } from 'react-polyglot' +import { MemoryRouter, useLocation } from 'react-router-dom' +import { Route, Switch } from 'wouter' + +import { useMenuRoutes, WalletConnectedRoutes } from './hooks/useMenuRoutes' +import { SubMenuContainer } from './SubMenuContainer' +import { WalletImage } from './WalletImage' + +import { CircularProgress } from '@/components/CircularProgress/CircularProgress' +import { SuspenseErrorBoundary } from '@/components/ErrorBoundary' +import type { WalletProviderRouteProps } from '@/context/WalletProvider/config' +import { SUPPORTED_WALLETS } from '@/context/WalletProvider/config' +import type { InitialState } from '@/context/WalletProvider/WalletProvider' + +const entries = [WalletConnectedRoutes.Connected] + +// The default loading fallback is full height, which blows the menu open while a chunk loads +const suspenseFallback = ( + + + +) + +const eyeOffIcon = +const repeatIcon = +const closeIcon = + +export type DrawerWalletMenuProps = { + walletInfo: ComponentProps['walletInfo'] + connectedType: InitialState['connectedType'] + label: string | undefined + onDisconnect: () => void + onSwitchProvider: () => void + onManageHiddenAssets: () => void +} + +const useConnectedWalletMenuRoutes = (connectedType: InitialState['connectedType']) => + useMemo( + () => connectedType && SUPPORTED_WALLETS[connectedType]?.connectedWalletMenuRoutes, + [connectedType], + ) + +const DrawerWalletMenuRoot = ({ + walletInfo, + connectedType, + label, + onDisconnect, + onSwitchProvider, + onManageHiddenAssets, +}: DrawerWalletMenuProps) => { + const translate = useTranslate() + const { navigateToRoute } = useMenuRoutes() + + const connectedWalletMenuRoutes = useConnectedWalletMenuRoutes(connectedType) + + const ConnectMenuComponent = useMemo( + () => connectedType && SUPPORTED_WALLETS[connectedType]?.connectedMenuComponent, + [connectedType], + ) + + const walletImageIcon = useMemo(() => , [walletInfo]) + + const handleWalletClick = useCallback(() => { + if (!connectedWalletMenuRoutes) return + + navigateToRoute( + (connectedType && SUPPORTED_WALLETS[connectedType]?.connectedWalletMenuInitialPath) ?? + WalletConnectedRoutes.Connected, + ) + }, [connectedType, connectedWalletMenuRoutes, navigateToRoute]) + + return ( + <> + + + + {label} + {connectedWalletMenuRoutes && } + + + + + + {/* GridPlus supplies a lazy component, and the menu remounts on every open */} + {ConnectMenuComponent && ( + + + + )} + + + {translate('manageHiddenAssets.title')} + + + + {translate('connectWallet.menu.switchWallet')} + + + {translate('connectWallet.menu.disconnect')} + + + + ) +} + +const DrawerWalletMenuRoutes = (props: DrawerWalletMenuProps) => { + const location = useLocation() + const connectedWalletMenuRoutes = useConnectedWalletMenuRoutes(props.connectedType) + + const renderRoute = useCallback((route: WalletProviderRouteProps, i: number) => { + const Component = route.component + + return ( + + + + + + ) + }, []) + + return ( + + + + + + + + {connectedWalletMenuRoutes?.map((route, index) => renderRoute(route, index))} + + + ) +} + +export const DrawerWalletMenu = (props: DrawerWalletMenuProps) => { + return ( + + + + ) +} diff --git a/src/components/Layout/Header/NavBar/KeepKey/ChangeLabel.tsx b/src/components/Layout/Header/NavBar/KeepKey/ChangeLabel.tsx index bd8896ec3a1..214f4f5afb2 100644 --- a/src/components/Layout/Header/NavBar/KeepKey/ChangeLabel.tsx +++ b/src/components/Layout/Header/NavBar/KeepKey/ChangeLabel.tsx @@ -1,4 +1,5 @@ import { Button, Flex, Input, useColorModeValue } from '@chakra-ui/react' +import { upperFirst } from 'lodash' import { useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' @@ -7,16 +8,23 @@ import { SubMenuContainer } from '../SubMenuContainer' import type { AwaitKeepKeyProps } from '@/components/Layout/Header/NavBar/KeepKey/AwaitKeepKey' import { AwaitKeepKey } from '@/components/Layout/Header/NavBar/KeepKey/AwaitKeepKey' -import { LastDeviceInteractionStatus } from '@/components/Layout/Header/NavBar/KeepKey/LastDeviceInteractionStatus' +import { useDeviceSettingToast } from '@/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast' import { SubmenuHeader } from '@/components/Layout/Header/NavBar/SubmenuHeader' +import { WalletActions } from '@/context/WalletProvider/actions' import { useKeepKey } from '@/context/WalletProvider/KeepKeyProvider' -import { useNotificationToast } from '@/hooks/useNotificationToast' import { useWallet } from '@/hooks/useWallet/useWallet' +const setting = 'label' +const buttonPromptTranslation: AwaitKeepKeyProps['translation'] = [ + 'walletProvider.keepKey.settings.descriptions.buttonPrompt', + { setting }, +] + export const ChangeLabel = () => { + const { toastSuccess, toastError } = useDeviceSettingToast(setting) + const translate = useTranslate() - const toast = useNotificationToast() - const { state } = useWallet() + const { state, dispatch } = useWallet() const { walletInfo } = state const { state: { keepKeyWallet }, @@ -29,18 +37,18 @@ export const ChangeLabel = () => { const [keepKeyLabel, setKeepKeyLabel] = useState(walletInfo?.meta?.label ?? walletInfo?.name) const handleChangeLabelInitializeEvent = useCallback(async () => { - await keepKeyWallet?.applySettings({ label: keepKeyLabel }).catch(e => { - console.error(e) - toast({ - title: translate('common.error'), - description: e?.message ?? translate('common.somethingWentWrong'), - status: 'error', - isClosable: true, - }) - }) - }, [keepKeyLabel, keepKeyWallet, toast, translate]) + if (!keepKeyWallet) return + + try { + await keepKeyWallet.applySettings({ label: keepKeyLabel }) + // Nothing reads the label back from the device, so mirror it into wallet state + if (keepKeyLabel) dispatch({ type: WalletActions.SET_WALLET_LABEL, payload: keepKeyLabel }) + toastSuccess() + } catch (e) { + toastError(e) + } + }, [dispatch, keepKeyLabel, keepKeyWallet, toastError, toastSuccess]) - const setting = 'label' const inputBackground = useColorModeValue('white', 'gray.800') const placeholderOpacity = useColorModeValue(0.6, 0.4) const inputPlaceholder = useMemo( @@ -48,11 +56,6 @@ export const ChangeLabel = () => { [placeholderOpacity], ) - const buttonPromptTranslation: AwaitKeepKeyProps['translation'] = useMemo( - () => ['walletProvider.keepKey.settings.descriptions.buttonPrompt', { setting }], - [setting], - ) - const handleLabelInputChange = useCallback( (e: React.ChangeEvent) => setKeepKeyLabel(e.target.value), [], @@ -63,12 +66,11 @@ export const ChangeLabel = () => { - { size='sm' onClick={handleChangeLabelInitializeEvent} > - {translate('walletProvider.keepKey.settings.actions.update', { setting })} + {translate('walletProvider.keepKey.settings.actions.update', { + setting: upperFirst(setting), + })} diff --git a/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx b/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx index b82aa5cc440..dd782180187 100644 --- a/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx +++ b/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx @@ -10,9 +10,9 @@ import { Spinner, Stack, Switch, - useToast, } from '@chakra-ui/react' -import { useCallback } from 'react' +import { upperFirst } from 'lodash' +import { useCallback, useState } from 'react' import { useTranslate } from 'react-polyglot' import { SubMenuBody } from '../SubMenuBody' @@ -20,7 +20,7 @@ import { SubMenuContainer } from '../SubMenuContainer' import type { AwaitKeepKeyProps } from '@/components/Layout/Header/NavBar/KeepKey/AwaitKeepKey' import { AwaitKeepKey } from '@/components/Layout/Header/NavBar/KeepKey/AwaitKeepKey' -import { LastDeviceInteractionStatus } from '@/components/Layout/Header/NavBar/KeepKey/LastDeviceInteractionStatus' +import { useDeviceSettingToast } from '@/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast' import { SubmenuHeader } from '@/components/Layout/Header/NavBar/SubmenuHeader' import { WalletActions } from '@/context/WalletProvider/actions' import { useKeepKey } from '@/context/WalletProvider/KeepKeyProvider' @@ -30,17 +30,20 @@ import { portfolio } from '@/state/slices/portfolioSlice/portfolioSlice' import { selectWalletId } from '@/state/slices/selectors' import { useAppDispatch, useAppSelector } from '@/state/store' -const setting = 'Passphrase' +const setting = 'passphrase' const awaitKeepkeyButtonPromptTranslation: AwaitKeepKeyProps['translation'] = [ 'walletProvider.keepKey.settings.descriptions.buttonPrompt', { setting }, ] export const ChangePassphrase = () => { + const { toastSuccess, toastError } = useDeviceSettingToast(setting) + // The switch only moves once the device confirms, so nothing else stops a second click + const [isSubmitting, setIsSubmitting] = useState(false) + const appDispatch = useAppDispatch() const walletId = useAppSelector(selectWalletId) const translate = useTranslate() - const toast = useToast() const { setHasPassphrase, state: { hasPassphrase, keepKeyWallet }, @@ -54,20 +57,31 @@ export const ChangePassphrase = () => { } = useWallet() const handleToggle = useCallback(async () => { - if (!walletId || !keepKeyWallet) return + if (!walletId || !keepKeyWallet || isSubmitting) return + + setIsSubmitting(true) const currentValue = !!hasPassphrase const newHasPassphrase = !hasPassphrase - setHasPassphrase(newHasPassphrase) - await keepKeyWallet?.applySettings({ usePassphrase: !currentValue }).catch(e => { - console.error(e) - toast({ - title: translate('common.error'), - description: e?.message ?? translate('common.somethingWentWrong'), - status: 'error', - isClosable: true, - }) - }) + + try { + await keepKeyWallet.applySettings({ usePassphrase: !currentValue }) + // Writing this before the device confirms would show a setting it never applied + setHasPassphrase(newHasPassphrase) + // Nothing is stored, so "updated" would imply a passphrase we now hold + toastSuccess( + translate( + newHasPassphrase + ? 'walletProvider.keepKey.settings.descriptions.passphraseEnabled' + : 'walletProvider.keepKey.settings.descriptions.passphraseDisabled', + ), + ) + } catch (e) { + toastError(e) + return + } finally { + setIsSubmitting(false) + } // Clear all previous wallet meta appDispatch(portfolio.actions.clearWalletMetadata(walletId)) // Trigger a refresh of the wallet metadata only once the settings have been applied @@ -79,17 +93,15 @@ export const ChangePassphrase = () => { connect, dispatch, hasPassphrase, + isSubmitting, keepKeyWallet, setHasPassphrase, - toast, + toastError, + toastSuccess, translate, walletId, ]) - const onCancel = useCallback(() => { - setHasPassphrase(!hasPassphrase) - }, [hasPassphrase, setHasPassphrase]) - return ( { - {translate('walletProvider.keepKey.settings.actions.enable', { - setting, + setting: upperFirst(setting), })} - {awaitingDeviceInteraction && } + {(awaitingDeviceInteraction || isSubmitting) && } - + ) } diff --git a/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx b/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx index f78ac4d1702..76fb545cfbe 100644 --- a/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx +++ b/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx @@ -1,23 +1,24 @@ import { Box, Button, Flex, useColorModeValue } from '@chakra-ui/react' +import { upperFirst } from 'lodash' import type { JSX } from 'react' -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' -import { useMenuRoutes } from '../hooks/useMenuRoutes' +import { useMenuRoutes, WalletConnectedRoutes } from '../hooks/useMenuRoutes' import { SubMenuBody } from '../SubMenuBody' import { SubMenuContainer } from '../SubMenuContainer' -import { LastDeviceInteractionStatus } from './LastDeviceInteractionStatus' import { CircularProgress } from '@/components/CircularProgress/CircularProgress' import type { AwaitKeepKeyProps } from '@/components/Layout/Header/NavBar/KeepKey/AwaitKeepKey' import { AwaitKeepKey } from '@/components/Layout/Header/NavBar/KeepKey/AwaitKeepKey' +import { useDeviceSettingToast } from '@/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast' import { SubmenuHeader } from '@/components/Layout/Header/NavBar/SubmenuHeader' import { Text } from '@/components/Text' import { WalletActions } from '@/context/WalletProvider/actions' import { KeepKeyPin } from '@/context/WalletProvider/KeepKey/components/Pin' import { PinMatrixRequestType } from '@/context/WalletProvider/KeepKey/KeepKeyTypes' import { useKeepKey } from '@/context/WalletProvider/KeepKeyProvider' -import { useNotificationToast } from '@/hooks/useNotificationToast' import { useWallet } from '@/hooks/useWallet/useWallet' const gridProps = { spacing: 2 } @@ -25,6 +26,9 @@ const gridProps = { spacing: 2 } const SETTING = 'PIN' export const ChangePin = () => { + const navigate = useNavigate() + const { toastSuccess, toastError } = useDeviceSettingToast(SETTING) + const { handleBackClick } = useMenuRoutes() const translate = useTranslate() const { @@ -34,11 +38,20 @@ export const ChangePin = () => { dispatch, state: { keepKeyPinRequestType, - deviceState: { awaitingDeviceInteraction, isUpdatingPin, isDeviceLoading }, + deviceState: { awaitingDeviceInteraction, isUpdatingPin, isCancellingPin, isDeviceLoading }, }, setDeviceState, } = useWallet() - const toast = useNotificationToast() + const keepKeyWalletRef = useRef(keepKeyWallet) + const setDeviceStateRef = useRef(setDeviceState) + const isUpdatingPinRef = useRef(isUpdatingPin) + + useEffect(() => { + keepKeyWalletRef.current = keepKeyWallet + setDeviceStateRef.current = setDeviceState + isUpdatingPinRef.current = isUpdatingPin + }, [isUpdatingPin, keepKeyWallet, setDeviceState]) + const pinButtonBackground = useColorModeValue('gray.200', 'gray.600') const pinButtonBackgroundHover = useColorModeValue('gray.100', 'text.subtle') @@ -49,7 +62,7 @@ export const ChangePin = () => { case PinMatrixRequestType.NEWSECOND: return 'newPinConfirm' default: - return 'pin' + return 'currentPin' } })() @@ -69,24 +82,49 @@ export const ChangePin = () => { [], ) - const handleCancel = useCallback(async () => { - await keepKeyWallet - ?.cancel() - .catch(e => { - console.error(e) - toast({ - title: translate('common.error'), - description: e?.message?.message ?? translate('common.somethingWentWrong'), - status: 'error', - isClosable: true, - }) + useEffect(() => { + return () => { + if (!isUpdatingPinRef.current) return + + // The drawer can unmount this mid-flow, and a stale isUpdatingPin routes the device's + // next pin request to a view that is gone + keepKeyWalletRef.current + ?.cancel() + .catch(e => console.error('KeepKey: cancel on unmount failed', e)) + setDeviceStateRef.current({ + isUpdatingPin: false, + isCancellingPin: false, + awaitingDeviceInteraction: false, }) - .finally(() => { - setDeviceState({ - isUpdatingPin: false, - }) + } + }, []) + + const handleCancel = useCallback(async () => { + // isUpdatingPin routes in-flight requests, so hide the pad with its own flag rather than + // clearing it early + setDeviceState({ isCancellingPin: true }) + + try { + await keepKeyWallet?.cancel() + // Back navigation can unmount before the ref resyncs, and cleanup would cancel again + isUpdatingPinRef.current = false + setDeviceState({ + isUpdatingPin: false, + isCancellingPin: false, + awaitingDeviceInteraction: false, }) - }, [keepKeyWallet, setDeviceState, toast, translate]) + } catch (e) { + // The device is still mid-flow, so return to the pad rather than the idle view, and leave + // isUpdatingPin set so a second change cannot start and unmount cleanup still retries + setDeviceState({ isCancellingPin: false }) + toastError(e) + } + }, [keepKeyWallet, setDeviceState, toastError]) + + // AwaitKeepKey clears awaitingDeviceInteraction before cancelling, which would flash the pad + const handleAwaitCancel = useCallback(() => { + setDeviceState({ isCancellingPin: true }) + }, [setDeviceState]) const handleHeaderBackClick = useCallback(async () => { await handleCancel() @@ -95,32 +133,36 @@ export const ChangePin = () => { }, [handleBackClick, handleCancel]) const handleChangePin = useCallback(async () => { + // Cancelling hides the pad, which re-exposes this button while cancel is still in flight + if (!keepKeyWallet || isCancellingPin) return + setDeviceState({ isUpdatingPin: true, awaitingDeviceInteraction: true, + isCancellingPin: false, }) dispatch({ type: WalletActions.RESET_LAST_DEVICE_INTERACTION_STATE }) - await keepKeyWallet - ?.changePin() - .catch(e => { - console.error(e) - toast({ - title: translate('common.error'), - description: e?.message?.message ?? translate('common.somethingWentWrong'), - status: 'error', - isClosable: true, - }) + try { + await keepKeyWallet.changePin() + toastSuccess() + // Navigating unmounts this before the ref resyncs, and the cleanup would cancel a success + isUpdatingPinRef.current = false + // Nothing left to do on this panel + navigate(WalletConnectedRoutes.Connected) + } catch (e) { + toastError(e) + } finally { + setDeviceState({ + isUpdatingPin: false, + isCancellingPin: false, + awaitingDeviceInteraction: false, }) - .finally(() => { - setDeviceState({ - isUpdatingPin: false, - }) - }) - }, [dispatch, keepKeyWallet, setDeviceState, toast, translate]) + } + }, [dispatch, isCancellingPin, keepKeyWallet, navigate, setDeviceState, toastError, toastSuccess]) - const shouldDisplayEntryPinView = isUpdatingPin && !awaitingDeviceInteraction + const shouldDisplayEntryPinView = isUpdatingPin && !awaitingDeviceInteraction && !isCancellingPin const renderedPinState: JSX.Element = (() => { return shouldDisplayEntryPinView ? ( @@ -149,17 +191,21 @@ export const ChangePin = () => { ) : ( <> - - + ) })() @@ -170,7 +216,7 @@ export const ChangePin = () => { {!shouldDisplayEntryPinView ? ( { + const { toastSuccess, toastError } = useDeviceSettingToast(setting) + const translate = useTranslate() const { state: { deviceTimeout, keepKeyWallet }, @@ -34,36 +42,38 @@ export const ChangeTimeout = () => { deviceState: { awaitingDeviceInteraction }, }, } = useWallet() - const toast = useNotificationToast() const [radioTimeout, setRadioTimeout] = useState() + // A queued second change would be rolled back by the first one failing + const [isSubmitting, setIsSubmitting] = useState(false) const handleChange = useCallback( async (value: DeviceTimeout) => { + if (!keepKeyWallet || isSubmitting) return + + setIsSubmitting(true) + const parsedTimeout = value ? parseInt(value) : parseInt(DeviceTimeout.TenMinutes) + const previousTimeout = radioTimeout value && setRadioTimeout(value) - await keepKeyWallet?.applySettings({ autoLockDelayMs: parsedTimeout }).catch(e => { - console.error(e) - toast({ - title: translate('common.error'), - description: e?.message ?? translate('common.somethingWentWrong'), - status: 'error', - isClosable: true, - }) - }) + + try { + await keepKeyWallet.applySettings({ autoLockDelayMs: parsedTimeout }) + toastSuccess() + } catch (e) { + // Cancelling is silent, so leaving the radio moved would be the only thing the user sees + setRadioTimeout(previousTimeout) + toastError(e) + } finally { + setIsSubmitting(false) + } }, - [keepKeyWallet, toast, translate], + [isSubmitting, keepKeyWallet, radioTimeout, toastError, toastSuccess], ) - const setting = 'timeout' const colorScheme = useColorModeValue('blackAlpha', 'white') const checkColor = useColorModeValue('green', 'blue.400') - const keepkeyButtonPromptTranslation: AwaitKeepKeyProps['translation'] = useMemo( - () => ['walletProvider.keepKey.settings.descriptions.buttonPrompt', { setting }], - [setting], - ) - useEffect(() => { if (deviceTimeout?.value) { setRadioTimeout(deviceTimeout.value) @@ -74,20 +84,20 @@ export const ChangeTimeout = () => { - diff --git a/src/components/Layout/Header/NavBar/KeepKey/KeepKeyMenu.tsx b/src/components/Layout/Header/NavBar/KeepKey/KeepKeyMenu.tsx index d1306147e1a..1b1c315897a 100644 --- a/src/components/Layout/Header/NavBar/KeepKey/KeepKeyMenu.tsx +++ b/src/components/Layout/Header/NavBar/KeepKey/KeepKeyMenu.tsx @@ -102,7 +102,7 @@ export const KeepKeyMenu = () => { - {walletInfo?.name} + {walletInfo?.meta?.label || walletInfo?.name} { - {walletInfo?.name} + {walletInfo?.meta?.label || walletInfo?.name} {!isConnected && ( { - const translate = useTranslate() - const { - state: { - deviceState: { lastDeviceInteractionStatus }, - }, - } = useWallet() - const greenShade = useColorModeValue('green.700', 'green.200') - const yellowShade = useColorModeValue('yellow.500', 'yellow.200') - - return lastDeviceInteractionStatus ? ( - - - {lastDeviceInteractionStatus === 'success' - ? translate('walletProvider.keepKey.settings.descriptions.updateSuccess', { - setting: upperFirst(setting), - }) - : translate('walletProvider.keepKey.settings.descriptions.updateFailed', { - setting, - })} - - ) : null -} diff --git a/src/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast.ts b/src/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast.ts new file mode 100644 index 00000000000..dee379eb2d8 --- /dev/null +++ b/src/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast.ts @@ -0,0 +1,75 @@ +import { HDWalletErrorType } from '@shapeshiftoss/hdwallet-core' +import { upperFirst } from 'lodash' +import { useCallback } from 'react' +import { useTranslate } from 'react-polyglot' + +import { FailureType } from '@/context/WalletProvider/KeepKey/KeepKeyTypes' +import { useNotificationToast } from '@/hooks/useNotificationToast' + +const CANCELLED_FAILURE_TYPES = [FailureType.ACTIONCANCELLED, FailureType.PINCANCELLED] + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +// The app-side cancel throws ActionCancelled, a device-side one rethrows a raw failure response +const isCancelled = (e: unknown): boolean => { + if (!isRecord(e)) return false + if (e.name === HDWalletErrorType.ActionCancelled) return true + if (!isRecord(e.message)) return false + + const { code } = e.message + + return typeof code === 'number' && CANCELLED_FAILURE_TYPES.includes(code) +} + +// KeepKey rejections carry a { code, message } object where an Error would carry a string +const getErrorMessage = (e: unknown): string | undefined => { + if (!isRecord(e)) return undefined + + const { message } = e + + if (typeof message === 'string') return message + if (!isRecord(message)) return undefined + + return typeof message.message === 'string' ? message.message : undefined +} + +// Toasting from the awaited call, so the outcome survives the device prompt closing the panel +export const useDeviceSettingToast = (setting: string) => { + const translate = useTranslate() + const toast = useNotificationToast() + + const toastSuccess = useCallback( + (title?: string) => + toast({ + title: + title ?? + translate('walletProvider.keepKey.settings.descriptions.updateSuccess', { + setting: upperFirst(setting), + }), + status: 'success', + isClosable: true, + }), + [setting, toast, translate], + ) + + const toastError = useCallback( + (e: unknown) => { + console.error(e) + // Cancelling is the user getting what they asked for, not a failure + if (isCancelled(e)) return + + toast({ + title: translate('walletProvider.keepKey.settings.descriptions.updateFailed', { + setting: upperFirst(setting), + }), + description: getErrorMessage(e) ?? translate('common.somethingWentWrong'), + status: 'error', + isClosable: true, + }) + }, + [setting, toast, translate], + ) + + return { toastSuccess, toastError } +} diff --git a/src/components/Radio/Radio.tsx b/src/components/Radio/Radio.tsx index 8438d822c20..891d10be576 100644 --- a/src/components/Radio/Radio.tsx +++ b/src/components/Radio/Radio.tsx @@ -5,7 +5,7 @@ import type { ThemeTypings } from '@chakra-ui/styled-system' import type { HistoryTimeframe } from '@shapeshiftoss/types' import type Polyglot from 'node-polyglot' import type { InterpolationOptions } from 'node-polyglot' -import { memo } from 'react' +import { memo, useEffect } from 'react' import { useTranslate } from 'react-polyglot' import { CircularProgress } from '@/components/CircularProgress/CircularProgress' @@ -65,6 +65,8 @@ export interface RadioOption { export interface RadioProps { name?: string defaultValue?: T + // Syncs the group with a value that arrives late or is reverted + value?: T options: readonly RadioOption[] onChange: (value: T) => void variant?: string @@ -83,6 +85,7 @@ export const Radio = ({ options, onChange, defaultValue, + value, variant = 'ghost', colorScheme = 'gray', buttonGroupProps, @@ -91,12 +94,18 @@ export const Radio = ({ checkColor, isLoading, }: RadioProps) => { - const { getRootProps, getRadioProps } = useRadioGroup({ + const { getRootProps, getRadioProps, setValue } = useRadioGroup({ name: name ?? 'radio', defaultValue, onChange, }) + useEffect(() => { + if (value === undefined) return + + setValue(value) + }, [setValue, value]) + const group = getRootProps() return ( diff --git a/src/context/WalletProvider/WalletProvider.tsx b/src/context/WalletProvider/WalletProvider.tsx index fe8b196bcd6..6dafe9f7447 100644 --- a/src/context/WalletProvider/WalletProvider.tsx +++ b/src/context/WalletProvider/WalletProvider.tsx @@ -79,6 +79,7 @@ export type DeviceState = { recoveryCharacterIndex: number | undefined recoveryWordIndex: number | undefined isUpdatingPin: boolean | undefined + isCancellingPin: boolean | undefined isDeviceLoading: boolean | undefined } @@ -91,6 +92,7 @@ const initialDeviceState: DeviceState = { recoveryCharacterIndex: undefined, recoveryWordIndex: undefined, isUpdatingPin: false, + isCancellingPin: false, isDeviceLoading: false, } export type InitialState = { @@ -203,28 +205,10 @@ const reducer = (state: InitialState, action: ActionTypes): InitialState => { case WalletActions.SET_PIN_REQUEST_TYPE: return { ...state, keepKeyPinRequestType: action.payload } case WalletActions.SET_DEVICE_STATE: { - const { deviceState } = state - const { - awaitingDeviceInteraction = deviceState.awaitingDeviceInteraction, - lastDeviceInteractionStatus = deviceState.lastDeviceInteractionStatus, - disposition = deviceState.disposition, - recoverWithPassphrase = deviceState.recoverWithPassphrase, - recoveryEntropy = deviceState.recoveryEntropy, - isUpdatingPin = deviceState.isUpdatingPin, - isDeviceLoading = deviceState.isDeviceLoading, - } = action.payload + // Omitted keys keep their value, explicitly undefined ones clear return { ...state, - deviceState: { - ...deviceState, - awaitingDeviceInteraction, - lastDeviceInteractionStatus, - disposition, - recoverWithPassphrase, - recoveryEntropy, - isUpdatingPin, - isDeviceLoading, - }, + deviceState: { ...state.deviceState, ...action.payload }, } } case WalletActions.SET_WALLET_MODAL: