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
2 changes: 1 addition & 1 deletion CarillonReactNative.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
45 changes: 10 additions & 35 deletions android/src/main/java/dev/carillon/reactnative/CarillonModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<String, TagValue?> {
val result = LinkedHashMap<String, TagValue?>()
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<String, String?> = tags.toHashMap().mapValues { (_, value) ->
require(value == null || value is String) { "Text tags require strings" }
value as String?
}

/**
Expand Down
4 changes: 2 additions & 2 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- CarillonReactNative (0.3.0):
- CarillonReactNative (0.4.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2026,7 +2026,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c
CarillonReactNative: ef7c0bf23bdb1e8c778bbbeab6dd9946d15977b1
CarillonReactNative: 918768632843ae83cb8f0adbd0edfd67993f3877
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
hermes-engine: e7f572408afef1c5ca0f7169418aab10c6c28fc3
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
Expand Down
30 changes: 30 additions & 0 deletions ios/Carillon.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
32 changes: 9 additions & 23 deletions ios/CarillonBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/NativeCarillon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
45 changes: 40 additions & 5 deletions src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' })),
Expand Down Expand Up @@ -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',
});
});

Expand Down Expand Up @@ -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);
});
Loading
Loading