From 8b121af2e438ca77cc5f5b1715516107c18aff6f Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 11 Jul 2024 18:43:10 -0700 Subject: [PATCH] referrers for all platforms (#4514) --- app.config.js | 1 + .../referrer/ExpoBlueskyReferrerModule.kt | 46 +++++++++++++++++++ .../src/Referrer/index.android.ts | 8 +++- .../src/Referrer/index.ios.ts | 37 +++++++++++++++ .../src/Referrer/index.ts | 7 ++- .../src/Referrer/index.web.ts | 34 ++++++++++++++ .../src/Referrer/types.ts | 17 ++++--- plugins/withAppDelegateReferrer.js | 41 +++++++++++++++++ src/Navigation.tsx | 15 +++++- src/lib/hooks/useIntentHandler.ts | 15 +++++- src/lib/statsig/events.ts | 5 ++ src/lib/statsig/statsig.tsx | 21 --------- 12 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts create mode 100644 modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts create mode 100644 plugins/withAppDelegateReferrer.js diff --git a/app.config.js b/app.config.js index 1467f762..cd8a4b03 100644 --- a/app.config.js +++ b/app.config.js @@ -221,6 +221,7 @@ module.exports = function (config) { './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', './plugins/shareExtension/withShareExtensions.js', './plugins/notificationsExtension/withNotificationsExtension.js', + './plugins/withAppDelegateReferrer.js', ].filter(Boolean), extra: { eas: { diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt index ac6ed90b..bac55523 100644 --- a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/referrer/ExpoBlueskyReferrerModule.kt @@ -1,5 +1,8 @@ package expo.modules.blueskyswissarmy.referrer +import android.content.Intent +import android.net.Uri +import android.os.Build import android.util.Log import com.android.installreferrer.api.InstallReferrerClient import com.android.installreferrer.api.InstallReferrerStateListener @@ -8,10 +11,53 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class ExpoBlueskyReferrerModule : Module() { + private var intent: Intent? = null + private var activityReferrer: Uri? = null + override fun definition() = ModuleDefinition { Name("ExpoBlueskyReferrer") + OnNewIntent { + intent = it + activityReferrer = appContext.currentActivity?.referrer + } + + AsyncFunction("getReferrerInfoAsync") { + val intentReferrer = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(Intent.EXTRA_REFERRER, Uri::class.java) + } else { + intent?.getParcelableExtra(Intent.EXTRA_REFERRER) + } + + // Some apps explicitly set a referrer, like Chrome. In these cases, we prefer this since + // it's the actual website that the user came from rather than the app. + if (intentReferrer is Uri) { + val res = + mapOf( + "referrer" to intentReferrer.toString(), + "hostname" to intentReferrer.host, + ) + intent = null + return@AsyncFunction res + } + + // In all other cases, we'll just record the app that sent the intent. + if (activityReferrer != null) { + // referrer could become null here. `.toString()` though can be called on null + val res = + mapOf( + "referrer" to activityReferrer.toString(), + "hostname" to (activityReferrer?.host ?: ""), + ) + activityReferrer = null + return@AsyncFunction res + } + + return@AsyncFunction null + } + AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise -> val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build() referrerClient.startConnection( diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts index 06dfd2d0..ec2bcb57 100644 --- a/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts @@ -1,9 +1,13 @@ import {requireNativeModule} from 'expo' -import {GooglePlayReferrerInfo} from './types' +import {GooglePlayReferrerInfo, ReferrerInfo} from './types' export const NativeModule = requireNativeModule('ExpoBlueskyReferrer') -export function getGooglePlayReferrerInfoAsync(): Promise { +export function getGooglePlayReferrerInfoAsync(): Promise { return NativeModule.getGooglePlayReferrerInfoAsync() } + +export function getReferrerInfoAsync(): Promise { + return NativeModule.getReferrerInfoAsync() +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts new file mode 100644 index 00000000..2bf1497a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts @@ -0,0 +1,37 @@ +import {SharedPrefs} from '../../index' +import {NotImplementedError} from '../NotImplemented' +import {GooglePlayReferrerInfo, ReferrerInfo} from './types' + +export function getGooglePlayReferrerInfoAsync(): Promise { + throw new NotImplementedError() +} + +export function getReferrerInfoAsync(): Promise { + const referrer = SharedPrefs.getString('referrer') + if (referrer) { + SharedPrefs.removeValue('referrer') + try { + const url = new URL(referrer) + return { + referrer, + hostname: url.hostname, + } + } catch (e) { + return { + referrer, + hostname: undefined, + } + } + } + + const referrerApp = SharedPrefs.getString('referrerApp') + if (referrerApp) { + SharedPrefs.removeValue('referrerApp') + return { + referrer: referrerApp, + hostname: referrerApp, + } + } + + return null +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts index 25539855..a60f7b6d 100644 --- a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts @@ -1,7 +1,10 @@ import {NotImplementedError} from '../NotImplemented' -import {GooglePlayReferrerInfo} from './types' +import {GooglePlayReferrerInfo, ReferrerInfo} from './types' -// @ts-ignore throws export function getGooglePlayReferrerInfoAsync(): Promise { throw new NotImplementedError() } + +export function getReferrerInfoAsync(): Promise { + throw new NotImplementedError() +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts new file mode 100644 index 00000000..76f03e7c --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts @@ -0,0 +1,34 @@ +import {Platform} from 'react-native' + +import {NotImplementedError} from '../NotImplemented' +import {GooglePlayReferrerInfo, ReferrerInfo} from './types' + +export function getGooglePlayReferrerInfoAsync(): Promise { + throw new NotImplementedError() +} + +export function getReferrerInfoAsync(): Promise { + if ( + Platform.OS === 'web' && + // for ssr + typeof document !== 'undefined' && + document != null && + document.referrer + ) { + try { + const url = new URL(document.referrer) + if (url.hostname !== 'bsky.app') { + return { + referrer: url.href, + hostname: url.hostname, + } + } + } catch { + // If something happens to the URL parsing, we don't want to actually cause any problems for the user. Just + // log the error so we might catch it + console.error('Failed to parse referrer URL') + } + } + + return null +} diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/types.ts b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts index 55faaff4..921e3a69 100644 --- a/modules/expo-bluesky-swiss-army/src/Referrer/types.ts +++ b/modules/expo-bluesky-swiss-army/src/Referrer/types.ts @@ -1,7 +1,10 @@ -export type GooglePlayReferrerInfo = - | { - installReferrer?: string - clickTimestamp?: number - installTimestamp?: number - } - | undefined +export type GooglePlayReferrerInfo = { + installReferrer?: string + clickTimestamp?: number + installTimestamp?: number +} + +export type ReferrerInfo = { + referrer: string + hostname: string +} diff --git a/plugins/withAppDelegateReferrer.js b/plugins/withAppDelegateReferrer.js new file mode 100644 index 00000000..de773df0 --- /dev/null +++ b/plugins/withAppDelegateReferrer.js @@ -0,0 +1,41 @@ +const {withAppDelegate} = require('@expo/config-plugins') +const {mergeContents} = require('@expo/config-plugins/build/utils/generateCode') +const path = require('path') +const fs = require('fs') + +module.exports = config => { + // eslint-disable-next-line no-shadow + return withAppDelegate(config, async config => { + const delegatePath = path.join( + config.modRequest.platformProjectRoot, + 'AppDelegate.mm', + ) + + let newContents = config.modResults.contents + newContents = mergeContents({ + src: newContents, + anchor: '// Linking API', + newSrc: ` + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults setObject:options[UIApplicationOpenURLOptionsSourceApplicationKey] forKey:@"referrerApp"];\n`, + offset: 2, + tag: 'referrer info - deep links', + comment: '//', + }).contents + + newContents = mergeContents({ + src: newContents, + anchor: '// Universal Links', + newSrc: ` + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults setURL:userActivity.referrerURL forKey:@"referrer"];\n`, + offset: 2, + tag: 'referrer info - universal links', + comment: '//', + }).contents + + config.modResults.contents = newContents + + return config + }) +} diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 49543512..8c815a3f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -31,7 +31,7 @@ import { } from 'lib/routes/types' import {RouteParams, State} from 'lib/routes/types' import {bskyTitle} from 'lib/strings/headings' -import {isAndroid, isNative} from 'platform/detection' +import {isAndroid, isNative, isWeb} from 'platform/detection' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {AppPasswords} from 'view/screens/AppPasswords' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' @@ -49,6 +49,7 @@ import { StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' +import {Referrer} from '../modules/expo-bluesky-swiss-army' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' @@ -769,6 +770,18 @@ function logModuleInitTime() { initMs, }) + if (isWeb) { + Referrer.getReferrerInfoAsync().then(info => { + if (info && info.hostname !== 'bsky.app') { + logEvent('deepLink:referrerReceived', { + to: window.location.href, + referrer: info?.referrer, + hostname: info?.hostname, + }) + } + }) + } + if (__DEV__) { // This log is noisy, so keep false committed const shouldLog = false diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 8741530b..3235e1a6 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -1,9 +1,12 @@ import React from 'react' import * as Linking from 'expo-linking' + +import {logEvent} from 'lib/statsig/statsig' import {isNative} from 'platform/detection' -import {useComposerControls} from 'state/shell' import {useSession} from 'state/session' +import {useComposerControls} from 'state/shell' import {useCloseAllActiveElements} from 'state/util' +import {Referrer} from '../../../modules/expo-bluesky-swiss-army' type IntentType = 'compose' @@ -15,6 +18,16 @@ export function useIntentHandler() { React.useEffect(() => { const handleIncomingURL = (url: string) => { + Referrer.getReferrerInfoAsync().then(info => { + if (info && info.hostname !== 'bsky.app') { + logEvent('deepLink:referrerReceived', { + to: url, + referrer: info?.referrer, + hostname: info?.hostname, + }) + } + }) + // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 4946fb7f..159061ea 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -25,6 +25,11 @@ export type LogEvents = { } 'state:foreground:sampled': {} 'router:navigate:sampled': {} + 'deepLink:referrerReceived': { + to: string + referrer: string + hostname: string + } // Screen events 'splash:signInPressed': {} diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 94a1e63d..81707d2b 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -28,8 +28,6 @@ type StatsigUser = { bundleDate: number refSrc: string refUrl: string - referrer: string - referrerHostname: string appLanguage: string contentLanguages: string[] } @@ -37,29 +35,12 @@ type StatsigUser = { let refSrc = '' let refUrl = '' -let referrer = '' -let referrerHostname = '' if (isWeb && typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search) refSrc = params.get('ref_src') ?? '' refUrl = decodeURIComponent(params.get('ref_url') ?? '') } -if ( - isWeb && - typeof document !== 'undefined' && - document != null && - document.referrer -) { - try { - const url = new URL(document.referrer) - if (url.hostname !== 'bsky.app') { - referrer = document.referrer - referrerHostname = url.hostname - } - } catch {} -} - export type {LogEvents} function createStatsigOptions(prefetchUsers: StatsigUser[]) { @@ -222,8 +203,6 @@ function toStatsigUser(did: string | undefined): StatsigUser { custom: { refSrc, refUrl, - referrer, - referrerHostname, platform: Platform.OS as 'ios' | 'android' | 'web', bundleIdentifier: BUNDLE_IDENTIFIER, bundleDate: BUNDLE_DATE,