diff --git a/__tests__/inbox-accessibility-labels.test.ts b/__tests__/inbox-accessibility-labels.test.ts new file mode 100644 index 00000000..05c6065d --- /dev/null +++ b/__tests__/inbox-accessibility-labels.test.ts @@ -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', + }); + }); +}); diff --git a/android/gradle.properties b/android/gradle.properties index 9010b7b3..2896b287 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 diff --git a/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt b/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt index cb14e3cd..d32c6e61 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt @@ -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}" + } } 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 a8d49bef..9ca2ac7a 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 @@ -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 @@ -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 + ): NotificationInboxAccessibilityLabels? { + val labels = config.getTypedValue>( + Keys.Config.NOTIFICATION_INBOX_ACCESSIBILITY_LABELS + ) ?: return null + + val unreadCountTemplate = labels.getTypedValue( + 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(Keys.InboxAccessibilityLabels.BELL), + bellWithUnreadCount = unreadCountTemplate?.let { template -> + { count: Int -> + template.replace( + Keys.InboxAccessibilityLabels.COUNT_PLACEHOLDER, + count.toString() + ) + } + }, + loadingIndicator = labels.getTypedValue( + Keys.InboxAccessibilityLabels.LOADING_INDICATOR + ), + emptyState = labels.getTypedValue( + Keys.InboxAccessibilityLabels.EMPTY_STATE + ) + ) + } } } diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index 5268bc39..44278cce 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -30,6 +30,7 @@ export type CioConfig = { autoTrackDeviceAttributes?: boolean; inApp?: { siteId: string; + notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels; }; push?: { android?: { @@ -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; diff --git a/example/src/screens/settings.tsx b/example/src/screens/settings.tsx index 1d362be1..0c1dd702 100644 --- a/example/src/screens/settings.tsx +++ b/example/src/screens/settings.tsx @@ -38,7 +38,9 @@ export const SettingsScreen = () => { /> { - 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" @@ -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 }); }} diff --git a/example/src/screens/visual-inbox.tsx b/example/src/screens/visual-inbox.tsx index fb2ad6eb..d0c109fe 100644 --- a/example/src/screens/visual-inbox.tsx +++ b/example/src/screens/visual-inbox.tsx @@ -6,7 +6,7 @@ import { NotificationInboxView, } from 'customerio-reactnative'; import React, { useEffect } from 'react'; -import { Linking, StyleSheet, Text, View } from 'react-native'; +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; import { showMessage } from 'react-native-flash-message'; /** @@ -94,6 +94,28 @@ export const VisualInboxScreen = ({}: NavigationScreenProps<'Visual Inbox'>) => Tapping the bell opens the SDK's inbox panel — this screen presents nothing. + {/* + Debug affordance for checking the empty state. It is driven entirely by SDK data — + NotificationInboxView takes no props that could force it — so the only way to see the + dimmed-bell empty state is a profile with no messages. Identifying a fresh random user + gives exactly that, through the real SDK path rather than a faked view state. + */} + { + const userId = `inbox-empty-${Date.now()}`; + CustomerIO.identify({ userId }); + showMessage({ + message: `Identified ${userId} — inbox should now be empty`, + type: 'info', + }); + }} + > + + Debug: identify a fresh profile (empty inbox) + + + {/* 2. The message list, placed inline by this screen. */} NotificationInboxView @@ -127,6 +149,14 @@ const styles = StyleSheet.create({ // 88 not 56: the native composition insets the 56dp bell by 16 on each side, so a // 56-square box squeezes the circle down onto the glyph. bell: { width: 88, height: 88 }, + debugButton: { + backgroundColor: '#e8e8ef', + borderRadius: 6, + paddingVertical: 10, + paddingHorizontal: 12, + alignItems: 'center', + }, + debugButtonText: { fontSize: 13, color: '#333', fontWeight: '600' }, embeddedList: { flex: 1, backgroundColor: '#fff', diff --git a/example/src/services/storage.ts b/example/src/services/storage.ts index 7267a740..a18c51b1 100644 --- a/example/src/services/storage.ts +++ b/example/src/services/storage.ts @@ -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, @@ -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; }; diff --git a/jest.config.js b/jest.config.js index 24964da9..d97f1cea 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,10 @@ module.exports = { testEnvironment: 'node', testMatch: ['/__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, + }, }; diff --git a/package.json b/package.json index 4f9955dd..c7cccd23 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "./package.json": "./package.json" }, - "cioNativeiOSSdkVersion": "= 4.7.6", + "cioNativeiOSSdkVersion": "= 4.8.0", "cioiOSFirebaseWrapperSdkVersion": "= 1.0.0", "files": [ "src", diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index 3e6fefed..c64fe5f1 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -1,3 +1,4 @@ +import type { NotificationInboxAccessibilityLabels } from './inbox'; import type { LiveActivitiesConfig } from './live-activities'; import type { PushClickBehaviorAndroid } from './push'; @@ -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?: { diff --git a/src/types/inbox.ts b/src/types/inbox.ts index 03ba8c18..25a23015 100644 --- a/src/types/inbox.ts +++ b/src/types/inbox.ts @@ -49,3 +49,62 @@ export class InboxMessageEvent { this.actionValue = actionValue; } } + +/** + * Host-provided accessibility labels for the Visual Notification Inbox UI. + * + * The SDK ships no text of its own in the visual inbox — the empty state is an icon and the + * loading state is a spinner — so accessibility labels are the one place a string is still + * needed. Because the SDK cannot know your app's language, every label is optional and unset by + * default, and an omitted label leaves that element unlabeled rather than falling back to + * English. Pass strings already localized for the user's language. + * + * @example + * ```ts + * CustomerIO.initialize({ + * cdpApiKey: '...', + * inApp: { + * siteId: '...', + * notificationInboxAccessibilityLabels: { + * bell: t('inbox.bell'), + * bellWithUnreadCount: t('inbox.unread'), // e.g. "{count} unread notifications" + * loadingIndicator: t('inbox.loading'), + * emptyState: t('inbox.empty'), + * }, + * }, + * }); + * ``` + * + * @public + */ +export type NotificationInboxAccessibilityLabels = { + /** + * Label for the inbox bell button. Also used when the bell shows an unread badge but + * `bellWithUnreadCount` is not provided. Unset → the bell is announced as an unnamed button + * (it stays focusable and tappable; hiding it would leave screen reader users no way in). + */ + bell?: string; + /** + * Label for the bell while it shows an unread badge. Include `{count}` where the number of + * unread messages should appear; it is substituted at render time. Unset → falls back to `bell`. + * + * The badge itself is always hidden from assistive technologies, so the count is announced + * only through this label, never as bare digits appended to the button. + * + * This is a template rather than a function because configuration crosses the native bridge, + * which carries data but not callbacks. One template cannot express languages whose plural + * rules need a distinct form per count. + */ + bellWithUnreadCount?: string; + /** + * Label announced for the loading spinner. + * + * Unset behaves differently per platform: on Android the spinner keeps its indeterminate + * progress role, which TalkBack describes in the device's own language, while on iOS it is not + * an accessibility element at all, so VoiceOver skips it rather than focusing an unnamed + * control. Set a label if you want the loading state announced on both. + */ + loadingIndicator?: string; + /** Label announced for the empty-state icon. Unset → the icon is treated as decorative. */ + emptyState?: string; +};