diff --git a/.changeset/khaki-months-fold.md b/.changeset/khaki-months-fold.md new file mode 100644 index 000000000..bb0082d5e --- /dev/null +++ b/.changeset/khaki-months-fold.md @@ -0,0 +1,6 @@ +--- +'@status-im/wallet': patch +'wallet': patch +--- + +feat(wallet): implement multi-wallets diff --git a/.changeset/lovely-mangos-protect.md b/.changeset/lovely-mangos-protect.md index a90947559..0dcd3c153 100644 --- a/.changeset/lovely-mangos-protect.md +++ b/.changeset/lovely-mangos-protect.md @@ -1,7 +1,7 @@ --- -"@status-im/wallet": patch -"status.app": patch -"wallet": patch +'@status-im/wallet': patch +'status.app': patch +'wallet': patch --- feat: connect wallet to dApp diff --git a/apps/wallet/src/components/onboarding/back-button.tsx b/apps/wallet/src/components/onboarding/back-button.tsx new file mode 100644 index 000000000..e2bbfc244 --- /dev/null +++ b/apps/wallet/src/components/onboarding/back-button.tsx @@ -0,0 +1,16 @@ +import { Button } from '@status-im/components' +import { ArrowLeftIcon } from '@status-im/icons/20' + +type Props = { href: string } | { onClick: () => void } + +export function BackButton(props: Props) { + return ( + + + ) +} diff --git a/apps/wallet/src/components/onboarding/flow-layout.tsx b/apps/wallet/src/components/onboarding/flow-layout.tsx new file mode 100644 index 000000000..d36515a02 --- /dev/null +++ b/apps/wallet/src/components/onboarding/flow-layout.tsx @@ -0,0 +1,32 @@ +import { BlurredCircle } from '@status-im/wallet/components' + +type Props = { + children: React.ReactNode +} + +export function OnboardingFlowLayout({ children }: Props) { + return ( +
+ + + + + +
+ {children} +
+
+ ) +} diff --git a/apps/wallet/src/components/onboarding/import-wallet-flow.tsx b/apps/wallet/src/components/onboarding/import-wallet-flow.tsx new file mode 100644 index 000000000..1476f7c52 --- /dev/null +++ b/apps/wallet/src/components/onboarding/import-wallet-flow.tsx @@ -0,0 +1,151 @@ +import { useState, useTransition } from 'react' + +import { Text } from '@status-im/components' +import { + type CreatePasswordFormValues, + ImportRecoveryPhraseForm, + type ImportRecoveryPhraseFormValues, +} from '@status-im/wallet/components' + +import { useImportWallet } from '@/hooks/use-import-wallet' +import { useWalletFlowSuccess } from '@/hooks/use-wallet-flow-success' +import { usePassword } from '@/providers/password-context' + +import { BackButton } from './back-button' +import { CreatePasswordStep } from './create-password-step' + +import type { SubmitHandler } from 'react-hook-form' + +type Props = { + backHref: string + successHref: string + requiresPasswordCreation?: boolean +} + +type ImportFlowStep = + | { step: 'enter-mnemonic'; mnemonic: string } + | { step: 'create-password'; mnemonic: string } + +export function ImportWalletFlow({ + backHref, + successHref, + requiresPasswordCreation = true, +}: Props) { + const { importWalletAsync } = useImportWallet() + const { requestPassword } = usePassword() + const onSuccess = useWalletFlowSuccess(successHref) + const [isLoading, setIsLoading] = useState(false) + const [flowStep, setFlowStep] = useState({ + step: 'enter-mnemonic', + mnemonic: '', + }) + + const completeImport = async (mnemonic: string, password?: string) => { + const wallet = await importWalletAsync({ mnemonic, password }) + await onSuccess(wallet, `Imported ${wallet.name}`) + } + + const handleMnemonicSubmit: SubmitHandler< + ImportRecoveryPhraseFormValues + > = async data => { + if (requiresPasswordCreation) { + setFlowStep({ step: 'create-password', mnemonic: data.mnemonic }) + return + } + + setIsLoading(true) + try { + const isUnlocked = await requestPassword({ + title: 'Enter password', + description: 'To import your wallet', + requireFreshPassword: true, + }) + if (!isUnlocked) return + await completeImport(data.mnemonic) + } catch (error) { + console.error(error) + } finally { + setIsLoading(false) + } + } + + const handlePasswordSubmit: SubmitHandler< + CreatePasswordFormValues + > = async data => { + setIsLoading(true) + try { + await completeImport(flowStep.mnemonic, data.password) + } catch (error) { + console.error(error) + } finally { + setIsLoading(false) + } + } + + if (flowStep.step === 'create-password') { + return ( + + setFlowStep({ + step: 'enter-mnemonic', + mnemonic: flowStep.mnemonic, + }) + } + onSubmit={handlePasswordSubmit} + isLoading={isLoading} + confirmButtonLabel="Import Wallet" + /> + ) + } + + return ( + + ) +} + +function ImportMnemonicStep({ + backHref, + defaultMnemonic, + isLoading, + onSubmit, +}: { + backHref: string + defaultMnemonic: string + isLoading: boolean + onSubmit: SubmitHandler +}) { + const [isPending, startTransition] = useTransition() + const isSubmitting = isPending || isLoading + + const handleSubmit: SubmitHandler = data => { + if (isLoading) return + startTransition(() => { + void onSubmit(data) + }) + } + + return ( +
+
+ +
+ + Import via recovery phrase + + + Type or paste your 12, 15, 18, 21 or 24 words Ethereum recovery phrase + + + +
+ ) +} diff --git a/apps/wallet/src/components/splitted-layout.tsx b/apps/wallet/src/components/splitted-layout.tsx index 1cfcef4ab..095f67fc6 100644 --- a/apps/wallet/src/components/splitted-layout.tsx +++ b/apps/wallet/src/components/splitted-layout.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react' -import { Avatar, Skeleton } from '@status-im/components' +import { Skeleton } from '@status-im/components' import { Balance, StickyHeaderContainer, @@ -12,6 +12,7 @@ import { useWallet } from '@/providers/wallet-context' import { ActionButtons } from '../components/action-buttons' import { RecoveryPhraseBackup } from '../components/recovery-phrase-backup' +import { WalletSelector } from '../components/wallet-selector' import { AccountSkeleton, ActionButtonsSkeleton, @@ -107,20 +108,8 @@ const AccountInfo = () => { } return ( -
- -
- {account.name} -
+
+
) } @@ -188,9 +177,9 @@ const MainContentBody = ({ hideAssetsBelowOneToggle?: HideAssetsBelowOneToggle }) => { const { account, isLoading } = usePortfolio() - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { currentAccount, isLoading: isWalletLoading } = useWallet() - const address = currentWallet?.activeAccounts[0].address + const address = currentAccount?.address if (isLoading || isWalletLoading) { return ( diff --git a/apps/wallet/src/components/wallet-selector.tsx b/apps/wallet/src/components/wallet-selector.tsx new file mode 100644 index 000000000..1ad64885e --- /dev/null +++ b/apps/wallet/src/components/wallet-selector.tsx @@ -0,0 +1,99 @@ +import { Avatar, Button, DropdownMenu /*Tooltip*/ } from '@status-im/components' +import { + AddIcon, + ChevronDownIcon, + ImportIcon, + WalletIcon, +} from '@status-im/icons/20' +// import { shortenAddress } from '@status-im/wallet/components' +import { useNavigate } from '@tanstack/react-router' + +import { useWallet } from '@/providers/wallet-context' + +type Props = { + className?: string +} + +const DEFAULT_ACCOUNT_EMOJI = '🍑' +const DEFAULT_ACCOUNT_NAME = 'Account 1' + +export function WalletSelector(props: Props) { + const { className } = props + const navigate = useNavigate() + const { wallets, currentWallet /*, currentAccount*/, setCurrentWallet } = + useWallet() + + if (!currentWallet) { + return null + } + + return ( +
+
+ +
+
+ {currentWallet.name} +
+ {/* TODO: Uncomment to display current account's name + when multi-account support is implemented */} + {/* {currentAccount?.address ? ( + +
+ {shortenAddress(currentAccount.address)} +
+
+ ) : null} */} +
+
+ + +
+ ) +} diff --git a/apps/wallet/src/data/api.ts b/apps/wallet/src/data/api.ts index b1e0f3330..bdc7b1d18 100644 --- a/apps/wallet/src/data/api.ts +++ b/apps/wallet/src/data/api.ts @@ -87,10 +87,17 @@ const apiRouter = router({ // todo: words count option // todo: handle cancelation add: procedure - .input(z.object({ password: z.string(), name: z.string() })) + .input( + z.object({ + password: z.string().optional(), + name: z.string().optional(), + }), + ) .mutation(async ({ input, ctx }) => { const { walletCore, session } = ctx - const hd = walletCore.HDWallet.create(128, input.password) + const walletCount = (await walletMetadata.getAll()).length + const walletName = input.name ?? `Wallet ${walletCount + 1}` + const hd = walletCore.HDWallet.create(128, input.password ?? '') const mnemonic = hd.mnemonic() hd.delete() const id = crypto.randomUUID() @@ -104,6 +111,9 @@ const apiRouter = router({ if (await hasVault()) { await session.addWalletToVault(id, 'mnemonic', mnemonic) } else { + if (!input.password) { + throw new Error('Password is required to create the first wallet') + } await encryptAndStore(input.password, { wallets: { [id]: { type: 'mnemonic', secret: mnemonic } }, }) @@ -111,13 +121,15 @@ const apiRouter = router({ } await walletMetadata.save({ id, - name: input.name, + name: walletName, type: 'mnemonic', - activeAccounts: [account], + accounts: [account], + selectedAccountAddress: account.address, }) return { // note: reference and store accounts with id, + name: walletName, mnemonic, } }), @@ -142,12 +154,14 @@ const apiRouter = router({ .input( z.object({ mnemonic: z.string(), - name: z.string(), - password: z.string(), + name: z.string().optional(), + password: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { const { walletCore, session } = ctx + const walletCount = (await walletMetadata.getAll()).length + const walletName = input.name ?? `Wallet ${walletCount + 1}` const id = crypto.randomUUID() const account = deriveAccount( walletCore, @@ -159,6 +173,9 @@ const apiRouter = router({ if (await hasVault()) { await session.addWalletToVault(id, 'mnemonic', input.mnemonic) } else { + if (!input.password) { + throw new Error('Password is required to import the first wallet') + } await encryptAndStore(input.password, { wallets: { [id]: { type: 'mnemonic', secret: input.mnemonic } }, }) @@ -166,11 +183,12 @@ const apiRouter = router({ } await walletMetadata.save({ id, - name: input.name, + name: walletName, type: 'mnemonic', - activeAccounts: [account], + accounts: [account], + selectedAccountAddress: account.address, }) - return { id, mnemonic: input.mnemonic } + return { id, name: walletName, mnemonic: input.mnemonic } }), account: router({ @@ -179,7 +197,7 @@ const apiRouter = router({ .query(async ({ input }) => { const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - return wallet.activeAccounts + return wallet.accounts }), ethereum: router({ @@ -196,7 +214,7 @@ const apiRouter = router({ if (!wallet) throw new Error('Wallet not found') const mnemonic = await ctx.session.getMnemonic(input.walletId) const index = deriveNextAccountIndex( - wallet.activeAccounts, + wallet.accounts, walletCore.CoinType.ethereum.value, ) const account = deriveAccount( @@ -227,7 +245,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -272,7 +290,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -318,7 +336,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -360,7 +378,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -405,7 +423,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -493,7 +511,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -528,7 +546,7 @@ const apiRouter = router({ if (!wallet) throw new Error('Wallet not found') const mnemonic = await ctx.session.getMnemonic(input.walletId) const index = deriveNextAccountIndex( - wallet.activeAccounts, + wallet.accounts, walletCore.CoinType.solana.value, ) const account = deriveAccount( @@ -556,7 +574,7 @@ const apiRouter = router({ const { walletCore } = ctx const wallet = await walletMetadata.get(input.walletId) if (!wallet) throw new Error('Wallet not found') - const account = wallet.activeAccounts.find( + const account = wallet.accounts.find( a => a.address === input.fromAddress, ) if (!account) throw new Error('From address not found') @@ -592,7 +610,7 @@ const apiRouter = router({ const mnemonic = await ctx.session.getMnemonic(input.walletId) const index = deriveNextAccountIndex( - wallet.activeAccounts, + wallet.accounts, walletCore.CoinType.cardano.value, ) const account = deriveAccount( @@ -614,12 +632,14 @@ const apiRouter = router({ .input( z.object({ privateKey: z.string(), - name: z.string(), + name: z.string().optional(), password: z.string(), }), ) .mutation(async ({ input, ctx }) => { const { walletCore, session } = ctx + const walletCount = (await walletMetadata.getAll()).length + const walletName = input.name ?? `Wallet ${walletCount + 1}` const hex = input.privateKey.replace(/^0x/, '') const keyBytes = walletCore.HexCoding.decode(hex) const pk = walletCore.PrivateKey.createWithData(keyBytes) @@ -645,9 +665,10 @@ const apiRouter = router({ } await walletMetadata.save({ id, - name: input.name, + name: walletName, type: 'privateKey', - activeAccounts: [account], + accounts: [account], + selectedAccountAddress: account.address, }) return { // reference stored (single) account diff --git a/apps/wallet/src/data/session.ts b/apps/wallet/src/data/session.ts index f63462c65..89f0eb3c5 100644 --- a/apps/wallet/src/data/session.ts +++ b/apps/wallet/src/data/session.ts @@ -66,19 +66,18 @@ async function migrateFromLegacy( try { const secret = (await keyStore.export(w.id, password)) as string secrets.wallets[w.id] = { type: 'mnemonic', secret } - const activeAccounts: WalletAccount[] = (w.activeAccounts ?? []).map( - a => ({ - address: a.address, - coin: a.coin ?? 60, - derivationPath: a.derivationPath ?? FALLBACK_DERIVATION_PATH, - derivation: a.derivation ?? 0, - }), - ) + const accounts: WalletAccount[] = (w.activeAccounts ?? []).map(a => ({ + address: a.address, + coin: a.coin ?? 60, + derivationPath: a.derivationPath ?? FALLBACK_DERIVATION_PATH, + derivation: a.derivation ?? 0, + })) await walletMetadata.save({ id: w.id, name: w.name, type: 'mnemonic', - activeAccounts, + accounts, + selectedAccountAddress: accounts[0]?.address, }) await keyStore.delete(w.id, password) } catch (err) { diff --git a/apps/wallet/src/data/wallet-metadata.ts b/apps/wallet/src/data/wallet-metadata.ts index d3826cc3d..68c823163 100644 --- a/apps/wallet/src/data/wallet-metadata.ts +++ b/apps/wallet/src/data/wallet-metadata.ts @@ -13,10 +13,75 @@ export type WalletMeta = { id: string name: string type: 'mnemonic' | 'privateKey' - activeAccounts: WalletAccount[] + accounts: WalletAccount[] + selectedAccountAddress?: string } -type MetadataStore = Record +// TODO: remove LegacyWalletMeta once no user has legacy wallets; use WalletMeta directly in MetadataStore +type LegacyWalletMeta = { + id: string + name: string + type: 'mnemonic' | 'privateKey' + activeAccounts?: WalletAccount[] + accounts?: WalletAccount[] + selectedAccountAddress?: string +} + +type MetadataStore = Record + +// TODO: remove LEGACY_WALLET_NAME, WALLET_NAME_REGEX, and renameLegacyWallets once no user has legacy wallets +const LEGACY_WALLET_NAME = 'Account 1' +// Matches "Wallet 1", "Wallet 42", etc. & captures the trailing integer +const WALLET_NAME_REGEX = /^Wallet (\d+)$/ + +// TODO: remove isNormalized and normalizeWallet once no user has legacy wallets +function isNormalized(wallet: LegacyWalletMeta): wallet is WalletMeta { + return wallet.accounts !== undefined && wallet.activeAccounts === undefined +} + +function normalizeWallet(wallet: LegacyWalletMeta): WalletMeta { + if (isNormalized(wallet)) return wallet + + const accounts = wallet.accounts ?? wallet.activeAccounts ?? [] + const selectedAccountAddress = accounts.some( + account => account.address === wallet.selectedAccountAddress, + ) + ? wallet.selectedAccountAddress + : accounts[0]?.address + + return { + id: wallet.id, + name: wallet.name, + type: wallet.type, + accounts, + selectedAccountAddress, + } +} + +function renameLegacyWallets(wallets: WalletMeta[]): WalletMeta[] { + const legacyCount = wallets.filter(w => w.name === LEGACY_WALLET_NAME).length + if (legacyCount === 0) return wallets + + // Collect integers already used by "Wallet N" names to avoid collisions + const takenNumbers = new Set() + for (const w of wallets) { + const match = WALLET_NAME_REGEX.exec(w.name) + if (match) takenNumbers.add(Number(match[1])) + } + + // Pre-compute exactly as many free integers as there are legacy wallets + const available: number[] = [] + for (let n = 1; available.length < legacyCount; n++) { + if (!takenNumbers.has(n)) available.push(n) + } + + // Assign each legacy wallet the next free "Wallet N" name in order + let idx = 0 + return wallets.map(w => { + if (w.name !== LEGACY_WALLET_NAME) return w + return { ...w, name: `Wallet ${available[idx++]}` } + }) +} async function getStore(): Promise { const data = await storage.getItem(VAULT_METADATA_KEY) @@ -30,17 +95,27 @@ async function setStore(store: MetadataStore): Promise { export async function getAll(): Promise { const store = await getStore() - return Object.values(store) + const wallets = Object.values(store).map(normalizeWallet) + const renamed = renameLegacyWallets(wallets) + + // Persist renames so legacy names are updated once and not re-computed every read + if (renamed !== wallets) { + const updated: MetadataStore = {} + for (const w of renamed) updated[w.id] = w + await setStore(updated) + } + + return renamed } export async function get(walletId: string): Promise { - const store = await getStore() - return store[walletId] ?? null + const all = await getAll() + return all.find(w => w.id === walletId) ?? null } export async function save(wallet: WalletMeta): Promise { const store = await getStore() - store[wallet.id] = wallet + store[wallet.id] = normalizeWallet(wallet) await setStore(store) } @@ -50,8 +125,11 @@ export async function addAccount( ): Promise { const wallet = await get(walletId) if (!wallet) throw new Error(`Wallet ${walletId} not found`) - if (wallet.activeAccounts.some(a => a.address === account.address)) return - wallet.activeAccounts.push(account) + if (wallet.accounts.some(a => a.address === account.address)) return + wallet.accounts.push(account) + if (!wallet.selectedAccountAddress) { + wallet.selectedAccountAddress = account.address + } await save(wallet) } diff --git a/apps/wallet/src/hooks/use-create-wallet.ts b/apps/wallet/src/hooks/use-create-wallet.ts index ad22792eb..dbd4c583b 100644 --- a/apps/wallet/src/hooks/use-create-wallet.ts +++ b/apps/wallet/src/hooks/use-create-wallet.ts @@ -10,12 +10,12 @@ export const useCreateWallet = () => { const { mutate, mutateAsync, ...result } = useMutation({ mutationKey: ['create-wallet'], - mutationFn: async (password: string) => { - await api.wallet.add.mutate({ + mutationFn: async (password?: string) => { + const createdWallet = await api.wallet.add.mutate({ password, - name: 'Account 1', }) await markAsNeedsBackup() + return createdWallet }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['wallets'] }) diff --git a/apps/wallet/src/hooks/use-import-wallet.ts b/apps/wallet/src/hooks/use-import-wallet.ts index 5bedae32e..cb0087c50 100644 --- a/apps/wallet/src/hooks/use-import-wallet.ts +++ b/apps/wallet/src/hooks/use-import-wallet.ts @@ -13,12 +13,11 @@ export const useImportWallet = () => { password, }: { mnemonic: string - password: string + password?: string }) => { - await api.wallet.import.mutate({ + return api.wallet.import.mutate({ mnemonic, password, - name: 'Account 1', }) }, onSuccess: () => { diff --git a/apps/wallet/src/hooks/use-portfolio.ts b/apps/wallet/src/hooks/use-portfolio.ts index 656850440..99ddaada2 100644 --- a/apps/wallet/src/hooks/use-portfolio.ts +++ b/apps/wallet/src/hooks/use-portfolio.ts @@ -9,22 +9,27 @@ const DEFAULT_SUMMARY = { } const MOCK_ACCOUNT = { - name: 'Account 1', emoji: '🍑', color: 'magenta', + name: 'Account 1', } const usePortfolio = () => { - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { + currentWallet, + currentAccount, + isLoading: isWalletLoading, + } = useWallet() const { data: assetsData, isLoading: isAssetsLoading } = useAssets({ - address: currentWallet?.activeAccounts[0]?.address, + address: currentAccount?.address, isWalletLoading, }) const account = { ...MOCK_ACCOUNT, - name: currentWallet?.name || MOCK_ACCOUNT.name, + // TODO: Use currently selected account name instead when multi-account support is implemented. + name: currentWallet?.name ?? MOCK_ACCOUNT.name, } const summary = assetsData?.summary || DEFAULT_SUMMARY diff --git a/apps/wallet/src/hooks/use-wallet-flow-success.ts b/apps/wallet/src/hooks/use-wallet-flow-success.ts new file mode 100644 index 000000000..3f479cb51 --- /dev/null +++ b/apps/wallet/src/hooks/use-wallet-flow-success.ts @@ -0,0 +1,20 @@ +import { useToast } from '@status-im/components' +import { useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' + +import { useWallet } from '@/providers/wallet-context' + +export function useWalletFlowSuccess(successHref: string) { + const { setCurrentWallet } = useWallet() + const queryClient = useQueryClient() + const navigate = useNavigate() + const toast = useToast() + + return async (wallet: { id: string; name: string }, message: string) => { + await queryClient.invalidateQueries({ queryKey: ['wallets'] }) + setCurrentWallet(wallet.id) + toast.positive(message) + await queryClient.invalidateQueries({ queryKey: ['session', 'status'] }) + navigate({ to: successHref }) + } +} diff --git a/apps/wallet/src/lib/rpc-handler.ts b/apps/wallet/src/lib/rpc-handler.ts index 37dfe67cc..f2fcbe5e0 100644 --- a/apps/wallet/src/lib/rpc-handler.ts +++ b/apps/wallet/src/lib/rpc-handler.ts @@ -18,6 +18,7 @@ const publicClient = createPublicClient({ const DEFAULT_CHAIN_ID = '0x1' const SUPPORTED_CHAIN_IDS = new Set(['0x1', '0x6300b5ea']) +const DEFAULT_ACCOUNT_NAME = 'Account 1' // 5 minutes in ms const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 @@ -47,7 +48,7 @@ async function getAddress(): Promise { async function getAccountName(): Promise { const result = await chrome.storage.session.get('dappAccountName') - return (result.dappAccountName as string) || 'Account 1' + return (result.dappAccountName as string) || DEFAULT_ACCOUNT_NAME } async function getWalletId(): Promise { diff --git a/apps/wallet/src/providers/signer-context.tsx b/apps/wallet/src/providers/signer-context.tsx index 44091ce60..8f653c75a 100644 --- a/apps/wallet/src/providers/signer-context.tsx +++ b/apps/wallet/src/providers/signer-context.tsx @@ -32,6 +32,8 @@ type SignerContextValue = { requestUnlock: () => Promise } +const DEFAULT_ACCOUNT_NAME = 'Account 1' + const SignerContext = createContext(undefined) export function useWalletSigner() { @@ -43,13 +45,17 @@ export function useWalletSigner() { } export function SignerProvider({ children }: { children: React.ReactNode }) { - const { currentWallet } = useWallet() + const { currentWallet, currentAccount } = useWallet() const { hasActiveSession, requestPassword, clearSession } = usePassword() - const address = currentWallet?.activeAccounts[0]?.address as - | Address - | undefined - const accountName = currentWallet?.name ?? 'Account 1' + const address = useMemo(() => { + return currentAccount?.address as Address | undefined + }, [currentAccount]) + + const accountName = useMemo(() => { + // TODO: Use currently selected account name instead when multi-account support is implemented. + return currentWallet?.name ?? DEFAULT_ACCOUNT_NAME + }, [currentWallet]) useEffect(() => { if (address) { diff --git a/apps/wallet/src/providers/wallet-context.tsx b/apps/wallet/src/providers/wallet-context.tsx index 5cbfdccfb..72aecd2f9 100644 --- a/apps/wallet/src/providers/wallet-context.tsx +++ b/apps/wallet/src/providers/wallet-context.tsx @@ -8,19 +8,22 @@ import { } from 'react' import { useQuery } from '@tanstack/react-query' +import { storage } from '@wxt-dev/storage' import { useSynchronizedRefetch } from '../hooks/use-synchronized-refetch' import { apiClient } from './api-client' -import type { WalletMeta } from '../data/wallet-metadata' +import type { WalletAccount, WalletMeta } from '../data/wallet-metadata' const WALLET_LIST_STALE_TIME_MS = 5 * 60 * 1000 // 5 minutes const WALLET_LIST_GC_TIME_MS = 60 * 60 * 1000 // 1 hour +const SELECTED_WALLET_ID_KEY = 'local:wallet:selected-id' type Wallet = WalletMeta type WalletContext = { currentWallet: Wallet | null + currentAccount: WalletAccount | null wallets: Wallet[] isLoading: boolean hasWallets: boolean @@ -39,6 +42,8 @@ export function useWallet() { export function WalletProvider({ children }: { children: React.ReactNode }) { const [selectedWalletId, setSelectedWalletId] = useState(null) + const [hasHydratedSelectedWallet, setHasHydratedSelectedWallet] = + useState(false) const { data: wallets = [], isLoading } = useQuery({ queryKey: ['wallets'], @@ -62,29 +67,73 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { return wallets[0] || null }, [hasWallets, selectedWalletId, wallets]) + const currentAccount = useMemo(() => { + if (!currentWallet) return null + // TODO: Use currently selected account when multi-account support is implemented. See ^^^ + return currentWallet.accounts[0] ?? null + }, [currentWallet]) + useEffect(() => { - if (hasWallets && !selectedWalletId && wallets[0]) { + if ( + hasWallets && + !selectedWalletId && + wallets[0] && + hasHydratedSelectedWallet + ) { setSelectedWalletId(wallets[0].id) } - }, [hasWallets, selectedWalletId, wallets]) + }, [hasHydratedSelectedWallet, hasWallets, selectedWalletId, wallets]) - const setCurrentWallet = useCallback( - (id: string) => { - const walletExists = wallets.some(w => w.id === id) - if (walletExists) { - setSelectedWalletId(id) - } else { - console.error(`Wallet with id ${id} not found`) + useEffect(() => { + let isCancelled = false + + async function hydrateSelectedWallet() { + const persistedSelectedWalletId = await storage.getItem( + SELECTED_WALLET_ID_KEY, + ) + if (isCancelled) return + if (persistedSelectedWalletId) { + setSelectedWalletId(persistedSelectedWalletId) } - }, - [wallets], - ) + setHasHydratedSelectedWallet(true) + } + + hydrateSelectedWallet().catch(error => { + console.error('Failed to hydrate selected wallet id:', error) + setHasHydratedSelectedWallet(true) + }) + + return () => { + isCancelled = true + } + }, []) + + useEffect(() => { + if (!hasHydratedSelectedWallet) return + + const persistSelectedWallet = async () => { + if (selectedWalletId) { + await storage.setItem(SELECTED_WALLET_ID_KEY, selectedWalletId) + return + } + await storage.removeItem(SELECTED_WALLET_ID_KEY) + } + + persistSelectedWallet().catch(error => + console.error('Failed to persist selected wallet id:', error), + ) + }, [hasHydratedSelectedWallet, selectedWalletId]) + + const setCurrentWallet = useCallback((id: string) => { + setSelectedWalletId(id) + }, []) // Auto-refresh - useSynchronizedRefetch(currentWallet?.activeAccounts[0]?.address ?? '') + useSynchronizedRefetch(currentAccount?.address ?? '') const contextValue: WalletContext = { currentWallet, + currentAccount, wallets, isLoading, hasWallets, diff --git a/apps/wallet/src/router.gen.ts b/apps/wallet/src/router.gen.ts index 13245845e..a81f2ef90 100644 --- a/apps/wallet/src/router.gen.ts +++ b/apps/wallet/src/router.gen.ts @@ -12,6 +12,8 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as OnboardingLayoutRouteImport } from './routes/onboarding/_layout' import { Route as IndexRouteImport } from './routes/index' import { Route as OnboardingIndexRouteImport } from './routes/onboarding/index' +import { Route as WalletFlowNewRouteImport } from './routes/wallet-flow/new' +import { Route as WalletFlowImportRouteImport } from './routes/wallet-flow/import' import { Route as OnboardingNewRouteImport } from './routes/onboarding/new' import { Route as OnboardingImportRouteImport } from './routes/onboarding/import' import { Route as PortfolioCollectiblesIndexRouteImport } from './routes/portfolio/collectibles/index' @@ -35,6 +37,16 @@ const OnboardingIndexRoute = OnboardingIndexRouteImport.update({ path: '/', getParentRoute: () => OnboardingLayoutRoute, } as any) +const WalletFlowNewRoute = WalletFlowNewRouteImport.update({ + id: '/wallet-flow/new', + path: '/wallet-flow/new', + getParentRoute: () => rootRouteImport, +} as any) +const WalletFlowImportRoute = WalletFlowImportRouteImport.update({ + id: '/wallet-flow/import', + path: '/wallet-flow/import', + getParentRoute: () => rootRouteImport, +} as any) const OnboardingNewRoute = OnboardingNewRouteImport.update({ id: '/new', path: '/new', @@ -78,6 +90,8 @@ export interface FileRoutesByFullPath { '/onboarding': typeof OnboardingLayoutRouteWithChildren '/onboarding/import': typeof OnboardingImportRoute '/onboarding/new': typeof OnboardingNewRoute + '/wallet-flow/import': typeof WalletFlowImportRoute + '/wallet-flow/new': typeof WalletFlowNewRoute '/onboarding/': typeof OnboardingIndexRoute '/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute '/portfolio/activity': typeof PortfolioActivityIndexRoute @@ -89,6 +103,8 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/onboarding/import': typeof OnboardingImportRoute '/onboarding/new': typeof OnboardingNewRoute + '/wallet-flow/import': typeof WalletFlowImportRoute + '/wallet-flow/new': typeof WalletFlowNewRoute '/onboarding': typeof OnboardingIndexRoute '/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute '/portfolio/activity': typeof PortfolioActivityIndexRoute @@ -102,6 +118,8 @@ export interface FileRoutesById { '/onboarding': typeof OnboardingLayoutRouteWithChildren '/onboarding/import': typeof OnboardingImportRoute '/onboarding/new': typeof OnboardingNewRoute + '/wallet-flow/import': typeof WalletFlowImportRoute + '/wallet-flow/new': typeof WalletFlowNewRoute '/onboarding/': typeof OnboardingIndexRoute '/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute '/portfolio/activity/': typeof PortfolioActivityIndexRoute @@ -116,6 +134,8 @@ export interface FileRouteTypes { | '/onboarding' | '/onboarding/import' | '/onboarding/new' + | '/wallet-flow/import' + | '/wallet-flow/new' | '/onboarding/' | '/portfolio/assets/$ticker' | '/portfolio/activity' @@ -127,6 +147,8 @@ export interface FileRouteTypes { | '/' | '/onboarding/import' | '/onboarding/new' + | '/wallet-flow/import' + | '/wallet-flow/new' | '/onboarding' | '/portfolio/assets/$ticker' | '/portfolio/activity' @@ -139,6 +161,8 @@ export interface FileRouteTypes { | '/onboarding' | '/onboarding/import' | '/onboarding/new' + | '/wallet-flow/import' + | '/wallet-flow/new' | '/onboarding/' | '/portfolio/assets/$ticker' | '/portfolio/activity/' @@ -150,6 +174,8 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute OnboardingLayoutRoute: typeof OnboardingLayoutRouteWithChildren + WalletFlowImportRoute: typeof WalletFlowImportRoute + WalletFlowNewRoute: typeof WalletFlowNewRoute PortfolioAssetsTickerRoute: typeof PortfolioAssetsTickerRoute PortfolioActivityIndexRoute: typeof PortfolioActivityIndexRoute PortfolioAssetsIndexRoute: typeof PortfolioAssetsIndexRoute @@ -180,6 +206,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OnboardingIndexRouteImport parentRoute: typeof OnboardingLayoutRoute } + '/wallet-flow/new': { + id: '/wallet-flow/new' + path: '/wallet-flow/new' + fullPath: '/wallet-flow/new' + preLoaderRoute: typeof WalletFlowNewRouteImport + parentRoute: typeof rootRouteImport + } + '/wallet-flow/import': { + id: '/wallet-flow/import' + path: '/wallet-flow/import' + fullPath: '/wallet-flow/import' + preLoaderRoute: typeof WalletFlowImportRouteImport + parentRoute: typeof rootRouteImport + } '/onboarding/new': { id: '/onboarding/new' path: '/new' @@ -250,6 +290,8 @@ const OnboardingLayoutRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, OnboardingLayoutRoute: OnboardingLayoutRouteWithChildren, + WalletFlowImportRoute: WalletFlowImportRoute, + WalletFlowNewRoute: WalletFlowNewRoute, PortfolioAssetsTickerRoute: PortfolioAssetsTickerRoute, PortfolioActivityIndexRoute: PortfolioActivityIndexRoute, PortfolioAssetsIndexRoute: PortfolioAssetsIndexRoute, diff --git a/apps/wallet/src/routes/__root.tsx b/apps/wallet/src/routes/__root.tsx index 6b73959ec..3d962c56c 100644 --- a/apps/wallet/src/routes/__root.tsx +++ b/apps/wallet/src/routes/__root.tsx @@ -48,6 +48,10 @@ export const Route = createRootRouteWithContext<{ throw redirect({ to: '/onboarding' }) } + if (location.pathname.startsWith('/wallet-flow') && !hasWallets) { + throw redirect({ to: '/onboarding' }) + } + if (location.pathname.startsWith('/onboarding') && hasWallets) { throw redirect({ to: '/portfolio/assets' }) } diff --git a/apps/wallet/src/routes/onboarding/_layout.tsx b/apps/wallet/src/routes/onboarding/_layout.tsx index 527e92da8..6a4b17e70 100644 --- a/apps/wallet/src/routes/onboarding/_layout.tsx +++ b/apps/wallet/src/routes/onboarding/_layout.tsx @@ -1,33 +1,15 @@ -import { BlurredCircle } from '@status-im/wallet/components' import { createFileRoute, Outlet } from '@tanstack/react-router' +import { OnboardingFlowLayout } from '@/components/onboarding/flow-layout' + export const Route = createFileRoute('/onboarding')({ component: RouteComponent, }) function RouteComponent() { return ( -
- - - - - -
- -
-
+ + + ) } diff --git a/apps/wallet/src/routes/onboarding/import.tsx b/apps/wallet/src/routes/onboarding/import.tsx index 5a8ae84b0..a7ad8122c 100644 --- a/apps/wallet/src/routes/onboarding/import.tsx +++ b/apps/wallet/src/routes/onboarding/import.tsx @@ -1,164 +1,13 @@ -import { useState, useTransition } from 'react' +import { createFileRoute } from '@tanstack/react-router' -import { Button, Text } from '@status-im/components' -import { ArrowLeftIcon } from '@status-im/icons/20' -import { - CreatePasswordForm, - type CreatePasswordFormValues, - ImportRecoveryPhraseForm, - type ImportRecoveryPhraseFormValues, -} from '@status-im/wallet/components' -import { useQueryClient } from '@tanstack/react-query' -import { createFileRoute, useNavigate } from '@tanstack/react-router' - -import { useImportWallet } from '../../hooks/use-import-wallet' - -import type { SubmitHandler } from 'react-hook-form' +import { ImportWalletFlow } from '@/components/onboarding/import-wallet-flow' export const Route = createFileRoute('/onboarding/import')({ component: RouteComponent, }) -type OnboardingState = - | { - type: 'import-wallet' - mnemonic: string - } - | { - type: 'create-password' - mnemonic: string - } - function RouteComponent() { - const [onboardingState, setOnboardingState] = useState({ - type: 'import-wallet', - mnemonic: '', - }) - - return ( -
- {onboardingState.type === 'import-wallet' && ( - { - setOnboardingState({ type: 'create-password', mnemonic }) - }} - mnemonic={onboardingState.mnemonic} - /> - )} - {onboardingState.type === 'create-password' && ( - - setOnboardingState({ - type: 'import-wallet', - mnemonic, - }) - } - /> - )} -
- ) -} - -function ImportWallet({ - onNext, - mnemonic, -}: { - onNext: (mnemonic: string) => void - mnemonic: string -}) { - const [isPending, startTransition] = useTransition() - - const onSubmit: SubmitHandler< - ImportRecoveryPhraseFormValues - > = async data => { - try { - startTransition(() => { - onNext(data.mnemonic) - }) - } catch (error) { - console.error(error) - } - } - return ( -
-
-
- - Import via recovery phrase - - - Type or paste your 12, 15, 18, 21 or 24 words Ethereum recovery phrase - - - -
- ) -} - -function CreatePassword({ - mnemonic, - onBack, -}: { - mnemonic: string - onBack: (mnemonic: string) => void -}) { - const { importWalletAsync } = useImportWallet() - const queryClient = useQueryClient() - const navigate = useNavigate() - const [isLoading, setIsLoading] = useState(false) - - const handleSubmit: SubmitHandler = async data => { - setIsLoading(true) - try { - await importWalletAsync({ - mnemonic, - password: data.password, - }) - await queryClient.invalidateQueries({ queryKey: ['session', 'status'] }) - navigate({ to: '/portfolio/assets' }) - } catch (error) { - console.error(error) - } finally { - setIsLoading(false) - } - } - - return ( -
-
-
- -

Create password

-
- To unlock the extension and sign transactions, the password is stored - only on your device. Status can't recover it. -
- - -
+ ) } diff --git a/apps/wallet/src/routes/onboarding/new.tsx b/apps/wallet/src/routes/onboarding/new.tsx index 1ee76c4b6..f5adc2ae1 100644 --- a/apps/wallet/src/routes/onboarding/new.tsx +++ b/apps/wallet/src/routes/onboarding/new.tsx @@ -1,59 +1,13 @@ -import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' -import { Button } from '@status-im/components' -import { ArrowLeftIcon } from '@status-im/icons/20' -import { CreatePasswordForm } from '@status-im/wallet/components' -import { useQueryClient } from '@tanstack/react-query' -import { createFileRoute, useNavigate } from '@tanstack/react-router' - -import { useCreateWallet } from '../../hooks/use-create-wallet' - -import type { CreatePasswordFormValues } from '@status-im/wallet/components' -import type { SubmitHandler } from 'react-hook-form' +import { CreateWalletFlow } from '@/components/onboarding/create-wallet-flow' export const Route = createFileRoute('/onboarding/new')({ component: RouteComponent, }) function RouteComponent() { - const { createWalletAsync } = useCreateWallet() - const queryClient = useQueryClient() - const navigate = useNavigate() - const [isLoading, setIsLoading] = useState(false) - - const handleSubmit: SubmitHandler = async data => { - setIsLoading(true) - try { - await createWalletAsync(data.password) - await queryClient.invalidateQueries({ queryKey: ['session', 'status'] }) - navigate({ to: '/portfolio/assets' }) - } catch (error) { - console.error(error) - } finally { - setIsLoading(false) - } - } - return ( -
-
-
-
-

Create password

-
- To unlock the extension and sign transactions, the password is stored - only on your device. Status can't recover it. -
- - -
-
+ ) } diff --git a/apps/wallet/src/routes/portfolio/activity/index.tsx b/apps/wallet/src/routes/portfolio/activity/index.tsx index 02bdb585e..e5f32e479 100644 --- a/apps/wallet/src/routes/portfolio/activity/index.tsx +++ b/apps/wallet/src/routes/portfolio/activity/index.tsx @@ -23,10 +23,14 @@ export const Route = createFileRoute('/portfolio/activity/')({ }) function RouteComponent() { - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { + currentWallet, + currentAccount, + isLoading: isWalletLoading, + } = useWallet() const { isPinExtension, handleClose } = usePinExtension() const { pendingTransactions } = usePendingTransactions() - const address = currentWallet?.activeAccounts[0].address + const address = currentAccount?.address const toast = useToast() diff --git a/apps/wallet/src/routes/portfolio/assets/$ticker.tsx b/apps/wallet/src/routes/portfolio/assets/$ticker.tsx index af356a436..1cdf65825 100644 --- a/apps/wallet/src/routes/portfolio/assets/$ticker.tsx +++ b/apps/wallet/src/routes/portfolio/assets/$ticker.tsx @@ -21,7 +21,11 @@ export const Route = createFileRoute('/portfolio/assets/$ticker')({ }) function Component() { - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { + currentWallet, + currentAccount, + isLoading: isWalletLoading, + } = useWallet() const isDesktop = useMediaQuery('xl') const params = Route.useParams() @@ -29,7 +33,7 @@ function Component() { const router = useRouter() const routerState = useRouterState() const pathname = routerState.location.pathname - const address = currentWallet?.activeAccounts[0].address + const address = currentAccount?.address const { data: assets, isLoading } = useAssets({ address, diff --git a/apps/wallet/src/routes/portfolio/assets/index.tsx b/apps/wallet/src/routes/portfolio/assets/index.tsx index c2a3666e1..ba39d85ce 100644 --- a/apps/wallet/src/routes/portfolio/assets/index.tsx +++ b/apps/wallet/src/routes/portfolio/assets/index.tsx @@ -24,12 +24,16 @@ export const Route = createFileRoute('/portfolio/assets/')({ }) function Component() { - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { + currentWallet, + currentAccount, + isLoading: isWalletLoading, + } = useWallet() const { isPinExtension, handleClose } = usePinExtension() const toast = useToast() - const address = currentWallet?.activeAccounts?.[0]?.address + const address = currentAccount?.address const router = useRouter() const { data, isLoading, isError } = useAssets({ diff --git a/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx b/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx index d836c7e93..5508fbf7e 100644 --- a/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx +++ b/apps/wallet/src/routes/portfolio/collectibles/$network/$contract/$id.tsx @@ -25,7 +25,11 @@ export const Route = createFileRoute( }) function Component() { - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { + currentWallet, + currentAccount, + isLoading: isWalletLoading, + } = useWallet() const routerState = useRouterState() const params = Route.useParams() @@ -37,7 +41,7 @@ function Component() { const search = searchParams.get('search') ?? undefined const pathname = routerState.location.pathname - const address = currentWallet?.activeAccounts[0].address + const address = currentAccount?.address const { data, diff --git a/apps/wallet/src/routes/portfolio/collectibles/index.tsx b/apps/wallet/src/routes/portfolio/collectibles/index.tsx index f5ffb8ffe..c6970172a 100644 --- a/apps/wallet/src/routes/portfolio/collectibles/index.tsx +++ b/apps/wallet/src/routes/portfolio/collectibles/index.tsx @@ -23,7 +23,11 @@ export const Route = createFileRoute('/portfolio/collectibles/')({ }) function Component() { - const { currentWallet, isLoading: isWalletLoading } = useWallet() + const { + currentWallet, + currentAccount, + isLoading: isWalletLoading, + } = useWallet() const { isPinExtension, handleClose } = usePinExtension() const toast = useToast() @@ -32,7 +36,7 @@ function Component() { const search = searchParams.get('search') ?? undefined const pathname = window.location.pathname - const address = currentWallet?.activeAccounts[0].address + const address = currentAccount?.address const { data, diff --git a/apps/wallet/src/routes/wallet-flow/import.tsx b/apps/wallet/src/routes/wallet-flow/import.tsx new file mode 100644 index 000000000..1f6c5dc2d --- /dev/null +++ b/apps/wallet/src/routes/wallet-flow/import.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { OnboardingFlowLayout } from '@/components/onboarding/flow-layout' +import { ImportWalletFlow } from '@/components/onboarding/import-wallet-flow' + +export const Route = createFileRoute('/wallet-flow/import')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + + + + ) +} diff --git a/apps/wallet/src/routes/wallet-flow/new.tsx b/apps/wallet/src/routes/wallet-flow/new.tsx new file mode 100644 index 000000000..bb2b53e61 --- /dev/null +++ b/apps/wallet/src/routes/wallet-flow/new.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { CreateWalletFlow } from '@/components/onboarding/create-wallet-flow' +import { OnboardingFlowLayout } from '@/components/onboarding/flow-layout' + +export const Route = createFileRoute('/wallet-flow/new')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( + + + + ) +} diff --git a/packages/wallet/src/components/sticky-header-container/index.tsx b/packages/wallet/src/components/sticky-header-container/index.tsx index 3b26c896a..7b0040c60 100644 --- a/packages/wallet/src/components/sticky-header-container/index.tsx +++ b/packages/wallet/src/components/sticky-header-container/index.tsx @@ -30,8 +30,8 @@ const backgroundStyles = cva( const leftSlotStyles = cva('transition-opacity duration-100 ease-in-out', { variants: { collapsed: { - false: 'opacity-[0]', - true: 'opacity-[1]', + false: 'pointer-events-none opacity-[0]', + true: 'pointer-events-auto opacity-[1]', }, }, }) @@ -66,6 +66,14 @@ type Props = { const SMALL_THRESHOLD = 42 const LARGE_THRESHOLD = 132 +const getFocusGuard = + (isVisible: boolean): React.FocusEventHandler => + event => { + if (isVisible) return + if (!(event.target instanceof HTMLElement)) return + event.target.blur() + } + const StickyHeaderContainer = (props: Props) => { const { leftSlot, @@ -126,10 +134,23 @@ const StickyHeaderContainer = (props: Props) => { )} >
-
{leftSlot}
-
{rightSlot}
+
+ {leftSlot} +
+
+ {rightSlot} +
-
+
{secondaryLeftSlot}