Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions DEVELOPING_LOCALLY.md
Original file line number Diff line number Diff line change
@@ -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.
145 changes: 145 additions & 0 deletions __tests__/inbox-event-listener.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -43,6 +46,8 @@ android {

buildFeatures {
buildConfig true
// Required to host the Visual Notification Inbox Compose UI components.
compose true
}

buildTypes {
Expand Down Expand Up @@ -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'
}
2 changes: 2 additions & 0 deletions android/cio-core.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<ViewManager<*, *>> {
return listOf(InlineInAppMessageViewManager())
return listOf(
InlineInAppMessageViewManager(),
NotificationInboxBellViewManager(),
NotificationInboxViewManager()
)
}

override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
Expand All @@ -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
Expand Down Expand Up @@ -81,6 +89,8 @@ class CustomerIOReactNativePackage : BaseReactPackage() {
// doesn't crash at import time. getModule() returns null when disabled.
val moduleNames: List<String> = listOf(
InlineInAppMessageViewManager.NAME,
NotificationInboxBellViewManager.NAME,
NotificationInboxViewManager.NAME,
NativeCustomerIOLoggingModule.NAME,
NativeCustomerIOModule.NAME,
NativeLiveActivitiesModule.NAME,
Expand Down
Loading
Loading