From 4308bb19335b623cd51eb11f14e06c182576b0c3 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sat, 12 Sep 2026 00:45:12 +0400 Subject: [PATCH 1/2] 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/2] 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. */