Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions __tests__/inbox-accessibility-labels.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
2 changes: 1 addition & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ customerio.reactnative.kotlinVersion=2.1.20
customerio.reactnative.compileSdkVersion=36
customerio.reactnative.targetSdkVersion=36
customerio.reactnative.minSdkVersion=21
customerio.reactnative.cioSDKVersionAndroid=4.20.2
customerio.reactnative.cioSDKVersionAndroid=4.21.1
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@ internal object Keys {
const val SCREEN_VIEW_USE = "screenViewUse"
const val API_HOST = "apiHost"
const val CDN_HOST = "cdnHost"
const val NOTIFICATION_INBOX_ACCESSIBILITY_LABELS = "notificationInboxAccessibilityLabels"
// Push messaging
const val PUSH_CLICK_BEHAVIOR = "pushClickBehavior"
}

/**
* Keys of the `notificationInboxAccessibilityLabels` sub-map.
*
* [BELL_WITH_UNREAD_COUNT] carries a template string containing [COUNT_PLACEHOLDER] rather than
* a callback, because wrapper configuration crosses a bridge that carries data but not
* functions. The native SDK takes a `(Int) -> String`, so the template is converted to one when
* the module is configured.
*/
object InboxAccessibilityLabels {
const val BELL = "bell"
const val BELL_WITH_UNREAD_COUNT = "bellWithUnreadCount"
const val LOADING_INDICATOR = "loadingIndicator"
const val EMPTY_STATE = "emptyState"
const val COUNT_PLACEHOLDER = "{count}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import io.customer.messaginginapp.di.inAppMessaging
import io.customer.messaginginapp.gist.data.model.InboxMessage
import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory
import io.customer.messaginginapp.inbox.NotificationInbox
import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels
import io.customer.reactnative.sdk.NativeCustomerIOMessagingInAppSpec
import io.customer.reactnative.sdk.constant.Keys
import io.customer.reactnative.sdk.extension.getTypedValue
Expand Down Expand Up @@ -246,9 +247,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<String, Any>
): NotificationInboxAccessibilityLabels? {
val labels = config.getTypedValue<Map<String, Any>>(
Keys.Config.NOTIFICATION_INBOX_ACCESSIBILITY_LABELS
) ?: return null

val unreadCountTemplate = labels.getTypedValue<String>(
Keys.InboxAccessibilityLabels.BELL_WITH_UNREAD_COUNT
)
return NotificationInboxAccessibilityLabels(
bell = labels.getTypedValue<String>(Keys.InboxAccessibilityLabels.BELL),
bellWithUnreadCount = unreadCountTemplate?.let { template ->
{ count: Int ->
template.replace(
Keys.InboxAccessibilityLabels.COUNT_PLACEHOLDER,
count.toString()
)
}
},
loadingIndicator = labels.getTypedValue<String>(
Keys.InboxAccessibilityLabels.LOADING_INDICATOR
),
emptyState = labels.getTypedValue<String>(
Keys.InboxAccessibilityLabels.EMPTY_STATE
)
)
}
}
}
9 changes: 9 additions & 0 deletions api-extractor-output/customerio-reactnative.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type CioConfig = {
autoTrackDeviceAttributes?: boolean;
inApp?: {
siteId: string;
notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels;
};
push?: {
android?: {
Expand Down Expand Up @@ -351,6 +352,14 @@ export class NotificationInbox implements NotificationInboxPublicSpec {
trackMessageClicked(message: InboxMessage, actionName?: string): void;
}

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

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

Expand Down
6 changes: 3 additions & 3 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion example/src/screens/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TextField,
} from '@components';
import { Storage } from '@services';
import { withInboxAccessibilityLabels } from '@utils';
import {
CioConfig,
CioLocationTrackingMode,
Expand Down Expand Up @@ -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',
Expand Down
33 changes: 33 additions & 0 deletions example/src/utils/inbox-accessibility-labels.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions example/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './app-theming';
export * from './data-types';
export * from './random-users';
export * from './inbox-accessibility-labels';
Loading
Loading