From f96f8e465a8ce49cf1a27a6e64e66628cad2cd46 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 13 Aug 2026 13:10:39 -0700 Subject: [PATCH 1/3] Fix lint warnings in DeepLinkingManager Add explicit return types, type the catch callback as unknown, and replace the direct react-native-vector-icons import with a themed BellIcon. --- eslint.config.mjs | 2 +- src/components/icons/ThemedIcons.tsx | 2 ++ src/components/services/DeepLinkingManager.tsx | 16 +++++----------- 3 files changed, 8 insertions(+), 12 deletions(-) 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/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/services/DeepLinkingManager.tsx b/src/components/services/DeepLinkingManager.tsx index 065af603acd..b5db8d7f480 100644 --- a/src/components/services/DeepLinkingManager.tsx +++ b/src/components/services/DeepLinkingManager.tsx @@ -3,7 +3,6 @@ import messaging, { } from '@react-native-firebase/messaging' import * as React from 'react' import { Linking } from 'react-native' -import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome' import { launchDeepLink } from '../../actions/DeepLinkingActions' import { ENV } from '../../env' @@ -15,6 +14,7 @@ import { useDispatch, useSelector } from '../../types/reactRedux' import type { NavigationBase } from '../../types/routerTypes' import { parseDeepLink } from '../../util/DeepLinkParser' import { parsePushMessage } from '../../util/PushMessageParser' +import { BellIcon } from '../icons/ThemedIcons' import { FlashNotification } from '../navigation/FlashNotification' import { Airship, showDevError, showError } from './AirshipInstance' import { cacheStyles, type Theme, useTheme } from './ThemeContext' @@ -23,7 +23,7 @@ interface Props { navigation: NavigationBase } -export function DeepLinkingManager(props: Props) { +export const DeepLinkingManager: React.FC = props => { const { navigation } = props const dispatch = useDispatch() const theme = useTheme() @@ -96,7 +96,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 +130,9 @@ export function DeepLinkingManager(props: Props) { onPress={() => { bridge.resolve() }} - icon={ - - } + icon={} /> - )).catch(error => { + )).catch((error: unknown) => { showDevError(String(error)) }) } From eb87e4d35fd9d3ce2c879e6a021a7545def5dd50 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 13 Aug 2026 13:30:20 -0700 Subject: [PATCH 2/3] Wait only for the account state each deep link uses DeepLinkingManager held one gate for every link type, and it required every wallet in activeWalletIds to finish loading. A link that only navigates, such as the ramps buy/sell entry, sat pending for as long as wallet loading took. Each link type now declares the lightest app state it can safely run in, and the manager releases the link as soon as the app reaches it. Link types that search currencyWallets or open a wallet picker keep waiting for wallets, and promotion links keep waiting for the account referral so activatePromotion cannot persist default state. --- src/__tests__/DeepLink.test.ts | 93 +++++++++++++++++++ src/actions/DeepLinkingActions.tsx | 79 ++++++++++++++++ .../services/DeepLinkingManager.tsx | 32 +++++-- 3 files changed, 195 insertions(+), 9 deletions(-) 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/services/DeepLinkingManager.tsx b/src/components/services/DeepLinkingManager.tsx index b5db8d7f480..e36fc649e59 100644 --- a/src/components/services/DeepLinkingManager.tsx +++ b/src/components/services/DeepLinkingManager.tsx @@ -4,7 +4,12 @@ import messaging, { import * as React from 'react' import { Linking } from 'react-native' -import { launchDeepLink } from '../../actions/DeepLinkingActions' +import { + type DeepLinkReadiness, + deepLinkReadinessRank, + getDeepLinkReadiness, + launchDeepLink +} from '../../actions/DeepLinkingActions' import { ENV } from '../../env' import { useAsyncEffect } from '../../hooks/useAsyncEffect' import { useWatch } from '../../hooks/useWatch' @@ -37,7 +42,6 @@ export const DeepLinkingManager: React.FC = 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 const DeepLinkingManager: React.FC = 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( From 70e5ddcfad67639a2e6ec5ca32f21575b35a1b1a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 13 Aug 2026 13:30:49 -0700 Subject: [PATCH 3/3] Keep the ramp amount placeholder readable while the wallet loads The placeholder was built by formatting the crypto currency code, which is undefined until the persisted wallet selection resolves, so the field read "Amount undefined". That window was invisible before, because the scene was only reachable once the wallets had loaded; a deep link now opens it earlier. Fall back to a plain "Amount" until the code is known. --- CHANGELOG.md | 2 ++ src/components/scenes/RampCreateScene.tsx | 14 ++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) 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/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) => {