diff --git a/apps/browser-extension-wallet/.env.defaults b/apps/browser-extension-wallet/.env.defaults index 10818c118..d77a51909 100644 --- a/apps/browser-extension-wallet/.env.defaults +++ b/apps/browser-extension-wallet/.env.defaults @@ -81,6 +81,8 @@ CEXPLORER_URL_SANCHONET=https://sancho.cexplorer.io # Manifest.json LACE_EXTENSION_KEY=gafhhkghbfjjkeiendhlofajokpaflmk +LACE_EXTENSION_ID=gafhhkghbfjjkeiendhlofajokpaflmk +NAMI_EXTENSION_ID=lpfcbjknijpeeillifnkikgncikgfhdo # Extension uninstall redirect LACE_EXTENSION_UNINSTALL_REDIRECT_URL=https://forms.gle/XNcPfWafY8XgxkYw7 diff --git a/apps/browser-extension-wallet/.env.developerpreview b/apps/browser-extension-wallet/.env.developerpreview index 49fefe315..f6205fcf4 100644 --- a/apps/browser-extension-wallet/.env.developerpreview +++ b/apps/browser-extension-wallet/.env.developerpreview @@ -81,6 +81,8 @@ CEXPLORER_URL_SANCHONET=https://sancho.cexplorer.io # Manifest.json LACE_EXTENSION_KEY=djcdfchkaijggdjokfomholkalbffgil +LACE_EXTENSION_ID=djcdfchkaijggdjokfomholkalbffgil +NAMI_EXTENSION_ID=djcdfchkaijggdjokfomholkalbffgil # Extension uninstall redirect LACE_EXTENSION_UNINSTALL_REDIRECT_URL= diff --git a/apps/browser-extension-wallet/.env.example b/apps/browser-extension-wallet/.env.example index 2f5372e1c..bb24c0dcb 100644 --- a/apps/browser-extension-wallet/.env.example +++ b/apps/browser-extension-wallet/.env.example @@ -79,6 +79,8 @@ CEXPLORER_URL_SANCHONET=https://sancho.cexplorer.io # Manifest.json LACE_EXTENSION_KEY=gafhhkghbfjjkeiendhlofajokpaflmk +LACE_EXTENSION_ID=gafhhkghbfjjkeiendhlofajokpaflmk +NAMI_EXTENSION_ID=gafhhkghbfjjkeiendhlofajokpaflmk # Midnight MIDNIGHT_EVENT_BANNER_REMINDER_TIME=129600000 diff --git a/apps/browser-extension-wallet/package.json b/apps/browser-extension-wallet/package.json index ae6ee2609..8b2317494 100644 --- a/apps/browser-extension-wallet/package.json +++ b/apps/browser-extension-wallet/package.json @@ -62,7 +62,6 @@ "@react-rxjs/core": "^0.9.8", "@react-rxjs/utils": "^0.9.5", "@shiroyasha9/axios-fetch-adapter": "^1.0.3", - "@xsy/nami-migration-tool": "file:./xsy-nami-migration-tool-0.0.39.tgz", "antd": "^4.24.10", "are-you-es5": "^2.1.2", "bignumber.js": "9.0.1", diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx index 078c3caa8..071b81cf6 100644 --- a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/UserAvatar.tsx @@ -15,7 +15,7 @@ export const UserAvatar = ({ walletName, isPopup, avatar }: UserAvatarProps): Re {avatar ? ( ) : ( - {walletName && walletName[0]?.toUpperCase()} + {walletName?.[0]?.toUpperCase()} )} ); diff --git a/apps/browser-extension-wallet/src/dapp-connector.tsx b/apps/browser-extension-wallet/src/dapp-connector.tsx index ef153eb1c..641dfc538 100644 --- a/apps/browser-extension-wallet/src/dapp-connector.tsx +++ b/apps/browser-extension-wallet/src/dapp-connector.tsx @@ -5,43 +5,63 @@ import { StoreProvider } from '@stores'; import '@lib/i18n'; import 'antd/dist/antd.css'; import { CurrencyStoreProvider } from '@providers/currency'; -import { DatabaseProvider, AppSettingsProvider, AnalyticsProvider } from '@providers'; +import { DatabaseProvider, AppSettingsProvider, AnalyticsProvider, ExternalLinkOpenerProvider } from '@providers'; import { HashRouter } from 'react-router-dom'; import { ThemeProvider } from '@providers/ThemeProvider'; import { UIThemeProvider } from '@providers/UIThemeProvider'; import { BackgroundServiceAPIProvider } from '@providers/BackgroundServiceAPI'; -import { APP_MODE_POPUP } from './utils/constants'; +import { APP_MODE_POPUP, POPUP_WINDOW_NAMI_TITLE } from './utils/constants'; import { PostHogClientProvider } from '@providers/PostHogClientProvider'; import { ExperimentsProvider } from '@providers/ExperimentsProvider/context'; import { AddressesDiscoveryOverlay } from 'components/AddressesDiscoveryOverlay'; +import { useEffect, useState } from 'react'; +import { getBackgroundStorage } from '@lib/scripts/background/storage'; +import { NamiDappConnector } from './views/nami-mode/indexInternal'; -const App = (): React.ReactElement => ( - - - - - - - - - - - - - - - - - - - - - - - - - -); +const App = (): React.ReactElement => { + const [mode, setMode] = useState<'lace' | 'nami'>(); + useEffect(() => { + const getWalletMode = async () => { + const { namiMigration } = await getBackgroundStorage(); + if (namiMigration?.mode === 'nami') { + document.title = POPUP_WINDOW_NAMI_TITLE; + } + setMode(namiMigration?.mode || 'lace'); + }; + + getWalletMode(); + }, []); + + return ( + + + + + + + + + + + + + + {mode === 'nami' ? : } + + + + + + + + + + + + + + ); +}; const mountNode = document.querySelector('#lace-popup'); render(, mountNode); diff --git a/apps/browser-extension-wallet/src/features/dapp/components/collateral/CollateralContainer.tsx b/apps/browser-extension-wallet/src/features/dapp/components/collateral/CollateralContainer.tsx index 81fe7e68d..8a32bcd26 100644 --- a/apps/browser-extension-wallet/src/features/dapp/components/collateral/CollateralContainer.tsx +++ b/apps/browser-extension-wallet/src/features/dapp/components/collateral/CollateralContainer.tsx @@ -148,29 +148,30 @@ export const DappCollateralContainer = (): React.ReactElement => { } }, [redirectToCreateFailure]); - if (!isCalculatingCollateral) { - if (insufficientBalance) { - return ; - } else if (lockableUtxos.length > 0 && isInstanceOfCollateralInfoWithCollateralAmount(collateralInfo)) { - return ( - confirmCollateral(lockableUtxos)} - /> - ); - } - // Allow user to create tx to set collateral + if (isCalculatingCollateral || !inMemoryWallet) { + return ; + } + + if (insufficientBalance) { + return ; + } else if (lockableUtxos.length > 0 && isInstanceOfCollateralInfoWithCollateralAmount(collateralInfo)) { return ( - confirmCollateral(utxos)} reject={reject} + confirm={() => confirmCollateral(lockableUtxos)} /> ); } - return ; + // Allow user to create tx to set collateral + return ( + confirmCollateral(utxos)} + reject={reject} + /> + ); }; diff --git a/apps/browser-extension-wallet/src/features/dapp/components/confirm-transaction/ConfirmTransaction.tsx b/apps/browser-extension-wallet/src/features/dapp/components/confirm-transaction/ConfirmTransaction.tsx index 548657a3a..1b65ab27c 100644 --- a/apps/browser-extension-wallet/src/features/dapp/components/confirm-transaction/ConfirmTransaction.tsx +++ b/apps/browser-extension-wallet/src/features/dapp/components/confirm-transaction/ConfirmTransaction.tsx @@ -27,7 +27,7 @@ export const ConfirmTransaction = (): React.ReactElement => { setDappInfo, signTxRequest: { request: req, set: setSignTxRequest } } = useViewsFlowContext(); - const { walletType, isHardwareWallet } = useWalletStore(); + const { walletType, isHardwareWallet, walletInfo, inMemoryWallet } = useWalletStore(); const analytics = useAnalyticsContext(); const [confirmTransactionError] = useState(false); const disallowSignTx = useDisallowSignTx(req); @@ -83,7 +83,7 @@ export const ConfirmTransaction = (): React.ReactElement => { return ( - {req ? : } + {req && walletInfo && inMemoryWallet ? : } {!confirmTransactionError && (
- + + + )} @@ -181,11 +208,13 @@ const Asset = ({ asset, enableSend, cardanoCoin, ...props }: Props) => { ); }; -const Fallback = ({ name }) => { +const Fallback = ({ name }: Readonly<{ name?: string }>) => { const [timedOut, setTimedOut] = React.useState(false); const isMounted = useIsMounted(); React.useEffect(() => { - setTimeout(() => isMounted.current && setTimedOut(true), 30000); + setTimeout(() => { + isMounted.current && setTimedOut(true); + }, 30_000); }, []); if (timedOut) return ; return ; diff --git a/packages/nami/src/ui/app/components/assetBadge.tsx b/packages/nami/src/ui/app/components/assetBadge.tsx index d5d93dd4e..fa0ba96fa 100644 --- a/packages/nami/src/ui/app/components/assetBadge.tsx +++ b/packages/nami/src/ui/app/components/assetBadge.tsx @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { useCallback } from 'react'; + import { SmallCloseIcon } from '@chakra-ui/icons'; import { Avatar, @@ -10,11 +13,12 @@ import { InputRightElement, SkeletonCircle, } from '@chakra-ui/react'; -import React from 'react'; +import { NumericFormat } from 'react-number-format'; + import { toUnit } from '../../../api/extension'; import AssetPopover from './assetPopover'; -import { NumericFormat } from 'react-number-format'; + import type { AssetInput } from '../../../types/assets'; const useIsMounted = () => { @@ -32,77 +36,86 @@ const AssetBadge = ({ asset, onRemove, onInput, -}: { +}: Readonly<{ asset: AssetInput; - onRemove; - onInput; -}) => { + onRemove: (asset: Readonly) => void; + onInput: (asset: Readonly, input: string) => void; +}>) => { const [width, setWidth] = React.useState( BigInt(asset.quantity) <= 1 ? 60 : 200, ); + const [isPopoverVisible, setIsPopoverVisible] = React.useState(false); const [value, setValue] = React.useState(''); + const onPopoverClose = useCallback(() => { + setIsPopoverVisible(false); + }, [setIsPopoverVisible]); + React.useEffect(() => { const initialWidth = BigInt(asset.quantity) <= 1 ? 60 : 200; setWidth(initialWidth); if (BigInt(asset.quantity) == BigInt(1)) { setValue('1'); - onInput(1); + onInput(asset, '1'); } else { setValue(asset.input); - onInput(asset.input); + onInput(asset, asset.input); } }, [asset]); return ( - + + - - - - - } - /> + + + + { setValue(formattedValue); - onInput(formattedValue); + onInput(asset, formattedValue); }} isInvalid={ asset.input && @@ -132,22 +145,26 @@ const AssetBadge = ({ } customInput={Input} /> - onRemove()} /> - } - /> + + { + onRemove(asset); + }} + /> + ); }; -const Fallback = ({ name }) => { +const Fallback = ({ name }: Readonly<{ name?: string }>) => { const [timedOut, setTimedOut] = React.useState(false); const isMounted = useIsMounted(); React.useEffect(() => { - setTimeout(() => isMounted.current && setTimedOut(true), 30000); + setTimeout(() => { + isMounted.current && setTimedOut(true); + }, 30_000); }, []); if (timedOut) return ; return ; diff --git a/packages/nami/src/ui/app/components/assetPopover.tsx b/packages/nami/src/ui/app/components/assetPopover.tsx index 97629b4ff..9a346901e 100644 --- a/packages/nami/src/ui/app/components/assetPopover.tsx +++ b/packages/nami/src/ui/app/components/assetPopover.tsx @@ -1,4 +1,6 @@ -import React, { PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + import { Popover, PopoverArrow, @@ -12,99 +14,113 @@ import { Avatar, Text, } from '@chakra-ui/react'; + import Copy from './copy'; import UnitDisplay from './unitDisplay'; + import type { Asset } from '../../../types/assets'; type Props = PropsWithChildren<{ asset: Asset; gutter?: number; + isOpen: boolean; + onClose: () => void; }>; -const AssetPopover = ({ asset, gutter, ...props }: Props) => { +const AssetPopover = ({ + asset, + gutter, + isOpen, + onClose, + ...props +}: Readonly) => { return ( - + {props.children} - - - - - - {asset && ( - - } - /> - - + + + + + {asset && ( + - } + /> + + - {asset.displayName || asset.name} + + {asset.displayName || asset.name} + + + + + - - - - - - - - - - Policy: {asset.policy} - - - - - - - - Asset: {asset.fingerprint} - - - - - )} - - - + + + + + Policy: {asset.policy} + + + + + + + + Asset: {asset.fingerprint} + + + + + )} + + + + )} ); }; diff --git a/packages/nami/src/ui/app/components/assetPopoverDiff.tsx b/packages/nami/src/ui/app/components/assetPopoverDiff.tsx index 0d77efa7e..b43e6d9b8 100644 --- a/packages/nami/src/ui/app/components/assetPopoverDiff.tsx +++ b/packages/nami/src/ui/app/components/assetPopoverDiff.tsx @@ -1,5 +1,7 @@ +import type { RefObject } from 'react'; import React from 'react'; -import { Scrollbars } from '../components/scrollbar'; + +import { ChevronDownIcon } from '@chakra-ui/icons'; import { Avatar, Box, @@ -14,21 +16,33 @@ import { PopoverHeader, PopoverTrigger, } from '@chakra-ui/react'; -import { ChevronDownIcon } from '@chakra-ui/icons'; +import MiddleEllipsis from 'react-middle-ellipsis'; import { FixedSizeList as List } from 'react-window'; -import Copy from './copy'; -import MiddleEllipsis from 'react-middle-ellipsis'; +import { abs } from '../../utils'; +import { Scrollbars } from '../components/scrollbar'; + +import Copy from './copy'; import UnitDisplay from './unitDisplay'; -const abs = big => { - return big < 0 ? big * (-1) : big; -}; +interface CustomScrollbarsProps { + onScroll?: React.UIEventHandler; + children?: React.ReactNode; + forwardedRef: + | React.ForwardedRef + | ((ref: RefObject | null) => void); + style?: React.CSSProperties; +} -const CustomScrollbars = ({ onScroll, forwardedRef, style, children }) => { +const CustomScrollbars = ({ + onScroll, + forwardedRef, + style, + children, +}: Readonly) => { const refSetter = React.useCallback(scrollbarsRef => { - if (scrollbarsRef) { - forwardedRef(scrollbarsRef.view); + if (typeof forwardedRef === 'function') { + scrollbarsRef ? forwardedRef(scrollbarsRef.view) : forwardedRef(null); } }, []); @@ -54,7 +68,9 @@ const AssetsPopover = ({ assets, isDifference }) => { - e.stopPropagation()} w="98%"> + { + e.stopPropagation(); + }} + w="98%" + > Assets @@ -119,6 +140,8 @@ const AssetsPopover = ({ assets, isDifference }) => { }; const Asset = ({ asset, isDifference }) => { + const differenceColor = asset.quantity <= 0 ? 'red.300' : 'teal.500'; + const differenceSign = asset.quantity <= 0 ? '-' : '+'; return ( { - - {isDifference ? (asset.quantity <= 0 ? '-' : '+') : '+'}{' '} - + {isDifference ? differenceSign : '+'} { - const { cardanoCoin } = useOutsideHandles(); +import type { Asset as NamiAsset } from '../../../types/assets'; + +export interface AssetsModalRef { + openModal: (data: Readonly) => void; +} + +interface AssetsModalData { + title: React.ReactNode; + assets: NamiAsset[]; + background?: string; + color?: string; + userInput?: boolean; +} + +const AssetsModal = (_props, ref) => { + const { cardanoCoin } = useCommonOutsideHandles(); const { isOpen, onOpen, onClose } = useDisclosure(); - const [data, setData] = React.useState({ + const [data, setData] = React.useState({ title: '', assets: [], background: '', @@ -24,13 +42,13 @@ const AssetsModal = React.forwardRef((props, ref) => { }); const background = useColorModeValue('white', 'gray.800'); - const abs = (big) => { - return big < 0 ? BigInt(big) * BigInt(-1) : big; - }; - React.useImperativeHandle(ref, () => ({ - openModal(data) { - setData(data); + openModal: (data: Readonly) => { + const transformedAssets = data?.assets.map((a: Readonly) => ({ + ...a, + quantity: abs(a.quantity).toString(), + })); + setData({ ...data, assets: transformedAssets }); onOpen(); }, })); @@ -67,32 +85,26 @@ const AssetsModal = React.forwardRef((props, ref) => { {data.title} - {data.assets.map((asset, index) => { - asset = { - ...asset, - quantity: abs(asset.quantity).toString(), - }; - return ( - - - - - - - - ); - })} + {data.assets.map((asset, index) => ( + + + + + + + + ))} { ); -}); +}; -export default AssetsModal; +export default React.forwardRef(AssetsModal); diff --git a/packages/nami/src/ui/app/components/assetsViewer.tsx b/packages/nami/src/ui/app/components/assetsViewer.tsx index e267c3bf9..a86b24243 100644 --- a/packages/nami/src/ui/app/components/assetsViewer.tsx +++ b/packages/nami/src/ui/app/components/assetsViewer.tsx @@ -1,3 +1,7 @@ +/* eslint-disable unicorn/no-null */ +import React, { useMemo } from 'react'; + +import { SearchIcon, SmallCloseIcon } from '@chakra-ui/icons'; import { Box, IconButton, @@ -13,21 +17,24 @@ import { Text, useColorModeValue, } from '@chakra-ui/react'; -import { SearchIcon, SmallCloseIcon } from '@chakra-ui/icons'; -import React from 'react'; -import Asset from './asset'; import { Planet } from 'react-kawaii'; import { LazyLoadComponent } from 'react-lazy-load-image-component'; -import { useOutsideHandles } from '../../../features/outside-handles-provider'; + import { searchTokens } from '../../../adapters/assets'; -import { Asset as NamiAsset } from '../../../types/assets'; +import { useCommonOutsideHandles } from '../../../features/common-outside-handles-provider'; + +import Asset from './asset'; -const AssetsViewer = ({ assets }) => { +import type { Asset as NamiAsset } from '../../../types/assets'; + +const AssetsViewer = ({ assets }: Readonly<{ assets: NamiAsset[] }>) => { const totalColor = useColorModeValue( 'rgb(26, 32, 44)', 'rgba(255, 255, 255, 0.92)', ); - const [assetsArray, setAssetsArray] = React.useState(null); + const [assetsArray, setAssetsArray] = React.useState( + null, + ); const [search, setSearch] = React.useState(''); const [total, setTotal] = React.useState(0); const createArray = async () => { @@ -37,7 +44,11 @@ const AssetsViewer = ({ assets }) => { return; } setAssetsArray(null); - await new Promise((res, rej) => setTimeout(() => res(), 10)); + await new Promise((res, rej) => + setTimeout(() => { + res(void 0); + }, 10), + ); const filteredAssets = search ? searchTokens(assets, search) : assets; setTotal(filteredAssets.length); setAssetsArray(filteredAssets); @@ -53,19 +64,10 @@ const AssetsViewer = ({ assets }) => { }; }, []); - return ( - <> - - {!(assets && assetsArray) ? ( - - - - ) : assetsArray.length <= 0 ? ( + const AssetComponent = useMemo(() => { + if (assets && assetsArray) { + if (assetsArray.length <= 0) { + return ( { No Assets - ) : ( + ); + } else { + return ( <> { - )} + ); + } + } + + return ( + + + + ); + }, [assets, assetsArray, totalColor, total]); + + return ( + <> + + {AssetComponent} @@ -103,8 +121,8 @@ const AssetsViewer = ({ assets }) => { ); }; -const AssetsGrid = ({ assets }) => { - const { cardanoCoin } = useOutsideHandles(); +const AssetsGrid = ({ assets }: Readonly<{ assets: NamiAsset[] }>) => { + const { cardanoCoin } = useCommonOutsideHandles(); return ( { 0 && 4} + mt={(index > 0 && 4) || undefined} display="flex" alignItems="center" justifyContent="center" @@ -131,10 +149,13 @@ const AssetsGrid = ({ assets }) => { ); }; -const Search = ({ setSearch, assets }) => { +const Search = ({ + setSearch, + assets, +}: Readonly<{ setSearch: (s: string) => void; assets: NamiAsset[] }>) => { const [input, setInput] = React.useState(''); const iconColor = useColorModeValue('gray.800', 'rgba(255, 255, 255, 0.92)'); - const ref = React.useRef(); + const ref = React.useRef(null); React.useEffect(() => { if (!assets) { setInput(''); @@ -145,7 +166,7 @@ const Search = ({ setSearch, assets }) => { setTimeout(() => ref.current.focus())} + onOpen={() => setTimeout(() => ref.current?.focus())} > { rounded="md" placeholder="Search policy, asset, name" fontSize="xs" - onInput={(e) => { - setInput(e.target.value); + onInput={e => { + setInput((e.target as HTMLInputElement).value); }} - onKeyDown={(e) => { + onKeyDown={e => { if (e.key === 'Enter' && input) setSearch(input); }} /> - setInput('')} /> - } - /> + + { + setInput(''); + }} + /> + { size="sm" rounded="md" color="teal.400" - onClick={() => input && setSearch(input)} + onClick={() => { + input && setSearch(input); + }} icon={} /> diff --git a/packages/nami/src/ui/app/components/avatarLoader.tsx b/packages/nami/src/ui/app/components/avatarLoader.tsx index 07220c6d2..7475895f4 100644 --- a/packages/nami/src/ui/app/components/avatarLoader.tsx +++ b/packages/nami/src/ui/app/components/avatarLoader.tsx @@ -1,16 +1,14 @@ -import { Box } from '@chakra-ui/react'; import React from 'react'; + +import { Box } from '@chakra-ui/react'; + import { avatarToImage } from '../../../api/extension'; const AvatarLoader = ({ avatar, width, smallRobot, -}: { - avatar?: string; - width: string; - smallRobot?: boolean; -}) => { +}: Readonly<{ avatar?: string; width: string; smallRobot?: boolean }>) => { const [loaded, setLoaded] = React.useState(''); const fetchAvatar = async () => { @@ -30,7 +28,7 @@ const AvatarLoader = ({ backgroundImage={loaded ? `url(${loaded})` : 'none'} backgroundRepeat={'no-repeat'} backgroundSize={'cover'} - > + /> ); }; diff --git a/packages/nami/src/ui/app/components/changePasswordModal.tsx b/packages/nami/src/ui/app/components/changePasswordModal.tsx index 0e8faa94f..fa31a95ce 100644 --- a/packages/nami/src/ui/app/components/changePasswordModal.tsx +++ b/packages/nami/src/ui/app/components/changePasswordModal.tsx @@ -1,3 +1,7 @@ +/* eslint-disable unicorn/no-null */ +/* eslint-disable react/no-unescaped-entities */ +import React from 'react'; + import { Button, Box, @@ -14,22 +18,25 @@ import { useToast, useDisclosure, } from '@chakra-ui/react'; -import React from 'react'; -import { useCaptureEvent } from '../../../features/analytics/hooks'; + import { Events } from '../../../features/analytics/events'; +import { useCaptureEvent } from '../../../features/analytics/hooks'; + +interface Props { + changePassword: ( + currentPassword: string, + newPassword: string, + ) => Promise; +} -export const ChangePasswordModal = React.forwardRef< - {}, - { - changePassword: ( - currentPassword: string, - newPassword: string, - ) => Promise; - } ->(({ changePassword }, ref) => { +export interface ChangePasswordModalComponentRef { + openModal: () => void; +} + +const ChangePasswordModalComponent = ({ changePassword }: Props, ref) => { const capture = useCaptureEvent(); - const cancelRef = React.useRef(); - const inputRef = React.useRef(); + const cancelRef = React.useRef(null); + const inputRef = React.useRef(null); const toast = useToast(); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -39,7 +46,7 @@ export const ChangePasswordModal = React.forwardRef< newPassword: '', repeatPassword: '', matchingPassword: false, - passwordLen: null, + passwordLen: false, show: false, }); @@ -49,13 +56,13 @@ export const ChangePasswordModal = React.forwardRef< newPassword: '', repeatPassword: '', matchingPassword: false, - passwordLen: null, + passwordLen: false, show: false, }); }, [isOpen]); React.useImperativeHandle(ref, () => ({ - openModal() { + openModal: () => { onOpen(); }, })); @@ -82,9 +89,10 @@ export const ChangePasswordModal = React.forwardRef< capture(Events.SettingsChangePasswordConfirm); onClose(); - } catch (e) { + } catch (error) { toast({ - title: e instanceof Error ? e.message : 'Password update failed!', + title: + error instanceof Error ? error.message : 'Password update failed!', status: 'error', duration: 5000, }); @@ -118,16 +126,18 @@ export const ChangePasswordModal = React.forwardRef< variant="filled" pr="4.5rem" type={state.show ? 'text' : 'password'} - onChange={e => - setState(s => ({ ...s, currentPassword: e.target.value })) - } + onChange={e => { + setState(s => ({ ...s, currentPassword: e.target.value })); + }} placeholder="Enter current password" /> @@ -145,24 +155,26 @@ export const ChangePasswordModal = React.forwardRef< pr="4.5rem" isInvalid={state.passwordLen === false} type={state.show ? 'text' : 'password'} - onChange={e => - setState(s => ({ ...s, newPassword: e.target.value })) - } - onBlur={e => + onChange={e => { + setState(s => ({ ...s, newPassword: e.target.value })); + }} + onBlur={e => { setState(s => ({ ...s, passwordLen: e.target.value ? e.target.value.length >= 8 - : null, - })) - } + : false, + })); + }} placeholder="Enter new password" /> @@ -184,21 +196,23 @@ export const ChangePasswordModal = React.forwardRef< focusBorderColor="teal.400" variant="filled" isInvalid={ - state.repeatPassword && + !!state.repeatPassword && state.newPassword !== state.repeatPassword } pr="4.5rem" type={state.show ? 'text' : 'password'} - onChange={e => - setState(s => ({ ...s, repeatPassword: e.target.value })) - } + onChange={e => { + setState(s => ({ ...s, repeatPassword: e.target.value })); + }} placeholder="Repeat new password" /> @@ -235,4 +249,8 @@ export const ChangePasswordModal = React.forwardRef< ); -}); +}; + +export const ChangePasswordModal = React.forwardRef( + ChangePasswordModalComponent, +); diff --git a/packages/nami/src/ui/app/components/collectible.tsx b/packages/nami/src/ui/app/components/collectible.tsx index b5d1d2f8f..d83bd1109 100644 --- a/packages/nami/src/ui/app/components/collectible.tsx +++ b/packages/nami/src/ui/app/components/collectible.tsx @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from 'react'; + import { Box, Avatar, @@ -5,127 +8,137 @@ import { Skeleton, useColorModeValue, } from '@chakra-ui/react'; -import React from 'react'; + import './styles.css'; -import { useCaptureEvent } from '../../../features/analytics/hooks'; import { Events } from '../../../features/analytics/events'; +import { useCaptureEvent } from '../../../features/analytics/hooks'; const useIsMounted = () => { const isMounted = React.useRef(false); - React.useEffect(() => { + React.useEffect((): (() => void) => { isMounted.current = true; return () => (isMounted.current = false); }, []); return isMounted; }; -const Collectible = React.forwardRef(({ asset, ...props }, ref) => { +const CollectibleComponent = ({ asset, ...props }, ref) => { const capture = useCaptureEvent(); const background = useColorModeValue('gray.300', 'white'); const [showInfo, setShowInfo] = React.useState(false); return ( + { + capture(Events.NFTsImageClick); + asset && ref.current?.openModal(asset); + }} + position="relative" + display="flex" + alignItems="center" + justifyContent="center" + flexDirection="column" + width="160px" + height="160px" + overflow="hidden" + rounded="3xl" + background={background} + border="solid 1px" + borderColor={background} + onMouseEnter={() => { + setShowInfo(true); + }} + onMouseLeave={() => { + setShowInfo(false); + }} + cursor="pointer" + userSelect="none" + data-testid={props.testId} + > { - capture(Events.NFTsImageClick); - asset && ref.current.openModal(asset); - }} - position="relative" + filter={(showInfo && 'brightness(0.6)') || undefined} + width="180%" display="flex" alignItems="center" justifyContent="center" - flexDirection="column" - width="160px" - height="160px" - overflow="hidden" - rounded="3xl" - background={background} - border="solid 1px" - borderColor={background} - onMouseEnter={() => setShowInfo(true)} - onMouseLeave={() => setShowInfo(false)} - cursor="pointer" - userSelect="none" - data-testid={props.testId} > + {asset ? ( + + ) : ( + + ) + } + /> + ) : ( + + )} + + {asset && ( - {!asset ? ( - - ) : ( - - ) : ( - - ) - } - /> - )} - - {asset && ( + {asset.name} + + - - {asset.name} - - - x {asset.quantity} - + x {asset.quantity} - )} - + + )} + ); -}); +}; + +const Collectible = React.forwardRef(CollectibleComponent); + +Collectible.displayName = 'Collectible'; -const Fallback = ({ name }) => { +const Fallback = ({ name }: Readonly<{ name?: string }>) => { const [timedOut, setTimedOut] = React.useState(false); const isMounted = useIsMounted(); React.useEffect(() => { - setTimeout(() => isMounted.current && setTimedOut(true), 30000); + setTimeout(() => { + isMounted.current && setTimedOut(true); + }, 30_000); }, []); if (timedOut) return ; return ; diff --git a/packages/nami/src/ui/app/components/collectiblesViewer.tsx b/packages/nami/src/ui/app/components/collectiblesViewer.tsx index 15b20709d..fcf9a8380 100644 --- a/packages/nami/src/ui/app/components/collectiblesViewer.tsx +++ b/packages/nami/src/ui/app/components/collectiblesViewer.tsx @@ -1,3 +1,8 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable unicorn/no-null */ +import React, { useRef } from 'react'; + +import { SearchIcon, SmallCloseIcon } from '@chakra-ui/icons'; import { Box, SimpleGrid, @@ -21,28 +26,31 @@ import { useColorModeValue, Button, } from '@chakra-ui/react'; -import { SearchIcon, SmallCloseIcon } from '@chakra-ui/icons'; -import React, { useRef } from 'react'; +import { BsArrowUpRight } from 'react-icons/bs'; import { Planet } from 'react-kawaii'; -import Collectible from './collectible'; import { LazyLoadComponent } from 'react-lazy-load-image-component'; -import './styles.css'; -import Copy from './copy'; import { useHistory } from 'react-router-dom'; -import { BsArrowUpRight } from 'react-icons/bs'; -import { useStoreActions, useStoreState } from '../../store'; -import { useCaptureEvent } from '../../../features/analytics/hooks'; -import { Events } from '../../../features/analytics/events'; -import { Asset } from '../../../types/assets'; + import { searchTokens } from '../../../adapters/assets'; +import { Events } from '../../../features/analytics/events'; +import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { useStoreActions, useStoreState } from '../../store'; + +import Collectible from './collectible'; +import './styles.css'; +import Copy from './copy'; + +import type { Asset as NamiAsset } from '../../../types/assets'; interface Props { - assets: Asset[]; + assets: NamiAsset[]; setAvatar: (image: string) => void; } const CollectiblesViewer = ({ assets, setAvatar }: Readonly) => { - const [assetsArray, setAssetsArray] = React.useState(null); + const [assetsArray, setAssetsArray] = React.useState( + null, + ); const [search, setSearch] = React.useState(''); const [total, setTotal] = React.useState(0); const ref = useRef(); @@ -55,7 +63,11 @@ const CollectiblesViewer = ({ assets, setAvatar }: Readonly) => { return; } setAssetsArray(null); - await new Promise((res, rej) => setTimeout(() => res(), 10)); + await new Promise(res => + setTimeout(() => { + res(void 0); + }, 10), + ); const filteredAssets = searchTokens(assets, search); setTotal(filteredAssets.length); setAssetsArray(filteredAssets); @@ -79,7 +91,32 @@ const CollectiblesViewer = ({ assets, setAvatar }: Readonly) => { return ( <> - {!(assets && assetsArray) ? ( + {assets && assetsArray ? ( + assetsArray.length <= 0 ? ( + + + + + No Collectibles + + + ) : ( + <> + + {total} {total == 1 ? 'Collectible' : 'Collectibles'} + + + + + ) + ) : ( ) => { > - ) : assetsArray.length <= 0 ? ( - - - - - No Collectibles - - - ) : ( - <> - - {total} {total == 1 ? 'Collectible' : 'Collectibles'} - - - - )} @@ -121,9 +135,16 @@ const CollectiblesViewer = ({ assets, setAvatar }: Readonly) => { ); }; -export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { +interface CollectibleModalProps { + onUpdateAvatar: (s: string) => Promise; +} + +export const CollectibleModalComponent = ( + { onUpdateAvatar }: CollectibleModalProps, + ref, +) => { const { isOpen, onOpen, onClose } = useDisclosure(); - const [asset, setAsset] = React.useState(null); + const [asset, setAsset] = React.useState(null); const [fallback, setFallback] = React.useState(false); // remove short flickering where image is not instantly loaded const background = useColorModeValue('white', 'gray.800'); const dividerColor = useColorModeValue('gray.200', 'gray.700'); @@ -133,15 +154,17 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { ]; const history = useHistory(); const navigate = history.push; - const timer = React.useRef(); + const timer = React.useRef(); React.useImperativeHandle(ref, () => ({ - openModal(asset) { + openModal: asset => { setAsset(asset); - timer.current = setTimeout(() => setFallback(true)); + timer.current = setTimeout(() => { + setFallback(true); + }); onOpen(); }, - closeModal() { + closeModal: () => { clearTimeout(timer.current); setFallback(false); onClose(); @@ -149,7 +172,7 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { })); return ( { {asset && ( @@ -176,14 +199,15 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { width="full" objectFit="contain" fallback={ - fallback && ( + (fallback && ( - ) + )) || + undefined } /> ) : ( @@ -213,7 +237,7 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { top="22px" size="xs" onClick={async () => { - await onUpdateAvatar(asset.image); + await onUpdateAvatar(asset.image ?? ''); }} > As Avatar @@ -226,7 +250,7 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { size="xs" rightIcon={} onClick={e => { - setValue({ ...value, assets: [asset] }); + setValue({ ...value, assets: [{ ...asset, input: '' }] }); navigate('/send'); }} > @@ -242,7 +266,12 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { Policy - e.stopPropagation()}> + { + e.stopPropagation(); + }} + > {asset.policy}{' '} @@ -254,7 +283,12 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { Asset - e.stopPropagation()}> + { + e.stopPropagation(); + }} + > {asset.fingerprint} @@ -265,36 +299,47 @@ export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => { )} ); -}); +}; -const AssetsGrid = React.forwardRef(({ assets }, ref) => { - return ( - - - {assets.map((asset) => ( - - - - - - ))} - - - ); -}); +const CollectibleModal = React.forwardRef(CollectibleModalComponent); -const Search = ({ setSearch, assets }) => { +CollectibleModal.displayName = 'CollectibleModal'; + +const AssetsGrid = React.forwardRef( + ({ assets }: Readonly<{ assets: NamiAsset[] }>, ref) => { + return ( + + + {assets.map(asset => ( + + + + + + ))} + + + ); + }, +); + +AssetsGrid.displayName = 'AssetsGrid'; + +const Search = ({ + setSearch, + assets, +}: Readonly<{ setSearch: (s: string) => void; assets: NamiAsset[] }>) => { const [input, setInput] = React.useState(''); - const ref = React.useRef(); + const ref = React.useRef(null); React.useEffect(() => { if (!assets) { setInput(''); @@ -305,7 +350,7 @@ const Search = ({ setSearch, assets }) => { setTimeout(() => ref.current.focus())} + onOpen={() => setTimeout(() => ref.current?.focus())} > { placeholder="Search policy, asset, name" fontSize="xs" onInput={e => { - setInput(e.target.value); + setInput((e.target as HTMLInputElement).value); }} onKeyDown={e => { if (e.key === 'Enter' && input) setSearch(input); }} /> - setInput('')} /> - } - /> + + { + setInput(''); + }} + /> + { size="sm" rounded="md" color="teal.400" - onClick={() => input && setSearch(input)} + onClick={() => { + input && setSearch(input); + }} icon={} /> @@ -363,4 +413,6 @@ const Search = ({ setSearch, assets }) => { ); }; +Search.displayName = 'Search'; + export default CollectiblesViewer; diff --git a/packages/nami/src/ui/app/components/confirmModal.tsx b/packages/nami/src/ui/app/components/confirmModal.tsx index 69d0c4beb..e475d8b99 100644 --- a/packages/nami/src/ui/app/components/confirmModal.tsx +++ b/packages/nami/src/ui/app/components/confirmModal.tsx @@ -1,4 +1,8 @@ -import type { PasswordObj as Password } from '@lace/core'; +/* eslint-disable unicorn/prefer-logical-operator-over-ternary */ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from 'react'; + +import { WalletType } from '@cardano-sdk/web-extension'; import { Icon, Box, @@ -15,78 +19,125 @@ import { ModalHeader, ModalOverlay, } from '@chakra-ui/react'; -import React from 'react'; import { MdUsb } from 'react-icons/md'; -import { indexToHw, initHW, isHW } from '../../../api/extension'; -import { ERROR, HW } from '../../../config/config'; + +import { ERROR } from '../../../config/config'; + +import type { PasswordObj as Password } from '@lace/core'; +import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { Events } from '../../../features/analytics/events'; interface Props { - ready: boolean; - onConfirm: (status: boolean, tx: string) => void; - sign: (password: string, hw: object) => Promise; - setPassword: (pw: Readonly>) => void; - onCloseBtn: () => void; - title: React.ReactNode; - info: React.ReactNode; + ready?: boolean; + onConfirm: (status: boolean, error?: string) => Promise | void; + sign: (password?: string) => Promise; + setPassword?: (pw: Readonly>) => void; + onCloseBtn?: () => void; + title?: React.ReactNode; + info?: React.ReactNode; + walletType: WalletType; + openHWFlow?: (path: string) => void; + getCbor?: () => Promise; + setCollateral?: boolean; + isPopup?: boolean; } -const ConfirmModal = React.forwardRef( - ({ ready, onConfirm, sign, onCloseBtn, title, info, setPassword }, ref) => { - const { - isOpen: isOpenNormal, - onOpen: onOpenNormal, - onClose: onCloseNormal, - } = useDisclosure(); - const { - isOpen: isOpenHW, - onOpen: onOpenHW, - onClose: onCloseHW, - } = useDisclosure(); - const props = { - ready, - onConfirm, - sign, - onCloseBtn, - title, - info, - }; - const [hw, setHw] = React.useState(''); +export interface ConfirmModalRef { + openModal: () => void; + closeModal: () => void; +} - React.useImperativeHandle(ref, () => ({ - openModal(accountIndex) { - if (isHW(accountIndex)) { - setHw(indexToHw(accountIndex)); - onOpenHW(); - } else { - onOpenNormal(); - } - }, - closeModal() { - onCloseNormal(); - onCloseHW(); - }, - })); +const ConfirmModal = ( + { + ready, + onConfirm, + sign, + onCloseBtn, + title, + info, + setPassword, + walletType, + openHWFlow, + getCbor, + setCollateral, + isPopup, + }: Readonly, + ref, +) => { + const { + isOpen: isOpenNormal, + onOpen: onOpenNormal, + onClose: onCloseNormal, + } = useDisclosure(); + const { + isOpen: isOpenHW, + onOpen: onOpenHW, + onClose: onCloseHW, + } = useDisclosure(); + const props = { + ready, + onConfirm, + sign, + onCloseBtn, + title, + info, + walletType, + setCollateral, + isPopup, + }; + React.useImperativeHandle(ref, () => ({ + openModal: () => { + if ( + walletType === WalletType.Ledger || + walletType === WalletType.Trezor + ) { + onOpenHW(); + } else { + onOpenNormal(); + } + }, + closeModal: () => { + onCloseNormal(); + onCloseHW(); + }, + })); - return ( - <> + return ( + <> + {typeof openHWFlow === 'function' && + [WalletType.Ledger, WalletType.Trezor].includes(walletType) ? ( + ) : ( - - ); - } -); + )} + + ); +}; -const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { +interface ConfirmModalNormalProps { + isOpen?: boolean; + onClose: () => void; + setPassword?: (pw: Readonly>) => void; + props: Props; +} + +const ConfirmModalNormal = ({ + props, + isOpen, + onClose, + setPassword, +}: Readonly) => { const [state, setState] = React.useState({ wrongPassword: false, password: '', @@ -94,7 +145,7 @@ const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { name: '', }); const [waitReady, setWaitReady] = React.useState(true); - const inputRef = React.useRef(); + const inputRef = React.useRef(null); React.useEffect(() => { setState({ @@ -109,13 +160,20 @@ const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { if (!state.password || props.ready === false || !waitReady) return; try { setWaitReady(false); - const signedMessage = await props.sign(state.password); - await props.onConfirm(true, signedMessage); + await props.sign(state.password); + await props?.onConfirm(true); onClose?.(); - } catch (e) { - if (e === ERROR.wrongPassword || e.name === 'AuthenticationError') - setState((s) => ({ ...s, wrongPassword: true })); - else await props.onConfirm(false, e); + } catch (error) { + if ( + error === ERROR.wrongPassword || + (error instanceof Error && error.name === 'AuthenticationError') + ) + setState(s => ({ ...s, wrongPassword: true })); + else + await props.onConfirm( + false, + error instanceof Error ? error.name : (error || '').toString(), + ); } setWaitReady(true); }; @@ -123,17 +181,16 @@ const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { return ( { if (props.onCloseBtn) { props.onCloseBtn(); } - onClose() + onClose(); }} isCentered initialFocusRef={inputRef} blockScrollOnMount={false} - // styleConfig={{maxWidth: '100%'}} > @@ -147,14 +204,14 @@ const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { ref={inputRef} focusBorderColor="teal.400" variant="filled" - isInvalid={state.wrongPassword === true} + isInvalid={state.wrongPassword} pr="4.5rem" type={state.show ? 'text' : 'password'} - onChange={(e) => { + onChange={e => { setPassword?.(e.target); - setState((s) => ({ ...s, password: e.target.value })); + setState(s => ({ ...s, password: e.target.value })); }} - onKeyDown={(e) => { + onKeyDown={e => { if (e.key == 'Enter') confirmHandler(); }} placeholder="Enter password" @@ -163,13 +220,15 @@ const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { - {state.wrongPassword === true && ( + {state.wrongPassword && ( Password is wrong )} @@ -201,22 +260,57 @@ const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => { ); }; -const ConfirmModalHw = ({ props, isOpen, onClose, hw }) => { +interface ConfirmModalHwProps { + isOpen?: boolean; + onClose: () => void; + openHWFlow: (path: string) => void; + getCbor?: () => Promise; + props: Props; +} + +const ConfirmModalHw = ({ + props, + isOpen, + getCbor, + openHWFlow, + onClose, +}: Readonly) => { const [waitReady, setWaitReady] = React.useState(true); const [error, setError] = React.useState(''); + const capture = useCaptureEvent(); const confirmHandler = async () => { - if (props.ready === false || !waitReady) return; - try { + if ( + props.walletType === WalletType.Trezor && + props.isPopup && + typeof getCbor === 'function' + ) { + const cbor = await getCbor(); + + if (cbor === '') { + setError('An error occurred'); + return; + } + + if (props.setCollateral) { + openHWFlow(`/hwTab/trezorTx/${cbor}/${props.setCollateral}`); + } else { + openHWFlow(`/hwTab/trezorTx/${cbor}`); + } + } else { + if (props.ready === false || !waitReady) return; setWaitReady(false); - const appAda = await initHW({ device: hw.device, id: hw.id }); - const signedMessage = await props.sign(null, { ...hw, appAda }); - await props.onConfirm(true, signedMessage); - } catch (e) { - if (e === ERROR.submit) props.onConfirm(false, e); - else setError('An error occured'); + try { + await props.sign(); + await props.onConfirm(true); + capture(Events.SendTransactionConfirmed); + } catch (error_) { + console.error(error_); + if (error_ === ERROR.submit) props.onConfirm(false, error_); + else setError('An error occured'); + } + setWaitReady(true); } - setWaitReady(true); }; React.useEffect(() => { @@ -227,7 +321,7 @@ const ConfirmModalHw = ({ props, isOpen, onClose, hw }) => { <> { display="flex" alignItems="center" justifyContent="center" - background={hw.device == HW.ledger ? 'blue.400' : 'green.400'} + background={ + props.walletType === WalletType.Ledger + ? 'blue.400' + : 'green.400' + } rounded="xl" py={2} width="70%" @@ -258,11 +356,9 @@ const ConfirmModalHw = ({ props, isOpen, onClose, hw }) => { > - {!waitReady - ? `Waiting for ${ - hw.device == HW.ledger ? 'Ledger' : 'Trezor' - }` - : `Connect ${hw.device == HW.ledger ? 'Ledger' : 'Trezor'}`} + {waitReady + ? `Connect ${props.walletType}` + : `Waiting for ${props.walletType}`} {error && ( @@ -301,4 +397,4 @@ const ConfirmModalHw = ({ props, isOpen, onClose, hw }) => { ); }; -export default ConfirmModal; +export default React.forwardRef(ConfirmModal); diff --git a/packages/nami/src/ui/app/components/copy.tsx b/packages/nami/src/ui/app/components/copy.tsx index 32d28070c..55818a36f 100644 --- a/packages/nami/src/ui/app/components/copy.tsx +++ b/packages/nami/src/ui/app/components/copy.tsx @@ -1,7 +1,16 @@ -import { Box, Tooltip } from '@chakra-ui/react'; +/* eslint-disable @typescript-eslint/naming-convention */ import React from 'react'; -const Copy = ({ label, copy, onClick, ...props }) => { +import { Box, Tooltip } from '@chakra-ui/react'; + +interface Props { + label?: React.ReactNode; + copy: string; + onClick?: () => void; + children?: React.ReactNode; +} + +const Copy = ({ label, copy, onClick, ...props }: Readonly) => { const [copied, setCopied] = React.useState(false); return ( @@ -11,7 +20,9 @@ const Copy = ({ label, copy, onClick, ...props }) => { if (onClick) onClick(); navigator.clipboard.writeText(copy); setCopied(true); - setTimeout(() => setCopied(false), 800); + setTimeout(() => { + setCopied(false); + }, 800); }} > {props.children} diff --git a/packages/nami/src/ui/app/components/historyViewer.tsx b/packages/nami/src/ui/app/components/historyViewer.tsx index 7619b4f7a..c679722db 100644 --- a/packages/nami/src/ui/app/components/historyViewer.tsx +++ b/packages/nami/src/ui/app/components/historyViewer.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/no-multi-comp */ -import React from 'react'; +import React, { useMemo } from 'react'; import { ChevronDownIcon } from '@chakra-ui/icons'; import { Box, Text, Spinner, Accordion, Button } from '@chakra-ui/react'; @@ -7,6 +7,7 @@ import { File } from 'react-kawaii'; import { Events } from '../../../features/analytics/events'; import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { useCommonOutsideHandles } from '../../../features/common-outside-handles-provider'; import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles'; import Transaction from './transaction'; @@ -17,99 +18,99 @@ const BATCH = 5; const HistoryViewer = () => { const capture = useCaptureEvent(); - const { cardanoCoin, transactions, environmentName, openExternalLink } = + const { transactions, environmentName, openExternalLink } = useOutsideHandles(); + + const { cardanoCoin } = useCommonOutsideHandles(); const [historySlice, setHistorySlice] = React.useState< - Wallet.Cardano.HydratedTx[] | undefined + (Wallet.Cardano.HydratedTx | Wallet.TxInFlight)[] | undefined >(); const [page, setPage] = React.useState(1); const [isFinal, setIsFinal] = React.useState(false); const getTxs = () => { if (!transactions) return; - const slice = (historySlice ?? []).concat( - transactions.slice((page - 1) * BATCH, page * BATCH), - ); + const slice = transactions.slice(0, page * BATCH); if (slice.length < page * BATCH) setIsFinal(true); setHistorySlice(slice); }; React.useEffect(() => { getTxs(); - }, [page]); + }, [page, transactions]); - return ( - - {historySlice ? ( - historySlice.length <= 0 ? ( - + historySlice && historySlice.length <= 0 ? ( + + + + + No History + + + ) : ( + <> + { + void capture(Events.ActivityActivityActivityRowClick); + }} > - - - - No History - - - ) : ( - <> - { - void capture(Events.ActivityActivityActivityRowClick); - }} + {historySlice?.map(tx => ( + + ))} + + {isFinal ? ( + - {historySlice.map(tx => ( - - ))} - - {isFinal ? ( - + ) : ( + + - - )} - - ) - ) : ( - - )} - + + + + )} + + ), + [historySlice, setPage, openExternalLink, capture], + ); + + return ( + {historySlice ? history : } ); }; diff --git a/packages/nami/src/ui/app/components/laceSecondaryButton.tsx b/packages/nami/src/ui/app/components/laceSecondaryButton.tsx index a5229b94a..8169151c6 100644 --- a/packages/nami/src/ui/app/components/laceSecondaryButton.tsx +++ b/packages/nami/src/ui/app/components/laceSecondaryButton.tsx @@ -1,22 +1,26 @@ import React from 'react'; + import { Button, Text } from '@chakra-ui/react'; -type Props = { - children: string; +interface Props { + children: React.ReactNode; onClick?: () => void; -}; +} -const LaceSecondaryButton = ({ children, onClick }: Props) => { +const LaceSecondaryButton = ({ children, onClick }: Readonly) => { return ( ); }; diff --git a/packages/nami/src/ui/app/components/privacyPolicy.tsx b/packages/nami/src/ui/app/components/privacyPolicy.tsx index 5c8df6680..90f64db1d 100644 --- a/packages/nami/src/ui/app/components/privacyPolicy.tsx +++ b/packages/nami/src/ui/app/components/privacyPolicy.tsx @@ -1,4 +1,5 @@ import React from 'react'; + import { Box, Text, @@ -13,15 +14,21 @@ import { ListItem, Link, } from '@chakra-ui/react'; + import { Scrollbars } from './scrollbar'; -const PrivacyPolicy = React.forwardRef((props, ref) => { +export interface PrivacyPolicyRef { + openModal: () => void; + closeModal: () => void; +} + +const PrivacyPolicy = React.forwardRef((_props, ref) => { const { isOpen, onOpen, onClose } = useDisclosure(); React.useImperativeHandle(ref, () => ({ - openModal() { + openModal: () => { onOpen(); }, - closeModal() { + closeModal: () => { onClose(); }, })); @@ -588,8 +595,8 @@ const PrivacyPolicy = React.forwardRef((props, ref) => { IOG may update this Privacy Policy from time to time. Such changes will be posted on this page. The effective date of such changes will be notified via email and/or a prominent notice on - the Product, with an update to the "effective date" at the top of - this Privacy Policy. + the Product, with an update to the "effective date" at the top + of this Privacy Policy. You are advised to review this Privacy Policy periodically for @@ -644,4 +651,6 @@ const PrivacyPolicy = React.forwardRef((props, ref) => { ); }); +PrivacyPolicy.displayName = 'PrivacyPolicy'; + export default PrivacyPolicy; diff --git a/packages/nami/src/ui/app/components/qrCode.tsx b/packages/nami/src/ui/app/components/qrCode.tsx index 24bbc2ca9..b6cb6bf9f 100644 --- a/packages/nami/src/ui/app/components/qrCode.tsx +++ b/packages/nami/src/ui/app/components/qrCode.tsx @@ -1,7 +1,9 @@ import React from 'react'; + +import { useColorModeValue } from '@chakra-ui/react'; import QRCodeStyling from 'qr-code-styling'; + import Ada from '../../../assets/img/ada.png'; -import { useColorModeValue } from '@chakra-ui/react'; const qrCode = new QRCodeStyling({ width: 150, @@ -18,16 +20,16 @@ const qrCode = new QRCodeStyling({ }, }); -const QrCode = ({ value }) => { - const ref = React.useRef(null); +const QrCode = ({ value }: Readonly<{ value?: string }>) => { + const ref = React.useRef(null); const bgColor = useColorModeValue('white', '#2D3748'); const contentColor = useColorModeValue( { corner: '#DD6B20', dots: '#319795' }, - { corner: '#FBD38D', dots: '#81E6D9' } + { corner: '#FBD38D', dots: '#81E6D9' }, ); React.useEffect(() => { - qrCode.append(ref.current); + ref.current && qrCode.append(ref.current); }, []); React.useEffect(() => { diff --git a/packages/nami/src/ui/app/components/switchToLaceBanner.tsx b/packages/nami/src/ui/app/components/switchToLaceBanner.tsx index 5a9b4fd7b..2fb47ec2f 100644 --- a/packages/nami/src/ui/app/components/switchToLaceBanner.tsx +++ b/packages/nami/src/ui/app/components/switchToLaceBanner.tsx @@ -14,9 +14,11 @@ import { useStoreActions, useStoreState } from '../../store'; import LaceSecondaryButton from './laceSecondaryButton'; export const getLaceVideoBackgroundSrc = () => { - return typeof chrome !== 'undefined' && - chrome.runtime?.getURL('laceVideoBackground.mp4') - || laceVideoBackground; + return ( + (typeof chrome !== 'undefined' && + chrome.runtime?.getURL('laceVideoBackground.mp4')) || + laceVideoBackground + ); }; interface Props { @@ -154,7 +156,7 @@ export const SwitchToLaceBanner = ({ switchWalletMode }: Props) => { bgClip="text" fontWeight="extrabold" > - Your Nami wallet evolved! + Upgrade your wallet! { }} > - Enable Lace Mode to unlock access to new and exciting Web - 3 features + Enable Lace mode to access new and exciting Web3 features. - You can return to the "Nami Mode" at any time + You can revert to Nami mode at any time via the Settings + page.
@@ -199,7 +201,7 @@ export const SwitchToLaceBanner = ({ switchWalletMode }: Props) => { bgGradient="linear(180deg, transparent, rgba(255, 255, 255, 0.9) 50%)" /> - Activate Lace Mode + Activate Lace mode { diff --git a/packages/nami/src/ui/app/components/termsOfUse.tsx b/packages/nami/src/ui/app/components/termsOfUse.tsx index e19f3cf87..9f5c3dcdf 100644 --- a/packages/nami/src/ui/app/components/termsOfUse.tsx +++ b/packages/nami/src/ui/app/components/termsOfUse.tsx @@ -1,4 +1,6 @@ +/* eslint-disable react/no-unescaped-entities */ import React from 'react'; + import { Box, Text, @@ -13,20 +15,27 @@ import { ListItem, Link, } from '@chakra-ui/react'; -import { Scrollbars } from './scrollbar'; -import { useCaptureEvent } from '../../../features/analytics/hooks'; -import { Events } from '../../../features/analytics/events'; import { useOutsideHandles } from 'features/outside-handles-provider/useOutsideHandles'; -const TermsOfUse = React.forwardRef((props, ref) => { +import { Events } from '../../../features/analytics/events'; +import { useCaptureEvent } from '../../../features/analytics/hooks'; + +import { Scrollbars } from './scrollbar'; + +export interface TermsOfUseRef { + openModal: () => void; + closeModal: () => void; +} + +const TermsOfUse = React.forwardRef((_props, ref) => { const capture = useCaptureEvent(); const { openExternalLink } = useOutsideHandles(); const { isOpen, onOpen, onClose } = useDisclosure(); React.useImperativeHandle(ref, () => ({ - openModal() { + openModal: () => { onOpen(); }, - closeModal() { + closeModal: () => { onClose(); }, })); @@ -112,11 +121,11 @@ const TermsOfUse = React.forwardRef((props, ref) => { color="teal" isExternal textDecoration="underline" - onClick={() => + onClick={() => { openExternalLink( - 'https://static.iohk.io/terms/iog-privacy-policy.pdf' - ) - } + 'https://static.iohk.io/terms/iog-privacy-policy.pdf', + ); + }} > Privacy Policy @@ -144,7 +153,9 @@ const TermsOfUse = React.forwardRef((props, ref) => { the Products. Feel free to submit feedback at{' '} openExternalLink('https://iohk.io/en/contact/')} + onClick={() => { + openExternalLink('https://iohk.io/en/contact/'); + }} > https://iohk.io/en/contact/ @@ -374,11 +385,11 @@ const TermsOfUse = React.forwardRef((props, ref) => { color="teal" isExternal textDecoration="underline" - onClick={() => + onClick={() => { openExternalLink( - 'https://static.iohk.io/terms/iog-dmca-policy.pdf' - ) - } + 'https://static.iohk.io/terms/iog-dmca-policy.pdf', + ); + }} > Digital Millennium Copyright Act (DMCA) Policy @@ -479,7 +490,9 @@ const TermsOfUse = React.forwardRef((props, ref) => { the form at{' '} openExternalLink('https://iohk.io/en/contact/')} + onClick={() => { + openExternalLink('https://iohk.io/en/contact/'); + }} > https://iohk.io/en/contact/ {' '} @@ -636,7 +649,9 @@ const TermsOfUse = React.forwardRef((props, ref) => { color="teal" isExternal textDecoration="underline" - onClick={() => openExternalLink('https://iohk.io/en/contact/')} + onClick={() => { + openExternalLink('https://iohk.io/en/contact/'); + }} > contact us {' '} @@ -658,4 +673,6 @@ const TermsOfUse = React.forwardRef((props, ref) => { ); }); +TermsOfUse.displayName = 'TermsOfUse'; + export default TermsOfUse; diff --git a/packages/nami/src/ui/app/components/transaction.tsx b/packages/nami/src/ui/app/components/transaction.tsx index 8bc6c7508..f98d8ab00 100644 --- a/packages/nami/src/ui/app/components/transaction.tsx +++ b/packages/nami/src/ui/app/components/transaction.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-multi-comp */ /* eslint-disable unicorn/no-null */ -import React from 'react'; +import React, { useMemo } from 'react'; import { ExternalLinkIcon } from '@chakra-ui/icons'; import { @@ -48,6 +48,7 @@ import UnitDisplay from './unitDisplay'; import type { Extra, TxInfo, Type } from '../../../adapters/transactions'; import type { Wallet } from '@lace/cardano'; +import type { CommonOutsideHandlesContextValue } from 'features/common-outside-handles-provider'; import type { OutsideHandlesContextValue } from 'features/outside-handles-provider'; import type { TransactionDetail } from 'types'; @@ -83,9 +84,9 @@ const txTypeLabel = { }; interface TransactionProps { - tx: Wallet.Cardano.HydratedTx; + tx: Wallet.Cardano.HydratedTx | Wallet.TxInFlight; network: OutsideHandlesContextValue['environmentName']; - cardanoCoin: OutsideHandlesContextValue['cardanoCoin']; + cardanoCoin: CommonOutsideHandlesContextValue['cardanoCoin']; openExternalLink: OutsideHandlesContextValue['openExternalLink']; } @@ -103,6 +104,18 @@ const Transaction = ({ assetsBtnHover: useColorModeValue('teal.200', 'gray.700'), }; + const extraInfo = useMemo( + () => + displayInfo && displayInfo.extra.length > 0 ? ( + + {getTxExtra(displayInfo.extra)} + + ) : ( + '' + ), + [displayInfo], + ); + return ( @@ -110,7 +123,6 @@ const Transaction = ({ @@ -161,12 +173,8 @@ const Transaction = ({ decimals={6} symbol={cardanoCoin.symbol} /> - ) : displayInfo.extra.length > 0 ? ( - - {getTxExtra(displayInfo.extra)} - ) : ( - '' + extraInfo )} {['internalIn', 'externalIn'].includes(displayInfo.type) ? ( '' diff --git a/packages/nami/src/ui/app/components/transactionBuilder.tsx b/packages/nami/src/ui/app/components/transactionBuilder.tsx index 535107ac8..da8166c3c 100644 --- a/packages/nami/src/ui/app/components/transactionBuilder.tsx +++ b/packages/nami/src/ui/app/components/transactionBuilder.tsx @@ -1,3 +1,8 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +/* eslint-disable functional/no-throw-statements */ +/* eslint-disable unicorn/prefer-logical-operator-over-ternary */ import React from 'react'; import { CheckIcon, WarningIcon } from '@chakra-ui/icons'; @@ -28,14 +33,18 @@ import { GoStop } from 'react-icons/go'; import { useCollateral } from '../../../adapters/collateral'; import { useDelegation } from '../../../adapters/delegation'; +import { encodeToCbor } from '../../../adapters/transactions'; import { ERROR } from '../../../config/config'; import { Events } from '../../../features/analytics/events'; import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { useCommonOutsideHandles } from '../../../features/common-outside-handles-provider'; import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles'; import ConfirmModal from './confirmModal'; import UnitDisplay from './unitDisplay'; +import type { ConfirmModalRef } from './confirmModal'; + type States = 'DONE' | 'EDITING' | 'ERROR' | 'LOADING'; const PoolStates: Record = { LOADING: 'LOADING', @@ -96,460 +105,614 @@ const poolTooltipMessage = ( return `${ticker} / ${name}`; }; -const TransactionBuilder = React.forwardRef( - (undefined, ref) => { - const capture = useCaptureEvent(); - const { - isInitializingCollateral, - initializeCollateralTx: initializeCollateral, - collateralFee, - inMemoryWallet, - cardanoCoin, - buildDelegation, - setSelectedStakePool, - delegationTxFee, - isBuildingTx, - stakingError, - passwordUtil: { setPassword }, - signAndSubmitTransaction, - getStakePoolInfo, - submitCollateralTx, - withSignTxConfirmation, - resetDelegationState, - hasNoFunds, - openExternalLink - } = useOutsideHandles(); - const { initDelegation, stakeRegistration } = useDelegation({ - inMemoryWallet, - buildDelegation, - setSelectedStakePool, - }); - const { hasCollateral, reclaimCollateral, submitCollateral } = - useCollateral({ - inMemoryWallet, - submitCollateralTx, - withSignTxConfirmation, +export interface TransactionBuilderRef { + initDelegation: () => Promise; + initUndelegate: () => Promise; + initCollateral: () => Promise; +} + +const TransactionBuilder = (undefined, ref) => { + const capture = useCaptureEvent(); + const { + isInitializingCollateral, + initializeCollateralTx: initializeCollateral, + collateralFee, + + buildDelegation, + setSelectedStakePool, + delegationTxFee, + isBuildingTx, + stakingError, + passwordUtil: { setPassword }, + signAndSubmitTransaction, + getStakePoolInfo, + submitCollateralTx, + resetDelegationState, + hasNoFunds, + openExternalLink, + delegationStoreDelegationTxBuilder, + collateralTxBuilder, + } = useOutsideHandles(); + const { + inMemoryWallet, + walletType, + cardanoCoin, + withSignTxConfirmation, + openHWFlow, + } = useCommonOutsideHandles(); + const { initDelegation, stakeRegistration } = useDelegation({ + inMemoryWallet, + buildDelegation, + setSelectedStakePool, + }); + const { hasCollateral, reclaimCollateral, submitCollateral } = useCollateral({ + inMemoryWallet, + submitCollateralTx, + withSignTxConfirmation, + }); + const toast = useToast(); + const { + isOpen: isOpenCol, + onOpen: onOpenCol, + onClose: onCloseCol, + } = useDisclosure(); + const [isLoading, setIsLoading] = React.useState(false); + const [data, setData] = React.useState<{ + error?: string; + pool: PoolDisplayValue; + }>({ + error: '', + pool: { ...poolDefaultValue }, + }); + const delegationRef = React.useRef(null); + const undelegateRef = React.useRef(null); + const collateralRef = React.useRef(null); + + const prepareDelegationTx = async () => { + if (data.pool.id === '') return; + + setData(d => ({ + ...d, + pool: { + ...d.pool, + state: PoolStates.LOADING, + }, + })); + + try { + let poolId; + try { + poolId = Wallet.Cardano.PoolId(data.pool.id); + } catch { + throw new Error('Stake pool not found'); + } + + const [pool] = await getStakePoolInfo(poolId).catch(() => { + throw new Error('Stake pool not found'); }); - const toast = useToast(); - const { - isOpen: isOpenCol, - onOpen: onOpenCol, - onClose: onCloseCol, - } = useDisclosure(); - const [isLoading, setIsLoading] = React.useState(false); - const [data, setData] = React.useState<{ - error?: string; - pool: PoolDisplayValue; - }>({ - error: '', - pool: { ...poolDefaultValue }, - }); - const delegationRef = React.useRef(); - const undelegateRef = React.useRef(); - const collateralRef = React.useRef(); - - const prepareDelegationTx = async () => { - if (data.pool.id === '') return; + if (!pool) throw new Error('Stake pool not found'); + + await initDelegation(pool).catch(() => { + throw new Error( + 'Transaction not possible (maybe insufficient balance)', + ); + }); + setData(d => ({ + ...d, + pool: { + ticker: pool.metadata?.ticker!, + name: pool.metadata?.name!, + id: pool.id.toString(), + state: PoolStates.DONE, + showTooltip: false, + }, + })); + } catch (error_) { setData(d => ({ ...d, pool: { ...d.pool, - state: PoolStates.LOADING, + error: (error_ instanceof Error && error_.message) || undefined, + state: PoolStates.ERROR, }, })); + } + }; + React.useImperativeHandle(ref, () => ({ + initDelegation: async () => { + delegationRef.current?.openModal(); + if (hasNoFunds) { + setData(d => ({ + ...d, + error: 'Transaction not possible (maybe insufficient balance)', + })); + } + }, + initUndelegate: async () => { + undelegateRef.current?.openModal(); try { - let poolId; - try { - poolId = Wallet.Cardano.PoolId(data.pool.id); - } catch { - throw new Error('Stake pool not found'); - } - - const [pool] = await getStakePoolInfo(poolId).catch(() => { - throw new Error('Stake pool not found'); - }); - - if (!pool) throw new Error('Stake pool not found'); - - await initDelegation(pool).catch(() => { - throw new Error( - 'Transaction not possible (maybe insufficient balance)', - ); - }); + await initDelegation(); + } catch { setData(d => ({ ...d, - pool: { - ticker: pool.metadata?.ticker!, - name: pool.metadata?.name!, - id: pool.id.toString(), - state: PoolStates.DONE, - showTooltip: false, - }, + error: 'Transaction not possible (maybe account balance too low)', })); - } catch (error_) { - console.log(error_); + } + }, + initCollateral: async () => { + if (hasCollateral) { + onOpenCol(); + return; + } + collateralRef.current?.openModal(); + + try { + await initializeCollateral(); + } catch { setData(d => ({ ...d, - pool: { - ...d.pool, - error: error_.message, - state: PoolStates.ERROR, - }, + error: 'Transaction not possible (maybe insufficient balance)', })); } - }; + }, + })); - React.useImperativeHandle(ref, () => ({ - initDelegation: async () => { - delegationRef.current.openModal(); - if (hasNoFunds) { - setData(d => ({ - ...d, - error: 'Transaction not possible (maybe insufficient balance)', - })); - } - }, - initUndelegate: async () => { - undelegateRef.current.openModal(); - try { - await initDelegation(); - } catch { - console.error(error); - setData(d => ({ - ...d, - error: 'Transaction not possible (maybe account balance too low)', - })); - } - }, - initCollateral: async () => { - if (hasCollateral) { - onOpenCol(); - return; - } - collateralRef.current.openModal(); - - try { - await initializeCollateral(); - } catch { - setData(d => ({ - ...d, - error: 'Transaction not possible (maybe insufficient balance)', - })); - } - }, - })); + const error = data.error || data.pool.error; - const error = data.error || data.pool.error; - - return ( - <> - { - setData({ pool: { ...poolDefaultValue } }); - resetDelegationState(); - }} - setPassword={setPassword} - ready={!isBuildingTx && data.pool.state === PoolStates.DONE} - title="Delegate your funds" - sign={async () => { - try { - await signAndSubmitTransaction(); - } catch (error) { - console.error(error); - } - }} - onConfirm={status => { - if (status === true) { - capture(Events.StakingConfirmClick); - toast({ - title: 'Delegation submitted', - status: 'success', - duration: 4000, - }); - } else { - toast({ - title: 'Transaction failed', - description: stakingError, - status: 'error', - duration: 3000, - }); - } - delegationRef.current.closeModal(); - }} - info={ - - - Enter the Stake Pool ID to delegate your funds and start - receiving rewards. Alternatively, head to{' '} - openExternalLink('https://pool.pm')} - > - https://pool.pm - - , connect your Nami wallet and delegate to a stake pool of your - choice - - - + { + setData({ pool: { ...poolDefaultValue } }); + resetDelegationState(); + }} + openHWFlow={openHWFlow} + walletType={walletType} + setPassword={setPassword} + ready={!isBuildingTx && data.pool.state === PoolStates.DONE} + title="Delegate your funds" + sign={async () => { + try { + await signAndSubmitTransaction(); + } catch (error) { + console.error(error); + throw error; + } + }} + onConfirm={status => { + if (status) { + capture(Events.StakingConfirmClick); + toast({ + title: 'Delegation submitted', + status: 'success', + duration: 4000, + }); + } else { + toast({ + title: 'Transaction failed', + description: stakingError, + status: 'error', + duration: 3000, + }); + } + delegationRef.current?.closeModal(); + }} + getCbor={async () => { + if (!delegationStoreDelegationTxBuilder) { + toast({ + title: 'Transaction failed', + description: 'Transaction could not be built', + status: 'error', + duration: 3000, + }); + delegationRef.current?.closeModal(); + return ''; + } + + const tx = await delegationStoreDelegationTxBuilder.build(); + + const inspection = await tx.inspect(); + + return encodeToCbor({ + body: inspection.body, + witness: inspection.witness, + auxiliaryData: inspection.auxiliaryData, + }); + }} + info={ + + + Enter the Stake Pool ID to delegate your funds and start receiving + rewards. Alternatively, head to{' '} + { + openExternalLink('https://pool.pm'); + }} > - - { - setData(s => ({ - ...s, - pool: { - ...s.pool, - id: e.target.value, - state: PoolStates.EDITING, - }, - })); - }} - placeholder="Enter Pool ID" - onKeyDown={async e => { - if (e.key == 'Enter') await prepareDelegationTx(); - }} - onMouseEnter={() => { - setData(s => ({ - ...s, - pool: { - ...s.pool, - showTooltip: s.pool.state === PoolStates.DONE, - }, - })); - }} - onMouseLeave={() => { - setData(s => ({ - ...s, - pool: { - ...s.pool, - showTooltip: false, - }, - })); - }} - /> - - {data.pool.state === PoolStates.EDITING && ( - - )} - {data.pool.state === PoolStates.DONE && ( - - )} - {data.pool.state === PoolStates.ERROR && ( - - )} - - - - {error ? ( - - {error} - - ) : ( - - {stakeRegistration && ( - + , connect your Nami wallet and delegate to a stake pool of your + choice + + + + + { + setData(s => ({ + ...s, + pool: { + ...s.pool, + id: e.target.value, + state: PoolStates.EDITING, + }, + })); + }} + placeholder="Enter Pool ID" + onKeyDown={async e => { + if (e.key == 'Enter') await prepareDelegationTx(); + }} + onMouseEnter={() => { + setData(s => ({ + ...s, + pool: { + ...s.pool, + showTooltip: s.pool.state === PoolStates.DONE, + }, + })); + }} + onMouseLeave={() => { + setData(s => ({ + ...s, + pool: { + ...s.pool, + showTooltip: false, + }, + })); + }} + /> + + {data.pool.state === PoolStates.EDITING && ( + + )} + {data.pool.state === PoolStates.DONE && ( + )} + {data.pool.state === PoolStates.ERROR && ( + + )} + + + + {error ? ( + + {error} + + ) : ( + + {stakeRegistration && ( - + Fee: + + Stake Registration: - + )} + + + Fee: + + - )} - + + + )} + + } + ref={delegationRef} + /> + { + setData({ pool: { ...poolDefaultValue } }); + resetDelegationState(); + }} + openHWFlow={openHWFlow} + walletType={walletType} + setPassword={setPassword} + ready={!isBuildingTx} + title="Stake deregistration" + sign={async () => { + try { + await signAndSubmitTransaction(); + } catch (error) { + console.error(error); + throw error; } - ref={delegationRef} - /> - { - setData({ pool: { ...poolDefaultValue } }); - resetDelegationState(); - }} - setPassword={setPassword} - ready={!isBuildingTx} - title="Stake deregistration" - sign={async () => { - try { - await signAndSubmitTransaction(); - } catch (error) { - console.log(error); - } - }} - onConfirm={status => { - if (status === true) { - capture(Events.StakingUnstakeConfirmClick); - toast({ - title: 'Deregistration submitted', - status: 'success', - duration: 4000, - }); - } else { - toast({ - title: 'Transaction failed', - description: stakingError, - status: 'error', - duration: 3000, - }); - } - }} - info={ - - - - - Going forward with deregistration will have the following - effects: - - - You will no longer receive rewards. - - Rewards from the 2 previous epoch will be lost. - - Full reward balance will be withdrawn. - The 2 ADA deposit will be refunded. - - You will have to re-register and wait 20 days to receive - rewards again. - - - - {data.error ? ( - - {data.error} + }} + onConfirm={status => { + if (status) { + capture(Events.StakingUnstakeConfirmClick); + toast({ + title: 'Deregistration submitted', + status: 'success', + duration: 4000, + }); + } else { + toast({ + title: 'Transaction failed', + description: stakingError, + status: 'error', + duration: 3000, + }); + } + }} + getCbor={async () => { + if (!delegationStoreDelegationTxBuilder) { + toast({ + title: 'Transaction failed', + description: 'Transaction could not be built', + status: 'error', + duration: 3000, + }); + delegationRef.current?.closeModal(); + return ''; + } + + const tx = await delegationStoreDelegationTxBuilder.build(); + + const inspection = await tx.inspect(); + + return encodeToCbor({ + body: inspection.body, + witness: inspection.witness, + }); + }} + info={ + + + + + Going forward with deregistration will have the following effects: + + + You will no longer receive rewards. + + Rewards from the 2 previous epoch will be lost. + + Full reward balance will be withdrawn. + The 2 ADA deposit will be refunded. + + You will have to re-register and wait 20 days to receive rewards + again. + + + + {data.error ? ( + + {data.error} + + ) : ( + + + + Stake Deregistration - ) : ( - - - + Stake Deregistration - - - + Fee: - - - - + + + Fee: + + - )} - + + + )} + + } + ref={undelegateRef} + /> + + Collateral + + } + openHWFlow={openHWFlow} + walletType={walletType} + setPassword={setPassword} + sign={async (password = '') => { + await submitCollateral(password); + }} + onCloseBtn={() => { + capture(Events.SettingsCollateralXClick); + }} + onConfirm={(status, error) => { + if (status) { + capture(Events.SettingsCollateralConfirmClick); + toast({ + title: 'Collateral added', + status: 'success', + duration: 4000, + }); + } else if (error === ERROR.fullMempool) { + toast({ + title: 'Transaction failed', + description: 'Mempool full. Try again.', + status: 'error', + duration: 3000, + }); + } else + toast({ + title: 'Transaction failed', + status: 'error', + duration: 3000, + }); + collateralRef.current?.closeModal(); + capture(Events.SettingsCollateralXClick); + }} + setCollateral={true} + getCbor={async () => { + if (!collateralTxBuilder) { + toast({ + title: 'Transaction failed', + description: 'Transaction could not be built', + status: 'error', + duration: 3000, + }); + delegationRef.current?.closeModal(); + return ''; } - ref={undelegateRef} - /> - + + Add collateral in order to interact with smart contracts on + Cardano: + The recommended collateral amount is + + 5 {cardanoCoin.symbol} + {' '} + The amount is separated from your account balance, you can choose + to return it to your balance at any time. +
+ { + openExternalLink('https://namiwallet.io'); + }} + > + Read more + +
+ + {data.error ? ( + + {data.error} + + ) : ( + + + + Fee: + + + + + + )} + + } + ref={collateralRef} + /> + + { + capture(Events.SettingsCollateralXClick); + onCloseCol(); + }} + > + + + + {' '} Collateral - } - sign={async password => { - await submitCollateral(password); - }} - onCloseBtn={() => { - capture(Events.SettingsCollateralXClick); - }} - onConfirm={(status, signedTx) => { - if (status === true) { - capture(Events.SettingsCollateralConfirmClick); - toast({ - title: 'Collateral added', - status: 'success', - duration: 4000, - }); - } else if (signedTx === ERROR.fullMempool) { - toast({ - title: 'Transaction failed', - description: 'Mempool full. Try again.', - status: 'error', - duration: 3000, - }); - } else - toast({ - title: 'Transaction failed', - status: 'error', - duration: 3000, - }); - collateralRef.current.closeModal(); - capture(Events.SettingsCollateralXClick); - }} - info={ + + + + + Your collateral amount is{' '} + 5 {cardanoCoin.symbol}.
+
When removing the collateral amount, it is returned to the + account balance, but disables interactions with smart contracts. +
+ + ( justifyContent="center" flexDirection="column" > - - Add collateral in order to interact with smart contracts on - Cardano: - The recommended collateral amount is - - 5 {cardanoCoin.symbol} - {' '} - The amount is separated from your account balance, you can - choose to return it to your balance at any time. -
- openExternalLink('https://namiwallet.io')} - > - Read more - -
- - {data.error ? ( - - {data.error} - - ) : ( - - - + Fee: - - - - - - )} - - } - ref={collateralRef} - /> - - { - capture(Events.SettingsCollateralXClick); - onCloseCol(); - }} - > - - - - {' '} - - Collateral - - - - - - Your collateral amount is{' '} - 5 {cardanoCoin.symbol}.
-
When removing the collateral amount, it is returned to - the account balance, but disables interactions with smart - contracts. -
- - - { + setIsLoading(true); + await reclaimCollateral(); + capture(Events.SettingsCollateralReclaimCollateralClick); + toast({ + title: 'Collateral removed', + status: 'success', + duration: 4000, + }); + onCloseCol(); + capture(Events.SettingsCollateralXClick); + setIsLoading(false); + }} > - + Remove + - - -
-
-
- - ); - }, -); - -export default TransactionBuilder; + + +
+
+
+ + ); +}; + +TransactionBuilder.displayName = 'TransactionBuilder'; + +export default React.forwardRef(TransactionBuilder); diff --git a/packages/nami/src/ui/app/components/unitDisplay.tsx b/packages/nami/src/ui/app/components/unitDisplay.tsx index c7a725d0a..4c905bf1b 100644 --- a/packages/nami/src/ui/app/components/unitDisplay.tsx +++ b/packages/nami/src/ui/app/components/unitDisplay.tsx @@ -1,16 +1,35 @@ import React from 'react'; + import { Box } from '@chakra-ui/react'; + import { displayUnit } from '../../../api/extension'; -const hideZero = (str) => - str[str.length - 1] == 0 ? hideZero(str.slice(0, -1)) : str; +const hideZero = (str: string) => + str.endsWith('0') ? hideZero(str.slice(0, -1)) : str; + +interface Props { + quantity?: bigint | number | string; + decimals?: number | string; + symbol?: React.ReactNode; + hide?: boolean; + fontSize?: number | string; + fontWeight?: number | string; + color?: string; + display?: string; +} -const UnitDisplay = ({ quantity, decimals, symbol, hide = false, ...props }) => { +const UnitDisplay = ({ + quantity, + decimals, + symbol, + hide = false, + ...props +}: Readonly) => { const num = displayUnit(quantity, decimals) - .toLocaleString('en-EN', { minimumFractionDigits: decimals }) + .toLocaleString('en-EN', { minimumFractionDigits: Number(decimals) }) .split('.')[0]; const subNum = displayUnit(quantity, decimals) - .toLocaleString('en-EN', { minimumFractionDigits: decimals }) + .toLocaleString('en-EN', { minimumFractionDigits: Number(decimals) }) .split('.')[1]; return ( diff --git a/packages/nami/src/ui/app/components/userInfo.tsx b/packages/nami/src/ui/app/components/userInfo.tsx index 0ce222218..0085d4cf7 100644 --- a/packages/nami/src/ui/app/components/userInfo.tsx +++ b/packages/nami/src/ui/app/components/userInfo.tsx @@ -1,19 +1,17 @@ +/* eslint-disable unicorn/prefer-math-trunc */ +/* eslint-disable unicorn/prefer-code-point */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import React from 'react'; -import { - StarIcon, -} from '@chakra-ui/icons'; -import { - Box, - Stack, - Text, - MenuItem, -} from '@chakra-ui/react'; + +import { StarIcon } from '@chakra-ui/icons'; +import { Box, Stack, Text, MenuItem } from '@chakra-ui/react'; import AvatarLoader from '../components/avatarLoader'; import UnitDisplay from '../components/unitDisplay'; -import { OutsideHandlesContextValue } from '../../../features/outside-handles-provider'; -type Props = Pick & { +import type { CommonOutsideHandlesContextValue } from '../../../features/common-outside-handles-provider'; + +type Props = Pick & { onClick?: () => void; avatar?: string; name: string; @@ -25,23 +23,24 @@ type Props = Pick & { const hashCode = (s: string): number => { let h; - for(let i = 0; i < s.length; i++) - h = Math.imul(31, h) + s.charCodeAt(i) | 0; + for (let i = 0; i < s.length; i++) + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; return h; -} +}; -const UserInfo = ({ onClick, avatar, name, balance, isActive, isHW, cardanoCoin, index }: Props) => { +const UserInfo = ({ + onClick, + avatar, + name, + balance, + isActive, + isHW, + cardanoCoin, + index, +}: Readonly) => { return ( - - + + - + { return; } - if (!!connectionResult) { + if (connectionResult) { void capture(Events.HWConnectNextClick); onConfirm(connectionResult); return; diff --git a/packages/nami/src/ui/app/hw/hw.stories.tsx b/packages/nami/src/ui/app/hw/hw.stories.tsx index fb268ed76..35a477204 100644 --- a/packages/nami/src/ui/app/hw/hw.stories.tsx +++ b/packages/nami/src/ui/app/hw/hw.stories.tsx @@ -5,10 +5,6 @@ import { useColorMode } from '@chakra-ui/react'; import type { Meta, StoryObj } from '@storybook/react'; import { fn, userEvent, within } from '@storybook/test'; -import { - createHWAccounts, - getHwAccounts, -} from '../../../api/extension/api.mock'; import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles.mock'; import { HWConnectFlow } from './hw'; @@ -110,8 +106,6 @@ const meta: Meta = { }); return () => { - getHwAccounts.mockClear(); - createHWAccounts.mockClear(); useOutsideHandles.mockClear(); }; }, diff --git a/packages/nami/src/ui/app/hw/hw.tsx b/packages/nami/src/ui/app/hw/hw.tsx index fef38de8e..5519a4f6e 100644 --- a/packages/nami/src/ui/app/hw/hw.tsx +++ b/packages/nami/src/ui/app/hw/hw.tsx @@ -31,6 +31,7 @@ export const HWConnectFlow = ({ return ( { + const capture = useCaptureEvent(); + const backgroundColor = useColorModeValue('gray.200', 'gray.800'); + const Logo = useColorModeValue(LogoOriginal, LogoWhite); + const { cbor, setCollateral } = useParams<{ + cbor: string; + setCollateral?: string; + }>(); + + const setRoute = useStoreActions( + actions => actions.globalModel.routeStore.setRoute, + ); + const resetSend = useStoreActions( + actions => actions.globalModel.sendStore.reset, + ); + + const toast = useToast(); + const { inMemoryWallet, withSignTxConfirmation } = useCommonOutsideHandles(); + + React.useEffect(() => { + withSignTxConfirmation(async () => { + try { + capture(Events.SendTransactionConfirmationConfirmClick); + const serializableTx = Serialization.Transaction.fromCbor( + cbor as unknown as Serialization.TxCBOR, + ); + const signedTx = await inMemoryWallet.finalizeTx({ + tx: cbor as unknown as Serialization.TxCBOR, + }); + const witness = serializableTx.witnessSet(); + witness.setVkeys( + Serialization.CborSet.fromCore( + [...signedTx.witness.signatures.entries()], + Serialization.VkeyWitness.fromCore, + ), + ); + serializableTx.setWitnessSet(witness); + + const txId = await submitTx(serializableTx.toCbor(), inMemoryWallet); + + if (txId) { + if (setCollateral) { + const utxo = await getCollateralUtxo(txId, inMemoryWallet); + await inMemoryWallet.utxo.setUnspendable([utxo]); + } + + toast({ + title: 'Transaction submitted', + status: 'success', + duration: 3000, + }); + capture(Events.SendTransactionConfirmed); + } else { + toast({ + title: 'Transaction failed', + status: 'error', + duration: 3000, + }); + } + } catch (error) { + console.error(error); + toast({ + title: 'Transaction failed', + status: 'error', + duration: 3000, + }); + } + + setRoute('/'); + resetSend(); + setTimeout(() => { + window.close(); + }, 3000); + }, ''); + }, []); + + return ( + + + + + + Waiting for Trezor... + + + ); +}; diff --git a/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx b/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx index cfac20b5c..566c5ee6f 100644 --- a/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx +++ b/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx @@ -3,25 +3,33 @@ import React from 'react'; import { Box, useColorMode } from '@chakra-ui/react'; import type { Meta, StoryObj } from '@storybook/react'; -import { - getCurrentAccount, - getFavoriteIcon, -} from '../../../../api/extension/api.mock'; +import { getFavoriteIcon } from '../../../../api/extension/api.mock'; -import Enable from './enable'; -import { currentAccount } from '../../../../mocks/account.mock'; +import { Enable } from './enable'; const EnableStory = ({ colorMode, }: Readonly<{ colorMode: 'dark' | 'light' }>): React.ReactElement => { const { setColorMode } = useColorMode(); setColorMode(colorMode); + const origin = 'https://app.sundae.fi'; return ( {} }} + accountName={'Account 1'} + accountAvatar="0.51801253" + dappConnector={{ + getDappInfo: async () => + await { + logo: getFavoriteIcon(origin), + name: 'name', + url: origin, + domain: origin.split('//')[1], + }, + }} /> ); @@ -48,9 +56,6 @@ const meta: Meta = { layout: 'centered', }, beforeEach: () => { - getCurrentAccount.mockImplementation(async () => { - return await Promise.resolve(currentAccount); - }); getFavoriteIcon.mockImplementation(() => { return 'https://app.sundae.fi/static/images/favicon.png'; }); @@ -60,7 +65,6 @@ const meta: Meta = { }, }; return () => { - getCurrentAccount.mockReset(); getFavoriteIcon.mockReset(); }; }, diff --git a/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx b/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx index 42d4b89e3..0eb3fbf8f 100644 --- a/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx +++ b/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx @@ -1,18 +1,52 @@ +import React, { useEffect, useState } from 'react'; + import { CheckIcon } from '@chakra-ui/icons'; import { Box, Button, Text, Image, useColorModeValue } from '@chakra-ui/react'; -import React from 'react'; -import { getFavoriteIcon, setWhitelisted } from '../../../../api/extension'; -import { APIError } from '../../../../config/config'; -import Account from '../../components/account'; -import { useCaptureEvent } from '../../../../features/analytics/hooks'; import { Events } from '../../../../features/analytics/events'; +import { useCaptureEvent } from '../../../../features/analytics/hooks'; +import Account from '../../components/account'; -const Enable = ({ request, controller }) => { +import type { DappConnector } from '../../../../features/dapp-outside-handles-provider'; + +interface Props { + dappConnector: DappConnector; + controller: ( + authorization: 'allow' | 'deny', + url: string, + cleanupCb: () => void, + ) => void; + accountName: string; + accountAvatar?: string; +} + +export const Enable = ({ + dappConnector, + controller, + accountName, + accountAvatar, +}: Readonly) => { const capture = useCaptureEvent(); const background = useColorModeValue('gray.100', 'gray.700'); const containerBg = useColorModeValue('white', 'gray.800'); + const [dappInfo, setDappInfo] = useState<{ + logo: string; + name: string; + url: string; + domain: string; + }>(); + useEffect(() => { + dappConnector + .getDappInfo() + .then(({ logo, name, url }) => { + setDappInfo({ logo, name, url, domain: url.split('//')[1] }); + }) + .catch(error => { + console.error(error); + }); + }, []); + return ( { position="relative" background={containerBg} > - + { alignItems={'center'} justifyContent={'center'} > - + - {request.origin.split('//')[1]} + {dappInfo?.domain ?? 'loading...'} This app would like to: @@ -84,9 +113,10 @@ const Enable = ({ request, controller }) => { height={'50px'} width={'180px'} onClick={async () => { - capture(Events.DappConnectorAuthorizeDappCancelClick); - await controller.returnData({ error: APIError.Refused }); - window.close(); + await capture(Events.DappConnectorAuthorizeDappCancelClick); + controller('deny', dappInfo?.url ?? '', () => { + window.close(); + }); }} > Cancel @@ -97,10 +127,10 @@ const Enable = ({ request, controller }) => { width={'180px'} colorScheme="teal" onClick={async () => { - capture(Events.DappConnectorAuthorizeDappAuthorizeClick); - await setWhitelisted(request.origin); - await controller.returnData({ data: true }); - window.close(); + await capture(Events.DappConnectorAuthorizeDappAuthorizeClick); + controller('allow', dappInfo?.url ?? '', () => { + window.close(); + }); }} > Access @@ -109,5 +139,3 @@ const Enable = ({ request, controller }) => { ); }; - -export default Enable; diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx b/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx index e78b15373..dca17c30f 100644 --- a/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx +++ b/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx @@ -1,12 +1,6 @@ +/* eslint-disable functional/prefer-immutable-types */ import React, { useMemo } from 'react'; -import { - getCurrentAccount, - isHW, - signData, - signDataCIP30, -} from '../../../../api/extension'; -import Account from '../../components/account'; -import { Scrollbars } from '../../components/scrollbar'; + import { Box, Text, @@ -15,73 +9,86 @@ import { Spinner, useColorModeValue, } from '@chakra-ui/react'; -import ConfirmModal from '../../components/confirmModal'; -import Loader from '../../../../api/loader'; -import { DataSignError } from '../../../../config/config'; -import { useCaptureEvent } from '../../../../features/analytics/hooks'; +import { Wallet } from '@lace/cardano'; + +import { ERROR } from '../../../../config/config'; import { Events } from '../../../../features/analytics/events'; +import { useCaptureEvent } from '../../../../features/analytics/hooks'; +import { useCommonOutsideHandles } from '../../../../features/common-outside-handles-provider'; +import Account from '../../components/account'; +import ConfirmModal from '../../components/confirmModal'; +import { Scrollbars } from '../../components/scrollbar'; + +import type { UseAccount } from '../../../../adapters/account'; +import type { DappConnector } from '../../../../features/dapp-outside-handles-provider'; + +interface Props { + dappConnector: DappConnector; + account: UseAccount['activeAccount']; +} -const SignData = ({ request, controller }) => { +export const SignData = ({ dappConnector, account }: Readonly) => { const capture = useCaptureEvent(); const ref = React.useRef(); - const [account, setAccount] = React.useState(null); const [payload, setPayload] = React.useState(''); const [address, setAddress] = React.useState(''); + const [dappInfo, setDappInfo] = React.useState(); const [error, setError] = React.useState(''); const [isLoading, setIsLoading] = React.useState(true); + const [request, setRequest] = + React.useState< + Awaited>['request'] + >(); const background = useColorModeValue('gray.100', 'gray.700'); - const getAccount = async () => { - const currentAccount = await getCurrentAccount(); - if (isHW(currentAccount.index)) setError('HW not supported'); - setAccount(currentAccount); - }; - const getPayload = async () => { - await Loader.load(); - const payload = Buffer.from(request.data.payload, 'hex').toString('utf8'); - setPayload(payload); + + const getPayload = (payload: Readonly) => { + const payloadUtf8 = Buffer.from(payload, 'hex').toString('utf8'); + setPayload(payloadUtf8); }; + const { walletType, openHWFlow } = useCommonOutsideHandles(); + const signDataMsg = useMemo(() => { - const result = []; - payload.split(/\r?\n/).forEach(line => { + const result: JSX.Element[] = []; + for (const line of payload.split(/\r?\n/)) { result.push( -

+

{line}

, ); - }); + } return result; }, [payload]); - const getAddress = async () => { - await Loader.load(); - try { - const baseAddr = Loader.Cardano.BaseAddress.from_address( - Loader.Cardano.Address.from_bytes( - Buffer.from(request.data.address, 'hex'), - ), - ); - if (!baseAddr) throw Error('Not a valid base address'); - setAddress('payment'); + const getAddress = ( + address: + | Wallet.Cardano.DRepID + | Wallet.Cardano.PaymentAddress + | Wallet.Cardano.RewardAccount, + ) => { + const addressObj = Wallet.Cardano.Address.fromString(address); + if (!addressObj) { + console.error('SignData: Invalid address', address); + setAddress('unknown'); return; - } catch (e) {} - try { - const rewardAddr = Loader.Cardano.RewardAddress.from_address( - Loader.Cardano.Address.from_bytes( - Buffer.from(request.data.address, 'hex'), - ), - ); - if (!rewardAddr) throw Error('Not a valid base address'); + } + + if (Wallet.Cardano.isRewardAccount(address)) { setAddress('stake'); - return; - } catch (e) {} - setAddress('unknown'); + } else { + setAddress('payment'); + } }; const loadData = async () => { - await getAccount(); - await getPayload(); - await getAddress(); + const { dappInfo, request } = await dappConnector.getSignDataRequest(); + getPayload(request.data.payload); + getAddress(request.data.address); + setDappInfo(dappInfo); + setRequest(request); setIsLoading(false); }; @@ -108,7 +115,7 @@ const SignData = ({ request, controller }) => { flexDirection="column" position="relative" > - + { draggable={false} width={4} height={4} - src={`chrome-extension://${chrome.runtime.id}/_favicon/?pageUrl=${request.origin}&size=32`} + src={dappInfo?.logo} /> - {request.origin.split('//')[1]} + {dappInfo?.url.split('//')[1]} @@ -196,11 +203,10 @@ const SignData = ({ request, controller }) => { height={'50px'} width={'180px'} onClick={async () => { - capture(Events.DappConnectorDappDataCancelClick); - await controller.returnData({ - error: DataSignError.UserDeclined, + await capture(Events.DappConnectorDappDataCancelClick); + await request?.reject(() => { + window.close(); }); - window.close(); }} > Cancel @@ -209,11 +215,11 @@ const SignData = ({ request, controller }) => { @@ -449,11 +501,10 @@ const SignTx = ({ request, controller }) => { height={'50px'} width={'180px'} onClick={async () => { - capture(Events.DappConnectorDappTxCancelClick); - await controller.returnData({ - error: TxSignError.UserDeclined, + await capture(Events.DappConnectorDappTxCancelClick); + await request?.reject(() => { + window.close(); }); - window.close(); }} > Cancel @@ -462,11 +513,11 @@ const SignTx = ({ request, controller }) => { - )} - -
-
- ); - })} - - - )} - {property.metadata && ( - <> - - Metadata - - - - -
-                            
-                              {JSON.stringify(property.metadata, null, 2)}
-                            
-                          
-
-
- - - )} - - Signing keys - - - - {keyHashes.kind.map((keyHash, index) => ( - - - {keyHash} - - - ))} - - - {Object.keys(property).some(key => property[key]) && ( - <> - - Tags - - - - {Object.keys(property) - .filter(p => property[p]) - .map((p, index) => ( + {' '} + {Object.keys(externalValue).length > 0 && ( + + + Recipients + + + {Object.keys(externalValue).map((address, index) => { + const lovelace = externalValue?.[address]?.value?.find( + v => v.unit === 'lovelace', + )?.quantity; + const assets = externalValue[address].value.filter( + v => v.unit !== 'lovelace', + ) as unknown as NamiAsset[]; + return ( + + - - {p == 'minting' && 'Minting'} - {p == 'certificate' && 'Certificate'} - {p == 'withdrawal' && 'Withdrawal'} - {p == 'metadata' && 'Metadata'} - {p == 'contract' && 'Contract'} - {p == 'script' && 'Script'} - {p == 'datum' && 'Datum'} - + + + + + {address} + + + + + {externalValue[address].script && ( + + {externalValue[address].datumHash ? ( + + Contract + + ) : ( + 'Script' + )} + + )} - ))} - - - - )} - - - Raw transaction - - - - {tx} + + + {assets.length > 0 && ( + + )} + + + + ); + })} + - + )} + {property.metadata && ( + <> + + Metadata + + + + +
+                          {property.metadata}
+                        
+
+
+ + + )} + + Signing keys + + + + {keyHashes.kind.map((keyHash, index) => ( + + + {keyHash} + + + ))} + + + {Object.keys(property).some(key => property[key]) && ( + <> + + Tags + + + + {Object.keys(property) + .filter(p => property[p]) + .map((p, index) => ( + + + {p == 'minting' && 'Minting'} + {p == 'certificate' && 'Certificate'} + {p == 'withdrawal' && 'Withdrawal'} + {p == 'metadata' && 'Metadata'} + {p == 'contract' && 'Contract'} + {p == 'script' && 'Script'} + {p == 'datum' && 'Datum'} + + + ))} + + + + )} + + + Raw transaction + + + + {tx} + + + - - - + - - - - - ); - }, -); + + + + + + ); +}; + +DetailsModalComponent.displayName = 'DetailsModal'; + +const DetailsModal = React.forwardRef(DetailsModalComponent); -export default SignTx; +DetailsModal.displayName = 'DetailsModal'; diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts index 8322a31f0..263ec3327 100644 --- a/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts +++ b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts @@ -4,6 +4,9 @@ import * as actualApi from './signTxUtil'; export * from './signTxUtil'; -export const getValue = fn(actualApi.getValue).mockName('getValue'); +export const getValue = fn(actualApi.getValueWithSdk).mockName('getValue'); +export const getValueWithSdk = fn(actualApi.getValueWithSdk).mockName( + 'getValueWithSdk', +); export const getKeyHashes = fn(actualApi.getKeyHashes).mockName('getKeyHashes'); diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts index 3d51061c3..4eeb24259 100644 --- a/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts +++ b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts @@ -1,77 +1,82 @@ -import AssetFingerprint from '@emurgo/cip14-js'; +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable functional/prefer-immutable-types */ +/* eslint-disable unicorn/no-null */ +/* eslint-disable max-params */ import { - bytesAddressToBinary, - extractKeyOrScriptHash, - getSpecificUtxo, -} from '../../../../api/extension'; -import { valueToAssets } from '../../../../api/util'; -import { Loader } from '../../../../api/loader'; - -const getPaymentKeyHash = async address => { - try { - return Buffer.from( - Loader.Cardano.BaseAddress.from_address( - Loader.Cardano.Address.from_bytes(address.to_bytes()), - ) - .payment_cred() - .to_keyhash() - .to_bytes(), - ).toString('hex'); - } catch (e) {} - try { - return Buffer.from( - Loader.Cardano.EnterpriseAddress.from_address( - Loader.Cardano.Address.from_bytes(address.to_bytes()), - ) - .payment_cred() - .to_keyhash() - .to_bytes(), - ).toString('hex'); - } catch (e) {} + coalesceValueQuantities, + totalAddressInputsValueInspector, + totalAddressOutputsValueInspector, + Cardano, + Serialization, +} from '@cardano-sdk/core'; +import { Wallet } from '@lace/cardano'; +import { toAsset } from 'adapters/assets'; +import groupBy from 'lodash/groupBy'; + +import type { Asset, CardanoAsset } from '../../../../types/assets'; +import type { AssetInfoWithAmount } from '@cardano-sdk/core'; +import type { DappConnector } from 'features/dapp-outside-handles-provider'; + +const isNFT = (asset: AssetInfoWithAmount) => + asset.assetInfo.supply === BigInt(1); + +const getFallbackName = (asset: AssetInfoWithAmount) => { try { - return Buffer.from( - Loader.Cardano.PointerAddress.from_address( - Loader.Cardano.Address.from_bytes(address.to_bytes()), - ) - .payment_cred() - .to_keyhash() - .to_bytes(), - ).toString('hex'); - } catch (e) {} - throw Error('Not supported address type'); + return Wallet.Cardano.AssetName.toUTF8(asset.assetInfo.name); + } catch { + return asset.assetInfo.assetId; + } }; -export const getKeyHashes = async ( - tx, - utxos, - account, -): Promise<{ key: string[]; kind: string[] } | { error: string }> => { - let requiredKeyHashes: string[] = []; - const baseAddr = Loader.Cardano.BaseAddress.from_address( - Loader.Cardano.Address.from_bech32(account.paymentAddr), +const getAssetTokenName = (assetWithAmount: AssetInfoWithAmount) => { + if (isNFT(assetWithAmount)) { + return ( + assetWithAmount.assetInfo.nftMetadata?.name ?? + getFallbackName(assetWithAmount) + ); + } + return ( + assetWithAmount.assetInfo.tokenMetadata?.ticker ?? + getFallbackName(assetWithAmount) ); - const paymentKeyHash = Buffer.from( - baseAddr.payment_cred().to_keyhash().to_bytes(), - ).toString('hex'); - const stakeKeyHash = Buffer.from( - baseAddr.stake_cred().to_keyhash().to_bytes(), - ).toString('hex'); +}; + +const inputResolver = ( + utxos: Readonly, +): Cardano.InputResolver => ({ + resolveInput: async (input: Readonly) => + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + Promise.resolve( + utxos.find( + utxo => utxo[0].txId === input.txId && utxo[0].index === input.index, + )?.[1] ?? null, + ), +}); + +export const getKeyHashes = ( + tx: Cardano.Tx, + utxos: Wallet.Cardano.Utxo[], + paymentAddr: Cardano.PaymentAddress, +): { error: string } | { key: string[]; kind: string[] } => { + let requiredKeyHashes: string[] = []; + const baseAddr = Cardano.Address.fromString(paymentAddr)?.asBase(); + + if (!baseAddr) { + return { + error: `Invalid payment address: ${paymentAddr}`, + }; + } + + const paymentKeyHash = baseAddr.getPaymentCredential().hash; + const stakeKeyHash = baseAddr.getStakeCredential().hash; //get key hashes from inputs - const inputs = tx.body().inputs(); - for (let i = 0; i < inputs.len(); i++) { - const input = inputs.get(i); - const txHash = Buffer.from(input.transaction_id().to_bytes()).toString( - 'hex', - ); - const index = parseInt(input.index().to_str()); + const inputs = tx.body.inputs; + for (const input of inputs) { + const txHash = input.txId; + const index = input.index; if ( - utxos.some( - utxo => - Buffer.from(utxo.input().transaction_id().to_bytes()).toString( - 'hex', - ) === txHash && parseInt(utxo.input().index().to_str()) === index, - ) + utxos.some(utxo => utxo[0].txId === txHash && utxo[0].index === index) ) { requiredKeyHashes.push(paymentKeyHash); } else { @@ -80,120 +85,119 @@ export const getKeyHashes = async ( } //get key hashes from certificates - const txBody = tx.body(); - const keyHashFromCert = txBody => { - for (let i = 0; i < txBody.certs().len(); i++) { - const cert = txBody.certs().get(i); - if (cert.kind() === 0) { - const credential = cert.as_stake_registration().stake_credential(); - if (credential.kind() === 0) { + const txBody = tx.body; + const keyHashFromCert = (txBody: Cardano.TxBody) => { + for (const cert of txBody.certificates ?? []) { + switch (cert.__typename) { + case Cardano.CertificateType.StakeRegistration: { // stake registration doesn't required key hash + + break; } - } else if (cert.kind() === 1) { - const credential = cert.as_stake_deregistration().stake_credential(); - if (credential.kind() === 0) { - const keyHash = Buffer.from( - credential.to_keyhash().to_bytes(), - ).toString('hex'); - requiredKeyHashes.push(keyHash); - } - } else if (cert.kind() === 2) { - const credential = cert.as_stake_delegation().stake_credential(); - if (credential.kind() === 0) { - const keyHash = Buffer.from( - credential.to_keyhash().to_bytes(), - ).toString('hex'); - requiredKeyHashes.push(keyHash); + case Cardano.CertificateType.StakeDeregistration: { + const credential = cert.stakeCredential; + if (credential.type === Cardano.CredentialType.KeyHash) { + const keyHash = credential.hash; + requiredKeyHashes.push(keyHash); + } + + break; } - } else if (cert.kind() === 3) { - const owners = cert.as_pool_registration().pool_params().pool_owners(); - for (let i = 0; i < owners.len(); i++) { - const keyHash = Buffer.from(owners.get(i).to_bytes()).toString('hex'); - requiredKeyHashes.push(keyHash); + case Cardano.CertificateType.StakeDelegation: { + const credential = cert.stakeCredential; + if (credential.type === Cardano.CredentialType.KeyHash) { + const keyHash = credential.hash; + requiredKeyHashes.push(keyHash); + } + + break; } - } else if (cert.kind() === 4) { - const operator = cert.as_pool_retirement().pool_keyhash().to_hex(); - requiredKeyHashes.push(operator); - } else if (cert.kind() === 6) { - const instant_reward = cert - .as_move_instantaneous_rewards_cert() - .move_instantaneous_reward() - .as_to_stake_creds() - .keys(); - for (let i = 0; i < instant_reward.len(); i++) { - const credential = instant_reward.get(i); - - if (credential.kind() === 0) { - const keyHash = Buffer.from( - credential.to_keyhash().to_bytes(), - ).toString('hex'); + case Cardano.CertificateType.PoolRegistration: { + const owners = cert.poolParameters.owners; + for (const owner of owners) { + const keyHash = Cardano.RewardAccount.toHash(owner); requiredKeyHashes.push(keyHash); } + + break; + } + case Cardano.CertificateType.PoolRetirement: { + const operator = Cardano.PoolId.toKeyHash(cert.poolId); + requiredKeyHashes.push(operator); + + break; + } + case Cardano.CertificateType.MIR: { + if (cert.kind === Cardano.MirCertificateKind.ToStakeCreds) { + const keyHash = cert.stakeCredential?.hash; + if (keyHash) { + requiredKeyHashes.push(keyHash); + } + } + break; + } + default: { + break; } } } }; - if (txBody.certs()) keyHashFromCert(txBody); + if (txBody.certificates) keyHashFromCert(txBody); // key hashes from withdrawals - const withdrawals = txBody.withdrawals(); - const keyHashFromWithdrawal = withdrawals => { - const rewardAddresses = withdrawals.keys(); - for (let i = 0; i < rewardAddresses.len(); i++) { - const credential = rewardAddresses.get(i).payment_cred(); - if (credential.kind() === 0) { - requiredKeyHashes.push(credential.to_keyhash().to_hex()); - } + const withdrawals = txBody.withdrawals; + const keyHashFromWithdrawal = (withdrawals: Cardano.Withdrawal[]) => { + for (const withdrawal of withdrawals) { + const credential = withdrawal.stakeAddress; + requiredKeyHashes.push(Cardano.RewardAccount.toHash(credential)); } }; if (withdrawals) keyHashFromWithdrawal(withdrawals); //get key hashes from scripts - const scripts = tx.witness_set().native_scripts(); - const keyHashFromScript = scripts => { - for (let i = 0; i < scripts.len(); i++) { - const script = scripts.get(i); - if (script.kind() === 0) { - const keyHash = Buffer.from( - script.as_script_pubkey().addr_keyhash().to_bytes(), - ).toString('hex'); + const scripts = tx.witness.scripts?.filter( + (script): script is Cardano.NativeScript => + script.__type === Cardano.ScriptType.Native, + ); + const keyHashFromScript = (scripts: Cardano.NativeScript[]) => { + for (const script of scripts) { + if (script.kind === Cardano.NativeScriptKind.RequireSignature) { + const keyHash = script.keyHash; requiredKeyHashes.push(keyHash); } - if (script.kind() === 1) { - return keyHashFromScript(script.as_script_all().native_scripts()); + if (script.kind === Cardano.NativeScriptKind.RequireAllOf) { + return keyHashFromScript(script.scripts); } - if (script.kind() === 2) { - return keyHashFromScript(script.as_script_any().native_scripts()); + if (script.kind === Cardano.NativeScriptKind.RequireAnyOf) { + return keyHashFromScript(script.scripts); } - if (script.kind() === 3) { - return keyHashFromScript(script.as_script_n_of_k().native_scripts()); + if (script.kind === Cardano.NativeScriptKind.RequireNOf) { + return keyHashFromScript(script.scripts); } } }; if (scripts) keyHashFromScript(scripts); //get keyHashes from required signers - const requiredSigners = tx.body().required_signers(); + const requiredSigners = tx.body.requiredExtraSignatures; if (requiredSigners) { - for (let i = 0; i < requiredSigners.len(); i++) { - requiredKeyHashes.push( - Buffer.from(requiredSigners.get(i).to_bytes()).toString('hex'), - ); + for (const signer of requiredSigners) { + requiredKeyHashes.push(signer); } } //get keyHashes from collateral - const collateral = txBody.collateral(); - if (collateral) { - for (let i = 0; i < collateral.len(); i++) { - const c = collateral.get(i); - const utxo = await getSpecificUtxo( - Buffer.from(c.transaction_id().to_bytes()).toString('hex'), - c.index(), + if (txBody.collaterals) { + for (const c of txBody.collaterals) { + const utxo = utxos.find( + utxo => utxo[0].txId === c.txId && utxo[0].index === c.index, ); if (utxo) { - const address = Loader.Cardano.Address.from_bech32(utxo.address); - requiredKeyHashes.push(await getPaymentKeyHash(address)); + const address = Cardano.Address.fromString(utxo[1].address); + const paymentHash = address?.getProps().paymentPart?.hash; + if (paymentHash) { + requiredKeyHashes.push(paymentHash); + } } } } @@ -211,121 +215,178 @@ export const getKeyHashes = async ( return { key: requiredKeyHashes, kind: keyKind }; }; -export const getValue = async (tx, utxos, account) => { - let inputValue = Loader.Cardano.Value.new( - Loader.Cardano.BigNum.from_str('0'), - ); - const inputs = tx.body().inputs(); - for (let i = 0; i < inputs.len(); i++) { - const input = inputs.get(i); - const inputTxHash = Buffer.from(input.transaction_id().to_bytes()).toString( - 'hex', +interface ExternalOutput { + value: (Asset | CardanoAsset)[]; + script?: boolean; + datumHash?: string; +} + +export interface TransactionValue { + ownValue: (Asset | CardanoAsset)[]; + externalValue: Record; +} + +export const isScriptAddress = (address: Cardano.PaymentAddress) => { + const addressObj = Cardano.Address.fromString(address); + if (!addressObj) { + console.error( + `Failed to parse address: ${address} while calculating external outputs`, ); - const inputTxId = parseInt(input.index().to_str()); - const utxo = utxos.find(utxo => { - const utxoTxHash = Buffer.from( - utxo.input().transaction_id().to_bytes(), - ).toString('hex'); - const utxoTxId = parseInt(utxo.input().index().to_str()); - return inputTxHash === utxoTxHash && inputTxId === utxoTxId; - }); - if (utxo) { - inputValue = inputValue.checked_add(utxo.output().amount()); - } + return false; } - const outputs = tx.body().outputs(); - let ownOutputValue = Loader.Cardano.Value.new( - Loader.Cardano.BigNum.from_str('0'), - ); - const externalOutputs = {}; - if (!outputs) return; - for (let i = 0; i < outputs.len(); i++) { - const output = outputs.get(i); - const address = output.address().to_bech32(); - const hashBech32 = await extractKeyOrScriptHash( - Buffer.from(output.address().to_bytes()).toString('hex'), - ); - // making sure funds at mangled addresses are also included - if (hashBech32 === account.paymentKeyHashBech32) { - //own - ownOutputValue = ownOutputValue.checked_add(output.amount()); - } else { - //external - if (!externalOutputs[address]) { - const value = Loader.Cardano.Value.new(output.amount().coin()); - if (output.amount().multiasset()) - value.set_multiasset(output.amount().multiasset()); - externalOutputs[address] = { value }; - } else - externalOutputs[address].value = externalOutputs[ - address - ].value.checked_add(output.amount()); - const prefix = bytesAddressToBinary(output.address().to_bytes()).slice( - 0, - 4, - ); - // from cardano ledger specs; if any of these prefixes match then it means the payment credential is a script hash, so it's a contract address - if ( - prefix == '0111' || - prefix == '0011' || - prefix == '0001' || - prefix == '0101' - ) { - externalOutputs[address].script = true; + + if (addressObj) { + switch (addressObj.getType()) { + case Cardano.AddressType.BasePaymentScriptStakeKey: + case Cardano.AddressType.BasePaymentKeyStakeScript: + case Cardano.AddressType.BasePaymentScriptStakeScript: + case Cardano.AddressType.PointerScript: + case Cardano.AddressType.EnterpriseScript: + case Cardano.AddressType.RewardScript: { + return true; + } + default: { + return false; } - const datum = output.datum(); - if (datum) - externalOutputs[address].datumHash = Buffer.from( - datum.kind() === 0 - ? datum.as_data_hash().to_bytes() - : Loader.Cardano.hash_plutus_data(datum.as_data().get()).to_bytes(), - ).toString('hex'); } } +}; + +/** + * Converts a Cardano.Value object to an array of Asset objects. + * + * @param {Cardano.Value} value - The Cardano.Value object to convert. + * @returns {Asset[]} An array of Asset objects representing the value. + */ +export const valueToAssetsSdk = (value: Cardano.Value): CardanoAsset[] => { + const assets: CardanoAsset[] = [ + { + unit: 'lovelace', + quantity: value.coins.toString(), + }, + ]; + + for (const [assetId, quantity] of value.assets || []) { + assets.push({ unit: assetId, quantity: quantity.toString() }); + } + + return assets; +}; + +export const getValueWithSdk = async ( + tx: Readonly, + utxos: Readonly, + addresses: Cardano.PaymentAddress[], + getAssetInfos: DappConnector['getAssetInfos'], +): Promise => { + const inputValue = valueToAssetsSdk( + await totalAddressInputsValueInspector(addresses, inputResolver(utxos))(tx), + ); + + const ownOutputValue = valueToAssetsSdk( + await totalAddressOutputsValueInspector(addresses)(tx), + ); - inputValue = await valueToAssets(inputValue); - ownOutputValue = await valueToAssets(ownOutputValue); + const externalOutputsByAddress = Object.entries( + groupBy( + tx.body.outputs.filter(output => + addresses.every(addr => addr !== output.address), + ), + output => output.address, + ), + ) as [Cardano.PaymentAddress, Cardano.TxOut[]][]; + + const externalOutputs: [ + Cardano.PaymentAddress, + Omit & { value: Cardano.Value }, + ][] = externalOutputsByAddress.map(([address, outputs]) => { + let datumHash = outputs.find(output => !!output.datumHash)?.datumHash; + const datum = outputs.find(output => !!output.datum)?.datum; + if (!datumHash && datum !== undefined) { + datumHash = Serialization.PlutusData.fromCore(datum).hash(); + } + return [ + address, + { + value: coalesceValueQuantities(outputs.map(output => output.value)), + script: isScriptAddress(address), + ...(datumHash ? { datumHash } : {}), + }, + ]; + }); + + // Create a list of all asset IDs from inputs and own outputs. Includes the ADA ('lovelace') asset. const involvedAssets = [ ...new Set([ ...inputValue.map(asset => asset.unit), ...ownOutputValue.map(asset => asset.unit), ]), ]; - const ownOutputValueDifference = involvedAssets.map(unit => { + + const ownOutputValueDifference = involvedAssets.map(unit => { const leftValue = inputValue.find(asset => asset.unit === unit); const rightValue = ownOutputValue.find(asset => asset.unit === unit); const difference = BigInt(leftValue ? leftValue.quantity : '') - BigInt(rightValue ? rightValue.quantity : ''); if (unit === 'lovelace') { - return { unit, quantity: difference }; + return { unit, quantity: difference.toString() }; } - const policy = unit.slice(0, 56); - const name = unit.slice(56); - const fingerprint = AssetFingerprint.fromParts( - Buffer.from(policy, 'hex'), - Buffer.from(name, 'hex'), - ).fingerprint(); - return { - unit, - quantity: difference, - fingerprint, - name: (leftValue || rightValue).name, - policy, - }; + + return { unit, quantity: difference.toString() }; }); - const externalValue = {}; - for (const address of Object.keys(externalOutputs)) { + const ownValue = ownOutputValueDifference.filter( + v => BigInt(v.quantity) != BigInt(0), + ); + + // Prepare an exhaustive list of AssetIds to query for asset info + const ownValueAssetIds = ownValue.map(({ unit }) => unit); + const externalOutputsAssetIds = externalOutputs + .flatMap(([, { value }]) => valueToAssetsSdk(value)) + .map(({ unit }) => unit); + const assetIds = [ + ...new Set( + [...ownValueAssetIds, ...externalOutputsAssetIds].filter( + unit => unit !== 'lovelace', + ) as Cardano.AssetId[], + ), + ]; + + // Fetch all asset infos for the involved assets only once + const assetInfos = await getAssetInfos({ assetIds, tx }); + + // Similar to externalOutputs, but with the value converted from Cardano.Value to Asset[] + const externalValue: Record = {}; + for (const [address, valueAndDatumHash] of externalOutputs) { externalValue[address] = { - ...externalOutputs[address], - value: await valueToAssets(externalOutputs[address].value), + ...valueAndDatumHash, + value: valueToAssetsSdk(valueAndDatumHash.value).map(asset => + toAssetInfo(asset, assetInfos), + ), }; } - const ownValue = ownOutputValueDifference.filter( - v => v.quantity != BigInt(0), + const ownValueWithAssetInfo = ownValue.map(asset => + toAssetInfo(asset, assetInfos), ); - return { ownValue, externalValue }; + + // Returns + return { ownValue: ownValueWithAssetInfo, externalValue }; +}; + +const toAssetInfo = ( + cardanoAsset: CardanoAsset, + assetInfos: Map, +) => { + if (cardanoAsset.unit === 'lovelace') { + return cardanoAsset; + } + + const assetInfo = assetInfos.get(cardanoAsset.unit as Cardano.AssetId); + if (!assetInfo) { + return cardanoAsset; + } + return toAsset(assetInfo, BigInt(cardanoAsset.quantity)); }; diff --git a/packages/nami/src/ui/app/pages/index.ts b/packages/nami/src/ui/app/pages/index.ts index 3c5958cf6..2d4419c3f 100644 --- a/packages/nami/src/ui/app/pages/index.ts +++ b/packages/nami/src/ui/app/pages/index.ts @@ -1 +1,4 @@ export * from './wallet'; +export * from './dapp-connector/enable'; +export * from './dapp-connector/signTx'; +export * from './dapp-connector/signData'; diff --git a/packages/nami/src/ui/app/pages/send.stories.tsx b/packages/nami/src/ui/app/pages/send.stories.tsx index 5c2854b22..4c90597af 100644 --- a/packages/nami/src/ui/app/pages/send.stories.tsx +++ b/packages/nami/src/ui/app/pages/send.stories.tsx @@ -11,8 +11,8 @@ import { getAdaHandle, } from '../../../api/extension/api.mock'; import { buildTx } from '../../../api/extension/wallet.mock'; -import { minAdaRequired, valueToAssets } from '../../../api/util.mock'; -import { account, account1, currentAccount } from '../../../mocks/account.mock'; +import { minAdaRequired } from '../../../api/util.mock'; +import { account1, currentAccount } from '../../../mocks/account.mock'; import { store } from '../../../mocks/store.mock'; import { useStoreState, useStoreActions } from '../../store.mock'; import { Cardano } from '../../../../.storybook/mocks/cardano-sdk.mock'; @@ -308,7 +308,6 @@ const meta: Meta = { useStoreActions.mockReset(); getAdaHandle.mockReset(); minAdaRequired.mockReset(); - valueToAssets.mockReset(); buildTx.mockReset(); Route.mockReset(); Cardano.Address.fromBech32.mockReset(); diff --git a/packages/nami/src/ui/app/pages/send.tsx b/packages/nami/src/ui/app/pages/send.tsx index 8d5134a82..8c1f7a3f0 100644 --- a/packages/nami/src/ui/app/pages/send.tsx +++ b/packages/nami/src/ui/app/pages/send.tsx @@ -1,15 +1,15 @@ -import React from 'react'; -import { useHistory } from 'react-router-dom'; -import { - createTab, - displayUnit, - getAdaHandle, - isValidAddress, - toUnit, -} from '../../../api/extension'; -import Account from '../components/account'; -import { Scrollbars } from '../components/scrollbar'; -import ConfirmModal from '../components/confirmModal'; +/* eslint-disable unicorn/consistent-function-scoping */ +/* eslint-disable unicorn/prefer-logical-operator-over-ternary */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable unicorn/prefer-string-slice */ +/* eslint-disable unicorn/no-new-array */ +/* eslint-disable unicorn/no-null */ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { RefObject } from 'react'; +import React, { useCallback, useMemo } from 'react'; + +import { Cardano, Serialization, ProviderUtil } from '@cardano-sdk/core'; import { CheckIcon, ChevronLeftIcon, @@ -39,39 +39,47 @@ import { useToast, Icon, } from '@chakra-ui/react'; +import { useObservable } from '@lace/common'; +import debouncePromise from 'debounce-promise'; +import latest from 'promise-latest'; +import { MdModeEdit } from 'react-icons/md'; +import { Planet } from 'react-kawaii'; import MiddleEllipsis from 'react-middle-ellipsis'; -import UnitDisplay from '../components/unitDisplay'; +import { NumericFormat } from 'react-number-format'; +import { useHistory } from 'react-router-dom'; +import { FixedSizeList as List } from 'react-window'; +import useConstant from 'use-constant'; + +import { toAsset, withHandleInfo } from '../../../adapters/assets'; +import { encodeToCbor } from '../../../adapters/transactions'; import { - buildTx, - signAndSubmit, - signAndSubmitHW, -} from '../../../api/extension/wallet'; + displayUnit, + getAdaHandle, + isValidAddress, + toUnit, +} from '../../../api/extension'; +import { buildTx, signAndSubmit } from '../../../api/extension/wallet'; import { assetsToValue, minAdaRequired } from '../../../api/util'; -import { FixedSizeList as List } from 'react-window'; -import AssetBadge from '../components/assetBadge'; -import { ERROR, HW, TAB } from '../../../config/config'; -import { Planet } from 'react-kawaii'; +import { ERROR } from '../../../config/config'; +import { Events } from '../../../features/analytics/events'; +import { useCaptureEvent } from '../../../features/analytics/hooks'; +import { useCommonOutsideHandles } from '../../../features/common-outside-handles-provider'; import { useStoreActions, useStoreState } from '../../store'; -import { action } from 'easy-peasy'; +import Account from '../components/account'; +import AssetBadge from '../components/assetBadge'; +import AssetsModal from '../components/assetsModal'; import AvatarLoader from '../components/avatarLoader'; -import { NumericFormat } from 'react-number-format'; +import ConfirmModal from '../components/confirmModal'; import Copy from '../components/copy'; -import AssetsModal from '../components/assetsModal'; -import { MdModeEdit } from 'react-icons/md'; -import useConstant from 'use-constant'; -import { useCaptureEvent } from '../../../features/analytics/hooks'; -import { Events } from '../../../features/analytics/events'; -import debouncePromise from 'debounce-promise'; -import latest from 'promise-latest'; -import { Cardano, Serialization, ProviderUtil } from '@cardano-sdk/core'; -import { Ed25519KeyHashHex } from '@cardano-sdk/crypto'; +import { Scrollbars } from '../components/scrollbar'; +import UnitDisplay from '../components/unitDisplay'; + +import type { UseAccount } from '../../../adapters/account'; +import type { OutsideHandlesContextValue } from '../../../features/outside-handles-provider'; +import type { Asset as NamiAsset, AssetInput } from '../../../types/assets'; +import type { AssetsModalRef } from '../components/assetsModal'; +import type { ConfirmModalRef } from '../components/confirmModal'; import type { Wallet } from '@lace/cardano'; -import { useObservable } from '@lace/common'; -import { useHandleResolver } from '../../../features/ada-handle/useHandleResolver'; -import { toAsset, withHandleInfo } from '../../../adapters/assets'; -import type { Asset as NamiAsset } from '../../../types/assets'; -import { UseAccount } from '../../../adapters/account'; -import { useOutsideHandles } from '../../../features/outside-handles-provider'; interface Props { activeAddress: string; @@ -84,59 +92,21 @@ interface Props { action: () => Promise, password?: string, ) => Promise; + environmentName: OutsideHandlesContextValue['environmentName']; } const useIsMounted = () => { const isMounted = React.useRef(false); - React.useEffect(() => { + React.useEffect((): (() => void) => { isMounted.current = true; return () => (isMounted.current = false); }, []); return isMounted; }; -let timer = null; - -const initialState = { - fee: { fee: '0' }, - value: { ada: '', assets: [], personalAda: '', minAda: '0' }, - address: { result: '', display: '', error: '' }, - message: '', - tx: null, - txInfo: { - minUtxo: 0, - }, -}; +const objectToArray = obj => Object.keys(obj).map(key => obj[key]); -export const sendStore = { - ...initialState, - setFee: action((state, fee) => { - state.fee = fee; - }), - setValue: action((state, value) => { - state.value = value; - }), - setMessage: action((state, message) => { - state.message = message; - }), - setTx: action((state, tx) => { - state.tx = tx; - }), - setAddress: action((state, address) => { - state.address = address; - }), - setTxInfo: action((state, txInfo) => { - state.txInfo = txInfo; - }), - reset: action(state => { - state.fee = initialState.fee; - state.value = initialState.value; - state.message = initialState.message; - state.address = initialState.address; - state.tx = initialState.tx; - state.txInfo = initialState.txInfo; - }), -}; +export const isAdaHandleEnabled = process.env.USE_ADA_HANDLE === 'true'; const Send = ({ accounts, @@ -146,10 +116,11 @@ const Send = ({ currentChain, updateAccountMetadata, withSignTxConfirmation, -}: Props) => { + environmentName, +}: Readonly) => { const capture = useCaptureEvent(); const isMounted = useIsMounted(); - const { cardanoCoin } = useOutsideHandles(); + const { cardanoCoin, walletType, openHWFlow } = useCommonOutsideHandles(); const [address, setAddress] = [ useStoreState(state => state.globalModel.sendStore.address), useStoreActions(actions => actions.globalModel.sendStore.setAddress), @@ -182,48 +153,51 @@ const Send = ({ setTxUpdate(update => !update); }; - const assets = React.useRef({}); - const account = React.useRef(null); + const assets = React.useRef>({}); + const account = React.useRef(null); const resetState = useStoreActions( actions => actions.globalModel.sendStore.reset, ); const history = useHistory(); - const navigate = history.push; const toast = useToast(); - const ref = React.useRef(); + const ref = React.useRef(null); const [isLoading, setIsLoading] = React.useState(true); const focus = React.useRef(false); const background = useColorModeValue('gray.100', 'gray.600'); const containerBg = useColorModeValue('white', 'gray.800'); - const assetsModalRef = React.useRef(); + const assetsModalRef = React.useRef(null); const protocolParameters = useObservable(inMemoryWallet.protocolParameters$); const utxoTotal = useObservable(inMemoryWallet?.balance.utxo.total$); - const assetsInfo = withHandleInfo( - useObservable(inMemoryWallet.assetInfo$), - useObservable(inMemoryWallet.handles$), + const rawAssetsInfo = useObservable(inMemoryWallet.assetInfo$); + const handles = useObservable(inMemoryWallet.handles$); + + const assetsInfo = useMemo( + () => withHandleInfo(rawAssetsInfo, handles), + [rawAssetsInfo, handles], ); const rewards = useObservable( inMemoryWallet?.balance.rewardAccounts.rewards$, ); - const paymentKeyHash = Ed25519KeyHashHex( - Cardano.Address.fromBech32(activeAddress).asBase()!.getPaymentCredential() - .hash, + const walletAssets = useMemo( + () => + Array.from(utxoTotal?.assets || []) + .filter(([assetId]) => assetsInfo.has(assetId)) + .map(([assetId, quantity]) => + toAsset(assetsInfo.get(assetId)!, quantity), + ), + [utxoTotal?.assets, assetsInfo], ); - const walletAssets = Array.from(utxoTotal?.assets || []) - .filter(([assetId]) => assetsInfo.has(assetId)) - .map(([assetId, quantity]) => toAsset(assetsInfo.get(assetId)!, quantity)); - const prepareTx = async ( _, - data: { + data: Readonly<{ value: any; address: any; message: any; protocolParameters: Cardano.ProtocolParameters; - }, + }>, ) => { if (!isMounted.current) return; @@ -248,7 +222,11 @@ const Send = ({ setFee({ fee: '' }); setTx(null); - await new Promise((res, rej) => setTimeout(() => res(null))); + await new Promise((res, rej) => + setTimeout(() => { + res(null); + }), + ); try { const output = { address: _address.result, @@ -276,7 +254,10 @@ const Send = ({ const checkOutput = new Serialization.TransactionOutput( Cardano.Address.fromBytes( - isValidAddress(_address.result, currentChain), + isValidAddress( + _address.result, + currentChain, + ) as unknown as Wallet.HexBlob, ), assetsToValue(output.amount), ); @@ -287,15 +268,15 @@ const Send = ({ ); if (BigInt(minAda) <= BigInt(toUnit(_value.personalAda || '0'))) { - const displayAda = parseFloat( - _value.personalAda.replace(/[,\s]/g, ''), + const displayAda = Number.parseFloat( + _value.personalAda.replace(/[\s,]/g, ''), ).toLocaleString('en-EN', { minimumFractionDigits: 6 }); output.amount[0].quantity = toUnit(_value.personalAda || '0'); !focus.current && setValue({ ..._value, ada: displayAda }); } else if (_value.assets.length > 0) { output.amount[0].quantity = minAda; - const minAdaDisplay = parseFloat( - displayUnit(minAda).toString().replace(/[,\s]/g, ''), + const minAdaDisplay = Number.parseFloat( + displayUnit(minAda).toString().replace(/[\s,]/g, ''), ).toLocaleString('en-EN', { minimumFractionDigits: 6 }); setValue({ ..._value, @@ -310,18 +291,23 @@ const Send = ({ const transactionOutput = new Serialization.TransactionOutput( Cardano.Address.fromBytes( - isValidAddress(_address.result, currentChain), + isValidAddress( + _address.result, + currentChain, + ) as unknown as Wallet.HexBlob, ), assetsToValue(output.amount), ); - const generalMetadata: Map = - new Map(); + const generalMetadata = new Map< + bigint, + Serialization.TransactionMetadatum + >(); const auxiliaryData = new Serialization.AuxiliaryData(); // setting metadata for optional message (CIP-0020) if (_message) { - function chunkSubstr(str, size) { + const chunkSubstr = (str, size) => { const numChunks = Math.ceil(str.length / size); const chunks = new Array(numChunks); @@ -330,7 +316,7 @@ const Send = ({ } return chunks; - } + }; const msg = { msg: chunkSubstr(_message, 64) }; generalMetadata.set( BigInt('674'), @@ -346,15 +332,15 @@ const Send = ({ ); } - const tx = await buildTx( - transactionOutput, + const tx = await buildTx({ + output: transactionOutput, auxiliaryData, inMemoryWallet, - ); + }); const inspection = await tx.inspect(); setFee({ fee: inspection.inputSelection.fee.toString() }); setTx(tx); - } catch (e) { + } catch { setFee({ error: 'Transaction not possible' }); } }; @@ -382,26 +368,34 @@ const Send = ({ setTxInfo({ minUtxo }); }; - const objectToArray = obj => Object.keys(obj).map(key => obj[key]); - - const addAssets = _assets => { - _assets.forEach(asset => { - assets.current[asset.unit] = { ...asset }; - }); - const assetsList = objectToArray(assets.current); - triggerTxUpdate(() => setValue({ ...value, assets: assetsList })); - }; + const addAssets = useCallback( + _assets => { + for (const asset of _assets) { + assets.current[asset.unit] = { ...asset }; + } + const assetsList = objectToArray(assets.current); + triggerTxUpdate(() => { + setValue({ ...value, assets: assetsList }); + }); + }, + [triggerTxUpdate, setValue], + ); - const removeAsset = asset => { - delete assets.current[asset.unit]; - const assetsList = objectToArray(assets.current); - triggerTxUpdate(() => setValue({ ...value, assets: assetsList })); - }; + const removeAsset = useCallback( + asset => { + delete assets.current[asset.unit]; + const assetsList = objectToArray(assets.current); + triggerTxUpdate(() => { + setValue({ ...value, assets: assetsList }); + }); + }, + [assets.current, triggerTxUpdate, setValue], + ); React.useEffect(() => { if (protocolParameters) { - setTx(null); - setFee({ fee: '' }); + if (tx !== null) setTx(null); + if (fee.fee !== '' || fee.error) setFee({ fee: '' }); prepareTxDebounced(0, { value, address, @@ -409,7 +403,7 @@ const Send = ({ protocolParameters, }); } - }, [txUpdate]); + }, [txUpdate, message]); React.useEffect(() => { init(); @@ -420,10 +414,24 @@ const Send = ({ resetState(); }; }, []); + + const onAssetInput = useCallback( + async (asset: Readonly, val: string) => { + if (!assets.current[asset.unit]) return; + assets.current[asset.unit].input = val; + const v = value; + v.assets = objectToArray(assets.current); + triggerTxUpdate(() => { + setValue({ ...v, assets: v.assets }); + }); + }, + [assets.current, triggerTxUpdate, setValue], + ); + return ( <> { - history.goBack(); + history.push('/'); }} variant="ghost" + aria-label={''} icon={} /> @@ -466,13 +475,16 @@ const Send = ({ width="80%" > {address.error && ( - - {!isLoading ? ( - {cardanoCoin.symbol} - ) : ( - - )} - - } - /> + + + {isLoading ? ( + + ) : ( + {cardanoCoin.symbol} + )} + + + triggerTxUpdate(() => { setValue({ ...v, - }), - ); + }); + }); }} variant="filled" isDisabled={isLoading} @@ -561,12 +571,14 @@ const Send = ({ justifyContent={'center'} > - } /> + + + { - const msg = e.target.value; - triggerTxUpdate(() => setMessage(msg)); + const msg = (e.target as HTMLInputElement).value; + setMessage(msg); }} size={'sm'} variant={'flushed'} @@ -591,18 +603,8 @@ const Send = ({ {value.assets.map(asset => ( { - removeAsset(asset); - }} - onInput={async val => { - if (!assets.current[asset.unit]) return; - assets.current[asset.unit].input = val; - const v = value; - v.assets = objectToArray(assets.current); - triggerTxUpdate(() => - setValue({ ...v, assets: v.assets }), - ); - }} + onRemove={removeAsset} + onInput={onAssetInput} asset={asset} /> @@ -624,17 +626,17 @@ const Send = ({ isLoading={ !fee.fee && !fee.error && - address.result && + !!address.result && !address.error && - (value.ada || value.assets.length > 0) + !!(value.ada || value.assets.length > 0) } width={'366px'} height={'50px'} - isDisabled={!tx || !address.result || fee.error} + isDisabled={!tx || !address.result || !!fee.error} colorScheme="orange" onClick={() => { capture(Events.SendTransactionDataReviewTransactionClick); - ref.current.openModal(account.current.index); + ref.current?.openModal(); }} > {fee.error ? fee.error : 'Send'} @@ -645,6 +647,9 @@ const Send = ({ - assetsModalRef.current.openModal({ + assetsModalRef.current?.openModal({ userInput: true, assets: value.assets.map(asset => ({ ...asset, @@ -739,39 +744,45 @@ const Send = ({ } ref={ref} - sign={async (password, hw) => { + sign={async (password = '') => { capture(Events.SendTransactionConfirmationConfirmClick); - if (hw) { - if (hw.device === HW.trezor) { - return createTab(TAB.trezorTx, `?tx=${tx}`); - } - return await signAndSubmitHW(txDes, { - keyHashes: [paymentKeyHash], - account: account.current, - hw, - }); - } else - return await signAndSubmit( + try { + await signAndSubmit({ tx, password, withSignTxConfirmation, inMemoryWallet, - ); + }); + } catch (error) { + console.error('Failed to sign and submit transaction', error); + throw error; + } + }} + getCbor={async () => { + const inspection = await tx.inspect(); + + return encodeToCbor({ + body: inspection.body, + witness: inspection.witness, + auxiliaryData: inspection.auxiliaryData, + }); }} - onConfirm={async (status, signedTx) => { - if (status === true) { + onConfirm={async (status, error) => { + if (status) { capture(Events.SendTransactionConfirmed); toast({ title: 'Transaction submitted', status: 'success', duration: 5000, }); - if (await isValidAddress(address.result, currentChain)) { + if (isValidAddress(address.result, currentChain)) { await updateAccountMetadata({ - namiMode: { recentSendToAddress: address.result }, + namiMode: { + recentSendToAddress: { [environmentName]: address.result }, + }, }); } - } else if (signedTx === ERROR.fullMempool) { + } else if (error === ERROR.fullMempool) { toast({ title: 'Transaction failed', description: 'Mempool full. Try again.', @@ -779,7 +790,7 @@ const Send = ({ duration: 3000, isClosable: true, }); - ref.current.closeModal(); + ref.current?.closeModal(); return; // don't go back to home screen. let user try to submit same tx again } else toast({ @@ -789,7 +800,7 @@ const Send = ({ }); ref.current?.closeModal(); setTimeout(() => { - navigate(-1); + history.push('/'); }, 200); }} /> @@ -806,24 +817,22 @@ const AddressPopup = ({ triggerTxUpdate, isLoading, recentSendToAddress, -}: { - accounts: { - name: string; - avatar?: string; - address?: string; - }[]; + environmentName, +}: Readonly<{ + accounts: UseAccount['nonActiveAccounts']; recentSendToAddress?: string; currentChain: Wallet.Cardano.ChainId; setAddress: any; address: { result: string; display: string; error?: string }; triggerTxUpdate: any; isLoading: boolean; -}) => { + environmentName: OutsideHandlesContextValue['environmentName']; +}>) => { const { isOpen, onOpen, onClose } = useDisclosure(); const checkColor = useColorModeValue('teal.500', 'teal.200'); const ref = React.useRef(false); const latestHandleInputToken = React.useRef(0); - const handleResolver = useHandleResolver(currentChain.networkMagic); + const { handleResolver } = useCommonOutsideHandles(); const handleInput = async e => { const value = e.target.value; @@ -844,29 +853,26 @@ const AddressPopup = ({ }; } - if (isHandle) { + if (isHandle && isAdaHandleEnabled) { const handle = value; const resolvedAddress = await getAdaHandle( handle.slice(1), handleResolver, ); - if ( + addr = handle.length > 1 && resolvedAddress && isValidAddress(resolvedAddress, currentChain) - ) { - addr = { - result: resolvedAddress, - display: handle, - }; - } else { - addr = { - result: '', - display: handle, - error: '$handle not found', - }; - } + ? { + result: resolvedAddress, + display: handle, + } + : { + result: '', + display: handle, + error: '$handle not found', + }; } return addr; @@ -876,13 +882,24 @@ const AddressPopup = ({ debouncePromise(latest(handleInput), 700), ); + const filteredAccounts = useMemo( + () => accounts.filter(a => a.address?.[environmentName]), + [accounts], + ); + return ( 0) && isOpen} - onOpen={() => !isLoading && !address.result && !address.error && onOpen()} + onOpen={() => { + !isLoading && !address.result && !address.error && onOpen(); + }} autoFocus={false} onClose={async () => { - await new Promise((res, rej) => setTimeout(() => res())); + await new Promise((res, rej) => + setTimeout(() => { + res(); + }), + ); if (ref.current) { ref.current = false; return; @@ -900,20 +917,26 @@ const AddressPopup = ({ value={address.display} spellCheck={false} onBlur={async e => { - await new Promise((res, rej) => setTimeout(() => res())); + await new Promise((res, rej) => + setTimeout(() => { + res(); + }), + ); if (ref.current) { ref.current = false; return; } onClose(); - setTimeout(() => e.target.blur()); + setTimeout(() => { + e.target.blur(); + }); }} fontSize="xs" placeholder="Address or $handle" onInput={async e => { const handleInputToken = latestHandleInputToken.current + 1; latestHandleInputToken.current = handleInputToken; - setAddress({ display: e.target.value }); + setAddress({ display: (e.target as HTMLInputElement).value }); const addr = await handleInputDebounced(e); if (handleInputToken !== latestHandleInputToken.current) { @@ -926,9 +949,9 @@ const AddressPopup = ({ isInvalid={Boolean(address.error)} /> {address.result && !address.error && ( - } - /> + + + )} @@ -990,7 +1013,7 @@ const AddressPopup = ({ )} - {accounts.length > 0 && ( + {filteredAccounts.length > 0 && ( <> {' '} Accounts - {accounts.map(({ name, address, avatar }) => { + {filteredAccounts.map(({ name, address, avatar }) => { return ( @@ -1127,8 +1156,8 @@ const AssetsSelector = ({ justifyContent="center" > 0 && 3} + width={Object.keys(choice).length <= 0 ? '90%' : undefined} + flex={Object.keys(choice).length > 0 ? 3 : undefined} size="sm" > { - setSearch(e.target.value); + setSearch((e.target as HTMLInputElement).value); }} /> - setSearch('')} - /> - } - /> + + { + setSearch(''); + }} + /> + {Object.keys(choice).length > 0 && ( <> @@ -1164,7 +1193,9 @@ const AssetsSelector = ({ aria-label="close button" size="xs" rounded="md" - onClick={() => setChoice({})} + onClick={() => { + setChoice({}); + }} icon={} /> @@ -1196,17 +1227,17 @@ const AssetsSelector = ({ my="1" > {assets ? ( - filterAssets().length > 0 ? ( + filteredAssets.length > 0 ? ( {({ index, style }) => { - const asset = filterAssets()[index]; + const asset = filteredAssets[index]; return ( { +}>) => { const hoverColor = useColorModeValue('gray.100', 'gray.600'); return ( @@ -1345,12 +1376,7 @@ const Selection = ({ asset, choice, setChoice, -}: { - select; - asset: NamiAsset; - choice; - setChoice; -}) => { +}: Readonly<{ select; asset: NamiAsset; choice; setChoice }>) => { const selectColor = useColorModeValue('orange.500', 'orange.200'); return ( = { layout: 'centered', }, beforeEach: () => { - getCurrentAccount.mockImplementation(async () => { - return await Promise.resolve(currentAccount); - }); useStoreState.mockImplementation((callback: any) => { return callback(store); }); @@ -105,7 +99,6 @@ const meta: Meta = { }); return () => { - getCurrentAccount.mockReset(); useStoreState.mockReset(); useStoreActions.mockReset(); Route.mockReset(); diff --git a/packages/nami/src/ui/app/pages/settings.tsx b/packages/nami/src/ui/app/pages/settings.tsx index 359f15f0f..252ad4165 100644 --- a/packages/nami/src/ui/app/pages/settings.tsx +++ b/packages/nami/src/ui/app/pages/settings.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable react/no-multi-comp */ import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -27,7 +28,7 @@ import { useColorModeValue, } from '@chakra-ui/react'; import { MdModeEdit } from 'react-icons/md'; -import { Route, Switch, useHistory } from 'react-router-dom'; +import { Route, Switch, useHistory, useLocation } from 'react-router-dom'; import { CurrencyCode } from '../../../adapters/currency'; import { getFavoriteIcon } from '../../../api/extension'; @@ -38,10 +39,10 @@ import { useStoreActions } from '../../store'; import Account from '../components/account'; import AvatarLoader from '../components/avatarLoader'; import { ChangePasswordModal } from '../components/changePasswordModal'; -import ConfirmModal from '../components/confirmModal'; import type { UseAccount } from '../../../adapters/account'; import type { OutsideHandlesContextValue } from '../../../features/outside-handles-provider'; +import type { ChangePasswordModalComponentRef } from '../components/changePasswordModal'; import type { Wallet } from '@lace/cardano'; type Props = Pick< @@ -93,6 +94,7 @@ const Settings = ({ isValidURL, }: Readonly) => { const history = useHistory(); + const location = useLocation(); const containerBg = useColorModeValue('white', 'gray.800'); const textColor = useColorModeValue('rgb(26, 32, 44)', 'inherit'); const setIsLaceSwitchInProgress = useStoreActions( @@ -101,7 +103,7 @@ const Settings = ({ return ( { - history.goBack(); + history.push( + location.pathname === '/settings/' ? '/' : '/settings/', + ); }} variant="ghost" icon={} /> - + - + - + - + void }) => { variant="ghost" onClick={handleShowLaceBannerClick} > - Switch to Lace Mode + Upgrade to Lace - {state.wrongPassword === true && ( + {state.wrongPassword && ( Password is wrong )} @@ -743,8 +757,12 @@ const NewAccountModal = React.forwardRef< NewAccountModal.displayName = 'NewAccountModal'; +interface DeleteAccountRef { + openModal: () => void; +} + const DeleteAccountModal = React.forwardRef< - unknown, + DeleteAccountRef, { activateAccount: UseAccount['activateAccount']; removeAccount: UseAccount['removeAccount']; @@ -754,7 +772,7 @@ const DeleteAccountModal = React.forwardRef< >(({ activateAccount, removeAccount, accounts, activeAccount }, ref) => { const { isOpen, onOpen, onClose } = useDisclosure(); const [isLoading, setIsLoading] = React.useState(false); - const cancelRef = React.useRef(); + const cancelRef = React.useRef(null); const capture = useCaptureEvent(); React.useImperativeHandle(ref, () => ({ @@ -778,11 +796,13 @@ const DeleteAccountModal = React.forwardRef< const deleteAccount = useCallback(async () => { setIsLoading(true); - await activateAccount({ - accountIndex: nextAccount?.index!, - walletId: nextAccount?.walletId!, - force: true, - }); + if (nextAccount) { + await activateAccount({ + accountIndex: nextAccount?.index, + walletId: nextAccount?.walletId, + force: true, + }); + } setTimeout(async () => { await removeAccount({ walletId: activeAccount.walletId, @@ -842,21 +862,19 @@ const DeleteAccountModal = React.forwardRef< DeleteAccountModal.displayName = 'DeleteAccountModal'; -const DelegationPopover = ({ builderRef }) => { - const { - inMemoryWallet, - cardanoCoin, - buildDelegation, - setSelectedStakePool, - openExternalLink, - } = useOutsideHandles(); +const DelegationPopover = ({ + builderRef, +}: Readonly<{ builderRef: RefObject }>) => { + const { buildDelegation, setSelectedStakePool, openExternalLink } = + useOutsideHandles(); + const { inMemoryWallet, cardanoCoin } = useCommonOutsideHandles(); const { delegation } = useDelegation({ inMemoryWallet, buildDelegation, setSelectedStakePool, }); const capture = useCaptureEvent(); - const ref = React.useRef(); + const ref = React.useRef(null); const containerBg = useColorModeValue('gray.800', 'white'); const delegateButtonBg = useColorModeValue( 'gray.100', @@ -931,7 +949,7 @@ const DelegationPopover = ({ builderRef }) => {