diff --git a/DEVELOPING_LOCALLY.md b/DEVELOPING_LOCALLY.md new file mode 100644 index 00000000..1cbbb17a --- /dev/null +++ b/DEVELOPING_LOCALLY.md @@ -0,0 +1,131 @@ +# Developing locally — Visual Notification Inbox UI (SPIKE) + +This document covers the local-native-reference setup used by the +`spike/visual-inbox-wrappers` branch, which exposes three native Visual Notification Inbox +UI components to JS: + +| JS component | iOS native (SwiftUI) | Android native (Compose) | +| --- | --- | --- | +| `NotificationInboxOverlayView` | `NotificationInboxOverlay()` — iOS 16+ | `NotificationInboxOverlay(modifier)` | +| `NotificationInboxBellView` | `NotificationInboxBell(onTap:)` | `NotificationInboxBell(onClick, modifier)` | +| `NotificationInboxView` | `NotificationInboxView()` | `NotificationInboxView(modifier)` | + +These mirror the existing inline-in-app-message embedding (`InlineInAppMessageView`). The +headless inbox **data** API (`getMessages`/`subscribe`/`mark`/`track`) and the global action +`InboxEventListener` are already bridged and are NOT touched here — this is the UI layer only. + +## Android (primary build-validation target) + +The Visual Notification Inbox UI lives in a native module `:messaginginbox` +(artifact `io.customer.android:messaging-inbox`, package `io.customer.messaginginbox`). +It is not on Maven Central yet, so we serve it from `mavenLocal()` at version `local`. + +### 1. Publish the native Android SDK to Maven Local + +From the native Android source worktree (read-only reference repo): + +```bash +cd /path/to/wt-759-sample # branch: inbox-sample-listener +IS_DEVELOPMENT=true ./gradlew publishToMavenLocal +``` + +This publishes **all** CIO modules at version `local`, including `messaging-inbox`. Its Jist +dependency (`io.customer.android:jist`) is resolved from Maven Central (alpha). + +### 2. Point this repo's Android native dep at `local` + +Already wired on this branch: + +- `android/cio-core.gradle` adds: + `api "io.customer.android:messaging-inbox:$cioAndroidSDKVersion"` +- `android/gradle.properties` sets `customerio.reactnative.cioSDKVersionAndroid=local` + (revert to a released version, e.g. `4.17.0`, to go back to normal builds) +- `android/build.gradle` enables Jetpack Compose (the `org.jetbrains.kotlin.plugin.compose` + plugin + `buildFeatures { compose true }` + Compose BOM deps) because the view managers host + the native `@Composable` inbox components inside a `ComposeView`. +- `example/android/build.gradle` already has `mavenLocal()` in `allprojects.repositories`. +- `example/android/app/build.gradle` enables **core library desugaring** + (`coreLibraryDesugaringEnabled true` + `com.android.tools:desugar_jdk_libs`), required by + `messaging-inbox` and its Jist dependency. + +### 3. Build / validate + +The React Native **library module** is the primary validation target and compiles green: + +```bash +cd example/android +./gradlew :customerio-reactnative:compileDebugKotlin +``` + +This runs Codegen (generating the `NotificationInbox*NativeManagerInterface`/`Delegate` +classes from the specs in `src/specs/components/`) and compiles the new Kotlin view managers ++ Compose-hosted views. + +#### Full app build — known environment friction + +`./gradlew :app:assembleDebug` can fail in this environment for reasons **unrelated to the +wrapper code**: + +1. **Socket Firewall corrupts `autolinking.json`.** The `sfw` PATH wrapper intercepts the + `npx @react-native-community/cli config` call that the RN Gradle plugin uses to generate + `example/android/build/generated/autolinking/autolinking.json`, prepending a + `Protected by Socket Firewall` banner and appending a `=== Socket Firewall ===` footer, + which makes the JSON unparseable. Workaround: regenerate, then strip the non-JSON noise: + + ```bash + cd example/android + ./gradlew :app:generateAutolinkingNewArchitectureFiles --rerun-tasks + python3 - <<'PY' + import json + p='build/generated/autolinking/autolinking.json' + raw=open(p).read() + obj,_=json.JSONDecoder().raw_decode(raw, raw.index('{')) + open(p,'w').write(json.dumps(obj, indent=2)) + PY + # then build WITHOUT regenerating (so the firewall can't re-corrupt it): + ./gradlew :app:assembleDebug -x generateAutolinkingNewArchitectureFiles + ``` + +2. **Stale C++ codegen cache** for unrelated autolinked libs (e.g. `react_codegen_rnscreens`) + can surface as `ninja: error: unknown target ...` when the autolinking regen is skipped. + A clean `.cxx` / `react-native clean` resolves it. None of this involves the inbox wrappers. + +## iOS — KNOWN BLOCKER (Jist not on CocoaPods trunk) + +iOS uses CocoaPods. The bridge source under `ios/wrappers/inbox/` is written and source-correct +(mirrors `ios/wrappers/inapp/inline/`), and the podspec declares the dependency: + +```ruby +s.dependency "CustomerIO/MessagingInbox", package["cioNativeiOSSdkVersion"] +``` + +**Blocker:** `CustomerIO/MessagingInbox` transitively depends on **Jist**, which is not +published to the CocoaPods trunk yet. A full `pod install` of the inbox subspec will therefore +fail to resolve Jist. This is expected and documented rather than forced green here. Once Jist +ships a podspec (or is vendored locally), the iOS bridge compiles via `UIHostingController` +hosting the SwiftUI components. + +iOS native SwiftUI source reference: `wt-inbox-ios-fix` (branch `inbox-animation-and-data-fix`), +SPM product `MessagingInbox` (target `CioMessagingInbox`). + +## Cross-platform parity note + +- `NotificationInboxBellView` `onTap` maps to native `onTap` (iOS) / `onClick` (Android). +- `NotificationInboxOverlayView` exposes **no** panel-presentation prop. Both native overlays own + panel presentation internally (iOS presents a sheet with system detents), so there is no + host-facing open/close callback on either platform — the two are at parity here. +- `NotificationInboxOverlayView` requires **iOS 16+** and renders nothing on iOS 15, because the + native overlay's sheet uses system detents. The bell and list components have no such floor, so + compose those two if iOS 15 support is needed for a drop-in experience. +- `NotificationInboxView` has no events; message actions flow through the existing global + `InboxEventListener`. + +## To finish (beyond this spike) + +- Validate `pod install` + an iOS build. Jist is no longer the blocker (it published to the + CocoaPods trunk as `Jist 0.1.0`); what remains is that `CustomerIO/MessagingInbox` itself is + unreleased, so the podspec dependency is commented out and the sample Podfile pins the native + branch directly. +- Decide intrinsic-sizing behavior for the bell/list (the inline view animates size via + `onSizeChange`; the inbox components currently fill their RN-assigned bounds). +- Add unit/UI tests and Expo plugin coverage if the components graduate from spike status. diff --git a/__tests__/inbox-event-listener.test.ts b/__tests__/inbox-event-listener.test.ts new file mode 100644 index 00000000..990683f5 --- /dev/null +++ b/__tests__/inbox-event-listener.test.ts @@ -0,0 +1,145 @@ +/** + * Unit tests for the React Native Visual Notification Inbox event listener bridge. + * + * Mirrors the intent of the Flutter SDK's `inbox_event_listener_test.dart`: + * - registering wires the native forwarder + subscribes to the event emitter + * - native inbox events are mapped to `InboxMessageEvent` and delivered to the JS listener + * - `messageActionTaken` carries actionName/actionValue and the message is parsed + * - unregistering (subscription.remove()) tears down the native forwarder and stops delivery + * + * Note: `jest.mock` factories are hoisted above module-scope declarations, so every + * mock (including the fake native module) is created inside its factory and read back + * from the imported (mocked) module rather than closing over outer variables. + */ + +// Minimal react-native mock so importing the SDK modules does not touch the +// native runtime. Only the surface used at import time is provided. +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: (spec: { [key: string]: unknown }) => + spec.ios ?? spec.default ?? undefined, + }, +})); + +// The native Fabric components pull in codegen internals we don't need here. +jest.mock('../src/components', () => ({})); + +// Silence the native logger listener used inside the try/catch fallbacks so a +// real failure surfaces as a failing assertion rather than a swallowed warning. +jest.mock('../src/native-logger-listener', () => ({ + NativeLoggerListener: { warn: jest.fn(), initialize: jest.fn() }, +})); + +// Fake native TurboModule. Everything is created inside the factory (jest hoists +// this above the imports/consts below). The emitter passed to +// `onInboxEventReceived` is stored on the module so the test can simulate a +// native -> JS event by invoking `__emit`. +jest.mock('../src/specs/modules/NativeCustomerIOMessagingInApp', () => { + const nativeModule = { + registerInboxEventListener: jest.fn(), + unregisterInboxEventListener: jest.fn(), + onInboxEventReceived: jest.fn((emitter) => { + nativeModule.__emit = emitter; + return { remove: nativeModule.__nativeRemove }; + }), + __emit: undefined, + __nativeRemove: jest.fn(), + }; + return { __esModule: true, default: nativeModule }; +}); + +import { CustomerIOInAppMessaging } from '../src/customerio-inapp'; +import NativeMock from '../src/specs/modules/NativeCustomerIOMessagingInApp'; +import { InboxEventType, InboxMessageEvent } from '../src/types'; + +// Typed handle to the mock's extra test hooks. +const native = NativeMock as unknown as { + registerInboxEventListener: jest.Mock; + unregisterInboxEventListener: jest.Mock; + onInboxEventReceived: jest.Mock; + __nativeRemove: jest.Mock; + __emit?: (data: unknown) => void; +}; + +// Serialized inbox message matching the native `toWritableMap`/`toDictionary` shape. +const rawMessage = { + queueId: 'queue-123', + deliveryId: 'delivery-456', + expiry: 1710000000000, + sentAt: 1700000000000, + topics: ['promo', 'news'], + type: 'inbox', + opened: false, + priority: 1, + properties: { foo: 'bar' }, +}; + +describe('CustomerIOInAppMessaging inbox event listener bridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + native.__emit = undefined; + }); + + it('registers the native forwarder and subscribes to the emitter', () => { + const inApp = new CustomerIOInAppMessaging(); + inApp.registerInboxEventListener(() => {}); + + expect(native.registerInboxEventListener).toHaveBeenCalledTimes(1); + expect(native.onInboxEventReceived).toHaveBeenCalledTimes(1); + expect(native.__emit).toBeDefined(); + }); + + it('maps messageActionTaken to a parsed InboxMessageEvent with action data', () => { + const inApp = new CustomerIOInAppMessaging(); + const received: InboxMessageEvent[] = []; + inApp.registerInboxEventListener((event) => received.push(event)); + + native.__emit?.({ + eventType: 'messageActionTaken', + message: rawMessage, + actionName: 'messageAction', + actionValue: 'https://example.com', + }); + + expect(received).toHaveLength(1); + const event = received[0]!; + expect(event).toBeInstanceOf(InboxMessageEvent); + expect(event.eventType).toBe(InboxEventType.messageActionTaken); + expect(event.actionName).toBe('messageAction'); + expect(event.actionValue).toBe('https://example.com'); + expect(event.message.queueId).toBe('queue-123'); + expect(event.message.deliveryId).toBe('delivery-456'); + expect(event.message.topics).toEqual(['promo', 'news']); + expect(event.message.properties).toEqual({ foo: 'bar' }); + }); + + it('maps observational callbacks (shown/opened/dismissed) to the listener', () => { + const inApp = new CustomerIOInAppMessaging(); + const received: InboxEventType[] = []; + inApp.registerInboxEventListener((event) => received.push(event.eventType)); + + native.__emit?.({ eventType: 'messageShown', message: rawMessage }); + native.__emit?.({ eventType: 'messageOpened', message: rawMessage }); + native.__emit?.({ eventType: 'messageDismissed', message: rawMessage }); + + expect(received).toEqual([ + InboxEventType.messageShown, + InboxEventType.messageOpened, + InboxEventType.messageDismissed, + ]); + }); + + it('unregisters the native forwarder and stops delivery on remove()', () => { + const inApp = new CustomerIOInAppMessaging(); + const received: InboxMessageEvent[] = []; + const subscription = inApp.registerInboxEventListener((event) => + received.push(event) + ); + + subscription.remove(); + + expect(native.__nativeRemove).toHaveBeenCalledTimes(1); + expect(native.unregisterInboxEventListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/android/build.gradle b/android/build.gradle index 53ef27dc..98fead50 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -10,11 +10,14 @@ buildscript { dependencies { // noinspection DifferentKotlinGradleVersion classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + // Compose compiler plugin (Kotlin 2.x) for hosting the Visual Notification Inbox composables. + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version" } } apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: "com.facebook.react" def getExtOrDefault(name) { @@ -43,6 +46,8 @@ android { buildFeatures { buildConfig true + // Required to host the Visual Notification Inbox Compose UI components. + compose true } buildTypes { @@ -156,4 +161,12 @@ dependencies { // noinspection GradleDynamicVersion api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // Jetpack Compose — needed because the Visual Notification Inbox view managers host + // the native `@Composable` inbox components inside a ComposeView. + def composeBom = platform('androidx.compose:compose-bom:2025.10.00') + implementation composeBom + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.compose.runtime:runtime' } diff --git a/android/cio-core.gradle b/android/cio-core.gradle index 9facabbc..2e15c7e9 100644 --- a/android/cio-core.gradle +++ b/android/cio-core.gradle @@ -2,6 +2,8 @@ dependencies { api "io.customer.android:datapipelines:$cioAndroidSDKVersion" api "io.customer.android:messaging-push-fcm:$cioAndroidSDKVersion" api "io.customer.android:messaging-in-app:$cioAndroidSDKVersion" + // Visual Notification Inbox UI (Compose-based). + api "io.customer.android:messaging-inbox:$cioAndroidSDKVersion" // Location module is optional - customers enable it by adding // customerio_location_enabled=true in their gradle.properties. // Geofence implies Location: enabling geofence pulls in location too. diff --git a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt index 8d8c3280..93ada409 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/CustomerIOReactNativePackage.kt @@ -12,6 +12,8 @@ import io.customer.reactnative.sdk.location.NativeLocationModule import io.customer.reactnative.sdk.logging.NativeCustomerIOLoggingModule import io.customer.reactnative.sdk.messaginginapp.InlineInAppMessageViewManager import io.customer.reactnative.sdk.messaginginapp.NativeMessagingInAppModule +import io.customer.reactnative.sdk.messaginginbox.NotificationInboxBellViewManager +import io.customer.reactnative.sdk.messaginginbox.NotificationInboxViewManager import io.customer.reactnative.sdk.messagingpush.NativeMessagingPushModule import io.customer.reactnative.sdk.util.assertNotNull @@ -24,7 +26,11 @@ class CustomerIOReactNativePackage : BaseReactPackage() { * Creates the list of view managers for the Customer.io React Native SDK. */ override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf(InlineInAppMessageViewManager()) + return listOf( + InlineInAppMessageViewManager(), + NotificationInboxBellViewManager(), + NotificationInboxViewManager() + ) } override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { @@ -33,6 +39,8 @@ class CustomerIOReactNativePackage : BaseReactPackage() { // See: https://reactnative.dev/docs/fabric-native-components-introduction?platforms=android#4-write-the-reactwebviewpackage return when (name) { InlineInAppMessageViewManager.NAME -> InlineInAppMessageViewManager() + NotificationInboxBellViewManager.NAME -> NotificationInboxBellViewManager() + NotificationInboxViewManager.NAME -> NotificationInboxViewManager() NativeCustomerIOLoggingModule.NAME -> NativeCustomerIOLoggingModule(reactContext) NativeCustomerIOModule.NAME -> NativeCustomerIOModule(reactContext = reactContext) // Unconditional on purpose, unlike Location below. Location has its own artifact @@ -81,6 +89,8 @@ class CustomerIOReactNativePackage : BaseReactPackage() { // doesn't crash at import time. getModule() returns null when disabled. val moduleNames: List = listOf( InlineInAppMessageViewManager.NAME, + NotificationInboxBellViewManager.NAME, + NotificationInboxViewManager.NAME, NativeCustomerIOLoggingModule.NAME, NativeCustomerIOModule.NAME, NativeLiveActivitiesModule.NAME, diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt index 0f0c05e6..a8d49bef 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt @@ -33,6 +33,7 @@ class NativeMessagingInAppModule( private val inAppEventListener = ReactInAppEventListener.instance private val inboxChangeListener = ReactNotificationInboxChangeListener.instance + private val inboxEventListener = ReactInboxEventListener.instance // Dedicated lock for inbox listener setup to avoid blocking other operations private val inboxListenerLock = Any() @@ -63,6 +64,7 @@ class NativeMessagingInAppModule( override fun invalidate() { inAppEventListener.clearEventEmitter() clearInboxChangeListener() + clearInboxEventListener() super.invalidate() } @@ -74,6 +76,43 @@ class NativeMessagingInAppModule( setupInboxChangeListener() } + /** + * Registers the native inbox event forwarder with the SDK and wires it to the React Native + * event emitter. Called when a JS inbox event listener is registered. + */ + override fun registerInboxEventListener() { + inboxEventListener.setEventEmitter { data -> + emitOnInboxEventReceived(data) + } + val module = inAppMessagingModule + if (module == null) { + // Without this the failure is invisible: the JS subscription reports success while the SDK + // never forwards anything, so inbox callbacks silently never arrive. Matches the guidance + // used by getMessages() for the same situation. + logger.error( + "Cannot register inbox event listener: in-app messaging is not available. " + + "Ensure CustomerIO SDK is initialized before registering the listener." + ) + return + } + module.setInboxEventListener(inboxEventListener) + } + + /** + * Unregisters the native inbox event forwarder by installing a no-op listener (the native API is + * non-null), restoring the SDK's default action handling. Called when the JS inbox event + * listener is removed. + */ + override fun unregisterInboxEventListener() { + inAppMessagingModule?.setInboxEventListener(NoOpInboxEventListener) + inboxEventListener.clearEventEmitter() + } + + private fun clearInboxEventListener() { + inAppMessagingModule?.setInboxEventListener(NoOpInboxEventListener) + inboxEventListener.clearEventEmitter() + } + override fun getMessages(topic: String?, promise: Promise?) { try { val inbox = requireInboxInstance() ?: run { diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/ReactInboxEventListener.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/ReactInboxEventListener.kt new file mode 100644 index 00000000..eea20735 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/ReactInboxEventListener.kt @@ -0,0 +1,99 @@ +package io.customer.reactnative.sdk.messaginginapp + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableMap +import io.customer.messaginginapp.gist.data.model.InboxMessage +import io.customer.messaginginapp.type.InboxEventListener + +/** + * React Native bridge for Customer.io Visual Notification Inbox events. + * + * Registered on the SDK (via [io.customer.messaginginapp.ModuleMessagingInApp.setInboxEventListener]) + * only while a JS listener is registered. Because the New-Arch event emitter is one-way we cannot + * round-trip a bool back from JS, so [messageActionTaken] forwards the event fire-and-forget and + * RETURNS `true` — telling the SDK the host handled the action so the SDK suppresses its default + * navigation. The JS host owns action handling while a listener is registered. Observational + * callbacks forward fire-and-forget. + */ +class ReactInboxEventListener private constructor() : InboxEventListener { + // Event emitter function to send events to React Native layer + private var eventEmitter: ((ReadableMap) -> Unit)? = null + + // Sets the event emitter function + internal fun setEventEmitter(emitter: ((ReadableMap) -> Unit)?) { + this.eventEmitter = emitter + } + + // Clears the event emitter to prevent memory leaks + internal fun clearEventEmitter() { + this.eventEmitter = null + } + + // Emits an inbox message event to React Native with a serialized message payload + private fun emitInboxEvent( + eventType: String, + message: InboxMessage, + actionName: String? = null, + actionValue: String? = null, + ) { + // Get the emitter, return early if not set + val emitter = eventEmitter ?: return + + val data = Arguments.createMap().apply { + putString("eventType", eventType) + putMap("message", message.toWritableMap()) + actionName?.let { putString("actionName", it) } + actionValue?.let { putString("actionValue", it) } + } + + emitter.invoke(data) + } + + override fun messageActionTaken( + message: InboxMessage, + actionName: String, + actionValue: String, + ): Boolean { + emitInboxEvent( + eventType = "messageActionTaken", + message = message, + actionName = actionName, + actionValue = actionValue, + ) + // Host (React Native) owns action handling while a listener is registered. + return true + } + + override fun messageShown(message: InboxMessage) = emitInboxEvent( + eventType = "messageShown", + message = message, + ) + + override fun messageOpened(message: InboxMessage) = emitInboxEvent( + eventType = "messageOpened", + message = message, + ) + + override fun messageDismissed(message: InboxMessage) = emitInboxEvent( + eventType = "messageDismissed", + message = message, + ) + + companion object { + // Singleton instance with public visibility for direct access by Expo plugin + val instance: ReactInboxEventListener by lazy { ReactInboxEventListener() } + } +} + +/** + * No-op inbox listener used to clear the forwarder from the SDK. Returning `false` from + * [messageActionTaken] restores the SDK's default action handling. The native + * `setInboxEventListener` API is non-null, so a no-op is installed rather than clearing. + */ +internal object NoOpInboxEventListener : InboxEventListener { + override fun messageActionTaken( + message: InboxMessage, + actionName: String, + actionValue: String, + ): Boolean = false +} diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/NotificationInboxBellViewManager.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/NotificationInboxBellViewManager.kt new file mode 100644 index 00000000..7a4d2567 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/NotificationInboxBellViewManager.kt @@ -0,0 +1,38 @@ +package io.customer.reactnative.sdk.messaginginbox + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.NotificationInboxBellNativeManagerDelegate +import com.facebook.react.viewmanagers.NotificationInboxBellNativeManagerInterface + +/** + * View manager for the Visual Notification Inbox bell (just the bell; host opens its own UI). + * + * Registers the `onTap` direct event so taps reach the JS wrapper. + */ +@ReactModule(name = NotificationInboxBellViewManager.NAME) +class NotificationInboxBellViewManager : + NotificationInboxBellNativeManagerInterface, + SimpleViewManager() { + private val delegate = NotificationInboxBellNativeManagerDelegate(this) + + override fun getName() = NAME + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun createViewInstance( + reactContext: ThemedReactContext + ): ReactNotificationInboxBellView = ReactNotificationInboxBellView(reactContext) + + override fun getExportedCustomDirectEventTypeConstants(): MutableMap { + val customEvents = super.getExportedCustomDirectEventTypeConstants() ?: mutableMapOf() + customEvents[ReactNotificationInboxBellView.ON_TAP] = + mapOf("registrationName" to ReactNotificationInboxBellView.ON_TAP) + return customEvents + } + + companion object { + internal const val NAME = "NotificationInboxBellNative" + } +} diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/NotificationInboxViewManager.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/NotificationInboxViewManager.kt new file mode 100644 index 00000000..62f832c4 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/NotificationInboxViewManager.kt @@ -0,0 +1,30 @@ +package io.customer.reactnative.sdk.messaginginbox + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.NotificationInboxViewNativeManagerDelegate +import com.facebook.react.viewmanagers.NotificationInboxViewNativeManagerInterface + +/** + * View manager for the Visual Notification Inbox message list (the Jist-rendered list, + * embeddable in the host's own screen). No props or events; sizing comes from the JS style. + */ +@ReactModule(name = NotificationInboxViewManager.NAME) +class NotificationInboxViewManager : + NotificationInboxViewNativeManagerInterface, + SimpleViewManager() { + private val delegate = NotificationInboxViewNativeManagerDelegate(this) + + override fun getName() = NAME + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun createViewInstance( + reactContext: ThemedReactContext + ): ReactNotificationInboxView = ReactNotificationInboxView(reactContext) + + companion object { + internal const val NAME = "NotificationInboxViewNative" + } +} diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactComposeHostView.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactComposeHostView.kt new file mode 100644 index 00000000..65d52d13 --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactComposeHostView.kt @@ -0,0 +1,96 @@ +package io.customer.reactnative.sdk.messaginginbox + +import android.content.Context +import android.view.Choreographer +import android.view.View +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import com.facebook.react.bridge.WritableMap +import io.customer.reactnative.sdk.extension.sendUIEventToReactJS + +/** + * Base React Native view that hosts a Jetpack Compose [content] inside a [ComposeView]. + * + * Mirrors the role that the native SDK's `WrapperInlineView` plays for inline in-app + * messages, but the Visual Notification Inbox components are plain `@Composable`s, so we + * host them directly in a [ComposeView] rather than going through an inline-message wrapper. + * + * Subclasses provide the composable via [content] and emit events with [emitEvent]. + * + * Layout note: React Native (Yoga) lays this view out, so the [ComposeView] is sized to the + * bounds RN assigns. Because RN children measured by native code don't always re-trigger a + * layout pass, we schedule a manual measure/layout via the [Choreographer] whenever the view + * is attached, matching how other RN-hosted native views keep themselves laid out. + */ +abstract class ReactComposeHostView(context: Context) : FrameLayout(context) { + + private val composeView: ComposeView = ComposeView(context).apply { + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT + ) + setContent { content() } + } + + /** The Compose UI hosted by this view. Implemented by each concrete inbox component. */ + @Composable + protected abstract fun content() + + /** + * The Compose child is attached here rather than in `init`, gating setup on attachment the same + * way [io.customer.messaginginapp.ui.core.BaseInlineInAppMessageView] gates its subscription. + * + * Fabric measures a newly created view before the mount batch's INSERT attaches it to the + * window ([com.facebook.react.fabric.mounting.SurfaceMountingManager.updateLayout]), and + * `AbstractComposeView.onMeasure` unconditionally creates its composition, which needs a window + * to resolve the recomposer. Adding the child in `init` therefore threw + * `IllegalStateException: Cannot locate windowRecomposer` during that measure; the mount batch + * aborted and the half-mounted hierarchy crashed the host app. + */ + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (composeView.parent == null) { + addView(composeView) + } + } + + /** + * Dispatches a UI event back to the JS side using the React Native event dispatcher. + * + * @param eventName name registered in the view manager's + * `getExportedCustomDirectEventTypeConstants`. + * @param payload optional event data. + */ + protected fun emitEvent(eventName: String, payload: WritableMap? = null) { + sendUIEventToReactJS(eventName = eventName, payload = payload) + } + + /** + * React Native lays out views off the main Android layout pass, so manually re-run + * measure + layout against the bounds RN assigned. Without this, native children of + * RN-managed views can render with a zero size. + */ + override fun requestLayout() { + super.requestLayout() + post(measureAndLayout) + } + + private val measureAndLayout = Runnable { + // The callback is posted for the next frame, by which point the view may have been detached + // (Fabric recycles these). Measuring the Compose child without a window throws, so skip it — + // a later attach re-runs layout anyway. + if (!isAttachedToWindow) { + return@Runnable + } + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + ) + layout(left, top, right, bottom) + } + + private fun View.post(action: Runnable) { + Choreographer.getInstance().postFrameCallback { action.run() } + } +} diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactNotificationInboxBellView.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactNotificationInboxBellView.kt new file mode 100644 index 00000000..cc6e2e8c --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactNotificationInboxBellView.kt @@ -0,0 +1,48 @@ +package io.customer.reactnative.sdk.messaginginbox + +import android.content.Context +import android.view.MotionEvent +import androidx.compose.runtime.Composable +import com.facebook.react.bridge.Arguments +import io.customer.messaginginbox.NotificationInboxOverlay + +/** + * React Native host for the Visual Notification Inbox bell. + * + * Hosts the native [NotificationInboxOverlay] composable rather than `NotificationInboxBell`: only the + * overlay ties the bell to the SDK's own inbox panel, and the wrapper deliberately does not reimplement + * panel presentation. Sized to the frame React Native assigns, that composition *is* a bell that opens + * the inbox — the component wrappers expose. + * + * Remote branding still styles the bell (colors, icon). Branding's bell *position* has no effect here: + * alignment resolves inside this view's bounds, so the JS host owns placement via `style`. + * + * [ON_TAP] is observational — the SDK opens the panel itself, so the host has nothing to do in response. + */ +class ReactNotificationInboxBellView(context: Context) : ReactComposeHostView(context) { + @Composable + override fun content() { + NotificationInboxOverlay() + } + + /** + * Reports taps without consuming them. + * + * `dispatchTouchEvent` rather than `onTouchEvent`: the Compose child consumes the bell tap, so a + * parent `onTouchEvent` would never run. Dispatching to super first keeps the gesture flowing to + * Compose (which opens the panel); we only observe the outcome. Reporting on ACTION_UP and only + * when the touch was consumed approximates "the bell took this tap" rather than firing for taps + * that landed on the transparent area around it. + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + val handled = super.dispatchTouchEvent(event) + if (handled && event.actionMasked == MotionEvent.ACTION_UP) { + emitEvent(ON_TAP, Arguments.createMap()) + } + return handled + } + + companion object { + const val ON_TAP = "onTap" + } +} diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactNotificationInboxView.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactNotificationInboxView.kt new file mode 100644 index 00000000..9ebaaa8d --- /dev/null +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginbox/ReactNotificationInboxView.kt @@ -0,0 +1,17 @@ +package io.customer.reactnative.sdk.messaginginbox + +import android.content.Context +import androidx.compose.runtime.Composable +import io.customer.messaginginbox.NotificationInboxView + +/** + * React Native host for the native [NotificationInboxView] composable (the Jist-rendered + * message list). Sizing is driven by the JS `style` prop. Message actions are handled by the + * existing global InboxEventListener, so this component emits no per-message events. + */ +class ReactNotificationInboxView(context: Context) : ReactComposeHostView(context) { + @Composable + override fun content() { + NotificationInboxView() + } +} diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index 071b2202..de162768 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -140,6 +140,7 @@ export class CustomerIOInAppMessaging implements NativeInAppSpec { inbox(): NotificationInbox; // (undocumented) registerEventsListener(listener: (event: InAppMessageEvent) => void): EventSubscription; + registerInboxEventListener(listener: (event: InboxMessageEvent) => void): EventSubscription; } // Warning: (ae-forgotten-export) The symbol "NativeLiveActivitiesSpec" needs to be exported by the entry point index.d.ts @@ -218,6 +219,14 @@ export enum InAppMessageEventType { messageShown = "messageShown" } +// @public +export enum InboxEventType { + messageActionTaken = "messageActionTaken", + messageDismissed = "messageDismissed", + messageOpened = "messageOpened", + messageShown = "messageShown" +} + // @public export interface InboxMessage { deliveryId?: string; @@ -231,6 +240,15 @@ export interface InboxMessage { type: string; } +// @public +export class InboxMessageEvent { + constructor(eventType: InboxEventType, message: InboxMessage, actionName?: string, actionValue?: string); + actionName?: string; + actionValue?: string; + eventType: InboxEventType; + message: InboxMessage; +} + // @public (undocumented) export const InlineInAppMessageView: React_2.FC; @@ -322,11 +340,30 @@ export class NotificationInbox implements NotificationInboxPublicSpec { trackMessageClicked(message: InboxMessage, actionName?: string): void; } +// @public (undocumented) +export const NotificationInboxBellView: React_2.FC; + +// Warning: (ae-forgotten-export) The symbol "NativeProps_2" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface NotificationInboxBellViewProps extends Omit { + onTap?: () => void; +} + // @public export type NotificationInboxChangeListener = { onMessagesChanged: (messages: InboxMessage[]) => void; }; +// @public (undocumented) +export const NotificationInboxView: React_2.FC; + +// Warning: (ae-forgotten-export) The symbol "NativeProps_3" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export interface NotificationInboxViewProps extends NativeProps_3 { +} + // @public export enum PushClickBehaviorAndroid { ActivityNoFlags = "ACTIVITY_NO_FLAGS", diff --git a/customerio-reactnative-richpush.podspec b/customerio-reactnative-richpush.podspec index dfd8e206..9408fae7 100644 --- a/customerio-reactnative-richpush.podspec +++ b/customerio-reactnative-richpush.podspec @@ -15,7 +15,10 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/customerio/customerio-ios.git", :tag => "#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm,swift}" + # Dependency-only pod: installed into the NSE target, whose job is purely to pull in the minimal + # CIO push dependency below. It must NOT compile any sources — everything under ios/ is the React + # Native bridge (React + DataPipelines + InApp + Inbox), none of which the NSE target has or should + # have. Globbing them here made the NSE compile the whole bridge and fail on React headers. # Careful when declaring dependencies here. All dependencies will be included in the App Extension target in Xcode, not the host iOS app. diff --git a/customerio-reactnative.podspec b/customerio-reactnative.podspec index 3a74d29f..8d443dbe 100644 --- a/customerio-reactnative.podspec +++ b/customerio-reactnative.podspec @@ -28,6 +28,9 @@ Pod::Spec.new do |s| # Reference: https://guides.cocoapods.org/syntax/podfile.html#pod s.dependency "CustomerIO/DataPipelines", package["cioNativeiOSSdkVersion"] s.dependency "CustomerIO/MessagingInApp", package["cioNativeiOSSdkVersion"] + # Visual Notification Inbox UI (SwiftUI). Not optional: the bridge in ios/wrappers/inbox/* imports + # CioMessagingInbox, and a pod target only sees the modules its podspec declares. + s.dependency "CustomerIO/MessagingInbox", package["cioNativeiOSSdkVersion"] # If we do not specify a default_subspec, then *all* dependencies inside of *all* the subspecs will be downloaded by cocoapods. # We want customers to opt into push dependencies especially because the FCM subpsec downloads Firebase dependencies. APN customers should not install Firebase dependencies at all. diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 5d93e490..a2dc3485 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -106,6 +106,11 @@ android { proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } } + // The Customer.io native modules are compiled at Java 17. + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } } dependencies { diff --git a/example/package-lock.json b/example/package-lock.json index a6c0aec2..a1d275c3 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -5201,9 +5201,9 @@ "license": "MIT" }, "node_modules/customerio-reactnative": { - "version": "6.4.0", + "version": "6.5.2", "resolved": "file:../customerio-reactnative.tgz", - "integrity": "sha512-zecDKrOc6EXHFdC04KLqiTeYTBRmV0cFEGEhTzVn2+wjskKFy8kxMKN/5yAxXi7uMyO08AYM4nuckKfn5ayZjw==", + "integrity": "sha512-rNij5E016mf4wUwMmsSM9/VSsCxOUU2sVwUvsXfWV24qKyXhqzKyifEZs17APbfzkz1DmaJbtmMPJ49DzdS3mA==", "hasInstallScript": true, "license": "MIT", "engines": { diff --git a/example/src/App.tsx b/example/src/App.tsx index 2a950efb..0826908e 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -36,6 +36,10 @@ export default function App({ appName }: { appName: string }) { ? { Home: 'home', Settings: 'settings', + // Inbox screens are signed-in only: the inbox is per-profile, so an anonymous + // deep link should land on Login rather than an empty inbox. + 'Inbox Messages': 'inbox-messages', + 'Visual Inbox': 'visual-inbox', } : { Login: 'login', diff --git a/example/src/navigation/props.ts b/example/src/navigation/props.ts index d8a8ba0b..1461bcca 100644 --- a/example/src/navigation/props.ts +++ b/example/src/navigation/props.ts @@ -12,6 +12,7 @@ export const CustomDeviceAttrScreenName = 'Device Attributes' as const; export const InternalSettingsScreenName = 'Internal Settings' as const; export const InlineExamplesScreenName = 'Inline Examples' as const; export const InboxMessagesScreenName = 'Inbox Messages' as const; +export const VisualInboxScreenName = 'Visual Inbox' as const; export const LiveActivitiesScreenName = 'Live Activities' as const; export const LocationScreenName = 'Location' as const; @@ -26,6 +27,7 @@ export type NavigationStackParamList = { [InternalSettingsScreenName]: undefined; [InlineExamplesScreenName]: undefined; [InboxMessagesScreenName]: undefined; + [VisualInboxScreenName]: undefined; [LiveActivitiesScreenName]: undefined; [LocationScreenName]: undefined; }; diff --git a/example/src/screens/content-navigator.tsx b/example/src/screens/content-navigator.tsx index 201a2a68..d8cc833a 100644 --- a/example/src/screens/content-navigator.tsx +++ b/example/src/screens/content-navigator.tsx @@ -12,6 +12,7 @@ import { NavigationStackParamList, SettingsScreenName, TrackScreenName, + VisualInboxScreenName, } from '@navigation'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { screenStylingOptions } from '@utils'; @@ -29,6 +30,7 @@ import { LogingScreen, SettingsScreen, TrackScreen, + VisualInboxScreen, } from '@screens'; import { Storage } from '@services'; @@ -132,6 +134,14 @@ export const ContentNavigator = ({ appName }: { appName: string }) => { headerBackVisible: true, }} /> + +