diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..cf6d73d7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "arrowParens": "always", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "proseWrap": "always", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindConfig": "./tailwind.config.js" +} diff --git a/package.json b/package.json index d8c6b338..aa3f7623 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "postcss": "^8.4.21", "postcss-import": "^15.1.0", "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.4", "react-error-overlay": "6.0.9", "react-table": "^7.8.0", "sass": "^1.58.3", diff --git a/prettier.config.cjs b/prettier.config.cjs deleted file mode 100644 index 4d1a33ff..00000000 --- a/prettier.config.cjs +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - arrowParens: 'always', - printWidth: 120, - tabWidth: 2, - useTabs: false, - semi: true, - singleQuote: true, - trailingComma: 'all', - bracketSpacing: true, - proseWrap: 'always', -}; diff --git a/src/GlobalStateProvider/helpers.ts b/src/GlobalStateProvider/helpers.ts new file mode 100644 index 00000000..b1ed3f04 --- /dev/null +++ b/src/GlobalStateProvider/helpers.ts @@ -0,0 +1,52 @@ +import { getWalletBySource, WalletAccount } from '@talismn/connect-wallets'; +import { getSdkError } from '@walletconnect/utils'; +import { storageService } from '../services/storage/local'; +import { walletConnectService } from '../services/walletConnect'; +import { initiateMetamaskInjectedAccount } from '../services/metamask'; +import { chainIds } from '../config/walletConnect'; +import { TenantName } from '../models/Tenant'; +import { LocalStorageKeys } from '../hooks/useLocalStorage'; + +const initTalisman = async (dAppName: string, selected?: string) => { + const name = storageService.get(LocalStorageKeys.SELECTED_WALLET_NAME); + if (!name?.length) return; + const wallet = getWalletBySource(name); + if (!wallet) return; + await wallet.enable(dAppName); + const accounts = await wallet.getAccounts(); + const selectedWallet = accounts.find((a) => a.address === selected) || accounts[0]; + return selectedWallet; +}; + +const initWalletConnect = async (chainId: string) => { + const provider = await walletConnectService.getProvider(); + if (!provider?.session) return; + return await walletConnectService.init(provider?.session, chainId); +}; + +const initMetamask = async (tenantName: TenantName) => { + const metamaskWalletAddress = storageService.get(LocalStorageKeys.SELECTED_WALLET_NAME); + if (metamaskWalletAddress) { + const injectedAccounts = await initiateMetamaskInjectedAccount(tenantName); + return injectedAccounts[0]; + } +}; + +export const initSelectedWallet = async (dAppName: TenantName, tenantName: TenantName, storageAddress: string) => { + const appName = dAppName || TenantName.Amplitude; + return (await initTalisman(appName, storageAddress)) || + (await initWalletConnect(chainIds[tenantName])) || + (await initMetamask(tenantName)); +} + +export const handleWalletConnectDisconnect = async (walletAccount: WalletAccount | undefined) => { + if (walletAccount?.wallet?.extensionName === 'WalletConnect') { + const topic = walletConnectService.session?.topic; + if (topic) { + await walletConnectService.provider?.client.disconnect({ + topic, + reason: getSdkError('USER_DISCONNECTED'), + }); + } + } +} \ No newline at end of file diff --git a/src/GlobalStateProvider.tsx b/src/GlobalStateProvider/index.tsx similarity index 51% rename from src/GlobalStateProvider.tsx rename to src/GlobalStateProvider/index.tsx index 2c43026d..0c9a0faf 100644 --- a/src/GlobalStateProvider.tsx +++ b/src/GlobalStateProvider/index.tsx @@ -1,17 +1,14 @@ -import { getWalletBySource, WalletAccount } from '@talismn/connect-wallets'; -import { getSdkError } from '@walletconnect/utils'; import { ComponentChildren, createContext } from 'preact'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/compat'; import { useLocation } from 'react-router-dom'; -import { config } from './config'; -import { chainIds } from './config/walletConnect'; -import { storageKeys } from './constants/localStorage'; -import { useLocalStorage } from './hooks/useLocalStorage'; -import { TenantName } from './models/Tenant'; -import { ThemeName } from './models/Theme'; -import { initiateMetamaskInjectedAccount, WALLET_SOURCE_METAMASK } from './services/metamask/metamask'; -import { storageService } from './services/storage/local'; -import { walletConnectService } from './services/walletConnect'; +import { WalletAccount } from '@talismn/connect-wallets'; +import { config } from '../config'; +import { storageKeys } from '../constants/localStorage'; +import { LocalStorageKeys, useLocalStorage } from '../hooks/useLocalStorage'; +import { TenantName } from '../models/Tenant'; +import { ThemeName } from '../models/Theme'; +import { storageService } from '../services/storage/local'; +import { handleWalletConnectDisconnect, initSelectedWallet } from './helpers'; const SECONDS_IN_A_DAY = 86400; const EXPIRATION_PERIOD = 2 * SECONDS_IN_A_DAY; // 2 days @@ -29,31 +26,6 @@ export interface GlobalState { export const defaultTenant = TenantName.Pendulum; const GlobalStateContext = createContext(undefined); -const initTalisman = async (dAppName: string, selected?: string) => { - const name = storageService.get('@talisman-connect/selected-wallet-name'); - if (!name?.length) return; - const wallet = getWalletBySource(name); - if (!wallet) return; - await wallet.enable(dAppName); - const accounts = await wallet.getAccounts(); - const selectedWallet = accounts.find((a) => a.address === selected) || accounts[0]; - return selectedWallet; -}; -const initWalletConnect = async (chainId: string) => { - const provider = await walletConnectService.getProvider(); - //const pairings = provider.client.pairing.getAll({ active: true }); - if (!provider?.session) return; - return await walletConnectService.init(provider?.session, chainId); -}; - -const initMetamaskWallet = async (tenantName: TenantName) => { - const metamaskWalletAddress = storageService.get(`metamask-snap-account`); - if (metamaskWalletAddress) { - return await initiateMetamaskInjectedAccount(tenantName); - } - return; -}; - const GlobalStateProvider = ({ children }: { children: ComponentChildren }) => { const tenantRef = useRef(); const [walletAccount, setWallet] = useState(undefined); @@ -71,6 +43,7 @@ const GlobalStateProvider = ({ children }: { children: ComponentChildren }) => { [tenantName], ); + // Get currently selected wallet account from local storage const { state: storageAddress, set, @@ -80,34 +53,22 @@ const GlobalStateProvider = ({ children }: { children: ComponentChildren }) => { expire: EXPIRATION_PERIOD, }); - const handleWalletConnectDisconnect = useCallback(async () => { - if (walletAccount?.wallet?.extensionName === 'WalletConnect') { - const topic = walletConnectService.session?.topic; - if (topic) { - await walletConnectService.provider?.client.disconnect({ - topic, - reason: getSdkError('USER_DISCONNECTED'), - }); - } - } - }, [walletAccount]); + + const clearLocalStorageWallets = () => { + storageService.remove(LocalStorageKeys.SELECTED_WALLET_NAME); + } const removeWalletAccount = useCallback(async () => { - await handleWalletConnectDisconnect(); + await handleWalletConnectDisconnect(walletAccount); clear(); - // remove talisman - storageService.remove('@talisman-connect/selected-wallet-name'); - storageService.remove(`metamask-snap-account`); + clearLocalStorageWallets() setWallet(undefined); - }, [clear, handleWalletConnectDisconnect]); + }, [clear, walletAccount]); const setWalletAccount = useCallback( - (wallet: WalletAccount | undefined) => { - set(wallet?.address); - setWallet(wallet); - if (wallet?.source === WALLET_SOURCE_METAMASK) { - storageService.set(`metamask-snap-account`, wallet.address); - } + (newWalletAccount: WalletAccount | undefined) => { + set(newWalletAccount?.address); + setWallet(newWalletAccount); }, [set], ); @@ -122,11 +83,7 @@ const GlobalStateProvider = ({ children }: { children: ComponentChildren }) => { // skip if tenant already initialized if (tenantRef.current === tenantName || accountAddress) return; tenantRef.current = tenantName; - const appName = dAppName || TenantName.Amplitude; - const selectedWallet = - (await initTalisman(appName, storageAddress)) || - (await initWalletConnect(chainIds[tenantName])) || - (await initMetamaskWallet(tenantName)); + const selectedWallet = await initSelectedWallet(dAppName, tenantName, storageAddress) if (selectedWallet) setWallet(selectedWallet); }; run(); diff --git a/src/TermsAndConditions.tsx b/src/TermsAndConditions.tsx index 1e724911..b66a24d2 100644 --- a/src/TermsAndConditions.tsx +++ b/src/TermsAndConditions.tsx @@ -1,9 +1,9 @@ import { PropsWithChildren, useState } from 'preact/compat'; import { Button, Checkbox, Link, Modal } from 'react-daisyui'; -import { useLocalStorage } from './hooks/useLocalStorage'; +import { useLocalStorage, LocalStorageKeys } from './hooks/useLocalStorage'; const TermsAndConditions = (_props: PropsWithChildren) => { - const { state, set } = useLocalStorage({ key: 'termsAndConditions' }); + const { state, set } = useLocalStorage({ key: LocalStorageKeys.TERMS_AND_CONDITIONS }); const [checked, setChecked] = useState(false); const acceptTerms = () => { diff --git a/src/assets/CloseIcon.tsx b/src/assets/CloseIcon.tsx index e1e640f8..3387345b 100644 --- a/src/assets/CloseIcon.tsx +++ b/src/assets/CloseIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'preact/compat'; +import { FC } from "preact/compat"; interface Props { className?: string; diff --git a/src/assets/spacewalk.tsx b/src/assets/spacewalk.tsx index c47991a8..c4c9b848 100644 --- a/src/assets/spacewalk.tsx +++ b/src/assets/spacewalk.tsx @@ -3,7 +3,7 @@ import { HTMLAttributes } from 'preact/compat'; const SpacewalkIcon = (props: HTMLAttributes) => ( - {' '} + ); diff --git a/src/components/AccountCard/index.tsx b/src/components/AccountCard/index.tsx new file mode 100644 index 00000000..3dd0de7c --- /dev/null +++ b/src/components/AccountCard/index.tsx @@ -0,0 +1,27 @@ +import { WalletAccount } from '@talismn/connect-wallets'; +import { trimAddress } from '../../helpers/addressFormatter'; +import { useGlobalState } from '../../GlobalStateProvider'; +import ChainLogo from '../../assets/ChainLogo'; + +interface AccountProps { + account: WalletAccount; +} + +export const AccountCard = ({ account }: AccountProps) => { + const { setWalletAccount } = useGlobalState(); + + return ( +
  • + +
  • + ); +}; diff --git a/src/components/ChainSelector.tsx b/src/components/ChainSelector.tsx index 171841ae..1c18cefe 100644 --- a/src/components/ChainSelector.tsx +++ b/src/components/ChainSelector.tsx @@ -1,5 +1,4 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'; -import { Button, Dropdown } from 'react-daisyui'; import AmplitudeLogo from '../assets/AmplitudeLogo'; import PendulumLogo from '../assets/PendulumLogo'; import { toTitle } from '../helpers/string'; @@ -11,11 +10,9 @@ const options = [TenantName.Pendulum, TenantName.Amplitude, TenantName.Foucoco]; const ChainSelector = (): JSX.Element => { const { switchChain, currentTenant } = useSwitchChain(); return ( - - - + + + ); }; diff --git a/src/pages/staking/dialogs/Dialog.tsx b/src/components/Dialog/index.tsx similarity index 96% rename from src/pages/staking/dialogs/Dialog.tsx rename to src/components/Dialog/index.tsx index 7801a3a0..649f39ab 100644 --- a/src/pages/staking/dialogs/Dialog.tsx +++ b/src/components/Dialog/index.tsx @@ -1,14 +1,13 @@ import { Modal } from 'react-daisyui'; import { FC, createPortal, useCallback, useEffect, useRef, useState } from 'preact/compat'; - -import { CloseButton } from '../../../components/CloseButton'; +import { CloseButton } from '../CloseButton'; interface DialogProps { visible: boolean; onClose: () => void; headerText?: string; content: JSX.Element; - actions: JSX.Element; + actions?: JSX.Element; form?: { onSubmit: (event?: Event) => void | Promise; className?: string; diff --git a/src/components/GetToken/index.tsx b/src/components/GetToken/index.tsx index 50c448e5..afe274ba 100644 --- a/src/components/GetToken/index.tsx +++ b/src/components/GetToken/index.tsx @@ -1,5 +1,6 @@ import { Button } from 'react-daisyui'; import { NavLink } from 'react-router-dom'; +import { isDesktop } from 'react-device-detect'; import ampe from '../../assets/ampe.svg'; import pen from '../../assets/pen.svg'; @@ -74,29 +75,37 @@ export const GetToken = () => { const { currentTenant } = useSwitchChain(); const { tokenSymbol } = useNodeInfoState().state; + if (!tokenSymbol) return <>; + const link = `/${currentTenant}/gas`; const isBalanceZero = Number(total) === 0; + const showCurrentToken = getTokenIcon(currentTenant); + return (
    - {isBalanceZero && ( + {isBalanceZero && isDesktop ? ( <> - )} - - {tokenSymbol ? ( - - - ) : ( <> )} + + + +
    ); }; diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 0cd1d7c9..98d1a013 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -12,10 +12,11 @@ import Nav from './Nav'; import NetworkId from './NetworkId'; import SocialAndTermLinks from './SocialAndTermLinks'; import './styles.sass'; +import { isMobile } from 'react-device-detect'; export default function Layout(): JSX.Element | null { const [visible, setVisible] = useState(false); - const { tenantName, dAppName } = useGlobalState(); + const { tenantName } = useGlobalState(); const isPendulum = tenantName === TenantName.Pendulum; const isTestnet = tenantName === TenantName.Foucoco; const sideBarLogo = isPendulum ? PendulumLogo : AmplitudeLogo; @@ -34,7 +35,7 @@ export default function Layout(): JSX.Element | null { return (
    - -
    +
    -
    -
    - -
    - - +
    -
    - -
      +
    + +
    + +
    -
    +
    { + if (visible && isMobile) { + setVisible(false); + } + }} + >
    diff --git a/src/components/PublicKey/index.tsx b/src/components/PublicKey/index.tsx index cb6551e6..0904182e 100644 --- a/src/components/PublicKey/index.tsx +++ b/src/components/PublicKey/index.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback } from 'preact/compat'; +import { CSSProperties, memo, useCallback } from 'preact/compat'; import { Button } from 'react-daisyui'; import CopyIcon from '../../assets/CopyIcon'; import { useClipboard } from '../../hooks/useClipboard'; @@ -42,7 +42,7 @@ export function shortenName(name: string, intendedLength: number) { interface PublicKeyProps { publicKey: string; variant?: Variant; - style?: React.CSSProperties; + style?: CSSProperties; className?: string; showRaw?: boolean; } @@ -52,7 +52,7 @@ export const PublicKey = memo(function PublicKey(props: PublicKeyProps) { const { variant = 'full', className } = props; const digits = getDigitCounts(props.variant); - const style: React.CSSProperties = { + const style: CSSProperties = { userSelect: 'text', WebkitUserSelect: 'text', whiteSpace: variant !== 'full' ? 'pre' : undefined, @@ -72,7 +72,7 @@ interface AddressProps { publicKey: string; variant?: Variant; inline?: boolean; - style?: React.CSSProperties; + style?: CSSProperties; className?: string; icon?: JSX.Element; onClick?: () => void; @@ -83,7 +83,7 @@ interface AddressProps { export const ClickableAddress = memo(function ClickableAddress(props: AddressProps) { return ( - ))} -
    - - - - ); -}; - -export default MetamaskWallet; diff --git a/src/components/Wallet/NovaWallet/index.tsx b/src/components/Wallet/NovaWallet/index.tsx deleted file mode 100644 index 8482aa24..00000000 --- a/src/components/Wallet/NovaWallet/index.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { web3Accounts, web3Enable, web3FromAddress } from '@polkadot/extension-dapp'; -import { WalletAccount } from '@talismn/connect-wallets'; -import { useCallback, useEffect, useState } from 'preact/compat'; -import { Modal } from 'react-daisyui'; -import logo from '../../../assets/nova-wallet.png'; -import { GlobalState } from '../../../GlobalStateProvider'; -import { PublicKey } from '../../PublicKey'; - -export type NovaWalletProps = { - setWalletAccount: GlobalState['setWalletAccount']; -}; - -interface ExtensionAccount { - address: string; - name: string; - source: string; -} - -const NovaWallet = ({ setWalletAccount }: NovaWalletProps) => { - const [openModal, setOpenModal] = useState(false); - const [accounts, setAccounts] = useState([]); - const [selectedAccount, setSelectedAccount] = useState(); - - const onClick = useCallback(async () => { - const _ = await web3Enable('Pendulum Chain Portal'); - const allAccounts = await web3Accounts(); - setAccounts( - allAccounts - .filter(({ meta: { source } }) => source === 'polkadot-js') - .map( - ({ address, meta }): ExtensionAccount => ({ - address: address, - name: meta.name || 'My Account', - source: meta.source, - }), - ), - ); - setOpenModal(true); - }, [setOpenModal]); - - useEffect(() => { - async function buildWalletAccount(extAcc: ExtensionAccount) { - const signer = await web3FromAddress(extAcc.address); - return { - address: extAcc.address, - source: extAcc.source, - name: extAcc.name, - signer: signer as WalletAccount['signer'], - wallet: { - enable: () => undefined, - extensionName: 'polkadot-js', - title: 'Nova Wallet', - installUrl: 'https://novawallet.io/', - logo: { - src: logo, - alt: 'Nova Wallet', - }, - installed: true, - extension: undefined, - signer, - /** - * The following methods are tagged as 'Unused' since they are only required by the @talisman package, - * which we are not using to handle this wallet connection. - */ - getAccounts: () => Promise.resolve([]), // Unused - subscribeAccounts: () => undefined, // Unused - transformError: (err: Error) => err, // Unused - }, - }; - } - if (selectedAccount) { - buildWalletAccount(selectedAccount) - .then((account) => setWalletAccount(account)) - .then(() => { - setOpenModal(false); - }) - .catch((error) => console.error(error)); - } - }, [selectedAccount, setWalletAccount]); - - return ( -
    - -

    Select your Nova account

    -
    - {accounts.map((a, i) => ( - - ))} -
    -
    - -
    - ); -}; - -export default NovaWallet; diff --git a/src/components/Wallet/index.tsx b/src/components/Wallet/index.tsx index e75a9859..2c5b07b5 100644 --- a/src/components/Wallet/index.tsx +++ b/src/components/Wallet/index.tsx @@ -1,102 +1,12 @@ -import { ArrowLeftOnRectangleIcon } from '@heroicons/react/20/solid'; -import { WalletSelect } from '@talismn/connect-components'; -import { Button, Divider, Dropdown } from 'react-daisyui'; -import { isMobile } from 'react-device-detect'; +import { DisconnectModal } from './modals/DisconnectModal'; import { useGlobalState } from '../../GlobalStateProvider'; -import { useNodeInfoState } from '../../NodeInfoProvider'; -import { getAddressForFormat } from '../../helpers/addressFormatter'; -import { useAccountBalance } from '../../shared/useAccountBalance'; -import { CopyableAddress } from '../PublicKey'; -import { Skeleton } from '../Skeleton'; -import MetamaskWallet from './MetamaskWallet'; -import NovaWallet from './NovaWallet'; -import WalletConnect from './WalletConnect'; +import { ConnectModal, ConnectProps } from './modals/ConnectModal'; -interface Props { - isHeader?: boolean; -} +const OpenWallet = (props: ConnectProps): JSX.Element => { + const { walletAccount } = useGlobalState(); + const { address } = walletAccount || {}; -const OpenWallet = (props: Props): JSX.Element => { - const { walletAccount, dAppName, setWalletAccount, removeWalletAccount } = useGlobalState(); - const { wallet, address } = walletAccount || {}; - const { query, balances } = useAccountBalance(); - const { ss58Format, tokenSymbol } = useNodeInfoState().state; - const { total: balance } = balances; - - return ( - <> - {address ? ( - - - -
    {walletAccount?.name}
    -
    - -
    -

    - {balance} {tokenSymbol} -

    - -
    -
    - ) : ( - <> - - Connect to Wallet - - } - onAccountSelected={setWalletAccount} - footer={ - <> - {isMobile && ( - <> - - - - )} - - - - - } - /> - - )} - - ); + return address ? : ; }; export default OpenWallet; diff --git a/src/components/Wallet/modals/ConnectModal/ConnectModalDialog.tsx b/src/components/Wallet/modals/ConnectModal/ConnectModalDialog.tsx new file mode 100644 index 00000000..b477c6ed --- /dev/null +++ b/src/components/Wallet/modals/ConnectModal/ConnectModalDialog.tsx @@ -0,0 +1,82 @@ +import { Collapse } from 'react-daisyui'; +import { useState } from 'preact/hooks'; +import { useWalletConnection } from '../../../../hooks/useWalletConnection'; +import { METAMASK_EXTENSION_NAME } from '../../../../services/metamask'; +import { Dialog } from '../../../Dialog'; +import { ConnectModalWalletsList } from './ConnectModalList/ConnectModalWalletsList'; +import { ConnectModalAccountsList } from './ConnectModalList/ConnectModalAccountsList'; +import { ConnectModalDialogLoading } from './ConnectModalDialogLoading'; +import { Wallet } from '@talismn/connect-wallets'; + +interface ConnectModalDialogProps { + visible: boolean; + onClose: () => void; +} + +export const ConnectModalDialog = ({ visible, onClose }: ConnectModalDialogProps) => { + const { accounts, wallets, selectWallet, loading, selectedWallet } = useWalletConnection(); + const [isAccountsCollapseOpen, setIsAccountsCollapseOpen] = useState(false); + + const accountsContent = ( + + setIsAccountsCollapseOpen((state) => !state)}>Choose Account + + + {selectedWallet?.extensionName === METAMASK_EXTENSION_NAME ? ( +

    + For Metamask connection we use Polkadot-Snap which creates only one Polkadot address for your Metamask + Wallet. +

    + ) : ( + <> + )} +
    +
    + ); + + const walletsContent = ( + + Select Wallet + + { + setIsAccountsCollapseOpen(true); + selectWallet(wallet); + }} + onClose={onClose} + /> + + + ); + + const content = ( +
    + {walletsContent} + {accounts.length ? accountsContent : <>} +
    + ); + + return loading ? ( + + } + onClose={() => { + selectWallet(undefined); + onClose(); + }} + /> + ) : ( + { + selectWallet(undefined); + onClose(); + }} + content={content} + /> + ); +}; diff --git a/src/components/Wallet/modals/ConnectModal/ConnectModalDialogLoading.tsx b/src/components/Wallet/modals/ConnectModal/ConnectModalDialogLoading.tsx new file mode 100644 index 00000000..71ff73cf --- /dev/null +++ b/src/components/Wallet/modals/ConnectModal/ConnectModalDialogLoading.tsx @@ -0,0 +1,13 @@ +import { Loading } from 'react-daisyui'; + +interface ConnectModalDialogLoadingProps { + selectedWallet: string; +} + +export const ConnectModalDialogLoading = ({ selectedWallet }: ConnectModalDialogLoadingProps) => ( +
    + +

    Connecting wallet

    +

    Please approve {selectedWallet} and approve transaction.

    +
    +); diff --git a/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalAccountsList/index.tsx b/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalAccountsList/index.tsx new file mode 100644 index 00000000..14f4e369 --- /dev/null +++ b/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalAccountsList/index.tsx @@ -0,0 +1,28 @@ +import { WalletAccount } from '@talismn/connect-wallets'; +import { useDeferredValue, useState } from 'preact/compat'; +import { SearchInput } from '../../../../../SearchInput'; +import { AccountCard } from '../../../../../AccountCard'; + +interface ConnectModalAccountsListProps { + accounts: WalletAccount[]; +} + +export const ConnectModalAccountsList = ({ accounts }: ConnectModalAccountsListProps) => { + const [inputSearchValue, setInputSearchValue] = useState(''); + const deferredInputSearchValue = useDeferredValue(inputSearchValue); + + const filteredAccounts = deferredInputSearchValue.length + ? accounts.filter((account) => account.address.toLowerCase().includes(deferredInputSearchValue.toLowerCase())) + : accounts; + + return ( +
    + +
      + {filteredAccounts.map((account: WalletAccount) => ( + + ))} +
    +
    + ); +}; diff --git a/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalWalletsList/ConnectModalWalletsListItem.tsx b/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalWalletsList/ConnectModalWalletsListItem.tsx new file mode 100644 index 00000000..f2ba1850 --- /dev/null +++ b/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalWalletsList/ConnectModalWalletsListItem.tsx @@ -0,0 +1,26 @@ +import { Wallet } from '@talismn/connect-wallets'; +import { Button } from 'react-daisyui'; + +interface WalletButtonProps { + wallet: Wallet; + onClick: (wallet: Wallet) => void; +} + +function buttonOnClick(props: WalletButtonProps) { + const { wallet, onClick } = props; + + return wallet.installed + ? onClick?.(wallet) : + window.open(wallet.installUrl, '_blank', 'noopener,noreferrer') +} + +export const ConnectModalListWalletsItem = (props: WalletButtonProps) => ( + +); \ No newline at end of file diff --git a/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalWalletsList/index.tsx b/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalWalletsList/index.tsx new file mode 100644 index 00000000..d7369eab --- /dev/null +++ b/src/components/Wallet/modals/ConnectModal/ConnectModalList/ConnectModalWalletsList/index.tsx @@ -0,0 +1,28 @@ +import { Wallet } from '@talismn/connect-wallets'; +import { ConnectModalListWalletsItem } from './ConnectModalWalletsListItem'; +import WalletConnect from '../../../../wallets/WalletConnect'; + +interface ConnectWalletListProps { + wallets?: Wallet[]; + onClick: (wallet: Wallet) => void; + onClose: () => void; +} + +export function ConnectModalWalletsList({ wallets, onClick, onClose }: ConnectWalletListProps) { + if (!wallets?.length) { + return

    No wallet installed

    ; + } + + return ( +
    + {wallets.map((wallet: Wallet) => ( + + ))} + +
    + ); +} diff --git a/src/components/Wallet/modals/ConnectModal/index.tsx b/src/components/Wallet/modals/ConnectModal/index.tsx new file mode 100644 index 00000000..85f3a620 --- /dev/null +++ b/src/components/Wallet/modals/ConnectModal/index.tsx @@ -0,0 +1,31 @@ +import { Button } from 'react-daisyui'; +import { useState } from 'preact/hooks'; +import { ConnectModalDialog } from './ConnectModalDialog'; + +export interface ConnectProps { + isHeader?: boolean; +} + +export const ConnectModal = ({ isHeader }: ConnectProps) => { + const [visible, setVisible] = useState(false); + + return ( + <> + + { + setVisible(false); + }} + /> + + ); +}; diff --git a/src/components/Wallet/modals/DisconnectModal/index.tsx b/src/components/Wallet/modals/DisconnectModal/index.tsx new file mode 100644 index 00000000..6c732172 --- /dev/null +++ b/src/components/Wallet/modals/DisconnectModal/index.tsx @@ -0,0 +1,106 @@ +import { Wallet, WalletAccount } from '@talismn/connect-wallets'; +import { UseQueryResult } from '@tanstack/react-query'; +import { FrameSystemAccountInfo } from '@polkadot/types/lookup'; +import { ArrowLeftEndOnRectangleIcon } from '@heroicons/react/20/solid'; +import { Button, Dropdown } from 'react-daisyui'; + +import { getAddressForFormat } from '../../../../helpers/addressFormatter'; +import { useNodeInfoState } from '../../../../NodeInfoProvider'; +import { useAccountBalance } from '../../../../shared/useAccountBalance'; +import { useGlobalState } from '../../../../GlobalStateProvider'; +import { CopyableAddress } from '../../../PublicKey'; +import { Skeleton } from '../../../Skeleton'; + +interface WalletButtonProps { + wallet?: Wallet; + query: UseQueryResult; + balance?: string; + tokenSymbol?: string; + walletAccount?: WalletAccount; +} + +const WalletButton = ({ wallet, query, balance, tokenSymbol, walletAccount }: WalletButtonProps) => ( + +); + +interface WalletDropdownMenuProps { + address: string; + balance?: string; + tokenSymbol?: string; + walletAccount?: WalletAccount; + ss58Format?: number; + removeWalletAccount: () => void; +} + +const WalletDropdownMenu = ({ + walletAccount, + ss58Format, + address, + balance, + tokenSymbol, + removeWalletAccount, +}: WalletDropdownMenuProps) => ( + +
    {walletAccount?.name}
    +
    + +
    +

    + {balance} {tokenSymbol} +

    + +
    +); + +export const DisconnectModal = () => { + const { walletAccount, removeWalletAccount } = useGlobalState(); + const { query, balances } = useAccountBalance(); + const { ss58Format, tokenSymbol } = useNodeInfoState().state; + const { wallet, address } = walletAccount || {}; + const { total: balance } = balances; + + if (!address) return <>; + + return ( + + + + + ); +}; diff --git a/src/components/Wallet/WalletConnect/index.tsx b/src/components/Wallet/wallets/WalletConnect/index.tsx similarity index 68% rename from src/components/Wallet/WalletConnect/index.tsx rename to src/components/Wallet/wallets/WalletConnect/index.tsx index b2d6c2cc..bb9a12de 100644 --- a/src/components/Wallet/WalletConnect/index.tsx +++ b/src/components/Wallet/wallets/WalletConnect/index.tsx @@ -1,15 +1,20 @@ import { WalletConnectModal } from '@walletconnect/modal'; import UniversalProvider from '@walletconnect/universal-provider'; import { SessionTypes } from '@walletconnect/types'; +import { Button } from 'react-daisyui'; import { useCallback, useEffect, useState } from 'preact/compat'; -import logo from '../../../assets/wallet-connect.svg'; -import { config } from '../../../config'; -import { chainIds, walletConnectConfig } from '../../../config/walletConnect'; -import { useGlobalState } from '../../../GlobalStateProvider'; -import { walletConnectService } from '../../../services/walletConnect'; -import { ToastMessage, showToast } from '../../../shared/showToast'; +import logo from '../../../../assets/wallet-connect.svg'; +import { config } from '../../../../config'; +import { chainIds, walletConnectConfig } from '../../../../config/walletConnect'; +import { useGlobalState } from '../../../../GlobalStateProvider'; +import { walletConnectService } from '../../../../services/walletConnect'; +import { ToastMessage, showToast } from '../../../../shared/showToast'; -const WalletConnect = () => { +interface WalletConnectProps { + onClick: () => void; +} + +const WalletConnect = ({ onClick }: WalletConnectProps) => { const [loading, setLoading] = useState(false); const [provider, setProvider] = useState | undefined>(); const [modal, setModal] = useState(); @@ -58,14 +63,13 @@ const WalletConnect = () => { setLoading(true); try { await handleConnect(); - - //@eslint-disable-next-line no-explicit-any - } catch (error: any) { - showToast(ToastMessage.ERROR, error); + } catch (error: unknown) { + showToast(ToastMessage.ERROR, error as string); } finally { setLoading(false); + onClick(); } - }, [handleConnect]); + }, [handleConnect, onClick]); useEffect(() => { if (provider) return; @@ -78,16 +82,10 @@ const WalletConnect = () => { }, [provider]); return ( -
    - -
    + ); }; export default WalletConnect; diff --git a/src/components/nabla/Pools/Backstop/BackstopPoolModals.tsx b/src/components/nabla/Pools/Backstop/BackstopPoolModals.tsx index 2a0021c4..e67d35b2 100644 --- a/src/components/nabla/Pools/Backstop/BackstopPoolModals.tsx +++ b/src/components/nabla/Pools/Backstop/BackstopPoolModals.tsx @@ -3,7 +3,7 @@ import { NablaInstanceBackstopPool } from '../../../../hooks/nabla/useNablaInsta import { ModalTypes, useModal } from '../../../../services/modal'; import AddLiquidity from './AddLiquidity'; import WithdrawLiquidity from './WithdrawLiquidity'; -import { Dialog } from '../../../../pages/staking/dialogs/Dialog'; +import { Dialog } from '../../../Dialog'; export type LiquidityModalProps = { data?: NablaInstanceBackstopPool; @@ -24,7 +24,6 @@ export function BackstopPoolModals() { } content={Component ? : <>} /> ); diff --git a/src/components/nabla/Pools/Swap/SwapPoolModals.tsx b/src/components/nabla/Pools/Swap/SwapPoolModals.tsx index e2c8c68e..03a96e56 100644 --- a/src/components/nabla/Pools/Swap/SwapPoolModals.tsx +++ b/src/components/nabla/Pools/Swap/SwapPoolModals.tsx @@ -1,6 +1,6 @@ import { FunctionalComponent } from 'preact'; import { ModalTypes, useModal } from '../../../../services/modal'; -import { Dialog } from '../../../../pages/staking/dialogs/Dialog'; +import { Dialog } from '../../../Dialog'; import { SwapPoolColumn } from './columns'; import AddLiquidity from './AddLiquidity'; import Redeem from './Redeem'; @@ -28,7 +28,6 @@ export function SwapPoolModals() { } content={Component ? : <>} /> ); diff --git a/src/components/nabla/common/PoolSelectorModal.tsx b/src/components/nabla/common/PoolSelectorModal.tsx index 49895f78..0d4328e8 100644 --- a/src/components/nabla/common/PoolSelectorModal.tsx +++ b/src/components/nabla/common/PoolSelectorModal.tsx @@ -8,7 +8,7 @@ import { repeat } from '../../../helpers/general'; import { Skeleton } from '../../Skeleton'; import { NablaInstanceBackstopPool, NablaInstanceSwapPool } from '../../../hooks/nabla/useNablaInstance'; import { getIcon } from '../../../shared/AssetIcons'; -import { Dialog } from '../../../pages/staking/dialogs/Dialog'; +import { Dialog } from '../../Dialog'; export type PoolEntry = | { type: 'swapPool'; pool: NablaInstanceSwapPool } diff --git a/src/components/nabla/common/SwapProgress.tsx b/src/components/nabla/common/SwapProgress.tsx index 9d974c63..83330328 100644 --- a/src/components/nabla/common/SwapProgress.tsx +++ b/src/components/nabla/common/SwapProgress.tsx @@ -1,6 +1,6 @@ import { JSX } from 'preact'; import { TransactionProgress, TransactionProgressProps } from '../common/TransactionProgress'; -import { Dialog } from '../../../pages/staking/dialogs/Dialog'; +import { Dialog } from '../../Dialog'; export type SwapProgressProps = { open: boolean; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 2690831b..37d11d59 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -103,3 +103,9 @@ export const useLocalStorage = ({ return { state, set, merge, clear }; }; + + +export enum LocalStorageKeys { + TERMS_AND_CONDITIONS = "TERMS_AND_CONDITIONS", + SELECTED_WALLET_NAME = "SELECTED_WALLET_NAME", +} \ No newline at end of file diff --git a/src/hooks/useWalletConnection/index.tsx b/src/hooks/useWalletConnection/index.tsx new file mode 100644 index 00000000..f3554cf2 --- /dev/null +++ b/src/hooks/useWalletConnection/index.tsx @@ -0,0 +1,20 @@ +import { isMobile } from 'react-device-detect'; +import { useMetamask } from './useMetamask'; +import { useConnectWallet } from './useConnectWallet'; +import { useNova } from './useNova'; + +export function useWalletConnection() { + const { wallets = [], accounts = [], selectWallet, loading, selectedWallet } = useConnectWallet(); + const { selectedWallet: metamaskSelectedWallet } = useMetamask(); + const { selectedWallet: novaSelectedWallet } = useNova() + + const MOBILE_WALLETS = [novaSelectedWallet] + + const allWallets = [...wallets, metamaskSelectedWallet] + + if(isMobile){ + allWallets.push(...MOBILE_WALLETS) + } + + return { wallets: allWallets, accounts, selectWallet, loading, selectedWallet }; +} diff --git a/src/hooks/useWalletConnection/useConnectWallet/index.tsx b/src/hooks/useWalletConnection/useConnectWallet/index.tsx new file mode 100644 index 00000000..6c033929 --- /dev/null +++ b/src/hooks/useWalletConnection/useConnectWallet/index.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'preact/hooks'; +import { Wallet, WalletAccount, getWallets } from '@talismn/connect-wallets'; +import { useMutation } from '@tanstack/react-query'; +import { useGlobalState } from '../../../GlobalStateProvider'; +import { ToastMessage, showToast } from '../../../shared/showToast'; +import { storageService } from '../../../services/storage/local'; +import { LocalStorageKeys } from '../../useLocalStorage'; + + +const alwaysShowWallets = ['talisman', 'subwallet-js', 'polkadot-js'] + +export const useConnectWallet = () => { + const [wallets, setWallets] = useState(); + const [selectedWallet, setSelectedWallet] = useState(); + const { dAppName } = useGlobalState(); + + useEffect(() => { + const installedWallets = getWallets().filter((wallet) => alwaysShowWallets.includes(wallet.extensionName) || wallet.installed); + setWallets(installedWallets); + }, []); + + const { + mutate: selectWallet, + data: accounts, + isLoading: loading, + } = useMutation(async (wallet) => { + setSelectedWallet(wallet); + if (!wallet) return []; + try { + await wallet.enable(dAppName); + + // Save selected wallet name to local storage + if(wallet.installed){ + storageService.set(LocalStorageKeys.SELECTED_WALLET_NAME, wallet.extensionName); + } + + return wallet.getAccounts(); + } catch { + showToast(ToastMessage.WALLET_ALREADY_OPEN_PENDING_CONNECTION); + return []; + } + }); + + return { accounts, wallets, selectWallet, loading, selectedWallet }; +}; diff --git a/src/hooks/useWalletConnection/useMetamask/index.tsx b/src/hooks/useWalletConnection/useMetamask/index.tsx new file mode 100644 index 00000000..400d516e --- /dev/null +++ b/src/hooks/useWalletConnection/useMetamask/index.tsx @@ -0,0 +1,33 @@ +import { useGlobalState } from '../../../GlobalStateProvider'; +import { Wallet } from '@talismn/connect-wallets'; +import { useMemo } from 'preact/hooks'; +import { METAMASK_EXTENSION_NAME, initiateMetamaskInjectedAccount } from '../../../services/metamask'; +import logo from '../../../assets/metamask-wallet.png'; + +export const useMetamask = () => { + const { tenantName } = useGlobalState(); + + const selectedWallet: Wallet = useMemo( + () => ({ + extensionName: METAMASK_EXTENSION_NAME, + title: 'Metamask', + installUrl: 'https://metamask.io/', + logo: { + src: logo, + alt: 'Metamask Wallet', + }, + installed: Boolean(window.ethereum), + extension: window.ethereum, + + signer: undefined, + getAccounts: () => initiateMetamaskInjectedAccount(tenantName), // we use polkadot-snap, getAccounts gets a polkadot-snap account linked to the user's metamask wallet + subscribeAccounts: () => undefined, // we use polkadot-snap, subscribeAccounts is not needed + enable: () => undefined, // we use polkadot-snap, enable is not needed + transformError: (err: Error) => new Error(err.message), + }), + + [tenantName], + ); + + return { selectedWallet }; +}; diff --git a/src/hooks/useWalletConnection/useNova/index.ts b/src/hooks/useWalletConnection/useNova/index.ts new file mode 100644 index 00000000..c5334312 --- /dev/null +++ b/src/hooks/useWalletConnection/useNova/index.ts @@ -0,0 +1,78 @@ +import { web3Accounts, web3Enable, web3FromAddress } from '@polkadot/extension-dapp'; +import { Wallet, WalletAccount } from '@talismn/connect-wallets'; +import { useMemo } from 'preact/hooks'; +import logo from '../../../assets/nova-wallet.png'; + +declare global { + interface Window { + injectedWeb3?: { + 'polkadot-js'?: boolean + [key: string]: unknown; + }; + walletExtension?: { + isNovaWallet?: boolean; + [key: string]: unknown; + }; + } + } + + +function isNovaInstalled() { + + const injectedExtension = window?.injectedWeb3?.['polkadot-js'] + const isNovaWallet = window?.walletExtension?.isNovaWallet + + return !!(injectedExtension && isNovaWallet) +} + +async function getAccounts(): Promise{ + const _ = await web3Enable('Pendulum Chain Portal'); + const accounts = await web3Accounts(); + + const walletAccounts = Promise.all(accounts + .filter(({ meta: { source } }) => source === 'polkadot-js') + .map( + async ({ address, meta }) => { + const signer = await web3FromAddress(address); + + const account: WalletAccount = { + address, + name: meta.name, + source: meta.source, + signer, + wallet: undefined + } + + return account; + })); + + return await walletAccounts +} + + +export const useNova = () => { + + const selectedWallet: Wallet = useMemo( + () => ({ + extensionName: 'polkadot-js', + title: 'Nova Wallet', + installUrl: 'https://novawallet.io/', + logo: { + src: logo, + alt: 'Nova Wallet', + }, + installed: isNovaInstalled(), + + extension: undefined, + signer: undefined, + getAccounts, + subscribeAccounts: () => undefined, // Unused + enable: () => undefined, + transformError: (err: Error) => new Error(err.message), + }), + + [], + ); + + return { selectedWallet }; +}; diff --git a/src/index.css b/src/index.css index dca288c6..0bfa7bc6 100644 --- a/src/index.css +++ b/src/index.css @@ -341,14 +341,6 @@ w3m-modal { color: currentColor; } -.collapse-content { - max-height: 0; -} - -.collapse-open .collapse-content { - max-height: none; -} - .collapse-arrow > .collapse-title:after { top: 50%; } diff --git a/src/pages/dashboard/Portfolio.tsx b/src/pages/dashboard/Portfolio.tsx index a3e847d0..79e69e6b 100644 --- a/src/pages/dashboard/Portfolio.tsx +++ b/src/pages/dashboard/Portfolio.tsx @@ -13,7 +13,7 @@ function Portfolio() { }, []); return ( -
    +
    Wallet
    diff --git a/src/pages/gas/GasSuccessDialog.tsx b/src/pages/gas/GasSuccessDialog.tsx index 28c20bb8..3bf1e08f 100644 --- a/src/pages/gas/GasSuccessDialog.tsx +++ b/src/pages/gas/GasSuccessDialog.tsx @@ -1,7 +1,7 @@ -import { Button } from 'react-daisyui'; import { FC } from 'preact/compat'; +import { Button } from 'react-daisyui'; import SuccessDialogIcon from '../../assets/dialog-status-success'; -import { Dialog } from '../staking/dialogs/Dialog'; +import { Dialog } from '../../components/Dialog'; interface DialogProps { visible: boolean; @@ -13,8 +13,8 @@ export const GasSuccessDialog: FC = ({ visible, onClose, token }) = const content = (
    -

    You have successfully purchased {token}!

    -

    +

    You have successfully purchased {token}!

    +

    ); diff --git a/src/pages/spacewalk/bridge/Issue/ConfirmationDialog.tsx b/src/pages/spacewalk/bridge/Issue/ConfirmationDialog.tsx index be9304c3..a1c8ea4b 100644 --- a/src/pages/spacewalk/bridge/Issue/ConfirmationDialog.tsx +++ b/src/pages/spacewalk/bridge/Issue/ConfirmationDialog.tsx @@ -7,10 +7,10 @@ import { convertCurrencyToStellarAsset, deriveShortenedRequestId } from '../../. import { convertRawHexKeyToPublicKey } from '../../../../helpers/stellar'; import { RichIssueRequest } from '../../../../hooks/spacewalk/useIssuePallet'; import { nativeStellarToDecimal } from '../../../../shared/parseNumbers/metric'; -import { Dialog } from '../../../staking/dialogs/Dialog'; +import { Dialog } from '../../../../components/Dialog'; import { generateSEP0007URIScheme } from '../../../../helpers/stellar/sep0007'; -import { StellarUriScheme } from './StellarURIScheme'; import { PENDULUM_SUPPORT_CHAT_URL } from '../../../../shared/constants'; +import { StellarUriScheme } from './StellarURIScheme'; interface ConfirmationDialogProps { issueRequest: RichIssueRequest | undefined; diff --git a/src/pages/spacewalk/bridge/Issue/SettingsDialog.tsx b/src/pages/spacewalk/bridge/Issue/SettingsDialog.tsx index de3f409e..de7f244c 100644 --- a/src/pages/spacewalk/bridge/Issue/SettingsDialog.tsx +++ b/src/pages/spacewalk/bridge/Issue/SettingsDialog.tsx @@ -1,8 +1,9 @@ import { Button, Checkbox } from 'react-daisyui'; import { useMemo } from 'preact/hooks'; +import { ChangeEvent } from 'preact/compat'; import VaultSelector from '../../../../components/Selector/VaultSelector'; import useBridgeSettings from '../../../../hooks/spacewalk/useBridgeSettings'; -import { Dialog } from '../../../staking/dialogs/Dialog'; +import { Dialog } from '../../../../components/Dialog'; import { BridgeDirection } from '../index'; interface Props { @@ -18,22 +19,22 @@ export function SettingsDialog({ bridgeDirection, visible, onClose }: Props) { const content = useMemo( () => (
    -
    +
    ) => { + onChange={(e: ChangeEvent) => { if (e.target instanceof HTMLInputElement) { setManualVaultSelection(e.target.checked); } }} - className="checkbox rounded" + className="rounded checkbox" checked={manualVaultSelection} /> Manually select vault
    {manualVaultSelection && vaultsForCurrency && ( -
    +
    Select Vault
    >; } -export const BridgeContext = React.createContext({ setSelectedAsset: () => undefined }); +export const BridgeContext = createContext({ setSelectedAsset: () => undefined }); function Bridge(): JSX.Element | null { const [tabValue, setTabValue] = useState(BridgeTabs.Issue); @@ -61,30 +61,30 @@ function Bridge(): JSX.Element | null { return chain ? ( -
    +
    setSettingsVisible(false)} + bridgeDirection={bridgeDirection} />
    - - + + {chain.toLowerCase() === TenantName.Pendulum && } {(chain.toLowerCase() === TenantName.Amplitude || chain.toLowerCase() === TenantName.Foucoco) && ( )} To {chain} - + To Stellar