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
181 changes: 181 additions & 0 deletions __tests__/in-app-color-scheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Covers the two things the JavaScript layer actually owns for the in-app color scheme override:
* the wire value, and the runtime setter reaching the native module.
*
* The native SDKs do the real work — each resolves the scheme and re-themes messages already on
* screen, inline views included. What only JavaScript can get wrong is the wire value: both
* native layers match `auto`/`light`/`dark` lowercase, and iOS's config parser resolves anything
* else to `.auto`, so a re-cased or renamed value would silently render the device's theme
* instead of the one the app asked for. That is a styling bug with no error attached, which is
* why the serialized values are pinned here.
*
* Deliberately NOT covered: the `initialize` path. `CustomerIO.initialize` forwards the config
* object verbatim, so asserting `colorScheme` on the forwarded payload only re-reads the literal
* the test itself built — it would stay green if the Android config key or either native mapper
* broke. The integration points that can actually break are `Keys.Config.COLOR_SCHEME` and
* `colorSchemeFromRawValue` on Android and `colorScheme(fromRawValue:)` on iOS; guarding those
* needs a test on the native side of each bridge, which this package has no harness for.
*
* `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)) },
}));

jest.mock('../src/specs/modules/NativeCustomerIOMessagingInApp', () => ({
__esModule: true,
default: { setColorScheme: jest.fn() },
}));

import { CustomerIO } from '../src/customerio-cdp';
import NativeModule from '../src/specs/modules/NativeCustomerIO';
import NativeInAppModule from '../src/specs/modules/NativeCustomerIOMessagingInApp';
import { CioColorScheme, type CioConfig } from '../src/types';

const nativeInitialize = NativeModule.initialize as jest.Mock;
const nativeSetColorScheme = NativeInAppModule.setColorScheme as jest.Mock;

const configWith = (inApp: CioConfig['inApp']): CioConfig =>
({ cdpApiKey: 'test-key', inApp }) as CioConfig;

describe('in-app color scheme', () => {
beforeEach(() => {
nativeInitialize.mockClear();
nativeSetColorScheme.mockClear();
});

describe('wire values', () => {
// Both native layers match these lowercase and treat anything else as `auto`. Renaming a
// member is safe; changing one of these strings silently breaks the override on both
// platforms, so they are asserted literally rather than through the enum.
it('serializes every member to the value both native SDKs match', () => {
expect(CioColorScheme.Auto).toBe('auto');
expect(CioColorScheme.Light).toBe('light');
expect(CioColorScheme.Dark).toBe('dark');
});
});

describe('setColorScheme', () => {
it('sends the scheme to the native module as its wire value', () => {
CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Light);

expect(nativeSetColorScheme).toHaveBeenCalledWith('light');
});

it('can return to following the device appearance', () => {
CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Auto);

expect(nativeSetColorScheme).toHaveBeenCalledWith('auto');
});

// The setter is public and reachable from untyped JavaScript, so it validates too. Its
// fallback differs from the config path's — native leaves the current scheme in place
// rather than dropping to `auto` — so the warning has to say something different.
describe('invalid argument', () => {
let warn: jest.SpyInstance;

beforeEach(() => {
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warn.mockRestore();
});

it('warns and says the scheme is left unchanged', () => {
CustomerIO.inAppMessaging.setColorScheme(
'DARK' as unknown as CioColorScheme
);

expect(warn).toHaveBeenCalledWith(
expect.stringContaining('left unchanged')
);
});

it('warns when no scheme is given at all', () => {
// Absent is the mistake here, unlike in the config, where it just means "use the
// native default" — so null must not be skipped the way the config path skips it.
CustomerIO.inAppMessaging.setColorScheme(
null as unknown as CioColorScheme
);

expect(warn).toHaveBeenCalledWith(
expect.stringContaining('"colorScheme"')
);
});

it('stays quiet for a valid scheme', () => {
CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Dark);

expect(warn).not.toHaveBeenCalled();
});
});
});

// TypeScript rejects a bad value, but JavaScript callers reach this untyped. Neither native
// layer can report it usefully, so the warning is raised here.
describe('invalid value warning', () => {
let warn: jest.SpyInstance;

beforeEach(() => {
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warn.mockRestore();
});

it('warns when the value is not a CioColorScheme', async () => {
await CustomerIO.initialize(
configWith({
siteId: 'site',
colorScheme: 'DARK' as unknown as CioColorScheme,
})
);

expect(warn).toHaveBeenCalledWith(
expect.stringContaining('"inApp.colorScheme"')
);
});

it('stays quiet for a valid scheme', async () => {
await CustomerIO.initialize(
configWith({ siteId: 'site', colorScheme: CioColorScheme.Dark })
);

expect(warn).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal object Keys {
const val API_HOST = "apiHost"
const val CDN_HOST = "cdnHost"
const val NOTIFICATION_INBOX_ACCESSIBILITY_LABELS = "notificationInboxAccessibilityLabels"
const val COLOR_SCHEME = "colorScheme"
// Push messaging
const val PUSH_CLICK_BEHAVIOR = "pushClickBehavior"
}
Expand Down
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.ColorScheme
import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels
import io.customer.reactnative.sdk.NativeCustomerIOMessagingInAppSpec
import io.customer.reactnative.sdk.constant.Keys
Expand Down Expand Up @@ -73,6 +74,35 @@ class NativeMessagingInAppModule(
inAppMessagingModule?.dismissMessage()
}

// Nullable like every other argument-taking override here, and for the same reason: Codegen
// declares the parameter non-null, so a non-null Kotlin type would throw
// `Intrinsics.checkNotNullParameter` before the body runs when JavaScript passes null — which
// is what an untyped caller reading a stored theme sends on first launch.
override fun setColorScheme(colorScheme: String?) {
val resolved = colorSchemeFromRawValue(colorScheme)
if (resolved == null) {
// Leave the current scheme alone rather than resetting it to AUTO, so a bad value
// cannot quietly undo a scheme the app set correctly earlier.
logger.error(
"Unrecognized in-app colorScheme '$colorScheme', expected one of auto, light, " +
"dark. Leaving the color scheme unchanged."
)
return
}
val module = inAppMessagingModule
if (module == null) {
// Reachable when the host calls this before CustomerIO.initialize, or without the
// in-app module configured. Logged rather than ignored: the scheme is silently not
// applied, and nothing else surfaces that.
logger.error(
"In-app messaging is not available, so the color scheme was not applied. " +
"Ensure CustomerIO SDK is initialized with the inApp configuration."
)
return
}
module.setColorScheme(resolved)
}

override fun setupInboxListener() {
setupInboxChangeListener()
}
Expand Down Expand Up @@ -247,6 +277,9 @@ class NativeMessagingInAppModule(
val module = ModuleMessagingInApp(
MessagingInAppModuleConfig.Builder(siteId = siteId, region = region).apply {
setEventListener(eventListener = ReactInAppEventListener.instance)
colorSchemeFromConfig(config)?.let { colorScheme ->
setColorScheme(colorScheme)
}
inboxAccessibilityLabelsFromConfig(config)?.let { labels ->
setNotificationInboxAccessibilityLabels(labels)
}
Expand All @@ -255,6 +288,38 @@ class NativeMessagingInAppModule(
builder.addCustomerIOModule(module)
}

/**
* Maps the wrapper's `colorScheme` value onto the native [ColorScheme], or null when the
* value is absent or unrecognized.
*
* The accepted values are lowercase because that is the wire contract the JavaScript
* `CioColorScheme` enum serializes to and the one iOS already matches. Kept free of
* logging so each caller can report a bad value in its own terms: at initialization an
* unrecognized value falls back to AUTO, while the runtime setter leaves the scheme the
* app already chose untouched.
*/
internal fun colorSchemeFromRawValue(rawValue: String?): ColorScheme? = when (rawValue) {
"auto" -> ColorScheme.AUTO
"light" -> ColorScheme.LIGHT
"dark" -> ColorScheme.DARK
else -> null
}

private fun colorSchemeFromConfig(config: Map<String, Any>): ColorScheme? {
// Absent is not a mistake — the SDK's own AUTO default stands — so only a value the
// host actually provided is worth reporting.
val rawValue = config.getTypedValue<String>(Keys.Config.COLOR_SCHEME) ?: return null

return colorSchemeFromRawValue(rawValue).also { resolved ->
if (resolved == null) {
SDKComponent.logger.error(
"Unrecognized in-app colorScheme '$rawValue', expected one of auto, " +
"light, dark. Falling back to the device appearance."
)
}
}
}

/**
* 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
Expand Down
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 @@ -15,6 +15,13 @@ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes';
import type { ViewProps } from 'react-native';
import { ViewStyle } from 'react-native';

// @public
export enum CioColorScheme {
Auto = "auto",
Dark = "dark",
Light = "light"
}

// @public
export type CioConfig = {
cdpApiKey: string;
Expand All @@ -30,6 +37,7 @@ export type CioConfig = {
autoTrackDeviceAttributes?: boolean;
inApp?: {
siteId: string;
colorScheme?: CioColorScheme;
notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels;
};
push?: {
Expand Down Expand Up @@ -144,6 +152,7 @@ export class CustomerIOInAppMessaging implements NativeInAppSpec {
// (undocumented)
registerEventsListener(listener: (event: InAppMessageEvent) => void): EventSubscription;
registerInboxEventListener(listener: (event: InboxMessageEvent) => void): EventSubscription;
setColorScheme(colorScheme: CioColorScheme): void;
}

// Warning: (ae-forgotten-export) The symbol "NativeLiveActivitiesSpec" needs to be exported by the entry point index.d.ts
Expand Down
8 changes: 8 additions & 0 deletions ios/wrappers/inapp/NativeMessagingInApp.mm
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ - (void)dismissMessage {
[_swiftBridge dismissMessage];
}

// Signature matches the Codegen protocol, which declares the argument nonnull. ObjC does not
// enforce that at runtime, so a JavaScript null still arrives here as nil and is forwarded; the
// Swift side takes an optional and reports it rather than trapping.
- (void)setColorScheme:(NSString *)colorScheme {
[self assertBridgeAvailable:@"during setColorScheme"];
[_swiftBridge setColorScheme:colorScheme];
}

- (void)setupInboxListener {
[self assertBridgeAvailable:@"during setupInboxListener"];
[_swiftBridge setupInboxListener];
Expand Down
48 changes: 48 additions & 0 deletions ios/wrappers/inapp/NativeMessagingInApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,54 @@ public class NativeMessagingInApp: NSObject {
MessagingInApp.shared.dismissMessage()
}

/// Overrides the color scheme used to render in-app messages.
///
/// Receives the raw value of the JavaScript `CioColorScheme`, since the enum itself cannot
/// cross Codegen. An unrecognized value leaves the current scheme alone instead of resetting
/// it to `.auto`, so a typo cannot quietly undo a scheme the app set correctly earlier — the
/// Android bridge behaves the same way.
///
/// The parameter is optional even though Codegen declares it non-null: untyped JavaScript can
/// still pass `null` or `undefined`, which the bridge forwards as `nil`. Bridging that into a
/// non-optional `String` would trap before the value could be reported, and Android accepts a
/// nullable argument for the same reason, so a non-optional type here would also leave the two
/// platforms behaving differently on the same input.
@objc(setColorScheme:)
public func setColorScheme(_ colorScheme: String?) {
guard let resolved = Self.colorScheme(fromRawValue: colorScheme) else {
logger.error(
"Unrecognized in-app colorScheme '\(colorScheme ?? "nil")', expected one of auto, light, dark. Leaving the color scheme unchanged."
)
return
}
// Without this the call is dropped in silence: `setColorScheme` forwards through the
// module's `implementation?`, which is nil until the SDK is initialized, and the only
// trace is an `.info` line the default `.error` log level discards. Android logs this
// case at error, so reporting it here is what keeps the two platforms diagnosable in the
// same way. Logged rather than failed, again to match Android, which completes the call.
guard MessagingInApp.shared.hasBeenInitialized else {
logger.error(
"In-app messaging is not available, so the color scheme was not applied. Ensure CustomerIO SDK is initialized with the inApp configuration."
)
return
}
MessagingInApp.shared.setColorScheme(resolved)
}

/// Maps the wrapper's lowercase wire value onto the native `ColorScheme`.
///
/// Matched explicitly rather than handed to `MessagingInAppConfigBuilder`, which resolves
/// anything unrecognized to `.auto`; here an unrecognized value has to stay distinguishable
/// so it can be reported.
private static func colorScheme(fromRawValue rawValue: String?) -> ColorScheme? {
switch rawValue {
case "auto": return .auto
case "light": return .light
case "dark": return .dark
default: return nil
}
}

// MARK: - Inbox Methods

@objc(setupInboxListener)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"./package.json": "./package.json"
},
"cioNativeiOSSdkVersion": "= 4.8.0",
"cioNativeiOSSdkVersion": "= 4.8.1",
"cioiOSFirebaseWrapperSdkVersion": "= 1.0.0",
"files": [
"src",
Expand Down
Loading
Loading