diff --git a/.eslintrc.js b/.eslintrc.js index 0b931dfd2ed..30248d7375c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,7 @@ module.exports = { // methods, such as implicit use of signed transactions 'deprecation/deprecation': 'warn', 'no-console': ['error'], + 'no-duplicate-imports': ['error'], 'prefer-const': [ 'error', { diff --git a/package.json b/package.json index 742d2d76b83..8dea4cf12e7 100644 --- a/package.json +++ b/package.json @@ -251,14 +251,14 @@ "@sentry/webpack-plugin": "2.10.2", "@stacks/connect-react": "22.2.0", "@stacks/stacks-blockchain-api-types": "6.3.4", - "@storybook/addon-essentials": "7.6.7", - "@storybook/addon-interactions": "7.6.7", - "@storybook/addon-links": "7.6.7", + "@storybook/addon-essentials": "7.6.10", + "@storybook/addon-interactions": "7.6.10", + "@storybook/addon-links": "7.6.10", "@storybook/addon-onboarding": "1.0.10", - "@storybook/blocks": "7.6.7", - "@storybook/react": "7.6.7", - "@storybook/react-webpack5": "7.6.7", - "@storybook/test": "7.6.7", + "@storybook/blocks": "7.6.10", + "@storybook/react": "7.6.10", + "@storybook/react-webpack5": "7.6.10", + "@storybook/test": "7.6.10", "@types/argon2-browser": "1.18.2", "@types/chroma-js": "2.4.1", "@types/chrome": "0.0.246", @@ -319,7 +319,7 @@ "react-refresh": "0.14.0", "schema-inspector": "2.0.2", "speed-measure-webpack-plugin": "1.5.0", - "storybook": "7.6.7", + "storybook": "7.6.10", "stream-browserify": "3.0.0", "svg-url-loader": "8.0.0", "ts-node": "10.9.2", diff --git a/src/app/common/account-restoration/legacy-gaia-config-lookup.ts b/src/app/common/account-restoration/legacy-gaia-config-lookup.ts index 0736769f574..cd905ce1730 100644 --- a/src/app/common/account-restoration/legacy-gaia-config-lookup.ts +++ b/src/app/common/account-restoration/legacy-gaia-config-lookup.ts @@ -1,5 +1,9 @@ -import { fetchWalletConfig, generateWallet } from '@stacks/wallet-sdk'; -import { connectToGaiaHubWithConfig, getHubInfo } from '@stacks/wallet-sdk'; +import { + connectToGaiaHubWithConfig, + fetchWalletConfig, + generateWallet, + getHubInfo, +} from '@stacks/wallet-sdk'; import { gaiaUrl as gaiaHubUrl } from '@shared/constants'; diff --git a/src/app/common/hooks/use-bitcoin-contracts.ts b/src/app/common/hooks/use-bitcoin-contracts.ts index 88355b05eb5..8bec3e13ca6 100644 --- a/src/app/common/hooks/use-bitcoin-contracts.ts +++ b/src/app/common/hooks/use-bitcoin-contracts.ts @@ -11,8 +11,7 @@ import { import { Money, createMoneyFromDecimal } from '@shared/models/money.model'; import { RouteUrls } from '@shared/route-urls'; import { BitcoinContractResponseStatus } from '@shared/rpc/methods/accept-bitcoin-contract'; -import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; -import { makeRpcErrorResponse } from '@shared/rpc/rpc-methods'; +import { makeRpcErrorResponse, makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; import { sendAcceptedBitcoinContractOfferToProtocolWallet } from '@app/query/bitcoin/contract/send-accepted-bitcoin-contract-offer'; import { diff --git a/src/app/common/hooks/use-brc20-tokens.ts b/src/app/common/hooks/use-brc20-tokens.ts new file mode 100644 index 00000000000..9e638d67e31 --- /dev/null +++ b/src/app/common/hooks/use-brc20-tokens.ts @@ -0,0 +1,11 @@ +import { useGetBrc20TokensQuery } from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query'; + +export function useBrc20Tokens() { + const { data: allBrc20TokensResponse } = useGetBrc20TokensQuery(); + const brc20Tokens = allBrc20TokensResponse?.pages + .flatMap(page => page.brc20Tokens) + .filter(token => token.length > 0) + .flatMap(token => token); + + return brc20Tokens; +} diff --git a/src/app/common/hooks/use-event-listener.ts b/src/app/common/hooks/use-event-listener.ts index d7a1c1bd48d..b50275a17c4 100644 --- a/src/app/common/hooks/use-event-listener.ts +++ b/src/app/common/hooks/use-event-listener.ts @@ -43,32 +43,32 @@ const isBrowser = checkIsBrowser(); * * @param event the event name * @param handler the event handler function to execute - * @param doc the dom environment to execute against (defaults to `document`) + * @param element the dom environment to execute against (defaults to `document`) * @param options the event listener options */ export function useEventListener( event: keyof WindowEventMap, handler: (event: any) => void, - doc: Document | null = isBrowser ? document : null, + element: Document | null = isBrowser ? document : null, options?: AddEventListener[2] ) { const savedHandler = useLatestRef(handler); useEffect(() => { - if (!doc) return; + if (!element) return; const listener = (event: any) => { savedHandler.current(event); }; - doc.addEventListener(event, listener, options); + element.addEventListener(event, listener, options); return () => { - doc.removeEventListener(event, listener, options); + element.removeEventListener(event, listener, options); }; - }, [event, doc, options, savedHandler]); + }, [event, element, options, savedHandler]); return () => { - doc?.removeEventListener(event, savedHandler.current, options); + element?.removeEventListener(event, savedHandler.current, options); }; } diff --git a/src/app/common/theme-provider.tsx b/src/app/common/theme-provider.tsx index fc4bb066f18..4a493699aa5 100644 --- a/src/app/common/theme-provider.tsx +++ b/src/app/common/theme-provider.tsx @@ -82,7 +82,7 @@ export function ThemeSwitcherProvider({ children }: ThemeSwitcherProviderProps) return ( - {children} + {children} ); diff --git a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts index e35f615f865..efe3f638dcb 100644 --- a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts +++ b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts @@ -34,7 +34,7 @@ describe(determineUtxosForSpend.name, () => { describe('sorting algorithm (biggest first and no dust)', () => { test('that it filters out dust utxos', () => { const result = generate10kSpendWithTestData('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m'); - const hasDust = result.orderedUtxos.some(utxo => utxo.value <= BTC_P2WPKH_DUST_AMOUNT); + const hasDust = result.filteredUtxos.some(utxo => utxo.value <= BTC_P2WPKH_DUST_AMOUNT); expect(hasDust).toBeFalsy(); }); diff --git a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts index cdbe309d93f..ade9b348719 100644 --- a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +++ b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts @@ -1,10 +1,8 @@ -import { getAddressInfo, validate } from 'bitcoin-address-validation'; - -import { BTC_P2WPKH_DUST_AMOUNT } from '@shared/constants'; +import { validate } from 'bitcoin-address-validation'; import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client'; -import { BtcSizeFeeEstimator } from '../fees/btc-size-fee-estimator'; +import { filterUneconomicalUtxos, getSizeInfo } from '../utils'; export interface DetermineUtxosForSpendArgs { amount: number; @@ -20,17 +18,12 @@ export function determineUtxosForSpendAll({ utxos, }: DetermineUtxosForSpendArgs) { if (!validate(recipient)) throw new Error('Cannot calculate spend of invalid address type'); + const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, address: recipient }); - const addressInfo = getAddressInfo(recipient); - - const txSizer = new BtcSizeFeeEstimator(); - - const filteredUtxos = utxos.filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT); - - const sizeInfo = txSizer.calcTxSize({ - input_script: 'p2wpkh', - input_count: filteredUtxos.length, - [addressInfo.type + '_output_count']: 1, + const sizeInfo = getSizeInfo({ + inputLength: filteredUtxos.length, + outputLength: 1, + recipient, }); // Fee has already been deducted from the amount with send all @@ -54,25 +47,23 @@ export function determineUtxosForSpend({ }: DetermineUtxosForSpendArgs) { if (!validate(recipient)) throw new Error('Cannot calculate spend of invalid address type'); - const addressInfo = getAddressInfo(recipient); - - const orderedUtxos = utxos - .filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT) - .sort((a, b) => b.value - a.value); + const orderedUtxos = utxos.sort((a, b) => b.value - a.value); - const txSizer = new BtcSizeFeeEstimator(); + const filteredUtxos = filterUneconomicalUtxos({ + utxos: orderedUtxos, + feeRate, + address: recipient, + }); const neededUtxos = []; let sum = 0n; let sizeInfo = null; - for (const utxo of orderedUtxos) { - sizeInfo = txSizer.calcTxSize({ - // Only p2wpkh is supported by the wallet - input_script: 'p2wpkh', - input_count: neededUtxos.length, - // From the address of the recipient, we infer the output type - [addressInfo.type + '_output_count']: 2, + for (const utxo of filteredUtxos) { + sizeInfo = getSizeInfo({ + inputLength: neededUtxos.length, + outputLength: 2, + recipient, }); if (sum >= BigInt(amount) + BigInt(Math.ceil(sizeInfo.txVBytes * feeRate))) break; @@ -92,7 +83,7 @@ export function determineUtxosForSpend({ ]; return { - orderedUtxos, + filteredUtxos, inputs: neededUtxos, outputs, size: sizeInfo.txVBytes, diff --git a/src/app/common/transactions/bitcoin/fees/bitcoin-fees.spec.ts b/src/app/common/transactions/bitcoin/fees/bitcoin-fees.spec.ts new file mode 100644 index 00000000000..4e28ccdbde9 --- /dev/null +++ b/src/app/common/transactions/bitcoin/fees/bitcoin-fees.spec.ts @@ -0,0 +1,143 @@ +import BigNumber from 'bignumber.js'; +import { sha256 } from 'bitcoinjs-lib/src/crypto'; + +import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client'; + +import { filterUneconomicalUtxos } from '../utils'; +import { calculateMaxBitcoinSpend } from './calculate-max-bitcoin-spend'; + +function generateTxId(value: number): UtxoResponseItem { + const buffer = Buffer.from(Math.random().toString()); + return { + txid: sha256(sha256(buffer)).toString(), + vout: 0, + status: { + confirmed: true, + block_height: 2568495, + block_hash: '000000000000008622fafce4a5388861b252d534f819d0f7cb5d4f2c5f9c1638', + block_time: 1703787327, + }, + value, + }; +} + +function generateTransactions(values: number[]) { + return values.map(val => generateTxId(val)); +} + +function generateAverageFee(value: number) { + return { + hourFee: BigNumber(value / 2), + halfHourFee: BigNumber(value), + fastestFee: BigNumber(value * 2), + }; +} + +describe(calculateMaxBitcoinSpend.name, () => { + const utxos = generateTransactions([600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000]); + + test('with 1 sat/vb fee', () => { + const fee = 1; + const maxBitcoinSpend = calculateMaxBitcoinSpend({ + address: '', + utxos, + fetchedFeeRates: generateAverageFee(fee), + }); + expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50087948); + }); + + test('with 5 sat/vb fee', () => { + const fee = 5; + const maxBitcoinSpend = calculateMaxBitcoinSpend({ + address: '', + utxos, + fetchedFeeRates: generateAverageFee(fee), + }); + expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50085342); + }); + + test('with 30 sat/vb fee', () => { + const fee = 30; + const maxBitcoinSpend = calculateMaxBitcoinSpend({ + address: '', + utxos, + fetchedFeeRates: generateAverageFee(fee), + }); + expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50073585); + }); + + test('with 100 sat/vb fee', () => { + const fee = 100; + const maxBitcoinSpend = calculateMaxBitcoinSpend({ + address: '', + utxos, + fetchedFeeRates: generateAverageFee(fee), + }); + expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50046950); + }); + + test('with 400 sat/vb fee', () => { + const fee = 400; + const maxBitcoinSpend = calculateMaxBitcoinSpend({ + address: '', + utxos, + fetchedFeeRates: generateAverageFee(fee), + }); + expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(49969100); + }); +}); + +describe(filterUneconomicalUtxos.name, () => { + const utxos = generateTransactions([600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000]); + + test('with 1 sat/vb fee', () => { + const fee = 1; + const filteredUtxos = filterUneconomicalUtxos({ + address: '', + utxos, + feeRate: fee, + }); + + expect(filteredUtxos.length).toEqual(9); + }); + + test('with 10 sat/vb fee', () => { + const fee = 10; + const filteredUtxos = filterUneconomicalUtxos({ + address: '', + utxos, + feeRate: fee, + }); + expect(filteredUtxos.length).toEqual(7); + }); + + test('with 30 sat/vb fee', () => { + const fee = 30; + const filteredUtxos = filterUneconomicalUtxos({ + address: '', + utxos, + feeRate: fee, + }); + expect(filteredUtxos.length).toEqual(5); + }); + + test('with 200 sat/vb fee', () => { + const fee = 200; + const filteredUtxos = filterUneconomicalUtxos({ + address: '', + utxos, + feeRate: fee, + }); + expect(filteredUtxos.length).toEqual(3); + }); + + test('with 400 sat/vb fee', () => { + const fee = 400; + const filteredUtxos = filterUneconomicalUtxos({ + address: '', + utxos, + feeRate: fee, + }); + expect(filteredUtxos.length).toEqual(2); + }); +}); diff --git a/src/app/common/transactions/bitcoin/fees/btc-size-fee-estimator.ts b/src/app/common/transactions/bitcoin/fees/btc-size-fee-estimator.ts index 2b4d4cf06fc..ecbe796f1d3 100644 --- a/src/app/common/transactions/bitcoin/fees/btc-size-fee-estimator.ts +++ b/src/app/common/transactions/bitcoin/fees/btc-size-fee-estimator.ts @@ -10,7 +10,7 @@ type InputScriptTypes = | 'p2wsh' | 'p2tr'; -interface Params { +interface TxSizerParams { input_count: number; input_script: InputScriptTypes; input_m: number; @@ -48,7 +48,7 @@ export class BtcSizeFeeEstimator { 'p2tr', ]; - defaultParams: Params = { + defaultParams: TxSizerParams = { input_count: 0, input_script: 'p2wpkh', input_m: 0, @@ -62,7 +62,7 @@ export class BtcSizeFeeEstimator { p2tr_output_count: 0, }; - params: Params = { ...this.defaultParams }; + params: TxSizerParams = { ...this.defaultParams }; getSizeOfScriptLengthElement(length: number) { if (length < 75) { @@ -128,7 +128,7 @@ export class BtcSizeFeeEstimator { return witness_vbytes * 3; } - prepareParams(opts: Partial) { + prepareParams(opts: Partial) { // Verify opts and set them to this.params opts = opts || Object.assign(this.defaultParams); @@ -279,7 +279,7 @@ export class BtcSizeFeeEstimator { }; } - calcTxSize(opts: Partial) { + calcTxSize(opts: Partial) { this.prepareParams(opts); const output_count = this.getOutputCount(); const { inputSize, inputWitnessSize } = this.getSizeBasedOnInputType(); diff --git a/src/app/common/transactions/bitcoin/fees/calculate-max-bitcoin-spend.ts b/src/app/common/transactions/bitcoin/fees/calculate-max-bitcoin-spend.ts new file mode 100644 index 00000000000..c5e38813c09 --- /dev/null +++ b/src/app/common/transactions/bitcoin/fees/calculate-max-bitcoin-spend.ts @@ -0,0 +1,50 @@ +import BigNumber from 'bignumber.js'; + +import { AverageBitcoinFeeRates } from '@shared/models/fees/bitcoin-fees.model'; +import { createMoney } from '@shared/models/money.model'; + +import { satToBtc } from '@app/common/money/unit-conversion'; +import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client'; + +import { filterUneconomicalUtxos, getSpendableAmount } from '../utils'; + +interface CalculateMaxBitcoinSpend { + address: string; + utxos: UtxoResponseItem[]; + fetchedFeeRates?: AverageBitcoinFeeRates; + feeRate?: number; +} + +export function calculateMaxBitcoinSpend({ + address, + utxos, + feeRate, + fetchedFeeRates, +}: CalculateMaxBitcoinSpend) { + if (!utxos.length || !fetchedFeeRates) + return { + spendAllFee: 0, + amount: createMoney(0, 'BTC'), + spendableBitcoin: new BigNumber(0), + }; + + const currentFeeRate = feeRate ?? fetchedFeeRates.halfHourFee.toNumber(); + + const filteredUtxos = filterUneconomicalUtxos({ + utxos, + feeRate: currentFeeRate, + address, + }); + + const { spendableAmount, fee } = getSpendableAmount({ + utxos: filteredUtxos, + feeRate: currentFeeRate, + address, + }); + + return { + spendAllFee: fee, + amount: createMoney(spendableAmount, 'BTC'), + spendableBitcoin: satToBtc(spendableAmount), + }; +} diff --git a/src/app/common/transactions/bitcoin/utils.ts b/src/app/common/transactions/bitcoin/utils.ts index 6730c435990..b4df0aab8f2 100644 --- a/src/app/common/transactions/bitcoin/utils.ts +++ b/src/app/common/transactions/bitcoin/utils.ts @@ -1,10 +1,15 @@ -import { getAddressInfo } from 'bitcoin-address-validation'; +import BigNumber from 'bignumber.js'; +import { getAddressInfo, validate } from 'bitcoin-address-validation'; -import { BitcoinTransactionVectorOutput } from '@shared/models/transactions/bitcoin-transaction.model'; -import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model'; +import { BTC_P2WPKH_DUST_AMOUNT } from '@shared/constants'; +import { + BitcoinTransactionVectorOutput, + BitcoinTx, +} from '@shared/models/transactions/bitcoin-transaction.model'; import { sumNumbers } from '@app/common/math/helpers'; import { satToBtc } from '@app/common/money/unit-conversion'; +import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client'; import { truncateMiddle } from '@app/ui/utils/truncate-middle'; import { BtcSizeFeeEstimator } from './fees/btc-size-fee-estimator'; @@ -12,14 +17,71 @@ import { BtcSizeFeeEstimator } from './fees/btc-size-fee-estimator'; export function containsTaprootInput(tx: BitcoinTx) { return tx.vin.some(input => input.prevout.scriptpubkey_type === 'v1_p2tr'); } +export function getSpendableAmount({ + utxos, + feeRate, + address, +}: { + utxos: UtxoResponseItem[]; + feeRate: number; + address: string; +}) { + const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0); + + const size = getSizeInfo({ + inputLength: utxos.length, + outputLength: 1, + recipient: address, + }); + const fee = Math.ceil(size.txVBytes * feeRate); + const bigNumberBalance = BigNumber(balance); + return { + spendableAmount: BigNumber.max(0, bigNumberBalance.minus(fee)), + fee, + }; +} + +// Check if the spendable amount drops when adding a utxo. If it drops, don't use that utxo. +// Method might be not particularly efficient as it would +// go through the utxo array multiple times, but it's reliable. +export function filterUneconomicalUtxos({ + utxos, + feeRate, + address, +}: { + utxos: UtxoResponseItem[]; + feeRate: number; + address: string; +}) { + const { spendableAmount: fullSpendableAmount } = getSpendableAmount({ + utxos, + feeRate, + address, + }); + + const filteredUtxos = utxos + .filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT) + .filter(utxo => { + // calculate spendableAmount without that utxo. + const { spendableAmount } = getSpendableAmount({ + utxos: utxos.filter(u => u.txid !== utxo.txid), + feeRate, + address, + }); + // if spendable amount becomes bigger, do not use that utxo + return spendableAmount.toNumber() < fullSpendableAmount.toNumber(); + }); + return filteredUtxos; +} export function getSizeInfo(payload: { inputLength: number; - recipient: string; outputLength: number; + recipient: string; }) { const { inputLength, recipient, outputLength } = payload; - const addressInfo = getAddressInfo(recipient); + const addressInfo = validate(recipient) ? getAddressInfo(recipient) : null; + const outputAddressTypeWithFallback = addressInfo ? addressInfo.type : 'p2wpkh'; const txSizer = new BtcSizeFeeEstimator(); const sizeInfo = txSizer.calcTxSize({ @@ -27,7 +89,7 @@ export function getSizeInfo(payload: { input_script: 'p2wpkh', input_count: inputLength, // From the address of the recipient, we infer the output type - [addressInfo.type + '_output_count']: outputLength, + [outputAddressTypeWithFallback + '_output_count']: outputLength, }); return sizeInfo; diff --git a/src/app/common/utils.spec.ts b/src/app/common/utils.spec.ts index 95c25e720f8..91c033e7105 100644 --- a/src/app/common/utils.spec.ts +++ b/src/app/common/utils.spec.ts @@ -1,5 +1,4 @@ -import { getTicker } from '@app/common/utils'; -import { extractPhraseFromString } from '@app/common/utils'; +import { extractPhraseFromString, getTicker } from '@app/common/utils'; import { countDecimals } from './math/helpers'; diff --git a/src/app/common/utils.ts b/src/app/common/utils.ts index 51ff3d352e1..8f7e1235e56 100644 --- a/src/app/common/utils.ts +++ b/src/app/common/utils.ts @@ -8,6 +8,7 @@ import { import { toUnicode } from 'punycode'; import { BitcoinChainConfig, BitcoinNetworkModes, KEBAB_REGEX } from '@shared/constants'; +import { isBoolean } from '@shared/utils'; export function createNullArrayOfLength(length: number) { return new Array(length).fill(null); @@ -314,3 +315,7 @@ export function removeTrailingNullCharacters(s: string) { export function removeMinusSign(value: string) { return value.replace('-', ''); } + +export function propIfDefined(prop: string, value: any) { + return isBoolean(value) ? { [prop]: value } : {}; +} diff --git a/src/app/components/account/account-list-item-layout.tsx b/src/app/components/account/account-list-item-layout.tsx index 91aae1cba45..5fde1bb6215 100644 --- a/src/app/components/account/account-list-item-layout.tsx +++ b/src/app/components/account/account-list-item-layout.tsx @@ -2,11 +2,11 @@ import { SettingsSelectors } from '@tests/selectors/settings.selectors'; import { Flex, HStack, Stack, StackProps, styled } from 'leather-styles/jsx'; import { useViewportMinWidth } from '@app/common/hooks/use-media-query'; +import { BulletSeparator } from '@app/ui/components/bullet-separator/bullet-separator'; import { CheckmarkIcon } from '@app/ui/components/icons/checkmark-icon'; import { Spinner } from '@app/ui/components/spinner'; import { truncateMiddle } from '@app/ui/utils/truncate-middle'; -import { CaptionDotSeparator } from '../caption-dot-separator'; import { Flag } from '../layout/flag'; import { StacksAccountLoader } from '../loaders/stacks-account-loader'; import { BitcoinNativeSegwitAccountLoader } from './bitcoin-account-loader'; @@ -65,7 +65,7 @@ export function AccountListItemLayout(props: AccountListItemLayoutProps) { )} - + {account => ( @@ -81,7 +81,7 @@ export function AccountListItemLayout(props: AccountListItemLayoutProps) { )} - + diff --git a/src/app/components/app-version.tsx b/src/app/components/app-version.tsx index 442e4b24a35..c67990f8ffe 100644 --- a/src/app/components/app-version.tsx +++ b/src/app/components/app-version.tsx @@ -1,5 +1,4 @@ -import { useMemo } from 'react'; -import { forwardRef } from 'react'; +import { forwardRef, useMemo } from 'react'; import { HTMLStyledProps, styled } from 'leather-styles/jsx'; diff --git a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx index 432bdc35320..227990cc93f 100644 --- a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx +++ b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx @@ -24,9 +24,9 @@ import { } from '@app/query/bitcoin/ordinals/inscription.hooks'; import { useGetInscriptionsByOutputQuery } from '@app/query/bitcoin/ordinals/inscriptions-by-param.query'; import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { BulletSeparator } from '@app/ui/components/bullet-separator/bullet-separator'; import { BtcIcon } from '@app/ui/components/icons/btc-icon'; -import { CaptionDotSeparator } from '../caption-dot-separator'; import { TransactionItemLayout } from '../transaction-item/transaction-item.layout'; import { BitcoinTransactionCaption } from './bitcoin-transaction-caption'; import { BitcoinTransactionIcon } from './bitcoin-transaction-icon'; @@ -79,12 +79,12 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact isOriginator && !transaction.status.confirmed && !containsTaprootInput(transaction); const txCaption = ( - + {caption} {inscriptionData ? ( {inscriptionData.mime_type} ) : null} - + ); const txValue = {value}; diff --git a/src/app/components/brc20-tokens-loader.tsx b/src/app/components/brc20-tokens-loader.tsx index 042559d4670..373467bcaa6 100644 --- a/src/app/components/brc20-tokens-loader.tsx +++ b/src/app/components/brc20-tokens-loader.tsx @@ -1,17 +1,12 @@ -import { - Brc20Token, - useGetBrc20TokensQuery, -} from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query'; +import { useBrc20Tokens } from '@app/common/hooks/use-brc20-tokens'; +import { Brc20Token } from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query'; interface Brc20TokensLoaderProps { children(brc20Tokens: Brc20Token[]): React.JSX.Element; } + export function Brc20TokensLoader({ children }: Brc20TokensLoaderProps) { - const { data: allBrc20TokensResponse } = useGetBrc20TokensQuery(); - const brc20Tokens = allBrc20TokensResponse?.pages - .flatMap(page => page.brc20Tokens) - .filter(token => token.length > 0) - .flatMap(token => token); + const brc20Tokens = useBrc20Tokens(); if (!brc20Tokens) return null; return children(brc20Tokens); diff --git a/src/app/components/caption-dot-separator.tsx b/src/app/components/caption-dot-separator.tsx deleted file mode 100644 index f7a27864bdc..00000000000 --- a/src/app/components/caption-dot-separator.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { cloneElement, isValidElement } from 'react'; - -import { BoxProps, styled } from 'leather-styles/jsx'; - -function CaptionSeparatorDot(props: BoxProps) { - return ( - - • - - ); -} - -interface CaptionDotSeparatorProps { - children: React.ReactNode; -} -export function CaptionDotSeparator({ children }: CaptionDotSeparatorProps) { - const parsedChildren = Array.isArray(children) ? children : [children]; - const content = parsedChildren - .flatMap((child, index) => { - if (!isValidElement(child)) return null; - return [cloneElement(child, { key: index }), ]; - }) - .filter(val => val !== null) - .slice(0, -1); - return <>{content}; -} diff --git a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/brc20-token-asset-list.tsx b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/brc20-token-asset-list.tsx index 7667d5b29aa..5bd77800863 100644 --- a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/brc20-token-asset-list.tsx +++ b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/brc20-token-asset-list.tsx @@ -7,9 +7,6 @@ import { Brc20TokenAssetItem } from '@app/components/crypto-assets/bitcoin/brc20 import { useNativeSegwitBalance } from '@app/query/bitcoin/balance/btc-native-segwit-balance.hooks'; import { Brc20Token } from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query'; import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -import { BasicTooltip } from '@app/ui/components/tooltip/basic-tooltip'; - -import { Brc20AssetListLayout } from './components/brc20-token-asset-list.layout'; export function Brc20TokenAssetList(props: { brc20Tokens?: Brc20Token[] }) { const navigate = useNavigate(); @@ -27,23 +24,13 @@ export function Brc20TokenAssetList(props: { brc20Tokens?: Brc20Token[] }) { } if (!props.brc20Tokens?.length) return null; - - return ( - - {props.brc20Tokens?.map(token => ( - - navigateToBrc20SendForm(token) : noop} - /> - - ))} - - ); + return props.brc20Tokens.map(token => ( + navigateToBrc20SendForm(token) : noop} + displayNotEnoughBalance={!hasPositiveBtcBalanceForFees} + key={token.tick} + /> + )); } diff --git a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.layout.tsx b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.layout.tsx index 2b9946fb0dd..0e9223ec493 100644 --- a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.layout.tsx +++ b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.layout.tsx @@ -15,6 +15,7 @@ interface Brc20TokenAssetItemLayoutProps extends BoxProps { isPressable?: boolean; onClick?(): void; title: string; + displayNotEnoughBalance?: boolean; } export function Brc20TokenAssetItemLayout({ balance, @@ -22,39 +23,47 @@ export function Brc20TokenAssetItemLayout({ isPressable, onClick, title, + displayNotEnoughBalance, }: Brc20TokenAssetItemLayoutProps) { const [component, bind] = usePressable(isPressable); const formattedBalance = formatBalance(balance.amount.toString()); return ( - - } spacing="space.04" width="100%"> - - - {title} - - - - {formattedBalance.value} + + + } spacing="space.04" width="100%"> + + + {title} - - - - - - {component} - - + + + {formattedBalance.value} + + + + + + + {component} + + + ); } diff --git a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.tsx b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.tsx index 131566227ba..29820b11099 100644 --- a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.tsx +++ b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-item.tsx @@ -8,8 +8,14 @@ interface Brc20TokenAssetItemProps { token: Brc20Token; isPressable?: boolean; onClick?(): void; + displayNotEnoughBalance?: boolean; } -export function Brc20TokenAssetItem({ token, isPressable, onClick }: Brc20TokenAssetItemProps) { +export function Brc20TokenAssetItem({ + token, + isPressable, + onClick, + displayNotEnoughBalance, +}: Brc20TokenAssetItemProps) { return ( ); } diff --git a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-list.layout.tsx b/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-list.layout.tsx deleted file mode 100644 index bf306441449..00000000000 --- a/src/app/components/crypto-assets/bitcoin/brc20-token-asset-list/components/brc20-token-asset-list.layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { CryptoAssetSelectors } from '@tests/selectors/crypto-asset.selectors'; -import { Stack, StackProps } from 'leather-styles/jsx'; - -export function Brc20AssetListLayout({ children }: StackProps) { - return ( - - {children} - - ); -} diff --git a/src/app/components/crypto-assets/choose-crypto-asset/crypto-asset-list.tsx b/src/app/components/crypto-assets/choose-crypto-asset/crypto-asset-list.tsx index a3d019aaf6e..48dbaac5e9d 100644 --- a/src/app/components/crypto-assets/choose-crypto-asset/crypto-asset-list.tsx +++ b/src/app/components/crypto-assets/choose-crypto-asset/crypto-asset-list.tsx @@ -1,14 +1,25 @@ import type { AllTransferableCryptoAssetBalances } from '@shared/models/crypto-asset-balance.model'; import { StacksFungibleTokenAsset } from '@shared/models/crypto-asset.model'; +import { useWalletType } from '@app/common/use-wallet-type'; +import { Brc20TokenAssetList } from '@app/components/crypto-assets/bitcoin/brc20-token-asset-list/brc20-token-asset-list'; +import { Brc20Token } from '@app/query/bitcoin/ordinals/brc20/brc20-tokens.query'; + import { CryptoAssetListItem } from './crypto-asset-list-item'; import { CryptoAssetListLayout } from './crypto-asset-list.layout'; interface CryptoAssetListProps { cryptoAssetBalances: AllTransferableCryptoAssetBalances[]; onItemClick(cryptoAssetBalance: AllTransferableCryptoAssetBalances): void; + brc20Tokens?: Brc20Token[]; } -export function CryptoAssetList({ cryptoAssetBalances, onItemClick }: CryptoAssetListProps) { +export function CryptoAssetList({ + cryptoAssetBalances, + onItemClick, + brc20Tokens, +}: CryptoAssetListProps) { + const { whenWallet } = useWalletType(); + return ( {cryptoAssetBalances.map(cryptoAssetBalance => ( @@ -21,6 +32,12 @@ export function CryptoAssetList({ cryptoAssetBalances, onItemClick }: CryptoAsse } /> ))} + {brc20Tokens + ? whenWallet({ + software: , + ledger: null, + }) + : null} ); } diff --git a/src/app/components/crypto-assets/components/asset-caption.tsx b/src/app/components/crypto-assets/components/asset-caption.tsx index d0ff9b5caa0..25116dfecf0 100644 --- a/src/app/components/crypto-assets/components/asset-caption.tsx +++ b/src/app/components/crypto-assets/components/asset-caption.tsx @@ -1,5 +1,6 @@ import { Flex, HStack, styled } from 'leather-styles/jsx'; +import { BulletOperator } from '@app/ui/components/bullet-separator/bullet-separator'; import { InfoIcon } from '@app/ui/components/icons/info-icon'; import { BasicTooltip } from '@app/ui/components/tooltip/basic-tooltip'; @@ -14,7 +15,7 @@ export function AssetCaption({ caption, isUnanchored }: AssetCaptionProps) { {isUnanchored ? ( <> - • Microblock + Microblock diff --git a/src/app/components/crypto-assets/components/asset-row-grid.tsx b/src/app/components/crypto-assets/components/asset-row-grid.tsx index 50abc1384ac..3508934ab32 100644 --- a/src/app/components/crypto-assets/components/asset-row-grid.tsx +++ b/src/app/components/crypto-assets/components/asset-row-grid.tsx @@ -22,7 +22,13 @@ export function AssetRowGrid({ {balance} ); return ( - + {title} diff --git a/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx b/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx index ff8086c2ff0..bf4a65215d5 100644 --- a/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx +++ b/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx @@ -1,6 +1,5 @@ import { CryptoAssetSelectors } from '@tests/selectors/crypto-asset.selectors'; -import { Flex } from 'leather-styles/jsx'; -import { styled } from 'leather-styles/jsx'; +import { Flex, styled } from 'leather-styles/jsx'; import { CryptoCurrencies } from '@shared/models/currencies.model'; import { Money } from '@shared/models/money.model'; @@ -80,11 +79,17 @@ export function CryptoCurrencyAssetItemLayout({ } - caption={{caption}} + caption={ + + {caption} + + } usdBalance={ {balance.amount.toNumber() > 0 && address ? ( - {usdBalance} + + {usdBalance} + ) : null} {additionalUsdBalanceInfo} diff --git a/src/app/components/fees-row/components/fees-row.layout.tsx b/src/app/components/fees-row/components/fees-row.layout.tsx index 9c60f48c63a..0a63fb8ccf5 100644 --- a/src/app/components/fees-row/components/fees-row.layout.tsx +++ b/src/app/components/fees-row/components/fees-row.layout.tsx @@ -1,6 +1,5 @@ import { useField } from 'formik'; -import { HstackProps, styled } from 'leather-styles/jsx'; -import { HStack } from 'leather-styles/jsx'; +import { HStack, HstackProps, styled } from 'leather-styles/jsx'; import { openInNewTab } from '@app/common/utils/open-in-new-tab'; import { SponsoredLabel } from '@app/components/sponsored-label'; diff --git a/src/app/components/secret-key/mnemonic-key/mnemonic-input-field.tsx b/src/app/components/secret-key/mnemonic-key/mnemonic-input-field.tsx deleted file mode 100644 index b1cf126aa3d..00000000000 --- a/src/app/components/secret-key/mnemonic-key/mnemonic-input-field.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState } from 'react'; - -import { TextField } from '@radix-ui/themes'; -import { useField } from 'formik'; -import { css } from 'leather-styles/css'; -import { FlexProps, styled } from 'leather-styles/jsx'; - -import { useIsFieldDirty } from '@app/common/form-utils'; - -interface InputFieldProps extends FlexProps { - dataTestId?: string; - name: string; - value: string; - onPaste(e: React.ClipboardEvent): void; - onUpdateWord(word: string): void; - hasError?: boolean; - wordlist: string[]; -} - -const psuedoBorderStyles = { - content: '""', - zIndex: 1, - top: 0, - right: 0, - bottom: 0, - left: 0, - background: 'transparent', - position: 'absolute', - border: '1px solid', - borderRadius: 'sm', -}; - -export function InputField({ dataTestId, name, onPaste, onChange, value }: InputFieldProps) { - const [field, meta] = useField(name); - const [isFocused, setIsFocused] = useState(false); - const isDirty = useIsFieldDirty(name); - return ( - - - {`${name}.`} - - setIsFocused(!isFocused)} - {...field} - value={value || field.value || ''} - // using onChangeCapture + onBlurCapture to keep Formik validation - onChangeCapture={onChange} - onBlurCapture={() => setIsFocused(!isFocused)} - onPaste={onPaste} - /> - - ); -} diff --git a/src/app/components/secret-key/mnemonic-key/mnemonic-word-input.tsx b/src/app/components/secret-key/mnemonic-key/mnemonic-word-input.tsx index f06ec9fa1d5..257d8a38bc4 100644 --- a/src/app/components/secret-key/mnemonic-key/mnemonic-word-input.tsx +++ b/src/app/components/secret-key/mnemonic-key/mnemonic-word-input.tsx @@ -1,8 +1,10 @@ -import { wordlist } from '@scure/bip39/wordlists/english'; +import { useState } from 'react'; -import { extractPhraseFromString } from '@app/common/utils'; +import { useField } from 'formik'; -import { InputField } from './mnemonic-input-field'; +import { useIsFieldDirty } from '@app/common/form-utils'; +import { extractPhraseFromString } from '@app/common/utils'; +import { Input } from '@app/ui/components/input/input'; interface MnemonicWordInputProps { fieldNumber: number; @@ -16,28 +18,41 @@ export function MnemonicWordInput({ onUpdateWord, onPasteEntireKey, }: MnemonicWordInputProps) { + const name = `${fieldNumber}`; + const [field, meta] = useField(name); + const [isFocused, setIsFocused] = useState(false); + const isDirty = useIsFieldDirty(name); return ( - { - const pasteValue = extractPhraseFromString(e.clipboardData.getData('text')); - if (pasteValue.includes(' ')) { + + setIsFocused(!isFocused)} + {...field} + value={value || field.value || ''} + // using onChangeCapture + onBlurCapture to keep Formik validation + onChangeCapture={(e: any) => { e.preventDefault(); - //assume its a full key - onPasteEntireKey(pasteValue); - } - }} - onChange={(e: any) => { - e.preventDefault(); - onUpdateWord(e.target.value); - }} - wordlist={wordlist} - value={value} - height="3rem" - /> + onUpdateWord(e.target.value); + }} + onBlurCapture={() => setIsFocused(!isFocused)} + onPaste={e => { + const pasteValue = extractPhraseFromString(e.clipboardData.getData('text')); + if (pasteValue.includes(' ')) { + e.preventDefault(); + //assume its a full key + onPasteEntireKey(pasteValue); + } + }} + /> + Word {fieldNumber} + ); } diff --git a/src/app/features/add-network/add-network.tsx b/src/app/features/add-network/add-network.tsx index 06fbb497d29..339faa38c97 100644 --- a/src/app/features/add-network/add-network.tsx +++ b/src/app/features/add-network/add-network.tsx @@ -22,7 +22,7 @@ import { useNetworksActions, } from '@app/store/networks/networks.hooks'; import { Button } from '@app/ui/components/button/button'; -import { Input } from '@app/ui/components/input'; +import { Input } from '@app/ui/components/input/input'; import { Title } from '@app/ui/components/typography/title'; /** @@ -70,14 +70,14 @@ export function AddNetwork() { const setStacksUrl = useCallback( (value: string) => { - setFieldValue('stacksUrl', value); + void setFieldValue('stacksUrl', value); }, [setFieldValue] ); const setBitcoinUrl = useCallback( (value: string) => { - setFieldValue('bitcoinUrl', value); + void setFieldValue('bitcoinUrl', value); }, [setFieldValue] ); @@ -224,17 +224,17 @@ export function AddNetwork() { . Make sure you review and trust the host before you add it. - + + Name + + Bitcoin API {/* TODO: Replace with new Select */} @@ -269,37 +269,38 @@ export function AddNetwork() { Stacks API URL - + + Name + + Bitcoin API URL - - + + Bitcoin API URL + + + + Network key + + {error ? ( {error} ) : null} diff --git a/src/app/features/asset-list/components/stacks-asset-list.tsx b/src/app/features/asset-list/components/stacks-asset-list.tsx index 0c8d0e6308e..40207ba6177 100644 --- a/src/app/features/asset-list/components/stacks-asset-list.tsx +++ b/src/app/features/asset-list/components/stacks-asset-list.tsx @@ -1,9 +1,12 @@ +import { styled } from 'leather-styles/jsx'; + import { useStxBalance } from '@app/common/hooks/balance/stx/use-stx-balance'; import { ftDecimals } from '@app/common/stacks-utils'; import { CryptoCurrencyAssetItem } from '@app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item'; import { StxAvatar } from '@app/components/crypto-assets/stacks/components/stx-avatar'; import { useStacksFungibleTokenAssetBalancesAnchoredWithMetadata } from '@app/query/stacks/balance/stacks-ft-balances.hooks'; import { StacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.models'; +import { BulletOperator } from '@app/ui/components/bullet-separator/bullet-separator'; import { Caption } from '@app/ui/components/typography/caption'; import { StacksFungibleTokenAssetList } from './stacks-fungible-token-asset-list'; @@ -20,11 +23,17 @@ export function StacksAssetList({ account }: StacksAssetListProps) { useStxBalance(); const stxAdditionalBalanceInfo = stxLockedBalance?.amount.isGreaterThan(0) ? ( - <>({ftDecimals(stxLockedBalance.amount, stxLockedBalance.decimals || 0)} locked) + + + {ftDecimals(stxLockedBalance.amount, stxLockedBalance.decimals || 0)} locked + ) : undefined; const stxAdditionalUsdBalanceInfo = stxLockedBalance?.amount.isGreaterThan(0) ? ( - ({stxUsdLockedBalance} locked) + + + {stxUsdLockedBalance} locked + ) : undefined; return ( diff --git a/src/app/features/bitcoin-choose-fee/bitcoin-choose-fee.tsx b/src/app/features/bitcoin-choose-fee/bitcoin-choose-fee.tsx index 99e3f3472bc..bfdc9ebb901 100644 --- a/src/app/features/bitcoin-choose-fee/bitcoin-choose-fee.tsx +++ b/src/app/features/bitcoin-choose-fee/bitcoin-choose-fee.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; -import { Box, FlexProps, Stack } from 'leather-styles/jsx'; -import { styled } from 'leather-styles/jsx'; +import { Box, FlexProps, Stack, styled } from 'leather-styles/jsx'; import { BtcFeeType } from '@shared/models/fees/bitcoin-fees.model'; import { Money } from '@shared/models/money.model'; diff --git a/src/app/features/collectibles/components/taproot-balance-displayer.tsx b/src/app/features/collectibles/components/taproot-balance-displayer.tsx index b5440a6cadd..e82e23a3986 100644 --- a/src/app/features/collectibles/components/taproot-balance-displayer.tsx +++ b/src/app/features/collectibles/components/taproot-balance-displayer.tsx @@ -18,7 +18,7 @@ export function TaprootBalanceDisplayer({ onSelectRetrieveBalance }: TaprootBala if (!isRecoverFeatureEnabled) return null; if (balance.amount.isLessThanOrEqualTo(0)) return null; return ( - + onSelectRetrieveBalance()} textStyle="caption.02" variant="text"> {formatMoney(balance)} diff --git a/src/app/features/container/container.tsx b/src/app/features/container/container.tsx index 76d8367fcaf..55d4ea9e5c8 100644 --- a/src/app/features/container/container.tsx +++ b/src/app/features/container/container.tsx @@ -4,8 +4,7 @@ import { Outlet, useLocation } from 'react-router-dom'; import { closeWindow } from '@shared/utils'; -import { useInitalizeAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; +import { useAnalytics, useInitalizeAnalytics } from '@app/common/hooks/analytics/use-analytics'; import { LoadingSpinner } from '@app/components/loading-spinner'; import { useOnSignOut } from '@app/routes/hooks/use-on-sign-out'; import { useOnWalletLock } from '@app/routes/hooks/use-on-wallet-lock'; diff --git a/src/app/features/edit-nonce-drawer/components/edit-nonce-field.tsx b/src/app/features/edit-nonce-drawer/components/edit-nonce-field.tsx index ee8825535f0..de37a000648 100644 --- a/src/app/features/edit-nonce-drawer/components/edit-nonce-field.tsx +++ b/src/app/features/edit-nonce-drawer/components/edit-nonce-field.tsx @@ -4,7 +4,7 @@ import { useField } from 'formik'; import { Stack, StackProps } from 'leather-styles/jsx'; import { ErrorLabel } from '@app/components/error-label'; -import { Input } from '@app/ui/components/input'; +import { Input } from '@app/ui/components/input/input'; interface EditNonceFieldProps extends StackProps { onBlur(): void; @@ -15,14 +15,16 @@ export const EditNonceField = memo((props: EditNonceFieldProps) => { return ( - ) => { - await helpers.setValue(evt.currentTarget.value); - }} - placeholder="Enter a custom nonce" - value={field.value} - /> + + Custom nonce + ) => { + await helpers.setValue(evt.currentTarget.value); + }} + /> + {meta.error && {meta.error}} ); diff --git a/src/app/features/leather-intro-dialog/leather-intro-steps.tsx b/src/app/features/leather-intro-dialog/leather-intro-steps.tsx index 766d3b8c5bd..29b62738bfc 100644 --- a/src/app/features/leather-intro-dialog/leather-intro-steps.tsx +++ b/src/app/features/leather-intro-dialog/leather-intro-steps.tsx @@ -1,8 +1,7 @@ import { useLayoutEffect, useRef, useState } from 'react'; import Confetti from 'react-dom-confetti'; -import { Dialog } from '@radix-ui/themes'; -import { Inset } from '@radix-ui/themes'; +import { Dialog, Inset } from '@radix-ui/themes'; import { css } from 'leather-styles/css'; import { Box, Flex, Stack, styled } from 'leather-styles/jsx'; diff --git a/src/app/features/ledger/utils/stacks-ledger-utils.ts b/src/app/features/ledger/utils/stacks-ledger-utils.ts index 71edcb5a606..63899a1895f 100644 --- a/src/app/features/ledger/utils/stacks-ledger-utils.ts +++ b/src/app/features/ledger/utils/stacks-ledger-utils.ts @@ -19,8 +19,8 @@ import { SemVerObject, prepareLedgerDeviceForAppFn, promptOpenAppOnDevice, + versionObjectToVersionString, } from './generic-ledger-utils'; -import { versionObjectToVersionString } from './generic-ledger-utils'; export function requestPublicKeyForStxAccount(app: StacksApp) { return async (index: number) => diff --git a/src/app/features/message-signer/message-preview-box.tsx b/src/app/features/message-signer/message-preview-box.tsx index 6eb65039d44..ba45782363c 100644 --- a/src/app/features/message-signer/message-preview-box.tsx +++ b/src/app/features/message-signer/message-preview-box.tsx @@ -1,5 +1,4 @@ -import { Stack } from 'leather-styles/jsx'; -import { styled } from 'leather-styles/jsx'; +import { Stack, styled } from 'leather-styles/jsx'; import { HashDrawer } from './hash-drawer'; diff --git a/src/app/features/pending-brc-20-transfers/pending-brc-20-transfers.tsx b/src/app/features/pending-brc-20-transfers/pending-brc-20-transfers.tsx index 8c06beec5d9..9a99ec21b4e 100644 --- a/src/app/features/pending-brc-20-transfers/pending-brc-20-transfers.tsx +++ b/src/app/features/pending-brc-20-transfers/pending-brc-20-transfers.tsx @@ -5,7 +5,6 @@ import { Box, Flex, HStack, Stack } from 'leather-styles/jsx'; import { RouteUrls } from '@shared/route-urls'; import { noop } from '@shared/utils'; -import { CaptionDotSeparator } from '@app/components/caption-dot-separator'; import { usePressable } from '@app/components/item-hover'; import { Flag } from '@app/components/layout/flag'; import { StatusPending } from '@app/components/status-pending'; @@ -21,6 +20,7 @@ import { PendingBrc20Transfer, usePendingBrc20Transfers, } from '@app/store/ordinals/ordinals.slice'; +import { BulletSeparator } from '@app/ui/components/bullet-separator/bullet-separator'; import { BasicTooltip } from '@app/ui/components/tooltip/basic-tooltip'; import { Caption } from '@app/ui/components/typography/caption'; @@ -128,7 +128,7 @@ function PendingBrcTransfer({ order }: PendingBrcTransferProps) { {order.amount} {order.tick} - + - + {component} diff --git a/src/app/features/psbt-signer/components/psbt-request-sighash-warning-label.tsx b/src/app/features/psbt-signer/components/psbt-request-sighash-warning-label.tsx index b69c18efd57..9eb16180a6a 100644 --- a/src/app/features/psbt-signer/components/psbt-request-sighash-warning-label.tsx +++ b/src/app/features/psbt-signer/components/psbt-request-sighash-warning-label.tsx @@ -1,11 +1,13 @@ import { WarningLabel } from '@app/components/warning-label'; -export function PsbtRequestSighashWarningLabel() { +interface PsbtRequestSighashWarningLabelProps { + origin: string; +} +export function PsbtRequestSighashWarningLabel({ origin }: PsbtRequestSighashWarningLabelProps) { return ( - The details you see here are not guaranteed. Be sure to fully trust your counterparty, who can - later modify this transaction to send or receive other assets from your account, and possibly - even drain it. + The details of this transaction are not guaranteed and could be modified later. Continue only + if you trust {origin} ); } diff --git a/src/app/features/psbt-signer/psbt-signer.tsx b/src/app/features/psbt-signer/psbt-signer.tsx index d1d53249425..0b59788e404 100644 --- a/src/app/features/psbt-signer/psbt-signer.tsx +++ b/src/app/features/psbt-signer/psbt-signer.tsx @@ -98,7 +98,7 @@ export function PsbtSigner(props: PsbtSignerProps) { - {isPsbtMutable ? : null} + {isPsbtMutable ? : null} diff --git a/src/app/features/stacks-transaction-request/post-conditions/no-post-conditions.tsx b/src/app/features/stacks-transaction-request/post-conditions/no-post-conditions.tsx index 6b680558c91..6023981e5de 100644 --- a/src/app/features/stacks-transaction-request/post-conditions/no-post-conditions.tsx +++ b/src/app/features/stacks-transaction-request/post-conditions/no-post-conditions.tsx @@ -1,5 +1,4 @@ -import { Box, Circle, HStack } from 'leather-styles/jsx'; -import { styled } from 'leather-styles/jsx'; +import { Box, Circle, HStack, styled } from 'leather-styles/jsx'; import { LockIcon } from '@app/ui/components/icons/lock-icon'; diff --git a/src/app/pages/bitcoin-contract-list/bitcoin-contract-list.tsx b/src/app/pages/bitcoin-contract-list/bitcoin-contract-list.tsx index 5f8766e5a95..ba8eb71cd97 100644 --- a/src/app/pages/bitcoin-contract-list/bitcoin-contract-list.tsx +++ b/src/app/pages/bitcoin-contract-list/bitcoin-contract-list.tsx @@ -2,8 +2,10 @@ import { useState } from 'react'; import { Flex, styled } from 'leather-styles/jsx'; -import { useBitcoinContracts } from '@app/common/hooks/use-bitcoin-contracts'; -import { BitcoinContractListItem } from '@app/common/hooks/use-bitcoin-contracts'; +import { + BitcoinContractListItem, + useBitcoinContracts, +} from '@app/common/hooks/use-bitcoin-contracts'; import { useOnMount } from '@app/common/hooks/use-on-mount'; import { FullPageLoadingSpinner } from '@app/components/loading-spinner'; import { truncateMiddle } from '@app/ui/utils/truncate-middle'; diff --git a/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx b/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx index cbed6c93363..46150148e84 100644 --- a/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx +++ b/src/app/pages/bitcoin-contract-request/bitcoin-contract-request.tsx @@ -6,8 +6,10 @@ import { Stack } from 'leather-styles/jsx'; import { RouteUrls } from '@shared/route-urls'; import { BitcoinContractResponseStatus } from '@shared/rpc/methods/accept-bitcoin-contract'; -import { useBitcoinContracts } from '@app/common/hooks/use-bitcoin-contracts'; -import { BitcoinContractOfferDetails } from '@app/common/hooks/use-bitcoin-contracts'; +import { + BitcoinContractOfferDetails, + useBitcoinContracts, +} from '@app/common/hooks/use-bitcoin-contracts'; import { useOnMount } from '@app/common/hooks/use-on-mount'; import { initialSearchParams } from '@app/common/initial-search-params'; import { useCurrentAccountNativeSegwitSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; diff --git a/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-emergency-refund-time.tsx b/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-emergency-refund-time.tsx index 2e71ab32ec4..9c92c47ff8b 100644 --- a/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-emergency-refund-time.tsx +++ b/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-emergency-refund-time.tsx @@ -1,6 +1,5 @@ import { BitcoinContractRequestSelectors } from '@tests/selectors/bitcoin-contract-request.selectors'; -import { Flex } from 'leather-styles/jsx'; -import { styled } from 'leather-styles/jsx'; +import { Flex, styled } from 'leather-styles/jsx'; interface BitcoinContractEmergencyRefundTimeProps { emergencyRefundTime: string; diff --git a/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-lock-amount.tsx b/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-lock-amount.tsx index e8ce61bfba3..cc1cd5c6bcc 100644 --- a/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-lock-amount.tsx +++ b/src/app/pages/bitcoin-contract-request/components/bitcoin-contract-offer/bitcoin-contract-lock-amount.tsx @@ -44,11 +44,14 @@ export function BitcoinContractLockAmount({ + {/** TODO: We need to persist the tooltip after it is clicked. + Current implementation of radix-ui tooltip doesn't allow that, ref: https://github.com/radix-ui/primitives/issues/2029 */} {subtitle ? ( }> {homePageModalRoutes} - {homePageModalRoutes} diff --git a/src/app/pages/onboarding/set-password/components/password-field.tsx b/src/app/pages/onboarding/set-password/components/password-field.tsx index ffdb59c97fb..bd7e0cef06c 100644 --- a/src/app/pages/onboarding/set-password/components/password-field.tsx +++ b/src/app/pages/onboarding/set-password/components/password-field.tsx @@ -7,6 +7,7 @@ import { Box, Flex, styled } from 'leather-styles/jsx'; import { ValidatedPassword } from '@app/common/validation/validate-password'; import { EyeIcon } from '@app/ui/components/icons/eye-icon'; import { EyeSlashIcon } from '@app/ui/components/icons/eye-slash-icon'; +import { Input } from '@app/ui/components/input/input'; import { Caption } from '@app/ui/components/typography/caption'; import { getIndicatorsOfPasswordStrength } from './password-field.utils'; @@ -28,38 +29,33 @@ export function PasswordField({ strengthResult, isDisabled }: PasswordFieldProps return ( <> - + + Password + + setShowPassword(!showPassword)} position="absolute" right="space.04" - top="20px" - transform="matrix(-1, 0, 0, 1, 0, 0)" + top="22px" type="button" width="20px" + zIndex={10} > {showPassword ? : } diff --git a/src/app/pages/onboarding/welcome/welcome.layout.tsx b/src/app/pages/onboarding/welcome/welcome.layout.tsx index de153ec3e8c..d57bf278a8c 100644 --- a/src/app/pages/onboarding/welcome/welcome.layout.tsx +++ b/src/app/pages/onboarding/welcome/welcome.layout.tsx @@ -100,6 +100,7 @@ export function WelcomeLayout({ invert={isAtleastBreakpointMd} flex={1} mt={[0, 0, 'space.05']} + onClick={onSelectConnectLedger} > Use Ledger diff --git a/src/app/pages/onboarding/welcome/welcome.tsx b/src/app/pages/onboarding/welcome/welcome.tsx index fae11bd2dc8..b513e63bcaf 100644 --- a/src/app/pages/onboarding/welcome/welcome.tsx +++ b/src/app/pages/onboarding/welcome/welcome.tsx @@ -67,15 +67,22 @@ export function WelcomePage() { const restoreWallet = pageModeRoutingAction(RouteUrls.SignIn); + const onSelectConnectLedger = useCallback(async () => { + await keyActions.signOut(); + if (doesBrowserSupportWebUsbApi()) { + supportsWebUsbAction(); + } else { + doesNotSupportWebUsbAction(); + } + }, [doesNotSupportWebUsbAction, keyActions, supportsWebUsbAction]); + return ( <> - doesBrowserSupportWebUsbApi() ? supportsWebUsbAction() : doesNotSupportWebUsbAction() - } + onSelectConnectLedger={onSelectConnectLedger} onStartOnboarding={() => startOnboarding()} onRestoreWallet={() => restoreWallet()} /> diff --git a/src/app/pages/select-network/components/network-list-item.layout.tsx b/src/app/pages/select-network/components/network-list-item.layout.tsx index d6d6ba0ee8e..8e2de838d7c 100644 --- a/src/app/pages/select-network/components/network-list-item.layout.tsx +++ b/src/app/pages/select-network/components/network-list-item.layout.tsx @@ -1,6 +1,5 @@ import { SettingsSelectors } from '@tests/selectors/settings.selectors'; -import { Box, Flex, Stack } from 'leather-styles/jsx'; -import { styled } from 'leather-styles/jsx'; +import { Box, Flex, Stack, styled } from 'leather-styles/jsx'; import { NetworkConfiguration } from '@shared/constants'; diff --git a/src/app/pages/send/choose-crypto-asset/choose-crypto-asset.tsx b/src/app/pages/send/choose-crypto-asset/choose-crypto-asset.tsx index 7167b26ea96..14bf7580719 100644 --- a/src/app/pages/send/choose-crypto-asset/choose-crypto-asset.tsx +++ b/src/app/pages/send/choose-crypto-asset/choose-crypto-asset.tsx @@ -4,11 +4,10 @@ import { useNavigate } from 'react-router-dom'; import { AllTransferableCryptoAssetBalances } from '@shared/models/crypto-asset-balance.model'; import { RouteUrls } from '@shared/route-urls'; +import { useBrc20Tokens } from '@app/common/hooks/use-brc20-tokens'; import { useRouteHeader } from '@app/common/hooks/use-route-header'; import { useAllTransferableCryptoAssetBalances } from '@app/common/hooks/use-transferable-asset-balances.hooks'; import { useWalletType } from '@app/common/use-wallet-type'; -import { Brc20TokensLoader } from '@app/components/brc20-tokens-loader'; -import { Brc20TokenAssetList } from '@app/components/crypto-assets/bitcoin/brc20-token-asset-list/brc20-token-asset-list'; import { ChooseCryptoAssetLayout } from '@app/components/crypto-assets/choose-crypto-asset/choose-crypto-asset.layout'; import { CryptoAssetList } from '@app/components/crypto-assets/choose-crypto-asset/crypto-asset-list'; import { ModalHeader } from '@app/components/modal-header'; @@ -17,6 +16,7 @@ import { useCheckLedgerBlockchainAvailable } from '@app/store/accounts/blockchai export function ChooseCryptoAsset() { const allTransferableCryptoAssetBalances = useAllTransferableCryptoAssetBalances(); + const brc20Tokens = useBrc20Tokens(); const { whenWallet } = useWalletType(); const navigate = useNavigate(); @@ -49,6 +49,7 @@ export function ChooseCryptoAsset() { navigateToSendForm(cryptoAssetBalance)} + brc20Tokens={brc20Tokens} cryptoAssetBalances={allTransferableCryptoAssetBalances.filter(asset => whenWallet({ ledger: checkBlockchainAvailable(asset.blockchain), @@ -56,14 +57,6 @@ export function ChooseCryptoAsset() { }) )} /> - {whenWallet({ - software: ( - - {brc20Tokens => } - - ), - ledger: null, - })} ); } diff --git a/src/app/pages/send/ordinal-inscription/components/collectible-asset.tsx b/src/app/pages/send/ordinal-inscription/components/collectible-asset.tsx index e769cdfd4f5..adf6c33f02f 100644 --- a/src/app/pages/send/ordinal-inscription/components/collectible-asset.tsx +++ b/src/app/pages/send/ordinal-inscription/components/collectible-asset.tsx @@ -1,5 +1,4 @@ -import { Flex } from 'leather-styles/jsx'; -import { HStack, styled } from 'leather-styles/jsx'; +import { Flex, HStack, styled } from 'leather-styles/jsx'; interface CollectibleAssetProps { icon: React.JSX.Element; diff --git a/src/app/pages/send/send-crypto-asset-form/components/amount-field.tsx b/src/app/pages/send/send-crypto-asset-form/components/amount-field.tsx index 2225aa24356..03d182f6c19 100644 --- a/src/app/pages/send/send-crypto-asset-form/components/amount-field.tsx +++ b/src/app/pages/send/send-crypto-asset-form/components/amount-field.tsx @@ -1,5 +1,4 @@ -import type { ChangeEvent } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; import { useField } from 'formik'; diff --git a/src/app/pages/send/send-crypto-asset-form/components/form-footer.tsx b/src/app/pages/send/send-crypto-asset-form/components/form-footer.tsx index 2993596e33b..9a5157f55e9 100644 --- a/src/app/pages/send/send-crypto-asset-form/components/form-footer.tsx +++ b/src/app/pages/send/send-crypto-asset-form/components/form-footer.tsx @@ -1,5 +1,4 @@ -import { Box } from 'leather-styles/jsx'; -import { Flex } from 'leather-styles/jsx'; +import { Box, Flex } from 'leather-styles/jsx'; import { Money } from '@shared/models/money.model'; diff --git a/src/app/pages/send/send-crypto-asset-form/components/recipient-fields/components/recipient-address-displayer.tsx b/src/app/pages/send/send-crypto-asset-form/components/recipient-fields/components/recipient-address-displayer.tsx index 48c9b81c792..5241f2915fc 100644 --- a/src/app/pages/send/send-crypto-asset-form/components/recipient-fields/components/recipient-address-displayer.tsx +++ b/src/app/pages/send/send-crypto-asset-form/components/recipient-fields/components/recipient-address-displayer.tsx @@ -28,7 +28,9 @@ export function RecipientAddressDisplayer({ address }: RecipientAddressDisplayer > {address} - + {/** TODO: We need to persist the tooltip after it is clicked. + Current implementation of radix-ui tooltip doesn't allow that, ref: https://github.com/radix-ui/primitives/issues/2029 */} + ; return ( - - - onSendMax()} - {...props} - > - {isSendingMax ? 'Sending max' : 'Send max'} - - + + onSendMax()} + {...props} + > + {isSendingMax ? 'Sending max' : 'Send max'} + ); } diff --git a/src/app/pages/send/send-crypto-asset-form/family/bitcoin/hooks/use-calculate-max-spend.ts b/src/app/pages/send/send-crypto-asset-form/family/bitcoin/hooks/use-calculate-max-spend.ts index c63aecbe149..d83eef530f7 100644 --- a/src/app/pages/send/send-crypto-asset-form/family/bitcoin/hooks/use-calculate-max-spend.ts +++ b/src/app/pages/send/send-crypto-asset-form/family/bitcoin/hooks/use-calculate-max-spend.ts @@ -1,47 +1,20 @@ import { useCallback } from 'react'; -import BigNumber from 'bignumber.js'; -import { getAddressInfo, validate } from 'bitcoin-address-validation'; - -import { createMoney } from '@shared/models/money.model'; - -import { satToBtc } from '@app/common/money/unit-conversion'; -import { BtcSizeFeeEstimator } from '@app/common/transactions/bitcoin/fees/btc-size-fee-estimator'; -import { useCurrentNativeSegwitAddressBalance } from '@app/query/bitcoin/balance/btc-native-segwit-balance.hooks'; +import { calculateMaxBitcoinSpend } from '@app/common/transactions/bitcoin/fees/calculate-max-bitcoin-spend'; import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client'; import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks'; export function useCalculateMaxBitcoinSpend() { - const balance = useCurrentNativeSegwitAddressBalance(); const { data: feeRates } = useAverageBitcoinFeeRates(); return useCallback( - (address = '', utxos: UtxoResponseItem[], feeRate?: number) => { - if (!utxos.length || !feeRates) - return { - spendAllFee: 0, - amount: createMoney(0, 'BTC'), - spendableBitcoin: new BigNumber(0), - }; - - const txSizer = new BtcSizeFeeEstimator(); - const addressInfo = validate(address) ? getAddressInfo(address) : null; - const addressTypeWithFallback = addressInfo ? addressInfo.type : 'p2wpkh'; - const size = txSizer.calcTxSize({ - input_script: 'p2wpkh', - input_count: utxos.length, - [`${addressTypeWithFallback}_output_count`]: 1, - }); - const fee = Math.ceil(size.txVBytes * (feeRate ?? feeRates.halfHourFee.toNumber())); - - const spendableAmount = BigNumber.max(0, balance.amount.minus(fee)); - - return { - spendAllFee: fee, - amount: createMoney(spendableAmount, 'BTC'), - spendableBitcoin: satToBtc(spendableAmount), - }; - }, - [balance.amount, feeRates] + (address = '', utxos: UtxoResponseItem[], feeRate?: number) => + calculateMaxBitcoinSpend({ + address, + utxos, + feeRate, + fetchedFeeRates: feeRates, + }), + [feeRates] ); } diff --git a/src/app/pages/send/send-crypto-asset-form/form/brc-20/brc-20-choose-fee.tsx b/src/app/pages/send/send-crypto-asset-form/form/brc-20/brc-20-choose-fee.tsx index 763821f9d6e..22eb0020c78 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/brc-20/brc-20-choose-fee.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/brc-20/brc-20-choose-fee.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { toast } from 'react-hot-toast'; -import { Outlet } from 'react-router-dom'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Stack } from 'leather-styles/jsx'; import get from 'lodash.get'; diff --git a/src/app/query/common/market-data/market-data.hooks.ts b/src/app/query/common/market-data/market-data.hooks.ts index 506bc916320..499fd612837 100644 --- a/src/app/query/common/market-data/market-data.hooks.ts +++ b/src/app/query/common/market-data/market-data.hooks.ts @@ -1,5 +1,4 @@ -import { useMemo } from 'react'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import BigNumber from 'bignumber.js'; @@ -8,8 +7,10 @@ import { MarketData, createMarketData, createMarketPair } from '@shared/models/m import { Money, createMoney, currencyDecimalsMap } from '@shared/models/money.model'; import { calculateMeanAverage } from '@app/common/math/calculate-averages'; -import { convertAmountToFractionalUnit } from '@app/common/money/calculate-money'; -import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money'; +import { + baseCurrencyAmountInQuote, + convertAmountToFractionalUnit, +} from '@app/common/money/calculate-money'; import { selectBinanceUsdPrice, diff --git a/src/app/routes/onboarding-gate.tsx b/src/app/routes/onboarding-gate.tsx index 619022a117b..9ba0efb67ea 100644 --- a/src/app/routes/onboarding-gate.tsx +++ b/src/app/routes/onboarding-gate.tsx @@ -4,6 +4,7 @@ import { Navigate } from 'react-router-dom'; import { RouteUrls } from '@shared/route-urls'; import { useDefaultWalletSecretKey } from '@app/store/in-memory-key/in-memory-key.selectors'; +import { useHasLedgerKeys } from '@app/store/ledger/ledger.selectors'; import { useCurrentKeyDetails } from '@app/store/software-keys/software-key.selectors'; function hasAlreadyMadeWalletAndPlaintextKeyInMemory(encryptedKey?: string, inMemoryKey?: string) { @@ -20,10 +21,15 @@ interface OnboardingGateProps { export function OnboardingGate({ children }: OnboardingGateProps) { const keyDetails = useCurrentKeyDetails(); const currentInMemoryKey = useDefaultWalletSecretKey(); + const isLedger = useHasLedgerKeys(); if ( - keyDetails?.type === 'software' && - hasAlreadyMadeWalletAndPlaintextKeyInMemory(keyDetails.encryptedSecretKey, currentInMemoryKey) + (keyDetails?.type === 'software' && + hasAlreadyMadeWalletAndPlaintextKeyInMemory( + keyDetails.encryptedSecretKey, + currentInMemoryKey + )) || + isLedger ) { return ; } diff --git a/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts b/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts index 1076321a929..9d6b056de0c 100644 --- a/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts +++ b/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts @@ -7,9 +7,9 @@ import AppClient from 'ledger-bitcoin'; import { getBitcoinJsLibNetworkConfigByMode } from '@shared/crypto/bitcoin/bitcoin.network'; import { extractAddressIndexFromPath, + getInputPaymentType, getTaprootAddress, } from '@shared/crypto/bitcoin/bitcoin.utils'; -import { getInputPaymentType } from '@shared/crypto/bitcoin/bitcoin.utils'; import { getTaprootAccountDerivationPath } from '@shared/crypto/bitcoin/p2tr-address-gen'; import { getNativeSegwitAccountDerivationPath } from '@shared/crypto/bitcoin/p2wpkh-address-gen'; import { @@ -40,10 +40,12 @@ import { useCurrentAccountNativeSegwitSigner, useCurrentNativeSegwitAccount, useUpdateLedgerSpecificNativeSegwitBip32DerivationForAdddressIndexZero, + useUpdateLedgerSpecificNativeSegwitUtxoHexForAdddressIndexZero, } from './native-segwit-account.hooks'; -import { useUpdateLedgerSpecificNativeSegwitUtxoHexForAdddressIndexZero } from './native-segwit-account.hooks'; -import { useCurrentTaprootAccount } from './taproot-account.hooks'; -import { useUpdateLedgerSpecificTaprootInputPropsForAdddressIndexZero } from './taproot-account.hooks'; +import { + useCurrentTaprootAccount, + useUpdateLedgerSpecificTaprootInputPropsForAdddressIndexZero, +} from './taproot-account.hooks'; // Checks for both TR and NativeSegwit hooks export function useHasCurrentBitcoinAccount() { diff --git a/src/app/ui/components/bullet-separator/bullet-separator.stories.tsx b/src/app/ui/components/bullet-separator/bullet-separator.stories.tsx new file mode 100644 index 00000000000..cbe12f78380 --- /dev/null +++ b/src/app/ui/components/bullet-separator/bullet-separator.stories.tsx @@ -0,0 +1,52 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Caption } from '../typography/caption'; +import { Title } from '../typography/title'; +import { BulletSeparator as Component } from './bullet-separator'; + +/** + * Note that the BulletSeparator component doesn't bring it's own margins, these + * should be appiled separately + */ +const meta: Meta = { + component: Component, + tags: ['autodocs'], + title: 'BulletSeparator', +}; + +export default meta; +type Story = StoryObj; + +export const BulletSeparator: Story = { + render: () => ( + + Item 1 + Item 2 + Item 3 + + ), +}; + +export const WithCaption: Story = { + render: () => ( + + + Item 1 + Item 2 + Item 3 + + + ), +}; + +export const WithTitle: Story = { + render: () => ( + + <Component> + <span style={{ margin: '0 6px' }}>Item 1</span> + <span style={{ margin: '0 6px' }}>Item 2</span> + <span style={{ margin: '0 6px' }}>Item 3</span> + </Component> + + ), +}; diff --git a/src/app/ui/components/bullet-separator/bullet-separator.tsx b/src/app/ui/components/bullet-separator/bullet-separator.tsx new file mode 100644 index 00000000000..9d6c7f1213e --- /dev/null +++ b/src/app/ui/components/bullet-separator/bullet-separator.tsx @@ -0,0 +1,32 @@ +import { cloneElement, isValidElement } from 'react'; + +import { Circle, CircleProps } from 'leather-styles/jsx'; + +export function BulletOperator(props: CircleProps) { + return ( + + ); +} + +interface BulletSeparatorSeparatorProps { + children: React.ReactNode; +} +export function BulletSeparator({ children }: BulletSeparatorSeparatorProps) { + const parsedChildren = Array.isArray(children) ? children : [children]; + const content = parsedChildren + .flatMap((child, index) => { + if (!isValidElement(child)) return null; + return [cloneElement(child, { key: index }), ]; + }) + .filter(val => val !== null) + .slice(0, -1); + return <>{content}; +} diff --git a/src/app/ui/components/input.tsx b/src/app/ui/components/input.tsx deleted file mode 100644 index 09ec6190015..00000000000 --- a/src/app/ui/components/input.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { HTMLStyledProps, styled } from 'leather-styles/jsx'; - -type InputProps = HTMLStyledProps<'input'>; - -export function Input(props: InputProps) { - return ( - - ); -} diff --git a/src/app/ui/components/input/input.stories.tsx b/src/app/ui/components/input/input.stories.tsx new file mode 100644 index 00000000000..199aef5db32 --- /dev/null +++ b/src/app/ui/components/input/input.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Input } from './input'; + +const meta: Meta = { + component: Input.Root, + tags: ['autodocs'], + title: 'Input', +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + Label + + + ), +}; + +export const Error: Story = { + render: () => ( + + Error field + + + ), +}; + +export const Disabled: Story = { + render: () => ( + + Field is disabled + + + ), +}; + +export const DefaultValue: Story = { + render: () => ( + + Description + + + ), +}; + +/** + * Layout needs to be adjusted in case where there's no label provided + * An example of this is our Secret Key input form + */ +export const InputNoLabel: Story = { + render: () => ( + + + + ), +}; + +/** + * When using a placeholder, the label *must* come after the `Input.Field`. + */ +export const WithPlaceholder: Story = { + render: () => ( + + + Error field + + ), +}; diff --git a/src/app/ui/components/input/input.tsx b/src/app/ui/components/input/input.tsx new file mode 100644 index 00000000000..90afff1870a --- /dev/null +++ b/src/app/ui/components/input/input.tsx @@ -0,0 +1,215 @@ +import { + type ComponentProps, + LegacyRef, + createContext, + forwardRef, + useContext, + useImperativeHandle, + useRef, + useState, +} from 'react'; + +import { sva } from 'leather-styles/css'; +import { SystemStyleObject } from 'leather-styles/types'; + +import { useOnMount } from '@app/common/hooks/use-on-mount'; +import { propIfDefined } from '@app/common/utils'; +import { createStyleContext } from '@app/ui/utils/style-context'; + +const hackyDelayOneMs = 1; + +const transformedLabelStyles: SystemStyleObject = { + textStyle: 'label.03', + transform: 'translateY(-12px)', + fontWeight: 500, +}; + +const input = sva({ + slots: ['root', 'label', 'input'], + base: { + root: { + display: 'block', + pos: 'relative', + minHeight: '64px', + p: 'space.04', + ring: 'none', + textStyle: 'body.02', + zIndex: 4, + color: 'accent.text-subdued', + _before: { + content: '""', + rounded: 'xs', + pos: 'absolute', + top: '-1px', + left: '-1px', + right: '-1px', + bottom: '-1px', + border: '3px solid transparent', + zIndex: 9, + pointerEvents: 'none', + }, + _focusWithin: { + '& label': { color: 'accent.text-primary', ...transformedLabelStyles }, + _before: { + border: 'action', + borderWidth: '2px', + }, + }, + '&[data-has-error="true"]': { + color: 'error.label', + _before: { + borderColor: 'error.label', + borderWidth: '2px', + }, + }, + }, + input: { + background: 'transparent', + appearance: 'none', + rounded: 'xs', + pos: 'absolute', + px: 'space.04', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 5, + textStyle: 'body.01', + color: 'accent.text-primary', + border: '1px solid', + borderColor: 'accent.border-hover', + _disabled: { + bg: 'accent.component-background-default', + borderColor: 'accent.non-interactive', + cursor: 'not-allowed', + }, + _focus: { ring: 'none' }, + _placeholder: { color: 'accent.text-subdued' }, + '&:placeholder-shown + label': transformedLabelStyles, + '[data-has-label="true"] &': { + pt: '22px', + pb: '4px', + }, + }, + label: { + pointerEvents: 'none', + pos: 'absolute', + top: '36%', + left: 'space.04', + zIndex: 9, + color: 'inherit', + textStyle: 'body.02', + transition: 'font-size 100ms ease-in-out, transform 100ms ease-in-out', + // Move the input's label to the top when the input has a value + '[data-has-value="true"] &': transformedLabelStyles, + '[data-shrink="true"] &': transformedLabelStyles, + }, + }, +}); + +type InputChldren = 'root' | 'label' | 'input'; + +const { withProvider, withContext } = createStyleContext(input); + +interface InputContextProps { + hasValue: boolean; + setHasValue(hasValue: boolean): void; + registerChild(child: string): void; + children: InputChldren[]; +} + +const InputContext = createContext(null); + +function useInputContext() { + const context = useContext(InputContext); + if (!context) throw new Error('useInputContext must be used within an Input.Root'); + return context; +} + +const RootBase = withProvider('div', 'root'); + +interface RootProps extends ComponentProps<'div'> { + hasError?: boolean; + /** + * Display the label in a top fixed position. Often necessary when + * programmatically updating inputs, similar to issues described here + * https://mui.com/material-ui/react-text-field/#limitations + */ + shrink?: boolean; +} +function Root({ hasError, shrink, ...props }: RootProps) { + const [hasValue, setHasValue] = useState(false); + const [children, setChildren] = useState(['root']); + + function registerChild(child: InputChldren) { + setChildren(children => [...children, child]); + } + + const dataAttrs = { + ...propIfDefined('data-has-error', hasError), + ...propIfDefined('data-shrink', shrink), + 'data-has-label': children.includes('label'), + }; + + return ( + + + + ); +} + +const FieldBase = withContext('input', 'input'); + +const Field = forwardRef(({ type, ...props }: ComponentProps<'input'>, ref) => { + const { setHasValue } = useInputContext(); + const innerRef = useRef(null); + + useImperativeHandle(ref, () => innerRef.current); + + // We need to determine whether the input has a value on it's initial + // render. In many places we use Formik to apply default form values. + // Formik sets these values after the initial render, so we need to wait + // before doing this check to see if there's a value. + useOnMount( + () => + void setTimeout(() => { + if (innerRef.current?.value !== '') setHasValue(true); + }, hackyDelayOneMs) + ); + + // `type=number` is bad UX, instead we follow guidance here + // https://mui.com/material-ui/react-text-field/#type-quot-number-quot + // https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/ + const inputTypeProps = + type === 'number' + ? ({ type: 'text', inputMode: 'numeric', pattern: '[0-9]*' } as const) + : { type }; + + return ( + { + // Note: this logic to determine if the field is empty may have to be + // made dynamic to `input=type`, and potentially made configurable with + // a callback passed to `Input.Root` e.g. + // ``` + // typeof value === 'number' && value <= 0} /> + // ``` + if (e.target instanceof HTMLInputElement) setHasValue(e.target.value !== ''); + props.onInput?.(e); + }} + /> + ); +}); + +const LabelBase = withContext('label', 'label'); + +const Label = forwardRef((props: ComponentProps<'label'>, ref: LegacyRef) => { + const { registerChild } = useInputContext(); + useOnMount(() => registerChild('label')); + return ; +}); + +export const Input = { Root, Field, Label }; diff --git a/src/app/ui/components/link/link.tsx b/src/app/ui/components/link/link.tsx index 97e359844b5..ff714540708 100644 --- a/src/app/ui/components/link/link.tsx +++ b/src/app/ui/components/link/link.tsx @@ -1,3 +1,5 @@ +import { ForwardedRef, forwardRef } from 'react'; + import { styled } from 'leather-styles/jsx'; import { type LinkVariantProps, link as linkRecipe } from 'leather-styles/recipes/link'; @@ -6,11 +8,12 @@ const StyledLink = styled('a'); type LinkProps = Omit, keyof LinkVariantProps> & LinkVariantProps; -export function Link(props: LinkProps) { +export const Link = forwardRef((props: LinkProps, ref: ForwardedRef) => { const { children, fullWidth, invert, size, variant, ...rest } = props; return ( ); -} +}); diff --git a/src/app/ui/components/tabs/tabs.stories.tsx b/src/app/ui/components/tabs/tabs.stories.tsx index f2127fcc98e..c98cee10e08 100644 --- a/src/app/ui/components/tabs/tabs.stories.tsx +++ b/src/app/ui/components/tabs/tabs.stories.tsx @@ -9,6 +9,7 @@ const meta: Meta = { }; export default meta; + type Story = StoryObj; export const Tabs: Story = { diff --git a/src/app/ui/components/tooltip/basic-tooltip.tsx b/src/app/ui/components/tooltip/basic-tooltip.tsx index 79928e829a7..e10e2d61616 100644 --- a/src/app/ui/components/tooltip/basic-tooltip.tsx +++ b/src/app/ui/components/tooltip/basic-tooltip.tsx @@ -9,13 +9,14 @@ interface BasicTooltipProps { label?: string; disabled?: boolean; side?: RadixTooltip.TooltipContentProps['side']; + asChild?: boolean; } -export function BasicTooltip({ children, label, disabled, side }: BasicTooltipProps) { +export function BasicTooltip({ children, label, disabled, side, asChild }: BasicTooltipProps) { const isDisabled = !label || disabled; return ( - {children} + {children}