Skip to content
Draft
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
129 changes: 129 additions & 0 deletions __tests__/inbox-accessibility-labels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Verifies the Visual Notification Inbox accessibility labels survive the JS -> native hop.
*
* The labels are plain data on `CioConfig['inApp']`, and the native side does the real work: iOS
* parses the dictionary in `MessagingInAppConfigBuilder.build(from:)`, Android converts the
* `{count}` template into the `(Int) -> String` the SDK expects.
*
* Scope: these cover only the JavaScript half — that `initialize` forwards the object intact,
* under the key the native parsers read, without interpolating the count. They do NOT pin the
* native key names; renaming `bell` in either parser leaves these green while every label stops
* arriving. Guarding that needs a test on the native side of each bridge, which on Android would
* require a test source set this package does not have.
*
* `jest.mock` factories are hoisted above module-scope declarations, so each mock is created
* inside its factory and read back from the imported (mocked) module.
*/

// Importing `customerio-cdp` pulls in every sibling module, and each one resolves its TurboModule
// at import time — so the mock needs TurboModuleRegistry as well as Platform.
jest.mock('react-native', () => ({
Platform: {
OS: 'ios',
select: (spec: { [key: string]: unknown }) =>
spec.ios ?? spec.default ?? undefined,
},
TurboModuleRegistry: {
get: jest.fn(() => null),
getEnforcing: jest.fn(() => ({})),
},
NativeEventEmitter: jest.fn(() => ({
addListener: jest.fn(() => ({ remove: jest.fn() })),
})),
}));

// The native Fabric components pull in codegen internals this test does not need.
jest.mock('../src/components', () => ({}));

jest.mock('../src/native-logger-listener', () => ({
NativeLoggerListener: {
warn: jest.fn(),
initialize: jest.fn(),
// customerio-cdp calls this at module scope.
initNativeLogger: jest.fn(),
},
}));

jest.mock('../src/specs/modules/NativeCustomerIO', () => ({
__esModule: true,
default: { initialize: jest.fn(() => Promise.resolve(true)) },
}));

import { CustomerIO } from '../src/customerio-cdp';
import NativeModule from '../src/specs/modules/NativeCustomerIO';
import type { CioConfig } from '../src/types';

const nativeInitialize = NativeModule.initialize as jest.Mock;

const labels = {
bell: 'Aviseringar',
bellWithUnreadCount: 'Aviseringar, {count} olasta',
loadingIndicator: 'Laddar',
emptyState: 'Inga aviseringar',
};

const configWith = (inApp: CioConfig['inApp']): CioConfig =>
({ cdpApiKey: 'test-key', inApp }) as CioConfig;

describe('notification inbox accessibility labels', () => {
beforeEach(() => {
nativeInitialize.mockClear();
});

it('forwards every label to the native module under the shared config key', async () => {
await CustomerIO.initialize(
configWith({
siteId: 'site',
notificationInboxAccessibilityLabels: labels,
})
);

const [forwardedConfig] = nativeInitialize.mock.calls[0];
expect(forwardedConfig.inApp.notificationInboxAccessibilityLabels).toEqual(
labels
);
});

it('keeps the {count} placeholder intact for the native side to substitute', async () => {
await CustomerIO.initialize(
configWith({
siteId: 'site',
notificationInboxAccessibilityLabels: labels,
})
);

const [forwardedConfig] = nativeInitialize.mock.calls[0];
// JS must not interpolate: the count is only known natively, at render time.
expect(
forwardedConfig.inApp.notificationInboxAccessibilityLabels
.bellWithUnreadCount
).toContain('{count}');
});

it('omits the labels entirely when the app configures none', async () => {
await CustomerIO.initialize(configWith({ siteId: 'site' }));

const [forwardedConfig] = nativeInitialize.mock.calls[0];
// Absent rather than an empty object: the native default is "emit no labels at all",
// and nothing in the JS layer should manufacture a value the host did not set.
expect(
forwardedConfig.inApp.notificationInboxAccessibilityLabels
).toBeUndefined();
});

it('forwards a partial set without filling in the rest', async () => {
await CustomerIO.initialize(
configWith({
siteId: 'site',
notificationInboxAccessibilityLabels: {
emptyState: 'Inga aviseringar',
},
})
);

const [forwardedConfig] = nativeInitialize.mock.calls[0];
expect(forwardedConfig.inApp.notificationInboxAccessibilityLabels).toEqual({
emptyState: 'Inga aviseringar',
});
});
});
2 changes: 1 addition & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ customerio.reactnative.kotlinVersion=2.1.20
customerio.reactnative.compileSdkVersion=36
customerio.reactnative.targetSdkVersion=36
customerio.reactnative.minSdkVersion=21
customerio.reactnative.cioSDKVersionAndroid=4.20.2
customerio.reactnative.cioSDKVersionAndroid=4.21.1
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@ internal object Keys {
const val SCREEN_VIEW_USE = "screenViewUse"
const val API_HOST = "apiHost"
const val CDN_HOST = "cdnHost"
const val NOTIFICATION_INBOX_ACCESSIBILITY_LABELS = "notificationInboxAccessibilityLabels"
// Push messaging
const val PUSH_CLICK_BEHAVIOR = "pushClickBehavior"
}

/**
* Keys of the `notificationInboxAccessibilityLabels` sub-map.
*
* [BELL_WITH_UNREAD_COUNT] carries a template string containing [COUNT_PLACEHOLDER] rather than
* a callback, because wrapper configuration crosses a bridge that carries data but not
* functions. The native SDK takes a `(Int) -> String`, so the template is converted to one when
* the module is configured.
*/
object InboxAccessibilityLabels {
const val BELL = "bell"
const val BELL_WITH_UNREAD_COUNT = "bellWithUnreadCount"
const val LOADING_INDICATOR = "loadingIndicator"
const val EMPTY_STATE = "emptyState"
const val COUNT_PLACEHOLDER = "{count}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import io.customer.messaginginapp.di.inAppMessaging
import io.customer.messaginginapp.gist.data.model.InboxMessage
import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory
import io.customer.messaginginapp.inbox.NotificationInbox
import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels
import io.customer.reactnative.sdk.NativeCustomerIOMessagingInAppSpec
import io.customer.reactnative.sdk.constant.Keys
import io.customer.reactnative.sdk.extension.getTypedValue
Expand Down Expand Up @@ -246,9 +247,62 @@ class NativeMessagingInAppModule(
val module = ModuleMessagingInApp(
MessagingInAppModuleConfig.Builder(siteId = siteId, region = region).apply {
setEventListener(eventListener = ReactInAppEventListener.instance)
inboxAccessibilityLabelsFromConfig(config)?.let { labels ->
setNotificationInboxAccessibilityLabels(labels)
}
}.build(),
)
builder.addCustomerIOModule(module)
}

/**
* Builds the host's inbox accessibility labels from the wrapper configuration, or null when
* the app provided none — in which case the SDK keeps its default of emitting no labels at
* all rather than falling back to English.
*
* `bellWithUnreadCount` arrives as a template string because the bridge carries data but not
* functions; it is converted here into the `(Int) -> String` the native SDK expects. A
* template without the placeholder is returned verbatim for every count.
*/
private fun inboxAccessibilityLabelsFromConfig(
config: Map<String, Any>
): NotificationInboxAccessibilityLabels? {
val labels = config.getTypedValue<Map<String, Any>>(
Keys.Config.NOTIFICATION_INBOX_ACCESSIBILITY_LABELS
) ?: return null

val unreadCountTemplate = labels.getTypedValue<String>(
Keys.InboxAccessibilityLabels.BELL_WITH_UNREAD_COUNT
)
// A mistyped placeholder (`{COUNT}`, `{{count}}`, `%d`) substitutes nothing and is read
// aloud verbatim, braces included, with the count never announced. Nothing else in the
// stack can surface that, so say it here.
if (unreadCountTemplate != null &&
!unreadCountTemplate.contains(Keys.InboxAccessibilityLabels.COUNT_PLACEHOLDER)
) {
SDKComponent.logger.debug(
"Inbox accessibility label 'bellWithUnreadCount' has no " +
"'${Keys.InboxAccessibilityLabels.COUNT_PLACEHOLDER}' placeholder, so the " +
"unread count will not be announced."
)
}
return NotificationInboxAccessibilityLabels(
bell = labels.getTypedValue<String>(Keys.InboxAccessibilityLabels.BELL),
bellWithUnreadCount = unreadCountTemplate?.let { template ->
{ count: Int ->
template.replace(
Keys.InboxAccessibilityLabels.COUNT_PLACEHOLDER,
count.toString()
)
}
},
loadingIndicator = labels.getTypedValue<String>(
Keys.InboxAccessibilityLabels.LOADING_INDICATOR
),
emptyState = labels.getTypedValue<String>(
Keys.InboxAccessibilityLabels.EMPTY_STATE
)
)
}
}
}
9 changes: 9 additions & 0 deletions api-extractor-output/customerio-reactnative.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type CioConfig = {
autoTrackDeviceAttributes?: boolean;
inApp?: {
siteId: string;
notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels;
};
push?: {
android?: {
Expand Down Expand Up @@ -351,6 +352,14 @@ export class NotificationInbox implements NotificationInboxPublicSpec {
trackMessageClicked(message: InboxMessage, actionName?: string): void;
}

// @public
export type NotificationInboxAccessibilityLabels = {
bell?: string;
bellWithUnreadCount?: string;
loadingIndicator?: string;
emptyState?: string;
};

// @public (undocumented)
export const NotificationInboxBellView: React_2.FC<NotificationInboxBellViewProps>;

Expand Down
12 changes: 10 additions & 2 deletions example/src/screens/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export const SettingsScreen = () => {
/>
<TextField
onChangeText={(siteId) => {
const inApp = { siteId: siteId };
// Spread the existing config so editing the site ID keeps the accessibility
// labels configured in the default config.
const inApp = { ...config.inApp, siteId: siteId };
setConfig({ ...config, inApp });
}}
label="Site ID"
Expand Down Expand Up @@ -96,8 +98,14 @@ export const SettingsScreen = () => {
label="Enable In-App Messaging"
value={config.inApp?.siteId !== undefined}
onValueChange={(enableInApp) => {
// Disabling clears `inApp` entirely, so re-enabling has nothing to spread
// and would drop the accessibility labels. Fall back to the defaults.
const inApp = enableInApp
? { siteId: config.inApp?.siteId ?? '' }
? {
...Storage.instance.getDefaultCioConfig().inApp,
...config.inApp,
siteId: config.inApp?.siteId ?? '',
}
: undefined;
setConfig({ ...config, inApp });
}}
Expand Down
30 changes: 28 additions & 2 deletions example/src/services/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ const createDefaultConfig = (env: Env | null | undefined): Config => {
cdpApiKey: env?.API_KEY ?? '',
inApp: {
siteId: env?.SITE_ID ?? '',
// The SDK ships no text of its own in the visual inbox, so these are the only strings it
// can announce. A real app would resolve them through its own i18n so they follow the
// user's language; they are hardcoded here only to keep the sample self-contained.
notificationInboxAccessibilityLabels: {
bell: 'Notifications',
bellWithUnreadCount: 'Notifications, {count} unread',
loadingIndicator: 'Loading inbox',
emptyState: 'No notifications',
},
},
region: CioRegion.US,
logLevel: CioLogLevel.Debug,
Expand Down Expand Up @@ -67,8 +76,25 @@ export class Storage {
this.user = userJsonPayload ? JSON.parse(userJsonPayload) : null;
// Merge persisted config over defaults so newly added default keys (e.g. the
// geofence opt-in) are present for installs saved before those keys existed.
this.config = cioConfigJsonPayload
? { ...Storage.defaultConfig, ...JSON.parse(cioConfigJsonPayload) }
const savedConfig: Config | null = cioConfigJsonPayload
? JSON.parse(cioConfigJsonPayload)
: null;
this.config = savedConfig
? {
...Storage.defaultConfig,
...savedConfig,
// `inApp` needs one more level of merging than the spread above gives it. A
// config saved before the accessibility labels existed carries only `siteId`,
// so a shallow spread replaces the defaults' `inApp` wholesale and silently
// drops them — leaving every device that ever opened Settings demonstrating
// the unlabeled inbox. Only merged when the saved config has `inApp` at all,
// so disabling in-app messaging still persists.
...(savedConfig.inApp
? {
inApp: { ...Storage.defaultConfig.inApp, ...savedConfig.inApp },
}
: {}),
}
: null;
};

Expand Down
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['<rootDir>/__tests__/**/*.test.ts'],
globals: {
// React Native defines `__DEV__` at runtime; the bare node environment does not, so any test
// reaching the `assert.*` parameter validation in src/utils/param-validation.ts would throw a
// ReferenceError instead of exercising the validation. Matches React Native's own jest preset.
__DEV__: true,
},
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"./package.json": "./package.json"
},
"cioNativeiOSSdkVersion": "= 4.7.6",
"cioNativeiOSSdkVersion": "= 4.8.0",
"cioiOSFirebaseWrapperSdkVersion": "= 1.0.0",
"files": [
"src",
Expand Down
6 changes: 6 additions & 0 deletions src/types/data-pipelines.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NotificationInboxAccessibilityLabels } from './inbox';
import type { LiveActivitiesConfig } from './live-activities';
import type { PushClickBehaviorAndroid } from './push';

Expand Down Expand Up @@ -67,6 +68,11 @@ export type CioConfig = {
autoTrackDeviceAttributes?: boolean;
inApp?: {
siteId: string;
/**
* Accessibility labels for the Visual Notification Inbox. Optional; an omitted label leaves
* that element unlabeled rather than falling back to English.
*/
notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels;
};
push?: {
android?: {
Expand Down
Loading
Loading