From ac0fa94748c3db695164eeeb1e09438dfecf6c55 Mon Sep 17 00:00:00 2001 From: peachbits Date: Wed, 1 Jul 2026 12:10:12 -0700 Subject: [PATCH 1/2] Track marketing notification opens Recognize a marketing push payload (type 'marketing' with a campaignId and optional deep-link url) in parsePushMessage. When the user opens the app from one, report a Marketing_Notification_Opened event carrying the campaignId to analytics, and navigate to the deep link when present. Unrecognized or malformed urls degrade to track-only. Includes a parser unit test. --- src/__tests__/PushMessageParser.test.ts | 87 +++++++++++++++++++++++++ src/actions/DeepLinkingActions.tsx | 17 +++++ src/types/DeepLinkTypes.ts | 7 ++ src/util/PushMessageParser.ts | 33 +++++++++- src/util/tracking.ts | 2 + 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/PushMessageParser.test.ts diff --git a/src/__tests__/PushMessageParser.test.ts b/src/__tests__/PushMessageParser.test.ts new file mode 100644 index 00000000000..6103f458da6 --- /dev/null +++ b/src/__tests__/PushMessageParser.test.ts @@ -0,0 +1,87 @@ +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 | 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('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' + }) + }) +}) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 1a2661da9ed..48c37c60583 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -284,6 +284,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('Marketing_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 diff --git a/src/types/DeepLinkTypes.ts b/src/types/DeepLinkTypes.ts index 0d778106d19..9e09cc93c23 100644 --- a/src/types/DeepLinkTypes.ts +++ b/src/types/DeepLinkTypes.ts @@ -101,6 +101,12 @@ export interface PriceChangeLink { body: string // Human-readable message } +export interface MarketingLink { + type: 'marketing' + campaignId: string // Correlates notification opens to a marketing campaign + link?: DeepLink // Optional navigation target parsed from the payload URL +} + export interface RampLink { type: 'ramp' direction: FiatDirection @@ -165,6 +171,7 @@ export type DeepLink = | EdgeLoginLink | FiatPluginLink | FiatProviderLink + | MarketingLink | ModalLink | NoopLink | PasswordRecoveryLink diff --git a/src/util/PushMessageParser.ts b/src/util/PushMessageParser.ts index 0fd97c797c6..7b277fab39b 100644 --- a/src/util/PushMessageParser.ts +++ b/src/util/PushMessageParser.ts @@ -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. @@ -17,9 +19,38 @@ 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) + // Only navigate for a recognized Edge deep link. `parseDeepLink` is + // lenient and returns an `other` link for anything it does not know, so + // an unrecognized or malformed URL degrades to track-only. + 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) +}) diff --git a/src/util/tracking.ts b/src/util/tracking.ts index 4b68ef364b2..f7fb1b4e7b7 100644 --- a/src/util/tracking.ts +++ b/src/util/tracking.ts @@ -44,6 +44,7 @@ export type TrackingEventName = | 'Fio_Handle_Bundled_Tx' | 'Load_Install_Reason_Match' | 'Load_Install_Reason_Fail' + | 'Marketing_Notification_Opened' | 'Sell_Quote' | 'Sell_Quote_Next' | 'Sell_Success' @@ -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?: From b4eafc8778cba69d1ddd515c56915da09e4f968f Mon Sep 17 00:00:00 2001 From: peachbits Date: Thu, 9 Jul 2026 10:10:15 -0700 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cebfd37327..89aa38a792e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (develop) +- 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. - added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. - changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings". - fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule.