diff --git a/__tests__/inbox-accessibility-labels.test.ts b/__tests__/inbox-accessibility-labels.test.ts new file mode 100644 index 00000000..b03c0b2f --- /dev/null +++ b/__tests__/inbox-accessibility-labels.test.ts @@ -0,0 +1,197 @@ +/** + * 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 the JavaScript half — that `initialize` forwards the object intact, under + * the key the native parsers read, without interpolating the count — plus the placeholder + * warning, which lives in JavaScript precisely so it reaches the developer on both platforms. + * 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', + }); + }); + + // A mistyped placeholder is substituted by nothing and announced verbatim, braces included. + // Neither native layer can report that usefully — Android logs below the default level and + // iOS does not check at all — so the warning is raised here instead. + describe('{count} placeholder warning', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('warns when the template is missing the placeholder', async () => { + await CustomerIO.initialize( + configWith({ + siteId: 'site', + notificationInboxAccessibilityLabels: { + bellWithUnreadCount: 'Aviseringar, {COUNT} olasta', + }, + }) + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('bellWithUnreadCount') + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('{count}')); + }); + + it('still initializes when the placeholder is mistyped', async () => { + await CustomerIO.initialize( + configWith({ + siteId: 'site', + notificationInboxAccessibilityLabels: { + bellWithUnreadCount: 'Aviseringar, %d olasta', + }, + }) + ); + + // A cosmetic label typo must degrade the announcement, never fail initialization. + expect(nativeInitialize).toHaveBeenCalled(); + }); + + it('stays quiet when the placeholder is present', async () => { + await CustomerIO.initialize( + configWith({ + siteId: 'site', + notificationInboxAccessibilityLabels: labels, + }) + ); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('stays quiet when bellWithUnreadCount is not configured', async () => { + await CustomerIO.initialize( + configWith({ + siteId: 'site', + notificationInboxAccessibilityLabels: { bell: 'Aviseringar' }, + }) + ); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); 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..43d0f2bf 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,51 @@ 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; the JavaScript + * layer warns about that case, where the developer can actually see it. + */ + 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 + ) + 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/App.tsx b/example/src/App.tsx index 0826908e..7c142ee2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -5,7 +5,7 @@ import { NavigationCallbackContext } from '@navigation'; import { NavigationContainer } from '@react-navigation/native'; import { ContentNavigator } from '@screens'; import { Storage } from '@services'; -import { appTheme } from '@utils'; +import { appTheme, withInboxAccessibilityLabels } from '@utils'; import { CioConfig, CioPushPermissionStatus, CustomerIO, InAppMessageEvent, InAppMessageEventType } from 'customerio-reactnative'; import FlashMessage from 'react-native-flash-message'; import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -59,7 +59,7 @@ export default function App({ appName }: { appName: string }) { 'Initializing CustomerIO on app start with config', cioConfig ); - CustomerIO.initialize(cioConfig); + CustomerIO.initialize(withInboxAccessibilityLabels(cioConfig)); } const logInAppEvent = (name: string, params: InAppMessageEvent) => { @@ -132,7 +132,7 @@ export default function App({ appName }: { appName: string }) { value={{ onSetConfig: (config) => { console.log('Initializing CustomerIO with config', config); - CustomerIO.initialize(config); + CustomerIO.initialize(withInboxAccessibilityLabels(config)); }, onLogin: (user) => { console.log('Identifying user', user); diff --git a/example/src/screens/settings.tsx b/example/src/screens/settings.tsx index 1d362be1..df86aeb8 100644 --- a/example/src/screens/settings.tsx +++ b/example/src/screens/settings.tsx @@ -8,6 +8,7 @@ import { TextField, } from '@components'; import { Storage } from '@services'; +import { withInboxAccessibilityLabels } from '@utils'; import { CioConfig, CioLocationTrackingMode, @@ -137,7 +138,9 @@ export const SettingsScreen = () => { type: 'warning', }); } else { - CustomerIO.initialize(config as CioConfig); + CustomerIO.initialize( + withInboxAccessibilityLabels(config as CioConfig) + ); showMessage({ message: 'CustomerIO settings saved successfully and CustomerIO.initialize() has been called with the new settings', diff --git a/example/src/utils/inbox-accessibility-labels.ts b/example/src/utils/inbox-accessibility-labels.ts new file mode 100644 index 00000000..816dca64 --- /dev/null +++ b/example/src/utils/inbox-accessibility-labels.ts @@ -0,0 +1,33 @@ +import type { CioConfig } from 'customerio-reactnative'; + +// The SDK ships no text of its own in the visual notification inbox, so these labels 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. +const INBOX_ACCESSIBILITY_LABELS = { + bell: 'Notifications', + bellWithUnreadCount: 'Notifications, {count} unread', + loadingIndicator: 'Loading inbox', + emptyState: 'No notifications', +}; + +/** + * Adds the inbox accessibility labels to a config on its way to `CustomerIO.initialize`. + * + * Applied at the initialize call sites rather than stored with the Settings config, which is both + * persisted and user-editable: the labels are not something a tester should be able to edit away, + * and keeping them out of storage means Settings needs no special handling to preserve them. + * + * Every path that initializes the SDK has to go through here. Native `initialize` ignores every + * call after the first, so a path that skips these labels and happens to run first leaves the + * inbox unlabeled for the rest of the process. + */ +export const withInboxAccessibilityLabels = (config: CioConfig): CioConfig => + config.inApp + ? { + ...config, + inApp: { + ...config.inApp, + notificationInboxAccessibilityLabels: INBOX_ACCESSIBILITY_LABELS, + }, + } + : config; diff --git a/example/src/utils/index.ts b/example/src/utils/index.ts index e69609d2..f08945c0 100644 --- a/example/src/utils/index.ts +++ b/example/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './app-theming'; export * from './data-types'; export * from './random-users'; +export * from './inbox-accessibility-labels'; 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; +}; diff --git a/src/utils/param-validation.ts b/src/utils/param-validation.ts index 714e1145..8819738e 100644 --- a/src/utils/param-validation.ts +++ b/src/utils/param-validation.ts @@ -32,6 +32,16 @@ function failIf(condition: boolean, message: () => string): void { } } +/** + * Logs a warning if the condition is true. + * Use this for misconfigurations that degrade behavior but must not fail initialization. + */ +function warnIf(condition: boolean, message: () => string): void { + if (__DEV__ && condition) { + console.warn(`[CustomerIO] ${message()}`); + } +} + /** * Validates that a value is defined (not undefined or null). * Useful for checking required fields. @@ -137,6 +147,37 @@ function validateAttributes( return attributes as CustomAttributes; } +/** + * The placeholder the SDK substitutes with the unread count when rendering + * `bellWithUnreadCount`. + */ +const COUNT_PLACEHOLDER = '{count}'; + +/** + * Warns when `bellWithUnreadCount` is missing its `{count}` placeholder. + * + * The label is a template, not a finished string. A misspelled placeholder (`{COUNT}`, + * `{{count}}`, `%d`) matches nothing, so the template is announced verbatim — braces and all — + * and the count is never spoken. The check lives here rather than in either native layer + * because neither can report it usefully: Android logs at `debug`, which the default `ERROR` + * level discards, and iOS does not check at all. Warning from JavaScript reaches the + * developer's console on both platforms, whatever the SDK log level. + */ +function validateInboxAccessibilityLabels(value: unknown): void { + if (isUndefined(value) || typeof value !== 'object') { + return; + } + + const template = (value as { bellWithUnreadCount?: unknown }) + .bellWithUnreadCount; + warnIf( + typeof template === 'string' && !template.includes(COUNT_PLACEHOLDER), + () => + `"inApp.notificationInboxAccessibilityLabels.bellWithUnreadCount" has no ` + + `"${COUNT_PLACEHOLDER}" placeholder, so the unread count will not be announced.` + ); +} + /** * Validates that the given value is a valid CioConfig object. * Throws if required fields are missing or incorrectly typed. @@ -155,6 +196,9 @@ function validateConfig(value: unknown): asserts value is CioConfig { allowEmpty: false, usage: usage, }); + validateInboxAccessibilityLabels( + obj.inApp?.notificationInboxAccessibilityLabels + ); } // For type safety, we define a ConfigValidator type that asserts the value is a CioConfig