From df5d56d3911d0f3018a031b22140ca3ca0472e07 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:12:22 -0600 Subject: [PATCH 1/2] feat(keepkey): surface device settings in the wallet drawer The new wallet manager drawer had no route into the KeepKey submenu, so firmware, bootloader, label, PIN, timeout and passphrase were unreachable whenever the NewWalletManager flag was on. Ports the menu's routing skeleton to the drawer and replaces the panels' persistent status banner with toasts, which survive the device prompt tearing the drawer down. Co-Authored-By: Claude Opus 5 (1M context) --- src/assets/translations/en/main.json | 19 ++- .../Header/NavBar/DrawerWalletHeader.tsx | 80 +++------ .../Layout/Header/NavBar/DrawerWalletMenu.tsx | 153 ++++++++++++++++++ .../Header/NavBar/KeepKey/ChangeLabel.tsx | 50 +++--- .../NavBar/KeepKey/ChangePassphrase.tsx | 63 +++++--- .../Header/NavBar/KeepKey/ChangePin.tsx | 144 +++++++++++------ .../Header/NavBar/KeepKey/ChangeTimeout.tsx | 56 ++++--- .../Header/NavBar/KeepKey/KeepKeyMenu.tsx | 4 +- .../KeepKey/LastDeviceInteractionStatus.tsx | 40 ----- .../KeepKey/hooks/useDeviceSettingToast.ts | 75 +++++++++ src/components/Radio/Radio.tsx | 13 +- src/context/WalletProvider/WalletProvider.tsx | 24 +-- 12 files changed, 480 insertions(+), 241 deletions(-) create mode 100644 src/components/Layout/Header/NavBar/DrawerWalletMenu.tsx delete mode 100644 src/components/Layout/Header/NavBar/KeepKey/LastDeviceInteractionStatus.tsx create mode 100644 src/components/Layout/Header/NavBar/KeepKey/hooks/useDeviceSettingToast.ts 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..7cd07e97967 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,44 @@ 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 +199,21 @@ export const ChangePin = () => { ) : ( <> - - + ) })() @@ -170,7 +224,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: From 6b2feba47dfa91b18b36bd008fe0e504828a2b91 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:44:15 -0600 Subject: [PATCH 2/2] lint fix --- .../Layout/Header/NavBar/KeepKey/ChangePin.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx b/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx index 7cd07e97967..76fb545cfbe 100644 --- a/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx +++ b/src/components/Layout/Header/NavBar/KeepKey/ChangePin.tsx @@ -160,15 +160,7 @@ export const ChangePin = () => { awaitingDeviceInteraction: false, }) } - }, [ - dispatch, - isCancellingPin, - keepKeyWallet, - navigate, - setDeviceState, - toastError, - toastSuccess, - ]) + }, [dispatch, isCancellingPin, keepKeyWallet, navigate, setDeviceState, toastError, toastSuccess]) const shouldDisplayEntryPinView = isUpdatingPin && !awaitingDeviceInteraction && !isCancellingPin