UNPKG

react-native-adapty

Version:
454 lines (392 loc) 13.7 kB
import { ViewEmitter } from './view-emitter'; import { Platform } from 'react-native'; import { AdaptyCustomAsset, AdaptyUiDialogActionType, AdaptyUiDialogConfig, AdaptyUiView, CreatePaywallViewParamsInput, DEFAULT_EVENT_HANDLERS, EventHandlers, } from './types'; import { AdaptyPaywall } from '@/types'; import { LogContext, LogScope } from '@/logger'; import { AdaptyPaywallCoder } from '@/coders/adapty-paywall'; import { MethodName } from '@/types/bridge'; import { $bridge } from '@/bridge'; import { AdaptyError } from '@/adapty-error'; import { AdaptyType } from '@/coders/parse'; import { Def, Req } from '@/types/schema'; import { AdaptyUiDialogConfigCoder } from '@/coders/adapty-ui-dialog-config'; /** * Provides methods to control created paywall view * @public */ export class ViewController { /** * Intended way to create a ViewController instance. * It prepares a native controller to be presented * and creates reference between native controller and JS instance * @internal */ static async create( paywall: AdaptyPaywall, params: CreatePaywallViewParamsInput, ): Promise<ViewController> { const ctx = new LogContext(); const log = ctx.call({ methodName: 'createPaywallView' }); log.start({ paywall, params }); const view = new ViewController(); const coder = new AdaptyPaywallCoder(); const methodKey = 'adapty_ui_create_paywall_view'; const data: Req['AdaptyUICreatePaywallView.Request'] = { method: methodKey, paywall: coder.encode(paywall), preload_products: params.prefetchProducts ?? true, load_timeout: (params.loadTimeoutMs ?? 5000) / 1000, }; if (params.customTags) { data['custom_tags'] = params.customTags; } if (params.customTimers) { const convertTimerInfo = ( timerInfo: Record<string, Date>, ): Record<string, string> => { const formatDate = (date: Date): string => { const pad = (num: number, digits: number = 2): string => { const str = num.toString(); const paddingLength = digits - str.length; return paddingLength > 0 ? '0'.repeat(paddingLength) + str : str; }; const year = date.getUTCFullYear(); const month = pad(date.getUTCMonth() + 1); const day = pad(date.getUTCDate()); const hours = pad(date.getUTCHours()); const minutes = pad(date.getUTCMinutes()); const seconds = pad(date.getUTCSeconds()); const millis = pad(date.getUTCMilliseconds(), 3); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}Z`; }; const result: Record<string, string> = {}; for (const key in timerInfo) { if (timerInfo.hasOwnProperty(key)) { const date = timerInfo[key]; if (date instanceof Date) { result[key] = formatDate(date); } } } return result; }; data['custom_timers'] = convertTimerInfo(params.customTimers); } if (params.customAssets) { const argbToHex = (value: number): string => { const hex = value.toString(16).padStart(8, '0'); return `#${hex.slice(2)}${hex.slice(0, 2)}`; }; const rgbaToHex = (value: number): string => { return `#${value.toString(16).padStart(8, '0')}`; }; const rgbToHex = (value: number): string => { return `#${value.toString(16).padStart(6, '0')}FF`; }; const extractBase64Data = (input: string): string => { const commaIndex = input.indexOf(','); if (input.startsWith('data:') && commaIndex !== -1) { return input.slice(commaIndex + 1); } return input; }; const getAssetId = (asset: any): string => { if ('relativeAssetPath' in asset) { return Platform.select({ ios: asset.relativeAssetPath, android: `${asset.relativeAssetPath}a`, }); } if ('fileLocation' in asset) { const fileLocation = asset.fileLocation; return Platform.select({ ios: fileLocation.ios.fileName, android: 'relativeAssetPath' in fileLocation.android ? `${fileLocation.android.relativeAssetPath}a` : `${fileLocation.android.rawResName}r`, }); } return ''; }; const convertAssets = ( assets: Record<string, AdaptyCustomAsset>, ): Def['AdaptyUI.CustomAssets'] => { return Object.entries(assets) .map( ([id, asset]): Def['AdaptyUI.CustomAssets'][number] | undefined => { switch (asset.type) { case 'image': return 'base64' in asset ? { id, type: 'image', value: extractBase64Data(asset.base64), } : { id, type: 'image', asset_id: getAssetId(asset), }; case 'video': return { id, type: 'video', asset_id: getAssetId(asset), }; case 'color': let value: string; if ('argb' in asset) { value = argbToHex(asset.argb); } else if ('rgba' in asset) { value = rgbaToHex(asset.rgba); } else if ('rgb' in asset) { value = rgbToHex(asset.rgb); } else { return undefined; } return { id, type: 'color', value, }; case 'linear-gradient': const { values, points = {} } = asset; const { x0 = 0, y0 = 0, x1 = 1, y1 = 0 } = points; const colorStops = values .map(({ p, ...colorInput }) => { let color: string; if ('argb' in colorInput) { color = argbToHex(colorInput.argb); } else if ('rgba' in colorInput) { color = rgbaToHex(colorInput.rgba); } else if ('rgb' in colorInput) { color = rgbToHex(colorInput.rgb); } else { return undefined; } return { color, p }; }) .filter( (v): v is { color: string; p: number } => v !== undefined, ); if (colorStops.length !== values.length) return undefined; return { id, type: 'linear-gradient', values: colorStops, points: { x0, y0, x1, y1 }, }; default: return undefined; } }, ) .filter( (item): item is Def['AdaptyUI.CustomAssets'][number] => item !== undefined, ); }; data['custom_assets'] = convertAssets(params.customAssets); } const body = JSON.stringify(data); const result = await view.handle<AdaptyUiView>( methodKey, body, 'AdaptyUiView', ctx, log, ); view.id = result.id; return view; } private id: string | null; // reference to a native view. UUID private unsubscribeAllListeners: null | (() => void) = null; /** * Since constructors in JS cannot be async, it is not * preferred to create ViewControllers in direct way. * Consider using @link{ViewController.create} instead * * @remarks * Creating ViewController this way does not let you * to make native create request and set _id. * It is intended to avoid usage * * @internal */ private constructor() { this.id = null; } private async handle<T>( method: MethodName, params: string, resultType: AdaptyType, ctx: LogContext, log: LogScope, ): Promise<T> { try { const result = await $bridge.request(method, params, resultType, ctx); log.success(result); return result as T; } catch (error) { /* * Success because error was handled validly * It is a developer task to define which errors must be logged */ log.success({ error }); throw error; } } /** * Presents a paywall view as a full-screen modal * * @remarks * Calling `present` upon already visible paywall view * would result in an error * * @throws {AdaptyError} */ public async present(): Promise<void> { const ctx = new LogContext(); const log = ctx.call({ methodName: 'present' }); log.start({ _id: this.id }); if (this.id === null) { log.failed({ error: 'no _id' }); throw this.errNoViewReference(); } const methodKey = 'adapty_ui_present_paywall_view'; const body = JSON.stringify({ method: methodKey, id: this.id, } satisfies Req['AdaptyUIPresentPaywallView.Request']); const result = await this.handle<void>(methodKey, body, 'Void', ctx, log); return result; } /** * Dismisses a paywall view * * @throws {AdaptyError} */ public async dismiss(): Promise<void> { const ctx = new LogContext(); const log = ctx.call({ methodName: 'dismiss' }); log.start({ _id: this.id }); if (this.id === null) { log.failed({ error: 'no id' }); throw this.errNoViewReference(); } const methodKey = 'adapty_ui_dismiss_paywall_view'; const body = JSON.stringify({ method: methodKey, id: this.id, destroy: false, } satisfies Req['AdaptyUIDismissPaywallView.Request']); await this.handle<void>(methodKey, body, 'Void', ctx, log); if (this.unsubscribeAllListeners) { this.unsubscribeAllListeners(); } } /** * Presents the dialog * * @param {AdaptyUiDialogConfig} config - A config for showing the dialog. * * @remarks * If you provide two actions in the config, be sure `primaryAction` cancels the operation * and leaves things unchanged. * * @returns {Promise<AdaptyUiDialogActionType>} A Promise that resolves to the {@link AdaptyUiDialogActionType} object * * @throws {AdaptyError} */ public async showDialog( config: AdaptyUiDialogConfig, ): Promise<AdaptyUiDialogActionType> { const ctx = new LogContext(); const log = ctx.call({ methodName: 'showDialog' }); log.start({ _id: this.id }); if (this.id === null) { log.failed({ error: 'no id' }); throw this.errNoViewReference(); } const coder = new AdaptyUiDialogConfigCoder(); const methodKey = 'adapty_ui_show_dialog'; const body = JSON.stringify({ method: methodKey, id: this.id, configuration: coder.encode(config), } satisfies Req['AdaptyUIShowDialog.Request']); return await this.handle<AdaptyUiDialogActionType>( methodKey, body, 'Void', ctx, log, ); } /** * Creates a set of specific view event listeners * * @see {@link https://docs.adapty.io/docs/react-native-handling-events | [DOC] Handling View Events} * * @remarks * It registers only requested set of event handlers. * Your config is assigned into four event listeners {@link DEFAULT_EVENT_HANDLERS}, * that handle default closing behavior. * - `onCloseButtonPress` * - `onAndroidSystemBack` * - `onRestoreCompleted` * - `onPurchaseCompleted` * * If you want to override these listeners, we strongly recommend to return `true` (or `purchaseResult.type !== 'user_cancelled'` in case of `onPurchaseCompleted`) * from your custom listener to retain default closing behavior. * * @param {Partial<EventHandlers> | undefined} [eventHandlers] - set of event handling callbacks * @returns {() => void} unsubscribe - function to unsubscribe all listeners */ public registerEventHandlers( eventHandlers: Partial<EventHandlers> = DEFAULT_EVENT_HANDLERS, ): () => void { const ctx = new LogContext(); const log = ctx.call({ methodName: 'registerEventHandlers' }); log.start({ _id: this.id }); if (this.id === null) { throw this.errNoViewReference(); } const finalEventHandlers: Partial<EventHandlers> = { ...DEFAULT_EVENT_HANDLERS, ...eventHandlers, }; // DIY way to tell TS that original arg should not be used const deprecateVar = (_target: unknown): _target is never => true; if (!deprecateVar(eventHandlers)) { return () => {}; } const viewEmitter = new ViewEmitter(this.id); Object.keys(finalEventHandlers).forEach(eventStr => { const event = eventStr as keyof EventHandlers; if (!finalEventHandlers.hasOwnProperty(event)) { return; } const handler = finalEventHandlers[ event ] as EventHandlers[keyof EventHandlers]; viewEmitter.addListener(event, handler, () => this.dismiss()); }); const unsubscribe = () => viewEmitter.removeAllListeners(); // expose to class to be able to unsubscribe on dismiss this.unsubscribeAllListeners = unsubscribe; return unsubscribe; } private errNoViewReference(): AdaptyError { throw new Error('View reference not found'); } }