diff --git a/CHANGELOG.md b/CHANGELOG.md index 9030b09847b..2066fd45ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - changed: Use a custom chart icon for the side menu Markets row, so it matches the rest of the menu. - changed: Use the UI4 warning card for the Reveal Raw Keys and Reveal Master Private Key password confirmation warnings. - changed: Tron resource staking now describes its claim action as reclaiming your own TRX, instead of claiming a reward. +- changed: Deep links now wait only for the account state they actually use, so a link that just opens a scene, such as the buy/sell entry, follows immediately after login instead of waiting for every wallet to finish loading. +- fixed: The buy/sell amount field no longer reads "Amount undefined" while the app is still working out which wallet to use. - fixed: Bitwave CSV exports now use ISO 8601 UTC timestamps, leave the fee columns blank so Bitwave does not double-count fees, and copy the description into the second custom metadata column. - fixed: Bitwave account ids are no longer capitalized by the keyboard or padded with whitespace when entered, so exports import without hand-editing the account id. - fixed: NYM max swaps from EVM wallets now report the correct limit error instead of an unsupported-route error (edge-exchange-plugins 2.52.1). diff --git a/eslint.config.mjs b/eslint.config.mjs index 40097460d0c..549653b2787 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -322,7 +322,7 @@ export default [ 'src/components/services/AirshipInstance.tsx', 'src/components/services/AutoLogout.ts', 'src/components/services/ContactsLoader.ts', - 'src/components/services/DeepLinkingManager.tsx', + 'src/components/services/EdgeContextCallbackManager.tsx', 'src/components/services/FioService.ts', diff --git a/src/__tests__/DeepLink.test.ts b/src/__tests__/DeepLink.test.ts index f3f968b2032..43cfc213b21 100644 --- a/src/__tests__/DeepLink.test.ts +++ b/src/__tests__/DeepLink.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from '@jest/globals' +import { + type DeepLinkReadiness, + getDeepLinkReadiness +} from '../actions/DeepLinkingActions' import type { DeepLink } from '../types/DeepLinkTypes' import { parseDeepLink } from '../util/DeepLinkParser' @@ -635,3 +639,92 @@ describe('parseDeepLink', function () { }) }) }) + +describe('getDeepLinkReadiness', function () { + /** + * Every member of the `DeepLink` union, paired with the app state it must + * wait for. A new link type will not compile until it appears here. + */ + const cases: Array<[DeepLink, DeepLinkReadiness]> = [ + [{ type: 'noop' }, 'loggedOut'], + [{ type: 'passwordRecovery', passwordRecoveryKey: 'key' }, 'loggedOut'], + + [{ type: 'edgeLogin', lobbyId: 'lobby' }, 'account'], + [ + { + type: 'fiatProvider', + direction: 'buy', + providerId: 'simplex', + path: '', + query: {}, + uri: 'edge://fiatprovider/buy/simplex' + }, + 'account' + ], + [{ type: 'price-change', pluginId: 'bitcoin', body: 'up' }, 'account'], + [ + { + type: 'ramp', + direction: 'buy', + providerId: 'simplex', + path: '', + query: {}, + uri: 'edge://ramp/buy/simplex' + }, + 'account' + ], + [ + { type: 'rampCreate', direction: 'buy', providerId: 'moonpay' }, + 'account' + ], + [{ type: 'scene', sceneName: 'walletList', query: undefined }, 'account'], + [{ type: 'swap' }, 'account'], + + [{ type: 'promotion', installerId: 'bob' }, 'referral'], + [ + { type: 'affiliate', installerId: 'bob', link: { type: 'swap' } }, + 'referral' + ], + + [{ type: 'azteco', uri: 'https://azte.co/partners/key' }, 'wallets'], + [{ type: 'fiatPlugin', pluginId: 'moonpay', direction: 'buy' }, 'wallets'], + [{ type: 'modal', modalName: 'fundAccount' }, 'wallets'], + [{ type: 'other', protocol: 'bitcoin', uri: 'bitcoin:addr' }, 'wallets'], + [{ type: 'paymentProto', uri: 'https://pay.example/i/abc' }, 'wallets'], + [ + { + type: 'paymentRedirect', + currencyCode: 'btc', + depositAddress: 'addr' + }, + 'wallets' + ], + [{ type: 'plugin', pluginId: 'custom', path: '/', query: {} }, 'wallets'], + [ + { + type: 'requestAddress', + assets: [{ nativeCode: 'BTC', tokenCode: 'BTC' }], + post: 'https://example.com' + }, + 'wallets' + ], + [{ type: 'rewards', pluginId: 'bitcoin', tokenId: null }, 'wallets'], + [{ type: 'walletConnect', uri: 'wc:topic@2' }, 'wallets'] + ] + + for (const [link, expected] of cases) { + it(`${link.type} needs ${expected}`, function () { + expect(getDeepLinkReadiness(link)).toBe(expected) + }) + } + + it('an affiliate link inherits its inner link when that is stricter', function () { + expect( + getDeepLinkReadiness({ + type: 'affiliate', + installerId: 'bob', + link: { type: 'other', protocol: 'bitcoin', uri: 'bitcoin:addr' } + }) + ).toBe('wallets') + }) +}) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index b6f00dc0ef4..1c0568768e3 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -47,6 +47,85 @@ const CREATE_WALLET_ASSETS: Record = { dash: { pluginId: 'dash', tokenId: null } } +/** + * How much of the app must be loaded before a link can be handled, + * from least to most demanding: + * + * - `loggedOut`: Nothing at all. + * - `account`: A logged-in account with its settings. + * - `referral`: Also the account referral state. + * - `wallets`: Also every wallet in `activeWalletIds`. + * + * Wallets take by far the longest to load, so a link that merely navigates + * should never wait for them. + */ +export type DeepLinkReadiness = 'loggedOut' | 'account' | 'referral' | 'wallets' + +/** Compares two `DeepLinkReadiness` levels. Higher means more demanding. */ +export const deepLinkReadinessRank: Record = { + loggedOut: 0, + account: 1, + referral: 2, + wallets: 3 +} + +/** + * Returns the app state a link needs before `launchDeepLink` can follow it. + * Keep this in sync with `handleLink` below - a link that reads + * `account.currencyWallets` or opens a wallet picker needs `wallets`. + */ +export function getDeepLinkReadiness(link: DeepLink): DeepLinkReadiness { + switch (link.type) { + // We can always handle recovery links, and there is nothing to wait for + // when there is nothing to do: + case 'passwordRecovery': + case 'noop': + return 'loggedOut' + + // These write the account referral state, which would clobber the real + // `CreationReason.json` with default values if it hasn't loaded yet: + case 'promotion': + return 'referral' + case 'affiliate': { + const inner = getDeepLinkReadiness(link.link) + return deepLinkReadinessRank[inner] > deepLinkReadinessRank.referral + ? inner + : 'referral' + } + + // These search `account.currencyWallets` or open a wallet picker, so a + // half-loaded account would show an incomplete list or no match at all. + // `walletConnect` belongs here because `WcConnectionsScene` opens the + // picker as soon as it mounts with a uri: + case 'azteco': + case 'modal': + case 'other': + case 'paymentProto': + case 'paymentRedirect': + case 'requestAddress': + case 'rewards': + case 'walletConnect': + return 'wallets' + + // These check `state.ui.exchangeInfo` for a disabled plugin. That comes + // from the info server, which has no readiness flag of its own, so they + // keep waiting for wallets to give the fetch time to land: + case 'fiatPlugin': + case 'plugin': + return 'wallets' + + // Everything else just navigates, or hands off to an already-open scene: + case 'edgeLogin': + case 'fiatProvider': + case 'price-change': + case 'ramp': + case 'rampCreate': + case 'scene': + case 'swap': + return 'account' + } +} + /** * The app has just received some of link, * so try to follow it if possible, or save it for later if not. diff --git a/src/components/icons/ThemedIcons.tsx b/src/components/icons/ThemedIcons.tsx index 29edb725397..866d7c05b2b 100644 --- a/src/components/icons/ThemedIcons.tsx +++ b/src/components/icons/ThemedIcons.tsx @@ -164,6 +164,8 @@ export const InformationCircleIcon = makeFontIcon( export const DotsThreeVerticalIcon = makeFontIcon(Entypo, 'dots-three-vertical') +export const BellIcon = makeFontIcon(FontAwesome, 'bell-o') + export const CopyIcon = makeFontIcon(FontAwesome, 'copy') export const CheckIcon = makeFontIcon(AntDesignIcon, 'check') diff --git a/src/components/scenes/RampCreateScene.tsx b/src/components/scenes/RampCreateScene.tsx index 2236eebc401..4e7c3746df0 100644 --- a/src/components/scenes/RampCreateScene.tsx +++ b/src/components/scenes/RampCreateScene.tsx @@ -972,6 +972,15 @@ export const RampCreateScene: React.FC = (props: Props) => { const cryptoInputDisabled = isLoadingPersistedCryptoSelection || amountTypeSupport.onlyFiat + // The persisted crypto selection can still be loading, which the scene shows + // for longer when a deep link opens it before the wallets have loaded: + const cryptoAmountDisplay = + getSelectedCryptoDisplay() ?? selectedCryptoCurrencyCode + const cryptoAmountPlaceholder = + cryptoAmountDisplay == null + ? lstrings.string_amount + : sprintf(lstrings.trade_create_amount_s, cryptoAmountDisplay) + // Render trade form view return ( = (props: Props) => { = props => { const { navigation } = props const dispatch = useDispatch() const theme = useTheme() @@ -37,7 +42,6 @@ export function DeepLinkingManager(props: Props) { ) const settingsLoaded = useSelector(state => state.ui.settings.settingsLoaded) - // Wait for wallets to load: const activeWalletIds = useWatch(account, 'activeWalletIds') const currencyWallets = useWatch(account, 'currencyWallets') const currencyWalletErrors = useWatch(account, 'currencyWalletErrors') @@ -47,14 +51,24 @@ export function DeepLinkingManager(props: Props) { currencyWalletErrors[walletId] != null ) - // We need to be fully logged in to handle most link types: + // How much of the app is ready right now: + const loggedIn = account !== defaultAccount && settingsLoaded === true + const appReadiness: DeepLinkReadiness = + loggedIn && accountReferralLoaded && allWalletsLoaded + ? 'wallets' + : loggedIn && accountReferralLoaded + ? 'referral' + : loggedIn + ? 'account' + : 'loggedOut' + + // Each link type waits only for the state it actually uses. Wallets are the + // slowest thing to load, so a link that merely navigates - such as the ramps + // buy/sell entry - follows as soon as the account is logged in: const canHandleLink: boolean = - (account !== defaultAccount && - accountReferralLoaded && - allWalletsLoaded && - settingsLoaded === true) || - // We can always handle recovery links: - pendingLink?.type === 'passwordRecovery' + pendingLink != null && + deepLinkReadinessRank[appReadiness] >= + deepLinkReadinessRank[getDeepLinkReadiness(pendingLink)] // Launches links, no matter how we got them: useAsyncEffect( @@ -96,7 +110,7 @@ export function DeepLinkingManager(props: Props) { /** Handler for push messages received while app is in the foreground. */ const handleForegroundPushMessage = ( message: FirebaseMessagingTypes.RemoteMessage - ) => { + ): void => { const title = message.notification?.title ?? '' const body = message.notification?.body ?? '' @@ -130,15 +144,9 @@ export function DeepLinkingManager(props: Props) { onPress={() => { bridge.resolve() }} - icon={ - - } + icon={} /> - )).catch(error => { + )).catch((error: unknown) => { showDevError(String(error)) }) }