diff --git a/package.json b/package.json index 70bd1da3..b2be9d15 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@stacks/network": "2.0.0-beta.0", "@stacks/rpc-client": "1.0.3", "@stacks/transactions": "2.0.0-beta.1", - "@stacks/ui": "7.8.0", + "@stacks/ui": "file:.yalc/@stacks/ui", "@stacks/ui-core": "7.3.0", "@stacks/ui-theme": "7.5.0", "@stacks/ui-utils": "7.5.0", @@ -64,6 +64,7 @@ "bignumber.js": "9.0.1", "bn.js": "5.2.0", "body-parser": "1.19.0", + "boring-avatars": "^1.5.8", "buffer": "6.0.3", "capsize": "2.0.0", "chroma-js": "2.1.2", @@ -72,17 +73,23 @@ "dayjs": "1.10.5", "dompurify": "2.2.9", "downshift": "6.1.3", + "fast-deep-equal": "^3.1.3", "formik": "2.2.9", "history": "5.0.0", "http-server": "0.12.3", + "jotai": "^1.0.0", "jsontokens": "3.0.0", "mdi-react": "7.5.0", + "micro-memoize": "^4.0.9", + "object-hash": "^2.2.0", + "optics-ts": "^2.1.0", "preact": "10.5.13", "prismjs": "1.23.0", - "react": "17.0.2", - "react-dom": "17.0.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", "react-hot-toast": "2.0.0", "react-icons": "4.2.0", + "react-query": "^3.17.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "recoil": "0.3.1", @@ -99,8 +106,6 @@ }, "devDependencies": { "@actions/core": "1.3.0", - "@changesets/changelog-github": "0.4.0", - "@changesets/cli": "2.16.0", "@babel/core": "7.14.3", "@babel/plugin-proposal-class-properties": "7.13.0", "@babel/plugin-transform-regenerator": "7.13.15", @@ -111,6 +116,9 @@ "@babel/runtime": "7.14.0", "@blockstack/stacks-blockchain-api-types": "0.55.3", "@blockstack/stacks-blockchain-sidecar-types": "0.0.22", + "@changesets/changelog-github": "0.4.0", + "@changesets/cli": "2.16.0", + "@emotion/babel-plugin": "^11.3.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.0-beta.8", "@schemastore/web-manifest": "0.0.5", "@stacks/prettier-config": "0.0.8", @@ -125,6 +133,7 @@ "@types/jest": "26.0.23", "@types/just-debounce-it": "1.5.0", "@types/node": "15.12.1", + "@types/object-hash": "^2.1.0", "@types/prismjs": "1.16.5", "@types/qrcode.react": "1.0.1", "@types/react": "17.0.9", @@ -148,8 +157,8 @@ "copy-webpack-plugin": "8.1.1", "cross-env": "7.0.3", "crypto-browserify": "3.12.0", - "download": "8.0.0", "deepmerge": "4.2.2", + "download": "8.0.0", "esbuild-loader": "2.13.1", "eslint": "7.28.0", "eslint-plugin-jest": "24.3.6", @@ -190,7 +199,7 @@ "resolutions": { "**/**/dns-packet": "5.2.2", "**/**/browserslist": "4.16.5", - "@stacks/ui": "7.8.0", + "@stacks/ui": "file:.yalc/@stacks/ui", "@stacks/ui-core": "7.3.0", "@stacks/ui-theme": "7.5.0", "@stacks/ui-utils": "7.5.0", diff --git a/public/html/full-page.html b/public/html/full-page.html index 9c189bf8..6ca28546 100644 --- a/public/html/full-page.html +++ b/public/html/full-page.html @@ -1,11 +1,28 @@ - + <%= htmlWebpackPlugin.options.title %> +
diff --git a/src/app.tsx b/src/app.tsx index 3c064319..72ad1dfb 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,40 +1,38 @@ import React, { useEffect } from 'react'; import { ThemeProvider, ColorModeProvider } from '@stacks/ui'; import { Toaster } from 'react-hot-toast'; -import { RecoilRoot } from 'recoil'; -import packageJson from '../package.json'; import { theme } from '@common/theme'; import { HashRouter as Router } from 'react-router-dom'; import { GlobalStyles } from '@components/global-styles'; import { VaultLoader } from '@components/vault-loader'; -import { AccountsDrawer } from './components/drawer/accounts'; -import { NetworksDrawer } from './components/drawer/networks-drawer'; -import { DebugObserver } from '@components/debug-observer'; +import { AccountsDrawer } from '@components/drawer/accounts'; +import { NetworksDrawer } from '@components/drawer/networks-drawer'; import { Routes } from './routes'; +import { Devtools } from '@components/devtools'; export const App: React.FC = () => { useEffect(() => { - (window as any).__APP_VERSION__ = packageJson.version; + (window as any).__APP_VERSION__ = VERSION; }, []); return ( - - - + <> + + <> + - - - + + ); }; diff --git a/src/common/atom-utils.ts b/src/common/atom-utils.ts new file mode 100644 index 00000000..beeaf88e --- /dev/null +++ b/src/common/atom-utils.ts @@ -0,0 +1,10 @@ +import { Atom } from 'jotai'; +import { ContractPrincipal } from '@store/assets/types'; + +export function debugLabelWithContractPrincipal( + atom: Atom, + key: string, + contractPrincipal: ContractPrincipal +) { + atom.debugLabel = `${key}/${contractPrincipal.contractAddress}.${contractPrincipal.contractName}`; +} diff --git a/src/common/atom-with-params.ts b/src/common/atom-with-params.ts new file mode 100644 index 00000000..9bf2ec0b --- /dev/null +++ b/src/common/atom-with-params.ts @@ -0,0 +1,38 @@ +import { atom, PrimitiveAtom } from 'jotai'; + +const event = 'popstate'; + +type ParamType = string | null; + +export function atomWithParam(key: string, initialValue: ParamType): PrimitiveAtom { + const anAtom: PrimitiveAtom = atom( + initialValue, + (get, set, update: ParamType | ((prev: ParamType) => ParamType)) => { + const newValue = + typeof update === 'function' + ? (update as (prev: ParamType) => ParamType)(get(anAtom)) + : update; + set(anAtom, newValue); + const searchParams = new URLSearchParams(location.hash.slice(2)); + if (newValue) searchParams.set(key, newValue); + if (newValue === null) searchParams.delete(key); + const newRelativePathQuery = window.location.pathname + '?' + searchParams.toString(); + history.pushState(null, '', newRelativePathQuery); + } + ); + anAtom.onMount = setAtom => { + const callback = () => { + const searchParams = new URLSearchParams(location.hash.slice(2)); + const str = searchParams.get(key); + if (str !== null) { + setAtom(str); + } + }; + window.addEventListener(event, callback); + callback(); + return () => { + window.removeEventListener(event, callback); + }; + }; + return anAtom; +} diff --git a/src/common/hooks/account/use-account-activity.ts b/src/common/hooks/account/use-account-activity.ts index dd1d2c6a..e20d42dd 100644 --- a/src/common/hooks/account/use-account-activity.ts +++ b/src/common/hooks/account/use-account-activity.ts @@ -1,6 +1,6 @@ -import { useLoadable } from '@common/hooks/use-loadable'; import { accountTransactionsState } from '@store/accounts'; +import { useAtomValue } from 'jotai/utils'; export function useAccountActivity() { - return useLoadable(accountTransactionsState); + return useAtomValue(accountTransactionsState); } diff --git a/src/common/hooks/account/use-account-balances.ts b/src/common/hooks/account/use-account-balances.ts index df344e59..687be9c1 100644 --- a/src/common/hooks/account/use-account-balances.ts +++ b/src/common/hooks/account/use-account-balances.ts @@ -1,7 +1,13 @@ import { useFetchAccountData } from '@common/hooks/account/use-account-info'; +import { useAtomValue } from 'jotai/utils'; +import { accountInfoState } from '@store/accounts'; export function useAccountBalances() { const accountData = useFetchAccountData(); - return accountData?.value?.balances; + return accountData?.balances; +} + +export function useAccountInfo() { + return useAtomValue(accountInfoState); } diff --git a/src/common/hooks/account/use-account-info.ts b/src/common/hooks/account/use-account-info.ts index 875d241e..db3d2abb 100644 --- a/src/common/hooks/account/use-account-info.ts +++ b/src/common/hooks/account/use-account-info.ts @@ -1,10 +1,10 @@ import { accountBalancesState, accountDataState } from '@store/accounts'; -import { useLoadable } from '../use-loadable'; +import { useAtomValue } from 'jotai/utils'; export const useFetchAccountData = () => { - return useLoadable(accountDataState); + return useAtomValue(accountDataState); }; export const useFetchBalances = () => { - return useLoadable(accountBalancesState); + return useAtomValue(accountBalancesState); }; diff --git a/src/common/hooks/account/use-account-names.ts b/src/common/hooks/account/use-account-names.ts index 92e90f9c..febcb116 100644 --- a/src/common/hooks/account/use-account-names.ts +++ b/src/common/hooks/account/use-account-names.ts @@ -1,11 +1,13 @@ import { accountNameState } from '@store/accounts/names'; -import { useLoadable } from '@common/hooks/use-loadable'; import { Account } from '@stacks/wallet-sdk'; import { useCurrentAccount } from '@common/hooks/account/use-current-account'; import { cleanUsername } from '@common/utils'; +import { useAtomValue } from 'jotai/utils'; +import { useMemo } from 'react'; export function useAccountNames() { - return useLoadable(accountNameState); + const atom = useMemo(() => accountNameState, []); + return useAtomValue(atom); } export function useAccountDisplayName(__account?: Account) { @@ -14,7 +16,7 @@ export function useAccountDisplayName(__account?: Account) { const account = __account || _account; if (!account || typeof account?.index !== 'number') return 'Account'; return ( - names.value?.[account.index]?.names?.[0] || + names?.[account.index]?.names?.[0] || (account?.username && cleanUsername(account.username)) || `Account ${account?.index + 1}` ); diff --git a/src/common/hooks/account/use-accounts.ts b/src/common/hooks/account/use-accounts.ts new file mode 100644 index 00000000..388d79b8 --- /dev/null +++ b/src/common/hooks/account/use-accounts.ts @@ -0,0 +1,6 @@ +import { useAtomValue } from 'jotai/utils'; +import { accountsWithAddressState } from '@store/accounts'; + +export function useAccounts() { + return useAtomValue(accountsWithAddressState); +} diff --git a/src/common/hooks/account/use-current-account.ts b/src/common/hooks/account/use-current-account.ts index 5faa4f4b..abd3b155 100644 --- a/src/common/hooks/account/use-current-account.ts +++ b/src/common/hooks/account/use-current-account.ts @@ -1,11 +1,6 @@ -import { useRecoilValue } from 'recoil'; -import { currentAccountState, currentAccountStxAddressState } from '@store/accounts'; +import { currentAccountState } from '@store/accounts'; +import { useAtomValue } from 'jotai/utils'; export function useCurrentAccount() { - const accountInfo = useRecoilValue(currentAccountState); - const stxAddress = useRecoilValue(currentAccountStxAddressState); - return { - ...accountInfo, - stxAddress, - }; + return useAtomValue(currentAccountState); } diff --git a/src/common/hooks/account/use-switch-account.ts b/src/common/hooks/account/use-switch-account.ts index 134601f5..22b06986 100644 --- a/src/common/hooks/account/use-switch-account.ts +++ b/src/common/hooks/account/use-switch-account.ts @@ -1,16 +1,19 @@ import { useWallet } from '@common/hooks/use-wallet'; -import { useRecoilState, useRecoilValue } from 'recoil'; import { hasSwitchedAccountsState, transactionAccountIndexState } from '@store/accounts'; import { useCallback } from 'react'; import { transactionNetworkVersionState } from '@store/transactions'; +import { useAtomValue } from 'jotai/utils'; +import { useAtom } from 'jotai'; +import { useCurrentAccount } from '@common/hooks/account/use-current-account'; const TIMEOUT = 350; export const useSwitchAccount = (callback?: () => void) => { - const { wallet, currentAccountIndex, doSwitchAccount } = useWallet(); - const txIndex = useRecoilValue(transactionAccountIndexState); - const transactionVersion = useRecoilValue(transactionNetworkVersionState); - const [hasSwitched, setHasSwitched] = useRecoilState(hasSwitchedAccountsState); + const { doSwitchAccount } = useWallet(); + const currentAccount = useCurrentAccount(); + const txIndex = useAtomValue(transactionAccountIndexState); + const transactionVersion = useAtomValue(transactionNetworkVersionState); + const [hasSwitched, setHasSwitched] = useAtom(hasSwitchedAccountsState); const handleSwitchAccount = useCallback( async index => { @@ -25,9 +28,13 @@ export const useSwitchAccount = (callback?: () => void) => { [txIndex, setHasSwitched, doSwitchAccount, callback] ); - const accounts = wallet?.accounts || []; - const getIsActive = (index: number) => - typeof txIndex === 'number' && !hasSwitched ? index === txIndex : index === currentAccountIndex; + const getIsActive = useCallback( + (index: number) => + typeof txIndex === 'number' && !hasSwitched + ? index === txIndex + : index === currentAccount?.index, + [txIndex, hasSwitched, currentAccount] + ); - return { accounts, handleSwitchAccount, getIsActive, transactionVersion }; + return { handleSwitchAccount, getIsActive, transactionVersion }; }; diff --git a/src/common/hooks/auth/use-auth-request.ts b/src/common/hooks/auth/use-auth-request.ts index 42045d55..4438bb7d 100644 --- a/src/common/hooks/auth/use-auth-request.ts +++ b/src/common/hooks/auth/use-auth-request.ts @@ -1,6 +1,6 @@ -import { useRecoilValue } from 'recoil'; import { authRequestState } from '@store/onboarding'; +import { useAtomValue } from 'jotai/utils'; export function useAuthRequest() { - return useRecoilValue(authRequestState); + return useAtomValue(authRequestState); } diff --git a/src/common/hooks/auth/use-magic-recovery-code.ts b/src/common/hooks/auth/use-magic-recovery-code.ts index 98b6d028..00114da2 100644 --- a/src/common/hooks/auth/use-magic-recovery-code.ts +++ b/src/common/hooks/auth/use-magic-recovery-code.ts @@ -1,4 +1,3 @@ -import { useRecoilState } from 'recoil'; import { magicRecoveryCodePasswordState, magicRecoveryCodeState } from '@store/onboarding'; import { useLoading } from '@common/hooks/use-loading'; import { useWallet } from '@common/hooks/use-wallet'; @@ -8,10 +7,11 @@ import { useDoChangeScreen } from '@common/hooks/use-do-change-screen'; import { USERNAMES_ENABLED } from '@common/constants'; import { ScreenPaths } from '@store/common/types'; import { decrypt } from '@stacks/wallet-sdk'; +import { useAtom } from 'jotai'; export function useMagicRecoveryCode() { - const [magicRecoveryCode, setMagicRecoveryCode] = useRecoilState(magicRecoveryCodeState); - const [password, setPassword] = useRecoilState(magicRecoveryCodePasswordState); + const [magicRecoveryCode, setMagicRecoveryCode] = useAtom(magicRecoveryCodeState); + const [password, setPassword] = useAtom(magicRecoveryCodePasswordState); const { isLoading, setIsLoading, setIsIdle } = useLoading('useMagicRecoveryCode'); const { doStoreSeed, doSetPassword, doFinishSignIn } = useWallet(); const [error, setPasswordError] = useState(''); @@ -37,8 +37,8 @@ export function useMagicRecoveryCode() { setIsLoading(); try { const codeBuffer = Buffer.from(magicRecoveryCode, 'base64'); - const seed = await decrypt(codeBuffer, password); - await doStoreSeed(seed); + const secretKey = await decrypt(codeBuffer, password); + await doStoreSeed({ secretKey }); await doSetPassword(password); handleNavigate(); } catch (error) { diff --git a/src/common/hooks/auth/use-onboarding-state.ts b/src/common/hooks/auth/use-onboarding-state.ts index 621ba259..95d51f3d 100644 --- a/src/common/hooks/auth/use-onboarding-state.ts +++ b/src/common/hooks/auth/use-onboarding-state.ts @@ -1,4 +1,3 @@ -import { useRecoilValue } from 'recoil'; import { authRequestState, currentScreenState, @@ -8,18 +7,19 @@ import { secretKeyState, usernameState, } from '@store/onboarding'; +import { useAtomValue } from 'jotai/utils'; export const useOnboardingState = () => { - const secretKey = useRecoilValue(secretKeyState); - const screen = useRecoilValue(currentScreenState); + const secretKey = useAtomValue(secretKeyState); + const screen = useAtomValue(currentScreenState); const { authRequest, decodedAuthRequest, appName, appIcon, appURL } = - useRecoilValue(authRequestState); + useAtomValue(authRequestState); - const magicRecoveryCode = useRecoilValue(magicRecoveryCodeState); - const isOnboardingInProgress = useRecoilValue(onboardingProgressState); - const username = useRecoilValue(usernameState); - const onboardingPath = useRecoilValue(onboardingPathState); + const magicRecoveryCode = useAtomValue(magicRecoveryCodeState); + const isOnboardingInProgress = useAtomValue(onboardingProgressState); + const username = useAtomValue(usernameState); + const onboardingPath = useAtomValue(onboardingPathState); return { secretKey, diff --git a/src/common/hooks/auth/use-save-auth-request-callback.ts b/src/common/hooks/auth/use-save-auth-request-callback.ts index 2be8dd19..1bf64f8a 100644 --- a/src/common/hooks/auth/use-save-auth-request-callback.ts +++ b/src/common/hooks/auth/use-save-auth-request-callback.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import { useSetRecoilState } from 'recoil'; + import { decodeToken } from 'jsontokens'; import { DecodedAuthRequest } from '@common/dev/types'; import { useWallet } from '@common/hooks/use-wallet'; @@ -8,12 +8,13 @@ import { getRequestOrigin, StorageKey } from '@common/storage'; import { ScreenPaths } from '@store/common/types'; import { useOnboardingState } from './use-onboarding-state'; import { authRequestState, currentScreenState } from '@store/onboarding'; +import { useUpdateAtom } from 'jotai/utils'; export function useSaveAuthRequest() { const { wallet } = useWallet(); const { screen } = useOnboardingState(); - const changeScreen = useSetRecoilState(currentScreenState); - const saveAuthRequest = useSetRecoilState(authRequestState); + const changeScreen = useUpdateAtom(currentScreenState); + const saveAuthRequest = useUpdateAtom(authRequestState); const location = useLocation(); const accounts = wallet?.accounts; const saveAuthRequestParam = useCallback( diff --git a/src/common/hooks/auth/use-sign-in.ts b/src/common/hooks/auth/use-sign-in.ts index 64f3407e..b235124c 100644 --- a/src/common/hooks/auth/use-sign-in.ts +++ b/src/common/hooks/auth/use-sign-in.ts @@ -1,4 +1,3 @@ -import { useRecoilState, useSetRecoilState } from 'recoil'; import { magicRecoveryCodeState, seedInputErrorState, seedInputState } from '@store/onboarding'; import React, { useCallback, useEffect, useRef } from 'react'; import { useWallet } from '@common/hooks/use-wallet'; @@ -10,11 +9,13 @@ import { } from '@common/utils'; import { ScreenPaths } from '@store/common/types'; import { useLoading } from '@common/hooks/use-loading'; +import { useUpdateAtom } from 'jotai/utils'; +import { useAtom } from 'jotai'; export function useSignIn() { - const setMagicRecoveryCode = useSetRecoilState(magicRecoveryCodeState); - const [seed, setSeed] = useRecoilState(seedInputState); - const [error, setError] = useRecoilState(seedInputErrorState); + const setMagicRecoveryCode = useUpdateAtom(magicRecoveryCodeState); + const [seed, setSeed] = useAtom(seedInputState); + const [error, setError] = useAtom(seedInputErrorState); const { isLoading, setIsLoading, setIsIdle } = useLoading('useSignIn'); const doChangeScreen = useDoChangeScreen(); @@ -57,7 +58,7 @@ export function useSignIn() { } try { - await doStoreSeed(parsedKeyInput); + await doStoreSeed({ secretKey: parsedKeyInput }); doChangeScreen(ScreenPaths.SET_PASSWORD); setIsIdle(); } catch (error) { diff --git a/src/common/hooks/use-assets.ts b/src/common/hooks/use-assets.ts index f5a6bad1..108ee50a 100644 --- a/src/common/hooks/use-assets.ts +++ b/src/common/hooks/use-assets.ts @@ -1,4 +1,3 @@ -import { useLoadable } from '@common/hooks/use-loadable'; import { assetsState, fungibleTokensState, @@ -6,23 +5,28 @@ import { stxTokenState, transferableAssetsState, } from '@store/assets/tokens'; +import { useAtomValue } from 'jotai/utils'; +import { useMemo } from 'react'; export const useAssets = () => { - return useLoadable(assetsState); + return useAtomValue(assetsState); }; export const useTransferableAssets = () => { - return useLoadable(transferableAssetsState); + return useAtomValue(transferableAssetsState); }; export function useFungibleTokenState() { - return useLoadable(fungibleTokensState); + const atom = useMemo(() => fungibleTokensState, []); + return useAtomValue(atom); } export function useNonFungibleTokenState() { - return useLoadable(nonFungibleTokensState); + const atom = useMemo(() => nonFungibleTokensState, []); + return useAtomValue(atom); } export function useStxTokenState() { - return useLoadable(stxTokenState); + const atom = useMemo(() => stxTokenState, []); + return useAtomValue(atom); } diff --git a/src/common/hooks/use-current-network.ts b/src/common/hooks/use-current-network.ts index 865a0672..3f9e74dc 100644 --- a/src/common/hooks/use-current-network.ts +++ b/src/common/hooks/use-current-network.ts @@ -1,12 +1,12 @@ -import { useRecoilValue } from 'recoil'; import { currentNetworkState } from '@store/networks'; import { useMemo } from 'react'; import { ChainID } from '@stacks/transactions'; +import { useAtomValue } from 'jotai/utils'; type Modes = 'testnet' | 'mainnet'; export function useCurrentNetwork() { - const network = useRecoilValue(currentNetworkState); + const network = useAtomValue(currentNetworkState); const isTestnet = useMemo(() => network.chainId === ChainID.Testnet, [network.chainId]); const mode = (isTestnet ? 'testnet' : 'mainnet') as Modes; return { diff --git a/src/common/hooks/use-do-change-screen.ts b/src/common/hooks/use-do-change-screen.ts index ad565b7e..c1bb0c12 100644 --- a/src/common/hooks/use-do-change-screen.ts +++ b/src/common/hooks/use-do-change-screen.ts @@ -1,14 +1,15 @@ import { ScreenPaths } from '@store/common/types'; import { useNavigate } from 'react-router-dom'; import { useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; + import { currentScreenState } from '@store/onboarding'; +import { useUpdateAtom } from 'jotai/utils'; type DoChangeScreen = (path: ScreenPaths, changeRoute?: boolean) => void; export function useDoChangeScreen(): DoChangeScreen { const navigate = useNavigate(); - const changeScreen = useSetRecoilState(currentScreenState); + const changeScreen = useUpdateAtom(currentScreenState); const doNavigatePage = useCallback( (path: ScreenPaths) => { diff --git a/src/common/hooks/use-drawers.ts b/src/common/hooks/use-drawers.ts index 35375bcb..d19e38ff 100644 --- a/src/common/hooks/use-drawers.ts +++ b/src/common/hooks/use-drawers.ts @@ -1,16 +1,16 @@ +import { useAtom } from 'jotai'; import { showNetworksStore, accountDrawerStep, showAccountsStore, showSettingsStore, } from '@store/ui'; -import { useRecoilState } from 'recoil'; export const useDrawers = () => { - const [accountStep, setAccountStep] = useRecoilState(accountDrawerStep); - const [showAccounts, setShowAccounts] = useRecoilState(showAccountsStore); - const [showNetworks, setShowNetworks] = useRecoilState(showNetworksStore); - const [showSettings, setShowSettings] = useRecoilState(showSettingsStore); + const [accountStep, setAccountStep] = useAtom(accountDrawerStep); + const [showAccounts, setShowAccounts] = useAtom(showAccountsStore); + const [showNetworks, setShowNetworks] = useAtom(showNetworksStore); + const [showSettings, setShowSettings] = useAtom(showSettingsStore); return { accountStep, diff --git a/src/common/hooks/use-home-tabs.ts b/src/common/hooks/use-home-tabs.ts index a937b905..1dea4e1f 100644 --- a/src/common/hooks/use-home-tabs.ts +++ b/src/common/hooks/use-home-tabs.ts @@ -1,8 +1,8 @@ -import { useRecoilState } from 'recoil'; import { tabState } from '@store/ui'; +import { useAtom } from 'jotai'; export function useHomeTabs() { - const [activeTab, setActiveTab] = useRecoilState(tabState('HOME_TABS')); + const [activeTab, setActiveTab] = useAtom(tabState('HOME_TABS')); const setActiveTabBalances = () => setActiveTab(0); const setActiveTabActivity = () => setActiveTab(1); diff --git a/src/common/hooks/use-loading.ts b/src/common/hooks/use-loading.ts index 3502ee13..abc5a91d 100644 --- a/src/common/hooks/use-loading.ts +++ b/src/common/hooks/use-loading.ts @@ -1,15 +1,15 @@ -import { useRecoilState } from 'recoil'; import { loadingState } from '@store/ui'; +import { useAtom } from 'jotai'; export enum LOADING_KEYS { SUBMIT_TRANSACTION = 'loading/SUBMIT_TRANSACTION', } export function useLoading(key: string) { - const [state, setState] = useRecoilState(loadingState(key)); + const [state, setState] = useAtom(loadingState(key)); - const setIsLoading = () => setState(() => 'loading'); - const setIsIdle = () => setState(() => 'idle'); + const setIsLoading = () => setState('loading'); + const setIsIdle = () => setState('idle'); const isLoading = state === 'loading'; return { diff --git a/src/common/hooks/use-on-cancel.ts b/src/common/hooks/use-on-cancel.ts index bd00784e..b3328ee6 100644 --- a/src/common/hooks/use-on-cancel.ts +++ b/src/common/hooks/use-on-cancel.ts @@ -1,20 +1,23 @@ -import { useRecoilCallback } from 'recoil'; +import { useAtomCallback } from 'jotai/utils'; import { requestTokenState } from '@store/transactions/requests'; import { finalizeTxSignature } from '@common/utils'; import { transactionBroadcastErrorState } from '@store/transactions'; +import { useCallback } from 'react'; export function useOnCancel() { - return useRecoilCallback(({ snapshot, set }) => async () => { - const requestToken = await snapshot.getPromise(requestTokenState); - if (!requestToken) { - set(transactionBroadcastErrorState, 'No pending transaction found.'); - return; - } - try { - const result = 'cancel'; - finalizeTxSignature(requestToken, result); - } catch (error) { - set(transactionBroadcastErrorState, error.message); - } - }); + return useAtomCallback( + useCallback(async (get, set) => { + const requestToken = get(requestTokenState); + if (!requestToken) { + set(transactionBroadcastErrorState, 'No pending transaction found.'); + return; + } + try { + const result = 'cancel'; + finalizeTxSignature(requestToken, result); + } catch (error) { + set(transactionBroadcastErrorState, error.message); + } + }, []) + ); } diff --git a/src/common/hooks/use-origin.ts b/src/common/hooks/use-origin.ts index 50352720..16914ff4 100644 --- a/src/common/hooks/use-origin.ts +++ b/src/common/hooks/use-origin.ts @@ -1,6 +1,6 @@ -import { useRecoilValue } from 'recoil'; +import { useAtomValue } from 'jotai/utils'; import { requestTokenOriginState } from '@store/transactions/requests'; export function useOrigin() { - return useRecoilValue(requestTokenOriginState); + return useAtomValue(requestTokenOriginState); } diff --git a/src/common/hooks/use-revalidate-api.ts b/src/common/hooks/use-revalidate-api.ts deleted file mode 100644 index f3ba6f0f..00000000 --- a/src/common/hooks/use-revalidate-api.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useRecoilCallback } from 'recoil'; -import { apiRevalidation } from '@store/common/api-helpers'; - -export function useRevalidateApi() { - return useRecoilCallback(({ snapshot, set }) => async () => { - const count = await snapshot.getPromise(apiRevalidation); - set(apiRevalidation, count + 1); - }); -} diff --git a/src/common/hooks/use-selected-asset.ts b/src/common/hooks/use-selected-asset.ts index d7ecc3ce..54e06dea 100644 --- a/src/common/hooks/use-selected-asset.ts +++ b/src/common/hooks/use-selected-asset.ts @@ -1,19 +1,18 @@ -import { useSetRecoilState } from 'recoil'; -import { useLoadable } from '@common/hooks/use-loadable'; import { selectedAssetIdState, selectedAssetStore } from '@store/assets/asset-search'; import { AssetWithMeta } from '@store/assets/types'; import { getTicker } from '@common/utils'; import { useCallback, useMemo } from 'react'; import { ftDecimals, stacksValue } from '@common/stacks-utils'; import BigNumber from 'bignumber.js'; +import { useAtomValue, useUpdateAtom } from 'jotai/utils'; export function getFullyQualifiedAssetName(asset?: AssetWithMeta) { return asset ? `${asset.contractAddress}.${asset.contractName}::${asset.name}` : undefined; } export function useSelectedAsset() { - const { value: selectedAsset } = useLoadable(selectedAssetStore); - const setSelectedAsset = useSetRecoilState(selectedAssetIdState); + const selectedAsset = useAtomValue(selectedAssetStore); + const setSelectedAsset = useUpdateAtom(selectedAssetIdState); const handleUpdateSelectedAsset = useCallback( (asset: AssetWithMeta | undefined) => { setSelectedAsset(getFullyQualifiedAssetName(asset) || undefined); diff --git a/src/common/hooks/use-send-form-validation.ts b/src/common/hooks/use-send-form-validation.ts index 87fab265..57386fba 100644 --- a/src/common/hooks/use-send-form-validation.ts +++ b/src/common/hooks/use-send-form-validation.ts @@ -30,10 +30,10 @@ export const useSendFormValidation = ({ errors.amount = 'Must be more than zero'; } if (selectedAsset) { - if (balances.value) { + if (balances) { const amountBN = new BigNumber(amount); if (selectedAsset.type === 'stx') { - const curBalance = microStxToStx(balances.value.stx.balance); + const curBalance = microStxToStx(balances.stx.balance); if (curBalance.lt(amountBN)) { errors.amount = `You don't have enough tokens, Your balance is ${curBalance.toString()}`; } diff --git a/src/common/hooks/use-send-form.ts b/src/common/hooks/use-send-form.ts index afe4f712..801868c8 100644 --- a/src/common/hooks/use-send-form.ts +++ b/src/common/hooks/use-send-form.ts @@ -16,9 +16,9 @@ export function useSendAmountFieldActions({ const isStx = selectedAsset?.type === 'stx'; const handleSetSendMax = useCallback(() => { - if (!selectedAsset || !balances.value) return; + if (!selectedAsset || !balances) return; if (isStx) { - const stx = microStxToStx(balances.value.stx.balance); + const stx = microStxToStx(balances.stx.balance); setFieldValue('amount', stx.toNumber()); } else { if (balance) setFieldValue('amount', removeCommas(balance)); @@ -76,10 +76,10 @@ export function useSendForm({ const assets = useAssets(); useEffect(() => { - if (assets.value?.length === 1 && amountFieldRef.current) { + if (assets?.length === 1 && amountFieldRef.current) { amountFieldRef?.current?.focus?.(); } - }, [amountFieldRef, assets.value?.length]); + }, [amountFieldRef, assets?.length]); useEffect(() => { if (previous !== selectedAsset) { diff --git a/src/common/hooks/use-transaction-request.ts b/src/common/hooks/use-transaction-request.ts index f231f13d..37e49e3e 100644 --- a/src/common/hooks/use-transaction-request.ts +++ b/src/common/hooks/use-transaction-request.ts @@ -1,7 +1,6 @@ -import { useLoadable } from '@common/hooks/use-loadable'; import { requestTokenPayloadState } from '@store/transactions/requests'; +import { useAtomValue } from 'jotai/utils'; export function useTransactionRequest() { - const payload = useLoadable(requestTokenPayloadState); - return payload?.value; + return useAtomValue(requestTokenPayloadState); } diff --git a/src/common/hooks/use-vault-messenger.ts b/src/common/hooks/use-vault-messenger.ts index 2c589395..a8e26420 100644 --- a/src/common/hooks/use-vault-messenger.ts +++ b/src/common/hooks/use-vault-messenger.ts @@ -5,7 +5,7 @@ import { UnlockWallet, SwitchAccount, } from '@background/vault-types'; -import { RecoilState, useRecoilCallback } from 'recoil'; + import { hasSetPasswordState, walletState, @@ -15,87 +15,102 @@ import { } from '@store/wallet'; import { InMemoryVault } from '@background/vault'; import { InternalMethods } from '@content-scripts/message-types'; -import { currentAccountIndexState } from '@store/accounts'; import { textToBytes } from '@store/common/utils'; +import { useAtomCallback } from 'jotai/utils'; +import { useCallback } from 'react'; +import { currentAccountIndexState } from '@store/accounts'; -type Set = (store: RecoilState, value: T) => void; - -const innerMessageWrapper = async (message: VaultActions, set: Set) => { - return new Promise((resolve, reject) => { - chrome.runtime.sendMessage(message, (vaultOrError: InMemoryVault | Error) => { - if ('hasSetPassword' in vaultOrError) { - const vault = vaultOrError; - set(hasRehydratedVaultStore, true); - set(hasSetPasswordState, vault.hasSetPassword); - set(walletState, vault.wallet); - set(secretKeyState, vault.secretKey ? textToBytes(vault.secretKey) : undefined); - set(currentAccountIndexState, vault.currentAccountIndex); - set(encryptedSecretKeyStore, vault.encryptedSecretKey); - resolve(vault); - } else { - reject(vaultOrError); - } - }); - }); -}; - -const messageWrapper = (message: VaultActions) => { - return useRecoilCallback( - ({ set }) => - () => - innerMessageWrapper(message, set), - [message] +const useInnerMessageWrapper = () => { + return useAtomCallback( + useCallback(async (_get, set, message) => { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (vaultOrError: InMemoryVault | Error) => { + try { + if ('hasSetPassword' in vaultOrError) { + const vault = vaultOrError; + set(hasRehydratedVaultStore, true); + set(hasSetPasswordState, vault.hasSetPassword); + set(walletState, vault.wallet); + set(secretKeyState, vault.secretKey ? textToBytes(vault.secretKey) : undefined); + set(currentAccountIndexState, vault.currentAccountIndex); + set(encryptedSecretKeyStore, vault.encryptedSecretKey); + resolve(vault); + } else { + reject(vaultOrError); + } + } catch (e) { + console.error(e); + } + }); + }); + }, []) ); }; export const useVaultMessenger = () => { - const doSetPassword = useRecoilCallback(({ set }) => (password: string) => { - const message: SetPassword = { - method: InternalMethods.setPassword, - payload: password, - }; - return innerMessageWrapper(message, set); - }); + const innerMessageWrapper = useInnerMessageWrapper(); - const doStoreSeed = useRecoilCallback(({ set }) => (secretKey: string, password?: string) => { - const message: StoreSeed = { - method: InternalMethods.storeSeed, - payload: { - secretKey, - password, - }, - }; - return innerMessageWrapper(message, set); - }); + const doSetPassword = useCallback( + (payload: string) => { + const message: SetPassword = { + method: InternalMethods.setPassword, + payload, + }; + return innerMessageWrapper(message); + }, + [innerMessageWrapper] + ); - const doUnlockWallet = useRecoilCallback(({ set }) => (password: string) => { - const message: UnlockWallet = { - method: InternalMethods.unlockWallet, - payload: password, - }; - return innerMessageWrapper(message, set); - }); + const doStoreSeed = useCallback( + (payload: { secretKey: string; password?: string }) => { + const message: StoreSeed = { + method: InternalMethods.storeSeed, + payload, + }; + return innerMessageWrapper(message); + }, + [innerMessageWrapper] + ); - const doSwitchAccount = useRecoilCallback(({ set }) => (index: number) => { - const message: SwitchAccount = { - method: InternalMethods.switchAccount, - payload: index, - }; - return innerMessageWrapper(message, set); - }); + const doUnlockWallet = useCallback( + payload => { + const message: UnlockWallet = { + method: InternalMethods.unlockWallet, + payload, + }; + return innerMessageWrapper(message); + }, + [innerMessageWrapper] + ); - const getWallet = messageWrapper({ method: InternalMethods.getWallet, payload: undefined }); - const doMakeWallet = messageWrapper({ method: InternalMethods.makeWallet, payload: undefined }); - const doCreateNewAccount = messageWrapper({ - method: InternalMethods.createNewAccount, - payload: undefined, - }); - const handleSignOut = messageWrapper({ method: InternalMethods.signOut, payload: undefined }); + const doSwitchAccount = useCallback( + payload => { + const message: SwitchAccount = { + method: InternalMethods.switchAccount, + payload, + }; + return innerMessageWrapper(message); + }, + [innerMessageWrapper] + ); + + const getWallet = () => + innerMessageWrapper({ method: InternalMethods.getWallet, payload: undefined }); + const doMakeWallet = () => + innerMessageWrapper({ method: InternalMethods.makeWallet, payload: undefined }); + const doCreateNewAccount = () => + innerMessageWrapper({ + method: InternalMethods.createNewAccount, + payload: undefined, + }); + const handleSignOut = () => + innerMessageWrapper({ method: InternalMethods.signOut, payload: undefined }); const doSignOut = async () => { await handleSignOut(); localStorage.clear(); }; - const doLockWallet = messageWrapper({ method: InternalMethods.lockWallet, payload: undefined }); + const doLockWallet = () => + innerMessageWrapper({ method: InternalMethods.lockWallet, payload: undefined }); return { getWallet, diff --git a/src/common/hooks/use-wallet.ts b/src/common/hooks/use-wallet.ts index 4fc525a5..c6de2baf 100644 --- a/src/common/hooks/use-wallet.ts +++ b/src/common/hooks/use-wallet.ts @@ -6,28 +6,21 @@ import { makeAuthResponse, getAccountDisplayName, } from '@stacks/wallet-sdk'; -import { useRecoilValue, useRecoilState, useRecoilCallback } from 'recoil'; + import { gaiaUrl } from '@common/constants'; -import { - currentNetworkKeyState, - currentNetworkState, - networksState, - latestBlockHeightState, -} from '@store/networks'; +import { currentNetworkKeyState, currentNetworkState, networksState } from '@store/networks'; import { walletState, encryptedSecretKeyStore, secretKeyState, hasSetPasswordState, - walletConfigStore, hasRehydratedVaultStore, } from '@store/wallet'; import { useVaultMessenger } from '@common/hooks/use-vault-messenger'; import { useOnboardingState } from './auth/use-onboarding-state'; import { finalizeAuthResponse } from '@common/utils'; -import { apiRevalidation } from '@store/common/api-helpers'; -import { useLoadable } from '@common/hooks/use-loadable'; + import { currentAccountIndexState, currentAccountState, @@ -36,21 +29,22 @@ import { import { localNoncesState } from '@store/accounts/nonce'; import { bytesToText } from '@store/common/utils'; import { transactionNetworkVersionState } from '@store/transactions'; +import { useAtom } from 'jotai'; +import { useAtomValue, useAtomCallback } from 'jotai/utils'; export const useWallet = () => { - const hasRehydratedVault = useRecoilValue(hasRehydratedVaultStore); - const [wallet, setWallet] = useRecoilState(walletState); - const secretKey = useRecoilValue(secretKeyState); - const encryptedSecretKey = useRecoilValue(encryptedSecretKeyStore); - const currentAccountIndex = useRecoilValue(currentAccountIndexState); - const hasSetPassword = useRecoilValue(hasSetPasswordState); - const currentAccount = useRecoilValue(currentAccountState); - const currentAccountStxAddress = useRecoilValue(currentAccountStxAddressState); - const transactionVersion = useRecoilValue(transactionNetworkVersionState); - const networks = useRecoilValue(networksState); - const currentNetwork = useRecoilValue(currentNetworkState); - const currentNetworkKey = useRecoilValue(currentNetworkKeyState); - const walletConfig = useLoadable(walletConfigStore); + const hasRehydratedVault = useAtomValue(hasRehydratedVaultStore); + const [wallet, setWallet] = useAtom(walletState); + const secretKey = useAtomValue(secretKeyState); + const encryptedSecretKey = useAtomValue(encryptedSecretKeyStore); + const currentAccountIndex = useAtomValue(currentAccountIndexState); + const hasSetPassword = useAtomValue(hasSetPasswordState); + const currentAccount = useAtomValue(currentAccountState); + const currentAccountStxAddress = useAtomValue(currentAccountStxAddressState); + const transactionVersion = useAtomValue(transactionNetworkVersionState); + const networks = useAtomValue(networksState); + const currentNetwork = useAtomValue(currentNetworkState); + const currentNetworkKey = useAtomValue(currentNetworkKeyState); const vaultMessenger = useVaultMessenger(); const currentAccountDisplayName = currentAccount @@ -61,21 +55,14 @@ export const useWallet = () => { const isSignedIn = !!wallet; - const doSetLatestNonce = useRecoilCallback( - ({ snapshot, set }) => - async (newNonce?: number) => { - if (newNonce !== undefined) { - set(apiRevalidation, current => (current as number) + 1); - const blockHeight = await snapshot.getPromise(latestBlockHeightState); - const network = await snapshot.getPromise(currentNetworkState); - const address = await snapshot.getPromise(currentAccountStxAddressState); - set(localNoncesState([network.url, address || '']), () => ({ - blockHeight, - nonce: newNonce, - })); - } - }, - [] + const doSetLatestNonce = useAtomCallback( + useCallback((get, set, newNonce) => { + if (newNonce !== undefined) { + const network = get(currentNetworkState); + const address = get(currentAccountStxAddressState); + set(localNoncesState([network.url, address || '']), newNonce); + } + }, []) ); const handleCancelAuthentication = useCallback(() => { @@ -86,10 +73,10 @@ export const useWallet = () => { finalizeAuthResponse({ decodedAuthRequest, authRequest, authResponse }); }, [decodedAuthRequest, authRequest]); - const doFinishSignIn = useRecoilCallback( - ({ set, snapshot }) => - async (accountIndex: number) => { - const wallet = await snapshot.getPromise(walletState); + const doFinishSignIn = useAtomCallback( + useCallback( + async (get, set, accountIndex) => { + const wallet = get(walletState); const account = wallet?.accounts[accountIndex]; if (!decodedAuthRequest || !authRequest || !account || !wallet) { console.error('Uh oh! Finished onboarding without auth info.'); @@ -125,7 +112,8 @@ export const useWallet = () => { set(currentAccountIndexState, accountIndex); finalizeAuthResponse({ decodedAuthRequest, authRequest, authResponse }); }, - [decodedAuthRequest, authRequest, appName, appIcon] + [appName, appIcon, authRequest, decodedAuthRequest] + ) ); return { @@ -138,7 +126,7 @@ export const useWallet = () => { currentAccountStxAddress, currentAccountDisplayName, transactionVersion, - walletConfig, + networks, currentNetwork, currentNetworkKey, diff --git a/src/common/token-utils.ts b/src/common/token-utils.ts index 1eb6acdd..47575fb6 100644 --- a/src/common/token-utils.ts +++ b/src/common/token-utils.ts @@ -11,11 +11,7 @@ function handleErrorMessage(message = 'Error') { export type SIP010TransferResponse = { okay: true; hasMemo: boolean } | { error: string }; -export async function isSip10Transfer({ - contractInterface, -}: { - contractInterface: ContractInterface; -}): Promise { +export function isSip10Transfer(contractInterface: ContractInterface): SIP010TransferResponse { try { let hasCorrectName = false; let hasCorrectSender = false; diff --git a/src/common/transactions/postcondition-utils.ts b/src/common/transactions/postcondition-utils.ts index 0d7f1adf..f4c3ffc4 100644 --- a/src/common/transactions/postcondition-utils.ts +++ b/src/common/transactions/postcondition-utils.ts @@ -33,6 +33,13 @@ export const getSymbolFromPostCondition = (pc: PostCondition) => { return 'STX'; }; +export const getNameFromPostCondition = (pc: PostCondition) => { + if ('assetInfo' in pc) { + return pc.assetInfo.assetName.content; + } + return 'STX'; +}; + export function getPostConditionCodeMessage( code: FungibleConditionCode | NonFungibleConditionCode, isSender: boolean @@ -142,7 +149,7 @@ export const useAssetInfoFromPostCondition = (pc: PostCondition) => { const contractAddress = addressToString(pc.assetInfo.address); const contractName = pc.assetInfo.contractName.content; - const asset = assets?.value?.find( + const asset = assets?.find( asset => asset.contractAddress === contractAddress && asset.contractName === contractName && diff --git a/src/components/account-avatar.tsx b/src/components/account-avatar.tsx index 7149e3de..3874402d 100644 --- a/src/components/account-avatar.tsx +++ b/src/components/account-avatar.tsx @@ -3,12 +3,11 @@ import React from 'react'; import { Account, getAccountDisplayName } from '@stacks/wallet-sdk'; import { useAccountGradient } from '@common/hooks/account/use-account-gradient'; +import { AccountWithAddress } from '@store/accounts'; -export const AccountAvatar: React.FC<{ account: Account; name?: string } & BoxProps> = ({ - account, - name, - ...props -}) => { +export const AccountAvatar: React.FC< + { account: AccountWithAddress | Account; name?: string } & BoxProps +> = ({ account, name, ...props }) => { const displayName = name && name.includes('.') ? name : getAccountDisplayName(account); const gradient = useAccountGradient(account); diff --git a/src/components/account-item.tsx b/src/components/account-item.tsx index e53af915..72d68993 100644 --- a/src/components/account-item.tsx +++ b/src/components/account-item.tsx @@ -9,7 +9,7 @@ import { AccountWithAddress } from '@store/accounts'; export const AccountItem = memo(({ account, ...rest }: { account: AccountWithAddress }) => { const names = useAccountNames(); - const name = names.value?.[account.index]?.names?.[0] || getAccountDisplayName(account); + const name = names?.[account.index]?.names?.[0] || getAccountDisplayName(account); return ( diff --git a/src/components/accounts/index.tsx b/src/components/accounts/index.tsx index 5b0feaa0..177fef0e 100644 --- a/src/components/accounts/index.tsx +++ b/src/components/accounts/index.tsx @@ -9,16 +9,16 @@ import { useOnboardingState } from '@common/hooks/auth/use-onboarding-state'; import { useAccountDisplayName } from '@common/hooks/account/use-account-names'; import { accountsWithAddressState, AccountWithAddress } from '@store/accounts'; -import { useLoadable } from '@common/hooks/use-loadable'; import { AccountAvatar } from '@components/account-avatar'; import { SpaceBetween } from '@components/space-between'; import { cleanUsername, slugify } from '@common/utils'; import { usePressable } from '@components/item-hover'; import { FiPlusCircle } from 'react-icons/fi'; -import { useSetRecoilState } from 'recoil'; + import { accountDrawerStep, AccountStep, showAccountsStore } from '@store/ui'; import { useLoading } from '@common/hooks/use-loading'; +import { useAtomValue, useUpdateAtom } from 'jotai/utils'; const loadingProps = { color: '#A1A7B3' }; const getLoadingProps = (loading: boolean) => (loading ? loadingProps : {}); @@ -42,18 +42,16 @@ export const AccountItem: React.FC = ({ selectedAddress, accou }, [setIsLoading, doFinishSignIn, account]); return ( - handleOnClick()} {...bind} {...rest} > - - - - + + + + = ({ selectedAddress, accou {truncateMiddle(account.address)} </Caption> </Stack> - </Stack> - {isLoading && <Spinner width={4} height={4} {...loadingProps} />} - </SpaceBetween> + {isLoading && <Spinner width={4} height={4} {...loadingProps} />} + </SpaceBetween> + </Stack> {component} - </Stack> + </Box> ); }; const AddAccountAction = memo(() => { - const setAccounts = useSetRecoilState(showAccountsStore); - const setAccountDrawerStep = useSetRecoilState(accountDrawerStep); + const setAccounts = useUpdateAtom(showAccountsStore); + const setAccountDrawerStep = useUpdateAtom(accountDrawerStep); + const [component, bind] = usePressable(true); + return ( - <SpaceBetween alignItems="center"> - <Stack - isInline - p="base" - bg={color('bg-4')} - borderRadius="10px" - alignItems="center" - color={color('text-body')} - _hover={{ cursor: 'pointer', color: color('brand') }} - onClick={() => { - setAccounts(true); - setAccountDrawerStep(AccountStep.Create); - }} - > + <Box + px="base-tight" + py="tight" + onClick={() => { + setAccounts(true); + setAccountDrawerStep(AccountStep.Create); + }} + {...bind} + > + <Stack isInline alignItems="center" color={color('text-body')}> <Box size="16px" as={FiPlusCircle} color={color('brand')} /> - <Text color="currentColor">Add account</Text> + <Text color="currentColor">Generate new account</Text> </Stack> - </SpaceBetween> + {component} + </Box> ); }); @@ -108,20 +105,20 @@ interface AccountsProps extends FlexProps { export const Accounts: React.FC<AccountsProps> = memo( ({ showAddAccount, accountIndex, next, ...rest }) => { const { wallet } = useWallet(); - const { value: accounts } = useLoadable(accountsWithAddressState); + const accounts = useAtomValue(accountsWithAddressState); const { decodedAuthRequest } = useOnboardingState(); if (!wallet || !accounts || !decodedAuthRequest) return null; return ( - <Stack py="extra-loose" spacing="loose" px="extra-loose" {...rest}> - <Stack spacing="loose"> + <> + <Stack py="extra-loose" spacing="loose" px="extra-loose" {...rest}> {accounts.map(account => ( <AccountItem key={account.address} account={account} /> ))} + <AddAccountAction /> </Stack> - <AddAccountAction /> - </Stack> + </> ); } ); diff --git a/src/components/asset-search/asset-search.tsx b/src/components/asset-search/asset-search.tsx index c1c1a6a6..598e1b52 100644 --- a/src/components/asset-search/asset-search.tsx +++ b/src/components/asset-search/asset-search.tsx @@ -2,11 +2,13 @@ import React, { memo, useMemo, forwardRef } from 'react'; import { Box, Fade, Text, Flex, Input, color, Stack, StackProps } from '@stacks/ui'; import { useCombobox } from 'downshift'; import { searchInputStore } from '@store/assets/asset-search'; -import { useRecoilState } from 'recoil'; + import { SelectedAsset } from './selected-asset'; import { useTransferableAssets } from '@common/hooks/use-assets'; import { useSelectedAsset } from '@common/hooks/use-selected-asset'; import { AssetRow } from '@components/asset-row'; +import { useAtomValue } from 'jotai/utils'; +import { useAtom } from 'jotai'; interface AssetSearchResultsProps extends StackProps { isOpen: boolean; @@ -17,17 +19,15 @@ interface AssetSearchResultsProps extends StackProps { const AssetSearchResults = forwardRef( ({ isOpen, highlightedIndex, getItemProps, ...props }: AssetSearchResultsProps, ref) => { const assets = useTransferableAssets(); - const [searchInput] = useRecoilState(searchInputStore); + const searchInput = useAtomValue(searchInputStore); const items = useMemo( () => - assets.value?.filter(item => - item.name.toLowerCase().includes(searchInput.toLowerCase() || '') - ), - [assets.value, searchInput] + assets?.filter(item => item.name.toLowerCase().includes(searchInput.toLowerCase() || '')), + [assets, searchInput] ); - if (!assets.value) return null; + if (!assets) return null; return ( <Fade in={isOpen && !!items?.length}> @@ -74,7 +74,7 @@ export const AssetSearchField: React.FC<{ const { selectedAsset, handleUpdateSelectedAsset } = useSelectedAsset(); - const [searchInput, setSearchInput] = useRecoilState(searchInputStore); + const [searchInput, setSearchInput] = useAtom(searchInputStore); const { isOpen, @@ -86,7 +86,7 @@ export const AssetSearchField: React.FC<{ getItemProps, openMenu, } = useCombobox({ - items: assets.value || [], + items: assets || [], initialIsOpen: true, inputValue: searchInput, defaultIsOpen: false, @@ -103,7 +103,7 @@ export const AssetSearchField: React.FC<{ const labelRef = React.useRef(null); const comboRef = React.useRef(null); - if (assets.isLoading) return null; + if (!assets) return null; return ( <Flex flexDirection="column" width="100%" position="relative" overflow="visible" {...rest}> @@ -152,7 +152,7 @@ export const AssetSearch: React.FC<{ const { selectedAsset } = useSelectedAsset(); const assets = useTransferableAssets(); - if (assets.isLoading) { + if (!assets) { return ( <Stack spacing="tight" {...rest}> <Box height="16px" width="68px" bg={color('bg-4')} borderRadius="8px" /> diff --git a/src/components/asset-search/selected-asset.tsx b/src/components/asset-search/selected-asset.tsx index be6f039f..121b555b 100644 --- a/src/components/asset-search/selected-asset.tsx +++ b/src/components/asset-search/selected-asset.tsx @@ -4,13 +4,14 @@ import { Box, ChevronIcon, Text, color, Stack, StackProps, BoxProps } from '@sta import { searchInputStore } from '@store/assets/asset-search'; import React, { memo, useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; + import { Caption } from '@components/typography'; import { useSelectedAsset } from '@common/hooks/use-selected-asset'; +import { useUpdateAtom } from 'jotai/utils'; const SelectedAssetItem = memo(({ hideArrow, ...rest }: { hideArrow?: boolean } & BoxProps) => { const { selectedAsset, ticker, name, handleUpdateSelectedAsset } = useSelectedAsset(); - const setSearchInput = useSetRecoilState(searchInputStore); + const setSearchInput = useUpdateAtom(searchInputStore); const handleClear = useCallback(() => { setSearchInput(''); diff --git a/src/components/current-user/current-stx-address.tsx b/src/components/current-user/current-stx-address.tsx new file mode 100644 index 00000000..25c9785c --- /dev/null +++ b/src/components/current-user/current-stx-address.tsx @@ -0,0 +1,20 @@ +import { Box, BoxProps } from '@stacks/ui'; +import { truncateMiddle } from '@stacks/ui-utils'; +import React from 'react'; +import { useCurrentAccount } from '@common/hooks/account/use-current-account'; +import { memoWithAs } from '@stacks/ui-core'; +import { LoadingRectangle } from '@components/loading-rectangle'; + +const CurrentStxAddressSuspense = memoWithAs((props: BoxProps) => { + const currentAccount = useCurrentAccount(); + if (!currentAccount) return null; + return <Box {...props}>{truncateMiddle(currentAccount.address, 4)}</Box>; +}); + +export const CurrentStxAddress = memoWithAs((props: BoxProps) => { + return ( + <React.Suspense fallback={<LoadingRectangle height="16px" width="50px" {...props} />}> + <CurrentStxAddressSuspense {...props} /> + </React.Suspense> + ); +}); diff --git a/src/components/current-user/current-user-avatar.tsx b/src/components/current-user/current-user-avatar.tsx new file mode 100644 index 00000000..11d50d3b --- /dev/null +++ b/src/components/current-user/current-user-avatar.tsx @@ -0,0 +1,30 @@ +import React, { memo } from 'react'; +import { useCurrentAccount } from '@common/hooks/account/use-current-account'; +import { useAccountNames } from '@common/hooks/account/use-account-names'; +import { getAccountDisplayName } from '@stacks/wallet-sdk'; +import { AccountAvatar } from '@components/account-avatar'; +import { BoxProps } from '@stacks/ui'; + +const UserAvatarSuspense = memo((props: BoxProps) => { + const currentAccount = useCurrentAccount(); + const names = useAccountNames(); + if (!currentAccount || typeof currentAccount.index === 'undefined') return null; + const name = + names?.[currentAccount.index]?.names?.[0] || getAccountDisplayName(currentAccount as any); + return <AccountAvatar name={name} flexShrink={0} account={currentAccount} {...props} />; +}); + +export const CurrentUserAvatar = memo((props: BoxProps) => { + const currentAccount = useCurrentAccount(); + const defaultName = getAccountDisplayName(currentAccount as any); + if (!currentAccount) return null; + return ( + <React.Suspense + fallback={ + <AccountAvatar name={defaultName} flexShrink={0} account={currentAccount} {...props} /> + } + > + <UserAvatarSuspense {...props} /> + </React.Suspense> + ); +}); diff --git a/src/components/current-user/current-user-name.tsx b/src/components/current-user/current-user-name.tsx new file mode 100644 index 00000000..9b86f637 --- /dev/null +++ b/src/components/current-user/current-user-name.tsx @@ -0,0 +1,50 @@ +import React, { memo } from 'react'; +import { BoxProps } from '@stacks/ui'; +import { useCurrentAccount } from '@common/hooks/account/use-current-account'; +import { useAccountNames } from '@common/hooks/account/use-account-names'; +import { getAccountDisplayName } from '@stacks/wallet-sdk'; +import { truncateString } from '@common/utils'; +import { Tooltip } from '@components/tooltip'; +import { Title } from '@components/typography'; +import { memoWithAs } from '@stacks/ui-core'; + +const UsernameTitle = (props: BoxProps) => ( + <Title + data-test="home-current-display-name" + as="h1" + lineHeight="1rem" + fontSize={4} + fontWeight={500} + {...props} + /> +); + +const UsernameSuspense = memo((props: BoxProps) => { + const currentAccount = useCurrentAccount(); + const names = useAccountNames(); + if (!currentAccount || typeof currentAccount.index === 'undefined') return null; + const nameCharLimit = 18; + const name = + names?.[currentAccount.index]?.names?.[0] || getAccountDisplayName(currentAccount as any); + const isLong = name.length > nameCharLimit; + const displayName = truncateString(name, nameCharLimit); + + return ( + <UsernameTitle {...props}> + <Tooltip label={isLong ? name : undefined}> + <div>{displayName}</div> + </Tooltip> + </UsernameTitle> + ); +}); + +export const CurrentUsername = memoWithAs((props: BoxProps) => { + const currentAccount = useCurrentAccount(); + const defaultName = getAccountDisplayName(currentAccount as any); + const fallback = <UsernameTitle {...props}>{defaultName}</UsernameTitle>; + return ( + <React.Suspense fallback={fallback}> + <UsernameSuspense {...props} /> + </React.Suspense> + ); +}); diff --git a/src/components/debug-observer.tsx b/src/components/debug-observer.tsx deleted file mode 100644 index 35188b4f..00000000 --- a/src/components/debug-observer.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useRecoilSnapshot } from 'recoil'; -import { useEffect } from 'react'; - -export function DebugObserver() { - const snapshot = useRecoilSnapshot(); - useEffect(() => { - if (process.env.NODE_ENV === 'development' && !!localStorage.getItem('DEBUG')) { - for (const node of snapshot.getNodes_UNSTABLE({ isModified: true })) { - console.group(`[state change] ${node.key}`); - console.log(snapshot.getLoadable(node).contents); - console.groupEnd(); - } - } - }, [snapshot]); - - return null; -} diff --git a/src/components/devtools.tsx b/src/components/devtools.tsx new file mode 100644 index 00000000..69a190a4 --- /dev/null +++ b/src/components/devtools.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { useAtomDevtools } from 'jotai/devtools'; +import { walletState } from '@store/wallet'; + +export function Devtools() { + useAtomDevtools(walletState); + return null; +} diff --git a/src/components/drawer/accounts/index.tsx b/src/components/drawer/accounts/index.tsx index 26c60a52..f8051574 100644 --- a/src/components/drawer/accounts/index.tsx +++ b/src/components/drawer/accounts/index.tsx @@ -1,23 +1,22 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { ControlledDrawer } from '../controlled'; import { SwitchAccounts } from './switch-accounts'; import { CreateAccount } from './create-account'; import { AddUsername } from './add-username'; import { useDrawers } from '@common/hooks/use-drawers'; -import { useRecoilCallback } from 'recoil'; + import { AccountStep, showAccountsStore, accountDrawerStep } from '@store/ui'; +import { useAtomCallback } from 'jotai/utils'; export const AccountsDrawer: React.FC = () => { const { accountStep } = useDrawers(); - const close = useRecoilCallback( - ({ set }) => - () => { - set(showAccountsStore, false); - const drawerAnimationTime = 200; - setTimeout(() => set(accountDrawerStep, AccountStep.Switch), drawerAnimationTime); - }, - [] + const close = useAtomCallback( + useCallback((_get, set) => { + set(showAccountsStore, false); + const drawerAnimationTime = 200; + setTimeout(() => set(accountDrawerStep, AccountStep.Switch), drawerAnimationTime); + }, []) ); const getTitle = () => { diff --git a/src/components/drawer/accounts/switch-accounts.tsx b/src/components/drawer/accounts/switch-accounts.tsx index c32fa9c5..2ab78aef 100644 --- a/src/components/drawer/accounts/switch-accounts.tsx +++ b/src/components/drawer/accounts/switch-accounts.tsx @@ -1,81 +1,135 @@ -import React, { memo } from 'react'; -import { Box, Fade, Button, Stack, color } from '@stacks/ui'; +import React, { memo, useCallback } from 'react'; +import { Box, Fade, Button, Stack, color, BoxProps, Spinner } from '@stacks/ui'; import { Title, Caption } from '@components/typography'; -import { useSetRecoilState } from 'recoil'; + import { accountDrawerStep, AccountStep } from '@store/ui'; -import { getAccountDisplayName, getStxAddress } from '@stacks/wallet-sdk'; +import { getAccountDisplayName } from '@stacks/wallet-sdk'; import { truncateMiddle } from '@stacks/ui-utils'; import { SpaceBetween } from '@components/space-between'; import { IconCheck } from '@tabler/icons'; import { AccountAvatar } from '@components/account-avatar'; -import { useAccountNames } from '@common/hooks/account/use-account-names'; +import { useAccountDisplayName } from '@common/hooks/account/use-account-names'; import { useSwitchAccount } from '@common/hooks/account/use-switch-account'; +import { useUpdateAtom } from 'jotai/utils'; +import { useAccounts } from '@common/hooks/account/use-accounts'; +import { AccountWithAddress } from '@store/accounts'; +import { useLoading } from '@common/hooks/use-loading'; interface SwitchAccountProps { close: () => void; } -// eslint-disable-next-line no-warning-comments -// TODO: this page is nearly identical to the network switcher abstract it out into a shared component -const AccountList: React.FC<{ handleClose: () => void }> = memo(({ handleClose }) => { - const names = useAccountNames(); - const { accounts, handleSwitchAccount, transactionVersion, getIsActive } = - useSwitchAccount(handleClose); - if (!names.value) return null; - return ( - <> - {accounts.map((account, index) => { - const name = names.value?.[index]?.names?.[0] || getAccountDisplayName(account); +interface WithAccount { + account: AccountWithAddress; +} - return ( - <SpaceBetween - width="100%" - key={`account-${account.index}`} - _hover={{ - bg: color('bg-4'), - }} - cursor="pointer" - py="base" - px="loose" - onClick={() => handleSwitchAccount(index)} - > - <Stack isInline alignItems="center" spacing="base"> - <AccountAvatar name={name} account={account} /> - <Stack spacing="base-tight"> - <Title fontSize={2} lineHeight="1rem" fontWeight="400"> - {name} - - - {truncateMiddle( - getStxAddress({ - account: account, - transactionVersion, - }), - 9 - )} - - - - - {styles => ( - - )} - - - ); - })} - +const AccountNameSuspense = memo(({ account }: WithAccount) => { + const name = useAccountDisplayName(account); + + return ( + + {name} + ); }); +const AccountName = memo(({ account, ...rest }: BoxProps & WithAccount) => { + const defaultName = getAccountDisplayName(account); + return ( + + + {defaultName} + + } + > + + + + ); +}); + +const AccountAvatarSuspense = memo(({ account }: { account: AccountWithAddress }) => { + const name = useAccountDisplayName(account); + return ; +}); + +const AccountAvatarItem = memo(({ account, ...rest }: BoxProps & WithAccount) => { + const defaultName = getAccountDisplayName(account); + return ( + + }> + + + + ); +}); + +const AccountListItem = memo( + ({ account, handleClose }: { account: AccountWithAddress; handleClose: () => void }) => { + const { isLoading, setIsLoading, setIsIdle } = useLoading('SWITCH_ACCOUNTS' + account.address); + const { handleSwitchAccount, getIsActive } = useSwitchAccount(handleClose); + const handleClick = useCallback(async () => { + setIsLoading(); + setTimeout(async () => { + await handleSwitchAccount(account.index); + setIsIdle(); + }, 80); + }, [setIsLoading, setIsIdle, account.index, handleSwitchAccount]); + return ( + + + + + + {truncateMiddle(account.address)} + + + + {styles => } + + + {styles => ( + + )} + + + ); + } +); + +const AccountList: React.FC<{ handleClose: () => void }> = memo(({ handleClose }) => { + const accounts = useAccounts(); + return accounts ? ( + <> + {accounts.map(account => { + return ( + + ); + })} + + ) : null; +}); + export const SwitchAccounts: React.FC = memo(({ close }) => { - const setAccountDrawerStep = useSetRecoilState(accountDrawerStep); + const setAccountDrawerStep = useUpdateAtom(accountDrawerStep); return ( <> diff --git a/src/components/drawer/confirm-send-drawer.tsx b/src/components/drawer/confirm-send-drawer.tsx index 12fb0768..32ca13bc 100644 --- a/src/components/drawer/confirm-send-drawer.tsx +++ b/src/components/drawer/confirm-send-drawer.tsx @@ -32,7 +32,7 @@ const TransactionDetails: React.FC< } & StackProps > = ({ amount, nonce, fee, recipient, ...rest }) => { const { ticker } = useSelectedAsset(); - const { stxAddress } = useCurrentAccount(); + const currentAccount = useCurrentAccount(); const { selectedAsset } = useSelectedAsset(); const gradientString = `${selectedAsset?.contractAddress}.${selectedAsset?.contractName}::${selectedAsset?.name}`; return ( @@ -49,7 +49,9 @@ const TransactionDetails: React.FC< icon={selectedAsset?.contractAddress ? gradientString : 'STX'} ticker={ticker || 'STX'} title="You will transfer exactly" - left={stxAddress ? `From ${truncateMiddle(stxAddress)}` : undefined} + left={ + currentAccount?.address ? `From ${truncateMiddle(currentAccount?.address)}` : undefined + } right={`To ${truncateMiddle(recipient)}`} /> diff --git a/src/components/drawer/controlled.tsx b/src/components/drawer/controlled.tsx index 57067d63..81d659d8 100644 --- a/src/components/drawer/controlled.tsx +++ b/src/components/drawer/controlled.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react'; -import { RecoilState, useRecoilState } from 'recoil'; -import { BaseDrawer } from '.'; -interface RecoilControlledDrawerProps { +import { BaseDrawer } from '.'; +import { useAtom, WritableAtom } from 'jotai'; + +interface ControlledDrawerProps { /** The Recoil atom used to represent the visibility state of this drawer */ - state: RecoilState; + state: WritableAtom; /** An optional callback that is fired _after_ visibility has been turned off. */ close?: () => void; title: string; @@ -14,20 +15,20 @@ interface RecoilControlledDrawerProps { * `ControlledDrawer` is a wrapper around our `BaseDrawer` component. * It expects a recoil atom to be used that manages the visibility of this drawer. */ -export const ControlledDrawer: React.FC = ({ +export const ControlledDrawer: React.FC = ({ state, close: _close, title, children, }) => { - const [showing, setShowing] = useRecoilState(state); + const [isShowing, setShowing] = useAtom(state); const close = useCallback(() => { setShowing(false); _close?.(); }, [setShowing, _close]); return ( - + {children} ); diff --git a/src/components/drawer/networks-drawer.tsx b/src/components/drawer/networks-drawer.tsx index 5f8ae922..d6e9d7a4 100644 --- a/src/components/drawer/networks-drawer.tsx +++ b/src/components/drawer/networks-drawer.tsx @@ -1,5 +1,5 @@ import React, { memo, useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; + import { Box, Flex, Button, Stack, color, FlexProps, BoxProps } from '@stacks/ui'; import { ControlledDrawer } from './controlled'; import { useWallet } from '@common/hooks/use-wallet'; @@ -11,11 +11,12 @@ import { showNetworksStore } from '@store/ui'; import { useDrawers } from '@common/hooks/use-drawers'; import { Caption, Title } from '@components/typography'; import { getUrlHostname } from '@common/utils'; +import { useUpdateAtom } from 'jotai/utils'; const NetworkListItem: React.FC<{ item: string } & BoxProps> = memo(({ item, ...props }) => { const { setShowNetworks } = useDrawers(); const { networks, currentNetworkKey } = useWallet(); - const setCurrentNetworkKey = useSetRecoilState(currentNetworkKeyState); + const setCurrentNetworkKey = useUpdateAtom(currentNetworkKeyState); const network = networks[item]; const delayToShowCheckmarkMotion = 350; diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index 3a679f69..78c94faa 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -1,13 +1,8 @@ +// @ts-nocheck + import React from 'react'; -import { PopupContainer } from '@components/popup/container'; -import { Box, Text, Button } from '@stacks/ui'; + import { useLoadable } from '@common/hooks/use-loadable'; -import { accountDataState } from '@store/accounts'; -import { walletState } from '@store/wallet'; -import { useRecoilValue } from 'recoil'; -import { Header } from '@components/header'; -import { useTransactionRequest } from '@common/hooks/use-transaction-request'; -import { signedTransactionState } from '@store/transactions'; const openGithubIssue = (loadable: ReturnType) => { const issueParams = new URLSearchParams(); @@ -53,69 +48,55 @@ type Loadables = ReturnType[]; * */ export const ErrorBoundary: React.FC = ({ children }) => { - const pendingTransaction = useTransactionRequest(); - const wallet = useRecoilValue(walletState); - let loadables: Loadables = []; - const walletLoadables: Loadables = [useLoadable(accountDataState)]; - const txLoadables: Loadables = [useLoadable(signedTransactionState)]; - - if (wallet) { - loadables = loadables.concat(walletLoadables); - } - - if (pendingTransaction) { - loadables = loadables.concat(txLoadables); - } - - const errorLoadables = loadables.filter(loadable => !!loadable.errorMaybe()); - - if (errorLoadables.length > 0) { - const [loadable] = errorLoadables; - const error = errorLoadables[0].errorOrThrow(); - return ( - }> - - - {String(error)} - - - - - Version: - - - {VERSION} - - - - - Branch: - - - {BRANCH} - - - - - Commit: - - - {COMMIT_SHA} - - - - - If you believe this was caused by a bug in our code, please file an issue on Github. - - - - - - - ); + // if (errorLoadables.length > 0) { + // const [loadable] = errorLoadables; + // const error = errorLoadables[0].errorOrThrow(); + // return ( + // }> + // + // + // {String(error)} + // + // + // + // + // Version: + // + // + // {VERSION} + // + // + // + // + // Branch: + // + // + // {BRANCH} + // + // + // + // + // Commit: + // + // + // {COMMIT_SHA} + // + // + // + // + { + /* If you believe this was caused by a bug in our code, please file an issue on Github.*/ } + // + // + // + // + // + // + // ); + // } return <>{children}; }; diff --git a/src/components/home/components/actions.tsx b/src/components/home/components/actions.tsx new file mode 100644 index 00000000..c2778a8a --- /dev/null +++ b/src/components/home/components/actions.tsx @@ -0,0 +1,74 @@ +import { Box, Button, ButtonProps, Stack, StackProps } from '@stacks/ui'; +import { ScreenPaths } from '@store/common/types'; +import React, { memo, useCallback, useRef } from 'react'; +import { useDoChangeScreen } from '@common/hooks/use-do-change-screen'; +import { IconArrowUp, IconQrcode } from '@tabler/icons'; +import { useTransferableAssets } from '@common/hooks/use-assets'; + +interface TxButtonProps extends ButtonProps { + kind: 'send' | 'receive'; + path: ScreenPaths.POPUP_SEND | ScreenPaths.POPUP_RECEIVE; +} + +const TxButton: React.FC = memo(({ kind, path, ...rest }) => { + const ref = useRef(null); + const doChangeScreen = useDoChangeScreen(); + + const isSend = kind === 'send'; + + const handleClick = useCallback(() => { + doChangeScreen(path); + }, [path, doChangeScreen]); + + const label = isSend ? 'Send' : 'Receive'; + return ( + <> + + + ); +}); + +const SendButtonSuspense = () => { + const assets = useTransferableAssets(); + const isDisabled = !assets || assets?.length === 0; + return ; +}; +const SendButtonFallback = memo(() => ( + +)); + +const SendButton = () => ( + }> + + +); + +export const HomeActions: React.FC = props => { + return ( + + + + + ); +}; diff --git a/src/components/home/components/user-area.tsx b/src/components/home/components/user-area.tsx new file mode 100644 index 00000000..4aefd34c --- /dev/null +++ b/src/components/home/components/user-area.tsx @@ -0,0 +1,46 @@ +import { color, Box, Stack, StackProps, useClipboard } from '@stacks/ui'; +import { Caption } from '@components/typography'; +import React, { memo } from 'react'; +import { useCurrentAccount } from '@common/hooks/account/use-current-account'; +import { Tooltip } from '@components/tooltip'; +import { truncateMiddle } from '@stacks/ui-utils'; +import { FiCopy } from 'react-icons/fi'; +import { CurrentUserAvatar } from '@components/current-user/current-user-avatar'; +import { CurrentUsername } from '@components/current-user/current-user-name'; + +const UserAddress = memo((props: StackProps) => { + const currentAccount = useCurrentAccount(); + const { onCopy, hasCopied } = useClipboard(currentAccount?.address || ''); + return currentAccount ? ( + + + {truncateMiddle(currentAccount.address)} + + + + + ) : null; +}); + +export const UserAccount: React.FC = memo(props => { + const currentAccount = useCurrentAccount(); + if (!currentAccount) { + console.error('Error! Homepage rendered without account state, which should never happen.'); + return null; + } + return ( + + + + + + + + ); +}); diff --git a/src/components/network-mode-badge.tsx b/src/components/network-mode-badge.tsx index 7aa17e9c..63d94137 100644 --- a/src/components/network-mode-badge.tsx +++ b/src/components/network-mode-badge.tsx @@ -1,13 +1,12 @@ import React, { memo, useMemo } from 'react'; import { Box, color, Flex, FlexProps, Text } from '@stacks/ui'; -import { useRecoilValue } from 'recoil'; -import { currentNetworkState } from '@store/networks'; import { ChainID } from '@stacks/transactions'; import { IconFlask } from '@tabler/icons'; import { useDrawers } from '@common/hooks/use-drawers'; +import { useCurrentNetwork } from '@common/hooks/use-current-network'; export const NetworkModeBadge: React.FC = memo(props => { - const { chainId } = useRecoilValue(currentNetworkState); + const { chainId } = useCurrentNetwork(); const isTestnet = useMemo(() => chainId === ChainID.Testnet, [chainId]); const { setShowNetworks } = useDrawers(); diff --git a/src/components/popup/balances-and-activity.tsx b/src/components/popup/balances-and-activity.tsx index 81815b96..33a86104 100644 --- a/src/components/popup/balances-and-activity.tsx +++ b/src/components/popup/balances-and-activity.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react'; -import { Box, Stack, SlideFade } from '@stacks/ui'; +import React, { memo, useMemo } from 'react'; +import { Box, Stack, SlideFade, Flex, Spinner, color } from '@stacks/ui'; import type { StackProps } from '@stacks/ui'; import { TokenAssets } from '@components/popup/token-assets'; @@ -26,22 +26,24 @@ function EmptyActivity() { ); } -function ActivityList() { - const { isLoading, value: transactions } = useAccountActivity(); - +const ActivityList = memo(() => { + const transactions = useAccountActivity(); const groupedTxs = useMemo( () => (transactions ? createTxDateFormatList(transactions) : []), [transactions] ); - - if (isLoading) return null; - return !transactions || transactions.length === 0 ? ( ) : ( ); -} +}); + +const Loading = memo(() => ( + + + +)); export function BalancesAndActivity(props: StackProps) { const { activeTab, setActiveTab } = useHomeTabs(); @@ -55,20 +57,23 @@ export function BalancesAndActivity(props: StackProps) { activeTab={activeTab} onTabClick={setActiveTab} /> - - - {styles => ( - - )} - - - {styles => ( - - - - )} - - + + + }> + + {styles => ( + + )} + + + {styles => ( + + + + )} + + + ); } diff --git a/src/components/popup/collectible-assets.tsx b/src/components/popup/collectible-assets.tsx index 37054e03..83e9d2f5 100644 --- a/src/components/popup/collectible-assets.tsx +++ b/src/components/popup/collectible-assets.tsx @@ -8,9 +8,9 @@ import { Stack } from '@stacks/ui'; export const CollectibleAssets = memo((props: StackProps) => { const accountData = useFetchAccountData(); - if (!accountData.value) return null; + if (!accountData) return null; - const balances = accountData.value.balances; + const balances = accountData.balances; const noCollectibles = Object.keys(balances.non_fungible_tokens).length === 0; if (noCollectibles) return null; diff --git a/src/components/popup/container.tsx b/src/components/popup/container.tsx index 12eda38a..33437e38 100644 --- a/src/components/popup/container.tsx +++ b/src/components/popup/container.tsx @@ -1,6 +1,5 @@ -import React, { memo, useCallback, useEffect } from 'react'; -import { Flex, color, Spinner } from '@stacks/ui'; -import { SettingsPopover } from './settings-popover'; +import React, { useCallback, useEffect } from 'react'; +import { Flex, color } from '@stacks/ui'; import { useWallet } from '@common/hooks/use-wallet'; import { useOnCancel } from '@common/hooks/use-on-cancel'; import { usePendingTransaction } from '@pages/transaction-signing/hooks/use-pending-transaction'; @@ -8,40 +7,51 @@ import { useAuthRequest } from '@common/hooks/auth/use-auth-request'; interface PopupHomeProps { header?: any; - requestType?: string; + // TODO: remove the need for prop drilling this + requestType?: 'transaction' | 'auth'; } -const Loading = memo(() => ( - - - -)); +const UnmountEffectSuspense = ({ + requestType, +}: { + requestType?: PopupHomeProps['requestType']; +}) => { + const pendingTx = usePendingTransaction(); + const { authRequest } = useAuthRequest(); + const handleCancelTransaction = useOnCancel(); + const { handleCancelAuthentication } = useWallet(); -export const PopupContainer: React.FC = memo( - ({ children, header, requestType }) => { - const pendingTx = usePendingTransaction(); - const { authRequest } = useAuthRequest(); - const handleCancelTransaction = useOnCancel(); - const { handleCancelAuthentication, hasRehydratedVault } = useWallet(); + /* + * When the popup is closed, this checks the requestType and forces + * the request promise to fail; triggering an onCancel callback function. + */ + const handleUnmount = useCallback(async () => { + if (requestType === 'transaction' || !!pendingTx) { + await handleCancelTransaction(); + } else if (requestType === 'auth' || !!authRequest) { + handleCancelAuthentication(); + } + }, [requestType, handleCancelAuthentication, authRequest, pendingTx, handleCancelTransaction]); - /* - * When the popup is closed, this checks the requestType and forces - * the request promise to fail; triggering an onCancel callback function. - */ - const handleUnmount = useCallback(async () => { - if (requestType === 'transaction' || !!pendingTx) { - await handleCancelTransaction(); - } else if (requestType === 'auth' || !!authRequest) { - handleCancelAuthentication(); - } - }, [requestType, handleCancelAuthentication, authRequest, pendingTx, handleCancelTransaction]); + useEffect(() => { + window.addEventListener('beforeunload', handleUnmount); + return () => window.removeEventListener('beforeunload', handleUnmount); + }, [handleUnmount]); - useEffect(() => { - window.addEventListener('beforeunload', handleUnmount); - return () => window.removeEventListener('beforeunload', handleUnmount); - }, [handleUnmount]); + return null; +}; - return ( +const UnmountEffect = ({ requestType }: { requestType?: PopupHomeProps['requestType'] }) => ( + }> + + +); + +export const PopupContainer: React.FC = ({ children, header, requestType }) => { + const { hasRehydratedVault } = useWallet(); + return hasRehydratedVault ? ( + <> + = memo( position="relative" overflow="auto" > - {header && header} - + {header || null} = memo( px="loose" pb="loose" > - {hasRehydratedVault ? children : } + {children} - ); - } -); + + ) : null; +}; diff --git a/src/components/popup/token-assets.tsx b/src/components/popup/token-assets.tsx index d27b851f..c11f3ced 100644 --- a/src/components/popup/token-assets.tsx +++ b/src/components/popup/token-assets.tsx @@ -1,50 +1,34 @@ import React, { memo } from 'react'; -import { Box, Stack, StackProps, Circle, color, Button, useClipboard } from '@stacks/ui'; +import { Stack, StackProps, color, Button, useClipboard } from '@stacks/ui'; import { AssetRow } from '../asset-row'; import { useFungibleTokenState, useStxTokenState } from '@common/hooks/use-assets'; import { Caption } from '@components/typography'; -import { SpaceBetween } from '@components/space-between'; import { CollectibleAssets } from '@components/popup/collectible-assets'; import { useAccountBalances } from '@common/hooks/account/use-account-balances'; import { NoAssetsEmptyIllustration } from '@components/vector/no-assets'; import { useCurrentAccount } from '@common/hooks/account/use-current-account'; -const LoadingAssetRowItem = memo((props: StackProps) => ( - - - - - - - - - - -)); - function FungibleAssets(props: StackProps) { const fungibleTokens = useFungibleTokenState(); const balances = useAccountBalances(); if (!balances) return null; - const fungibleTokensLoading = !fungibleTokens.value && fungibleTokens.state === 'loading'; - const ftCount = Object.keys(balances.fungible_tokens); const noTokens = ftCount.length === 0; - if (noTokens) return null; + if (noTokens || !fungibleTokens) return null; return ( - {fungibleTokensLoading - ? ftCount.map(item => ) - : fungibleTokens?.value?.map(asset => )} + {fungibleTokens?.map(asset => ( + + ))} ); } function NoAssets(props: StackProps) { - const { stxAddress } = useCurrentAccount(); - const { onCopy, hasCopied } = useClipboard(stxAddress || ''); + const currentAccount = useCurrentAccount(); + const { onCopy, hasCopied } = useClipboard(currentAccount?.address || ''); return ( = memo(({ ...props }) => { if (!balances) return null; const noAssets = - !stxTokens.value && + !stxTokens && Object.keys(balances.fungible_tokens).length === 0 && Object.keys(balances.non_fungible_tokens).length === 0; @@ -84,7 +68,7 @@ export const TokenAssets: React.FC = memo(({ ...props }) => { ) : ( - {stxTokens.value && } + {stxTokens && } diff --git a/src/components/send/amount-field.tsx b/src/components/send/amount-field.tsx index a57a432b..1660bceb 100644 --- a/src/components/send/amount-field.tsx +++ b/src/components/send/amount-field.tsx @@ -45,14 +45,14 @@ export const AmountField = memo( width="100%" placeholder={placeholder || 'Select an asset first'} min="0" - autoFocus={assets.value?.length === 1} + autoFocus={assets?.length === 1} value={value === 0 ? '' : value} onKeyDown={handleOnKeyDown} onChange={onChange} autoComplete="off" name="amount" /> - {balances.value && selectedAsset ? ( + {balances && selectedAsset ? ( { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const doChangeScreen = useDoChangeScreen(); - const setNetworks = useSetRecoilState(networksState); - const setNetworkKey = useSetRecoilState(currentNetworkKeyState); + const setNetworks = useUpdateAtom(networksState); + const setNetworkKey = useUpdateAtom(currentNetworkKeyState); return ( = memo(({ onClick }) => { - const { currentAccountStxAddress } = useWallet(); - const { onCopy, hasCopied } = useClipboard(currentAccountStxAddress || ''); - return ( - <> - { - onCopy(); - onClick?.(event); - }} - position="absolute" - right={0} - zIndex={3} - > - - - - - - - - ); -}); - -const TxButton: React.FC = memo(({ kind, path, ...rest }) => { - const ref = useRef(null); - const doChangeScreen = useDoChangeScreen(); - const assets = useAssets(); - - const isSend = kind === 'send'; - const sendDisabled = !assets.value || (isSend && assets.value?.length === 0); - - const handleClick = useCallback(() => { - doChangeScreen(path); - }, [path, doChangeScreen]); - - const handleFocus = useCallback(() => { - ref?.current?.focus(); - }, [ref]); - - const label = isSend ? 'Send' : 'Receive'; - return ( - <> - - - ); -}); - -const UserAccount: React.FC = memo(props => { - const names = useAccountNames(); - const { currentAccount, currentAccountStxAddress } = useWallet(); - if (!currentAccount || !currentAccountStxAddress) { - console.error('Error! Homepage rendered without account state, which should never happen.'); - return null; - } - const nameCharLimit = 18; - const name = - names?.value?.[currentAccount.index]?.names?.[0] || getAccountDisplayName(currentAccount); - const isLong = name.length > nameCharLimit; - const displayName = truncateString(name, nameCharLimit); - - return ( - - - - - - - {displayName} - - - - {truncateMiddle(currentAccountStxAddress, 8)} - - - ); -}); - -const Actions: React.FC = memo(props => { - return ( - - - - - ); -}); - -const PageTop: React.FC = memo(props => ( +const PageTop: React.FC = props => ( - + -)); +); -export const PopupHome: React.FC = memo(() => ( +export const PopupHome: React.FC = () => ( } requestType="auth"> - ; + -)); +); diff --git a/src/pages/send-tokens/send-tokens.tsx b/src/pages/send-tokens/send-tokens.tsx index d97d7b15..ecd43c39 100644 --- a/src/pages/send-tokens/send-tokens.tsx +++ b/src/pages/send-tokens/send-tokens.tsx @@ -12,7 +12,6 @@ import { AssetSearch } from '@components/asset-search/asset-search'; import { Header } from '@components/header'; import { useSelectedAsset } from '@common/hooks/use-selected-asset'; -import { useRevalidateApi } from '@common/hooks/use-revalidate-api'; import { useSendFormValidation } from '@common/hooks/use-send-form-validation'; import { AmountField } from '@components/send/amount-field'; import { RecipientField } from '@components/send/recipient-field'; @@ -40,7 +39,6 @@ const Form = ({ setAssetError: (error: string | undefined) => void; } & FormikProps) => { const doChangeScreen = useDoChangeScreen(); - const revalidate = useRevalidateApi(); const { selectedAsset } = useSelectedAsset(); const onChange = useCallback( @@ -53,10 +51,9 @@ const Form = ({ const onSubmit = useCallback(async () => { if (values.amount && values.recipient && selectedAsset) { - await revalidate(); // we want up to date data handleSubmit(); } - }, [revalidate, handleSubmit, values, selectedAsset]); + }, [handleSubmit, values, selectedAsset]); const onItemClick = useCallback(() => { setValues({ ...values, amount: '' }); diff --git a/src/pages/sign-up/create.tsx b/src/pages/sign-up/create.tsx index c0b1e3fb..de96d80a 100644 --- a/src/pages/sign-up/create.tsx +++ b/src/pages/sign-up/create.tsx @@ -5,8 +5,9 @@ import { Screen, ScreenBody, PoweredBy, ScreenFooter, ScreenHeader } from '@scre import { useAppDetails } from '@common/hooks/auth/use-app-details'; import { useWallet } from '@common/hooks/use-wallet'; -import { useSetRecoilState } from 'recoil'; + import { onboardingProgressState } from '@store/onboarding'; +import { useUpdateAtom } from 'jotai/utils'; interface ExplainerCardProps { title: string; @@ -57,7 +58,7 @@ export const Create: React.FC = props => { const [cardIndex, setCardIndex] = useState(0); const { wallet, doMakeWallet } = useWallet(); const { name } = useAppDetails(); - const doSetOnboardingProgress = useSetRecoilState(onboardingProgressState); + const doSetOnboardingProgress = useUpdateAtom(onboardingProgressState); const { next } = props; const explainerData: ExplainerCardProps[] = useMemo( diff --git a/src/pages/transaction-signing/components/actions.tsx b/src/pages/transaction-signing/components/actions.tsx index ef32195e..cb779b5c 100644 --- a/src/pages/transaction-signing/components/actions.tsx +++ b/src/pages/transaction-signing/components/actions.tsx @@ -1,21 +1,22 @@ import React, { memo, useCallback } from 'react'; -import { Box, Button, color, Stack, StackProps } from '@stacks/ui'; +import { Box, Button, ButtonProps, color, Stack, StackProps } from '@stacks/ui'; import { LOADING_KEYS, useLoading } from '@common/hooks/use-loading'; import { SpaceBetween } from '@components/space-between'; import { Caption } from '@components/typography'; import { NetworkRowItem } from '@components/network-row-item'; import { FeeComponent } from '@pages/transaction-signing/components/fee'; import { FiAlertTriangle } from 'react-icons/fi'; -import { useRecoilValue } from 'recoil'; + import { useTransactionBroadcast } from '@pages/transaction-signing/hooks/use-transaction-broadcast'; import { transactionBroadcastErrorState } from '@store/transactions'; import { useTransactionError } from '../hooks/use-transaction-error'; -import { useSignedTransaction } from '../hooks/use-transaction'; import { TransactionErrorReason } from './transaction-error'; +import { useAtomValue } from 'jotai/utils'; +import { LoadingRectangle } from '@components/loading-rectangle'; -const MinimalErrorMessage = memo((props: StackProps) => { +const MinimalErrorMessageSuspense = memo((props: StackProps) => { const error = useTransactionError(); - const broadcastError = useRecoilValue(transactionBroadcastErrorState); + const broadcastError = useAtomValue(transactionBroadcastErrorState); if (!error) return null; @@ -45,13 +46,26 @@ const MinimalErrorMessage = memo((props: StackProps) => { ); }); -export const TransactionsActions = memo((props: StackProps) => { +export const MinimalErrorMessage = memo((props: StackProps) => { + return ( + }> + + + ); +}); + +const BaseConfirmButton = (props: ButtonProps) => ( + +); + +const SubmitActionSuspense = (props: ButtonProps) => { const handleBroadcastTransaction = useTransactionBroadcast(); - const signedTransaction = useSignedTransaction(); const error = useTransactionError(); const { setIsLoading, setIsIdle, isLoading } = useLoading(LOADING_KEYS.SUBMIT_TRANSACTION); - - const isDisabled = !!error || !signedTransaction.value; + // + const isDisabled = !!error; const handleSubmit = useCallback(async () => { setIsLoading(); @@ -60,30 +74,61 @@ export const TransactionsActions = memo((props: StackProps) => { }, [setIsLoading, setIsIdle, handleBroadcastTransaction]); return ( - - - - Fees - - - - - - Network - - - - - - + + Confirm + ); -}); +}; +const SubmitAction = (props: ButtonProps) => { + return ( + }> + + + ); +}; + +const FeeRowItemSuspense = () => { + return ( + + Fees + + + + + ); +}; +const FeeRowItemFallback = () => { + return ( + + Fees + + + ); +}; + +const FeeRowItem = () => { + return ( + }> + + + ); +}; + +export const TransactionsActions = () => { + return ( + <> + + + Network + + + + + + ); +}; diff --git a/src/pages/transaction-signing/components/asset-item.tsx b/src/pages/transaction-signing/components/asset-item.tsx index cefb311c..50c27dc4 100644 --- a/src/pages/transaction-signing/components/asset-item.tsx +++ b/src/pages/transaction-signing/components/asset-item.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Stack, StackProps, Text } from '@stacks/ui'; import { AssetAvatar } from '@components/stx-avatar'; +import { SpaceBetween } from '@components/space-between'; export const AssetItem: React.FC< StackProps & { @@ -10,14 +11,16 @@ export const AssetItem: React.FC< } > = ({ iconString, amount, ticker, ...rest }) => { return ( - - + + + + + {ticker} + + - {ticker} - - {amount} - + ); }; diff --git a/src/pages/transaction-signing/components/contract-call-details.tsx b/src/pages/transaction-signing/components/contract-call-details.tsx index b33b7724..edfd56ed 100644 --- a/src/pages/transaction-signing/components/contract-call-details.tsx +++ b/src/pages/transaction-signing/components/contract-call-details.tsx @@ -7,11 +7,10 @@ import { useExplorerLink } from '@common/hooks/use-explorer-link'; import { Caption, Title } from '@components/typography'; import { ContractPreview } from '@pages/transaction-signing/components/contract-preview'; import { useTransactionRequest } from '@common/hooks/use-transaction-request'; -import { useLoadable } from '@common/hooks/use-loadable'; -import { transactionFunctionsState } from '@store/transactions/contract-call'; import { AttachmentRow } from './attachment-row'; import { RowItem } from './row-item'; +import { useTransactionFunction } from '@pages/transaction-signing/hooks/use-transaction'; interface ArgumentProps { arg: string; @@ -19,10 +18,10 @@ interface ArgumentProps { } const FunctionArgumentRow: React.FC = memo(({ arg, index, ...rest }) => { - const payload = useLoadable(transactionFunctionsState); + const txFunction = useTransactionFunction(); const argCV = deserializeCV(Buffer.from(arg, 'hex')); const strValue = cvToString(argCV); - const name = payload.value?.args[index].name || null; + const name = txFunction?.args[index].name || null; return ; }); @@ -35,19 +34,25 @@ const FunctionArgumentsList = memo((props: StackProps) => { } const hasArgs = transactionRequest.functionArgs.length > 0; return ( - } spacing="base" {...props}> + <> {hasArgs ? ( - transactionRequest.functionArgs.map((arg, index) => { - return ; - }) + } spacing="base" {...props}> + {transactionRequest.functionArgs.map((arg, index) => { + return ( + loading}> + + + ); + })} + ) : ( There are no additional arguments passed for this function call. )} - + ); }); -export const ContractCallDetails = memo(() => { +export const ContractCallDetailsSuspense = () => { const transactionRequest = useTransactionRequest(); const { handleOpenTxLink } = useExplorerLink(); if (!transactionRequest || transactionRequest.txType !== 'contract_call') return null; @@ -78,4 +83,10 @@ export const ContractCallDetails = memo(() => { ); -}); +}; + +export const ContractCallDetails = () => ( + }> + + +); diff --git a/src/pages/transaction-signing/components/fee.tsx b/src/pages/transaction-signing/components/fee.tsx index 392be342..2a60ee74 100644 --- a/src/pages/transaction-signing/components/fee.tsx +++ b/src/pages/transaction-signing/components/fee.tsx @@ -1,11 +1,9 @@ -import React, { memo } from 'react'; -import { LoadingRectangle } from '@components/loading-rectangle'; +import React from 'react'; import { stacksValue } from '@common/stacks-utils'; import { useTransactionFee } from '@pages/transaction-signing/hooks/use-transaction-fee'; -export const FeeComponent = memo(() => { - const { isLoading, isSponsored, amount } = useTransactionFee(); - if (isLoading) return ; +export const FeeComponent = () => { + const { isSponsored, amount } = useTransactionFee(); if (typeof amount === 'undefined') return null; return ( <> @@ -17,4 +15,4 @@ export const FeeComponent = memo(() => { })} ); -}); +}; diff --git a/src/pages/transaction-signing/components/popup-header.tsx b/src/pages/transaction-signing/components/popup-header.tsx index 97bfe5b3..1442a488 100644 --- a/src/pages/transaction-signing/components/popup-header.tsx +++ b/src/pages/transaction-signing/components/popup-header.tsx @@ -1,52 +1,61 @@ -import { useWallet } from '@common/hooks/use-wallet'; -import { useFetchBalances } from '@common/hooks/account/use-account-info'; -import { Box, color, Stack, Text } from '@stacks/ui'; -import { AccountAvatar } from '@components/account-avatar'; -import { Caption, Title } from '@components/typography'; -import { truncateMiddle } from '@stacks/ui-utils'; -import { stacksValue } from '@common/stacks-utils'; -import { LoadingRectangle } from '@components/loading-rectangle'; -import React from 'react'; -import { useAccountDisplayName } from '@common/hooks/account/use-account-names'; +import { Box, color, Stack } from '@stacks/ui'; +import { Caption } from '@components/typography'; -export function PopupHeader() { - const { currentAccount, currentAccountStxAddress } = useWallet(); - const balances = useFetchBalances(); - const displayName = useAccountDisplayName(); - if (!currentAccount || !currentAccountStxAddress) return null; +import { stacksValue } from '@common/stacks-utils'; +import React from 'react'; + +import { CurrentUserAvatar } from '@components/current-user/current-user-avatar'; +import { CurrentUsername } from '@components/current-user/current-user-name'; +import { CurrentStxAddress } from '@components/current-user/current-stx-address'; +import { useAccountInfo } from '@common/hooks/account/use-account-balances'; +import { LoadingRectangle } from '@components/loading-rectangle'; + +const Balance = () => { + const info = useAccountInfo(); + return info?.balance ? ( + + {stacksValue({ + value: info.balance.toString(10), + withTicker: true, + })} + + ) : null; +}; + +export function PopupHeaderFallback() { return ( - - {displayName} - - {truncateMiddle(currentAccountStxAddress, 4)} - + + + - - {balances.value ? ( - - {stacksValue({ - value: balances.value.stx.balance, - withTicker: true, - abbreviate: true, - })} - - ) : ( - - )} - + ); } + +export function PopupHeaderSuspense() { + return ( + + + + + + + + + + + ); +} + +export const PopupHeader = () => { + return ( + }> + + + ); +}; diff --git a/src/pages/transaction-signing/components/post-conditions/list.tsx b/src/pages/transaction-signing/components/post-conditions/list.tsx index 53f6b276..1aa04475 100644 --- a/src/pages/transaction-signing/components/post-conditions/list.tsx +++ b/src/pages/transaction-signing/components/post-conditions/list.tsx @@ -1,17 +1,17 @@ -import React, { memo, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { Box, Circle, color, Flex, Stack } from '@stacks/ui'; -import { useRecoilValue } from 'recoil'; + import { TransactionTypes } from '@stacks/connect'; import { stacksValue } from '@common/stacks-utils'; import { IconLock } from '@tabler/icons'; import { Body } from '@components/typography'; import { truncateMiddle } from '@stacks/ui-utils'; -import { useLoadable } from '@common/hooks/use-loadable'; import { postConditionsState } from '@store/transactions'; -import { useTransactionRequest } from '../../../../common/hooks/use-transaction-request'; +import { useTransactionRequest } from '@common/hooks/use-transaction-request'; import { TransactionEventCard } from '../event-card'; import { PostConditionComponent } from './single'; +import { useAtomValue } from 'jotai/utils'; function StxPostcondition() { const pendingTransaction = useTransactionRequest(); @@ -49,24 +49,24 @@ function NoPostconditions() { ); } -const PostConditionsList = memo(() => { - const postConditions = useRecoilValue(postConditionsState); +const PostConditionsList = () => { + const postConditions = useAtomValue(postConditionsState); return ( <> {postConditions?.map((pc, index) => ( ))} ); -}); +}; -export const PostConditions: React.FC = memo(() => { - const { value: postConditions } = useLoadable(postConditionsState); +export const PostConditionsSuspense: React.FC = () => { + const postConditions = useAtomValue(postConditionsState); const pendingTransaction = useTransactionRequest(); const hasPostConditions = useMemo( () => postConditions && postConditions?.length > 0, @@ -84,7 +84,6 @@ export const PostConditions: React.FC = memo(() => { borderRadius="12px" width="100%" flexDirection="column" - my="loose" > {hasPostConditions ? ( @@ -95,4 +94,12 @@ export const PostConditions: React.FC = memo(() => { )} ); -}); +}; + +export const PostConditions = () => { + return ( + }> + + + ); +}; diff --git a/src/pages/transaction-signing/components/post-conditions/single.tsx b/src/pages/transaction-signing/components/post-conditions/single.tsx index efd98e51..1ca5eda7 100644 --- a/src/pages/transaction-signing/components/post-conditions/single.tsx +++ b/src/pages/transaction-signing/components/post-conditions/single.tsx @@ -8,6 +8,7 @@ import { useCurrentAccount } from '@common/hooks/account/use-current-account'; import { getAmountFromPostCondition, getIconStringFromPostCondition, + getNameFromPostCondition, getPostConditionCodeMessage, getPostConditionTitle, getSymbolFromPostCondition, @@ -21,16 +22,63 @@ interface PostConditionProps { isLast?: boolean; } -export const PostConditionComponent: React.FC = ({ pc, isLast }) => { - const { stxAddress } = useCurrentAccount(); +export const PostConditionFallbackComponent: React.FC = ({ pc, isLast }) => { + const currentAccount = useCurrentAccount(); + const pendingTransaction = useTransactionRequest(); + const title = getPostConditionTitle(pc); + const iconString = getIconStringFromPostCondition(pc); + const ticker = getSymbolFromPostCondition(pc); + const amount = getAmountFromPostCondition(pc); + const name = getNameFromPostCondition(pc); + const address = addressToString(pc.principal.address); + const isSending = address === currentAccount?.address; + + const isContractPrincipal = + (pendingTransaction?.txType == TransactionTypes.ContractCall && + pendingTransaction.contractAddress === address) || + address.includes('.'); + + if (!pendingTransaction) return null; + + const message = pc.conditionCode + ? `${getPostConditionCodeMessage( + pc.conditionCode, + isSending + )} ${amount} ${ticker} or the transaction will abort.` + : undefined; + + return ( + <> + + + ); +}; + +export const PostConditionComponentSuspense: React.FC = ({ pc, isLast }) => { + const currentAccount = useCurrentAccount(); const asset = useAssetInfoFromPostCondition(pc); const pendingTransaction = useTransactionRequest(); const title = getPostConditionTitle(pc); const iconString = getIconStringFromPostCondition(pc); const _ticker = getSymbolFromPostCondition(pc); const _amount = getAmountFromPostCondition(pc); + const name = getNameFromPostCondition(pc); const address = addressToString(pc.principal.address); - const isSending = address === stxAddress; + const isSending = address === currentAccount?.address; const amount = typeof asset?.meta?.decimals === 'number' ? ftDecimals(_amount, asset.meta.decimals) : _amount; @@ -48,7 +96,6 @@ export const PostConditionComponent: React.FC = ({ pc, isLas ? `${getPostConditionCodeMessage( pc.conditionCode, isSending - // TODO: fetch asset info in SIP 10 branch )} ${amount} ${ticker} or the transaction will abort.` : undefined; @@ -58,7 +105,7 @@ export const PostConditionComponent: React.FC = ({ pc, isLas title={`${ isContractPrincipal ? 'The contract ' : isSending ? 'You ' : 'Another address ' } ${title}`} - left={asset?.meta?.name} + left={asset?.meta?.name || name} right={`${isSending ? 'From' : 'To'} ${truncateMiddle( addressToString(pc.principal.address), 4 @@ -72,3 +119,11 @@ export const PostConditionComponent: React.FC = ({ pc, isLas ); }; + +export const PostConditionComponent = (props: PostConditionProps) => { + return ( + }> + + + ); +}; diff --git a/src/pages/transaction-signing/components/transaction-error.tsx b/src/pages/transaction-signing/components/transaction-error.tsx index d008c1ca..11ff01d2 100644 --- a/src/pages/transaction-signing/components/transaction-error.tsx +++ b/src/pages/transaction-signing/components/transaction-error.tsx @@ -19,7 +19,7 @@ export enum TransactionErrorReason { ExpiredRequest = 7, } -export const TransactionError = memo(() => { +export const TransactionErrorSuspense = memo(() => { const reason = useTransactionError(); if (!reason) return null; switch (reason) { @@ -39,3 +39,11 @@ export const TransactionError = memo(() => { return null; } }); + +export const TransactionError = memo(() => { + return ( + }> + + + ); +}); diff --git a/src/pages/transaction-signing/components/transaction-errors.tsx b/src/pages/transaction-signing/components/transaction-errors.tsx index e9ee74cd..0ac0e781 100644 --- a/src/pages/transaction-signing/components/transaction-errors.tsx +++ b/src/pages/transaction-signing/components/transaction-errors.tsx @@ -11,14 +11,15 @@ import { useCurrentNetwork } from '@common/hooks/use-current-network'; import { truncateMiddle } from '@stacks/ui-utils'; import { ErrorMessage } from '@pages/transaction-signing/components/error'; import { useDrawers } from '@common/hooks/use-drawers'; -import { useRecoilValue } from 'recoil'; + import { transactionBroadcastErrorState } from '@store/transactions'; import { useScrollLock } from '@common/hooks/use-scroll-lock'; +import { useAtomValue } from 'jotai/utils'; export const FeeInsufficientFundsErrorMessage = memo(props => { const currentAccount = useCurrentAccount(); const { setShowAccounts } = useDrawers(); - const { onCopy, hasCopied } = useClipboard(currentAccount.address || ''); + const { onCopy, hasCopied } = useClipboard(currentAccount?.address || ''); return ( { const balances = useFetchBalances(); const currentAccount = useCurrentAccount(); const { setShowAccounts } = useDrawers(); - const { onCopy, hasCopied } = useClipboard(currentAccount.address || ''); + const { onCopy, hasCopied } = useClipboard(currentAccount?.address || ''); return ( { Current balance - {balances?.value?.stx?.balance + {balances?.stx?.balance ? stacksValue({ - value: balances?.value?.stx?.balance, + value: balances?.stx?.balance, withTicker: true, }) : '--'} @@ -175,7 +176,7 @@ export const ExpiredRequestErrorMessage = memo(props => { }); export const BroadcastErrorMessage = memo(props => { - const broadcastError = useRecoilValue(transactionBroadcastErrorState); + const broadcastError = useAtomValue(transactionBroadcastErrorState); if (!broadcastError) return null; return ( { const pageTitle = useTransactionPageTitle(); const network = useCurrentNetwork(); if (!transactionRequest) return null; - const appName = transactionRequest?.appDetails?.name; - - const testnetAddition = network.isTestnet ? ( - <> - {' '} - on
- {getUrlHostname(network.url)} - - ) : null; + const originAddition = origin ? ` (${getUrlHostname(origin)})` : ''; + const testnetAddition = network.isTestnet ? ` using ${getUrlHostname(network.url)}` : ''; + const caption = appName ? `Requested by "${appName}"${originAddition}${testnetAddition}` : null; return ( {pageTitle} - {appName ? ( - - Requested by {appName} {origin ? `(${getUrlHostname(origin)})` : null} - {testnetAddition} - - ) : null} + {caption && {caption}} ); }); diff --git a/src/pages/transaction-signing/hooks/use-asset-transfer.ts b/src/pages/transaction-signing/hooks/use-asset-transfer.ts index c5ddd957..f200fe9f 100644 --- a/src/pages/transaction-signing/hooks/use-asset-transfer.ts +++ b/src/pages/transaction-signing/hooks/use-asset-transfer.ts @@ -10,14 +10,16 @@ import { noneCV, PostCondition, someCV, + StacksTransaction, standardPrincipalCVFromAddress, uintCV, } from '@stacks/transactions'; import BN from 'bn.js'; -import { useRecoilCallback } from 'recoil'; import { makeFungibleTokenTransferState } from '@store/transactions/fungible-token-transfer'; import { selectedAssetStore } from '@store/assets/asset-search'; import { ftUnshiftDecimals } from '@common/stacks-utils'; +import { useAtomCallback } from 'jotai/utils'; +import { useCallback } from 'react'; interface PostConditionsOptions { contractAddress: string; @@ -39,67 +41,76 @@ function makePostCondition(options: PostConditionsOptions): PostCondition { ); } -export function useMakeAssetTransfer() { - return useRecoilCallback(({ snapshot }) => async ({ amount, recipient, memo }) => { - const assetTransferState = await snapshot.getPromise(makeFungibleTokenTransferState); - const selectedAsset = await snapshot.getPromise(selectedAssetStore); - if (!assetTransferState || !selectedAsset) return; - const { - balances, - network, - senderKey, - assetName, - contractAddress, - contractName, - nonce, - stxAddress, - } = assetTransferState; - - const functionName = 'transfer'; - - const tokenBalanceKey = Object.keys(balances?.fungible_tokens || {}).find(contract => { - return contract.startsWith(contractAddress); - }); - - const realAmount = - selectedAsset.type === 'ft' - ? ftUnshiftDecimals(amount, selectedAsset?.meta?.decimals || 0) - : amount; - - const postConditionOptions = tokenBalanceKey - ? { - contractAddress, - contractName, - assetName, - stxAddress, - amount: realAmount, - } - : undefined; - - const postConditions = postConditionOptions ? [makePostCondition(postConditionOptions)] : []; - - // (transfer (uint principal principal) (response bool uint)) - const functionArgs: ClarityValue[] = [ - uintCV(realAmount), - standardPrincipalCVFromAddress(createAddress(stxAddress)), - standardPrincipalCVFromAddress(createAddress(recipient)), - ]; - - if (selectedAsset.hasMemo) { - functionArgs.push(memo !== '' ? someCV(bufferCVFromString(memo)) : noneCV()); - } - const txOptions = { - network, - functionName, - functionArgs, - senderKey, - contractAddress, - contractName, - postConditions, - nonce: new BN(nonce, 10), - anchorMode: AnchorMode.Any, - }; - - return makeContractCall(txOptions); - }); +interface AssetTransferOptions { + amount: number; + recipient: string; + memo: string; +} + +export function useMakeAssetTransfer() { + return useAtomCallback( + useCallback(async (get, _set, arg) => { + const { amount, recipient, memo } = arg; + const assetTransferState = get(makeFungibleTokenTransferState); + const selectedAsset = get(selectedAssetStore); + if (!assetTransferState || !selectedAsset) return; + const { + balances, + network, + senderKey, + assetName, + contractAddress, + contractName, + nonce, + stxAddress, + } = assetTransferState; + + const functionName = 'transfer'; + + const tokenBalanceKey = Object.keys(balances?.fungible_tokens || {}).find(contract => { + return contract.startsWith(contractAddress); + }); + + const realAmount = + selectedAsset.type === 'ft' + ? ftUnshiftDecimals(amount, selectedAsset?.meta?.decimals || 0) + : amount; + + const postConditionOptions = tokenBalanceKey + ? { + contractAddress, + contractName, + assetName, + stxAddress, + amount: realAmount, + } + : undefined; + + const postConditions = postConditionOptions ? [makePostCondition(postConditionOptions)] : []; + + // (transfer (uint principal principal) (response bool uint)) + const functionArgs: ClarityValue[] = [ + uintCV(realAmount), + standardPrincipalCVFromAddress(createAddress(stxAddress)), + standardPrincipalCVFromAddress(createAddress(recipient)), + ]; + + if (selectedAsset.hasMemo) { + functionArgs.push(memo !== '' ? someCV(bufferCVFromString(memo)) : noneCV()); + } + const txOptions = { + network, + functionName, + functionArgs, + senderKey, + contractAddress, + contractName, + postConditions, + nonce: new BN(nonce, 10), + anchorMode: AnchorMode.Any, + }; + + return makeContractCall(txOptions); + }, []) + ); } diff --git a/src/pages/transaction-signing/hooks/use-make-stx-transfer.ts b/src/pages/transaction-signing/hooks/use-make-stx-transfer.ts index 9cf731b3..e833a840 100644 --- a/src/pages/transaction-signing/hooks/use-make-stx-transfer.ts +++ b/src/pages/transaction-signing/hooks/use-make-stx-transfer.ts @@ -1,5 +1,4 @@ -import { useRecoilCallback, waitForAll } from 'recoil'; - +import { useAtomCallback, waitForAll } from 'jotai/utils'; import { currentStacksNetworkState } from '@store/networks'; import { currentAccountState } from '@store/accounts'; import { correctNonceState } from '@store/accounts/nonce'; @@ -7,7 +6,7 @@ import { AnchorMode, makeSTXTokenTransfer, StacksTransaction } from '@stacks/tra import BN from 'bn.js'; import { stxToMicroStx } from '@stacks/ui-utils'; import { useLoading } from '@common/hooks/use-loading'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useMakeAssetTransfer } from '@pages/transaction-signing/hooks/use-asset-transfer'; import { useSelectedAsset } from '@common/hooks/use-selected-asset'; @@ -18,28 +17,30 @@ interface TokenTransferParams { } export function useMakeStxTransfer() { - return useRecoilCallback(({ snapshot }) => async (params: TokenTransferParams) => { - const { amount, recipient, memo } = params; - const { network, account, nonce } = await snapshot.getPromise( - waitForAll({ - network: currentStacksNetworkState, - account: currentAccountState, - nonce: correctNonceState, - }) - ); + return useAtomCallback( + useCallback(async (get, _set, arg) => { + const { amount, recipient, memo } = arg; + const { network, account, nonce } = get( + waitForAll({ + network: currentStacksNetworkState, + account: currentAccountState, + nonce: correctNonceState, + }) + ); - if (!account) return; + if (!account) return; - return makeSTXTokenTransfer({ - recipient, - amount: new BN(stxToMicroStx(amount).toString(), 10), - memo, - senderKey: account.stxPrivateKey, - network, - nonce: new BN(nonce.toString(), 10), - anchorMode: AnchorMode.Any, - }); - }); + return makeSTXTokenTransfer({ + recipient, + amount: new BN(stxToMicroStx(amount).toString(), 10), + memo, + senderKey: account.stxPrivateKey, + network, + nonce: new BN(nonce.toString(), 10), + anchorMode: AnchorMode.Any, + }); + }, []) + ); } export function useMakeTransferEffect({ @@ -65,38 +66,20 @@ export function useMakeTransferEffect({ const handleMakeFtTransaction = useMakeAssetTransfer(); const isActive = isShowing && !!amount && !!recipient; const notLoaded = selectedAsset && !transaction && !isLoading; + const method = selectedAsset?.type === 'stx' ? handleMakeStxTransaction : handleMakeFtTransaction; + + const handleGenerateTransfer = useCallback(async () => { + setIsLoading(); + const tx = await method({ + amount, + recipient, + memo, + }); + if (tx) setTransaction(tx); + setIsIdle(); + }, [amount, recipient, memo, method, setTransaction, setIsLoading, setIsIdle]); useEffect(() => { - const method = - selectedAsset?.type === 'stx' ? handleMakeStxTransaction : handleMakeFtTransaction; - if (isActive) { - if (notLoaded) { - setIsLoading(); - void method({ - amount, - recipient, - memo, - }).then(tx => { - if (tx) { - setTransaction(tx); - } - setIsIdle(); - }); - } - } - }, [ - memo, - selectedAsset?.type, - handleMakeFtTransaction, - isActive, - notLoaded, - setTransaction, - amount, - recipient, - setIsLoading, - setIsIdle, - handleMakeStxTransaction, - transaction, - isLoading, - ]); + if (isActive && notLoaded) void handleGenerateTransfer(); + }, [isActive, notLoaded, setIsLoading, handleGenerateTransfer]); } diff --git a/src/pages/transaction-signing/hooks/use-pending-transaction.ts b/src/pages/transaction-signing/hooks/use-pending-transaction.ts index 45dc3586..1826c0bc 100644 --- a/src/pages/transaction-signing/hooks/use-pending-transaction.ts +++ b/src/pages/transaction-signing/hooks/use-pending-transaction.ts @@ -1,6 +1,6 @@ -import { useRecoilValue } from 'recoil'; import { pendingTransactionState } from '@store/transactions'; +import { useAtomValue } from 'jotai/utils'; export function usePendingTransaction() { - return useRecoilValue(pendingTransactionState); + return useAtomValue(pendingTransactionState); } diff --git a/src/pages/transaction-signing/hooks/use-submit-stx-transaction.ts b/src/pages/transaction-signing/hooks/use-submit-stx-transaction.ts index e85403d4..61a66630 100644 --- a/src/pages/transaction-signing/hooks/use-submit-stx-transaction.ts +++ b/src/pages/transaction-signing/hooks/use-submit-stx-transaction.ts @@ -6,13 +6,12 @@ import { import { useDoChangeScreen } from '@common/hooks/use-do-change-screen'; import { useWallet } from '@common/hooks/use-wallet'; import { useLoading } from '@common/hooks/use-loading'; -import { useRecoilValue } from 'recoil'; import { currentStacksNetworkState } from '@store/networks'; import { useCallback } from 'react'; import { ScreenPaths } from '@store/common/types'; -import { useRevalidateApi } from '@common/hooks/use-revalidate-api'; import { toast } from 'react-hot-toast'; import { useHomeTabs } from '@common/hooks/use-home-tabs'; +import { useAtomValue } from 'jotai/utils'; function getErrorMessage( reason: TxBroadcastResultRejected['reason'] | 'ConflictingNonceInMempool' @@ -43,8 +42,7 @@ export function useHandleSubmitTransaction({ const doChangeScreen = useDoChangeScreen(); const { doSetLatestNonce } = useWallet(); const { setIsLoading, setIsIdle } = useLoading(loadingKey); - const stacksNetwork = useRecoilValue(currentStacksNetworkState); - const revalidate = useRevalidateApi(); + const stacksNetwork = useAtomValue(currentStacksNetworkState); const { setActiveTabActivity } = useHomeTabs(); return useCallback(async () => { @@ -56,7 +54,6 @@ export function useHandleSubmitTransaction({ toast.error(getErrorMessage(response.reason)); } else { await doSetLatestNonce(transaction.auth.spendingCondition?.nonce.toNumber()); - await revalidate(); toast.success('Transaction submitted!'); } } catch (e) { @@ -70,7 +67,6 @@ export function useHandleSubmitTransaction({ doChangeScreen(ScreenPaths.HOME); }, [ setActiveTabActivity, - revalidate, doChangeScreen, doSetLatestNonce, setIsLoading, diff --git a/src/pages/transaction-signing/hooks/use-transaction-broadcast.ts b/src/pages/transaction-signing/hooks/use-transaction-broadcast.ts index c665d146..3f11ea4b 100644 --- a/src/pages/transaction-signing/hooks/use-transaction-broadcast.ts +++ b/src/pages/transaction-signing/hooks/use-transaction-broadcast.ts @@ -1,5 +1,5 @@ import { useWallet } from '@common/hooks/use-wallet'; -import { useRecoilCallback, waitForAll } from 'recoil'; +import { useAtomCallback, waitForAll } from 'jotai/utils'; import { currentAccountState } from '@store/accounts'; import { transactionAttachmentState, @@ -10,22 +10,22 @@ import { requestTokenState } from '@store/transactions/requests'; import { currentNetworkState } from '@store/networks'; import { finalizeTxSignature } from '@common/utils'; import { handleBroadcastTransaction } from '@common/transactions/transactions'; +import { useCallback } from 'react'; export function useTransactionBroadcast() { const { doSetLatestNonce } = useWallet(); - return useRecoilCallback( - ({ snapshot, set }) => - async () => { - const { account, signedTransaction, attachment, requestToken, network } = - await snapshot.getPromise( - waitForAll({ - signedTransaction: signedTransactionState, - account: currentAccountState, - attachment: transactionAttachmentState, - requestToken: requestTokenState, - network: currentNetworkState, - }) - ); + return useAtomCallback( + useCallback( + async (get, set) => { + const { account, signedTransaction, attachment, requestToken, network } = get( + waitForAll({ + signedTransaction: signedTransactionState, + account: currentAccountState, + attachment: transactionAttachmentState, + requestToken: requestTokenState, + network: currentNetworkState, + }) + ); if (!account || !requestToken || !signedTransaction) { set(transactionBroadcastErrorState, 'No pending transaction found.'); @@ -41,13 +41,14 @@ export function useTransactionBroadcast() { attachment, networkUrl: network.url, }); - await doSetLatestNonce(nonce); + typeof nonce !== 'undefined' && (await doSetLatestNonce(nonce)); finalizeTxSignature(requestToken, result); } catch (error) { console.error(error); set(transactionBroadcastErrorState, error.message); } }, - [doSetLatestNonce] + [doSetLatestNonce] + ) ); } diff --git a/src/pages/transaction-signing/hooks/use-transaction-error.ts b/src/pages/transaction-signing/hooks/use-transaction-error.ts index fa593778..88ef0618 100644 --- a/src/pages/transaction-signing/hooks/use-transaction-error.ts +++ b/src/pages/transaction-signing/hooks/use-transaction-error.ts @@ -1,6 +1,5 @@ import { useTransactionContractInterface } from '@pages/transaction-signing/hooks/use-transaction'; import { useTransactionRequest } from '@common/hooks/use-transaction-request'; -import { useRecoilValue } from 'recoil'; import { useWallet } from '@common/hooks/use-wallet'; import { useFetchBalances } from '@common/hooks/account/use-account-info'; import { useMemo } from 'react'; @@ -10,42 +9,43 @@ import { TransactionTypes } from '@stacks/connect'; import { useTransactionFee } from '@pages/transaction-signing/hooks/use-transaction-fee'; import { transactionBroadcastErrorState } from '@store/transactions'; import { useOrigin } from '@common/hooks/use-origin'; -import { useLoadable } from '@common/hooks/use-loadable'; import { transactionRequestValidationState } from '@store/transactions/requests'; +import { useAtomValue } from 'jotai/utils'; export function useTransactionError() { const transactionRequest = useTransactionRequest(); - const fee = useTransactionFee(); const contractInterface = useTransactionContractInterface(); - const broadcastError = useRecoilValue(transactionBroadcastErrorState); - const isValidTransaction = useLoadable(transactionRequestValidationState); + const fee = useTransactionFee(); + const broadcastError = useAtomValue(transactionBroadcastErrorState); + const isValidTransaction = useAtomValue(transactionRequestValidationState); const origin = useOrigin(); const { currentAccount } = useWallet(); const balances = useFetchBalances(); + + // return null; return useMemo(() => { if (origin === false) return TransactionErrorReason.ExpiredRequest; - if (isValidTransaction.contents === false && !isValidTransaction.isLoading) - return TransactionErrorReason.Unauthorized; + if (isValidTransaction === false) return TransactionErrorReason.Unauthorized; - if (!transactionRequest || balances.errorMaybe() || !currentAccount) { + if (!transactionRequest || !balances || !currentAccount) { return TransactionErrorReason.Generic; } - if ( - transactionRequest.txType === TransactionTypes.ContractCall && - !contractInterface.isLoading && - !contractInterface.contents - ) + if (transactionRequest.txType === TransactionTypes.ContractCall && !contractInterface) return TransactionErrorReason.NoContract; if (broadcastError) return TransactionErrorReason.BroadcastError; - if (balances.value) { - const stxBalance = new BigNumber(balances.value.stx.balance); + if (balances) { + const stxBalance = new BigNumber(balances.stx.balance); + const zeroBalance = stxBalance.toNumber() === 0; if (transactionRequest.txType === TransactionTypes.STXTransfer) { + if (zeroBalance) return TransactionErrorReason.StxTransferInsufficientFunds; + const transferAmount = new BigNumber(transactionRequest.amount); if (transferAmount.gte(stxBalance)) return TransactionErrorReason.StxTransferInsufficientFunds; } + if (zeroBalance) return TransactionErrorReason.FeeInsufficientFunds; if (fee && !fee.isSponsored && fee.amount) { const feeAmount = new BigNumber(fee.amount); if (feeAmount.gte(stxBalance)) return TransactionErrorReason.FeeInsufficientFunds; diff --git a/src/pages/transaction-signing/hooks/use-transaction-fee.ts b/src/pages/transaction-signing/hooks/use-transaction-fee.ts index 4a07cfc0..9833e7f2 100644 --- a/src/pages/transaction-signing/hooks/use-transaction-fee.ts +++ b/src/pages/transaction-signing/hooks/use-transaction-fee.ts @@ -1,11 +1,12 @@ -import { useSignedTransaction } from './use-transaction'; +import { useAtomValue } from 'jotai/utils'; +import { transactionFeeState, transactionSponsoredState } from '@store/transactions'; export function useTransactionFee() { - const signedTransaction = useSignedTransaction(); + const amount = useAtomValue(transactionFeeState); + const isSponsored = useAtomValue(transactionSponsoredState); return { - amount: signedTransaction?.value?.fee, - isSponsored: signedTransaction?.value?.isSponsored, - isLoading: signedTransaction.isLoading, + amount, + isSponsored, }; } diff --git a/src/pages/transaction-signing/hooks/use-transaction.ts b/src/pages/transaction-signing/hooks/use-transaction.ts index f995b3bd..16cc6552 100644 --- a/src/pages/transaction-signing/hooks/use-transaction.ts +++ b/src/pages/transaction-signing/hooks/use-transaction.ts @@ -1,4 +1,4 @@ -import { useLoadable } from '@common/hooks/use-loadable'; +import { useAtomValue } from 'jotai/utils'; import { postConditionsState, signedTransactionState } from '@store/transactions'; import { transactionContractInterfaceState, @@ -7,23 +7,21 @@ import { } from '@store/transactions/contract-call'; export function useTransactionContractInterface() { - return useLoadable(transactionContractInterfaceState); + return useAtomValue(transactionContractInterfaceState); } export function useTransactionContractSource() { - return useLoadable(transactionContractSourceState); + return useAtomValue(transactionContractSourceState); } export function useTransactionFunction() { - const payload = useLoadable(transactionFunctionsState); - return payload?.value; + return useAtomValue(transactionFunctionsState); } export function useTransactionPostConditions() { - const payload = useLoadable(postConditionsState); - return payload?.value; + return useAtomValue(postConditionsState); } export function useSignedTransaction() { - return useLoadable(signedTransactionState); + return useAtomValue(signedTransactionState); } diff --git a/src/pages/transaction-signing/transaction-signing.tsx b/src/pages/transaction-signing/transaction-signing.tsx index c2aec1e1..f288d8a8 100644 --- a/src/pages/transaction-signing/transaction-signing.tsx +++ b/src/pages/transaction-signing/transaction-signing.tsx @@ -9,19 +9,22 @@ import { ContractDeployDetails } from '@pages/transaction-signing/components/con import { PostConditions } from '@pages/transaction-signing/components/post-conditions/list'; import { StxTransferDetails } from '@pages/transaction-signing/components/stx-transfer-details'; import { useTransactionRequest } from '@common/hooks/use-transaction-request'; +import { Stack } from '@stacks/ui'; export const TransactionPage = memo(() => { const transactionRequest = useTransactionRequest(); if (!transactionRequest) return null; return ( }> - - - - - - - + + + + + {transactionRequest.txType === 'contract_call' && } + {transactionRequest.txType === 'token_transfer' && } + {transactionRequest.txType === 'smart_contract' && } + + ); }); diff --git a/src/routes.tsx b/src/routes.tsx index e0ceb301..d3d31509 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect } from 'react'; -import { useSetRecoilState } from 'recoil'; + import { Route as RouterRoute, Routes as RoutesDom, useLocation } from 'react-router-dom'; import { MagicRecoveryCode } from '@pages/install/magic-recovery-code'; @@ -26,6 +26,7 @@ import { AccountGateRoute } from '@components/account-gate-route'; import { lastSeenStore } from '@store/wallet'; import { ErrorBoundary } from '@components/error-boundary'; import { Unlock } from '@components/unlock'; +import { useUpdateAtom } from 'jotai/utils'; interface RouteProps { path: ScreenPaths; @@ -40,7 +41,7 @@ export const Routes: React.FC = () => { const { isSignedIn: signedIn, encryptedSecretKey } = useWallet(); const { isOnboardingInProgress } = useOnboardingState(); const { pathname } = useLocation(); - const setLastSeen = useSetRecoilState(lastSeenStore); + const setLastSeen = useUpdateAtom(lastSeenStore); const doChangeScreen = useDoChangeScreen(); useSaveAuthRequest(); @@ -98,7 +99,14 @@ export const Routes: React.FC = () => { } /> } />; - } /> + }> + + + } + /> {/* Transactions */} diff --git a/src/store/accounts/index.ts b/src/store/accounts/index.ts index 673a6936..40ae3180 100644 --- a/src/store/accounts/index.ts +++ b/src/store/accounts/index.ts @@ -1,18 +1,18 @@ import { MempoolTransaction, Transaction } from '@blockstack/stacks-blockchain-api-types'; import { Account, getStxAddress } from '@stacks/wallet-sdk'; -import { atom, selector, waitForAll } from 'recoil'; +import { atomWithDefault, waitForAll } from 'jotai/utils'; +import { atom } from 'jotai'; import BN from 'bn.js'; import type { AllAccountData } from '@common/api/accounts'; import { fetchAllAccountData } from '@common/api/accounts'; -import { apiRevalidation, intervalState } from '@store/common/api-helpers'; -import { fetcher } from '@common/api/wrapped-fetch'; import { transactionRequestStxAddressState } from '@store/transactions/requests'; import { currentNetworkState } from '@store/networks'; -import { DEFAULT_POLLING_INTERVAL } from '@store/common/constants'; import { walletState } from '@store/wallet'; import { transactionNetworkVersionState } from '@store/transactions'; +import { atomFamilyWithQuery } from '@store/query'; +import { accountsApiClientState } from '@store/common/api-clients'; /** * -------------------------------------- @@ -31,48 +31,28 @@ import { transactionNetworkVersionState } from '@store/transactions'; * accountInfoStore - external API data from the `v2/accounts` endpoint, should be the most up-to-date */ -enum ACCOUNT_KEYS { - ALL_ACCOUNTS = 'accounts/ALL_ACCOUNTS', - ALL_ACCOUNTS_WITH_ADDRESSES = 'accounts/ACCOUNTS_WITH_ADDRESSES', - HAS_SWITCHED_ACCOUNTS = 'accounts/HAS_SWITCHED_ACCOUNTS', - TRANSACTION_ACCOUNT_INDEX = 'accounts/TRANSACTION_ACCOUNT_INDEX', - CURRENT_ACCOUNT_INDEX = 'accounts/CURRENT_ACCOUNT_INDEX', - CURRENT_ACCOUNT = 'accounts/CURRENT_ACCOUNT', - CURRENT_ACCOUNT_ADDRESS = 'accounts/CURRENT_ACCOUNT_ADDRESS', - CURRENT_ACCOUNT_DATA = 'accounts/CURRENT_ACCOUNT_DATA', - CURRENT_ACCOUNT_BALANCES = 'accounts/CURRENT_ACCOUNT_BALANCES', - CURRENT_ACCOUNT_INFO = 'accounts/CURRENT_ACCOUNT_INFO', - CURRENT_ACCOUNT_TRANSACTIONS = 'accounts/CURRENT_ACCOUNT_TRANSACTIONS', -} - //-------------------------------------- // All accounts //-------------------------------------- -export const accountsState = selector({ - key: ACCOUNT_KEYS.ALL_ACCOUNTS, - get: ({ get }) => { - const wallet = get(walletState); - if (!wallet) return undefined; - return wallet.accounts; - }, +export const accountsState = atomWithDefault(get => { + const wallet = get(walletState); + if (!wallet) return undefined; + return wallet.accounts; }); export type AccountWithAddress = Account & { address: string }; // map through the accounts and get the address for the current network mode (testnet|mainnet) -export const accountsWithAddressState = selector({ - key: ACCOUNT_KEYS.ALL_ACCOUNTS_WITH_ADDRESSES, - get: ({ get }) => { - const accounts = get(accountsState); - const transactionVersion = get(transactionNetworkVersionState); - if (!accounts) return undefined; - return accounts.map(account => { - const address = getStxAddress({ account, transactionVersion }); - return { - ...account, - address, - }; - }); - }, +export const accountsWithAddressState = atom(get => { + const accounts = get(accountsState); + const transactionVersion = get(transactionNetworkVersionState); + if (!accounts) return undefined; + return accounts.map(account => { + const address = getStxAddress({ account, transactionVersion }); + return { + ...account, + address, + }; + }); }); //-------------------------------------- @@ -81,127 +61,118 @@ export const accountsWithAddressState = selector({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT_INDEX, - default: undefined, -}); +export const currentAccountIndexState = atom(0); // This is only used when there is a pending transaction request and // the user switches accounts during the signing process -export const hasSwitchedAccountsState = atom({ - key: ACCOUNT_KEYS.HAS_SWITCHED_ACCOUNTS, - default: false, -}); +export const hasSwitchedAccountsState = atom(false); // if there is a pending transaction that has a stxAccount param // find the index from the accounts atom and return it -export const transactionAccountIndexState = selector({ - key: ACCOUNT_KEYS.TRANSACTION_ACCOUNT_INDEX, - get: ({ get }) => { - const { accounts, txAddress } = get( - waitForAll({ - accounts: accountsWithAddressState, - txAddress: transactionRequestStxAddressState, - }) - ); +export const transactionAccountIndexState = atom(get => { + const { accounts, txAddress } = get( + waitForAll({ + accounts: accountsWithAddressState, + txAddress: transactionRequestStxAddressState, + }) + ); - if (txAddress && accounts) { - const selectedAccount = accounts.findIndex(account => account.address === txAddress); - if (typeof selectedAccount === 'number') return selectedAccount; - } - return undefined; - }, + if (txAddress && accounts) { + return accounts.findIndex(account => account.address === txAddress); // selected account + } + return undefined; }); // This contains the state of the current account: // could be the account associated with an in-process transaction request // or the last selected / first account of the user -export const currentAccountState = selector({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT, - get: ({ get }) => { - const { accountIndex, txIndex, hasSwitched, accounts } = get( - waitForAll({ - accountIndex: currentAccountIndexState, - txIndex: transactionAccountIndexState, - hasSwitched: hasSwitchedAccountsState, - accounts: accountsWithAddressState, - }) - ); - if (!accounts) return undefined; - if (typeof txIndex === 'number' && !hasSwitched) return accounts[txIndex]; - if (typeof accountIndex === 'number') return accounts[accountIndex]; - return undefined; - }, - dangerouslyAllowMutability: true, +export const currentAccountState = atom(get => { + const { accountIndex, txIndex, hasSwitched, accounts } = get( + waitForAll({ + accountIndex: currentAccountIndexState, + txIndex: transactionAccountIndexState, + hasSwitched: hasSwitchedAccountsState, + accounts: accountsWithAddressState, + }) + ); + if (!accounts) return undefined; + if (typeof txIndex === 'number' && !hasSwitched) return accounts[txIndex]; + return accounts[accountIndex]; }); // gets the address of the current account (in the current network mode) -export const currentAccountStxAddressState = selector({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT_ADDRESS, - get: ({ get }) => get(currentAccountState)?.address, -}); +export const currentAccountStxAddressState = atom( + get => get(currentAccountState)?.address +); +// gets the private key of the current account +export const currentAccountPrivateKeyState = atom( + get => get(currentAccountState)?.stxPrivateKey +); -// external API data associated with the current account's address -export const accountDataState = selector({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT_DATA, - get: async ({ get }) => { - const { network, address } = get( - waitForAll({ - apiRevalidation, - interval: intervalState(DEFAULT_POLLING_INTERVAL), - network: currentNetworkState, - address: currentAccountStxAddressState, - }) - ); - if (!address) return; +const accountDataResponseState = atomFamilyWithQuery<[string, string], AllAccountData | undefined>( + `ALL_ACCOUNT_DATA`, + async (_get, [address, networkUrl]) => { try { - return fetchAllAccountData(network.url)(address); + return fetchAllAccountData(networkUrl)(address); } catch (error) { console.error(error); - console.error(`Unable to fetch account data from ${network.url}`); + console.error(`Unable to fetch account data from ${networkUrl}`); return; } - }, -}); - -// the balances of the current account's address -export const accountBalancesState = selector({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT_BALANCES, - get: ({ get }) => get(accountDataState)?.balances, + } +); +// external API data associated with the current account's address +export const accountDataState = atom(get => { + const { network, address } = get( + waitForAll({ + network: currentNetworkState, + address: currentAccountStxAddressState, + }) + ); + if (!address) return; + return get(accountDataResponseState([address, network.url])); }); // the raw account info from the `v2/accounts` endpoint, should be most up-to-date info (compared to the extended API) -export const accountInfoState = selector({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT_INFO, - get: async ({ get }) => { - const { address, network } = get( - waitForAll({ - revalidation: apiRevalidation, - interval: intervalState(DEFAULT_POLLING_INTERVAL), - address: currentAccountStxAddressState, - network: currentNetworkState, - }) - ); - if (!address) return; - const url = `${network.url}/v2/accounts/${address}`; - const error = new Error(`Unable to fetch account info from ${url}`); - const response = await fetcher(url); - if (!response.ok) throw error; - const data = await response.json(); +export const accountInfoResponseState = atomFamilyWithQuery( + 'ACCOUNT_INFO_STATE_ATOM', + async (get, principal) => { + const client = get(accountsApiClientState); + const data = await client.getAccountInfo({ + principal, + proof: 0, + }); return { balance: new BN(data.balance.slice(2), 16), nonce: data.nonce, }; - }, + } +); +export const accountInfoState = atom(get => { + const principal = get(currentAccountStxAddressState); + if (!principal) return; + return get(accountInfoResponseState(principal)); }); +// the balances of the current account's address +export const accountBalancesState = atom( + get => get(accountDataState)?.balances +); // combo of pending and confirmed transactions for the current address -export const accountTransactionsState = selector<(MempoolTransaction | Transaction)[]>({ - key: ACCOUNT_KEYS.CURRENT_ACCOUNT_TRANSACTIONS, - get: async ({ get }) => { - const data = get(accountDataState); - const transactions = data?.transactions?.results || []; - const pending = data?.pendingTransactions || []; - return [...pending, ...transactions]; - }, +export const accountTransactionsState = atom<(MempoolTransaction | Transaction)[]>(get => { + const data = get(accountDataState); + const transactions = data?.transactions?.results || []; + const pending = data?.pendingTransactions || []; + return [...pending, ...transactions]; }); + +accountsState.debugLabel = 'accountsState'; +accountsWithAddressState.debugLabel = 'accountsWithAddressState'; +currentAccountIndexState.debugLabel = 'currentAccountIndexState'; +hasSwitchedAccountsState.debugLabel = 'hasSwitchedAccountsState'; +transactionAccountIndexState.debugLabel = 'transactionAccountIndexState'; +currentAccountState.debugLabel = 'currentAccountState'; +currentAccountStxAddressState.debugLabel = 'currentAccountStxAddressState'; +currentAccountPrivateKeyState.debugLabel = 'currentAccountPrivateKeyState'; +accountBalancesState.debugLabel = 'accountBalancesState'; +accountTransactionsState.debugLabel = 'accountTransactionsState'; diff --git a/src/store/accounts/names.ts b/src/store/accounts/names.ts index 501336e5..ab9c832a 100644 --- a/src/store/accounts/names.ts +++ b/src/store/accounts/names.ts @@ -1,4 +1,4 @@ -import { selector } from 'recoil'; +import { atom } from 'jotai'; import { getStxAddress } from '@stacks/wallet-sdk'; import { accountsState } from './index'; @@ -32,61 +32,57 @@ type AccountNameState = AccountName[] | null; const STALE_TIME = 30 * 60 * 1000; // 30 min -enum KEYS { - NAMES = 'account/NAMES', -} +export const accountNameState = atom(async get => { + const accounts = get(accountsState); + const network = get(currentNetworkState); + const transactionVersion = get(transactionNetworkVersionState); -export const accountNameState = selector({ - key: KEYS.NAMES, - get: async ({ get }) => { - const accounts = get(accountsState); - const network = get(currentNetworkState); - const transactionVersion = get(transactionNetworkVersionState); + if (!network || !accounts) return null; - if (!network || !accounts) return null; - - const promises = accounts.map(async account => { - const address = getStxAddress({ - account: account, - transactionVersion, - }); - - // let's try to find any saved names first - const local = getLocalNames(network.url, address); - - if (local) { - const [names, timestamp] = local; - const now = Date.now(); - const isStale = now - timestamp > STALE_TIME; - if (!isStale) - return { - address, - index: account.index, - names, - }; - } - - try { - const names = await fetchNamesByAddress(network.url, address); - if (names?.length) { - // persist them for next time - setLocalNames(network.url, address, [names, Date.now()]); - } - return { - address, - index: account.index, - names: names || [], - }; - } catch (e) { - console.error(e); - return { - address, - index: account.index, - names: [], - }; - } + const promises = accounts.map(async account => { + const address = getStxAddress({ + account: account, + transactionVersion, }); - if (!promises) return null; - return Promise.all(promises); - }, + + // let's try to find any saved names first + const local = getLocalNames(network.url, address); + + if (local) { + const [names, timestamp] = local; + const now = Date.now(); + const isStale = now - timestamp > STALE_TIME; + if (!isStale) + return { + address, + index: account.index, + names, + }; + } + + try { + const names = await fetchNamesByAddress(network.url, address); + if (names?.length) { + // persist them for next time + setLocalNames(network.url, address, [names, Date.now()]); + } + return { + address, + index: account.index, + names: names || [], + }; + } catch (e) { + console.error(e); + return { + address, + index: account.index, + names: [], + }; + } + }); + if (!promises) return null; + const value = await Promise.all(promises); + return value; }); + +accountNameState.debugLabel = 'accountNameState'; diff --git a/src/store/accounts/nonce.ts b/src/store/accounts/nonce.ts index 7b087c78..ff9cc82f 100644 --- a/src/store/accounts/nonce.ts +++ b/src/store/accounts/nonce.ts @@ -1,118 +1,89 @@ -import { atomFamily, selector, waitForAll } from 'recoil'; +import { atom } from 'jotai'; +import { atomFamily, atomWithStorage, waitForAll } from 'jotai/utils'; import { accountDataState, currentAccountStxAddressState, accountInfoState } from '@store/accounts'; import { currentNetworkState } from '@store/networks'; -import { apiRevalidation } from '@store/common/api-helpers'; -import { localStorageEffect } from '@store/common/utils'; +import deepEqual from 'fast-deep-equal'; -enum NONCE_KEYS { - LOCAL_NONCES = 'account/LOCAL_NONCES', - LATEST_LOCAL_NONCE = 'account/LATEST_LOCAL_NONCE', - CORRECT_NONCE = 'account/CORRECT_NONCE', -} +export const localNoncesState = atomFamily<[string, string], number, number>( + _params => atomWithStorage('LOCAL_NONCE_STATE', 0), + deepEqual +); -export const localNoncesState = atomFamily< - { nonce: number; blockHeight: number }, - [string, string] ->({ - key: NONCE_KEYS.LOCAL_NONCES, - default: () => ({ - nonce: 0, - blockHeight: 0, - }), - effects_UNSTABLE: [localStorageEffect()], +export const latestNonceState = atom(get => { + const { network, address } = get( + waitForAll({ + network: currentNetworkState, + address: currentAccountStxAddressState, + }) + ); + return get(localNoncesState([network.url, address || ''])); }); -export const latestNonceState = selector({ - key: NONCE_KEYS.LATEST_LOCAL_NONCE, - get: ({ get }) => { - const { network, address } = get( - waitForAll({ - network: currentNetworkState, - address: currentAccountStxAddressState, - }) - ); - return get(localNoncesState([network.url, address || ''])); - }, -}); - -export const correctNonceState = selector({ - key: NONCE_KEYS.CORRECT_NONCE, - get: ({ get }) => { - get(apiRevalidation); - - const { account, accountData, address, latestLocalNonce } = get( - waitForAll({ - account: accountInfoState, - accountData: accountDataState, - address: currentAccountStxAddressState, - latestLocalNonce: latestNonceState, - }) - ); - - const lastLocalNonce = latestLocalNonce.nonce; - - // most recent confirmed transactions sent by current address - const lastConfirmedTx = accountData?.transactions.results?.filter( - tx => tx.sender_address === address - )?.[0]; - - // most recent pending transactions sent by current address - const latestPendingTx = accountData?.pendingTransactions?.filter( - tx => tx.sender_address === address - )?.[0]; - - // oldest pending transactions sent by current address - const oldestPendingTx = accountData?.pendingTransactions?.length - ? accountData?.pendingTransactions?.filter(tx => tx.sender_address === address)?.[ - accountData?.pendingTransactions?.length - 1 - ] - : undefined; - - // they have any pending or confirmed transactions - const hasTransactions = !!latestPendingTx || !!lastConfirmedTx; - - if (!hasTransactions || !account || (!hasTransactions && account.nonce === 0)) return 0; - - // if the oldest pending tx is more than 1 above the account nonce, it's likely there was - // a race condition such that the client didn't have the most up to date pending tx - // if this is true, we should rely on the account nonce - const hasNonceMismatch = - oldestPendingTx && lastConfirmedTx - ? oldestPendingTx.nonce > lastConfirmedTx.nonce + 1 - : false; - - // if they do have a miss match, let's use the account nonce - if (hasNonceMismatch) return account.nonce; - - // otherwise, without micro-blocks, the account nonce will likely be out of date compared - // and not be incremented based on pending transactions - const pendingNonce = latestPendingTx?.nonce || 0; - const lastConfirmedTxNonce = lastConfirmedTx?.nonce || 0; - - // lastLocalNonce can be set when the user sends transactions - // and can often be faster that waiting for a new response from the API - const useLocalNonce = lastLocalNonce > pendingNonce && lastLocalNonce > lastConfirmedTxNonce; - - const usePendingNonce = - !useLocalNonce && - ((lastConfirmedTx && pendingNonce > lastConfirmedTx.nonce) || - pendingNonce + 1 > account.nonce); - - // if they have a last confirmed transaction (but no pending) - // and it's greater than account nonce, we should use that one - // else we will use the account nonce - const useLastTxNonce = - hasTransactions && lastConfirmedTx && lastConfirmedTx.nonce + 1 > account.nonce; - const lastConfirmedNonce = - useLastTxNonce && lastConfirmedTx ? lastConfirmedTx.nonce + 1 : account.nonce; - - return useLocalNonce - ? lastLocalNonce - : usePendingNonce - ? // if pending nonce is greater, use that - pendingNonce + 1 - : // else we use the last confirmed nonce - lastConfirmedNonce; - }, +export const correctNonceState = atom(get => { + const account = get(accountInfoState); + const accountData = get(accountDataState); + const address = get(currentAccountStxAddressState); + const lastLocalNonce = get(latestNonceState); + + // most recent confirmed transactions sent by current address + const lastConfirmedTx = accountData?.transactions.results?.filter( + tx => tx.sender_address === address + )?.[0]; + + // most recent pending transactions sent by current address + const latestPendingTx = accountData?.pendingTransactions?.filter( + tx => tx.sender_address === address + )?.[0]; + + // oldest pending transactions sent by current address + const oldestPendingTx = accountData?.pendingTransactions?.length + ? accountData?.pendingTransactions?.filter(tx => tx.sender_address === address)?.[ + accountData?.pendingTransactions?.length - 1 + ] + : undefined; + + // they have any pending or confirmed transactions + const hasTransactions = !!latestPendingTx || !!lastConfirmedTx; + + if (!hasTransactions || !account || (!hasTransactions && account.nonce === 0)) return 0; + + // if the oldest pending tx is more than 1 above the account nonce, it's likely there was + // a race condition such that the client didn't have the most up to date pending tx + // if this is true, we should rely on the account nonce + const hasNonceMismatch = + oldestPendingTx && lastConfirmedTx ? oldestPendingTx.nonce > lastConfirmedTx.nonce + 1 : false; + + // if they do have a miss match, let's use the account nonce + if (hasNonceMismatch) return account.nonce; + + // otherwise, without micro-blocks, the account nonce will likely be out of date compared + // and not be incremented based on pending transactions + const pendingNonce = latestPendingTx?.nonce || 0; + const lastConfirmedTxNonce = lastConfirmedTx?.nonce || 0; + + // lastLocalNonce can be set when the user sends transactions + // and can often be faster that waiting for a new response from the API + const useLocalNonce = lastLocalNonce > pendingNonce && lastLocalNonce > lastConfirmedTxNonce; + + const usePendingNonce = + !useLocalNonce && + ((lastConfirmedTx && pendingNonce > lastConfirmedTx.nonce) || pendingNonce + 1 > account.nonce); + + // if they have a last confirmed transaction (but no pending) + // and it's greater than account nonce, we should use that one + // else we will use the account nonce + const useLastTxNonce = + hasTransactions && lastConfirmedTx && lastConfirmedTx.nonce + 1 > account.nonce; + const lastConfirmedNonce = + useLastTxNonce && lastConfirmedTx ? lastConfirmedTx.nonce + 1 : account.nonce; + + return useLocalNonce + ? lastLocalNonce + : usePendingNonce + ? // if pending nonce is greater, use that + pendingNonce + 1 + : // else we use the last confirmed nonce + lastConfirmedNonce; }); +correctNonceState.debugLabel = 'correctNonceState'; diff --git a/src/store/assets/asset-search.ts b/src/store/assets/asset-search.ts index 12f5e2bc..7e934c64 100644 --- a/src/store/assets/asset-search.ts +++ b/src/store/assets/asset-search.ts @@ -1,39 +1,23 @@ -import { atom, selector } from 'recoil'; +import { atom } from 'jotai'; import { assetsState } from '@store/assets/tokens'; import { getFullyQualifiedAssetName } from '@common/hooks/use-selected-asset'; import { AssetWithMeta } from '@store/assets/types'; +import { atomWithDefault } from 'jotai/utils'; -enum ASSET_SEARCH_KEYS { - ASSET_ID = 'asset-search/ASSET_ID', - ASSET = 'asset-search/ASSET', - INPUT = 'asset-search/INPUT', -} +export const selectedAssetIdState = atom(undefined); -export const selectedAssetIdState = atom({ - key: ASSET_SEARCH_KEYS.ASSET_ID, - default: undefined, +export const selectedAssetStore = atom(get => { + const fqn = get(selectedAssetIdState); + const assets = get(assetsState); + return assets?.find(asset => getFullyQualifiedAssetName(asset) === fqn); }); +selectedAssetStore.debugLabel = 'selectedAssetStore'; -export const selectedAssetStore = selector({ - key: ASSET_SEARCH_KEYS.ASSET, - get: ({ get }) => { - const fqn = get(selectedAssetIdState); - const assets = get(assetsState); - return assets?.find(asset => getFullyQualifiedAssetName(asset) === fqn); - }, -}); +export const searchInputStore = atom(''); +searchInputStore.debugLabel = 'searchInputStore'; -export const searchInputStore = atom({ - key: ASSET_SEARCH_KEYS.INPUT, - default: '', -}); - -const defaultSearchResultState = selector({ - key: 'asset-search.results', - get: async ({ get }) => get(assetsState), -}); -export const searchResultState = atom({ - key: 'asset-search.results', - default: defaultSearchResultState, -}); +export const searchResultState = atomWithDefault(get => + get(assetsState) +); +searchResultState.debugLabel = 'searchResultState'; diff --git a/src/store/assets/fungible-tokens.ts b/src/store/assets/fungible-tokens.ts new file mode 100644 index 00000000..1a243f98 --- /dev/null +++ b/src/store/assets/fungible-tokens.ts @@ -0,0 +1,100 @@ +import { atomFamily } from 'jotai/utils'; +import { ContractPrincipal, FtMeta, MetaDataMethodNames } from '@store/assets/types'; +import { atom } from 'jotai'; +import { currentNetworkState } from '@store/networks'; +import { getLocalData, setLocalData } from '@store/common/utils'; +import { fetchFungibleTokenMetaData, getMatchingFunction } from '@store/assets/utils'; +import { contractInterfaceState } from '@store/contracts'; +import deepEqual from 'fast-deep-equal'; +import { debugLabelWithContractPrincipal } from '@common/atom-utils'; + +enum FungibleTokensQueryKeys { + SIP_10_COMPLIANT = 'SIP_10_COMPLIANT', + META_DATA_METHODS = 'META_DATA_METHODS', + ASSET_META_DATA = 'ASSET_META_DATA', +} + +const assetMetaDataMethodsResponseState = atomFamily< + Readonly, + MetaDataMethodNames | null +>(({ contractName, contractAddress }) => { + const anAtom = atom(get => { + const network = get(currentNetworkState); + const keyParams = [ + network.url, + contractAddress, + contractName, + FungibleTokensQueryKeys.META_DATA_METHODS, + ]; + const contractInterface = get(contractInterfaceState({ contractName, contractAddress })); + if (!contractInterface) return null; + const decimalsFunction = contractInterface.functions.find(getMatchingFunction('decimals')); + const symbolFunction = contractInterface.functions.find(getMatchingFunction('symbol')); + const nameFunction = contractInterface.functions.find(getMatchingFunction('name')); + + if (decimalsFunction && symbolFunction && nameFunction) { + const data = { + decimals: decimalsFunction.name, + symbol: symbolFunction.name, + name: nameFunction.name, + }; + return setLocalData(keyParams, data); + } + return null; + }); + anAtom.debugLabel = `assetMetaDataMethodsResponseState/${contractAddress}.${contractName}`; + return anAtom; +}, deepEqual); + +const assetMetaDataMethods = atomFamily, MetaDataMethodNames | null>( + ({ contractName, contractAddress }) => { + const anAtom = atom(get => { + const network = get(currentNetworkState); + const keyParams = [ + network.url, + contractAddress, + contractName, + FungibleTokensQueryKeys.META_DATA_METHODS, + ]; + const local = getLocalData(keyParams); + return local || get(assetMetaDataMethodsResponseState({ contractName, contractAddress })); + }); + debugLabelWithContractPrincipal(anAtom, 'assetMetaDataMethods', { + contractName, + contractAddress, + }); + return anAtom; + }, + deepEqual +); + +export const assetMetaDataState = atomFamily( + ({ contractAddress, contractName }) => { + const anAtom = atom(async get => { + const methods = get(assetMetaDataMethods({ contractName, contractAddress })); + if (!methods) return null; + const network = get(currentNetworkState); + const keyParams = [ + network.url, + contractAddress, + contractName, + FungibleTokensQueryKeys.ASSET_META_DATA, + ]; + const localData = getLocalData(keyParams); + if (localData) return localData; + const data = await fetchFungibleTokenMetaData({ + contractName, + contractAddress, + network: network.url, + methods, + }); + return setLocalData(keyParams, data); + }); + debugLabelWithContractPrincipal(anAtom, 'assetMetaDataState', { + contractName, + contractAddress, + }); + return anAtom; + }, + deepEqual +); diff --git a/src/store/assets/tokens.ts b/src/store/assets/tokens.ts index 7b5f5059..19912f73 100644 --- a/src/store/assets/tokens.ts +++ b/src/store/assets/tokens.ts @@ -1,280 +1,93 @@ -import { selector, selectorFamily, waitForAll } from 'recoil'; -import { currentNetworkState } from '@store/networks'; +import { atom } from 'jotai'; +import deepEqual from 'fast-deep-equal'; +import { atomFamily, waitForAll } from 'jotai/utils'; import { accountBalancesState } from '@store/accounts'; -import { ChainID } from '@stacks/transactions'; -import { ContractInterface } from '@stacks/rpc-client'; -import { isSip10Transfer, SIP010TransferResponse } from '@common/token-utils'; -import { smartContractClientState } from '@store/common/api-clients'; -import { - fetchFungibleTokenMetaData, - getLocalData, - getMatchingFunction, - getSip10Status, - setLocalData, - transformAssets, -} from '@store/assets/utils'; -import { - Asset, - AssetWithMeta, - ContractPrincipal, - FtMeta, - MetaDataMethodNames, -} from '@store/assets/types'; +import { transformAssets } from '@store/assets/utils'; +import { Asset, AssetWithMeta, ContractPrincipal } from '@store/assets/types'; +import { assetMetaDataState } from '@store/assets/fungible-tokens'; +import { contractInterfaceState } from '@store/contracts'; +import { isSip10Transfer } from '@common/token-utils'; -export const assetSip10ImplementationState = selectorFamily< - boolean | null, - { contractName: string; contractAddress: string } ->({ - key: 'asset.sip-010-compliant', - get: - ({ contractName, contractAddress }) => - async ({ get }) => { - const network = get(currentNetworkState); - const chain = network.url.includes('regtest') - ? 'regtest' - : network.chainId === ChainID.Testnet - ? 'testnet' - : 'mainnet'; - const local = getLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - key: 'asset.sip-010-compliant', - }); - if (local || typeof local === 'boolean') return local; - try { - const data = await getSip10Status({ - networkUrl: network.url, - contractAddress, +const transferDataState = atomFamily( + ({ contractAddress, contractName }) => { + const anAtom = atom(get => { + const contractInterface = get( + contractInterfaceState({ contractName, - chain, - }); - if (typeof data === 'boolean') - setLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - data, - key: 'asset.sip-010-compliant', - }); - return data; - } catch (e) { - console.log(e); - return null; - } - }, -}); - -const assetContractInterface = selectorFamily>({ - key: 'asset.contract-interface', - get: - ({ contractName, contractAddress }) => - async ({ get }) => { - const network = get(currentNetworkState); - const client = get(smartContractClientState); - - const local = getLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - key: 'asset.contract-interface', - }); - - if (local) return local; - const data = await client.getContractInterface({ - contractName, - contractAddress, - }); - setLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - data, - key: 'asset.contract-interface', - }); - return data; - }, -}); - -const assetMetaDataMethods = selectorFamily< - MetaDataMethodNames | null, - Readonly ->({ - key: 'asset.meta-data-methods', - get: - ({ contractName, contractAddress }) => - async ({ get }) => { - const network = get(currentNetworkState); - - const local = getLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - key: 'asset.meta-data-methods', - }); - - if (local) return local; - - const contractInterface = get(assetContractInterface({ contractName, contractAddress })); - const decimalsFunction = contractInterface.functions.find(getMatchingFunction('decimals')); - const symbolFunction = contractInterface.functions.find(getMatchingFunction('symbol')); - const nameFunction = contractInterface.functions.find(getMatchingFunction('name')); - - if (decimalsFunction && symbolFunction && nameFunction) { - const data = { - decimals: decimalsFunction.name, - symbol: symbolFunction.name, - name: nameFunction.name, - }; - setLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - data, - key: 'asset.meta-data-methods', - }); - return data; - } - - return null; - }, -}); - -export const assetMetaDataState = selectorFamily< - FtMeta | null, - { contractName: string; contractAddress: string } ->({ - key: 'asset.meta-data', - get: - ({ contractName, contractAddress }) => - async ({ get }) => { - const methods = get(assetMetaDataMethods({ contractName, contractAddress })); - if (!methods) return null; - - const isImplemented = get( - assetSip10ImplementationState({ contractAddress, - contractName, }) ); - const network = get(currentNetworkState); - const localData = getLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - key: 'asset.meta-data', - }); - if (localData) { - return { - ...localData, - ftTrait: isImplemented, - }; - } - const data = await fetchFungibleTokenMetaData({ - contractName, - contractAddress, - network: network.url, - methods, - }); - if (data) { - setLocalData({ - networkUrl: network.url, - address: contractAddress, - name: contractName, - data, - key: 'asset.meta-data', - }); - return { - ...data, - ftTrait: isImplemented, - }; - } - - return null; - }, -}); - -export const canTransferAssetState = selectorFamily< - undefined | SIP010TransferResponse, - Readonly ->({ - key: 'assets.can-transfer', - get: - ({ contractName, contractAddress }) => - async ({ get }) => { - if (!contractAddress || !contractName) return; - const contractInterface = get(assetContractInterface({ contractName, contractAddress })); - return isSip10Transfer({ contractInterface }); - }, -}); - -const assetItemState = selectorFamily>({ - key: 'assets.item', - get: - asset => - ({ get }) => { - if (asset.type === 'ft') { - const { transferData, meta } = get( - waitForAll({ - transferData: canTransferAssetState({ - contractAddress: asset.contractAddress, - contractName: asset.contractName, - }), - meta: assetMetaDataState({ - contractAddress: asset.contractAddress, - contractName: asset.contractName, - }), - }) - ); - - const canTransfer = !(!transferData || 'error' in transferData); - const hasMemo = transferData && !('error' in transferData) && transferData.hasMemo; - return { ...asset, meta, canTransfer, hasMemo } as AssetWithMeta; - } - return asset as AssetWithMeta; - }, -}); - -export const assetsState = selector({ - key: 'assets.base', - get: async ({ get }) => { - const balance = get(accountBalancesState); - if (!balance) return; - const assets = transformAssets(balance); - return get(waitForAll(assets.map(asset => assetItemState(asset)))); + if (!contractInterface) return; + return isSip10Transfer(contractInterface); + }); + anAtom.debugLabel = `transferDataState/${contractAddress}.${contractName}`; + return anAtom; }, + deepEqual +); + +const assetItemState = atomFamily(asset => { + const anAtom = atom(get => { + if (asset.type === 'ft') { + const transferData = get( + transferDataState({ + contractName: asset.contractName, + contractAddress: asset.contractAddress, + }) + ); + const meta = get( + assetMetaDataState({ + contractAddress: asset.contractAddress, + contractName: asset.contractName, + }) + ); + const canTransfer = !(!transferData || 'error' in transferData); + const hasMemo = transferData && !('error' in transferData) && transferData.hasMemo; + return { ...asset, meta, canTransfer, hasMemo } as AssetWithMeta; + } + return asset as AssetWithMeta; + }); + anAtom.debugLabel = `assetItemState/${asset.contractAddress}.${asset.contractName}::${asset.name}`; + return anAtom; +}, deepEqual); + +export const baseAssetsState = atom(get => { + const balance = get(accountBalancesState); + return balance ? transformAssets(balance) : undefined; }); -export const transferableAssetsState = selector({ - key: 'assets.transferable', - get: ({ get }) => get(assetsState)?.filter(asset => asset.canTransfer), +export const assetsState = atom(get => { + const assets = get(baseAssetsState) || []; + return get(waitForAll(assets.map(assetItemState))); }); -export const fungibleTokensState = selector({ - key: 'assets.ft', - get: ({ get }) => { - const assets = get(assetsState); - return assets?.filter(asset => asset.type === 'ft'); - }, +export const transferableAssetsState = atom(get => + get(assetsState)?.filter(asset => asset.canTransfer) +); + +export const fungibleTokensState = atom(get => { + const assets = get(assetsState); + return assets?.filter(asset => asset.type === 'ft'); }); -export const nonFungibleTokensState = selector({ - key: 'assets.nft', - get: ({ get }) => { - const assets = get(assetsState); - return assets?.filter(asset => asset.type !== 'nft'); - }, +export const nonFungibleTokensState = atom(get => { + const assets = get(assetsState); + return assets?.filter(asset => asset.type !== 'nft'); }); -export const stxTokenState = selector({ - key: 'assets.stx', - get: ({ get }) => { - const balances = get(accountBalancesState); - if (!balances || balances.stx.balance === '0') return; - return { - type: 'stx', - contractAddress: '', - balance: balances.stx.balance, - subtitle: 'STX', - name: 'Stacks Token', - } as AssetWithMeta; - }, +export const stxTokenState = atom(get => { + const balances = get(accountBalancesState); + if (!balances || balances.stx.balance === '0') return; + return { + type: 'stx', + contractAddress: '', + balance: balances.stx.balance, + subtitle: 'STX', + name: 'Stacks Token', + } as AssetWithMeta; }); + +baseAssetsState.debugLabel = 'baseAssetsState'; +assetsState.debugLabel = 'assetsState'; +transferableAssetsState.debugLabel = 'transferableAssetsState'; +fungibleTokensState.debugLabel = 'fungibleTokensState'; +nonFungibleTokensState.debugLabel = 'nonFungibleTokensState'; +stxTokenState.debugLabel = 'stxTokenState'; diff --git a/src/store/assets/utils.ts b/src/store/assets/utils.ts index 728747dd..54e89e04 100644 --- a/src/store/assets/utils.ts +++ b/src/store/assets/utils.ts @@ -5,7 +5,7 @@ import { SIP_010 } from '@common/constants'; import { fetcher } from '@common/api/wrapped-fetch'; import { AddressBalanceResponse } from '@blockstack/stacks-blockchain-api-types'; import { getAssetStringParts, truncateMiddle } from '@stacks/ui-utils'; -import { Asset, FungibleTokenOptions, MetaDataNames } from '@store/assets/types'; +import { Asset, FtMeta, FungibleTokenOptions, MetaDataNames } from '@store/assets/types'; async function callReadOnlyFunction({ contractName, @@ -69,21 +69,17 @@ export async function fetchFungibleTokenMetaData({ name: string; symbol: string; }; -}) { - try { - const [name, symbol, decimals] = await Promise.all([ - fetchName({ ...options, functionName: methods.name }), - fetchSymbol({ ...options, functionName: methods.symbol }), - fetchDecimals({ ...options, functionName: methods.decimals }), - ]); - return { - name, - symbol, - decimals, - }; - } catch (e) { - return null; - } +}): Promise { + const [name, symbol, decimals] = await Promise.all([ + fetchName({ ...options, functionName: methods.name }), + fetchSymbol({ ...options, functionName: methods.symbol }), + fetchDecimals({ ...options, functionName: methods.decimals }), + ]); + return { + name, + symbol, + decimals, + } as FtMeta; } function makeKey(networkUrl: string, address: string, name: string, key: string): string { diff --git a/src/store/common/api-clients.ts b/src/store/common/api-clients.ts index b9bb61aa..970aae72 100644 --- a/src/store/common/api-clients.ts +++ b/src/store/common/api-clients.ts @@ -1,4 +1,4 @@ -import { selector } from 'recoil'; +import { atom } from 'jotai'; import { currentNetworkState } from '@store/networks'; import { Configuration, @@ -9,50 +9,27 @@ import { } from '@stacks/blockchain-api-client'; import { fetcher } from '@common/api/wrapped-fetch'; -enum API_CLIENT_KEYS { - CONFIG = 'clients/CONFIG', - SMART_CONTRACTS = 'clients/SMART_CONTRACTS', - ACCOUNTS = 'clients/ACCOUNTS', - INFO = 'clients/INFO', - BLOCKS = 'clients/BLOCKS', -} - -export const apiClientConfiguration = selector({ - key: API_CLIENT_KEYS.CONFIG, - get: ({ get }) => { - const network = get(currentNetworkState); - return new Configuration({ basePath: network.url, fetchApi: fetcher }); - }, +export const apiClientConfiguration = atom(get => { + const network = get(currentNetworkState); + return new Configuration({ basePath: network.url, fetchApi: fetcher }); }); -export const smartContractClientState = selector({ - key: API_CLIENT_KEYS.SMART_CONTRACTS, - get: ({ get }) => { - const config = get(apiClientConfiguration); - return new SmartContractsApi(config); - }, +export const smartContractClientState = atom(get => { + const config = get(apiClientConfiguration); + return new SmartContractsApi(config); }); -export const accountsApiClientState = selector({ - key: API_CLIENT_KEYS.ACCOUNTS, - get: ({ get }) => { - const config = get(apiClientConfiguration); - return new AccountsApi(config); - }, +export const accountsApiClientState = atom(get => { + const config = get(apiClientConfiguration); + return new AccountsApi(config); }); -export const infoApiClientState = selector({ - key: API_CLIENT_KEYS.INFO, - get: ({ get }) => { - const config = get(apiClientConfiguration); - return new InfoApi(config); - }, +export const infoApiClientState = atom(get => { + const config = get(apiClientConfiguration); + return new InfoApi(config); }); -export const blocksApiClientState = selector({ - key: API_CLIENT_KEYS.BLOCKS, - get: ({ get }) => { - const config = get(apiClientConfiguration); - return new BlocksApi(config); - }, +export const blocksApiClientState = atom(get => { + const config = get(apiClientConfiguration); + return new BlocksApi(config); }); diff --git a/src/store/common/api-helpers.ts b/src/store/common/api-helpers.ts deleted file mode 100644 index 14ad359c..00000000 --- a/src/store/common/api-helpers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { atom, atomFamily } from 'recoil'; - -enum KEYS { - REVALIDATION_INDEX = 'api/REVALIDATION_INDEX', - INTERVAL = 'clients/INTERVAL', -} - -export const apiRevalidation = atom({ - key: KEYS.REVALIDATION_INDEX, - default: 0, -}); - -export const intervalState = atomFamily({ - key: KEYS.INTERVAL, - default: 0, - effects_UNSTABLE: (intervalMilliseconds: number) => [ - ({ setSelf }) => { - const interval = setInterval(() => { - setSelf(current => { - if (typeof current === 'number') { - return current + 1; - } - return 1; - }); - }, intervalMilliseconds); - return () => { - clearInterval(interval); - }; - }, - ], -}); diff --git a/src/store/common/utils.ts b/src/store/common/utils.ts index 11acc02a..76546c35 100644 --- a/src/store/common/utils.ts +++ b/src/store/common/utils.ts @@ -1,67 +1,5 @@ -import { AtomEffect, DefaultValue } from 'recoil'; +import hash from 'object-hash'; -export const ATOM_LOCALSTORAGE_PREFIX = '__hiro-recoil-v2__'; - -export const localStorageKey = (atomKey: string): string => { - return `${ATOM_LOCALSTORAGE_PREFIX}${atomKey}`; -}; - -interface LocalStorageTransformer { - serialize: (atom: T | DefaultValue) => string; - deserialize: (serialized: string) => T; -} - -export const guardRecoilDefaultValue = ( - candidate: DefaultValue | T -): candidate is DefaultValue => { - if (candidate instanceof DefaultValue) return true; - return false; -}; - -interface LocalStorageEffectOptions { - transformer?: LocalStorageTransformer; -} -export const localStorageEffect = - ({ transformer }: LocalStorageEffectOptions = {}): AtomEffect => - ({ setSelf, onSet, node }) => { - const key = localStorageKey(node.key); - if (typeof window !== 'undefined') { - const savedValue = localStorage.getItem(key); - if (savedValue) { - try { - if (transformer) { - setSelf(transformer.deserialize(savedValue)); - } else { - window.requestAnimationFrame(() => { - setSelf(JSON.parse(savedValue)); - }); - } - } catch (error) { - console.error(`Error when saving the recoil state ${key}.`, error); - console.error('Recoil value:', savedValue); - } - } - - onSet(newValue => { - if (transformer) { - const serialized = transformer.serialize(newValue); - if (serialized) { - localStorage.setItem(key, serialized); - } else { - localStorage.removeItem(key); - } - } else { - const doClear = - guardRecoilDefaultValue(newValue) || newValue === null || newValue === undefined; - if (doClear) { - localStorage.removeItem(key); - } else { - localStorage.setItem(key, JSON.stringify(newValue)); - } - } - }); - } - }; export function textToBytes(content: string) { return new TextEncoder().encode(content); } @@ -69,3 +7,29 @@ export function textToBytes(content: string) { export function bytesToText(buffer: Uint8Array) { return new TextDecoder().decode(buffer); } + +export function bytesToHex(uint8a: Uint8Array): string { + // pre-caching chars could speed this up 6x. + let hex = ''; + for (let i = 0; i < uint8a.length; i++) { + hex += uint8a[i].toString(16).padStart(2, '0'); + } + return hex; +} + +export function makeLocalDataKey(params: string[]): string { + return hash(params); +} + +export function getLocalData(params: string[]) { + const key = makeLocalDataKey(params); + const value = localStorage.getItem(key); + if (!value) return null; + return JSON.parse(value) as Data; +} + +export function setLocalData(params: string[], data: Data): Data { + const key = makeLocalDataKey(params); + localStorage.setItem(key, JSON.stringify(data)); + return data; +} diff --git a/src/store/contracts.ts b/src/store/contracts.ts new file mode 100644 index 00000000..b6d27b05 --- /dev/null +++ b/src/store/contracts.ts @@ -0,0 +1,95 @@ +import { atomFamilyWithQuery } from '@store/query'; +import { ContractPrincipal } from '@store/assets/types'; +import { smartContractClientState } from '@store/common/api-clients'; +import { atomFamily } from 'jotai/utils'; +import { atom } from 'jotai'; +import { currentNetworkState } from '@store/networks'; +import { getLocalData, setLocalData } from '@store/common/utils'; +import { ContractInterface } from '@stacks/rpc-client'; +import deepEqual from 'fast-deep-equal'; +import { ContractSourceResponse } from '@stacks/blockchain-api-client'; + +enum ContractQueryKeys { + ContractInterface = 'queries/ContractInterface', + ContractSource = 'queries/ContractSource', +} + +export const contractInterfaceResponseState = atomFamilyWithQuery< + ContractPrincipal, + ContractInterface | null +>(ContractQueryKeys.ContractInterface, async (get, { contractAddress, contractName }) => { + const client = get(smartContractClientState); + const network = get(currentNetworkState); + try { + const data = (await client.getContractInterface({ + contractAddress, + contractName, + })) as ContractInterface; + const keyParams = [ + network.url, + contractAddress, + contractName, + ContractQueryKeys.ContractInterface, + ]; + // for a given contract interface, it does not change once deployed so we should cache it + return setLocalData(keyParams, data); + } catch (e) { + console.log('contractInterfaceResponseState error', e); + return null; + } +}); + +export const contractInterfaceState = atomFamily( + ({ contractAddress, contractName }) => { + const anAtom = atom(get => { + const network = get(currentNetworkState); + const keyParams = [ + network.url, + contractAddress, + contractName, + ContractQueryKeys.ContractInterface, + ]; + const localData = getLocalData(keyParams); + if (localData) return localData; + return get(contractInterfaceResponseState({ contractAddress, contractName })); + }); + anAtom.debugLabel = `contractInterfaceState/${contractAddress}.${contractName}`; + return anAtom; + }, + deepEqual +); + +export const contractSourceResponseState = atomFamilyWithQuery< + ContractPrincipal, + ContractSourceResponse +>(ContractQueryKeys.ContractSource, async (get, { contractAddress, contractName }) => { + const client = get(smartContractClientState); + const network = get(currentNetworkState); + const data = await client.getContractSource({ + contractAddress, + contractName, + }); + const keyParams = [network.url, contractAddress, contractName, ContractQueryKeys.ContractSource]; + // for a given contract source, it does not change once deployed so we should cache it + return setLocalData(keyParams, data); +}); + +export const contractSourceState = atomFamily( + ({ contractAddress, contractName }) => { + const anAtom = atom(get => { + const network = get(currentNetworkState); + const keyParams = [ + network.url, + contractAddress, + contractName, + ContractQueryKeys.ContractSource, + ]; + const localData = getLocalData(keyParams); + if (localData) return localData; + return get(contractSourceResponseState({ contractAddress, contractName })); + }); + anAtom.debugLabel = `contractSourceState/${contractAddress}.${contractName}`; + return anAtom; + }, + deepEqual +); diff --git a/src/store/networks.ts b/src/store/networks.ts index 0b6f9b18..2c4918c6 100644 --- a/src/store/networks.ts +++ b/src/store/networks.ts @@ -1,111 +1,76 @@ -import { atom, DefaultValue, selector, waitForAll } from 'recoil'; -import { localStorageEffect } from './common/utils'; +import { atomWithStorage, waitForAll } from 'jotai/utils'; +import { atom } from 'jotai'; import { ChainID } from '@stacks/transactions'; import { StacksMainnet, StacksNetwork, StacksTestnet } from '@stacks/network'; -import { apiRevalidation } from '@store/common/api-helpers'; import { transactionRequestNetwork } from '@store/transactions/requests'; import { findMatchingNetworkKey } from '@common/utils'; import { defaultNetworks, Networks } from '@common/constants'; import { blocksApiClientState, infoApiClientState } from '@store/common/api-clients'; -enum KEYS { - NETWORKS = 'network/NETWORKS', - CURRENT_KEY = 'network/CURRENT_KEY', - CURRENT_KEY_DEFAULT = 'network/CURRENT_KEY_DEFAULT', - CURRENT_NETWORK = 'network/CURRENT_NETWORK', - STACKS_NETWORK = 'network/STACKS_NETWORK', - LATEST_BLOCK_HEIGHT = 'network/LATEST_BLOCK_HEIGHT', - INFO = 'network/INFO', -} - // Our root networks list, users can add to this list and it will persist to localstorage -export const networksState = atom({ - key: KEYS.NETWORKS, - default: defaultNetworks, - effects_UNSTABLE: [localStorageEffect()], -}); +export const networksState = atomWithStorage('networks', defaultNetworks); // the current key selected // if there is a pending transaction request, it will default to the network passed (if included) // else it will default to the persisted key or default (mainnet) -export const currentNetworkKeyState = atom({ - key: KEYS.CURRENT_KEY, - default: selector({ - key: KEYS.CURRENT_KEY_DEFAULT, - get: ({ get }) => { - const { networks, txNetwork } = get( - waitForAll({ - networks: networksState, - txNetwork: transactionRequestNetwork, - }) - ); - if (txNetwork) { - const newKey = findMatchingNetworkKey(txNetwork, networks); - if (newKey) return newKey; - } - const savedValue = localStorage.getItem(KEYS.CURRENT_KEY); - if (savedValue) { - try { - return JSON.parse(savedValue); - } catch (e) {} - } - return 'mainnet'; - }, - }), - effects_UNSTABLE: [ - ({ onSet }) => { - onSet(newValue => { - if (newValue instanceof DefaultValue) { - localStorage.removeItem(KEYS.CURRENT_KEY); - } else { - localStorage.setItem(KEYS.CURRENT_KEY, JSON.stringify(newValue)); - } - }); - }, - ], -}); -// the `Network` object for the current key selected -export const currentNetworkState = selector({ - key: KEYS.CURRENT_NETWORK, - get: ({ get }) => { - const { networks, key } = get( +const localCurrentNetworkKeyState = atomWithStorage('networkKey', 'mainnet'); +export const currentNetworkKeyState = atom( + get => { + const { networks, txNetwork } = get( waitForAll({ networks: networksState, - key: currentNetworkKeyState, + txNetwork: transactionRequestNetwork, }) ); - return networks[key]; + // if txNetwork, default to this always, users cannot currently change networks when signing a transaction + // @see https://github.com/blockstack/stacks-wallet-web/issues/1281 + if (txNetwork) { + const newKey = findMatchingNetworkKey(txNetwork, networks); + if (newKey) return newKey; + } + // otherwise default to the locally saved network key state + return get(localCurrentNetworkKeyState); }, + (_get, set, update) => { + if (update) set(localCurrentNetworkKeyState, update); + } +); + +// the `Network` object for the current key selected +export const currentNetworkState = atom(get => { + const { networks, key } = get( + waitForAll({ + networks: networksState, + key: currentNetworkKeyState, + }) + ); + return networks[key]; }); // a `StacksNetwork` instance using the current network -export const currentStacksNetworkState = selector({ - key: KEYS.STACKS_NETWORK, - get: ({ get }) => { - const network = get(currentNetworkState); - const stacksNetwork = - network.chainId === ChainID.Testnet ? new StacksTestnet() : new StacksMainnet(); - stacksNetwork.coreApiUrl = network.url; - stacksNetwork.bnsLookupUrl = network.url; - return stacksNetwork; - }, +export const currentStacksNetworkState = atom(get => { + const network = get(currentNetworkState); + const stacksNetwork = + network.chainId === ChainID.Testnet ? new StacksTestnet() : new StacksMainnet(); + stacksNetwork.coreApiUrl = network.url; + stacksNetwork.bnsLookupUrl = network.url; + return stacksNetwork; }); // external data, the most recent block height of the selected network -export const latestBlockHeightState = selector({ - key: KEYS.LATEST_BLOCK_HEIGHT, - get: async ({ get }) => { - const client = get(blocksApiClientState); - return (await client.getBlockList({}))?.results?.[0]?.height; - }, +export const latestBlockHeightState = atom(async get => { + const client = get(blocksApiClientState); + return (await client.getBlockList({}))?.results?.[0]?.height; }); // external data, `v2/info` endpoint of the selected network -export const networkInfoState = selector({ - key: KEYS.INFO, - get: async ({ get }) => { - get(apiRevalidation); - return get(infoApiClientState).getCoreApiInfo(); - }, -}); +export const networkInfoState = atom(get => get(infoApiClientState).getCoreApiInfo()); + +networksState.debugLabel = 'networksState'; +localCurrentNetworkKeyState.debugLabel = 'localCurrentNetworkKeyState'; +currentNetworkKeyState.debugLabel = 'currentNetworkKeyState'; +currentNetworkState.debugLabel = 'currentNetworkState'; +currentStacksNetworkState.debugLabel = 'currentStacksNetworkState'; +latestBlockHeightState.debugLabel = 'latestBlockHeightState'; +networkInfoState.debugLabel = 'networkInfoState'; diff --git a/src/store/onboarding.ts b/src/store/onboarding.ts index 2b9cc4ec..3107650f 100644 --- a/src/store/onboarding.ts +++ b/src/store/onboarding.ts @@ -1,4 +1,6 @@ -import { atom } from 'recoil'; +import { atom } from 'jotai'; +import { atomWithDefault } from 'jotai/utils'; + import { ScreenPaths } from '@store/common/types'; import { DecodedAuthRequest } from '@common/dev/types'; @@ -16,50 +18,6 @@ export interface OnboardingState { onboardingPath?: ScreenPaths; } -export const magicRecoveryCodePasswordState = atom({ - key: 'seed.magic-recovery-code.password', - default: '', -}); - -export const seedInputState = atom({ - key: 'seed.input', - default: '', -}); - -export const seedInputErrorState = atom({ - key: 'seed.input.error', - default: undefined, -}); - -export const currentScreenState = atom({ - key: 'onboarding.screen', - default: ScreenPaths.GENERATION, -}); - -export const secretKeyState = atom({ - key: 'onboarding.secretKey', - default: null, -}); - -export const magicRecoveryCodeState = atom({ - key: 'onboarding.magicRecoveryCode', - default: null, -}); - -export const onboardingProgressState = atom({ - key: 'onboarding.progress', - default: false, -}); - -export const usernameState = atom({ - key: 'onboarding.username', - default: null, -}); -export const onboardingPathState = atom({ - key: 'onboarding.path', - default: null, -}); - interface AuthRequestState { authRequest?: string; decodedAuthRequest?: DecodedAuthRequest; @@ -68,13 +26,30 @@ interface AuthRequestState { appURL?: URL; } +export const magicRecoveryCodePasswordState = atom(''); +export const seedInputState = atom(''); +export const seedInputErrorState = atom(undefined); +export const secretKeyState = atomWithDefault(() => null); +export const currentScreenState = atom(ScreenPaths.GENERATION); +export const magicRecoveryCodeState = atomWithDefault(() => null); +export const onboardingProgressState = atom(false); +export const usernameState = atomWithDefault(() => null); +export const onboardingPathState = atomWithDefault(() => null); export const authRequestState = atom({ - key: 'onboarding.authRequest', - default: { - authRequest: undefined, - decodedAuthRequest: undefined, - appName: undefined, - appIcon: undefined, - appURL: undefined, - }, + authRequest: undefined, + decodedAuthRequest: undefined, + appName: undefined, + appIcon: undefined, + appURL: undefined, }); + +magicRecoveryCodePasswordState.debugLabel = 'magicRecoveryCodePasswordState'; +seedInputState.debugLabel = 'seedInputState'; +seedInputErrorState.debugLabel = 'seedInputErrorState'; +secretKeyState.debugLabel = 'secretKeyState'; +currentScreenState.debugLabel = 'currentScreenState'; +magicRecoveryCodeState.debugLabel = 'magicRecoveryCodeState'; +onboardingProgressState.debugLabel = 'onboardingProgressState'; +usernameState.debugLabel = 'usernameState'; +onboardingPathState.debugLabel = 'onboardingPathState'; +authRequestState.debugLabel = 'authRequestState'; diff --git a/src/store/query.ts b/src/store/query.ts new file mode 100644 index 00000000..39dc780a --- /dev/null +++ b/src/store/query.ts @@ -0,0 +1,63 @@ +import { Atom, Getter } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import { atomWithQuery } from 'jotai/query'; +import deepEqual from 'fast-deep-equal'; +import { QueryObserverOptions } from 'react-query'; + +const withInterval = (enabled: boolean) => + enabled + ? { + refetchInterval: 10000, + } + : {}; + +export const queryAtom = ( + key: string, + queryFn: (get: Getter) => Data | Promise, + enableInterval = false, + equalityFn: (a: Data, b: Data) => boolean = Object.is +) => { + const anAtom = atomWithQuery( + get => ({ + queryKey: key, + queryFn: () => queryFn(get), + keepPreviousData: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + refetchOnMount: true, + ...withInterval(enableInterval), + }), + equalityFn + ); + anAtom.debugLabel = `queryAtom/${key}`; + return anAtom; +}; + +export const atomFamilyWithQuery = ( + key: string, + queryFn: (get: Getter, param: Param) => Promise, + options: { + enableInterval?: boolean; + equalityFn?: (a: Data, b: Data) => boolean; + onError?: QueryObserverOptions['onError']; + } = {} +): ((param: Param) => Atom) => { + const { enableInterval = false, equalityFn = deepEqual } = options; + return atomFamily(param => { + const anAtom = atomWithQuery( + get => ({ + queryKey: [key, param], + queryFn: () => queryFn(get, param), + useErrorBoundary: true, + keepPreviousData: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + refetchOnMount: true, + ...withInterval(enableInterval), + }), + equalityFn + ); + anAtom.debugLabel = `atomFamilyWithQuery/${key}`; + return anAtom; + }, deepEqual); +}; diff --git a/src/store/transactions/contract-call.ts b/src/store/transactions/contract-call.ts index 679289e0..95bfcb94 100644 --- a/src/store/transactions/contract-call.ts +++ b/src/store/transactions/contract-call.ts @@ -1,89 +1,71 @@ -import { selector, waitForAll } from 'recoil'; +import { atom } from 'jotai'; +import { waitForAll } from 'jotai/utils'; import { requestTokenPayloadState } from '@store/transactions/requests'; -import { smartContractClientState } from '@store/common/api-clients'; import { TransactionTypes } from '@stacks/connect'; import { ContractInterfaceResponse } from '@stacks/blockchain-api-client'; import { ContractInterfaceFunction } from '@stacks/rpc-client'; - -enum KEYS { - CONTRACT_INTERFACE = 'transactions/CONTRACT_INTERFACE', - CONTRACT_SOURCE = 'transactions/CONTRACT_SOURCE', - FUNCTION = 'transactions/FUNCTION', -} +import { contractInterfaceState, contractSourceState } from '@store/contracts'; type ContractInterfaceResponseWithFunctions = Omit & { functions: ContractInterfaceFunction[]; }; -export const transactionContractInterfaceState = selector< +export const transactionContractInterfaceState = atom< undefined | ContractInterfaceResponseWithFunctions ->({ - key: KEYS.CONTRACT_INTERFACE, - get: async ({ get }) => { - const { payload, client } = get( - waitForAll({ - payload: requestTokenPayloadState, - client: smartContractClientState, - }) - ); - - if (payload?.txType !== TransactionTypes.ContractCall) return; - try { - const data = await client.getContractInterface({ +>(async get => { + const payload = get(requestTokenPayloadState); + if (payload?.txType !== TransactionTypes.ContractCall) return; + try { + const data = get( + contractInterfaceState({ contractName: payload.contractName, contractAddress: payload.contractAddress, - }); - if (!data) return undefined; - return data as ContractInterfaceResponseWithFunctions; - } catch (e) { - return undefined; - } - }, -}); - -export const transactionContractSourceState = selector({ - key: KEYS.CONTRACT_SOURCE, - get: async ({ get }) => { - const { payload, client } = get( - waitForAll({ - payload: requestTokenPayloadState, - client: smartContractClientState, }) ); + if (!data) return undefined; + return data as ContractInterfaceResponseWithFunctions; + } catch (e) { + return undefined; + } +}); - if (payload?.txType !== TransactionTypes.ContractCall) return; - - try { - return client.getContractSource({ +export const transactionContractSourceState = atom(get => { + const payload = get(requestTokenPayloadState); + if (payload?.txType !== TransactionTypes.ContractCall) return; + try { + return get( + contractSourceState({ contractName: payload.contractName, contractAddress: payload.contractAddress, - }); - } catch (e) { - return undefined; - } - }, -}); -export const transactionFunctionsState = selector({ - key: KEYS.FUNCTION, - get: ({ get }) => { - const { payload, contractInterface } = get( - waitForAll({ - payload: requestTokenPayloadState, - contractInterface: transactionContractInterfaceState, }) ); - - if (!payload || payload.txType !== 'contract_call' || !contractInterface) return undefined; - - const selectedFunction = contractInterface.functions.find(func => { - return func.name === payload.functionName; - }); - if (!selectedFunction) { - throw new Error( - `Attempting to call a function (\`${payload.functionName}\`) that ` + - `does not exist on contract ${payload.contractAddress}.${payload.contractName}` - ); - } - return selectedFunction; - }, + } catch (e) { + return undefined; + } }); + +export const transactionFunctionsState = atom(get => { + const { payload, contractInterface } = get( + waitForAll({ + payload: requestTokenPayloadState, + contractInterface: transactionContractInterfaceState, + }) + ); + + if (!payload || payload.txType !== 'contract_call' || !contractInterface) return undefined; + + const selectedFunction = contractInterface.functions.find(func => { + return func.name === payload.functionName; + }); + if (!selectedFunction) { + throw new Error( + `Attempting to call a function (\`${payload.functionName}\`) that ` + + `does not exist on contract ${payload.contractAddress}.${payload.contractName}` + ); + } + return selectedFunction; +}); + +transactionContractInterfaceState.debugLabel = 'transactionContractInterfaceState'; +transactionContractSourceState.debugLabel = 'transactionContractSourceState'; +transactionFunctionsState.debugLabel = 'transactionFunctionsState'; diff --git a/src/store/transactions/fungible-token-transfer.ts b/src/store/transactions/fungible-token-transfer.ts index b160555b..dfccf2ac 100644 --- a/src/store/transactions/fungible-token-transfer.ts +++ b/src/store/transactions/fungible-token-transfer.ts @@ -1,4 +1,4 @@ -import { selector, waitForAll } from 'recoil'; +import { atom } from 'jotai'; import { selectedAssetStore } from '@store/assets/asset-search'; import { accountBalancesState, @@ -7,39 +7,35 @@ import { } from '@store/accounts'; import { currentStacksNetworkState } from '@store/networks'; import { correctNonceState } from '@store/accounts/nonce'; +import { waitForAll } from 'jotai/utils'; -enum KEYS { - ASSET_STATE = 'transaction/ASSET_STATE', -} +export const makeFungibleTokenTransferState = atom(get => { + const { asset, currentAccount, network, balances, stxAddress, nonce } = get( + waitForAll({ + asset: selectedAssetStore, + currentAccount: currentAccountState, + network: currentStacksNetworkState, + balances: accountBalancesState, + stxAddress: currentAccountStxAddressState, + nonce: correctNonceState, + }) + ); -export const makeFungibleTokenTransferState = selector({ - key: KEYS.ASSET_STATE, - get: ({ get }) => { - const { asset, currentAccount, network, balances, stxAddress, nonce } = get( - waitForAll({ - asset: selectedAssetStore, - currentAccount: currentAccountState, - network: currentStacksNetworkState, - balances: accountBalancesState, - stxAddress: currentAccountStxAddressState, - nonce: correctNonceState, - }) - ); - - if (asset && currentAccount && stxAddress) { - const { contractName, contractAddress, name: assetName } = asset; - return { - asset, - stxAddress, - nonce, - balances, - network, - senderKey: currentAccount.stxPrivateKey, - assetName, - contractAddress, - contractName, - }; - } - return; - }, + if (asset && currentAccount && stxAddress) { + const { contractName, contractAddress, name: assetName } = asset; + return { + asset, + stxAddress, + nonce, + balances, + network, + senderKey: currentAccount.stxPrivateKey, + assetName, + contractAddress, + contractName, + }; + } + return; }); + +makeFungibleTokenTransferState.debugLabel = 'makeFungibleTokenTransferState'; diff --git a/src/store/transactions/index.ts b/src/store/transactions/index.ts index 07c3ec61..4dce9379 100644 --- a/src/store/transactions/index.ts +++ b/src/store/transactions/index.ts @@ -1,4 +1,6 @@ -import { atom, selector, waitForAll } from 'recoil'; +import { atom } from 'jotai'; +import { waitForAll } from 'jotai/utils'; + import { AuthType, ChainID, TransactionVersion } from '@stacks/transactions'; import { currentNetworkState, currentStacksNetworkState } from '@store/networks'; @@ -11,104 +13,94 @@ import { getPostCondition, handlePostConditions } from '@common/transactions/pos import { TransactionPayload } from '@stacks/connect'; import { stacksTransactionToHex } from '@common/transactions/transaction-utils'; -enum KEYS { - POST_CONDITIONS = 'transactions/POST_CONDITIONS', - PENDING_TRANSACTION = 'transactions/PENDING_TRANSACTION', - ATTACHMENT = 'transactions/ATTACHMENT', - SIGNED_TRANSACTION = 'transactions/SIGNED_TRANSACTION', - TX_VERSION = 'transactions/TX_VERSION', - ERROR_IS_UNAUTHORIZED = 'transactions/ERROR_IS_UNAUTHORIZED', - ERROR_BROADCAST_FAILURE = 'transactions/ERROR_BROADCAST_FAILURE', -} +export const postConditionsState = atom(get => { + const { payload, address } = get( + waitForAll({ + payload: requestTokenPayloadState, + address: currentAccountStxAddressState, + }) + ); -export const postConditionsState = selector({ - key: KEYS.POST_CONDITIONS, - get: ({ get }) => { - const { payload, address } = get( - waitForAll({ - payload: requestTokenPayloadState, - address: currentAccountStxAddressState, - }) - ); + if (!payload || !address) return; - if (!payload || !address) return; + if (payload.postConditions) { + if (payload.stxAddress) + return handlePostConditions(payload.postConditions, payload.stxAddress, address); - if (payload.postConditions) { - if (payload.stxAddress) - return handlePostConditions(payload.postConditions, payload.stxAddress, address); - - return payload.postConditions.map(getPostCondition); - } - return []; - }, + return payload.postConditions.map(getPostCondition); + } + return []; }); -export const pendingTransactionState = selector({ - key: KEYS.PENDING_TRANSACTION, - get: ({ get }) => { - const { payload, postConditions, network } = get( - waitForAll({ - payload: requestTokenPayloadState, - postConditions: postConditionsState, - network: currentStacksNetworkState, - }) - ); - if (!payload) return; - return { ...payload, postConditions, network }; - }, +export const pendingTransactionState = atom(get => { + const { payload, postConditions, network } = get( + waitForAll({ + payload: requestTokenPayloadState, + postConditions: postConditionsState, + network: currentStacksNetworkState, + }) + ); + if (!payload) return; + return { ...payload, postConditions, network }; }); -export const transactionAttachmentState = selector({ - key: KEYS.ATTACHMENT, - get: ({ get }) => get(pendingTransactionState)?.attachment, +export const transactionAttachmentState = atom(get => get(pendingTransactionState)?.attachment); + +export const signedStacksTransactionState = atom(get => { + const { account, txData, nonce } = get( + waitForAll({ + account: currentAccountState, + txData: pendingTransactionState, + nonce: correctNonceState, + }) + ); + if (!account || !txData) return; + return generateSignedTransaction({ + senderKey: account.stxPrivateKey, + nonce, + txData, + }); }); -export const signedTransactionState = selector({ - key: KEYS.SIGNED_TRANSACTION, - get: async ({ get }) => { - const { account, pendingTransaction, nonce } = get( - waitForAll({ - account: currentAccountState, - pendingTransaction: pendingTransactionState, - nonce: correctNonceState, - }) - ); - - if (!account || !pendingTransaction) return; - - const signedTransaction = await generateSignedTransaction({ - senderKey: account.stxPrivateKey, - nonce, - txData: pendingTransaction, - }); - const serialized = signedTransaction?.serialize(); - const txRaw = stacksTransactionToHex(signedTransaction); - return { - serialized, - isSponsored: signedTransaction?.auth?.authType === AuthType.Sponsored, - nonce: signedTransaction?.auth.spendingCondition?.nonce.toNumber(), - fee: signedTransaction?.auth.spendingCondition?.fee?.toNumber(), - txRaw, - }; - }, +export const signedTransactionState = atom(get => { + const signedTransaction = get(signedStacksTransactionState); + console.log(signedTransaction); + if (!signedTransaction) return; + const serialized = signedTransaction.serialize(); + const txRaw = stacksTransactionToHex(signedTransaction); + return { + serialized, + isSponsored: signedTransaction?.auth?.authType === AuthType.Sponsored, + nonce: signedTransaction?.auth.spendingCondition?.nonce.toNumber(), + fee: signedTransaction?.auth.spendingCondition?.fee?.toNumber(), + txRaw, + }; }); -export const transactionNetworkVersionState = selector({ - key: KEYS.TX_VERSION, - get: ({ get }) => - get(currentNetworkState).chainId === ChainID.Mainnet - ? TransactionVersion.Mainnet - : TransactionVersion.Testnet, +export const transactionFeeState = atom(get => { + return get(signedTransactionState)?.fee; }); +export const transactionSponsoredState = atom(get => { + return get(signedTransactionState)?.isSponsored; +}); +export const transactionNetworkVersionState = atom(get => + get(currentNetworkState)?.chainId === ChainID.Mainnet + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet +); export type TransactionPayloadWithAttachment = TransactionPayload & { attachment?: string; }; -export const isUnauthorizedTransactionState = atom({ - key: KEYS.ERROR_IS_UNAUTHORIZED, - default: false, -}); -export const transactionBroadcastErrorState = atom({ - key: KEYS.ERROR_BROADCAST_FAILURE, - default: null, -}); +export const isUnauthorizedTransactionState = atom(false); +export const transactionBroadcastErrorState = atom(null); + +// dev tooling +postConditionsState.debugLabel = 'postConditionsState'; +pendingTransactionState.debugLabel = 'pendingTransactionState'; +transactionAttachmentState.debugLabel = 'transactionAttachmentState'; +signedStacksTransactionState.debugLabel = 'signedStacksTransactionState'; +signedTransactionState.debugLabel = 'signedTransactionState'; +transactionNetworkVersionState.debugLabel = 'transactionNetworkVersionState'; +isUnauthorizedTransactionState.debugLabel = 'isUnauthorizedTransactionState'; +transactionBroadcastErrorState.debugLabel = 'transactionBroadcastErrorState'; diff --git a/src/store/transactions/requests.ts b/src/store/transactions/requests.ts index 177a0363..6eec6e07 100644 --- a/src/store/transactions/requests.ts +++ b/src/store/transactions/requests.ts @@ -1,89 +1,58 @@ -import { atom, selector, waitForAll } from 'recoil'; +import { atom } from 'jotai'; +import { waitForAll } from 'jotai/utils'; import { getPayloadFromToken } from '@store/transactions/utils'; import { walletState } from '@store/wallet'; import { verifyTxRequest } from '@common/transactions/requests'; import { getRequestOrigin, StorageKey } from '@common/storage'; +import { atomWithParam } from '@common/atom-with-params'; -enum KEYS { - REQUEST_TOKEN = 'requests/REQUEST_TOKEN', - REQUEST_TOKEN_ORIGIN = 'requests/REQUEST_TOKEN_ORIGIN', - REQUEST_TOKEN_VALIDATION = 'requests/REQUEST_TOKEN_VALIDATION', - REQUEST_TOKEN_PAYLOAD = 'requests/REQUEST_TOKEN_PAYLOAD', - ADDRESS = 'requests/ADDRESS', - NETWORK = 'requests/NETWORK', -} +export const requestTokenState = atomWithParam('transaction?request', null); -export const requestTokenState = atom({ - key: KEYS.REQUEST_TOKEN, - default: null, - effects_UNSTABLE: [ - ({ setSelf, trigger }) => { - if (trigger === 'get') { - const requestToken = window.location.href?.split('?request=')[1]; - if (requestToken) { - setSelf(requestToken); - } - } - - return () => { - setSelf(null); - }; - }, - ], +export const requestTokenPayloadState = atom(get => { + const token = get(requestTokenState); + return token ? getPayloadFromToken(token) : null; }); -export const requestTokenPayloadState = selector({ - key: KEYS.REQUEST_TOKEN_PAYLOAD, - get: ({ get }) => { - const token = get(requestTokenState); - return token ? getPayloadFromToken(token) : null; - }, +export const requestTokenOriginState = atom(get => { + const token = get(requestTokenState); + if (!token) return; + try { + return getRequestOrigin(StorageKey.transactionRequests, token); + } catch (e) { + console.error(e); + return false; + } }); -export const requestTokenOriginState = selector({ - key: KEYS.REQUEST_TOKEN_ORIGIN, - get: ({ get }) => { - const token = get(requestTokenState); - if (!token) return; - try { - return getRequestOrigin(StorageKey.transactionRequests, token); - } catch (e) { - console.error(e); - return false; - } - }, +export const transactionRequestValidationState = atom(async get => { + const { requestToken, wallet, origin } = get( + waitForAll({ + requestToken: requestTokenState, + wallet: walletState, + origin: requestTokenOriginState, + }) + ); + if (!origin || !wallet || !requestToken) return; + try { + const valid = await verifyTxRequest({ + requestToken, + wallet, + appDomain: origin, + }); + return !!valid; + } catch (e) { + return false; + } }); -export const transactionRequestValidationState = selector({ - key: KEYS.REQUEST_TOKEN_VALIDATION, - get: async ({ get }) => { - const { requestToken, wallet, origin } = get( - waitForAll({ - requestToken: requestTokenState, - wallet: walletState, - origin: requestTokenOriginState, - }) - ); - if (!origin || !wallet || !requestToken) return; - try { - const valid = await verifyTxRequest({ - requestToken, - wallet, - appDomain: origin, - }); - return !!valid; - } catch (e) { - return false; - } - }, -}); +export const transactionRequestStxAddressState = atom( + get => get(requestTokenPayloadState)?.stxAddress +); -export const transactionRequestStxAddressState = selector({ - key: KEYS.ADDRESS, - get: ({ get }) => get(requestTokenPayloadState)?.stxAddress, -}); +export const transactionRequestNetwork = atom(get => get(requestTokenPayloadState)?.network); -export const transactionRequestNetwork = selector({ - key: KEYS.NETWORK, - get: ({ get }) => get(requestTokenPayloadState)?.network, -}); +requestTokenPayloadState.debugLabel = 'requestTokenPayloadState'; +requestTokenOriginState.debugLabel = 'requestTokenOriginState'; +transactionRequestValidationState.debugLabel = 'transactionRequestValidationState'; +transactionRequestStxAddressState.debugLabel = 'transactionRequestStxAddressState'; +transactionRequestNetwork.debugLabel = 'transactionRequestNetwork'; diff --git a/src/store/ui.ts b/src/store/ui.ts index 5d8fddf3..bf021811 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -1,4 +1,5 @@ -import { atomFamily, DefaultValue, AtomEffect, atom } from 'recoil'; +import { atomFamily, atomWithStorage } from 'jotai/utils'; +import { atom } from 'jotai'; export enum AccountStep { Switch = 'switch', @@ -6,49 +7,30 @@ export enum AccountStep { Username = 'username', } -const localStorageEffect = - (key: string): AtomEffect => - ({ setSelf, onSet }) => { - const savedValue = localStorage.getItem(key); - if (savedValue != null) { - setSelf(JSON.parse(savedValue)); - } - onSet(newValue => { - if (newValue instanceof DefaultValue) { - localStorage.removeItem(key); - } else { - localStorage.setItem(key, JSON.stringify(newValue)); - } - }); - }; - -export const tabState = atomFamily({ - key: 'tabs', - default: _param => 0, - effects_UNSTABLE: param => [localStorageEffect(`TABS__${param}`)], +export const tabState = atomFamily(param => { + const anAtom = atomWithStorage(`TABS__${param}`, 0); + anAtom.debugLabel = `TABS__${param}`; + return anAtom; }); -export const loadingState = atomFamily<'idle' | 'loading', string>({ - key: 'ui.loading', - default: () => 'idle', +type LoadingState = 'idle' | 'loading'; + +export const loadingState = atomFamily(_param => { + const anAtom = atom('idle'); + anAtom.debugLabel = `loading-atom/${_param}`; + return anAtom; }); -export const accountDrawerStep = atom({ - key: 'drawers.accounts.visibility', - default: AccountStep.Switch, -}); +export const accountDrawerStep = atom(AccountStep.Switch); -export const showAccountsStore = atom({ - key: 'drawers.switch-account', - default: false, -}); +// TODO: refactor into atom family +export const showAccountsStore = atom(false); -export const showNetworksStore = atom({ - key: 'drawers.show-networks', - default: false, -}); +export const showNetworksStore = atom(false); -export const showSettingsStore = atom({ - key: 'drawers.show-settings', - default: false, -}); +export const showSettingsStore = atom(false); + +accountDrawerStep.debugLabel = 'accountDrawerStep'; +showAccountsStore.debugLabel = 'showAccountsStore'; +showNetworksStore.debugLabel = 'showNetworksStore'; +showSettingsStore.debugLabel = 'showSettingsStore'; diff --git a/src/store/wallet.ts b/src/store/wallet.ts index 520d02d4..f538ed63 100644 --- a/src/store/wallet.ts +++ b/src/store/wallet.ts @@ -1,5 +1,5 @@ -import { atom, selector } from 'recoil'; -import { localStorageEffect } from './common/utils'; +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; import { Wallet, WalletConfig, @@ -8,44 +8,24 @@ import { } from '@stacks/wallet-sdk'; import { gaiaUrl } from '@common/constants'; -export const secretKeyState = atom({ - key: 'wallet.secret-key', - default: undefined, -}); +export const secretKeyState = atom(undefined); +export const hasSetPasswordState = atom(false); +export const walletState = atom(undefined); +export const encryptedSecretKeyStore = atom(undefined); +export const lastSeenStore = atomWithStorage('wallet.last-seen', new Date().getTime()); +export const walletConfigStore = atom(async get => { + const wallet = get(walletState); + if (!wallet) return null; -export const hasSetPasswordState = atom({ - key: 'wallet.has-set-password', - default: false, + const gaiaHubConfig = await createWalletGaiaConfig({ wallet, gaiaHubUrl: gaiaUrl }); + return fetchWalletConfig({ wallet, gaiaHubConfig }); }); +export const hasRehydratedVaultStore = atom(false); -export const walletState = atom({ - key: 'wallet', - default: undefined, - dangerouslyAllowMutability: true, -}); - -export const encryptedSecretKeyStore = atom({ - key: 'wallet.encrypted-key', - default: undefined, -}); - -export const lastSeenStore = atom({ - key: 'wallet.last-seen', - default: new Date().getTime(), - effects_UNSTABLE: [localStorageEffect()], -}); - -export const walletConfigStore = selector({ - key: 'wallet.wallet-config', - get: async ({ get }) => { - const wallet = get(walletState); - if (!wallet) return null; - const gaiaHubConfig = await createWalletGaiaConfig({ wallet, gaiaHubUrl: gaiaUrl }); - return fetchWalletConfig({ wallet, gaiaHubConfig }); - }, -}); - -export const hasRehydratedVaultStore = atom({ - key: 'wallet.has-rehydrated', - default: false, -}); +secretKeyState.debugLabel = 'secretKeyState'; +hasSetPasswordState.debugLabel = 'hasSetPasswordState'; +walletState.debugLabel = 'walletState'; +encryptedSecretKeyStore.debugLabel = 'encryptedSecretKeyStore'; +lastSeenStore.debugLabel = 'lastSeenStore'; +walletConfigStore.debugLabel = 'walletConfigStore'; +hasRehydratedVaultStore.debugLabel = 'hasRehydratedVaultStore'; diff --git a/webpack/webpack.config.base.js b/webpack/webpack.config.base.js index 79b6a8e1..84dfa05d 100755 --- a/webpack/webpack.config.base.js +++ b/webpack/webpack.config.base.js @@ -130,6 +130,7 @@ const config = { '@babel/preset-react', ], plugins: [ + '@emotion', ['@babel/plugin-proposal-class-properties', { loose: false }], '@babel/plugin-transform-runtime', '@babel/plugin-proposal-nullish-coalescing-operator', diff --git a/yarn.lock b/yarn.lock index 863073f3..6163977c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -991,6 +991,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.6.tgz#535203bc0892efc7dec60bdc27b2ecf6e409062d" + integrity sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.12.13", "@babel/template@^7.3.3": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" @@ -2849,10 +2856,8 @@ type-fest "^0.21.3" typescript "^4.1.2" -"@stacks/ui@7.8.0": +"@stacks/ui@file:.yalc/@stacks/ui": version "7.8.0" - resolved "https://registry.yarnpkg.com/@stacks/ui/-/ui-7.8.0.tgz#03bc42b9c95f432bef6650781bbabd81e19272cb" - integrity sha512-A8l1SKqMYBZ1t13V3yQcXySxv5RkPrjPovjlPoQswaNceS1Eh+UI3r5jMAwqHWDL9LE9G3wjSl4ect7whURkyg== dependencies: "@reach/alert" "^0.13.2" "@reach/auto-id" "^0.13.2" @@ -3438,6 +3443,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/object-hash@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-2.1.0.tgz#284353e535481690a72bf748619d77577bd23317" + integrity sha512-RW3VRiuQIMo5PJ4Q1IwBtdLHL/t8ACpzUY40norN9ejE6CUBwKetmSxJnITJ0NlzN/ymF1nvPvlpvegtns7yOg== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -4631,6 +4641,11 @@ better-path-resolve@1.0.0: dependencies: is-windows "^1.0.0" +big-integer@^1.6.16: + version "1.6.48" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e" + integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -4775,6 +4790,11 @@ boolbase@^1.0.0, boolbase@~1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= +boring-avatars@^1.5.8: + version "1.5.8" + resolved "https://registry.yarnpkg.com/boring-avatars/-/boring-avatars-1.5.8.tgz#736e4e99c2390724f31dbc9175a18ac6b52f8c55" + integrity sha512-Xy9+6UcdwLZ0JDrzY2r0hYZXnQS9ZJRHcW8TdfnBfFcjTq7UfCC7N8g7tu7GD90RCp6XHbzTuJ1QKoK4djcWPA== + boxen@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -4840,6 +4860,20 @@ breakword@^1.0.5: dependencies: wcwidth "^1.0.1" +broadcast-channel@^3.4.1: + version "3.7.0" + resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.7.0.tgz#2dfa5c7b4289547ac3f6705f9c00af8723889937" + integrity sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg== + dependencies: + "@babel/runtime" "^7.7.2" + detect-node "^2.1.0" + js-sha3 "0.8.0" + microseconds "0.2.0" + nano-time "1.0.0" + oblivious-set "1.0.0" + rimraf "3.0.2" + unload "2.2.0" + brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" @@ -6441,7 +6475,7 @@ detect-newline@^3.0.0, detect-newline@^3.1.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== -detect-node@^2.0.4: +detect-node@^2.0.4, detect-node@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== @@ -9695,6 +9729,11 @@ jest@26.6.3: import-local "^3.0.2" jest-cli "^26.6.3" +jotai@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.0.0.tgz#d5e7f7044d89e543a9c7429a9723e11528b2760e" + integrity sha512-6hZGy3hqIlBlLSKppTrxDc1Vb7mi3I8eEQOIu7Kj6ceX1PSzjxdsEVC9TjAqaio8gZJEz+2ufNUf4afvbs0RXg== + joycon@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.0.1.tgz#9074c9b08ccf37a6726ff74a18485f85efcaddaf" @@ -9705,6 +9744,11 @@ jpeg-js@^0.4.2: resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.3.tgz#6158e09f1983ad773813704be80680550eff977b" integrity sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q== +js-sha3@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" + integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -10354,6 +10398,14 @@ marky@^1.2.0: resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.2.tgz#4456765b4de307a13d263a69b0c79bf226e68323" integrity sha512-k1dB2HNeaNyORco8ulVEhctyEGkKHb2YWAhDsxeFlW2nROIirsctBYzKwwS3Vza+sKTS1zO4Z+n9/+9WbGLIxQ== +match-sorter@^6.0.2: + version "6.3.0" + resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.0.tgz#454a1b31ed218cddbce6231a0ecb5fdc549fed01" + integrity sha512-efYOf/wUpNb8FgNY+cOD2EIJI1S5I7YPKsw0LBp7wqPh5pmMS6i/wr3ZWwfwrAw1NvqTA2KUReVRWDX84lUcOQ== + dependencies: + "@babel/runtime" "^7.12.5" + remove-accents "0.4.2" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -10502,6 +10554,11 @@ micromatch@^4.0.0, micromatch@^4.0.2: braces "^3.0.1" picomatch "^2.2.3" +microseconds@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39" + integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA== + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -10699,6 +10756,13 @@ nan@^2.12.1, nan@^2.13.2, nan@^2.14.0: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== +nano-time@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef" + integrity sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8= + dependencies: + big-integer "^1.6.16" + nanoclone@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" @@ -10907,6 +10971,11 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" +object-hash@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + object-inspect@^1.10.3, object-inspect@^1.9.0: version "1.10.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" @@ -10949,6 +11018,11 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" +oblivious-set@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566" + integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw== + obuf@^1.0.0, obuf@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" @@ -11005,6 +11079,11 @@ opn@^5.5.0: dependencies: is-wsl "^1.1.0" +optics-ts@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/optics-ts/-/optics-ts-2.1.0.tgz#dcfd20297ff77ed169d326792733a71e933e801e" + integrity sha512-SJIwf16fwyo9+O+x303BifrWZ/H6Du/hS1zz4UERYt9BaRvxBID9XCYZlQ3c2aIrqeG64AxLcRy8VC/OYUkS9A== + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -11971,7 +12050,7 @@ react-dev-utils@11.0.4: strip-ansi "6.0.0" text-table "0.2.0" -react-dom@17.0.2: +react-dom@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== @@ -12019,6 +12098,15 @@ react-is@^16.7.0, react-is@^16.8.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-query@^3.17.0: + version "3.17.0" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.17.0.tgz#461c0a030044760cd874c7ea8aa9d55c2dceb15d" + integrity sha512-/qUNb6ESCz75Z/bR5p/ztp5ipRj8IQSiIpHK3AkCLTT4IqZsceAoD+9B+wbitA0LkxsR3snGrpgKUc9MMYQ/Ow== + dependencies: + "@babel/runtime" "^7.5.5" + broadcast-channel "^3.4.1" + match-sorter "^6.0.2" + react-refresh@0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.10.0.tgz#2f536c9660c0b9b1d500684d9e52a65e7404f7e3" @@ -12067,14 +12155,6 @@ react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" -react@17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" - integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - react@^16.13.1: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" @@ -12084,6 +12164,14 @@ react@^16.13.1: object-assign "^4.1.1" prop-types "^15.6.2" +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -12318,6 +12406,11 @@ relaxed-json@1.0.3: chalk "^2.4.2" commander "^2.6.0" +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U= + remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" @@ -12486,6 +12579,13 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" @@ -12493,13 +12593,6 @@ rimraf@^2.6.3: dependencies: glob "^7.1.3" -rimraf@^3.0.0, rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rimraf@~2.4.0: version "2.4.5" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" @@ -14198,6 +14291,14 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== +unload@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/unload/-/unload-2.2.0.tgz#ccc88fdcad345faa06a92039ec0f80b488880ef7" + integrity sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA== + dependencies: + "@babel/runtime" "^7.6.2" + detect-node "^2.0.4" + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"