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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message.
- added: `edge://buy` and `edge://sell` deep links (and their `https://deep.edge.app` equivalents) that open the buy/sell flow, optionally pinning a provider and payment method to the top of the quote options for that visit.
- added: Provider priority in the buy/sell options for affiliated accounts, configured through the info server promo card data.
- added: Track when a user opens the app from a marketing push notification, reporting the campaign to analytics and navigating to an optional deep link.
- changed: Target Android 16 (API level 36), which Google Play requires for app updates submitted after Aug 30, 2026. Predictive back is opted out of for now, since React Native 0.79 cannot handle it, so the back button behaves exactly as it did before.
- changed: Sign MoonPay buy/sell widget URLs and bind them to the customer's IP via the info server, for MoonPay's on-ramp IP-matching security upgrade.
- changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in".
Expand Down
110 changes: 110 additions & 0 deletions src/__tests__/PushMessageParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from '@jest/globals'
import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging'

import { parsePushMessage } from '../util/PushMessageParser'

// The parser only uses `showDevError` from AirshipInstance; stub it so the test
// avoids pulling in the native Airship module.
jest.mock('../components/services/AirshipInstance', () => ({
showDevError: () => undefined
}))

/**
* Builds a minimal RemoteMessage; the parser only reads `data` and
* `notification`.
*/
function makeMessage(
data: Record<string, string> | undefined,
notification?: { title?: string; body?: string }
): FirebaseMessagingTypes.RemoteMessage {
return {
data,
notification
} as unknown as FirebaseMessagingTypes.RemoteMessage
}

describe('parsePushMessage', () => {
it('captures the campaignId from a marketing payload', () => {
expect(
parsePushMessage(makeMessage({ type: 'marketing', campaignId: 'abc123' }))
).toEqual({
type: 'marketing',
campaignId: 'abc123',
link: undefined
})
})

it('parses an optional deep-link url into a nested navigation link', () => {
expect(
parsePushMessage(
makeMessage({
type: 'marketing',
campaignId: 'abc123',
url: 'edge://swap'
})
)
).toEqual({
type: 'marketing',
campaignId: 'abc123',
link: { type: 'swap' }
})
})

it('navigates wherever the campaign url points', () => {
// A campaign link reaches the same places a tapped link does, including
// both the new and legacy fiat ramp spellings:
const destinations: Array<[string, string]> = [
['edge://scene/edgeTabs?screen=home', 'scene'], // Home
['edge://scene/walletsTab?screen=walletList', 'scene'], // Wallet list
['edge://scene/earnScene', 'scene'], // Earn
['edge://swap', 'swap'],
['edge://buy/paybis/credit', 'rampCreate'], // Quote scene, pinned
['edge://plugin/creditcard/buy/moonpay/credit', 'fiatPlugin'], // Native ramp
['edge://plugin/moonpay/buy', 'plugin'], // Legacy ramp
['edge://promotion/summer50', 'promotion'],
['edge://modal/fundAccount', 'modal']
]
for (const [url, expectedType] of destinations) {
const result = parsePushMessage(
makeMessage({ type: 'marketing', campaignId: 'abc123', url })
)
if (result?.type !== 'marketing') throw new Error(`not marketing: ${url}`)
expect(result.link?.type).toBe(expectedType)
}
})

it('degrades to track-only when the url is unparseable', () => {
expect(
parsePushMessage(
makeMessage({
type: 'marketing',
campaignId: 'abc123',
url: 'not a url'
})
)
).toEqual({
type: 'marketing',
campaignId: 'abc123',
link: undefined
})
})

it('ignores a non-marketing payload', () => {
expect(parsePushMessage(makeMessage({ foo: 'bar' }))).toBeUndefined()
})

it('still parses a price-change payload', () => {
expect(
parsePushMessage(
makeMessage(
{ type: 'price-change', pluginId: 'bitcoin' },
{ body: 'BTC is up' }
)
)
).toEqual({
type: 'price-change',
pluginId: 'bitcoin',
body: 'BTC is up'
})
})
})
27 changes: 27 additions & 0 deletions src/actions/DeepLinkingActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ export function getDeepLinkReadiness(link: DeepLink): DeepLinkReadiness {
: 'referral'
}

// Tracking the open needs the account; navigation then waits for whatever
// the campaign's own link needs, if it carries one:
case 'marketing': {
const inner =
link.link == null ? 'account' : getDeepLinkReadiness(link.link)
return deepLinkReadinessRank[inner] > deepLinkReadinessRank.account
? inner
: 'account'
}

// 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
Expand Down Expand Up @@ -449,6 +459,23 @@ async function handleLink(
break
}

case 'marketing': {
// The user opened the app from a marketing push. Report it so the
// campaign's open rate can be tracked. The send UI lives in the internal
// tools project; the campaignId rides in the push notification payload.
dispatch(
logEvent('Push_Notification_Opened', {
campaignId: link.campaignId
})
)
// Optional navigation: delegate to the shared handler, mirroring the
// affiliate link. Unsupported targets fall through its existing guards.
if (link.link != null) {
await handleLink(navigation, dispatch, state, link.link)
}
break
}

case 'other': {
const matchingWalletIdsAndUris: Array<{
walletId: string
Expand Down
7 changes: 7 additions & 0 deletions src/types/DeepLinkTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export interface RampCreateLink {
paymentType?: FiatPaymentType
}

export interface MarketingLink {
type: 'marketing'
campaignId: string // Correlates notification opens to a marketing campaign
link?: DeepLink // Optional navigation target parsed from the payload URL
}

/**
* A provider return link (e.g. Simplex or Paybis sending the user back into the
* app once their session finishes). Handled by the ramp deeplink manager, not
Expand Down Expand Up @@ -210,6 +216,7 @@ export type DeepLink =
| EdgeLoginLink
| FiatPluginLink
| FiatProviderLink
| MarketingLink
| ModalLink
| NoopLink
| PasswordRecoveryLink
Expand Down
34 changes: 33 additions & 1 deletion src/util/PushMessageParser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
import { asMaybe, asObject, asString, asValue } from 'cleaners'
import { asMaybe, asObject, asOptional, asString, asValue } from 'cleaners'

import { showDevError } from '../components/services/AirshipInstance'
import type { DeepLink } from '../types/DeepLinkTypes'
import { parseDeepLink } from './DeepLinkParser'

/**
* Extracts a deep link from a push message, if present.
Expand All @@ -17,9 +19,39 @@ export function parsePushMessage(
body: asString(message.notification?.body)
}
}

const marketing = asMaybe(asMarketingPayloadData)(message.data)
if (marketing != null) {
let link: DeepLink | undefined
if (marketing.url != null) {
try {
const parsed = parseDeepLink(marketing.url)
// A campaign navigates wherever its URL points, the same as a link the
// user taps anywhere else. `parseDeepLink` is lenient and answers
// `other` for anything it does not recognize, so a malformed campaign
// URL tracks the open without navigating.
if (parsed.type !== 'other') link = parsed
} catch (error: unknown) {
// parseDeepLink can still throw on some malformed input; never let that
// drop the push, or we would also lose the open tracking.
showDevError(error)
}
}
return {
type: 'marketing',
campaignId: marketing.campaignId,
link
}
}
}

const asPriceChangePayloadData = asObject({
type: asValue('price-change'),
pluginId: asString
})

const asMarketingPayloadData = asObject({
type: asValue('marketing'),
campaignId: asString,
url: asOptional(asString)
})
2 changes: 2 additions & 0 deletions src/util/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type TrackingEventName =
| 'Fio_Handle_Bundled_Tx'
| 'Load_Install_Reason_Match'
| 'Load_Install_Reason_Fail'
| 'Push_Notification_Opened'
| 'Sell_Quote'
| 'Sell_Quote_Next'
| 'Sell_Success'
Expand Down Expand Up @@ -152,6 +153,7 @@ export interface TrackingValues extends LoginTrackingValues {
surveyCategory2?: string // User's answer to a survey (first tier response)
surveyResponse2?: string // User's answer to a survey
appleAdsKeywordId?: string // Apple Search Ads attribution keyword ID
campaignId?: string // Marketing push campaign identifier (notification opens)

// Conversion values
conversionValues?:
Expand Down
Loading