diff --git a/.env.development b/.env.development index 89bb519ccc8..03127ce0ee6 100644 --- a/.env.development +++ b/.env.development @@ -6,6 +6,7 @@ # feature flags VITE_FEATURE_THORCHAIN_TCY_ACTIVITY=true +VITE_FEATURE_ADDRESS_BOOK=true # mixpanel VITE_MIXPANEL_TOKEN=a867ce40912a6b7d01d088cf62b0e1ff diff --git a/.env.production b/.env.production index e32e5cacfd1..c040290c591 100644 --- a/.env.production +++ b/.env.production @@ -1,6 +1,7 @@ # feature flags VITE_FEATURE_MIXPANEL=true VITE_ENABLE_ADDRESSABLE=true +VITE_FEATURE_ADDRESS_BOOK=false VITE_FEATURE_THORCHAIN_TCY_ACTIVITY=false diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 59a560cecea..c862d350f6d 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -20,7 +20,9 @@ "closed": "Closed", "loadMore": "Load More", "volumeShort": "VOL", + "save": "Save", "saveChanges": "Save Changes", + "delete": "Delete", "new": "New", "continue": "Continue", "approve": "Approve", @@ -1085,6 +1087,18 @@ "toAddressOrEns": "To Address or ENS", "scanQrCode": "Scan QR Code", "qrCode": "QR Code", + "addressBook": "Address Book", + "noEntries": "No address found.", + "addNewAddress": "Add New Address", + "addAddress": { + "nameRequired": "Name is required", + "namePlaceholder": "Name", + "title": "Add Address" + }, + "confirmDelete": { + "title": "Delete Address", + "message": "Are you sure you want to delete %{name} from your address book?" + }, "permissionsButton": "Try again", "sent": "%{asset} sent", "errorTitle": "Your send of %{asset} failed", @@ -1112,7 +1126,13 @@ "slow": "Slow", "average": "Average", "fast": "Fast", - "scanQrCodeDescription": "Tap to scan an address" + "enterAmount": "Enter Amount", + "scanQrCode": "Scan QR Code", + "scanQrCodeDescription": "Tap to scan an address", + "yourAddresses": "Your Addresses", + "to": "To", + "from": "From", + "availableBalance": "%{balance} available" }, "status": { "pendingBody": "Sending %{amount} %{symbol}" diff --git a/src/components/CryptoFiatInput/CryptoFiatInput.tsx b/src/components/CryptoFiatInput/CryptoFiatInput.tsx index 3c6e6f776e9..33c4b333d79 100644 --- a/src/components/CryptoFiatInput/CryptoFiatInput.tsx +++ b/src/components/CryptoFiatInput/CryptoFiatInput.tsx @@ -134,15 +134,15 @@ export const CryptoFiatInput = ({ - - {isFiat ? ( - - ) : ( - - )} - - diff --git a/src/components/Modals/QrCode/QrCode.tsx b/src/components/Modals/QrCode/QrCode.tsx index c9b3b8e7700..77b881f89e4 100644 --- a/src/components/Modals/QrCode/QrCode.tsx +++ b/src/components/Modals/QrCode/QrCode.tsx @@ -1,14 +1,11 @@ -import { useMediaQuery } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' -import { useMemo } from 'react' import { MemoryRouter } from 'react-router-dom' -import { desktopSendRoutes, mobileSendRoutes, SendRoutes } from '../Send/SendCommon' +import { initialEntries, SendRoutes } from '../Send/SendCommon' import { Form } from './Form' import { Dialog } from '@/components/Modal/components/Dialog' import { useModal } from '@/hooks/useModal/useModal' -import { breakpoints } from '@/theme/theme' export type QrCodeModalProps = { assetId?: AssetId @@ -17,11 +14,6 @@ export type QrCodeModalProps = { export const QrCodeModal = ({ assetId, accountId }: QrCodeModalProps) => { const { close, isOpen } = useModal('qrCode') - const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) - - const initialEntries = useMemo(() => { - return isSmallerThanMd ? mobileSendRoutes : desktopSendRoutes - }, [isSmallerThanMd]) return ( diff --git a/src/components/Modals/Send/AddressBook/AddressBook.tsx b/src/components/Modals/Send/AddressBook/AddressBook.tsx new file mode 100644 index 00000000000..d7e54bc3f01 --- /dev/null +++ b/src/components/Modals/Send/AddressBook/AddressBook.tsx @@ -0,0 +1,154 @@ +import { Box, HStack, Icon, Text as CText, useDisclosure, VStack } from '@chakra-ui/react' +import type { ChainId } from '@shapeshiftoss/caip' +import { useCallback, useMemo, useState } from 'react' +import { useFormContext, useWatch } from 'react-hook-form' +import { FaRegAddressBook } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' + +import { SendFormFields } from '../SendCommon' + +import { AddressBookEntryButton } from '@/components/Modals/Send/AddressBook/AddressBookEntryButton' +import { ConfirmDelete } from '@/components/Modals/Send/AddressBook/ConfirmDelete' +import type { SendInput } from '@/components/Modals/Send/Form' +import { Text } from '@/components/Text' +import { makeBlockiesUrl } from '@/lib/blockies/makeBlockiesUrl' +import { addressBookSlice } from '@/state/slices/addressBookSlice/addressBookSlice' +import { + selectAddressBookEntriesByChainId, + selectAddressBookEntriesBySearchQuery, +} from '@/state/slices/addressBookSlice/selectors' +import type { AddressBookEntry } from '@/state/slices/addressBookSlice/types' +import { useAppDispatch, useAppSelector } from '@/state/store' + +type AddressBookProps = { + chainId?: ChainId + onEntryClick: (address: string) => void + emptyMessage?: string +} + +const addressBookMaxHeight = { + base: 'auto', + md: '200px', +} + +export const AddressBook = ({ + chainId, + onEntryClick, + emptyMessage = 'modals.send.noEntries', +}: AddressBookProps) => { + const translate = useTranslate() + const dispatch = useAppDispatch() + const { + control, + formState: { errors }, + } = useFormContext() + const { isOpen, onClose, onOpen } = useDisclosure() + const [selectedDeleteEntry, setSelectedDeleteEntry] = useState(null) + + const addressError = errors[SendFormFields.Input]?.message ?? null + + const input = useWatch({ + control, + name: SendFormFields.Input, + }) + + const addressBookEntriesFilter = useMemo(() => ({ chainId }), [chainId]) + const addressBookEntries = useAppSelector(state => + selectAddressBookEntriesByChainId(state, addressBookEntriesFilter), + ) + + const selectedEntry = useMemo(() => { + if (!input) return undefined + return addressBookEntries?.find(entry => entry.isExternal && entry.address === input) + }, [addressBookEntries, input]) + + const addressBookSearchEntriesFilter = useMemo( + () => ({ chainId, searchQuery: input }), + [chainId, input], + ) + + const addressBookSearchEntries = useAppSelector(state => + selectAddressBookEntriesBySearchQuery(state, addressBookSearchEntriesFilter), + ) + + const handleDelete = useCallback( + (entry: AddressBookEntry) => () => { + dispatch(addressBookSlice.actions.deleteAddress(entry)) + }, + [dispatch], + ) + + const handleDeleteConfirm = useCallback( + (selectedEntry: AddressBookEntry) => () => { + setSelectedDeleteEntry(selectedEntry) + onOpen() + }, + [onOpen], + ) + + const entries = useMemo(() => { + if (selectedEntry || !input || (input && !addressError)) return addressBookEntries + + return addressBookSearchEntries + }, [selectedEntry, addressBookEntries, addressBookSearchEntries, input, addressError]) + + const entryAvatars = useMemo(() => { + return entries?.reduce( + (acc, entry) => { + acc[entry.address] = makeBlockiesUrl(entry.address) + return acc + }, + {} as Record, + ) + }, [entries]) + + const addressBookButtons = useMemo(() => { + if (entries?.length === 0) + return + + return entries?.map(entry => { + return ( + + ) + }) + }, [entries, entryAvatars, handleDeleteConfirm, onEntryClick, emptyMessage]) + + return ( + + + + + {translate('modals.send.addressBook')} + + + + + + {addressBookButtons} + + + {selectedDeleteEntry && ( + + )} + + ) +} diff --git a/src/components/Modals/Send/AddressBook/AddressBookEntryButton.tsx b/src/components/Modals/Send/AddressBook/AddressBookEntryButton.tsx new file mode 100644 index 00000000000..823b86f7569 --- /dev/null +++ b/src/components/Modals/Send/AddressBook/AddressBookEntryButton.tsx @@ -0,0 +1,85 @@ +import { Avatar, Box, Button, HStack, Icon, Text as CText, VStack } from '@chakra-ui/react' +import { memo, useCallback } from 'react' +import { FaTrash } from 'react-icons/fa' + +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' + +type AddressBookEntryButtonProps = { + avatarUrl: string + label: string + address: string + entryKey: string + onSelect: (address: string) => void + onDelete: (key: string) => void +} + +const addressSx = { + _hover: { + background: 'background.surface.raised.base', + }, +} + +const deleteButtonSx = { + svg: { + width: '12px', + height: '12px', + }, +} + +const AddressBookEntryButtonComponent = ({ + avatarUrl, + label, + address, + entryKey, + onSelect, + onDelete, +}: AddressBookEntryButtonProps) => { + const handleClick = useCallback(() => onSelect(address), [onSelect, address]) + const handleDelete = useCallback( + (key: string) => (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + onDelete(key) + }, + [onDelete], + ) + + return ( + + + + + + {label} + + + + + + + ) +} + +// Memoize to prevent re-renders when props haven't changed +export const AddressBookEntryButton = memo(AddressBookEntryButtonComponent) diff --git a/src/components/Modals/Send/AddressBook/ConfirmDelete.tsx b/src/components/Modals/Send/AddressBook/ConfirmDelete.tsx new file mode 100644 index 00000000000..1d65958bf17 --- /dev/null +++ b/src/components/Modals/Send/AddressBook/ConfirmDelete.tsx @@ -0,0 +1,51 @@ +import { Button, Stack, Text as CText } from '@chakra-ui/react' +import { useCallback } from 'react' +import { useTranslate } from 'react-polyglot' + +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogFooter } from '@/components/Modal/components/DialogFooter' +import { DialogHeader, DialogHeaderMiddle } from '@/components/Modal/components/DialogHeader' +import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { Text } from '@/components/Text' + +type ConfirmDeleteProps = { + entryName: string + onDelete: () => void + onClose: () => void + isOpen: boolean +} + +export const ConfirmDelete = ({ entryName, onDelete, onClose, isOpen }: ConfirmDeleteProps) => { + const translate = useTranslate() + + const handleConfirm = useCallback(() => { + onDelete() + onClose() + }, [onDelete, onClose]) + + return ( + + + + {translate('modals.send.confirmDelete.title')} + + + + + {translate('modals.send.confirmDelete.message', { name: entryName })} + + + + + + + + + + ) +} diff --git a/src/components/Modals/Send/AddressBookSaveModal.tsx b/src/components/Modals/Send/AddressBookSaveModal.tsx new file mode 100644 index 00000000000..3c3bf1199a1 --- /dev/null +++ b/src/components/Modals/Send/AddressBookSaveModal.tsx @@ -0,0 +1,157 @@ +import { + Avatar, + Button, + FormControl, + FormErrorMessage, + Input, + Stack, + VStack, +} from '@chakra-ui/react' +import type { ChainId } from '@shapeshiftoss/caip' +import { toAccountId } from '@shapeshiftoss/caip' +import { useCallback, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslate } from 'react-polyglot' + +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' +import { Dialog } from '@/components/Modal/components/Dialog' +import { DialogBody } from '@/components/Modal/components/DialogBody' +import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' +import { DialogFooter } from '@/components/Modal/components/DialogFooter' +import { DialogHeader, DialogHeaderRight } from '@/components/Modal/components/DialogHeader' +import { Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { makeBlockiesUrl } from '@/lib/blockies/makeBlockiesUrl' +import { addressBookSlice } from '@/state/slices/addressBookSlice/addressBookSlice' +import { selectInternalAccountIdByAddress } from '@/state/slices/addressBookSlice/selectors' +import type { AddressBookEntry } from '@/state/slices/addressBookSlice/types' +import { useAppDispatch, useAppSelector } from '@/state/store' + +export type AddressBookSaveModalProps = { + address: string + vanityAddress?: string + chainId: ChainId + onSuccess?: () => void +} + +type FormData = { + label: string +} + +const focusInputStyle = { + border: 'none', + boxShadow: 'none', +} + +const hoverInputStyle = { + border: 'none', +} + +export const AddressBookSaveModal = ({ + address, + vanityAddress, + chainId, + onSuccess, +}: AddressBookSaveModalProps) => { + const translate = useTranslate() + const dispatch = useAppDispatch() + const { isOpen, close: onClose } = useModal('addressBookSave') + + const internalAccountIdFilter = useMemo( + () => ({ + accountAddress: address, + chainId, + }), + [address, chainId], + ) + + const internalAccountId = useAppSelector(state => + selectInternalAccountIdByAddress(state, internalAccountIdFilter), + ) + + const { + register, + handleSubmit, + reset, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + label: vanityAddress, + }, + }) + + const avatarUrl = useMemo(() => makeBlockiesUrl(address), [address]) + + const handleClose = useCallback(() => { + reset() + onClose() + }, [onClose, reset]) + + const onSubmit = useCallback( + (data: FormData) => { + // Can't access here if it's an internal account id as its supposed to be added automatically as a system + if (internalAccountId) return + + const accountId = toAccountId({ chainId, account: address }) + const entry: AddressBookEntry = { + address, + accountId, + label: data.label, + isInternal: false, + isExternal: true, + } + + dispatch(addressBookSlice.actions.addAddress(entry)) + onSuccess?.() + handleClose() + }, + [dispatch, address, chainId, onSuccess, handleClose, internalAccountId], + ) + + return ( + + + + + + +
+ + + + + + {errors.label?.message} + + + + + + + + + + + +
+
+ ) +} diff --git a/src/components/Modals/Send/AddressInput/AddressInput.tsx b/src/components/Modals/Send/AddressInput/AddressInput.tsx index 76256943443..3c9fda8f0e1 100644 --- a/src/components/Modals/Send/AddressInput/AddressInput.tsx +++ b/src/components/Modals/Send/AddressInput/AddressInput.tsx @@ -1,13 +1,21 @@ -import type { InputProps } from '@chakra-ui/react' +import type { InputProps, SpaceProps } from '@chakra-ui/react' import { + Avatar, + Box, + Button, + Flex, + HStack, IconButton, Input, InputGroup, InputLeftElement, InputRightElement, + Text as CText, Text, + VStack, } from '@chakra-ui/react' -import { useCallback, useMemo } from 'react' +import type { ChainId } from '@shapeshiftoss/caip' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ControllerProps, ControllerRenderProps, FieldValues } from 'react-hook-form' import { Controller, useFormContext, useWatch } from 'react-hook-form' import { useTranslate } from 'react-polyglot' @@ -17,20 +25,47 @@ import ResizeTextarea from 'react-textarea-autosize' import { SendFormFields, SendRoutes } from '../SendCommon' import { QRCodeIcon } from '@/components/Icons/QRCode' +import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import type { SendInput } from '@/components/Modals/Send/Form' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useModal } from '@/hooks/useModal/useModal' +import { makeBlockiesUrl } from '@/lib/blockies/makeBlockiesUrl' +import { isUtxoAccountId } from '@/lib/utils/utxo' +import { + selectExternalAddressBookEntryByAddress, + selectInternalAccountIdByAddress, +} from '@/state/slices/addressBookSlice/selectors' +import { accountIdToLabel } from '@/state/slices/portfolioSlice/utils' +import { selectAssetById, selectPortfolioAccountMetadata } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' type AddressInputProps = { rules: ControllerProps['rules'] enableQr?: boolean placeholder?: string + pe?: SpaceProps['pe'] + resolvedAddress?: string + isReadOnly?: boolean + chainId?: ChainId + shouldShowSaveButton?: boolean } & Omit +const addressInputSx = { + _hover: { + opacity: 0.8, + }, +} + const qrCodeIcon = export const AddressInput = ({ rules, placeholder, enableQr = false, + isReadOnly = false, + resolvedAddress, + chainId, + shouldShowSaveButton = true, onFocus, onBlur, onPaste, @@ -38,10 +73,75 @@ export const AddressInput = ({ }: AddressInputProps) => { const navigate = useNavigate() const translate = useTranslate() + const [isFocused, setIsFocused] = useState(false) const isValid = useFormContext().formState.isValid const isDirty = useFormContext().formState.isDirty const isValidating = useFormContext().formState.isValidating - const value = useWatch({ name: SendFormFields.Input }) + + const isAddressBookEnabled = useFeatureFlag('AddressBook') + + const { vanityAddress, input: value, to, assetId } = useWatch() as Partial + const inputRef = useRef(null) + const addressBookSaveModal = useModal('addressBookSave') + + const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) + + const handleSaveContact = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + + if (to && asset?.chainId) { + addressBookSaveModal.open({ address: to, vanityAddress, chainId: asset.chainId }) + } + }, + [to, vanityAddress, asset?.chainId, addressBookSaveModal], + ) + + const addressBookEntryFilter = useMemo( + () => ({ + accountAddress: value, + chainId, + }), + [value, chainId], + ) + const addressBookEntry = useAppSelector(state => + selectExternalAddressBookEntryByAddress(state, addressBookEntryFilter), + ) + + const internalAccountIdFilter = useMemo( + () => ({ + accountAddress: resolvedAddress, + chainId, + }), + [resolvedAddress, chainId], + ) + + const internalAccountId = useAppSelector(state => + selectInternalAccountIdByAddress(state, internalAccountIdFilter), + ) + + const accountMetadata = useAppSelector(selectPortfolioAccountMetadata) + + const accountNumber = useMemo( + () => + internalAccountId + ? accountMetadata[internalAccountId]?.bip44Params?.accountNumber + : undefined, + [accountMetadata, internalAccountId], + ) + + const internalAccountLabel = useMemo(() => { + if (!internalAccountId) return null + + if (isUtxoAccountId(internalAccountId)) { + return accountIdToLabel(internalAccountId) + } + + // Fallback to "Account" if accountNumber is not available yet + return accountNumber !== undefined + ? translate('accounts.accountNumber', { accountNumber }) + : translate('common.account') + }, [internalAccountId, accountNumber, translate]) const isInvalid = useMemo(() => { // Don't go invalid when async invalidation is running @@ -52,46 +152,218 @@ export const AddressInput = ({ return isValid === false }, [isValid, isValidating, value]) + const avatarUrl = useMemo(() => (value ? makeBlockiesUrl(value) : ''), [value]) + + useEffect(() => { + if (addressBookEntry) { + setIsFocused(false) + } + }, [addressBookEntry]) + + useEffect(() => { + if (isFocused && inputRef.current && document.activeElement !== inputRef.current) { + inputRef.current.focus() + } + }, [isFocused]) + + const handleFocus = useCallback( + (e: React.FocusEvent) => { + onFocus?.(e) + setIsFocused(true) + }, + [onFocus], + ) + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + onBlur?.(e) + setIsFocused(false) + }, + [onBlur], + ) + const handleQrClick = useCallback(() => { navigate(SendRoutes.Scan) }, [navigate]) + const handleDisplayClick = useCallback(() => { + setIsFocused(true) + }, []) + + const handlePaste = useCallback(() => { + if (isFocused && inputRef.current) { + // Add this at the end of the callstack so we leave space for the paste event to be handled + // by the onChange event handler of the form before blurring which would cancel the paste event + setTimeout(() => { + inputRef.current?.blur() + setIsFocused(false) + }, 0) + } + }, [isFocused]) + + const ensOrRawAddress = useMemo(() => { + if (vanityAddress) { + const ensAvatarAddress = makeBlockiesUrl(resolvedAddress ?? value ?? '') + + return ( + + + + + {vanityAddress} + + {resolvedAddress && ( + + )} + + + ) + } + + return ( + + + + ) + }, [value, resolvedAddress, vanityAddress]) + const renderController = useCallback( ({ field: { onChange, value }, }: { field: ControllerRenderProps - }) => ( - - - - {translate('trade.to')} - - - { + if ((isFocused || !value || isInvalid) && !isReadOnly) { + return ( + + + + {translate('trade.to')} + + + + + ) + } + + if (addressBookEntry) { + return ( + + + + {translate('modals.send.sendForm.to')} + + + + + + {addressBookEntry.label} + + + + + + + ) + } + + return ( + - - ), + w='full' + px={4} + sx={addressInputSx} + onClick={props.onClick ?? handleDisplayClick} + cursor='pointer' + > + + + {translate('modals.send.sendForm.to')} + + {internalAccountId ? ( + + + + + {`${internalAccountLabel}${vanityAddress ? ` (${vanityAddress})` : ''}`} + + {resolvedAddress && ( + + )} + + + ) : ( + ensOrRawAddress + )} + + {isAddressBookEnabled && shouldShowSaveButton && !internalAccountId && ( + + )} + + ) + }, // We want only behavior-specific props to rerender the controller, not all props // eslint-disable-next-line react-hooks/exhaustive-deps - [placeholder, isInvalid, isDirty, translate, onFocus, onBlur, onPaste], + [ + placeholder, + isInvalid, + isReadOnly, + isFocused, + isDirty, + translate, + onFocus, + onBlur, + onPaste, + resolvedAddress, + addressBookEntry, + internalAccountId, + internalAccountLabel, + avatarUrl, + shouldShowSaveButton, + isAddressBookEnabled, + vanityAddress, + ensOrRawAddress, + ], ) return ( diff --git a/src/components/Modals/Send/Form.tsx b/src/components/Modals/Send/Form.tsx index d4fe8f2c0e7..345fca58c90 100644 --- a/src/components/Modals/Send/Form.tsx +++ b/src/components/Modals/Send/Form.tsx @@ -1,4 +1,3 @@ -import { useMediaQuery } from '@chakra-ui/react' import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip' import type { FeeDataEstimate } from '@shapeshiftoss/chain-adapters' @@ -43,7 +42,6 @@ import { selectPortfolioAccountIdsByAssetIdFilter, } from '@/state/slices/selectors' import { store, useAppDispatch, useAppSelector } from '@/state/store' -import { breakpoints } from '@/theme/theme' const status = const sendAmount = @@ -92,7 +90,6 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco const { state: { wallet }, } = useWallet() - const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) const filter = useMemo(() => ({ assetId: initialAssetId }), [initialAssetId]) const accountIds = useAppSelector(state => @@ -235,16 +232,10 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco // Use requestAnimationFrame to ensure navigation happens after state updates requestAnimationFrame(() => { - if (isSmallerThanMd) { - navigate(SendRoutes.Address, { replace: true }) - return - } - // On desktop, go directly to AmountDetails - // On mobile, go to Address first - navigate(SendRoutes.AmountDetails, { replace: true }) + navigate(SendRoutes.Address, { replace: true }) }) }, - [navigate, methods, selectedCurrency, isSmallerThanMd], + [navigate, methods, selectedCurrency], ) const handleBack = useCallback(() => { @@ -318,17 +309,12 @@ export const Form: React.FC = ({ initialAssetId, input = '', acco .toString(), ) } - if (isSmallerThanMd) { - navigate(SendRoutes.Address) - return - } - - navigate(SendRoutes.AmountDetails) + navigate(SendRoutes.Address) } catch (e: any) { setAddressError(e.message) } }, - [navigate, methods, isSmallerThanMd], + [navigate, methods], ) const qrCodeScanner = useMemo( diff --git a/src/components/Modals/Send/Send.tsx b/src/components/Modals/Send/Send.tsx index 7ae7340236c..350cf2d0425 100644 --- a/src/components/Modals/Send/Send.tsx +++ b/src/components/Modals/Send/Send.tsx @@ -1,14 +1,11 @@ -import { useMediaQuery } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' -import { useMemo } from 'react' import { MemoryRouter } from 'react-router-dom' import { Form } from './Form' -import { desktopSendRoutes, mobileSendRoutes } from './SendCommon' import { Dialog } from '@/components/Modal/components/Dialog' +import { initialEntries } from '@/components/Modals/Send/SendCommon' import { useModal } from '@/hooks/useModal/useModal' -import { breakpoints } from '@/theme/theme' export type SendModalProps = { assetId?: AssetId @@ -19,12 +16,6 @@ export type SendModalProps = { export const SendModal = ({ assetId, accountId, input }: SendModalProps) => { const { close, isOpen } = useModal('send') - const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) - - const initialEntries = useMemo(() => { - return isSmallerThanMd ? mobileSendRoutes : desktopSendRoutes - }, [isSmallerThanMd]) - return ( diff --git a/src/components/Modals/Send/SendCommon.tsx b/src/components/Modals/Send/SendCommon.tsx index 7315d8297e4..5e97732e667 100644 --- a/src/components/Modals/Send/SendCommon.tsx +++ b/src/components/Modals/Send/SendCommon.tsx @@ -5,9 +5,10 @@ export enum SendRoutes { Scan = '/send/scan', Select = '/send/select', Address = '/send/address', + Amount = '/send/amount', } -export const mobileSendRoutes = [ +export const initialEntries = [ SendRoutes.Confirm, SendRoutes.Status, SendRoutes.Scan, @@ -16,15 +17,6 @@ export const mobileSendRoutes = [ SendRoutes.Address, ] -export const desktopSendRoutes = [ - SendRoutes.Confirm, - SendRoutes.Status, - SendRoutes.Scan, - SendRoutes.Select, - SendRoutes.Address, - SendRoutes.AmountDetails, -] - export enum SendFormFields { Input = 'input', // the raw field input on the address input Memo = 'memo', // an optional memo, used only on chains supporting it e.g Cosmos SDK chains diff --git a/src/components/Modals/Send/components/AddressInputWithDropdown.tsx b/src/components/Modals/Send/components/AddressInputWithDropdown.tsx deleted file mode 100644 index 1be3e634b0c..00000000000 --- a/src/components/Modals/Send/components/AddressInputWithDropdown.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { - Box, - Button, - FormControl, - Popover, - PopoverBody, - PopoverContent, - PopoverTrigger, - Text as ChakraText, - useDisclosure, - VStack, -} from '@chakra-ui/react' -import { useCallback, useRef } from 'react' -import type { ControllerProps } from 'react-hook-form' - -import { AddressInput } from '../AddressInput/AddressInput' - -import { QRCodeIcon } from '@/components/Icons/QRCode' - -interface AddressInputWithDropdownProps { - addressInputRules: ControllerProps['rules'] - supportsENS: boolean - translate: (key: string) => string - onScanQRCode: () => void -} - -const qrCodeIcon = - -export const AddressInputWithDropdown = ({ - addressInputRules, - supportsENS, - translate, - onScanQRCode, -}: AddressInputWithDropdownProps) => { - const { isOpen, onOpen, onClose } = useDisclosure() - const triggerRef = useRef(null) - - const handleFocus = useCallback(() => { - if (!isOpen) { - onOpen() - } - }, [onOpen, isOpen]) - - const handleQRClick = useCallback(() => { - onScanQRCode() - onClose() - }, [onScanQRCode, onClose]) - - return ( - - - - - - - - - - - - - - - - - ) -} diff --git a/src/components/Modals/Send/views/Address.tsx b/src/components/Modals/Send/views/Address.tsx index 2b06820ae78..09e70b2ac19 100644 --- a/src/components/Modals/Send/views/Address.tsx +++ b/src/components/Modals/Send/views/Address.tsx @@ -15,6 +15,7 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { useLocation, useNavigate } from 'react-router-dom' +import { AddressBook } from '../AddressBook/AddressBook' import { AddressInput } from '../AddressInput/AddressInput' import type { SendInput } from '../Form' import { SendFormFields, SendRoutes } from '../SendCommon' @@ -28,8 +29,13 @@ import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { SelectAssetRoutes } from '@/components/SelectAssets/SelectAssetCommon' import { SlideTransition } from '@/components/SlideTransition' import { Text } from '@/components/Text' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useModal } from '@/hooks/useModal/useModal' import { parseAddressInputWithChainId } from '@/lib/address/address' +import { + selectInternalAccountIdByAddress, + selectIsAddressInAddressBook, +} from '@/state/slices/addressBookSlice/selectors' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -55,6 +61,7 @@ export const Address = () => { const qrCode = useModal('qrCode') const assetId = useWatch({ name: SendFormFields.AssetId }) const qrBackground = useColorModeValue('blackAlpha.200', 'whiteAlpha.200') + const isAddressBookEnabled = useFeatureFlag('AddressBook') const location = useLocation() const isFromQrCode = useMemo(() => location.state?.isFromQrCode === true, [location.state]) @@ -78,9 +85,31 @@ export const Address = () => { const asset = useAppSelector(state => selectAssetById(state, assetId)) + const isInAddressBookFilter = useMemo( + () => ({ accountAddress: address, chainId: asset?.chainId }), + [address, asset?.chainId], + ) + const isInAddressBook = useAppSelector(state => + selectIsAddressInAddressBook(state, isInAddressBookFilter), + ) + + const internalAccountIdFilter = useMemo( + () => ({ accountAddress: address, chainId: asset?.chainId }), + [address, asset?.chainId], + ) + const internalAccountId = useAppSelector(state => + selectInternalAccountIdByAddress(state, internalAccountIdFilter), + ) + const supportsENS = asset?.chainId === ethChainId // We only support ENS resolution on ETH mainnet const addressError = get(errors, `${SendFormFields.Input}.message`, null) + const showSaveButton = useMemo(() => { + return Boolean( + !isInAddressBook && !internalAccountId && !!address && !addressError && isAddressBookEnabled, + ) + }, [isInAddressBook, address, addressError, isAddressBookEnabled, internalAccountId]) + useEffect(() => { trigger(SendFormFields.Input) }, [trigger]) @@ -150,6 +179,20 @@ export const Address = () => { navigate(SendRoutes.Scan) }, [navigate]) + const handleClickAddressBookEntry = useCallback( + (entryAddress: string) => { + setValue(SendFormFields.Input, entryAddress, { shouldValidate: true }) + navigate(SendRoutes.AmountDetails) + }, + [setValue, navigate], + ) + + const handleEmptyChange = useCallback(() => { + setValue(SendFormFields.Input, '', { shouldValidate: true }) + setValue(SendFormFields.To, '') + setValue(SendFormFields.VanityAddress, '') + }, [setValue]) + if (!asset) return null return ( @@ -168,8 +211,13 @@ export const Address = () => { placeholder={translate( supportsENS ? 'modals.send.toAddressOrEns' : 'modals.send.toAddress', )} + resolvedAddress={address} + chainId={asset?.chainId} + onEmptied={handleEmptyChange} + shouldShowSaveButton={showSaveButton} /> + + + {isAddressBookEnabled && ( + + )} diff --git a/src/components/Modals/Send/views/Confirm.tsx b/src/components/Modals/Send/views/Confirm.tsx index e0266e2fb45..47fecd1905d 100644 --- a/src/components/Modals/Send/views/Confirm.tsx +++ b/src/components/Modals/Send/views/Confirm.tsx @@ -1,5 +1,6 @@ import { ExternalLinkIcon } from '@chakra-ui/icons' import { + Avatar, Box, Button, Divider, @@ -54,12 +55,21 @@ import { getConfig } from '@/config' import { defaultLongPressConfig } from '@/constants/longPress' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { useWallet } from '@/hooks/useWallet/useWallet' +import { makeBlockiesUrl } from '@/lib/blockies/makeBlockiesUrl' import { isMobile } from '@/lib/globals' import { middleEllipsis } from '@/lib/utils' import { isUtxoAccountId } from '@/lib/utils/utxo' import { vibrate } from '@/lib/vibrate' -import { ProfileAvatar } from '@/pages/Dashboard/components/ProfileAvatar/ProfileAvatar' -import { selectAssetById, selectFeeAssetById } from '@/state/slices/selectors' +import { + selectExternalAddressBookEntryByAddress, + selectInternalAccountIdByAddress, +} from '@/state/slices/addressBookSlice/selectors' +import { accountIdToLabel } from '@/state/slices/portfolioSlice/utils' +import { + selectAssetById, + selectFeeAssetById, + selectPortfolioAccountMetadata, +} from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' export type FeePrice = { @@ -111,6 +121,110 @@ export const Confirm = ({ handleSubmit }: ConfirmProps) => { state: { wallet }, } = useWallet() + const addressBookEntryFilter = useMemo( + () => ({ accountAddress: to, chainId: asset?.chainId }), + [to, asset?.chainId], + ) + const addressBookEntry = useAppSelector(state => + selectExternalAddressBookEntryByAddress(state, addressBookEntryFilter), + ) + + const internalAccountIdFilter = useMemo( + () => ({ + accountAddress: to, + chainId: asset?.chainId, + }), + [to, asset?.chainId], + ) + + const internalAccountId = useAppSelector(state => + selectInternalAccountIdByAddress(state, internalAccountIdFilter), + ) + + const accountMetadata = useAppSelector(selectPortfolioAccountMetadata) + + const accountNumber = useMemo( + () => + internalAccountId + ? accountMetadata[internalAccountId]?.bip44Params?.accountNumber + : undefined, + [accountMetadata, internalAccountId], + ) + + const internalAccountLabel = useMemo(() => { + if (!internalAccountId) return null + + if (isUtxoAccountId(internalAccountId)) { + return accountIdToLabel(internalAccountId) + } + + if (accountNumber === undefined) return + + return translate('accounts.accountNumber', { accountNumber }) + }, [internalAccountId, accountNumber, translate]) + + const displayDestinationContent = useMemo(() => { + if (vanityAddress) { + return ( + <> + + {vanityAddress} + + + + + + ) + } + + if (addressBookEntry) { + return ( + <> + + {addressBookEntry.label} + + + + + + ) + } + + if (internalAccountLabel) { + return ( + <> + + {internalAccountLabel} + + + + + + ) + } + + return ( + + + + ) + }, [vanityAddress, addressBookEntry, internalAccountLabel, to]) + const showMemoRow = useMemo( () => Boolean(assetId && fromAssetId(assetId).chainNamespace === CHAIN_NAMESPACE.CosmosSdk), [assetId], @@ -127,6 +241,14 @@ export const Confirm = ({ handleSubmit }: ConfirmProps) => { [assetId, wallet], ) + const avatarUrl = useMemo( + () => + addressBookEntry && addressBookEntry.isExternal + ? makeBlockiesUrl(addressBookEntry.address) + : makeBlockiesUrl(to ?? ''), + [addressBookEntry, to], + ) + const borderColor = useColorModeValue('gray.100', 'gray.750') const handleNonceChange = useCallback( @@ -251,11 +373,9 @@ export const Confirm = ({ handleSubmit }: ConfirmProps) => { /> - - - - {vanityAddress ? vanityAddress : } - + + + {displayDestinationContent} @@ -280,8 +400,7 @@ export const Confirm = ({ handleSubmit }: ConfirmProps) => { > - {/* @TODO: Use custom receive address avatar */} - + diff --git a/src/components/Modals/Send/views/SendAmountDetails.tsx b/src/components/Modals/Send/views/SendAmountDetails.tsx index 4031f6e7368..03272e06f87 100644 --- a/src/components/Modals/Send/views/SendAmountDetails.tsx +++ b/src/components/Modals/Send/views/SendAmountDetails.tsx @@ -10,9 +10,7 @@ import { Input, Skeleton, Stack, - Text as CText, Tooltip, - useMediaQuery, VStack, } from '@chakra-ui/react' import { CHAIN_NAMESPACE, ethChainId, fromAssetId } from '@shapeshiftoss/caip' @@ -24,22 +22,19 @@ import { FaInfoCircle } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { useLocation, useNavigate } from 'react-router-dom' -import { AddressInputWithDropdown } from '../components/AddressInputWithDropdown' import type { SendInput } from '../Form' import { useSendDetails } from '../hooks/useSendDetails/useSendDetails' import { SendFormFields, SendRoutes } from '../SendCommon' import { AccountSelector } from '@/components/AccountSelector/AccountSelector' import { CryptoFiatInput } from '@/components/CryptoFiatInput/CryptoFiatInput' -import { Display } from '@/components/Display' -import { MiddleEllipsis } from '@/components/MiddleEllipsis/MiddleEllipsis' import { DialogBackButton } from '@/components/Modal/components/DialogBackButton' import { DialogBody } from '@/components/Modal/components/DialogBody' import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' +import { AddressInput } from '@/components/Modals/Send/AddressInput/AddressInput' import { SendMaxButton } from '@/components/Modals/Send/SendMaxButton/SendMaxButton' -import { SelectAssetRoutes } from '@/components/SelectAssets/SelectAssetCommon' import { SlideTransition } from '@/components/SlideTransition' import { Text } from '@/components/Text/Text' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' @@ -47,7 +42,6 @@ import { parseAddressInputWithChainId } from '@/lib/address/address' import { bnOrZero } from '@/lib/bignumber/bignumber' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -import { breakpoints } from '@/theme/theme' const accountDropdownBoxProps = { px: 0, my: 0 } const accountDropdownButtonProps = { px: 2 } @@ -74,7 +68,6 @@ export const SendAmountDetails = () => { const translate = useTranslate() const [isValidating, setIsValidating] = useState(false) - const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) const isFromQrCode = useMemo(() => location.state?.isFromQrCode === true, [location.state]) @@ -116,19 +109,6 @@ export const SendAmountDetails = () => { () => navigate(isFromQrCode ? SendRoutes.Scan : SendRoutes.Address), [navigate, isFromQrCode], ) - const handleAssetBackClick = useCallback(() => { - if (isFromQrCode) { - navigate(SendRoutes.Scan) - return - } - setValue(SendFormFields.AssetId, '') - navigate(SendRoutes.Select, { - state: { - toRoute: SelectAssetRoutes.Search, - assetId: '', - }, - }) - }, [navigate, setValue, isFromQrCode]) const handleMaxClick = useCallback(async () => { await handleSendMax() @@ -141,10 +121,6 @@ export const SendAmountDetails = () => { [setValue], ) - const handleScanQrCode = useCallback(() => { - navigate(SendRoutes.Scan) - }, [navigate]) - const currentValue = fieldName === SendFormFields.FiatAmount ? fiatAmount : amountCryptoPrecision const isFiat = fieldName === SendFormFields.FiatAmount @@ -237,12 +213,7 @@ export const SendAmountDetails = () => { return ( - - - - - - + {translate('modals.send.sendForm.sendAsset', { asset: asset.name })} @@ -251,33 +222,16 @@ export const SendAmountDetails = () => { - - - - - - - - {translate('trade.to')} - - - - - - - + {balancesLoading ? ( @@ -375,7 +329,7 @@ export const SendAmountDetails = () => { )}