diff --git a/packages/browser-wallet/src/assets/svgX/check.svg b/packages/browser-wallet/src/assets/svgX/check.svg new file mode 100644 index 000000000..fd51d5bb2 --- /dev/null +++ b/packages/browser-wallet/src/assets/svgX/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser-wallet/src/popup/popupX/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx b/packages/browser-wallet/src/popup/popupX/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx index 6c6df95f3..19d84e9e3 100644 --- a/packages/browser-wallet/src/popup/popupX/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx +++ b/packages/browser-wallet/src/popup/popupX/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { Outlet } from 'react-router-dom'; -import Toast from '@popup/shared/Toast/Toast'; +import Toast from '@popup/popupX/shared/Toast'; import clsx from 'clsx'; import { Connection } from '@popup/popupX/page-layouts/MainLayout/Header/components'; import FullscreenPromptLayout from '@popup/popupX/page-layouts/FullscreenPromptLayout'; diff --git a/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/Header/components/AccountSelector.tsx b/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/Header/components/AccountSelector.tsx index 608ba6636..76d29fc71 100644 --- a/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/Header/components/AccountSelector.tsx +++ b/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/Header/components/AccountSelector.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import Text from '@popup/popupX/shared/Text'; import { AccountInfoType } from '@concordium/web-sdk'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; +import { useCopyAddress } from '@popup/popupX/shared/utils/hooks'; function shortNumber(number: number | string): string { return number.toLocaleString('en-US', { @@ -77,6 +77,7 @@ export default function AccountSelector({ showAccountSelector, onUpdateSelectedA const { t } = useTranslation('x', { keyPrefix: 'header.accountSelector' }); const credentialsLoading = useAtomValue(credentialsAtomWithLoading); const [selectedAccount, setSelectedAccount] = useAtom(selectedAccountAtom); + const copyAddressToClipboard = useCopyAddress(); const [search, setSearch] = useState(''); const [ascSort, setAscSort] = useState(true); const credentials = credentialsLoading.value ?? []; @@ -97,7 +98,7 @@ export default function AccountSelector({ showAccountSelector, onUpdateSelectedA const copyAddress = (event: React.MouseEvent, address: string) => { event.stopPropagation(); - copyToClipboard(address); + copyAddressToClipboard(address); }; if (!showAccountSelector) return null; diff --git a/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/MainLayout.tsx b/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/MainLayout.tsx index e80702599..901e03357 100644 --- a/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/MainLayout.tsx +++ b/packages/browser-wallet/src/popup/popupX/page-layouts/MainLayout/MainLayout.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import clsx from 'clsx'; import Header from '@popup/popupX/page-layouts/MainLayout/Header'; +import Toast from '@popup/popupX/shared/Toast'; import { AccountButton, NavButton } from '@popup/popupX/page-layouts/MainLayout/Header/components'; import { relativeRoutes, RoutePath } from '@popup/popupX/constants/routes'; @@ -54,6 +55,7 @@ export default function MainLayout() { + ); } diff --git a/packages/browser-wallet/src/popup/popupX/pages/Accounts/Accounts.tsx b/packages/browser-wallet/src/popup/popupX/pages/Accounts/Accounts.tsx index 6e720e7ba..75a8b4c71 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/Accounts/Accounts.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/Accounts/Accounts.tsx @@ -13,7 +13,6 @@ import Card from '@popup/popupX/shared/Card'; import Text from '@popup/popupX/shared/Text'; import { generatePath, useNavigate } from 'react-router-dom'; import { absoluteRoutes } from '@popup/popupX/constants/routes'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; import { useAtomValue } from 'jotai'; import { credentialsAtom } from '@popup/store/account'; import { WalletCredential } from '@shared/storage/types'; @@ -21,6 +20,7 @@ import { displaySplitAddress, useIdentityName, useWritableSelectedAccount } from import { useAccountInfo } from '@popup/shared/AccountInfoListenerContext'; import { displayAsCcd } from 'wallet-common-helpers'; import useEditableValue from '@popup/popupX/shared/EditableValue'; +import { useCopyAddress } from '@popup/popupX/shared/utils/hooks'; function compareAsc(left: WalletCredential, right: WalletCredential): number { if (left.credName === '' && right.credName !== '') { @@ -43,6 +43,7 @@ type AccountListItemProps = { function AccountListItem({ credential }: AccountListItemProps) { const { t } = useTranslation('x', { keyPrefix: 'accounts' }); const nav = useNavigate(); + const copyAddressToClipboard = useCopyAddress(); const navToPrivateKey = () => nav(generatePath(absoluteRoutes.settings.accounts.privateKey.path, { account: credential.address })); const navToConnectedSites = () => @@ -82,7 +83,7 @@ function AccountListItem({ credential }: AccountListItemProps) { {address} - copyToClipboard(address)} icon={} /> + copyAddressToClipboard(address)} icon={} /> {t('ccdBalance')} diff --git a/packages/browser-wallet/src/popup/popupX/pages/Nft/NftRaw.tsx b/packages/browser-wallet/src/popup/popupX/pages/Nft/NftRaw.tsx index 3492af4d1..ca19ecc0f 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/Nft/NftRaw.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/Nft/NftRaw.tsx @@ -7,8 +7,8 @@ import { withSelectedCredential } from '@popup/popupX/shared/utils/hoc'; import { useTranslation } from 'react-i18next'; import Button from '@popup/popupX/shared/Button'; import Copy from '@assets/svgX/copy.svg'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; import Card from '@popup/popupX/shared/Card'; +import { useCopyToClipboard } from '@popup/popupX/shared/utils/hooks'; type Params = { contractIndex: string; @@ -24,6 +24,7 @@ function useSelectedToken(credential: WalletCredential) { function NftRaw({ credential }: { credential: WalletCredential }) { const { t } = useTranslation('x', { keyPrefix: 'nft' }); const token = useSelectedToken(credential); + const copyToClipboard = useCopyToClipboard(); const metadata = token?.metadata || {}; return ( diff --git a/packages/browser-wallet/src/popup/popupX/pages/PrivateKey/PrivateKey.tsx b/packages/browser-wallet/src/popup/popupX/pages/PrivateKey/PrivateKey.tsx index 16422143d..42548dedc 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/PrivateKey/PrivateKey.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/PrivateKey/PrivateKey.tsx @@ -12,9 +12,9 @@ import { networkConfigurationAtom } from '@popup/store/settings'; import { saveData } from '@popup/shared/utils/file-helpers'; import { NetworkConfiguration } from '@shared/storage/types'; import { getNet } from '@shared/utils/network-helpers'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; import { Navigate, useParams } from 'react-router-dom'; import { withPasswordProtected } from '@popup/popupX/shared/utils/hoc'; +import { useCopyToClipboard } from '@popup/popupX/shared/utils/hooks'; type CredentialKeys = { threshold: number; @@ -100,6 +100,7 @@ type Props = { function PrivateKey({ address }: Props) { const { t } = useTranslation('x', { keyPrefix: 'privateKey' }); const { privateKey, handleExport } = usePrivateKeyData(address); + const copyToClipboard = useCopyToClipboard(); return ( diff --git a/packages/browser-wallet/src/popup/popupX/pages/ReceiveFunds/ReceiveFunds.tsx b/packages/browser-wallet/src/popup/popupX/pages/ReceiveFunds/ReceiveFunds.tsx index 6ac40a3b5..5ab1d6f26 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/ReceiveFunds/ReceiveFunds.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/ReceiveFunds/ReceiveFunds.tsx @@ -7,12 +7,13 @@ import Card from '@popup/popupX/shared/Card'; import { useAtomValue } from 'jotai'; import { selectedCredentialAtom } from '@popup/store/account'; import { DisplayAsQR } from 'wallet-common-helpers'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; import { displayNameAndSplitAddress } from '@popup/shared/utils/account-helpers'; +import { useCopyAddress } from '@popup/popupX/shared/utils/hooks'; export default function ReceiveFunds() { const { t } = useTranslation('x', { keyPrefix: 'receiveFunds' }); const credential = useAtomValue(selectedCredentialAtom); + const copyAddressToClipboard = useCopyAddress(); if (credential === undefined) { return null; @@ -27,7 +28,10 @@ export default function ReceiveFunds() { {credential.address} - copyToClipboard(credential.address)} /> + copyAddressToClipboard(credential.address)} + /> diff --git a/packages/browser-wallet/src/popup/popupX/pages/SeedPhrase/SeedPhrase.tsx b/packages/browser-wallet/src/popup/popupX/pages/SeedPhrase/SeedPhrase.tsx index 2b98c30a1..79739970d 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/SeedPhrase/SeedPhrase.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/SeedPhrase/SeedPhrase.tsx @@ -10,13 +10,14 @@ import { useAsyncMemo } from 'wallet-common-helpers'; import { decrypt } from '@shared/utils/crypto'; import { useAtomValue } from 'jotai'; import { encryptedSeedPhraseAtom, sessionPasscodeAtom } from '@popup/store/settings'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; import { withPasswordProtected } from '@popup/popupX/shared/utils/hoc'; +import { useCopyToClipboard } from '@popup/popupX/shared/utils/hooks'; function SeedPhrase() { const { t } = useTranslation('x', { keyPrefix: 'seedPhrase' }); const passcode = useAtomValue(sessionPasscodeAtom); const encryptedSeed = useAtomValue(encryptedSeedPhraseAtom); + const copyToClipboard = useCopyToClipboard(); const seedPhrase = useAsyncMemo( async () => { diff --git a/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenRaw.tsx b/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenRaw.tsx index 9a3de7e4e..4e7e1b872 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenRaw.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/TokenDetails/TokenRaw.tsx @@ -7,8 +7,8 @@ import { withSelectedCredential } from '@popup/popupX/shared/utils/hoc'; import { useTranslation } from 'react-i18next'; import Button from '@popup/popupX/shared/Button'; import Copy from '@assets/svgX/copy.svg'; -import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; import Card from '@popup/popupX/shared/Card'; +import { useCopyToClipboard } from '@popup/popupX/shared/utils/hooks'; type Params = { contractIndex: string; @@ -23,6 +23,7 @@ function useSelectedToken(credential: WalletCredential) { function TokenRaw({ credential }: { credential: WalletCredential }) { const { t } = useTranslation('x', { keyPrefix: 'tokenDetails' }); const token = useSelectedToken(credential); + const copyToClipboard = useCopyToClipboard(); const metadata = token?.metadata || {}; return ( diff --git a/packages/browser-wallet/src/popup/popupX/shared/Toast/Messages.tsx b/packages/browser-wallet/src/popup/popupX/shared/Toast/Messages.tsx new file mode 100644 index 000000000..b6fe8f4ee --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Toast/Messages.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Check from '@assets/svgX/check.svg'; +import { displaySplitAddressShort } from '@popup/shared/utils/account-helpers'; +import Text from '@popup/popupX/shared/Text'; + +export function CopyAddress({ address, message }: { address: string; message: string }) { + return ( +
+ +
+ {message} + {displaySplitAddressShort(address)} +
+
+ ); +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Toast/Toast.scss b/packages/browser-wallet/src/popup/popupX/shared/Toast/Toast.scss new file mode 100644 index 000000000..85dd6a664 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Toast/Toast.scss @@ -0,0 +1,74 @@ +.toast-x { + visibility: hidden; + width: rem(175px); + left: 0; + right: 0; + margin: 0 auto; + background-color: $color-white; + color: $color-black; + text-align: center; + border-radius: rem(16px); + padding: rem(10px) rem(15px); + position: absolute; + z-index: 2; + bottom: rem(16px); + backdrop-filter: blur(5px); + box-shadow: 0 -6px 15.3px 0 rgba($color-black, 0.25); + + &__show { + visibility: visible; + animation: fadein-toast 0.5s; + } + + &__fadeout { + visibility: visible; + animation: fadeout-toast 0.5s; + } + + @keyframes fadein-toast { + from { + bottom: 0; + opacity: 0; + } + + to { + bottom: rem(16px); + opacity: 1; + } + } + + @keyframes fadeout-toast { + from { + bottom: rem(16px); + opacity: 1; + } + + to { + bottom: rem(16px); + opacity: 0; + } + } +} + +.copy-address-x { + display: flex; + flex-direction: row; + align-items: center; + + .copy-message { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: rem(4px); + margin-left: rem(10px); + + .label__main { + color: $color-black; + white-space: nowrap; + } + + .capture__main_small { + color: $color-black; + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Toast/Toast.tsx b/packages/browser-wallet/src/popup/popupX/shared/Toast/Toast.tsx new file mode 100644 index 000000000..2b056dfd9 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Toast/Toast.tsx @@ -0,0 +1,49 @@ +import { toastsAtom } from '@popup/state'; +import clsx from 'clsx'; +import { useAtom } from 'jotai'; +import React, { ReactNode, useEffect, useState } from 'react'; +import { noOp } from 'wallet-common-helpers'; + +// The fadeout timeout has to be aligned with the animation in the corresponding CSS. This is +// done by manually tweaking the values until the animation looks decent. Currently the value +// is 100ms less than the corresponding value in CSS. +const fadeoutTimeoutMs = 400; + +// Determines how long we display the toast. +const toastTimeoutMs = 5000; + +export default function Toast() { + const [toasts, setToasts] = useAtom(toastsAtom); + const [toastText, setToastText] = useState(); + const [fadeout, setFadeout] = useState(false); + useEffect(() => { + if (!toastText && toasts.length > 0) { + const [nextToast, ...remainderToasts] = toasts; + setToastText(nextToast); + setToasts(remainderToasts); + } + }, [toasts, toastText]); + + useEffect(() => { + if (toastText) { + const fadeoutTimer = setTimeout(() => { + setFadeout(true); + setTimeout(() => setFadeout(false), fadeoutTimeoutMs); + }, toastTimeoutMs - fadeoutTimeoutMs); + + const timeout = setTimeout(() => { + setToastText(undefined); + }, toastTimeoutMs); + + return () => { + clearTimeout(timeout); + clearTimeout(fadeoutTimer); + }; + } + return noOp; + }, [toastText]); + + return ( +
{toastText}
+ ); +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Toast/index.tsx b/packages/browser-wallet/src/popup/popupX/shared/Toast/index.tsx new file mode 100644 index 000000000..55a859a08 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Toast/index.tsx @@ -0,0 +1 @@ +export { default } from './Toast'; diff --git a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts index cd31e11fe..b810d7069 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts @@ -83,6 +83,10 @@ const t = { reject: 'Reject', error: 'Error', }, + messages: { + addressCopied: 'Address copied', + copied: 'Copied', + }, }; export default t; diff --git a/packages/browser-wallet/src/popup/popupX/shared/utils/hooks.tsx b/packages/browser-wallet/src/popup/popupX/shared/utils/hooks.tsx new file mode 100644 index 000000000..9ebbbdf9b --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/utils/hooks.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSetAtom } from 'jotai'; +import { addToastAtom } from '@popup/state'; +import { copyToClipboard } from '@popup/popupX/shared/utils/helpers'; +import { CopyAddress } from '@popup/popupX/shared/Toast/Messages'; + +export function useCopyAddress() { + const { t } = useTranslation('x', { keyPrefix: 'sharedX.messages' }); + const addToast = useSetAtom(addToastAtom); + + return (address: string) => { + copyToClipboard(address).then(() => addToast()); + }; +} + +export function useCopyToClipboard() { + const { t } = useTranslation('x', { keyPrefix: 'sharedX.messages' }); + const addToast = useSetAtom(addToastAtom); + + return (text: string) => { + copyToClipboard(text).then(() => addToast(t('copied'))); + }; +} diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index 601fa730a..6819c8bef 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -50,6 +50,7 @@ @import '../shared/Text/Text'; @import '../shared/Tooltip/Tooltip'; @import '../shared/Loader/Loader'; +@import '../shared/Toast/Toast'; @import '../shared/IdCard/IdCard'; @import '../shared/PasswordProtect/PasswordProtect'; @import '../shared/Web3IdCard/Web3IdCard'; diff --git a/packages/browser-wallet/src/popup/shared/Toast/Toast.tsx b/packages/browser-wallet/src/popup/shared/Toast/Toast.tsx index 0cedac003..b7decc0b5 100644 --- a/packages/browser-wallet/src/popup/shared/Toast/Toast.tsx +++ b/packages/browser-wallet/src/popup/shared/Toast/Toast.tsx @@ -1,7 +1,7 @@ import { toastsAtom } from '@popup/state'; import clsx from 'clsx'; import { useAtom } from 'jotai'; -import React, { useEffect, useState } from 'react'; +import React, { ReactNode, useEffect, useState } from 'react'; import { noOp } from 'wallet-common-helpers'; // The fadeout timeout has to be aligned with the animation in the corresponding CSS. This is @@ -14,7 +14,7 @@ const toastTimeoutMs = 5000; export default function Toast() { const [toasts, setToasts] = useAtom(toastsAtom); - const [toastText, setToastText] = useState(); + const [toastText, setToastText] = useState(); const [fadeout, setFadeout] = useState(false); useEffect(() => { diff --git a/packages/browser-wallet/src/popup/state/index.ts b/packages/browser-wallet/src/popup/state/index.ts index dceda1b5f..47029dc34 100644 --- a/packages/browser-wallet/src/popup/state/index.ts +++ b/packages/browser-wallet/src/popup/state/index.ts @@ -1,8 +1,9 @@ import { atom } from 'jotai'; +import { ReactNode } from 'react'; export const passcodeAtom = atom(undefined); -export const toastsAtom = atom([]); -export const addToastAtom = atom(null, (get, set, newToast) => { +export const toastsAtom = atom<(string | ReactNode)[]>([]); +export const addToastAtom = atom(null, (get, set, newToast) => { const currentToasts = get(toastsAtom); const updatedToasts = [...currentToasts, newToast]; set(toastsAtom, updatedToasts);