import type { Spec } from './NativePluginEngagementReactNative'; import { NativeModules } from 'react-native'; import type { EventSubscription } from 'react-native'; import type { AmplitudeBootOptions, AmplitudeInitOptions, AmplitudeIntegration, GuideOrSurvey, ThemeMode, } from './types'; import type { BaseEvent } from '@amplitude/analytics-core'; import { SDK_VERSION } from './constants'; import { Logger, type AmplitudeLogger } from './logger'; type PluginEngagementReactNativeType = Exclude; const isTurboModuleEnabled = (global as any).__turboModuleProxy != null; const PluginEngagementReactNative: PluginEngagementReactNativeType = isTurboModuleEnabled ? require('./NativePluginEngagementReactNative').default : NativeModules.PluginEngagementReactNative; /** * Async factory for an {@link AmplitudeEngagement}. Awaits the native * `newInstance` handle rather than requiring it synchronously. The native * `newInstance` is now Promise-returning and runs off the JS thread (the heavy * `AESDK_Factory.make` work is scheduled off the app-launch critical path on * each platform, so awaiting this does not block the caller. */ export function mkAmplitudeEngagement( apiKey: string, options?: AmplitudeInitOptions ): Promise { return AmplitudeEngagement.createAsync(apiKey, options); } export class AmplitudeEngagement { /** * Handle to the native instance. Kept as a Promise so callers can invoke SDK * methods before initialization has finished; each method awaits it before * touching the native module. */ id: Promise; logger: AmplitudeLogger = new Logger('AmplitudeEngagement'); integrations: AmplitudeIntegration[] = []; integrationsSubscription: EventSubscription | null = null; routerSubscription: EventSubscription | null = null; private constructor(id: Promise) { this.id = id; } /** * Fire-and-forget construction: kicks off the native `newInstance` and wraps * the returned handle promise without awaiting it, so the caller (the * synchronous {@link init}) is not blocked. SDK calls issued before the id * resolves queue on {@link id}. Used by {@link init}. */ static create( apiKey: string, options?: AmplitudeInitOptions ): AmplitudeEngagement { const id = Promise.resolve( PluginEngagementReactNative.newInstance(apiKey, { ...options, platformVersion: SDK_VERSION, }) ); return new AmplitudeEngagement(id); } /** * Awaits the native handle before returning, so callers that `await` this are * guaranteed the native instance exists before they issue further calls. The * native `newInstance` runs off the JS thread, so this still does not block * the caller's thread. Used by {@link initAsync}. */ static async createAsync( apiKey: string, options?: AmplitudeInitOptions ): Promise { const id = await PluginEngagementReactNative.newInstance(apiKey, { ...options, platformVersion: SDK_VERSION, }); return new AmplitudeEngagement(Promise.resolve(id)); } boot(options: AmplitudeBootOptions): Promise; boot( user_id?: string, device_id?: string, user_properties?: Object ): Promise; async boot( optionsOrUserId?: AmplitudeBootOptions | string, device_id?: string, user_properties?: Object ): Promise { let user_id: string | undefined; if (typeof optionsOrUserId === 'object' && optionsOrUserId !== null) { const options = optionsOrUserId; user_id = options.user.user_id; device_id = options.user.device_id; user_properties = options.user.user_properties; this.integrations = options.integrations ?? []; } else { user_id = optionsOrUserId; this.integrations = []; } if (this.integrations.length > 0 && !this.integrationsSubscription) { // Events tracked by the native SDK are emitted through onTrackEvent; // fan them out to the user-provided integrations. this.integrationsSubscription = PluginEngagementReactNative.onTrackEvent( (event) => { for (const integration of this.integrations) { try { integration( event.event_type, event.event_properties as { [key: string]: any } ); } catch (error) { this.logger.error( 'AmplitudeEngagement#boot integration failed to handle event', error ); } } } ); } const id = await this.id; PluginEngagementReactNative.boot(id, user_id, device_id, user_properties); } async enable(): Promise { PluginEngagementReactNative.enable(await this.id); } async disable(): Promise { PluginEngagementReactNative.disable(await this.id); } async shutdown(): Promise { this.integrations = []; if (this.integrationsSubscription) { this.integrationsSubscription.remove(); this.integrationsSubscription = null; } if (this.routerSubscription) { this.routerSubscription.remove(); this.routerSubscription = null; } PluginEngagementReactNative.shutdown(await this.id); } async setThemeMode(themeMode: ThemeMode): Promise { return PluginEngagementReactNative.setThemeMode(await this.id, themeMode); } async reset(key: string, stepIndex: number): Promise { return PluginEngagementReactNative.reset(await this.id, key, stepIndex); } async list(): Promise { return PluginEngagementReactNative.list(await this.id); } async show(key: string, stepIndex: number): Promise { const id = await this.id; return PluginEngagementReactNative.show(id, key, stepIndex); } async screen(screenName: string): Promise { return PluginEngagementReactNative.screen(await this.id, screenName); } async closeAll(): Promise { return PluginEngagementReactNative.closeAll(await this.id); } async forwardEvent(event: BaseEvent): Promise { return PluginEngagementReactNative.forwardEvent(await this.id, event); } addCallback(key: string, func: () => void): () => void { // this.id may still be pending. Register the JS listener immediately and // defer the native registration until the id resolves, so callers get a // usable unsubscribe synchronously (preserving the original API) without // waiting for init to finish. let resolvedId: number | undefined; const registered = this.id.then((id) => { resolvedId = id; PluginEngagementReactNative.addCallback(id, key); }); const handler = PluginEngagementReactNative.onInvokeCallback( (invocation) => { if ( resolvedId !== undefined && invocation.id === resolvedId && invocation.key === key ) { func(); } } ); return () => { // Ensure native registration has been attempted before removing. registered.finally(() => handler.remove()); }; } /** * Sets a router function used to handle navigation requests from guides * (e.g. "Go to screen" CTA actions). */ async setRouter(router: (url: string) => void): Promise { if (this.routerSubscription) { this.routerSubscription.remove(); this.routerSubscription = null; } const id = await this.id; this.routerSubscription = PluginEngagementReactNative.onInvokeRouter( (invocation) => { if (invocation.id === id) { try { router(invocation.url); } catch (error) { this.logger.error( 'AmplitudeEngagement#setRouter failed to handle route', error ); } } } ); PluginEngagementReactNative.setRouter(id); } /** Removes the currently configured router, if any. */ async unsetRouter(): Promise { if (this.routerSubscription) { this.routerSubscription.remove(); this.routerSubscription = null; } PluginEngagementReactNative.unsetRouter(await this.id); } async handleURL(url: string): Promise { return PluginEngagementReactNative.handleURL(await this.id, url); } async updateLanguage(locale: string): Promise { return PluginEngagementReactNative.updateLanguage(await this.id, locale); } }