diff --git a/__tests__/in-app-color-scheme.test.ts b/__tests__/in-app-color-scheme.test.ts new file mode 100644 index 00000000..da0a51f5 --- /dev/null +++ b/__tests__/in-app-color-scheme.test.ts @@ -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(); + }); + }); +}); 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 d32c6e61..9a7741b9 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 @@ -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" } 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 43d0f2bf..eb9917c7 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.ColorScheme import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels import io.customer.reactnative.sdk.NativeCustomerIOMessagingInAppSpec import io.customer.reactnative.sdk.constant.Keys @@ -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() } @@ -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) } @@ -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): 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(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 diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index 44278cce..b8c754fd 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -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; @@ -30,6 +37,7 @@ export type CioConfig = { autoTrackDeviceAttributes?: boolean; inApp?: { siteId: string; + colorScheme?: CioColorScheme; notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels; }; push?: { @@ -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 diff --git a/ios/wrappers/inapp/NativeMessagingInApp.mm b/ios/wrappers/inapp/NativeMessagingInApp.mm index f9830652..28a471a8 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.mm +++ b/ios/wrappers/inapp/NativeMessagingInApp.mm @@ -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]; diff --git a/ios/wrappers/inapp/NativeMessagingInApp.swift b/ios/wrappers/inapp/NativeMessagingInApp.swift index d81f766d..73a9aba8 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.swift +++ b/ios/wrappers/inapp/NativeMessagingInApp.swift @@ -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) diff --git a/package.json b/package.json index 288db38a..19c36040 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "./package.json": "./package.json" }, - "cioNativeiOSSdkVersion": "= 4.8.0", + "cioNativeiOSSdkVersion": "= 4.8.1", "cioiOSFirebaseWrapperSdkVersion": "= 1.0.0", "files": [ "src", diff --git a/src/customerio-inapp.ts b/src/customerio-inapp.ts index aec6d8e4..79834117 100644 --- a/src/customerio-inapp.ts +++ b/src/customerio-inapp.ts @@ -9,9 +9,10 @@ import { import NativeCustomerIOMessagingInApp, { type Spec as CodegenSpec, } from './specs/modules/NativeCustomerIOMessagingInApp'; -import type { InAppMessageEventType } from './types'; +import type { CioColorScheme, InAppMessageEventType } from './types'; import { InboxEventType, InboxMessageEvent } from './types'; import { callNativeModule, ensureNativeModule } from './utils/native-bridge'; +import { assert } from './utils/param-validation'; /** * Ensures all methods defined in codegen spec are implemented by the public module @@ -180,6 +181,23 @@ class CustomerIOInAppMessaging implements NativeInAppSpec { withNativeModule((native) => native.dismissMessage()); } + /** + * Overrides the color scheme used to render in-app messages. + * + * Takes effect immediately: messages already on screen — inline views included — are + * re-themed in place, so this can be called whenever the app's appearance setting + * changes rather than only before a message is shown. + * + * @param colorScheme - scheme to render with; `CioColorScheme.Auto` returns to following + * the device appearance + */ + setColorScheme(colorScheme: CioColorScheme) { + // Validated here as well as in `initialize`: this is a public method reachable from + // untyped JavaScript, and without it a bad value reaches the native fallback silently. + assert.colorScheme(colorScheme); + withNativeModule((native) => native.setColorScheme(colorScheme)); + } + /** * Gets the message inbox instance for managing inbox messages * diff --git a/src/specs/modules/NativeCustomerIOMessagingInApp.ts b/src/specs/modules/NativeCustomerIOMessagingInApp.ts index ec21a23b..d663d5f1 100644 --- a/src/specs/modules/NativeCustomerIOMessagingInApp.ts +++ b/src/specs/modules/NativeCustomerIOMessagingInApp.ts @@ -14,6 +14,8 @@ import type { /** TurboModule interface for CustomerIO In-App Messaging native operations */ export interface Spec extends TurboModule { dismissMessage(): void; + // Carries the CioColorScheme string value; the enum itself cannot cross Codegen. + setColorScheme(colorScheme: string): void; readonly onInAppEventReceived: EventEmitter; // Notification Inbox event listener methods. // Registers/unregisters a native forwarder with the SDK so inbox events diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index c64fe5f1..921cb9ae 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -1,3 +1,4 @@ +import type { CioColorScheme } from './in-app'; import type { NotificationInboxAccessibilityLabels } from './inbox'; import type { LiveActivitiesConfig } from './live-activities'; import type { PushClickBehaviorAndroid } from './push'; @@ -68,6 +69,14 @@ export type CioConfig = { autoTrackDeviceAttributes?: boolean; inApp?: { siteId: string; + /** + * Color scheme used to render in-app messages. Defaults to `CioColorScheme.Auto`, which + * follows the device appearance. Set it when the app has its own appearance setting that + * can disagree with the operating system. + * + * Can be changed after initialization with `CustomerIO.inAppMessaging.setColorScheme`. + */ + colorScheme?: CioColorScheme; /** * Accessibility labels for the Visual Notification Inbox. Optional; an omitted label leaves * that element unlabeled rather than falling back to English. diff --git a/src/types/in-app.ts b/src/types/in-app.ts index 3ffc3581..6ec560ec 100644 --- a/src/types/in-app.ts +++ b/src/types/in-app.ts @@ -27,6 +27,29 @@ export enum InAppMessageEventType { messageShown = 'messageShown', } +/** + * Color scheme used to render in-app messages. + * + * Selects which of the light/dark variants authored in the Customer.io editor is + * rendered. Use it when the app has its own appearance setting that can disagree with + * the operating system: `Auto` follows the device, while `Light` and `Dark` pin the + * variant regardless of it. + * + * The string values are the wire contract shared with both native SDKs, which match + * them lowercase and resolve anything unrecognized to `auto` — so they must stay + * exactly as written here even if the members are renamed. + * + * @public + */ +export enum CioColorScheme { + /** Follow the device's current appearance. The default when unset. */ + Auto = 'auto', + /** Always render the light variant, whatever the device is set to. */ + Light = 'light', + /** Always render the dark variant, whatever the device is set to. */ + Dark = 'dark', +} + /** * Represents an inbox message for a user. * diff --git a/src/utils/param-validation.ts b/src/utils/param-validation.ts index 8819738e..8d03ef70 100644 --- a/src/utils/param-validation.ts +++ b/src/utils/param-validation.ts @@ -1,6 +1,7 @@ // Argument validation utilities for SDK internal use // Ensures input safety and throws clear errors when validation fails +import { CioColorScheme } from '../types'; import type { CioConfig, CustomAttributes } from '../types'; /** @@ -178,6 +179,61 @@ function validateInboxAccessibilityLabels(value: unknown): void { ); } +/** + * Warns when a value is not one of the `CioColorScheme` values. + * + * TypeScript rejects a bad value already, but JavaScript callers reach both entry points + * untyped, and neither native layer reports the mistake at the default log level. The + * failure mode is a message rendered in the wrong theme with no error attached, so + * JavaScript is the only place the developer reliably sees it. + * + * `consequence` differs per entry point because the native fallback does: an unrecognized + * value at initialization resolves to `auto`, while the runtime setter leaves whatever + * scheme the app already chose in place. + */ +function warnIfNotColorScheme( + value: unknown, + fieldName: string, + consequence: string +): void { + const allowed: string[] = Object.values(CioColorScheme); + warnIf( + !(typeof value === 'string' && allowed.includes(value)), + () => + `"${fieldName}" is not a valid CioColorScheme (expected one of ` + + `${allowed.join(', ')}), so ${consequence}.` + ); +} + +/** + * Validates `inApp.colorScheme`. An absent value is legitimate here — the native SDKs + * already default to `auto` — so only a value the host actually set is checked. + */ +function validateConfigColorScheme(value: unknown): void { + if (isUndefined(value)) { + return; + } + + warnIfNotColorScheme( + value, + 'inApp.colorScheme', + 'in-app messages will follow the device appearance' + ); +} + +/** + * Validates the argument to `setColorScheme`. Unlike the config field, an absent value is + * itself the mistake: the caller asked for a scheme change and named none, and there is no + * default to fall back to, so null and undefined are reported rather than skipped. + */ +function validateColorSchemeArgument(value: unknown): void { + warnIfNotColorScheme( + value, + 'colorScheme', + 'the color scheme will be left unchanged' + ); +} + /** * Validates that the given value is a valid CioConfig object. * Throws if required fields are missing or incorrectly typed. @@ -196,6 +252,7 @@ function validateConfig(value: unknown): asserts value is CioConfig { allowEmpty: false, usage: usage, }); + validateConfigColorScheme(obj.inApp?.colorScheme); validateInboxAccessibilityLabels( obj.inApp?.notificationInboxAccessibilityLabels ); @@ -210,11 +267,13 @@ export const assert: { record: typeof validateRecord; attributes: typeof validateAttributes; config: ConfigValidator; + colorScheme: typeof validateColorSchemeArgument; } = { string: validateString, record: validateRecord, attributes: validateAttributes, config: validateConfig, + colorScheme: validateColorSchemeArgument, }; export const validate = {