Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
93 changes: 93 additions & 0 deletions src/__tests__/DeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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')
})
})
79 changes: 79 additions & 0 deletions src/actions/DeepLinkingActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,85 @@ const CREATE_WALLET_ASSETS: Record<string, EdgeAsset> = {
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<DeepLinkReadiness, number> = {
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'
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

/**
* The app has just received some of link,
* so try to follow it if possible, or save it for later if not.
Expand Down
2 changes: 2 additions & 0 deletions src/components/icons/ThemedIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
14 changes: 10 additions & 4 deletions src/components/scenes/RampCreateScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,15 @@ export const RampCreateScene: React.FC<Props> = (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 (
<SceneWrapper
Expand Down Expand Up @@ -1079,10 +1088,7 @@ export const RampCreateScene: React.FC<Props> = (props: Props) => {
<FilledTextInput
value={displayCryptoAmount}
onChangeText={handleCryptoChangeText}
placeholder={sprintf(
lstrings.trade_create_amount_s,
getSelectedCryptoDisplay() ?? selectedCryptoCurrencyCode
)}
placeholder={cryptoAmountPlaceholder}
keyboardType="decimal-pad"
numeric
maxDecimals={6}
Expand Down
48 changes: 28 additions & 20 deletions src/components/services/DeepLinkingManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ 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 {
type DeepLinkReadiness,
deepLinkReadinessRank,
getDeepLinkReadiness,
launchDeepLink
} from '../../actions/DeepLinkingActions'
import { ENV } from '../../env'
import { useAsyncEffect } from '../../hooks/useAsyncEffect'
import { useWatch } from '../../hooks/useWatch'
Expand All @@ -15,6 +19,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'
Expand All @@ -23,7 +28,7 @@ interface Props {
navigation: NavigationBase
}

export function DeepLinkingManager(props: Props) {
export const DeepLinkingManager: React.FC<Props> = props => {
const { navigation } = props
const dispatch = useDispatch()
const theme = useTheme()
Expand All @@ -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')
Expand All @@ -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(
Expand Down Expand Up @@ -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 ?? ''

Expand Down Expand Up @@ -130,15 +144,9 @@ export function DeepLinkingManager(props: Props) {
onPress={() => {
bridge.resolve()
}}
icon={
<FontAwesomeIcon
name="bell-o"
size={theme.rem(2)}
style={styles.icon}
/>
}
icon={<BellIcon size={theme.rem(2)} style={styles.icon} />}
/>
)).catch(error => {
)).catch((error: unknown) => {
showDevError(String(error))
})
}
Expand Down
Loading