From 4308bb19335b623cd51eb11f14e06c182576b0c3 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sat, 12 Sep 2026 00:45:12 +0400 Subject: [PATCH 1/5] feat(inbox): let apps configure visual inbox accessibility labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native SDKs stopped shipping hardcoded English labels for the visual notification inbox, so apps now supply their own. Expose that config through the wrapper: four optional strings on `inApp`, with the unread-count label as a `{count}` template because the bridge carries data but not callbacks. iOS needs no native change — the whole config already reaches MessagingInAppConfigBuilder.build(from:), which parses these keys. Android builds the labels and converts the template into the closure the SDK expects. Co-Authored-By: Claude Opus 5 --- __tests__/inbox-accessibility-labels.test.ts | 126 ++++++++++++++++++ android/gradle.properties | 2 +- .../customer/reactnative/sdk/constant/Keys.kt | 17 +++ .../NativeMessagingInAppModule.kt | 42 ++++++ .../customerio-reactnative.api.md | 9 ++ example/src/screens/settings.tsx | 6 +- example/src/services/storage.ts | 9 ++ jest.config.js | 6 + package.json | 2 +- src/types/data-pipelines.ts | 6 + src/types/inbox.ts | 55 ++++++++ 11 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 __tests__/inbox-accessibility-labels.test.ts diff --git a/__tests__/inbox-accessibility-labels.test.ts b/__tests__/inbox-accessibility-labels.test.ts new file mode 100644 index 00000000..744b5657 --- /dev/null +++ b/__tests__/inbox-accessibility-labels.test.ts @@ -0,0 +1,126 @@ +/** + * 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. What JavaScript owns is the + * contract — the labels must reach `native.initialize` intact, under exactly the keys the native + * parsers look for. A rename on either side silently drops every label (the SDK emits no text of + * its own, so the UI would simply go unlabeled rather than fail), which is what this locks down. + * + * `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..1b083fa1 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.0 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..02e2b528 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,50 @@ 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 + ) + 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..387311c2 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" @@ -97,7 +99,7 @@ export const SettingsScreen = () => { value={config.inApp?.siteId !== undefined} onValueChange={(enableInApp) => { const inApp = enableInApp - ? { siteId: config.inApp?.siteId ?? '' } + ? { ...config.inApp, siteId: config.inApp?.siteId ?? '' } : undefined; setConfig({ ...config, inApp }); }} diff --git a/example/src/services/storage.ts b/example/src/services/storage.ts index 7267a740..0b278af3 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, 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..9cd290b2 100644 --- a/src/types/inbox.ts +++ b/src/types/inbox.ts @@ -49,3 +49,58 @@ 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 → no label, leaving only the indeterminate + * progress role that the platform describes in the device's own language. + */ + loadingIndicator?: string; + /** Label announced for the empty-state icon. Unset → the icon is treated as decorative. */ + emptyState?: string; +}; From 8ffc1e41836a0774fe514da2848200a729d1a441 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sat, 12 Sep 2026 01:45:31 +0400 Subject: [PATCH 2/5] fix(inbox): stop the sample app from silently dropping the labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the wrapper PRs found two ways the example lost the accessibility labels it exists to demonstrate: - loadFromStorage merged persisted config over defaults shallowly, so any device that had ever opened Settings replaced the defaults' `inApp` wholesale and demonstrated the unlabeled inbox. - Toggling in-app messaging off clears `inApp`, so re-enabling had nothing to spread and dropped the labels permanently. Also corrects the `loadingIndicator` doc, which described Android's behaviour as if it were cross-platform: on iOS an unset label makes the spinner not an accessibility element at all, so VoiceOver skips it rather than announcing a progress role. Adds a debug log when `bellWithUnreadCount` carries no `{count}` placeholder — a typo like `{COUNT}` or `%d` is otherwise read aloud verbatim with the count never announced, and nothing else in the stack can surface that. Pins Android to 4.21.1, which adds an in-app open-url query fix at no cost, and narrows the test docstring to what it actually verifies: the JavaScript half, not the native key names. Co-Authored-By: Claude Opus 5 --- __tests__/inbox-accessibility-labels.test.ts | 11 ++++++---- android/gradle.properties | 2 +- .../NativeMessagingInAppModule.kt | 12 +++++++++++ example/src/screens/settings.tsx | 8 ++++++- example/src/services/storage.ts | 21 +++++++++++++++++-- src/types/inbox.ts | 8 +++++-- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/__tests__/inbox-accessibility-labels.test.ts b/__tests__/inbox-accessibility-labels.test.ts index 744b5657..05c6065d 100644 --- a/__tests__/inbox-accessibility-labels.test.ts +++ b/__tests__/inbox-accessibility-labels.test.ts @@ -3,10 +3,13 @@ * * 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. What JavaScript owns is the - * contract — the labels must reach `native.initialize` intact, under exactly the keys the native - * parsers look for. A rename on either side silently drops every label (the SDK emits no text of - * its own, so the UI would simply go unlabeled rather than fail), which is what this locks down. + * `{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. diff --git a/android/gradle.properties b/android/gradle.properties index 1b083fa1..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.21.0 +customerio.reactnative.cioSDKVersionAndroid=4.21.1 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 02e2b528..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 @@ -274,6 +274,18 @@ class NativeMessagingInAppModule( 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 -> diff --git a/example/src/screens/settings.tsx b/example/src/screens/settings.tsx index 387311c2..0c1dd702 100644 --- a/example/src/screens/settings.tsx +++ b/example/src/screens/settings.tsx @@ -98,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 - ? { ...config.inApp, siteId: config.inApp?.siteId ?? '' } + ? { + ...Storage.instance.getDefaultCioConfig().inApp, + ...config.inApp, + siteId: config.inApp?.siteId ?? '', + } : undefined; setConfig({ ...config, inApp }); }} diff --git a/example/src/services/storage.ts b/example/src/services/storage.ts index 0b278af3..a18c51b1 100644 --- a/example/src/services/storage.ts +++ b/example/src/services/storage.ts @@ -76,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/src/types/inbox.ts b/src/types/inbox.ts index 9cd290b2..25a23015 100644 --- a/src/types/inbox.ts +++ b/src/types/inbox.ts @@ -97,8 +97,12 @@ export type NotificationInboxAccessibilityLabels = { */ bellWithUnreadCount?: string; /** - * Label announced for the loading spinner. Unset → no label, leaving only the indeterminate - * progress role that the platform describes in the device's own language. + * 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. */ From f5b7df331d749366f7bec796934cbeee9fd9dd19 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sun, 13 Sep 2026 16:23:14 +0400 Subject: [PATCH 3/5] chore(sample): add a way to reach the inbox empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty state is driven entirely by SDK data — NotificationInboxView takes no props that could force it — so seeing the dimmed bell previously meant finding a profile that happened to have no messages. Identifying a fresh random user reaches it through the real SDK path instead of faking a view state. Co-Authored-By: Claude Opus 5 --- example/src/screens/visual-inbox.tsx | 32 +++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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', From cd507e616fe7502f8fb7adad7b53fc78abea27ab Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Tue, 15 Sep 2026 16:04:00 +0400 Subject: [PATCH 4/5] Move the {count} placeholder check to JavaScript; trim sample-app changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mistyped-placeholder warning lived in the Android mapper at debug level, which the default ERROR log level discards, and iOS had no check at all — so a typo like `{COUNT}` produced a screen reader announcing the template verbatim with no diagnostic on either platform. The check now runs in param-validation alongside the other config checks, warning rather than throwing since a label typo must not fail initialization. The sample app now supplies the labels at its single initialize() call instead of storing them in the editable Settings config, which keeps Settings and storage untouched. Drops the debug identify button: testers reach the empty state by logging in with a profile that has no inbox messages. Co-Authored-By: Claude Opus 5 --- __tests__/inbox-accessibility-labels.test.ts | 78 +++++++++++++++++-- .../NativeMessagingInAppModule.kt | 15 +--- example/src/App.tsx | 25 +++++- example/src/screens/settings.tsx | 12 +-- example/src/screens/visual-inbox.tsx | 32 +------- example/src/services/storage.ts | 30 +------ src/utils/param-validation.ts | 44 +++++++++++ 7 files changed, 148 insertions(+), 88 deletions(-) diff --git a/__tests__/inbox-accessibility-labels.test.ts b/__tests__/inbox-accessibility-labels.test.ts index 05c6065d..b03c0b2f 100644 --- a/__tests__/inbox-accessibility-labels.test.ts +++ b/__tests__/inbox-accessibility-labels.test.ts @@ -5,11 +5,12 @@ * 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. + * 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. @@ -126,4 +127,71 @@ describe('notification inbox accessibility labels', () => { 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/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt index 9ca2ac7a..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 @@ -262,7 +262,8 @@ class NativeMessagingInAppModule( * * `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. + * 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 @@ -274,18 +275,6 @@ class NativeMessagingInAppModule( 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 -> diff --git a/example/src/App.tsx b/example/src/App.tsx index 0826908e..6e8d29fa 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -12,6 +12,18 @@ import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native- import { enableFreeze, enableScreens } from 'react-native-screens'; import { getEnvForApp } from './env'; +// The SDK ships no text of its own in the visual notification inbox, so these labels are the only +// strings it can announce. Applied here, at the single initialize() call, rather than stored with +// the editable Settings config: they are not something a tester should be able to edit away, and +// keeping them out of the persisted config means Settings needs no special handling for them. +// A real app would resolve these through its own i18n so they follow the user's language. +const INBOX_ACCESSIBILITY_LABELS = { + bell: 'Notifications', + bellWithUnreadCount: 'Notifications, {count} unread', + loadingIndicator: 'Loading inbox', + emptyState: 'No notifications', +}; + // Enable native screen optimizations for better performance enableScreens(true); // Better native performance enableFreeze(true); // Let tabs "freeze" when not focused @@ -132,7 +144,18 @@ export default function App({ appName }: { appName: string }) { value={{ onSetConfig: (config) => { console.log('Initializing CustomerIO with config', config); - CustomerIO.initialize(config); + CustomerIO.initialize( + config.inApp + ? { + ...config, + inApp: { + ...config.inApp, + notificationInboxAccessibilityLabels: + INBOX_ACCESSIBILITY_LABELS, + }, + } + : config + ); }, onLogin: (user) => { console.log('Identifying user', user); diff --git a/example/src/screens/settings.tsx b/example/src/screens/settings.tsx index 0c1dd702..1d362be1 100644 --- a/example/src/screens/settings.tsx +++ b/example/src/screens/settings.tsx @@ -38,9 +38,7 @@ export const SettingsScreen = () => { /> { - // Spread the existing config so editing the site ID keeps the accessibility - // labels configured in the default config. - const inApp = { ...config.inApp, siteId: siteId }; + const inApp = { siteId: siteId }; setConfig({ ...config, inApp }); }} label="Site ID" @@ -98,14 +96,8 @@ 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 - ? { - ...Storage.instance.getDefaultCioConfig().inApp, - ...config.inApp, - siteId: config.inApp?.siteId ?? '', - } + ? { 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 d0c109fe..fb2ad6eb 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, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Linking, StyleSheet, Text, View } from 'react-native'; import { showMessage } from 'react-native-flash-message'; /** @@ -94,28 +94,6 @@ 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 @@ -149,14 +127,6 @@ 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 a18c51b1..7267a740 100644 --- a/example/src/services/storage.ts +++ b/example/src/services/storage.ts @@ -19,15 +19,6 @@ 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, @@ -76,25 +67,8 @@ 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. - 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 }, - } - : {}), - } + this.config = cioConfigJsonPayload + ? { ...Storage.defaultConfig, ...JSON.parse(cioConfigJsonPayload) } : null; }; 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 From d067ecbe84c83268c56c2d01c0eedc86f37e9dee Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Tue, 15 Sep 2026 16:19:15 +0400 Subject: [PATCH 5/5] Apply the inbox accessibility labels on the paths that actually initialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit put the labels in `onSetConfig`, describing it as the sample's single initialize() call. It is not called at all: the callback is defined, typed and given a no-op default, but no screen consumes it. The live paths are app start, which initializes from stored config, and the Settings save. Native initialize ignores every call after the first, so the app-start path won and the labels never reached the SDK — the sample demonstrated an unlabeled inbox on every launch. Moves the labels into a shared helper and applies it at every initialize call site, the unused callback included, so wiring that callback up later cannot silently lose them again. They stay out of the persisted, user-editable Settings config. Co-Authored-By: Claude Opus 5 --- example/src/App.tsx | 29 ++-------------- example/src/screens/settings.tsx | 5 ++- .../src/utils/inbox-accessibility-labels.ts | 33 +++++++++++++++++++ example/src/utils/index.ts | 1 + 4 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 example/src/utils/inbox-accessibility-labels.ts diff --git a/example/src/App.tsx b/example/src/App.tsx index 6e8d29fa..7c142ee2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -5,25 +5,13 @@ 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'; import { enableFreeze, enableScreens } from 'react-native-screens'; import { getEnvForApp } from './env'; -// The SDK ships no text of its own in the visual notification inbox, so these labels are the only -// strings it can announce. Applied here, at the single initialize() call, rather than stored with -// the editable Settings config: they are not something a tester should be able to edit away, and -// keeping them out of the persisted config means Settings needs no special handling for them. -// A real app would resolve these through its own i18n so they follow the user's language. -const INBOX_ACCESSIBILITY_LABELS = { - bell: 'Notifications', - bellWithUnreadCount: 'Notifications, {count} unread', - loadingIndicator: 'Loading inbox', - emptyState: 'No notifications', -}; - // Enable native screen optimizations for better performance enableScreens(true); // Better native performance enableFreeze(true); // Let tabs "freeze" when not focused @@ -71,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) => { @@ -144,18 +132,7 @@ export default function App({ appName }: { appName: string }) { value={{ onSetConfig: (config) => { console.log('Initializing CustomerIO with config', config); - CustomerIO.initialize( - config.inApp - ? { - ...config, - inApp: { - ...config.inApp, - notificationInboxAccessibilityLabels: - INBOX_ACCESSIBILITY_LABELS, - }, - } - : 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';