diff --git a/CarillonReactNative.podspec b/CarillonReactNative.podspec index 47e3254..cd3c9c7 100644 --- a/CarillonReactNative.podspec +++ b/CarillonReactNative.podspec @@ -38,7 +38,7 @@ Pod::Spec.new do |s| if carillon_swift.empty? spm_dependency(s, url: "https://github.com/exostack/carillon-swift.git", - requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.3.0" }, + requirement: { kind: "upToNextMajorVersion", minimumVersion: "0.4.0" }, products: ["Carillon"]) else spm_dependency(s, diff --git a/README.md b/README.md index 8efba40..2c91bbb 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ await Carillon.getPermission(); // same values, never prompts if (!(await Carillon.canRequestPermission())) Carillon.openNotificationSettings(); Carillon.identify('user-42'); // null forgets the identifier -Carillon.setTags({ plan: 'pro', seats: 12 }); // merged; omitted keys are unchanged +Carillon.setTag('plan', 'pro'); +Carillon.setTagNumber('seats', 12); Carillon.setTag('language', 'fr'); -Carillon.setTags({ seats: null }); +Carillon.removeTagNumber('seats'); Carillon.removeTag('language'); Carillon.optOut(); Carillon.optIn(); @@ -422,3 +423,18 @@ restore those values automatically. Strings containing JSON text remain strings. Read decoded values from `notification.data` in `onReceived` and `notification.payload` in `onOpened`. On Android this requires native SDK 0.3.0 or later. + + +## Typed tags + +Use `setTag` for strings, `setTagNumber` for finite numbers, `setTagBoolean` for +booleans, and `setTagDate` for native dates (Swift `Date`, Kotlin `java.util.Date`, +JavaScript `Date`). Dates are serialized as UTC ISO timestamps. `setTags` accepts +string values or null removals only. + +Each type has its own namespace: the same key may exist independently in several +types. Remove values with `removeTag`, `removeTagNumber`, `removeTagBoolean`, or +`removeTagDate`. Pending writes and removals survive restarts and retries. + +See the [tags guide](https://carillon.dev/docs/concepts/tags) for API formats, +shared user profiles, audience filters, limits and examples. diff --git a/android/build.gradle b/android/build.gradle index 2240a1f..f2511bb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { // The Android SDK this bridge binds. A published coordinate, resolved from // Maven Central once carillon-kotlin is there; see the README for what // resolves it today. - sdkVersion: "0.3.0" + sdkVersion: "0.4.0" ] ext.getExtOrDefault = { prop -> diff --git a/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt b/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt index 7191a2d..06c63e3 100644 --- a/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt +++ b/android/src/main/java/dev/carillon/reactnative/CarillonModule.kt @@ -13,8 +13,6 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import android.os.Handler import android.os.Looper -import dev.carillon.sdk.TagValue -import dev.carillon.sdk.tagOf import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -111,6 +109,13 @@ class CarillonModule(private val context: ReactApplicationContext) : override fun setTags(tags: ReadableMap) = Carillon.setTags(tagsOf(tags)) + override fun setTagNumber(name: String, value: Double) = Carillon.setTagNumber(name, value) + override fun setTagBoolean(name: String, value: Boolean) = Carillon.setTagBoolean(name, value) + override fun setTagDate(name: String, milliseconds: Double) = Carillon.setTagDate(name, java.util.Date(milliseconds.toLong())) + override fun removeTagNumber(name: String) = Carillon.removeTagNumber(name) + override fun removeTagBoolean(name: String) = Carillon.removeTagBoolean(name) + override fun removeTagDate(name: String) = Carillon.removeTagDate(name) + override fun optIn() = Carillon.optIn() override fun optOut() = Carillon.optOut() @@ -188,39 +193,9 @@ class CarillonModule(private val context: ReactApplicationContext) : } } - /** - * A JavaScript object, as the SDK's sealed tag type. - * - * Anything that is not a flat scalar is dropped rather than refused: the - * TypeScript surface is where a tag's shape is rejected, and it is rejected - * there before the call is written rather than after it has shipped. - */ - private fun tagsOf(tags: ReadableMap): Map { - val result = LinkedHashMap() - val names = tags.keySetIterator() - - while (names.hasNextKey()) { - val name = names.nextKey() - - when (tags.getType(name)) { - ReadableType.Null -> result[name] = null - ReadableType.String -> tags.getString(name)?.let { result[name] = tagOf(it) } - ReadableType.Boolean -> result[name] = tagOf(tags.getBoolean(name)) - ReadableType.Number -> { - // A whole number arrives from JavaScript as a double and would - // otherwise be sent as `12.0`. The SDK draws the same distinction - // when it restores a stored map; drawing it here means a tag survives - // a round trip looking like what the customer wrote. - val value = tags.getDouble(name) - result[name] = - if (value == Math.floor(value) && !value.isInfinite()) tagOf(value.toLong()) - else tagOf(value) - } - else -> Unit - } - } - - return result + private fun tagsOf(tags: ReadableMap): Map = tags.toHashMap().mapValues { (_, value) -> + require(value == null || value is String) { "Text tags require strings" } + value as String? } /** diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9e8f870..e174d69 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -21,7 +21,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CarillonReactNative (0.3.0): + - CarillonReactNative (0.4.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2026,7 +2026,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c - CarillonReactNative: ef7c0bf23bdb1e8c778bbbeab6dd9946d15977b1 + CarillonReactNative: 918768632843ae83cb8f0adbd0edfd67993f3877 FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b hermes-engine: e7f572408afef1c5ca0f7169418aab10c6c28fc3 RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 diff --git a/ios/Carillon.mm b/ios/Carillon.mm index 0205543..f4c426a 100644 --- a/ios/Carillon.mm +++ b/ios/Carillon.mm @@ -79,6 +79,36 @@ - (void)setTags:(NSDictionary *)tags [CarillonBridge setTags:tags]; } +- (void)setTagNumber:(NSString *)name value:(double)value +{ + [CarillonBridge setTagNumber:name value:value]; +} + +- (void)removeTagNumber:(NSString *)name +{ + [CarillonBridge removeTagNumber:name]; +} + +- (void)setTagBoolean:(NSString *)name value:(BOOL)value +{ + [CarillonBridge setTagBoolean:name value:value]; +} + +- (void)removeTagBoolean:(NSString *)name +{ + [CarillonBridge removeTagBoolean:name]; +} + +- (void)setTagDate:(NSString *)name milliseconds:(double)milliseconds +{ + [CarillonBridge setTagDate:name milliseconds:milliseconds]; +} + +- (void)removeTagDate:(NSString *)name +{ + [CarillonBridge removeTagDate:name]; +} + - (void)optIn { [CarillonBridge optIn]; diff --git a/ios/CarillonBridge.swift b/ios/CarillonBridge.swift index 6ef7e25..814d57b 100644 --- a/ios/CarillonBridge.swift +++ b/ios/CarillonBridge.swift @@ -50,14 +50,21 @@ public final class CarillonBridge: NSObject { @objc public static func setTags(_ tags: [String: Any]) { - var patch: [String: TagValue?] = [:] + var patch: [String: String?] = [:] for (name, value) in tags { if value is NSNull { patch.updateValue(nil, forKey: name) } - else if let tag = tagValue(of: value) { patch.updateValue(tag, forKey: name) } + else if let tag = value as? String { patch.updateValue(tag, forKey: name) } } Carillon.setTags(patch) } + @objc public static func setTagNumber(_ name: String, value: Double) { Carillon.setTagNumber(name, value) } + @objc public static func setTagBoolean(_ name: String, value: Bool) { Carillon.setTagBoolean(name, value) } + @objc public static func setTagDate(_ name: String, milliseconds: Double) { Carillon.setTagDate(name, Date(timeIntervalSince1970: milliseconds / 1000)) } + @objc public static func removeTagNumber(_ name: String) { Carillon.removeTagNumber(name) } + @objc public static func removeTagBoolean(_ name: String) { Carillon.removeTagBoolean(name) } + @objc public static func removeTagDate(_ name: String) { Carillon.removeTagDate(name) } + @objc public static func optIn() { Carillon.optIn() @@ -197,27 +204,6 @@ public final class CarillonBridge: NSObject { Carillon.didOpen(userInfo: userInfo) } - /// Converts scalar JavaScript tag values. Unsupported values are omitted. - private static func tagValue(of value: Any) -> TagValue? { - if let text = value as? String { return .string(text) } - - guard let number = value as? NSNumber else { return nil } - - // JavaScript's booleans and numbers are both NSNumber by the time they get - // here, and only the type id tells them apart. - if CFGetTypeID(number) == CFBooleanGetTypeID() { return .bool(number.boolValue) } - - let value = number.doubleValue - - // A whole number arrives from JavaScript as a double and would otherwise be - // sent as `12.0`. The SDK draws the same distinction when it restores a - // stored map; drawing it here means a tag survives a round trip looking - // like what the customer wrote. - guard value == value.rounded(), value.magnitude < 9e15 else { return .double(value) } - - return .int(Int(value)) - } - /// UTC timestamp format exposed to JavaScript, including milliseconds. private static let instantFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/package.json b/package.json index e55ef6b..ec5a5f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@exostack/carillon-react-native", - "version": "0.3.0", + "version": "0.4.0", "description": "React Native bridge over the Carillon native SDKs.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/NativeCarillon.ts b/src/NativeCarillon.ts index d15596b..039dc10 100644 --- a/src/NativeCarillon.ts +++ b/src/NativeCarillon.ts @@ -32,6 +32,12 @@ export interface Spec extends TurboModule { identify(externalId: string): void; clearIdentity(): void; setTags(tags: CodegenTypes.UnsafeObject): void; + setTagNumber(name: string, value: number): void; + setTagBoolean(name: string, value: boolean): void; + setTagDate(name: string, milliseconds: number): void; + removeTagNumber(name: string): void; + removeTagBoolean(name: string): void; + removeTagDate(name: string): void; optIn(): void; optOut(): void; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index cccde6e..2d81c55 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -40,6 +40,12 @@ const mockNative = { identify: jest.fn(), clearIdentity: jest.fn(), setTags: jest.fn(), + setTagDate: jest.fn(), + setTagNumber: jest.fn(), + setTagBoolean: jest.fn(), + removeTagDate: jest.fn(), + removeTagNumber: jest.fn(), + removeTagBoolean: jest.fn(), optIn: jest.fn(), optOut: jest.fn(), debugInfo: jest.fn(async () => ({ sdk_version: '0.1.0' })), @@ -187,12 +193,11 @@ describe('identify', () => { describe('the state the app sets', () => { it('sends the tag map whole', () => { - Carillon.setTags({ plan: 'pro', seats: 12, trial: false }); + Carillon.setTags({ plan: 'pro', locale: 'fr' }); expect(mockNative.setTags).toHaveBeenCalledWith({ plan: 'pro', - seats: 12, - trial: false, + locale: 'fr', }); }); @@ -332,6 +337,36 @@ it('merges one tag and forwards explicit removals', () => { expect(mockNative.setTags).toHaveBeenLastCalledWith({ plan: 'pro' }); Carillon.removeTag('plan'); expect(mockNative.setTags).toHaveBeenLastCalledWith({ plan: null }); - Carillon.setTags({ beta: null, seats: 3 }); - expect(mockNative.setTags).toHaveBeenLastCalledWith({ beta: null, seats: 3 }); + Carillon.setTags({ beta: null, plan: 'pro' }); + expect(mockNative.setTags).toHaveBeenLastCalledWith({ + beta: null, + plan: 'pro', + }); +}); + +it('routes explicit tag types and rejects invalid JavaScript values', () => { + const date = new Date('2026-10-01T12:00:00Z'); + Carillon.setTag('same', 'text'); + Carillon.setTagNumber('same', 12); + Carillon.setTagBoolean('same', false); + Carillon.setTagDate('same', date); + expect(mockNative.setTags).toHaveBeenCalledWith({ same: 'text' }); + expect(mockNative.setTagNumber).toHaveBeenCalledWith('same', 12); + expect(mockNative.setTagBoolean).toHaveBeenCalledWith('same', false); + expect(mockNative.setTagDate).toHaveBeenCalledWith('same', date.getTime()); + Carillon.removeTagDate('same'); + Carillon.removeTagNumber('same'); + Carillon.removeTagBoolean('same'); + expect(mockNative.removeTagDate).toHaveBeenCalledWith('same'); + expect(mockNative.removeTagNumber).toHaveBeenCalledWith('same'); + expect(mockNative.removeTagBoolean).toHaveBeenCalledWith('same'); + expect(() => Carillon.setTagNumber('x', NaN)).toThrow(TypeError); + expect(() => Carillon.setTagNumber('x', Infinity)).toThrow(TypeError); + expect(() => Carillon.setTagDate('x', new Date('invalid'))).toThrow( + TypeError, + ); + // @ts-expect-error Runtime checks also protect plain JavaScript callers. + expect(() => Carillon.setTag('x', 12)).toThrow(TypeError); + // @ts-expect-error No coercion from strings. + expect(() => Carillon.setTagBoolean('x', 'false')).toThrow(TypeError); }); diff --git a/src/index.tsx b/src/index.tsx index 0af339b..82688cd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,15 +26,12 @@ export type CarillonOptions = { * iOS also supports provisional (quiet delivery) and undetermined (not yet requested). */ export type PushPermission = - | 'allowed' - | 'denied' - | 'provisional' - | 'undetermined'; + 'allowed' | 'denied' | 'provisional' | 'undetermined'; /** * Device tag value. Nested values are unsupported. */ -export type TagValue = string | number | boolean; +export type TagValue = string; /** * Opened notification with its delivery id, original payload, and tap time. @@ -68,11 +65,19 @@ export function onReceived( ): () => void { removeReceived?.(); const subscription = NativeCarillon.onReceived((event) => { - const { requestId, ...notification } = event as ReceivedNotification & { requestId: string }; - Promise.resolve().then(() => handler(notification)).then( - (decision) => NativeCarillon.finishReceived(requestId, decision === 'suppress' ? 'suppress' : 'show'), - () => NativeCarillon.finishReceived(requestId, 'show') - ); + const { requestId, ...notification } = event as ReceivedNotification & { + requestId: string; + }; + Promise.resolve() + .then(() => handler(notification)) + .then( + (decision) => + NativeCarillon.finishReceived( + requestId, + decision === 'suppress' ? 'suppress' : 'show', + ), + () => NativeCarillon.finishReceived(requestId, 'show'), + ); }); const remove = () => { subscription.remove(); @@ -176,6 +181,12 @@ export function identify(externalId: string | null): void { * Merges supplied tags. Null removes a key; omitted keys are unchanged. */ export function setTags(tags: Record): void { + for (const value of Object.values(tags)) { + if (value !== null && typeof value !== 'string') + throw new TypeError( + 'Use setTagNumber or setTagBoolean for non-text tags', + ); + } NativeCarillon.setTags(tags); } @@ -183,6 +194,35 @@ export function setTag(name: string, value: TagValue): void { setTags({ [name]: value }); } +export function setTagNumber(name: string, value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new TypeError('Tag numbers must be finite'); + NativeCarillon.setTagNumber(name, value); +} +export function setTagBoolean(name: string, value: boolean): void { + if (typeof value !== 'boolean') throw new TypeError('Expected a boolean'); + NativeCarillon.setTagBoolean(name, value); +} +export function setTagDate(name: string, value: Date): void { + if ( + !(value instanceof Date) || + !Number.isFinite(value.getTime()) || + value.getUTCFullYear() < 1 || + value.getUTCFullYear() > 9999 + ) + throw new TypeError('Expected a valid Date'); + NativeCarillon.setTagDate(name, value.getTime()); +} +export function removeTagNumber(name: string): void { + NativeCarillon.removeTagNumber(name); +} +export function removeTagBoolean(name: string): void { + NativeCarillon.removeTagBoolean(name); +} +export function removeTagDate(name: string): void { + NativeCarillon.removeTagDate(name); +} + export function removeTag(name: string): void { setTags({ [name]: null }); } @@ -267,6 +307,12 @@ const Carillon = { identify, setTags, setTag, + setTagNumber, + setTagBoolean, + setTagDate, + removeTagNumber, + removeTagBoolean, + removeTagDate, removeTag, optIn, optOut,