diff --git a/.eslintrc.js b/.eslintrc.js index e0f808c23..bad66d857 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -48,4 +48,5 @@ module.exports = { }, }, ], + ignorePatterns: ['coverage/**/*', 'lib/**/*', 'docs/**/*'], }; diff --git a/.gitignore b/.gitignore index 356417c19..f042551bd 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ ios/generated android/generated # Iterable +.env.local .env .xcode.env.local coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ab851d81e..74bcf9f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,47 @@ -## 2.1.0-beta.1 +## 2.2.0 -## Fixes -- Add Temporary fix for circular paths, which break expo ([9c09743](https://github.com/Iterable/react-native-sdk/commit/9c09743)) +### Updates +- Updated Android SDK version to [3.6.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.6.2) +- Updated iOS SDK version to [6.6.3](https://github.com/Iterable/swift-sdk/releases/tag/6.6.3) +- Added JWT Capabilities: + - Added `Iterable.authhManager`, which manages the authentication flow + - Added `IterableRetryBackoff` and `IterableAuthFailureReason` enums + - Added `onJwtError` and `retryPolicy` for control over JWT flow +- Moved all native calls to `IterableApi.ts` +- Added JWT example to our example app +- Changed `onJWTError` to `onJwtError` +- Changed `IterableRetryBackoff` enum keys to be lowercase for consistency + across application +- [SDK-149] Added logout functionality -## 2.1.0-beta.0 +### Fixes +- Created a standalone `IterableLogger` to avoid circular dependencies +- [SDK-151] Fixed "cannot read property authtoken of undefined" error +- Fixed Android `retryInterval` not being updated on re-initialization. +## 2.1.0 ### Updates +- SDK is now compatible with both New Architecture and Legacy Architecture. Fix + for #691, #602, #563. + +### Fixes +- Dependencies update - Update SDK so that it has full support for [React Native New Architecture](https://reactnative.dev/architecture/landing-page) +- Add Temporary fix for circular paths, which break expo ([9c09743](https://github.com/Iterable/react-native-sdk/commit/9c09743)) ### Chores - Update dependencies for React Navigation and related packages ([95053bb](https://github.com/Iterable/react-native-sdk/commit/95053bb)) +## 2.0.4 + +### Updates +- Added API documentation via Netlify([1087275](https://github.com/Iterable/react-native-sdk/commit/1087275)) +- Removed dependency on `react-native-vector-icons`, per issues + [#513](https://github.com/Iterable/react-native-sdk/issues/513), + [#683](https://github.com/Iterable/react-native-sdk/issues/683) and + [#675](https://github.com/Iterable/react-native-sdk/issues/675) + ([6ece6e0](https://github.com/Iterable/react-native-sdk/commit/6ece6e0)) +- Updated dependencies ## 2.0.3 diff --git a/Iterable-React-Native-SDK.podspec b/Iterable-React-Native-SDK.podspec index 0d023409f..e85f0bf44 100644 --- a/Iterable-React-Native-SDK.podspec +++ b/Iterable-React-Native-SDK.podspec @@ -17,7 +17,7 @@ Pod::Spec.new do |s| s.private_header_files = "ios/**/*.h" # Load Iterables iOS SDK as a dependency - s.dependency "Iterable-iOS-SDK", "6.6.1" + s.dependency "Iterable-iOS-SDK", "6.6.3" # Basic Swift support s.pod_target_xcconfig = { diff --git a/README.md b/README.md index 390b8adb3..16266121f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ Iterable's React Native SDK relies on: _UI Components require additional peer dependencies_ - [React Navigation 6+](https://github.com/react-navigation/react-navigation) - [React Native Safe Area Context 4+](https://github.com/th3rdwave/react-native-safe-area-context) - - [React Native Vector Icons 10+](https://github.com/oblador/react-native-vector-icons) - [React Native WebView 13+](https://github.com/react-native-webview/react-native-webview) - **iOS** @@ -120,13 +119,13 @@ For quick reference, the following table lists the versions of the [Android SDK] | RN SDK Version | Android SDK Version | iOS SDK Version | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------- | -| [2.1.0-beta.0](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.1.0-beta.0) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) +| [2.2.0](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.2.0) | [3.6.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.6.2) | [6.6.3](https://github.com/Iterable/swift-sdk/releases/tag/6.6.3) +| [2.1.0](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.1.0) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) +| [2.0.4](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.4) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [2.0.3](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.3) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [2.0.2](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.2) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [2.0.1](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.1) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [2.0.0](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.0) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) -| [2.0.0-beta.1](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.0-beta.1) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) -| [2.0.0-beta](https://www.npmjs.com/package/@iterable/react-native-sdk/v/2.0.0-beta) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [1.3.21](https://www.npmjs.com/package/@iterable/react-native-sdk/v/1.3.20) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [1.3.20](https://www.npmjs.com/package/@iterable/react-native-sdk/v/1.3.20) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.4](https://github.com/Iterable/swift-sdk/releases/tag/6.5.4) | [1.3.19](https://www.npmjs.com/package/@iterable/react-native-sdk/v/1.3.19) | [3.5.2](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.5.2) | [6.5.3](https://github.com/Iterable/swift-sdk/releases/tag/6.5.3) diff --git a/android/build.gradle b/android/build.gradle index d546cce98..6a3eb970b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -105,7 +105,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - api "com.iterable:iterableapi:3.6.1" + api "com.iterable:iterableapi:3.6.2" // api project(":iterableapi") // links to local android SDK repo rather than by release } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index f67a64ed4..0baec2526 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -26,6 +26,7 @@ import com.iterable.iterableapi.IterableApi; import com.iterable.iterableapi.IterableAttributionInfo; import com.iterable.iterableapi.IterableAuthHandler; +import com.iterable.iterableapi.IterableAuthManager; import com.iterable.iterableapi.IterableConfig; import com.iterable.iterableapi.IterableCustomActionHandler; // import com.iterable.iterableapi.IterableEmbeddedManager; @@ -98,7 +99,40 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setEnableEmbeddedMessaging(configReadableMap.getBoolean("enableEmbeddedMessaging")); } - IterableApi.initialize(reactContext, apiKey, configBuilder.build()); + if (configReadableMap.hasKey("enableEmbeddedMessaging")) { + configBuilder.setEnableEmbeddedMessaging(configReadableMap.getBoolean("enableEmbeddedMessaging")); + } + + IterableConfig config = configBuilder.build(); + IterableApi.initialize(reactContext, apiKey, config); + + // Update retry policy on existing authManager if it was already created + // This fixes the issue where retryInterval is not respected after + // re-initialization + // TODO [SDK-197]: Fix the root cause of this issue, instead of this hack + try { + // Use reflection to access package-private fields and methods + java.lang.reflect.Field configRetryPolicyField = config.getClass().getDeclaredField("retryPolicy"); + configRetryPolicyField.setAccessible(true); + Object retryPolicy = configRetryPolicyField.get(config); + + if (retryPolicy != null) { + java.lang.reflect.Method getAuthManagerMethod = IterableApi.getInstance().getClass().getDeclaredMethod("getAuthManager"); + getAuthManagerMethod.setAccessible(true); + IterableAuthManager authManager = (IterableAuthManager) getAuthManagerMethod.invoke(IterableApi.getInstance()); + + if (authManager != null) { + // Update the retry policy field on the authManager + java.lang.reflect.Field authRetryPolicyField = authManager.getClass().getDeclaredField("authRetryPolicy"); + authRetryPolicyField.setAccessible(true); + authRetryPolicyField.set(authManager, retryPolicy); + IterableLogger.d(TAG, "Updated retry policy on existing authManager"); + } + } + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to update retry policy: " + e.getMessage()); + } + IterableApi.getInstance().setDeviceAttribute("reactNativeSDKVersion", version); IterableApi.getInstance().getInAppManager().addListener(this); @@ -137,7 +171,36 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, // override in the Android SDK. Check with @Ayyanchira and @evantk91 to // see what the best approach is. - IterableApi.initialize(reactContext, apiKey, configBuilder.build()); + IterableConfig config = configBuilder.build(); + IterableApi.initialize(reactContext, apiKey, config); + + // Update retry policy on existing authManager if it was already created + // This fixes the issue where retryInterval is not respected after + // re-initialization + // TODO [SDK-197]: Fix the root cause of this issue, instead of this hack + try { + // Use reflection to access package-private fields and methods + java.lang.reflect.Field configRetryPolicyField = config.getClass().getDeclaredField("retryPolicy"); + configRetryPolicyField.setAccessible(true); + Object retryPolicy = configRetryPolicyField.get(config); + + if (retryPolicy != null) { + java.lang.reflect.Method getAuthManagerMethod = IterableApi.getInstance().getClass().getDeclaredMethod("getAuthManager"); + getAuthManagerMethod.setAccessible(true); + IterableAuthManager authManager = (IterableAuthManager) getAuthManagerMethod.invoke(IterableApi.getInstance()); + + if (authManager != null) { + // Update the retry policy field on the authManager + java.lang.reflect.Field authRetryPolicyField = authManager.getClass().getDeclaredField("authRetryPolicy"); + authRetryPolicyField.setAccessible(true); + authRetryPolicyField.set(authManager, retryPolicy); + IterableLogger.d(TAG, "Updated retry policy on existing authManager"); + } + } + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to update retry policy: " + e.getMessage()); + } + IterableApi.getInstance().setDeviceAttribute("reactNativeSDKVersion", version); IterableApi.getInstance().getInAppManager().addListener(this); @@ -616,11 +679,6 @@ public void onTokenRegistrationSuccessful(String authToken) { sendEvent(EventName.handleAuthSuccessCalled.name(), null); } - public void onTokenRegistrationFailed(Throwable object) { - IterableLogger.v(TAG, "Failed to set authToken"); - sendEvent(EventName.handleAuthFailureCalled.name(), null); - } - public void addListener(String eventName) { // Keep: Required for RN built in Event Emitter Calls. } diff --git a/example/.env.example b/example/.env.example index e1efc4169..5b5663df6 100644 --- a/example/.env.example +++ b/example/.env.example @@ -9,11 +9,19 @@ # 4. Fill in the following fields: # - Name: A descriptive name for the API key # - Type: Mobile -# - JWT authentication: Leave **unchecked** (IMPORTANT) +# - JWT authentication: Whether or not you want to use JWT # 5. Click "Create API Key" -# 6. Copy the generated API key -# 7. Replace the placeholder text next to `ITBL_API_KEY=` with the copied API key +# 6. Copy the generated API key and replace the placeholder text next to +# `ITBL_API_KEY=` with the copied API key +# 7. If you chose to enable JWT authentication, copy the JWT secret and and +# replace the placeholder text next to `ITBL_JWT_SECRET=` with the copied +# JWT secret ITBL_API_KEY=replace_this_with_your_iterable_api_key +# Your JWT Secret, created when making your API key (see above) +ITBL_JWT_SECRET=replace_this_with_your_jwt_secret +# Is your api token JWT Enabled? +# Must be set to 'true' to enable JWT authentication +ITBL_IS_JWT_ENABLED=true # Your Iterable user ID or email address -ITBL_ID=replace_this_with_your_user_id_or_email \ No newline at end of file +ITBL_ID=replace_this_with_your_user_id_or_email diff --git a/example/README.md b/example/README.md index 4ba5d0e6d..819bc6826 100644 --- a/example/README.md +++ b/example/README.md @@ -23,7 +23,8 @@ _example app directory_. To do so, run the following: ```bash cd ios -pod install +bundle install +bundle exec pod install ``` Once this is done, `cd` back into the _example app directory_: @@ -40,12 +41,18 @@ In it, you will find: ```shell ITBL_API_KEY=replace_this_with_your_iterable_api_key +ITBL_JWT_SECRET=replace_this_with_your_jwt_secret +ITBL_IS_JWT_ENABLED=true ITBL_ID=replace_this_with_your_user_id_or_email ``` -Replace `replace_this_with_your_iterable_api_key` with your _mobile_ Iterable API key, -and replace `replace_this_with_your_user_id_or_email` with the email or user id -that you use to log into Iterable. +- Replace `replace_this_with_your_iterable_api_key` with your **_mobile_ +Iterable API key** +- Replace `replace_this_with_your_jwt_secret` with your **JWT Secret** (if you +have a JWT-enabled API key) +- Set `ITBL_IS_JWT_ENABLED` to true if you have a JWT-enabled key, and false if you do not. +- Replace `replace_this_with_your_user_id_or_email` with the **email or user +id** that you use to log into Iterable. Follow the steps below if you do not have a mobile Iterable API key. @@ -54,12 +61,12 @@ To add an API key, do the following: 1. Sign into your Iterable account 2. Go to [Integrations > API Keys](https://app.iterable.com/settings/apiKeys) 3. Click "New API Key" in the top right corner - 4. Fill in the followsing fields: + 4. Fill in the following fields: - Name: A descriptive name for the API key - Type: Mobile - - JWT authentication: Leave **unchecked** (IMPORTANT) + - JWT authentication: Check to enable JWT authentication. If enabled, will need to create a [JWT generator](https://support.iterable.com/hc/en-us/articles/360050801231-JWT-Enabled-API-Keys#sample-python-code-for-jwt-generation) to generate the JWT token. 5. Click "Create API Key" - 6. Copy the generated API key + 6. Copy the generated API key and JWT secret into your _.env_ file ## Step 3: Start the Metro Server diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 060f74075..a060fdcce 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -117,5 +117,3 @@ dependencies { implementation jscFlavor } } - -apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle") diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java b/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java new file mode 100644 index 000000000..4fa760e33 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/IterableJwtGenerator.java @@ -0,0 +1,106 @@ +package com.iterable; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Base64.Encoder; + +/** +* Utility class to generate JWTs for use with the Iterable API +* +* @author engineering@iterable.com +*/ +public class IterableJwtGenerator { + static Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + + private static final String algorithm = "HmacSHA256"; + + // Iterable enforces a 1-year maximum token lifetime + private static final Duration maxTokenLifetime = Duration.ofDays(365); + + private static long millisToSeconds(long millis) { + return millis / 1000; + } + + private static final String encodedHeader = encoder.encodeToString( + "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8) + ); + + /** + * Generates a JWT from the provided secret, header, and payload. Does not + * validate the header or payload. + * + * @param secret Your organization's shared secret with Iterable + * @param payload The JSON payload + * + * @return a signed JWT + */ + public static String generateToken(String secret, String payload) { + try { + String encodedPayload = encoder.encodeToString( + payload.getBytes(StandardCharsets.UTF_8) + ); + String encodedHeaderAndPayload = encodedHeader + "." + encodedPayload; + + // HMAC setup + Mac hmac = Mac.getInstance(algorithm); + SecretKeySpec keySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), algorithm + ); + hmac.init(keySpec); + + String signature = encoder.encodeToString( + hmac.doFinal( + encodedHeaderAndPayload.getBytes(StandardCharsets.UTF_8) + ) + ); + + return encodedHeaderAndPayload + "." + signature; + + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + } + + /** + * Generates a JWT (issued now, expires after the provided duration). + * + * @param secret Your organization's shared secret with Iterable. + * @param duration The token's expiration time. Up to one year. + * @param email The email to included in the token, or null. + * @param userId The userId to include in the token, or null. + * + * @return A JWT string + */ + public static String generateToken( + String secret, Duration duration, String email, String userId) { + + if (duration.compareTo(maxTokenLifetime) > 0) + throw new IllegalArgumentException( + "Duration must be one year or less." + ); + + if ((userId != null && email != null) || (userId == null && email == null)) + throw new IllegalArgumentException( + "The token must include a userId or email, but not both." + ); + + long now = millisToSeconds(System.currentTimeMillis()); + + String payload; + if (userId != null) + payload = String.format( + "{ \"userId\": \"%s\", \"iat\": %d, \"exp\": %d }", + userId, now, now + millisToSeconds(duration.toMillis()) + ); + else + payload = String.format( + "{ \"email\": \"%s\", \"iat\": %d, \"exp\": %d }", + email, now, now + millisToSeconds(duration.toMillis()) + ); + + return generateToken(secret, payload); + } +} diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt new file mode 100644 index 000000000..8a6f7f018 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenModule.kt @@ -0,0 +1,37 @@ +package iterable.reactnativesdk.example + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.Promise +import com.iterable.IterableJwtGenerator +import java.time.Duration + +class JwtTokenModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return NAME + } + + @ReactMethod + fun generateJwtToken( + secret: String, + durationMs: Double, + email: String?, + userId: String?, + promise: Promise + ) { + try { + val duration = Duration.ofMillis(durationMs.toLong()) + val token = IterableJwtGenerator.generateToken(secret, duration, email, userId) + promise.resolve(token) + } catch (e: Exception) { + promise.reject("JWT_GENERATION_ERROR", e.message, e) + } + } + + companion object { + const val NAME = "JwtTokenModule" + } +} + diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt new file mode 100644 index 000000000..e05384909 --- /dev/null +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/JwtTokenPackage.kt @@ -0,0 +1,34 @@ +package iterable.reactnativesdk.example + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class JwtTokenPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == JwtTokenModule.NAME) { + JwtTokenModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[JwtTokenModule.NAME] = ReactModuleInfo( + JwtTokenModule.NAME, + JwtTokenModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + true, // hasConstants + false // isCxxModule + ) + moduleInfos + } + } +} + diff --git a/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt b/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt index d0fba2035..9c004c88b 100644 --- a/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt +++ b/example/android/app/src/main/java/iterable/reactnativesdk/example/MainApplication.kt @@ -20,6 +20,7 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: // add(MyReactNativePackage()) + add(JwtTokenPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/example/ios/IterableJwtGenerator.swift b/example/ios/IterableJwtGenerator.swift new file mode 100644 index 000000000..8e4a9cbe7 --- /dev/null +++ b/example/ios/IterableJwtGenerator.swift @@ -0,0 +1,95 @@ +// +// IterableJwtGenerator.swift +// ReactNativeSdkExample +// +// Utility class to generate JWTs for use with the Iterable API +// + +import CryptoKit +import Foundation + +@objcMembers public final class IterableJwtGenerator: NSObject { + + private struct Header: Encodable { + let alg = "HS256" + let typ = "JWT" + } + + /// Base64 URL encode without padding (URL-safe base64 encoding for JWT) + private static func urlEncodedBase64(_ data: Data) -> String { + let base64 = data.base64EncodedString() + return + base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Generic JWT generation helper that works with any Encodable payload + private static func generateJwt(secret: String, payload: T) -> String { + let headerJsonData = try! JSONEncoder().encode(Header()) + let headerBase64 = urlEncodedBase64(headerJsonData) + + let payloadJsonData = try! JSONEncoder().encode(payload) + let payloadBase64 = urlEncodedBase64(payloadJsonData) + + let toSign = Data((headerBase64 + "." + payloadBase64).utf8) + + if #available(iOS 13.0, *) { + let privateKey = SymmetricKey(data: Data(secret.utf8)) + let signature = HMAC.authenticationCode(for: toSign, using: privateKey) + let signatureBase64 = urlEncodedBase64(Data(signature)) + + let token = [headerBase64, payloadBase64, signatureBase64].joined(separator: ".") + + return token + } + return "" + } + + public static func generateJwtForEmail(secret: String, iat: Int, exp: Int, email: String) + -> String + { + struct Payload: Encodable { + var email: String + var iat: Int + var exp: Int + } + + return generateJwt(secret: secret, payload: Payload(email: email, iat: iat, exp: exp)) + } + + public static func generateJwtForUserId(secret: String, iat: Int, exp: Int, userId: String) + -> String + { + struct Payload: Encodable { + var userId: String + var iat: Int + var exp: Int + } + + return generateJwt(secret: secret, payload: Payload(userId: userId, iat: iat, exp: exp)) + } + + public static func generateToken( + secret: String, durationMs: Int64, email: String?, userId: String? + ) throws -> String { + // Convert durationMs from milliseconds to seconds + let durationSeconds = Double(durationMs) / 1000.0 + let currentTime = Date().timeIntervalSince1970 + + if userId != nil { + return generateJwtForUserId( + secret: secret, iat: Int(currentTime), + exp: Int(currentTime + durationSeconds), userId: userId!) + } else if email != nil { + return generateJwtForEmail( + secret: secret, iat: Int(currentTime), + exp: Int(currentTime + durationSeconds), email: email!) + } else { + throw NSError( + domain: "JWTGenerator", code: 6, + userInfo: [NSLocalizedDescriptionKey: "No email or userId provided"]) + } + } +} diff --git a/example/ios/JwtTokenModule.mm b/example/ios/JwtTokenModule.mm new file mode 100644 index 000000000..10390bc43 --- /dev/null +++ b/example/ios/JwtTokenModule.mm @@ -0,0 +1,25 @@ +// +// JwtTokenModule.m +// ReactNativeSdkExample +// +// React Native module bridge for JWT token generation +// + +#import + +@interface RCT_EXTERN_MODULE(JwtTokenModule, NSObject) + +RCT_EXTERN_METHOD(generateJwtToken:(NSString *)secret + durationMs:(double)durationMs + email:(NSString *)email + userId:(NSString *)userId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +@end + diff --git a/example/ios/JwtTokenModule.swift b/example/ios/JwtTokenModule.swift new file mode 100644 index 000000000..5c121143b --- /dev/null +++ b/example/ios/JwtTokenModule.swift @@ -0,0 +1,40 @@ +// +// JwtTokenModule.swift +// ReactNativeSdkExample +// +// React Native module to generate JWT tokens +// + +import Foundation +import React + +@objc(JwtTokenModule) +class JwtTokenModule: NSObject { + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc + func generateJwtToken( + _ secret: String, + durationMs: Double, + email: String?, + userId: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + do { + let token = try IterableJwtGenerator.generateToken( + secret: secret, + durationMs: Int64(durationMs), + email: email, + userId: userId + ) + resolve(token) + } catch { + reject("JWT_GENERATION_ERROR", error.localizedDescription, error) + } + } +} diff --git a/example/ios/ReactNativeSdkExample-Bridging-Header.h b/example/ios/ReactNativeSdkExample-Bridging-Header.h index 339994e93..856694030 100644 --- a/example/ios/ReactNativeSdkExample-Bridging-Header.h +++ b/example/ios/ReactNativeSdkExample-Bridging-Header.h @@ -2,3 +2,6 @@ // Use this file to import your target's public headers that you would like to // expose to Swift. // + +#import +#import diff --git a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj index 2bf23431b..de9082c9c 100644 --- a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj +++ b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj @@ -9,10 +9,14 @@ /* Begin PBXBuildFile section */ 00E356F31AD99517003FC87E /* ReactNativeSdkExampleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* ReactNativeSdkExampleTests.m */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 6F9115D0765337926C434A5A /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5A9EB6D281A44211C67E972 /* libPods-ReactNativeSdkExample.a */; }; 779227342DFA3FB500D69EC0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779227332DFA3FB500D69EC0 /* AppDelegate.swift */; }; + 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */; }; + 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */; }; + 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + 81F6A9EA0E1CCC1AD730C5D9 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */; }; A3A40C20801B8F02005FA4C0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */; }; - CC7C0C660DB585466CC95446 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D7C71B2515F0E53180477AEC /* libPods-ReactNativeSdkExample.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -29,18 +33,21 @@ 00E356EE1AD99517003FC87E /* ReactNativeSdkExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReactNativeSdkExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 00E356F21AD99517003FC87E /* ReactNativeSdkExampleTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ReactNativeSdkExampleTests.m; sourceTree = ""; }; - 054F9627BFE1F378023F2570 /* Pods-ReactNativeSdkExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.debug.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* ReactNativeSdkExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReactNativeSdkExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = ReactNativeSdkExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = ReactNativeSdkExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeSdkExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeSdkExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 2BE74655C68E80463F6CD81B /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; + 3A95ED4563D4389808EDEA8F /* Pods-ReactNativeSdkExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.debug.xcconfig"; sourceTree = ""; }; + 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeSdkExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 779227312DFA3FB500D69EC0 /* ReactNativeSdkExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExample-Bridging-Header.h"; sourceTree = ""; }; 779227322DFA3FB500D69EC0 /* ReactNativeSdkExampleTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExampleTests-Bridging-Header.h"; sourceTree = ""; }; 779227332DFA3FB500D69EC0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ReactNativeSdkExample/AppDelegate.swift; sourceTree = ""; }; + 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IterableJwtGenerator.swift; sourceTree = ""; }; + 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = JwtTokenModule.mm; sourceTree = ""; }; + 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JwtTokenModule.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ReactNativeSdkExample/LaunchScreen.storyboard; sourceTree = ""; }; - D7C71B2515F0E53180477AEC /* libPods-ReactNativeSdkExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeSdkExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -56,7 +63,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CC7C0C660DB585466CC95446 /* libPods-ReactNativeSdkExample.a in Frameworks */, + 81F6A9EA0E1CCC1AD730C5D9 /* libPods-ReactNativeSdkExample.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -99,7 +106,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - D7C71B2515F0E53180477AEC /* libPods-ReactNativeSdkExample.a */, + 56080B9DEED42A97AD1B3D5C /* libPods-ReactNativeSdkExample.a */, ); name = Frameworks; sourceTree = ""; @@ -114,6 +121,9 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */, + 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */, + 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */, 13B07FAE1A68108700A75B9A /* ReactNativeSdkExample */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* ReactNativeSdkExampleTests */, @@ -138,8 +148,8 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - 054F9627BFE1F378023F2570 /* Pods-ReactNativeSdkExample.debug.xcconfig */, - 2BE74655C68E80463F6CD81B /* Pods-ReactNativeSdkExample.release.xcconfig */, + 3A95ED4563D4389808EDEA8F /* Pods-ReactNativeSdkExample.debug.xcconfig */, + EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -169,13 +179,13 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "ReactNativeSdkExample" */; buildPhases = ( - 787BEB56F90C9C0AEE4C88D5 /* [CP] Check Pods Manifest.lock */, + B07642200E1BCDE7A80934E9 /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 152370F00B0C82FBF20ABDA2 /* [CP] Embed Pods Frameworks */, - 6099F4827CE15646F9A0205B /* [CP] Copy Pods Resources */, + 756F1571292F7FB66FB0F625 /* [CP] Embed Pods Frameworks */, + C5D9D662E100C568A4F9922D /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -260,7 +270,7 @@ shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; - 152370F00B0C82FBF20ABDA2 /* [CP] Embed Pods Frameworks */ = { + 756F1571292F7FB66FB0F625 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -268,52 +278,60 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 6099F4827CE15646F9A0205B /* [CP] Copy Pods Resources */ = { + B07642200E1BCDE7A80934E9 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ReactNativeSdkExample-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 787BEB56F90C9C0AEE4C88D5 /* [CP] Check Pods Manifest.lock */ = { + C5D9D662E100C568A4F9922D /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-ReactNativeSdkExample-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -332,6 +350,9 @@ buildActionMask = 2147483647; files = ( 779227342DFA3FB500D69EC0 /* AppDelegate.swift in Sources */, + 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */, + 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */, + 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -406,7 +427,7 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 054F9627BFE1F378023F2570 /* Pods-ReactNativeSdkExample.debug.xcconfig */; + baseConfigurationReference = 3A95ED4563D4389808EDEA8F /* Pods-ReactNativeSdkExample.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -436,7 +457,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2BE74655C68E80463F6CD81B /* Pods-ReactNativeSdkExample.release.xcconfig */; + baseConfigurationReference = EA19B65827A1D757CC5AAC97 /* Pods-ReactNativeSdkExample.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -535,10 +556,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -611,10 +629,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/example/ios/ReactNativeSdkExample/Info.plist b/example/ios/ReactNativeSdkExample/Info.plist index 4e0430cdd..19ccb2e8f 100644 --- a/example/ios/ReactNativeSdkExample/Info.plist +++ b/example/ios/ReactNativeSdkExample/Info.plist @@ -52,27 +52,5 @@ UIViewControllerBasedStatusBarAppearance - UIAppFonts - - AntDesign.ttf - Entypo.ttf - EvilIcons.ttf - Feather.ttf - FontAwesome.ttf - FontAwesome5_Brands.ttf - FontAwesome5_Regular.ttf - FontAwesome5_Solid.ttf - FontAwesome6_Brands.ttf - FontAwesome6_Regular.ttf - FontAwesome6_Solid.ttf - Foundation.ttf - Ionicons.ttf - MaterialIcons.ttf - MaterialCommunityIcons.ttf - SimpleLineIcons.ttf - Octicons.ttf - Zocial.ttf - Fontisto.ttf - diff --git a/example/package.json b/example/package.json index f48f8af07..5e101d691 100644 --- a/example/package.json +++ b/example/package.json @@ -19,14 +19,13 @@ "react-native-gesture-handler": "^2.26.0", "react-native-safe-area-context": "^5.4.0", "react-native-screens": "^4.10.0", - "react-native-vector-icons": "^10.2.0", "react-native-webview": "^13.14.1" }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@react-native-community/cli": "18.0.0", + "@react-native-community/cli": "18.0.1", "@react-native-community/cli-platform-android": "18.0.0", "@react-native-community/cli-platform-ios": "18.0.0", "@react-native/babel-preset": "0.79.3", diff --git a/example/src/NativeJwtTokenModule.ts b/example/src/NativeJwtTokenModule.ts new file mode 100644 index 000000000..464cdc37b --- /dev/null +++ b/example/src/NativeJwtTokenModule.ts @@ -0,0 +1,45 @@ +import { NativeModules, TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + generateJwtToken( + secret: string, + durationMs: number, + email: string | null, + userId: string | null + ): Promise; +} + +// Try to use TurboModule if available (New Architecture) +// Fall back to NativeModules (Old Architecture) +const isTurboModuleEnabled = + '__turboModuleProxy' in global && + (global as Record).__turboModuleProxy != null; + +let JwtTokenModule: Spec | null = null; + +try { + JwtTokenModule = isTurboModuleEnabled + ? TurboModuleRegistry.getEnforcing('JwtTokenModule') + : NativeModules.JwtTokenModule; +} catch { + // Module not available - will throw error when used + console.warn('JwtTokenModule native module is not available yet'); +} + +// Create a proxy that throws a helpful error when methods are called +const createModuleProxy = (): Spec => { + const handler: ProxyHandler = { + get(_target, prop) { + if (!JwtTokenModule) { + throw new Error( + `JwtTokenModule native module is not available. Make sure the native module is properly linked and the app has been rebuilt.\n\nFor iOS: Add Swift files to Xcode project (see SETUP_GUIDE.md)\nFor Android: Ensure JwtTokenPackage is registered in MainApplication.kt` + ); + } + return JwtTokenModule[prop as keyof Spec]; + }, + }; + return new Proxy({} as Spec, handler); +}; + +export default createModuleProxy(); diff --git a/example/src/components/App/App.constants.ts b/example/src/components/App/App.constants.ts index 4710a6ba9..25743f261 100644 --- a/example/src/components/App/App.constants.ts +++ b/example/src/components/App/App.constants.ts @@ -1,8 +1,20 @@ import { Route } from '../../constants'; +// Taken from https://github.com/ionic-team/ionicons/blob/main/src/svg/cash-outline.svg +export const cashIcon = + 'data:image/png;base64,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'; + +// Taken from https://github.com/ionic-team/ionicons/blob/main/src/svg/person-outline.svg +export const personIcon = + 'data:image/png;base64,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'; + +// Taken from https://github.com/ionic-team/ionicons/blob/main/src/svg/mail-outline.svg +export const mailIcon = + 'data:image/png;base64,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'; + export const routeIcon = { - [Route.Commerce]: 'cash-outline', + [Route.Commerce]: cashIcon, [Route.Embedded]: 'chatbubble-outline', - [Route.Inbox]: 'mail-outline', - [Route.User]: 'person-outline', + [Route.Inbox]: mailIcon, + [Route.User]: personIcon, }; diff --git a/example/src/components/App/App.utils.tsx b/example/src/components/App/App.utils.tsx index 2dcde5374..6a3990472 100644 --- a/example/src/components/App/App.utils.tsx +++ b/example/src/components/App/App.utils.tsx @@ -1,5 +1,24 @@ -import Icon from 'react-native-vector-icons/Ionicons'; +import { Image, View } from 'react-native'; +import type { Route } from '../../constants/routes'; -export const getIcon = (name: string, props: Record) => ( - -); +export const getIcon = (name: Route, props: Record) => { + const { color, size = 25 } = props; + + return ( + + + + ); +}; diff --git a/example/src/components/App/Main.tsx b/example/src/components/App/Main.tsx index 07b7b2d2c..956973149 100644 --- a/example/src/components/App/Main.tsx +++ b/example/src/components/App/Main.tsx @@ -26,7 +26,7 @@ export const Main = () => { screenOptions={({ route }) => { const iconName = routeIcon[route.name]; return { - tabBarIcon: (props) => getIcon(iconName, props), + tabBarIcon: (props) => getIcon(iconName as Route, props), tabBarActiveTintColor: colors.brandPurple, tabBarInactiveTintColor: colors.textSecondary, headerShown: false, diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index 68b748048..64c31a65e 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -9,7 +9,7 @@ import { import styles from './Embedded.styles'; export const Embedded = () => { - const [placementIds, setPlacementIds] = useState([]); + const [placementIds] = useState([10, 2112]); const [embeddedMessages, setEmbeddedMessages] = useState< IterableEmbeddedMessage[] >([]); @@ -19,11 +19,11 @@ export const Embedded = () => { }, []); const getPlacementIds = useCallback(() => { - return Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { - console.log(ids); - setPlacementIds(ids as number[]); - return ids; - }); + // return Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { + // console.log(ids); + // setPlacementIds(ids as number[]); + // return ids; + // }); }, []); const startEmbeddedSession = useCallback(() => { @@ -41,13 +41,19 @@ export const Embedded = () => { }, []); const getEmbeddedMessages = useCallback(() => { - getPlacementIds() - .then((ids: number[]) => Iterable.embeddedManager.getMessages(ids)) + Iterable.embeddedManager + .getMessages(placementIds) .then((messages: IterableEmbeddedMessage[]) => { setEmbeddedMessages(messages); console.log(messages); }); - }, [getPlacementIds]); + // getPlacementIds() + // .then((ids: number[]) => Iterable.embeddedManager.getMessages(ids)) + // .then((messages: IterableEmbeddedMessage[]) => { + // setEmbeddedMessages(messages); + // console.log(messages); + // }); + }, [placementIds]); const startEmbeddedImpression = useCallback( (message: IterableEmbeddedMessage) => { diff --git a/example/src/hooks/useIterableApp.tsx b/example/src/hooks/useIterableApp.tsx index 136323dbd..055494ce4 100644 --- a/example/src/hooks/useIterableApp.tsx +++ b/example/src/hooks/useIterableApp.tsx @@ -20,6 +20,7 @@ import { import { Route } from '../constants/routes'; import type { RootStackParamList } from '../types/navigation'; +import NativeJwtTokenModule from '../NativeJwtTokenModule'; type Navigation = StackNavigationProp; @@ -86,6 +87,10 @@ const IterableAppContext = createContext({ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const getIsEmail = (id: string) => EMAIL_REGEX.test(id); + +let lastTimeStamp = 0; + export const IterableAppProvider: FunctionComponent< React.PropsWithChildren > = ({ children }) => { @@ -105,6 +110,21 @@ export const IterableAppProvider: FunctionComponent< const getUserId = useCallback(() => userId ?? process.env.ITBL_ID, [userId]); + const getJwtToken = useCallback(async () => { + const id = userId ?? process.env.ITBL_ID; + const idType = getIsEmail(id as string) ? 'email' : 'userId'; + const secret = process.env.ITBL_JWT_SECRET ?? ''; + const duration = 1000 * 60 * 60 * 24; // 1 day in milliseconds + const jwtToken = await NativeJwtTokenModule.generateJwtToken( + secret, + duration, + idType === 'email' ? (id as string) : null, // Email (can be null if userId is provided) + idType === 'userId' ? (id as string) : null // UserId (can be null if email is provided) + ); + + return jwtToken; + }, [userId]); + const login = useCallback(() => { const id = userId ?? process.env.ITBL_ID; @@ -112,8 +132,7 @@ export const IterableAppProvider: FunctionComponent< setLoginInProgress(true); - const isEmail = EMAIL_REGEX.test(id); - const fn = isEmail ? Iterable.setEmail : Iterable.setUserId; + const fn = getIsEmail(id) ? Iterable.setEmail : Iterable.setUserId; fn(id); setIsLoggedIn(true); @@ -124,20 +143,22 @@ export const IterableAppProvider: FunctionComponent< const initialize = useCallback( (navigation: Navigation) => { + logout(); + const config = new IterableConfig(); config.inAppDisplayInterval = 1.0; // Min gap between in-apps. No need to set this in production. config.retryPolicy = { maxRetry: 5, - retryInterval: 10, - retryBackoff: IterableRetryBackoff.LINEAR, + retryInterval: 5, + retryBackoff: IterableRetryBackoff.linear, }; config.enableEmbeddedMessaging = true; - config.onJWTError = (authFailure) => { - console.log('onJWTError', authFailure); + config.onJwtError = (authFailure) => { + console.log('onJwtError', authFailure); const failureReason = typeof authFailure.failureReason === 'string' @@ -175,21 +196,24 @@ export const IterableAppProvider: FunctionComponent< config.inAppHandler = () => IterableInAppShowResponse.show; - // NOTE: Uncomment to test authHandler failure - // config.authHandler = () => { - // console.log(`authHandler`); - - // return Promise.resolve({ - // authToken: 'SomethingNotValid', - // successCallback: () => { - // console.log(`authHandler > success`); - // }, - // // This is not firing - // failureCallback: () => { - // console.log(`authHandler > failure`); - // }, - // }); - // }; + if ( + process.env.ITBL_IS_JWT_ENABLED === 'true' && + process.env.ITBL_JWT_SECRET + ) { + config.authHandler = async () => { + console.group('authHandler'); + const now = Date.now(); + if (lastTimeStamp !== 0) { + console.log('Time since last call:', now - lastTimeStamp); + } + lastTimeStamp = now; + console.groupEnd(); + + // return 'InvalidToken'; // Uncomment this to test the failure callback + const token = await getJwtToken(); + return token; + }; + } setItblConfig(config); @@ -205,11 +229,12 @@ export const IterableAppProvider: FunctionComponent< .then((isSuccessful) => { setIsInitialized(isSuccessful); - if (!isSuccessful) - return Promise.reject('`Iterable.initialize` failed'); + if (isSuccessful && getUserId()) { + return login(); + } - if (getUserId()) { - login(); + if (!isSuccessful) { + return Promise.reject('`Iterable.initialize` failed'); } return isSuccessful; @@ -222,24 +247,17 @@ export const IterableAppProvider: FunctionComponent< setIsInitialized(false); setLoginInProgress(false); return Promise.reject(err); - }) - .finally(() => { - // For some reason, ios is throwing an error on initialize. - // To temporarily fix this, we're using the finally block to login. - // MOB-10419: Find out why initialize is throwing an error on ios - setIsInitialized(true); - if (getUserId()) { - login(); - } - return Promise.resolve(true); }); }, - [apiKey, getUserId, login] + // eslint-disable-next-line react-hooks/exhaustive-deps + [getUserId, apiKey, login, getJwtToken, userId] ); const logout = useCallback(() => { Iterable.setEmail(null); Iterable.setUserId(null); + Iterable.logout(); + lastTimeStamp = 0; setIsLoggedIn(false); }, []); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 91955f797..a61e1329f 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -277,6 +277,16 @@ - (void)pauseAuthRetries:(BOOL)pauseRetry { [_swiftAPI pauseAuthRetries:pauseRetry]; } +- (void)syncEmbeddedMessages { + [_swiftAPI syncEmbeddedMessages]; +} + +- (void)getEmbeddedMessages:(NSArray *_Nullable)placementIds + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_swiftAPI getEmbeddedMessages:placementIds resolver:resolve rejecter:reject]; +} + - (void)wakeApp { // Placeholder function -- this method is only used in Android } @@ -507,6 +517,15 @@ - (void)wakeApp { [_swiftAPI pauseAuthRetries:pauseRetry]; } +RCT_EXPORT_METHOD(syncEmbeddedMessages) { + [_swiftAPI syncEmbeddedMessages]; +} + +RCT_EXPORT_METHOD(getEmbeddedMessages : (NSArray *_Nullable)placementIds resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { + [_swiftAPI getEmbeddedMessages:placementIds resolver:resolve rejecter:reject]; +} + RCT_EXPORT_METHOD(wakeApp) { // Placeholder function -- this method is only used in Android } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index f04b08e42..6fe44affe 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -215,7 +215,8 @@ import React ITBError("Could not find message with id: \(messageId)") return } - IterableAPI.track(inAppOpen: message, location: InAppLocation.from(number: locationNumber as NSNumber)) + IterableAPI.track( + inAppOpen: message, location: InAppLocation.from(number: locationNumber as NSNumber)) } @objc(trackInAppClick:location:clickedUrl:) @@ -414,8 +415,10 @@ import React templateId: Double ) { ITBInfo() - let finalCampaignId: NSNumber? = (campaignId as NSNumber).intValue <= 0 ? nil : campaignId as NSNumber - let finalTemplateId: NSNumber? = (templateId as NSNumber).intValue <= 0 ? nil : templateId as NSNumber + let finalCampaignId: NSNumber? = + (campaignId as NSNumber).intValue <= 0 ? nil : campaignId as NSNumber + let finalTemplateId: NSNumber? = + (templateId as NSNumber).intValue <= 0 ? nil : templateId as NSNumber IterableAPI.updateSubscriptions( emailListIds, unsubscribedChannelIds: unsubscribedChannelIds, @@ -480,7 +483,7 @@ import React @objc(passAlongAuthToken:) public func passAlongAuthToken(authToken: String?) { ITBInfo() - passedAuthToken = authToken + self.passedAuthToken = authToken authHandlerSemaphore.signal() } @@ -490,6 +493,43 @@ import React IterableAPI.pauseAuthRetries(pauseRetry) } + @objc(syncEmbeddedMessages) + public func syncEmbeddedMessages() { + ITBInfo() + IterableAPI.embeddedManager.syncMessages(completion: {}) + } + + @objc(getEmbeddedMessages:resolver:rejecter:) + public func getEmbeddedMessages( + placementIds: [NSNumber]?, + resolver: RCTPromiseResolveBlock, + rejecter: RCTPromiseRejectBlock + ) { + ITBInfo() + ITBInfo("getEmbeddedMessages called with placementIds: \(String(describing: placementIds))") + var allMessages: [IterableEmbeddedMessage] = [] + + if let placementIds = placementIds, !placementIds.isEmpty { + // Get messages for each specified placement ID + ITBInfo("Getting messages for \(placementIds.count) placement IDs") + for placementId in placementIds { + ITBInfo("Getting messages for placement ID: \(placementId.intValue)") + let messages = IterableAPI.embeddedManager.getMessages(for: placementId.intValue) + ITBInfo("Found \(messages.count) messages for placement ID: \(placementId.intValue)") + allMessages.append(contentsOf: messages) + } + } else { + // Get messages for all placements by getting placement IDs first + ITBInfo("Getting all messages (no placement IDs specified)") + let messages = IterableAPI.embeddedManager.getMessages() + ITBInfo("Found \(messages.count) total messages") + allMessages.append(contentsOf: messages) + } + + ITBInfo("Returning \(allMessages.count) total embedded messages") + resolver(allMessages.map { $0.toDict() }) + } + // MARK: Private private var shouldEmit = false private let _methodQueue = DispatchQueue(label: String(describing: ReactIterableAPI.self)) @@ -537,7 +577,9 @@ import React iterableConfig.inAppDelegate = self } - if let authHandlerPresent = configDict["authHandlerPresent"] as? Bool, authHandlerPresent { + if let authHandlerPresent = configDict["authHandlerPresent"] as? Bool, + authHandlerPresent == true + { iterableConfig.authDelegate = self } @@ -554,6 +596,7 @@ import React apiEndPointOverride: apiEndPointOverride ) { result in resolver(result) + IterableAPI.embeddedManager.syncMessages(completion: {}) } IterableAPI.setDeviceAttribute(name: "reactNativeSDKVersion", value: version) diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index 478262924..fa57b7589 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -94,9 +94,27 @@ extension IterableConfig { } } + if let enableEmbeddedMessaging = dict["enableEmbeddedMesssaging"] as? Bool { + config.enableEmbeddedMessaging = enableEmbeddedMessaging + } + + if let retryPolicyDict = dict["retryPolicy"] as? [AnyHashable: Any] { + if let maxRetry = retryPolicyDict["maxRetry"] as? Int, + let retryInterval = retryPolicyDict["retryInterval"] as? TimeInterval, + let retryBackoffString = retryPolicyDict["retryBackoff"] as? String + { + let retryBackoffType: RetryPolicy.BackoffType = + retryBackoffString == "EXPONENTIAL" ? .exponential : .linear + config.retryPolicy = RetryPolicy( + maxRetry: maxRetry, retryInterval: retryInterval, retryBackoff: retryBackoffType) + } + } + return config } + + private static func createLogDelegate(logLevelNumber: NSNumber) -> IterableLogDelegate { DefaultLogDelegate(minLogLevel: LogLevel.from(number: logLevelNumber)) } @@ -267,3 +285,96 @@ extension InboxImpressionTracker.RowInfo { return rows.compactMap(InboxImpressionTracker.RowInfo.from(dict:)) } } + +extension IterableEmbeddedMessage { + func toDict() -> [AnyHashable: Any] { + var dict = [AnyHashable: Any]() + + // Metadata + var metadata = [AnyHashable: Any]() + metadata["messageId"] = self.metadata.messageId + metadata["placementId"] = self.metadata.placementId + if let campaignId = self.metadata.campaignId { + metadata["campaignId"] = campaignId + } + if let isProof = self.metadata.isProof { + metadata["isProof"] = isProof + } + dict["metadata"] = metadata + + // Elements + if let elements = self.elements { + var elementsDict = [AnyHashable: Any]() + + if let title = elements.title { + elementsDict["title"] = title + } + + if let body = elements.body { + elementsDict["body"] = body + } + + if let mediaUrl = elements.mediaUrl { + elementsDict["mediaUrl"] = mediaUrl + } + + if let mediaUrlCaption = elements.mediaUrlCaption { + elementsDict["mediaUrlCaption"] = mediaUrlCaption + } + + if let defaultAction = elements.defaultAction { + var actionDict = [AnyHashable: Any]() + actionDict["type"] = defaultAction.type + if let data = defaultAction.data { + actionDict["data"] = data + } + elementsDict["defaultAction"] = actionDict + } + + if let buttons = elements.buttons { + var buttonsArray = [[AnyHashable: Any]]() + for button in buttons { + var buttonDict = [AnyHashable: Any]() + buttonDict["id"] = button.id + if let title = button.title { + buttonDict["title"] = title + } + if let action = button.action { + var actionDict = [AnyHashable: Any]() + actionDict["type"] = action.type + if let data = action.data { + actionDict["data"] = data + } + buttonDict["action"] = actionDict + } else { + buttonDict["action"] = NSNull() + } + buttonsArray.append(buttonDict) + } + elementsDict["buttons"] = buttonsArray + } + + if let text = elements.text { + var textArray = [[AnyHashable: Any]]() + for textElement in text { + var textDict = [AnyHashable: Any]() + textDict["id"] = textElement.id + if let textValue = textElement.text { + textDict["text"] = textValue + } + textArray.append(textDict) + } + elementsDict["text"] = textArray + } + + dict["elements"] = elementsDict + } + + // Payload + if let payload = self.payload { + dict["payload"] = payload + } + + return dict + } +} diff --git a/jest.config.js b/jest.config.js index 482f53a77..c5b75e86c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,7 @@ module.exports = { ], testMatch: ['/src/**/*.(test|spec).[jt]s?(x)'], transformIgnorePatterns: [ - 'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-screens|react-native-safe-area-context|react-native-gesture-handler|react-native-webview|react-native-vector-icons)/)', + 'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-screens|react-native-safe-area-context|react-native-gesture-handler|react-native-webview)/)', ], collectCoverageFrom: [ 'src/**/*.{cjs,js,jsx,mjs,ts,tsx}', diff --git a/package.json b/package.json index d378ab5a3..f6ca88c1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iterable/react-native-sdk", - "version": "2.1.0-beta.1", + "version": "2.2.0", "description": "Iterable SDK for React Native.", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -69,6 +69,7 @@ "devDependencies": { "@commitlint/config-conventional": "^19.6.0", "@evilmartians/lefthook": "^1.5.0", + "@react-native-community/cli": "18.0.0", "@react-native/babel-preset": "0.79.3", "@react-native/eslint-config": "0.79.3", "@react-native/metro-config": "0.79.3", @@ -79,7 +80,6 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.5", "@types/react": "^19.0.0", - "@types/react-native-vector-icons": "^6.4.18", "@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/parser": "^8.13.0", "commitlint": "^19.6.1", @@ -98,7 +98,6 @@ "react-native-gesture-handler": "^2.26.0", "react-native-safe-area-context": "^5.4.0", "react-native-screens": "^4.10.0", - "react-native-vector-icons": "^10.2.0", "react-native-webview": "^13.14.1", "react-test-renderer": "19.0.0", "release-it": "^17.10.0", @@ -116,7 +115,6 @@ "react": "*", "react-native": "*", "react-native-safe-area-context": "*", - "react-native-vector-icons": "*", "react-native-webview": "*" }, "peerDependenciesMeta": { diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 1949c15bf..4094afcb0 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -16,10 +16,10 @@ export class MockRNIterableAPI { }); } - static setEmail(email: string, authToken?: string): void { + static setEmail = jest.fn((email: string, authToken?: string): void => { MockRNIterableAPI.email = email; MockRNIterableAPI.token = authToken; - } + }); static async getUserId(): Promise { return await new Promise((resolve) => { @@ -27,10 +27,10 @@ export class MockRNIterableAPI { }); } - static setUserId(userId: string, authToken?: string): void { + static setUserId = jest.fn((userId: string, authToken?: string): void => { MockRNIterableAPI.userId = userId; MockRNIterableAPI.token = authToken; - } + }); static disableDeviceForCurrentUser = jest.fn(); @@ -62,9 +62,11 @@ export class MockRNIterableAPI { }); } - static setAttributionInfo(attributionInfo?: IterableAttributionInfo): void { - MockRNIterableAPI.attributionInfo = attributionInfo; - } + static setAttributionInfo = jest.fn( + (attributionInfo?: IterableAttributionInfo): void => { + MockRNIterableAPI.attributionInfo = attributionInfo; + } + ); static initializeWithApiKey = jest.fn().mockResolvedValue(true); @@ -84,17 +86,41 @@ export class MockRNIterableAPI { }); } - static setAutoDisplayPaused = jest.fn(); + static async getInboxMessages(): Promise { + return await new Promise((resolve) => { + // Filter messages that are marked for inbox + const inboxMessages = + MockRNIterableAPI.messages?.filter((msg) => msg.saveToInbox) || []; + resolve(inboxMessages); + }); + } - static async showMessage( - _message: IterableInAppMessage, - _consume: boolean - ): Promise { + static async getHtmlInAppContentForMessage( + messageId: string + ): Promise { return await new Promise((resolve) => { - resolve(MockRNIterableAPI.clickedUrl); + // Mock HTML content for testing + const mockHtmlContent = { + edgeInsets: { top: 10, left: 20, bottom: 30, right: 40 }, + html: `
Mock HTML content for message ${messageId}
`, + }; + resolve(mockHtmlContent); }); } + static setAutoDisplayPaused = jest.fn(); + + static showMessage = jest.fn( + async ( + _messageId: string, + _consume: boolean + ): Promise => { + return await new Promise((resolve) => { + resolve(MockRNIterableAPI.clickedUrl); + }); + } + ); + static removeMessage = jest.fn(); static setReadForMessage = jest.fn(); @@ -109,6 +135,12 @@ export class MockRNIterableAPI { static updateSubscriptions = jest.fn(); + static startSession = jest.fn(); + + static endSession = jest.fn(); + + static updateVisibleRows = jest.fn(); + // set messages function is to set the messages static property // this is for testing purposes only static setMessages(messages: IterableInAppMessage[]): void { diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index afc5100dd..bfe4c26f6 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -74,6 +74,58 @@ describe('Iterable', () => { }); }); + describe('logout', () => { + it('should call setEmail with null', () => { + // GIVEN no parameters + // WHEN Iterable.logout is called + const setEmailSpy = jest.spyOn(Iterable, 'setEmail'); + Iterable.logout(); + // THEN Iterable.setEmail is called with null + expect(setEmailSpy).toBeCalledWith(null); + setEmailSpy.mockRestore(); + }); + + it('should call setUserId with null', () => { + // GIVEN no parameters + // WHEN Iterable.logout is called + const setUserIdSpy = jest.spyOn(Iterable, 'setUserId'); + Iterable.logout(); + // THEN Iterable.setUserId is called with null + expect(setUserIdSpy).toBeCalledWith(null); + setUserIdSpy.mockRestore(); + }); + + it('should clear email and userId', async () => { + // GIVEN a user is logged in + + // This is just for testing purposed. + // Usually you'd either call `setEmail` or `setUserId`, but not both. + Iterable.setEmail('user@example.com'); + Iterable.setUserId('user123'); + // WHEN Iterable.logout is called + Iterable.logout(); + // THEN email and userId are set to null + const email = await Iterable.getEmail(); + const userId = await Iterable.getUserId(); + expect(email).toBeNull(); + expect(userId).toBeNull(); + }); + + it('should call setEmail and setUserId with null', () => { + // GIVEN no parameters + const setEmailSpy = jest.spyOn(Iterable, 'setEmail'); + const setUserIdSpy = jest.spyOn(Iterable, 'setUserId'); + // WHEN Iterable.logout is called + Iterable.logout(); + // THEN both methods are called with null + expect(setEmailSpy).toBeCalledWith(null); + expect(setUserIdSpy).toBeCalledWith(null); + // Clean up + setEmailSpy.mockRestore(); + setUserIdSpy.mockRestore(); + }); + }); + describe('disableDeviceForCurrentUser', () => { it('should disable the device for the current user', () => { // GIVEN no parameters @@ -256,7 +308,7 @@ describe('Iterable', () => { expect(config.customActionHandler).toBe(undefined); expect(config.inAppHandler).toBe(undefined); expect(config.authHandler).toBe(undefined); - expect(config.logLevel).toBe(IterableLogLevel.info); + expect(config.logLevel).toBe(IterableLogLevel.debug); expect(config.logReactNativeSdkCalls).toBe(true); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(config.allowedProtocols).toEqual([]); @@ -272,7 +324,7 @@ describe('Iterable', () => { expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.inAppHandlerPresent).toBe(false); expect(configDict.authHandlerPresent).toBe(false); - expect(configDict.logLevel).toBe(IterableLogLevel.info); + expect(configDict.logLevel).toBe(IterableLogLevel.debug); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 1f4535d22..d9c98a572 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,6 +27,23 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); +/** + * Checks if the response is an IterableAuthResponse + */ +const isIterableAuthResponse = ( + response: IterableAuthResponse | string | undefined | null +): response is IterableAuthResponse => { + if (typeof response === 'string') return false; + if ( + response?.authToken || + response?.successCallback || + response?.failureCallback + ) { + return true; + } + return false; +}; + /* eslint-disable tsdoc/syntax */ /** * The main class for the Iterable React Native SDK. @@ -901,6 +918,40 @@ export class Iterable { }); } + /** + * Logs out the current user from the Iterable SDK. + * + * This method will remove all event listeners for the Iterable SDK and set the email and user ID to null. + * + * @example + * ```typescript + * Iterable.logout(); + * ``` + */ + static logout() { + Iterable.removeAllEventListeners(); + Iterable.setEmail(null); + Iterable.setUserId(null); + } + + /** + * Removes all event listeners for the Iterable SDK. + */ + private static removeAllEventListeners() { + RNEventEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + RNEventEmitter.removeAllListeners(IterableEventName.handleInAppCalled); + RNEventEmitter.removeAllListeners( + IterableEventName.handleCustomActionCalled + ); + RNEventEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + RNEventEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + } + /** * Sets up event handlers for various Iterable events. * @@ -923,12 +974,7 @@ export class Iterable { */ private static setupEventHandlers() { // Remove all listeners to avoid duplicate listeners - RNEventEmitter.removeAllListeners(IterableEventName.handleUrlCalled); - RNEventEmitter.removeAllListeners(IterableEventName.handleInAppCalled); - RNEventEmitter.removeAllListeners( - IterableEventName.handleCustomActionCalled - ); - RNEventEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + Iterable.removeAllEventListeners(); if (Iterable.savedConfig.urlHandler) { RNEventEmitter.addListener(IterableEventName.handleUrlCalled, (dict) => { @@ -980,38 +1026,38 @@ export class Iterable { // Promise result can be either just String OR of type AuthResponse. // If type AuthReponse, authToken will be parsed looking for `authToken` within promised object. Two additional listeners will be registered for success and failure callbacks sent by native bridge layer. // Else it will be looked for as a String. - if (typeof promiseResult === typeof new IterableAuthResponse()) { - Iterable.authManager.passAlongAuthToken( - (promiseResult as IterableAuthResponse).authToken - ); + if (isIterableAuthResponse(promiseResult)) { + Iterable.authManager.passAlongAuthToken(promiseResult.authToken); - const timeoutId = setTimeout(() => { + setTimeout(() => { if ( authResponseCallback === IterableAuthResponseResult.SUCCESS ) { - if ((promiseResult as IterableAuthResponse).successCallback) { - (promiseResult as IterableAuthResponse).successCallback?.(); + if (promiseResult.successCallback) { + promiseResult.successCallback?.(); } } else if ( authResponseCallback === IterableAuthResponseResult.FAILURE ) { // We are currently only reporting JWT related errors. In // the future, we should handle other types of errors as well. - if ((promiseResult as IterableAuthResponse).failureCallback) { - (promiseResult as IterableAuthResponse).failureCallback?.(); + if (promiseResult.failureCallback) { + promiseResult.failureCallback?.(); } } else { IterableLogger?.log('No callback received from native layer'); } }, 1000); - // Use unref() to prevent the timeout from keeping the process alive - timeoutId.unref(); } else if (typeof promiseResult === 'string') { - //If promise only returns string - Iterable.authManager.passAlongAuthToken(promiseResult as string); + // If promise only returns string + Iterable.authManager.passAlongAuthToken(promiseResult); + } else if (promiseResult === null || promiseResult === undefined) { + // Even though this will cause authentication to fail, we want to + // allow for this for JWT handling. + Iterable.authManager.passAlongAuthToken(promiseResult); } else { IterableLogger?.log( - 'Unexpected promise returned. Auth token expects promise of String or AuthResponse type.' + 'Unexpected promise returned. Auth token expects promise of String, null, undefined, or AuthResponse type.' ); } }) @@ -1033,7 +1079,7 @@ export class Iterable { authResponseCallback = IterableAuthResponseResult.FAILURE; // Call the actual JWT error with `authFailure` object. - Iterable.savedConfig?.onJWTError?.(authFailureResponse); + Iterable.savedConfig?.onJwtError?.(authFailureResponse); } ); } diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts new file mode 100644 index 000000000..77a229c58 --- /dev/null +++ b/src/core/classes/IterableApi.test.ts @@ -0,0 +1,1132 @@ +import { Platform } from 'react-native'; + +import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; +import { IterableApi } from './IterableApi'; +import { IterableConfig } from './IterableConfig'; +import { IterableAttributionInfo } from './IterableAttributionInfo'; +import { IterableCommerceItem } from './IterableCommerceItem'; +import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; +import { IterableInAppTrigger } from '../../inApp/classes/IterableInAppTrigger'; +import { IterableInAppTriggerType } from '../../inApp/enums/IterableInAppTriggerType'; +import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; +import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; +import { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; +import { IterableInAppShowResponse } from '../../inApp/enums/IterableInAppShowResponse'; +import { type IterableInboxImpressionRowInfo } from '../../inbox/types/IterableInboxImpressionRowInfo'; + +// Mock the RNIterableAPI module +jest.mock('../../api', () => ({ + __esModule: true, + default: MockRNIterableAPI, +})); + +describe('IterableApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ====================================================== // + // ===================== INITIALIZE ===================== // + // ====================================================== // + + describe('initializeWithApiKey', () => { + it('should call RNIterableAPI.initializeWithApiKey with correct parameters', async () => { + // GIVEN an API key, config, and version + const apiKey = 'test-api-key'; + const config = new IterableConfig(); + const version = '1.0.0'; + + // WHEN initializeWithApiKey is called + const result = await IterableApi.initializeWithApiKey(apiKey, { + config, + version, + }); + + // THEN RNIterableAPI.initializeWithApiKey is called with correct parameters + expect(MockRNIterableAPI.initializeWithApiKey).toBeCalledWith( + apiKey, + config.toDict(), + version + ); + expect(result).toBe(true); + }); + + it('should use default config when not provided', async () => { + // GIVEN an API key and version + const apiKey = 'test-api-key'; + const version = '1.0.0'; + + // WHEN initializeWithApiKey is called without config + const result = await IterableApi.initializeWithApiKey(apiKey, { + config: new IterableConfig(), + version, + }); + + // THEN RNIterableAPI.initializeWithApiKey is called with default config + expect(MockRNIterableAPI.initializeWithApiKey).toBeCalledWith( + apiKey, + expect.any(Object), + version + ); + expect(result).toBe(true); + }); + }); + + describe('initialize2WithApiKey', () => { + it('should call RNIterableAPI.initialize2WithApiKey with correct parameters', async () => { + // GIVEN an API key, config, version, and endpoint + const apiKey = 'test-api-key'; + const config = new IterableConfig(); + const version = '1.0.0'; + const apiEndPoint = 'https://api.staging.iterable.com'; + + // WHEN initialize2WithApiKey is called + const result = await IterableApi.initialize2WithApiKey(apiKey, { + config, + version, + apiEndPoint, + }); + + // THEN RNIterableAPI.initialize2WithApiKey is called with correct parameters + expect(MockRNIterableAPI.initialize2WithApiKey).toBeCalledWith( + apiKey, + config.toDict(), + version, + apiEndPoint + ); + expect(result).toBe(true); + }); + + it('should use default config when not provided', async () => { + // GIVEN an API key, version, and endpoint + const apiKey = 'test-api-key'; + const version = '1.0.0'; + const apiEndPoint = 'https://api.staging.iterable.com'; + + // WHEN initialize2WithApiKey is called without config + const result = await IterableApi.initialize2WithApiKey(apiKey, { + version, + apiEndPoint, + config: new IterableConfig(), + }); + + // THEN RNIterableAPI.initialize2WithApiKey is called with default config + expect(MockRNIterableAPI.initialize2WithApiKey).toBeCalledWith( + apiKey, + expect.any(Object), + version, + apiEndPoint + ); + expect(result).toBe(true); + }); + }); + + // ====================================================== // + // ===================== USER MANAGEMENT ================ // + // ====================================================== // + + describe('setEmail', () => { + it('should call RNIterableAPI.setEmail with email only', () => { + // GIVEN an email + const email = 'user@example.com'; + + // WHEN setEmail is called + IterableApi.setEmail(email); + + // THEN RNIterableAPI.setEmail is called with email + expect(MockRNIterableAPI.setEmail).toBeCalledWith(email, undefined); + }); + + it('should call RNIterableAPI.setEmail with email and auth token', () => { + // GIVEN an email and auth token + const email = 'user@example.com'; + const authToken = 'jwt-token'; + + // WHEN setEmail is called + IterableApi.setEmail(email, authToken); + + // THEN RNIterableAPI.setEmail is called with email and auth token + expect(MockRNIterableAPI.setEmail).toBeCalledWith(email, authToken); + }); + + it('should call RNIterableAPI.setEmail with null email', () => { + // GIVEN null email + const email = null; + + // WHEN setEmail is called + IterableApi.setEmail(email); + + // THEN RNIterableAPI.setEmail is called with null email + expect(MockRNIterableAPI.setEmail).toBeCalledWith(null, undefined); + }); + }); + + describe('getEmail', () => { + it('should return the email from RNIterableAPI', async () => { + // GIVEN a mock email + const expectedEmail = 'user@example.com'; + MockRNIterableAPI.email = expectedEmail; + + // WHEN getEmail is called + const result = await IterableApi.getEmail(); + + // THEN the email is returned + expect(result).toBe(expectedEmail); + }); + }); + + describe('setUserId', () => { + it('should call RNIterableAPI.setUserId with userId only', () => { + // GIVEN a userId + const userId = 'user123'; + + // WHEN setUserId is called + IterableApi.setUserId(userId); + + // THEN RNIterableAPI.setUserId is called with userId + expect(MockRNIterableAPI.setUserId).toBeCalledWith(userId, undefined); + }); + + it('should call RNIterableAPI.setUserId with userId and auth token', () => { + // GIVEN a userId and auth token + const userId = 'user123'; + const authToken = 'jwt-token'; + + // WHEN setUserId is called + IterableApi.setUserId(userId, authToken); + + // THEN RNIterableAPI.setUserId is called with userId and auth token + expect(MockRNIterableAPI.setUserId).toBeCalledWith(userId, authToken); + }); + + it('should call RNIterableAPI.setUserId with null userId', () => { + // GIVEN null userId + const userId = null; + + // WHEN setUserId is called + IterableApi.setUserId(userId); + + // THEN RNIterableAPI.setUserId is called with null userId + expect(MockRNIterableAPI.setUserId).toBeCalledWith(null, undefined); + }); + + it('should call RNIterableAPI.setUserId with undefined userId', () => { + // GIVEN undefined userId + const userId = undefined; + + // WHEN setUserId is called + IterableApi.setUserId(userId); + + // THEN RNIterableAPI.setUserId is called with undefined userId + expect(MockRNIterableAPI.setUserId).toBeCalledWith(undefined, undefined); + }); + }); + + describe('getUserId', () => { + it('should return the userId from RNIterableAPI', async () => { + // GIVEN a mock userId + const expectedUserId = 'user123'; + MockRNIterableAPI.userId = expectedUserId; + + // WHEN getUserId is called + const result = await IterableApi.getUserId(); + + // THEN the userId is returned + expect(result).toBe(expectedUserId); + }); + }); + + describe('disableDeviceForCurrentUser', () => { + it('should call RNIterableAPI.disableDeviceForCurrentUser', () => { + // GIVEN no parameters + // WHEN disableDeviceForCurrentUser is called + IterableApi.disableDeviceForCurrentUser(); + + // THEN RNIterableAPI.disableDeviceForCurrentUser is called + expect(MockRNIterableAPI.disableDeviceForCurrentUser).toBeCalled(); + }); + }); + + describe('updateUser', () => { + it('should call RNIterableAPI.updateUser with data fields and merge flag', () => { + // GIVEN data fields and merge flag + const dataFields = { name: 'John', age: 30 }; + const mergeNestedObjects = true; + + // WHEN updateUser is called + IterableApi.updateUser(dataFields, mergeNestedObjects); + + // THEN RNIterableAPI.updateUser is called with correct parameters + expect(MockRNIterableAPI.updateUser).toBeCalledWith( + dataFields, + mergeNestedObjects + ); + }); + + it('should call RNIterableAPI.updateUser with mergeNestedObjects false', () => { + // GIVEN data fields and merge flag set to false + const dataFields = { name: 'Jane' }; + const mergeNestedObjects = false; + + // WHEN updateUser is called + IterableApi.updateUser(dataFields, mergeNestedObjects); + + // THEN RNIterableAPI.updateUser is called with correct parameters + expect(MockRNIterableAPI.updateUser).toBeCalledWith( + dataFields, + mergeNestedObjects + ); + }); + }); + + describe('updateEmail', () => { + it('should call RNIterableAPI.updateEmail with email only', () => { + // GIVEN a new email + const email = 'newuser@example.com'; + + // WHEN updateEmail is called + IterableApi.updateEmail(email); + + // THEN RNIterableAPI.updateEmail is called with email + expect(MockRNIterableAPI.updateEmail).toBeCalledWith(email, undefined); + }); + + it('should call RNIterableAPI.updateEmail with email and auth token', () => { + // GIVEN a new email and auth token + const email = 'newuser@example.com'; + const authToken = 'new-jwt-token'; + + // WHEN updateEmail is called + IterableApi.updateEmail(email, authToken); + + // THEN RNIterableAPI.updateEmail is called with email and auth token + expect(MockRNIterableAPI.updateEmail).toBeCalledWith(email, authToken); + }); + + it('should call RNIterableAPI.updateEmail with null auth token', () => { + // GIVEN a new email and null auth token + const email = 'newuser@example.com'; + const authToken = null; + + // WHEN updateEmail is called + IterableApi.updateEmail(email, authToken); + + // THEN RNIterableAPI.updateEmail is called with email and null auth token + expect(MockRNIterableAPI.updateEmail).toBeCalledWith(email, null); + }); + }); + + // ====================================================== // + // ===================== TRACKING ====================== // + // ====================================================== // + + describe('trackPushOpenWithCampaignId', () => { + it('should call RNIterableAPI.trackPushOpenWithCampaignId with all parameters', () => { + // GIVEN push open parameters + const params = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + appAlreadyRunning: false, + dataFields: { source: 'push' }, + }; + + // WHEN trackPushOpenWithCampaignId is called + IterableApi.trackPushOpenWithCampaignId(params); + + // THEN RNIterableAPI.trackPushOpenWithCampaignId is called with correct parameters + expect(MockRNIterableAPI.trackPushOpenWithCampaignId).toBeCalledWith( + params.campaignId, + params.templateId, + params.messageId, + params.appAlreadyRunning, + params.dataFields + ); + }); + + it('should call RNIterableAPI.trackPushOpenWithCampaignId without dataFields', () => { + // GIVEN push open parameters without dataFields + const params = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + appAlreadyRunning: true, + }; + + // WHEN trackPushOpenWithCampaignId is called + IterableApi.trackPushOpenWithCampaignId(params); + + // THEN RNIterableAPI.trackPushOpenWithCampaignId is called with correct parameters + expect(MockRNIterableAPI.trackPushOpenWithCampaignId).toBeCalledWith( + params.campaignId, + params.templateId, + params.messageId, + params.appAlreadyRunning, + undefined + ); + }); + + it('should call RNIterableAPI.trackPushOpenWithCampaignId with null messageId', () => { + // GIVEN push open parameters with null messageId + const params = { + campaignId: 123, + templateId: 456, + messageId: null, + appAlreadyRunning: false, + }; + + // WHEN trackPushOpenWithCampaignId is called + IterableApi.trackPushOpenWithCampaignId(params); + + // THEN RNIterableAPI.trackPushOpenWithCampaignId is called with correct parameters + expect(MockRNIterableAPI.trackPushOpenWithCampaignId).toBeCalledWith( + params.campaignId, + params.templateId, + null, + params.appAlreadyRunning, + undefined + ); + }); + }); + + describe('trackPurchase', () => { + it('should call RNIterableAPI.trackPurchase with all parameters', () => { + // GIVEN purchase parameters + const total = 99.99; + const items = [ + new IterableCommerceItem('item1', 'Product 1', 49.99, 1), + new IterableCommerceItem('item2', 'Product 2', 49.99, 1), + ]; + const dataFields = { currency: 'USD', discount: 10 }; + + // WHEN trackPurchase is called + IterableApi.trackPurchase({ total, items, dataFields }); + + // THEN RNIterableAPI.trackPurchase is called with correct parameters + expect(MockRNIterableAPI.trackPurchase).toBeCalledWith( + total, + items, + dataFields + ); + }); + + it('should call RNIterableAPI.trackPurchase without dataFields', () => { + // GIVEN purchase parameters without dataFields + const total = 50.0; + const items = [new IterableCommerceItem('item1', 'Product 1', 50.0, 1)]; + + // WHEN trackPurchase is called + IterableApi.trackPurchase({ total, items }); + + // THEN RNIterableAPI.trackPurchase is called with correct parameters + expect(MockRNIterableAPI.trackPurchase).toBeCalledWith( + total, + items, + undefined + ); + }); + }); + + describe('trackInAppOpen', () => { + it('should call RNIterableAPI.trackInAppOpen with message and location', () => { + // GIVEN an in-app message and location + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + + // WHEN trackInAppOpen is called + IterableApi.trackInAppOpen({ message, location }); + + // THEN RNIterableAPI.trackInAppOpen is called with correct parameters + expect(MockRNIterableAPI.trackInAppOpen).toBeCalledWith( + message.messageId, + location + ); + }); + }); + + describe('trackInAppClick', () => { + it('should call RNIterableAPI.trackInAppClick with message, location, and clickedUrl', () => { + // GIVEN an in-app message, location, and clicked URL + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const clickedUrl = 'https://example.com'; + + // WHEN trackInAppClick is called + IterableApi.trackInAppClick({ message, location, clickedUrl }); + + // THEN RNIterableAPI.trackInAppClick is called with correct parameters + expect(MockRNIterableAPI.trackInAppClick).toBeCalledWith( + message.messageId, + location, + clickedUrl + ); + }); + }); + + describe('trackInAppClose', () => { + it('should call RNIterableAPI.trackInAppClose with message, location, and source', () => { + // GIVEN an in-app message, location, and source + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const source = IterableInAppCloseSource.back; + + // WHEN trackInAppClose is called + IterableApi.trackInAppClose({ message, location, source }); + + // THEN RNIterableAPI.trackInAppClose is called with correct parameters + expect(MockRNIterableAPI.trackInAppClose).toBeCalledWith( + message.messageId, + location, + source, + undefined + ); + }); + + it('should call RNIterableAPI.trackInAppClose with clickedUrl when provided', () => { + // GIVEN an in-app message, location, source, and clicked URL + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const source = IterableInAppCloseSource.link; + const clickedUrl = 'https://example.com'; + + // WHEN trackInAppClose is called + IterableApi.trackInAppClose({ message, location, source, clickedUrl }); + + // THEN RNIterableAPI.trackInAppClose is called with correct parameters + expect(MockRNIterableAPI.trackInAppClose).toBeCalledWith( + message.messageId, + location, + source, + clickedUrl + ); + }); + }); + + describe('trackEvent', () => { + it('should call RNIterableAPI.trackEvent with name and dataFields', () => { + // GIVEN event name and data fields + const name = 'customEvent'; + const dataFields = { category: 'user_action', value: 100 }; + + // WHEN trackEvent is called + IterableApi.trackEvent({ name, dataFields }); + + // THEN RNIterableAPI.trackEvent is called with correct parameters + expect(MockRNIterableAPI.trackEvent).toBeCalledWith(name, dataFields); + }); + + it('should call RNIterableAPI.trackEvent with name only', () => { + // GIVEN event name only + const name = 'simpleEvent'; + + // WHEN trackEvent is called + IterableApi.trackEvent({ name }); + + // THEN RNIterableAPI.trackEvent is called with correct parameters + expect(MockRNIterableAPI.trackEvent).toBeCalledWith(name, undefined); + }); + }); + + // ====================================================== // + // ======================= AUTH ======================= // + // ====================================================== // + + describe('pauseAuthRetries', () => { + it('should call RNIterableAPI.pauseAuthRetries with true', () => { + // GIVEN pauseRetry is true + const pauseRetry = true; + + // WHEN pauseAuthRetries is called + IterableApi.pauseAuthRetries(pauseRetry); + + // THEN RNIterableAPI.pauseAuthRetries is called with true + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(true); + }); + + it('should call RNIterableAPI.pauseAuthRetries with false', () => { + // GIVEN pauseRetry is false + const pauseRetry = false; + + // WHEN pauseAuthRetries is called + IterableApi.pauseAuthRetries(pauseRetry); + + // THEN RNIterableAPI.pauseAuthRetries is called with false + expect(MockRNIterableAPI.pauseAuthRetries).toBeCalledWith(false); + }); + }); + + describe('passAlongAuthToken', () => { + it('should call RNIterableAPI.passAlongAuthToken with valid token', () => { + // GIVEN a valid auth token + const authToken = 'valid-jwt-token'; + + // WHEN passAlongAuthToken is called + IterableApi.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with the token + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(authToken); + }); + + it('should call RNIterableAPI.passAlongAuthToken with null token', () => { + // GIVEN a null auth token + const authToken = null; + + // WHEN passAlongAuthToken is called + IterableApi.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with null + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(null); + }); + + it('should call RNIterableAPI.passAlongAuthToken with undefined token', () => { + // GIVEN an undefined auth token + const authToken = undefined; + + // WHEN passAlongAuthToken is called + IterableApi.passAlongAuthToken(authToken); + + // THEN RNIterableAPI.passAlongAuthToken is called with undefined + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith(undefined); + }); + }); + + // ====================================================== // + // ======================= IN-APP ======================= // + // ====================================================== // + + describe('inAppConsume', () => { + it('should call RNIterableAPI.inAppConsume with message, location, and source', () => { + // GIVEN an in-app message, location, and delete source + const message = new IterableInAppMessage( + 'msg123', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ); + const location = IterableInAppLocation.inApp; + const source = IterableInAppDeleteSource.deleteButton; + + // WHEN inAppConsume is called + IterableApi.inAppConsume(message, location, source); + + // THEN RNIterableAPI.inAppConsume is called with correct parameters + expect(MockRNIterableAPI.inAppConsume).toBeCalledWith( + message.messageId, + location, + source + ); + }); + }); + + describe('getInAppMessages', () => { + it('should return in-app messages from RNIterableAPI', async () => { + // GIVEN mock in-app messages + const mockMessages = [ + new IterableInAppMessage( + 'msg1', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + false, + undefined, + undefined, + false, + 0 + ), + new IterableInAppMessage( + 'msg2', + 456, + new IterableInAppTrigger(IterableInAppTriggerType.event), + new Date(), + new Date(), + true, + undefined, + undefined, + false, + 0 + ), + ]; + MockRNIterableAPI.messages = mockMessages; + + // WHEN getInAppMessages is called + const result = await IterableApi.getInAppMessages(); + + // THEN the messages are returned + expect(result).toBe(mockMessages); + }); + }); + + describe('getInboxMessages', () => { + it('should return inbox messages from RNIterableAPI', async () => { + // GIVEN mock inbox messages + const mockMessages = [ + new IterableInAppMessage( + 'msg1', + 123, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + new Date(), + true, // saveToInbox + undefined, + undefined, + false, + 0 + ), + ]; + MockRNIterableAPI.messages = mockMessages; + + // WHEN getInboxMessages is called + const result = await IterableApi.getInboxMessages(); + + // THEN the messages are returned + expect(result).toStrictEqual(mockMessages); + }); + }); + + describe('showMessage', () => { + it('should call RNIterableAPI.showMessage with messageId and consume flag', async () => { + // GIVEN a message ID and consume flag + const messageId = 'msg123'; + const consume = true; + const expectedUrl = 'https://example.com'; + MockRNIterableAPI.clickedUrl = expectedUrl; + + // WHEN showMessage is called + const result = await IterableApi.showMessage(messageId, consume); + + // THEN RNIterableAPI.showMessage is called with correct parameters + expect(MockRNIterableAPI.showMessage).toBeCalledWith(messageId, consume); + expect(result).toBe(expectedUrl); + }); + + it('should call RNIterableAPI.showMessage with consume set to false', async () => { + // GIVEN a message ID and consume flag set to false + const messageId = 'msg123'; + const consume = false; + + // WHEN showMessage is called + await IterableApi.showMessage(messageId, consume); + + // THEN RNIterableAPI.showMessage is called with consume set to false + expect(MockRNIterableAPI.showMessage).toBeCalledWith(messageId, false); + }); + }); + + describe('removeMessage', () => { + it('should call RNIterableAPI.removeMessage with messageId, location, and source', () => { + // GIVEN a message ID, location, and source + const messageId = 'msg123'; + const location = 1; // IterableInAppLocation.inApp + const source = 2; // IterableInAppDeleteSource.deleteButton + + // WHEN removeMessage is called + IterableApi.removeMessage(messageId, location, source); + + // THEN RNIterableAPI.removeMessage is called with correct parameters + expect(MockRNIterableAPI.removeMessage).toBeCalledWith( + messageId, + location, + source + ); + }); + }); + + describe('setReadForMessage', () => { + it('should call RNIterableAPI.setReadForMessage with messageId and read status', () => { + // GIVEN a message ID and read status + const messageId = 'msg123'; + const read = true; + + // WHEN setReadForMessage is called + IterableApi.setReadForMessage(messageId, read); + + // THEN RNIterableAPI.setReadForMessage is called with correct parameters + expect(MockRNIterableAPI.setReadForMessage).toBeCalledWith( + messageId, + read + ); + }); + + it('should call RNIterableAPI.setReadForMessage with read set to false', () => { + // GIVEN a message ID and read status set to false + const messageId = 'msg123'; + const read = false; + + // WHEN setReadForMessage is called + IterableApi.setReadForMessage(messageId, read); + + // THEN RNIterableAPI.setReadForMessage is called with read set to false + expect(MockRNIterableAPI.setReadForMessage).toBeCalledWith( + messageId, + false + ); + }); + }); + + describe('setAutoDisplayPaused', () => { + it('should call RNIterableAPI.setAutoDisplayPaused with true', () => { + // GIVEN autoDisplayPaused is true + const autoDisplayPaused = true; + + // WHEN setAutoDisplayPaused is called + IterableApi.setAutoDisplayPaused(autoDisplayPaused); + + // THEN RNIterableAPI.setAutoDisplayPaused is called with true + expect(MockRNIterableAPI.setAutoDisplayPaused).toBeCalledWith(true); + }); + + it('should call RNIterableAPI.setAutoDisplayPaused with false', () => { + // GIVEN autoDisplayPaused is false + const autoDisplayPaused = false; + + // WHEN setAutoDisplayPaused is called + IterableApi.setAutoDisplayPaused(autoDisplayPaused); + + // THEN RNIterableAPI.setAutoDisplayPaused is called with false + expect(MockRNIterableAPI.setAutoDisplayPaused).toBeCalledWith(false); + }); + }); + + describe('getHtmlInAppContentForMessage', () => { + it('should call RNIterableAPI.getHtmlInAppContentForMessage with messageId', async () => { + // GIVEN a message ID + const messageId = 'msg123'; + const mockContent = { html: '
Test content
' }; + MockRNIterableAPI.getHtmlInAppContentForMessage = jest + .fn() + .mockResolvedValue(mockContent); + + // WHEN getHtmlInAppContentForMessage is called + const result = await IterableApi.getHtmlInAppContentForMessage(messageId); + + // THEN RNIterableAPI.getHtmlInAppContentForMessage is called with messageId + expect(MockRNIterableAPI.getHtmlInAppContentForMessage).toBeCalledWith( + messageId + ); + expect(result).toBe(mockContent); + }); + }); + + describe('setInAppShowResponse', () => { + it('should call RNIterableAPI.setInAppShowResponse with response', () => { + // GIVEN an in-app show response + const response = IterableInAppShowResponse.show; + + // WHEN setInAppShowResponse is called + IterableApi.setInAppShowResponse(response); + + // THEN RNIterableAPI.setInAppShowResponse is called with response + expect(MockRNIterableAPI.setInAppShowResponse).toBeCalledWith(response); + }); + }); + + describe('startSession', () => { + it('should call RNIterableAPI.startSession with visible rows', () => { + // GIVEN visible rows + const visibleRows: IterableInboxImpressionRowInfo[] = [ + { messageId: 'msg1', silentInbox: true }, + { messageId: 'msg2', silentInbox: false }, + ]; + + // WHEN startSession is called + IterableApi.startSession(visibleRows); + + // THEN RNIterableAPI.startSession is called with visible rows + expect(MockRNIterableAPI.startSession).toBeCalledWith(visibleRows); + }); + }); + + describe('endSession', () => { + it('should call RNIterableAPI.endSession', () => { + // GIVEN no parameters + // WHEN endSession is called + IterableApi.endSession(); + + // THEN RNIterableAPI.endSession is called + expect(MockRNIterableAPI.endSession).toBeCalled(); + }); + }); + + describe('updateVisibleRows', () => { + it('should call RNIterableAPI.updateVisibleRows with visible rows', () => { + // GIVEN visible rows + const visibleRows: IterableInboxImpressionRowInfo[] = [ + { messageId: 'msg1', silentInbox: true }, + ]; + + // WHEN updateVisibleRows is called + IterableApi.updateVisibleRows(visibleRows); + + // THEN RNIterableAPI.updateVisibleRows is called with visible rows + expect(MockRNIterableAPI.updateVisibleRows).toBeCalledWith(visibleRows); + }); + + it('should call RNIterableAPI.updateVisibleRows with empty array when no rows provided', () => { + // GIVEN no visible rows + // WHEN updateVisibleRows is called without parameters + IterableApi.updateVisibleRows(); + + // THEN RNIterableAPI.updateVisibleRows is called with empty array + expect(MockRNIterableAPI.updateVisibleRows).toBeCalledWith([]); + }); + }); + + // ====================================================== // + // ======================= MOSC ======================= // + // ====================================================== // + + describe('updateCart', () => { + it('should call RNIterableAPI.updateCart with items', () => { + // GIVEN cart items + const items = [ + new IterableCommerceItem('item1', 'Product 1', 25.99, 2), + new IterableCommerceItem('item2', 'Product 2', 15.99, 1), + ]; + + // WHEN updateCart is called + IterableApi.updateCart(items); + + // THEN RNIterableAPI.updateCart is called with items + expect(MockRNIterableAPI.updateCart).toBeCalledWith(items); + }); + }); + + describe('wakeApp', () => { + it('should call RNIterableAPI.wakeApp on Android', () => { + // GIVEN Android platform + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // WHEN wakeApp is called + IterableApi.wakeApp(); + + // THEN RNIterableAPI.wakeApp is called + expect(MockRNIterableAPI.wakeApp).toBeCalled(); + + // Restore original platform + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + + it('should not call RNIterableAPI.wakeApp on iOS', () => { + // GIVEN iOS platform + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + + // WHEN wakeApp is called + IterableApi.wakeApp(); + + // THEN RNIterableAPI.wakeApp is not called + expect(MockRNIterableAPI.wakeApp).not.toBeCalled(); + + // Restore original platform + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + }); + + describe('handleAppLink', () => { + it('should call RNIterableAPI.handleAppLink with link', () => { + // GIVEN a link + const link = 'https://example.com/deep-link'; + + // WHEN handleAppLink is called + IterableApi.handleAppLink(link); + + // THEN RNIterableAPI.handleAppLink is called with link + expect(MockRNIterableAPI.handleAppLink).toBeCalledWith(link); + }); + }); + + describe('updateSubscriptions', () => { + it('should call RNIterableAPI.updateSubscriptions with all parameters', () => { + // GIVEN subscription parameters + const params = { + emailListIds: [1, 2, 3], + unsubscribedChannelIds: [4, 5], + unsubscribedMessageTypeIds: [6, 7, 8], + subscribedMessageTypeIds: [9, 10], + campaignId: 11, + templateId: 12, + }; + + // WHEN updateSubscriptions is called + IterableApi.updateSubscriptions(params); + + // THEN RNIterableAPI.updateSubscriptions is called with correct parameters + expect(MockRNIterableAPI.updateSubscriptions).toBeCalledWith( + params.emailListIds, + params.unsubscribedChannelIds, + params.unsubscribedMessageTypeIds, + params.subscribedMessageTypeIds, + params.campaignId, + params.templateId + ); + }); + + it('should call RNIterableAPI.updateSubscriptions with null arrays', () => { + // GIVEN subscription parameters with null arrays + const params = { + emailListIds: null, + unsubscribedChannelIds: null, + unsubscribedMessageTypeIds: null, + subscribedMessageTypeIds: null, + campaignId: 11, + templateId: 12, + }; + + // WHEN updateSubscriptions is called + IterableApi.updateSubscriptions(params); + + // THEN RNIterableAPI.updateSubscriptions is called with null arrays + expect(MockRNIterableAPI.updateSubscriptions).toBeCalledWith( + null, + null, + null, + null, + params.campaignId, + params.templateId + ); + }); + }); + + describe('getLastPushPayload', () => { + it('should return the last push payload from RNIterableAPI', async () => { + // GIVEN a mock push payload + const mockPayload = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + customData: { key: 'value' }, + }; + MockRNIterableAPI.lastPushPayload = mockPayload; + + // WHEN getLastPushPayload is called + const result = await IterableApi.getLastPushPayload(); + + // THEN the push payload is returned + expect(result).toBe(mockPayload); + }); + }); + + describe('getAttributionInfo', () => { + it('should return IterableAttributionInfo when attribution info exists', async () => { + // GIVEN mock attribution info + const mockAttributionDict = { + campaignId: 123, + templateId: 456, + messageId: 'msg123', + }; + MockRNIterableAPI.getAttributionInfo = jest + .fn() + .mockResolvedValue(mockAttributionDict); + + // WHEN getAttributionInfo is called + const result = await IterableApi.getAttributionInfo(); + + // THEN IterableAttributionInfo is returned + expect(result).toBeInstanceOf(IterableAttributionInfo); + expect(result?.campaignId).toBe(123); + expect(result?.templateId).toBe(456); + expect(result?.messageId).toBe('msg123'); + }); + + it('should return undefined when attribution info is null', async () => { + // GIVEN null attribution info + MockRNIterableAPI.getAttributionInfo = jest.fn().mockResolvedValue(null); + + // WHEN getAttributionInfo is called + const result = await IterableApi.getAttributionInfo(); + + // THEN undefined is returned + expect(result).toBeUndefined(); + }); + }); + + describe('setAttributionInfo', () => { + it('should call RNIterableAPI.setAttributionInfo with attribution info', () => { + // GIVEN attribution info + const attributionInfo = new IterableAttributionInfo(123, 456, 'msg123'); + + // WHEN setAttributionInfo is called + IterableApi.setAttributionInfo(attributionInfo); + + // THEN RNIterableAPI.setAttributionInfo is called with attribution info + expect(MockRNIterableAPI.setAttributionInfo).toBeCalledWith( + attributionInfo + ); + }); + + it('should call RNIterableAPI.setAttributionInfo with undefined', () => { + // GIVEN undefined attribution info + const attributionInfo = undefined; + + // WHEN setAttributionInfo is called + IterableApi.setAttributionInfo(attributionInfo); + + // THEN RNIterableAPI.setAttributionInfo is called with undefined + expect(MockRNIterableAPI.setAttributionInfo).toBeCalledWith(undefined); + }); + }); +}); diff --git a/src/core/classes/IterableAuthManager.ts b/src/core/classes/IterableAuthManager.ts index cb1022d46..44ece1af0 100644 --- a/src/core/classes/IterableAuthManager.ts +++ b/src/core/classes/IterableAuthManager.ts @@ -29,6 +29,12 @@ export class IterableAuthManager { * Pass along an auth token to the SDK. * * @param authToken - The auth token to pass along + * + * @example + * ```typescript + * const authManager = new IterableAuthManager(); + * authManager.passAlongAuthToken(MY_AUTH_TOKEN); + * ``` */ passAlongAuthToken( authToken: string | null | undefined diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 6623108e7..aeebfb91e 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -202,9 +202,9 @@ export class IterableConfig { * ``` * * @returns A promise that resolves to an `IterableAuthResponse`, a `string`, - * or `undefined`. + * `null`, or `undefined`. */ - authHandler?: () => Promise; + authHandler?: () => Promise; /** * A callback function that is called when the SDK encounters an error while @@ -218,23 +218,33 @@ export class IterableConfig { * @example * ```typescript * const config = new IterableConfig(); - * config.onJWTError = (authFailure) => { + * config.onJwtError = (authFailure) => { * console.error('Error fetching JWT:', authFailure); * }; * ``` */ - onJWTError?: (authFailure: IterableAuthFailure) => void; + onJwtError?: (authFailure: IterableAuthFailure) => void; /** * Set the verbosity of Android and iOS project's log system. * * By default, you will be able to see info level logs printed in IDE when running the app. */ - logLevel: IterableLogLevel = IterableLogLevel.info; + logLevel: IterableLogLevel = IterableLogLevel.debug; /** * Configuration for JWT refresh retry behavior. * If not specified, the SDK will use default retry behavior. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.retryPolicy = new IterableRetryPolicy({ + * maxRetries: 3, + * initialDelay: 1000, + * maxDelay: 10000, + * }); + * ``` */ retryPolicy?: IterableRetryPolicy; diff --git a/src/core/classes/IterableEdgeInsets.test.ts b/src/core/classes/IterableEdgeInsets.test.ts new file mode 100644 index 000000000..5be75f056 --- /dev/null +++ b/src/core/classes/IterableEdgeInsets.test.ts @@ -0,0 +1,347 @@ +import { IterableEdgeInsets } from './IterableEdgeInsets'; +import type { IterableEdgeInsetDetails } from '../types'; + +describe('IterableEdgeInsets', () => { + describe('constructor', () => { + it('should create instance with valid parameters', () => { + // GIVEN valid edge inset parameters + const top = 10; + const left = 20; + const bottom = 30; + const right = 40; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should have the correct properties + expect(edgeInsets.top).toBe(top); + expect(edgeInsets.left).toBe(left); + expect(edgeInsets.bottom).toBe(bottom); + expect(edgeInsets.right).toBe(right); + }); + + it('should create instance with zero values', () => { + // GIVEN zero edge inset parameters + const top = 0; + const left = 0; + const bottom = 0; + const right = 0; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should have zero values + expect(edgeInsets.top).toBe(0); + expect(edgeInsets.left).toBe(0); + expect(edgeInsets.bottom).toBe(0); + expect(edgeInsets.right).toBe(0); + }); + + it('should create instance with negative values', () => { + // GIVEN negative edge inset parameters + const top = -5; + const left = -10; + const bottom = -15; + const right = -20; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should have the negative values + expect(edgeInsets.top).toBe(-5); + expect(edgeInsets.left).toBe(-10); + expect(edgeInsets.bottom).toBe(-15); + expect(edgeInsets.right).toBe(-20); + }); + + it('should create instance with decimal values', () => { + // GIVEN decimal edge inset parameters + const top = 1.5; + const left = 2.7; + const bottom = 3.9; + const right = 4.1; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should have the decimal values + expect(edgeInsets.top).toBe(1.5); + expect(edgeInsets.left).toBe(2.7); + expect(edgeInsets.bottom).toBe(3.9); + expect(edgeInsets.right).toBe(4.1); + }); + + it('should create instance with large values', () => { + // GIVEN large edge inset parameters + const top = 1000; + const left = 2000; + const bottom = 3000; + const right = 4000; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should have the large values + expect(edgeInsets.top).toBe(1000); + expect(edgeInsets.left).toBe(2000); + expect(edgeInsets.bottom).toBe(3000); + expect(edgeInsets.right).toBe(4000); + }); + }); + + describe('fromDict', () => { + it('should create instance from valid dictionary', () => { + // GIVEN a valid dictionary with edge inset details + const dict: IterableEdgeInsetDetails = { + top: 10, + left: 20, + bottom: 30, + right: 40 + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have the correct properties + expect(edgeInsets.top).toBe(10); + expect(edgeInsets.left).toBe(20); + expect(edgeInsets.bottom).toBe(30); + expect(edgeInsets.right).toBe(40); + }); + + it('should create instance from dictionary with zero values', () => { + // GIVEN a dictionary with zero values + const dict: IterableEdgeInsetDetails = { + top: 0, + left: 0, + bottom: 0, + right: 0 + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have zero values + expect(edgeInsets.top).toBe(0); + expect(edgeInsets.left).toBe(0); + expect(edgeInsets.bottom).toBe(0); + expect(edgeInsets.right).toBe(0); + }); + + it('should create instance from dictionary with negative values', () => { + // GIVEN a dictionary with negative values + const dict: IterableEdgeInsetDetails = { + top: -5, + left: -10, + bottom: -15, + right: -20 + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have the negative values + expect(edgeInsets.top).toBe(-5); + expect(edgeInsets.left).toBe(-10); + expect(edgeInsets.bottom).toBe(-15); + expect(edgeInsets.right).toBe(-20); + }); + + it('should create instance from dictionary with decimal values', () => { + // GIVEN a dictionary with decimal values + const dict: IterableEdgeInsetDetails = { + top: 1.5, + left: 2.7, + bottom: 3.9, + right: 4.1 + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have the decimal values + expect(edgeInsets.top).toBe(1.5); + expect(edgeInsets.left).toBe(2.7); + expect(edgeInsets.bottom).toBe(3.9); + expect(edgeInsets.right).toBe(4.1); + }); + + it('should create instance from dictionary with mixed positive and negative values', () => { + // GIVEN a dictionary with mixed values + const dict: IterableEdgeInsetDetails = { + top: 10, + left: -5, + bottom: 0, + right: 15 + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have the mixed values + expect(edgeInsets.top).toBe(10); + expect(edgeInsets.left).toBe(-5); + expect(edgeInsets.bottom).toBe(0); + expect(edgeInsets.right).toBe(15); + }); + }); + + describe('property access', () => { + it('should allow property modification', () => { + // GIVEN an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(10, 20, 30, 40); + + // WHEN modifying properties + edgeInsets.top = 100; + edgeInsets.left = 200; + edgeInsets.bottom = 300; + edgeInsets.right = 400; + + // THEN the properties should be updated + expect(edgeInsets.top).toBe(100); + expect(edgeInsets.left).toBe(200); + expect(edgeInsets.bottom).toBe(300); + expect(edgeInsets.right).toBe(400); + }); + + it('should maintain property independence', () => { + // GIVEN an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(10, 20, 30, 40); + + // WHEN modifying one property + edgeInsets.top = 999; + + // THEN other properties should remain unchanged + expect(edgeInsets.top).toBe(999); + expect(edgeInsets.left).toBe(20); + expect(edgeInsets.bottom).toBe(30); + expect(edgeInsets.right).toBe(40); + }); + }); + + describe('edge cases', () => { + it('should handle NaN values', () => { + // GIVEN NaN values + const top = NaN; + const left = NaN; + const bottom = NaN; + const right = NaN; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should store NaN values + expect(edgeInsets.top).toBeNaN(); + expect(edgeInsets.left).toBeNaN(); + expect(edgeInsets.bottom).toBeNaN(); + expect(edgeInsets.right).toBeNaN(); + }); + + it('should handle Infinity values', () => { + // GIVEN Infinity values + const top = Infinity; + const left = -Infinity; + const bottom = Infinity; + const right = -Infinity; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should store Infinity values + expect(edgeInsets.top).toBe(Infinity); + expect(edgeInsets.left).toBe(-Infinity); + expect(edgeInsets.bottom).toBe(Infinity); + expect(edgeInsets.right).toBe(-Infinity); + }); + + it('should handle very small decimal values', () => { + // GIVEN very small decimal values + const top = 0.0001; + const left = 0.0002; + const bottom = 0.0003; + const right = 0.0004; + + // WHEN creating an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(top, left, bottom, right); + + // THEN it should store the small decimal values + expect(edgeInsets.top).toBe(0.0001); + expect(edgeInsets.left).toBe(0.0002); + expect(edgeInsets.bottom).toBe(0.0003); + expect(edgeInsets.right).toBe(0.0004); + }); + }); + + describe('interface compliance', () => { + it('should implement IterableEdgeInsetDetails interface', () => { + // GIVEN an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(10, 20, 30, 40); + + // THEN it should have all required properties + expect(edgeInsets).toHaveProperty('top'); + expect(edgeInsets).toHaveProperty('left'); + expect(edgeInsets).toHaveProperty('bottom'); + expect(edgeInsets).toHaveProperty('right'); + + // AND all properties should be numbers + expect(typeof edgeInsets.top).toBe('number'); + expect(typeof edgeInsets.left).toBe('number'); + expect(typeof edgeInsets.bottom).toBe('number'); + expect(typeof edgeInsets.right).toBe('number'); + }); + + it('should be assignable to IterableEdgeInsetDetails', () => { + // GIVEN an IterableEdgeInsets instance + const edgeInsets = new IterableEdgeInsets(10, 20, 30, 40); + + // WHEN assigning to IterableEdgeInsetDetails + const details: IterableEdgeInsetDetails = edgeInsets; + + // THEN it should work without type errors + expect(details.top).toBe(10); + expect(details.left).toBe(20); + expect(details.bottom).toBe(30); + expect(details.right).toBe(40); + }); + }); + + describe('fromDict with edge cases', () => { + it('should handle dictionary with NaN values', () => { + // GIVEN a dictionary with NaN values + const dict: IterableEdgeInsetDetails = { + top: NaN, + left: NaN, + bottom: NaN, + right: NaN + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have NaN values + expect(edgeInsets.top).toBeNaN(); + expect(edgeInsets.left).toBeNaN(); + expect(edgeInsets.bottom).toBeNaN(); + expect(edgeInsets.right).toBeNaN(); + }); + + it('should handle dictionary with Infinity values', () => { + // GIVEN a dictionary with Infinity values + const dict: IterableEdgeInsetDetails = { + top: Infinity, + left: -Infinity, + bottom: Infinity, + right: -Infinity + }; + + // WHEN creating from dictionary + const edgeInsets = IterableEdgeInsets.fromDict(dict); + + // THEN it should have Infinity values + expect(edgeInsets.top).toBe(Infinity); + expect(edgeInsets.left).toBe(-Infinity); + expect(edgeInsets.bottom).toBe(Infinity); + expect(edgeInsets.right).toBe(-Infinity); + }); + }); +}); diff --git a/src/core/classes/IterableLogger.test.ts b/src/core/classes/IterableLogger.test.ts index 9d35b4552..8caacdf86 100644 --- a/src/core/classes/IterableLogger.test.ts +++ b/src/core/classes/IterableLogger.test.ts @@ -1,5 +1,5 @@ import { IterableLogLevel } from '../enums/IterableLogLevel'; -import { IterableLogger } from './IterableLogger'; +import { IterableLogger, DEFAULT_LOG_LEVEL, DEFAULT_LOGGING_ENABLED } from './IterableLogger'; // Mock console.log to capture log output const mockConsoleLog = jest.fn(); @@ -8,8 +8,8 @@ const originalConsoleLog = console.log; describe('IterableLogger', () => { beforeEach(() => { // Reset to default values before each test - IterableLogger.loggingEnabled = true; - IterableLogger.logLevel = IterableLogLevel.info; + IterableLogger.loggingEnabled = DEFAULT_LOGGING_ENABLED; + IterableLogger.logLevel = DEFAULT_LOG_LEVEL; // Mock console.log console.log = mockConsoleLog; @@ -26,8 +26,8 @@ describe('IterableLogger', () => { expect(IterableLogger.loggingEnabled).toBe(true); }); - test('should have default log level as info', () => { - expect(IterableLogger.logLevel).toBe(IterableLogLevel.info); + test('should have default log level as debug', () => { + expect(IterableLogger.logLevel).toBe(IterableLogLevel.debug); }); test('should allow setting loggingEnabled directly', () => { @@ -86,9 +86,9 @@ describe('IterableLogger', () => { expect(IterableLogger.logLevel).toBe(IterableLogLevel.info); }); - test('should default to info when passed undefined', () => { + test('should default to debug when passed undefined', () => { IterableLogger.setLogLevel(undefined); - expect(IterableLogger.logLevel).toBe(IterableLogLevel.info); + expect(IterableLogger.logLevel).toBe(IterableLogLevel.debug); }); }); diff --git a/src/core/classes/IterableLogger.ts b/src/core/classes/IterableLogger.ts index 21e947df7..6ce8d0d7c 100644 --- a/src/core/classes/IterableLogger.ts +++ b/src/core/classes/IterableLogger.ts @@ -1,19 +1,20 @@ import { IterableLogLevel } from '../enums/IterableLogLevel'; -const DEFAULT_LOG_LEVEL = IterableLogLevel.info; -const DEFAULT_LOGGING_ENABLED = true; +export const DEFAULT_LOG_LEVEL = IterableLogLevel.debug; +export const DEFAULT_LOGGING_ENABLED = true; /** * A logger class for the Iterable SDK. * - * This class is responsible for logging messages based on the configuration provided. - * - * TODO: add a logLevel property to the IterableLogger class to control the level of logging. + * This class is responsible for logging messages based on the configuration + * provided, is useful in unit testing or debug environments. * * @remarks * The logging behavior is controlled by the `logReactNativeSdkCalls` property * in {@link IterableConfig}. - * If this property is not set, logging defaults to `true`, which is useful in unit testing or debug environments. + * + * If this property is not set, logging defaults to `true`, which is useful in + * unit testing or debug environments. * * @example * ```typescript @@ -37,7 +38,9 @@ export class IterableLogger { static loggingEnabled = DEFAULT_LOGGING_ENABLED; /** - * The level of logging to show in the developer console. + * The level of logging. + * + * This controls which logs will show when using the {@link IterableLogger.error}, {@link IterableLogger.debug}, and {@link IterableLogger.info} methods. */ static logLevel = DEFAULT_LOG_LEVEL; @@ -67,6 +70,11 @@ export class IterableLogger { * Logs a message to the console if logging is enabled. * * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.log('I will show if logging is enabled'); + * ``` */ static log(message?: unknown, ...optionalParams: unknown[]) { if (!IterableLogger.loggingEnabled) return; @@ -75,9 +83,14 @@ export class IterableLogger { } /** - * Logs a message to the console if the log level is error. + * Logs a message to the console if the log level is {@link IterableLogLevel.error}. * * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.error('I will only show if the log level is error and logging is enabled'); + * ``` */ static error(message?: unknown, ...optionalParams: unknown[]) { if (!IterableLogger.loggingEnabled) return; @@ -87,9 +100,16 @@ export class IterableLogger { } /** - * Logs a message to the console if the log level is debug or lower. + * Logs a message to the console if the log level is + * {@link IterableLogLevel.debug} or {@link IterableLogLevel.error}. * * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.debug('I will show if the log level is debug and logging is enabled'); + * IterableLogger.debug('I will also show if the log level is error and logging is enabled'); + * ``` */ static debug(message?: unknown, ...optionalParams: unknown[]) { if (!IterableLogger.loggingEnabled) return; @@ -104,9 +124,18 @@ export class IterableLogger { } /** - * Logs a message to the console if the log level is info or lower. + * Logs a message to the console if the log level is + * {@link IterableLogLevel.info}, {@link IterableLogLevel.debug} or + * {@link IterableLogLevel.error}. * * @param message - The message to be logged. + * + * @example + * ```typescript + * IterableLogger.info('I will show if the log level is info and logging is enabled'); + * IterableLogger.info('I will also show if the log level is debug and logging is enabled'); + * IterableLogger.info('I will also show if the log level is error and logging is enabled'); + * ``` */ static info(message?: unknown, ...optionalParams: unknown[]) { if (!IterableLogger.loggingEnabled) return; diff --git a/src/core/classes/IterableUtil.test.ts b/src/core/classes/IterableUtil.test.ts new file mode 100644 index 000000000..35217c619 --- /dev/null +++ b/src/core/classes/IterableUtil.test.ts @@ -0,0 +1,227 @@ +import { IterableUtil } from './IterableUtil'; + +/** + * Tests for IterableUtil class. + * + * Note: The current implementation of readBoolean has a limitation - it doesn't actually + * validate that the value is a boolean. It returns any truthy value as-is, or false for falsy values. + * This behavior is tested below, but the implementation may need to be updated to properly + * validate boolean types as suggested in the TODO comment in the source code. + */ +describe('IterableUtil', () => { + describe('readBoolean', () => { + it('should return true when the key exists and value is true', () => { + // GIVEN a dictionary with a true boolean value + const dict = { testKey: true }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return true + expect(result).toBe(true); + }); + + it('should return false when the key exists and value is false', () => { + // GIVEN a dictionary with a false boolean value + const dict = { testKey: false }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should return false when the key does not exist', () => { + // GIVEN a dictionary without the key + const dict = { otherKey: true }; + + // WHEN reading a non-existent key + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should return false when the key exists but value is undefined', () => { + // GIVEN a dictionary with undefined value + const dict = { testKey: undefined }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should return false when the key exists but value is null', () => { + // GIVEN a dictionary with null value + const dict = { testKey: null }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should return false when the key exists but value is 0', () => { + // GIVEN a dictionary with 0 value + const dict = { testKey: 0 }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should return false when the key exists but value is empty string', () => { + // GIVEN a dictionary with empty string value + const dict = { testKey: '' }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should return false when the key exists but value is NaN', () => { + // GIVEN a dictionary with NaN value + const dict = { testKey: NaN }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + // TODO: Verify that we want this to return a string instead of a boolean + it('should return truthy string as boolean when key exists', () => { + // GIVEN a dictionary with truthy string value + const dict = { testKey: 'true' }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return the string cast to boolean (truthy) + expect(result).toBe('true'); + }); + + // TODO: Verify that we want this to return a number instead of a boolean + it('should return truthy number as boolean when key exists', () => { + // GIVEN a dictionary with truthy number value + const dict = { testKey: 1 }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return the number cast to boolean (truthy) + expect(result).toBe(1); + }); + + // TODO: Verify that we want this to return an object instead of a boolean + it('should return truthy object as boolean when key exists', () => { + // GIVEN a dictionary with truthy object value + const dict = { testKey: {} }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return the object cast to boolean (truthy) + expect(result).toEqual({}); + }); + + // TODO: Verify that we want this to return an array instead of a boolean + it('should return truthy array as boolean when key exists', () => { + // GIVEN a dictionary with truthy array value + const dict = { testKey: [] }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return the array cast to boolean (truthy) + expect(result).toEqual([]); + }); + + // TODO: Verify that we want this to return a function instead of a boolean + it('should return truthy function as boolean when key exists', () => { + // GIVEN a dictionary with truthy function value + const dict = { testKey: () => {} }; + + // WHEN reading the boolean value + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return the function cast to boolean (truthy) + expect(result).toBeInstanceOf(Function); + }); + + it('should handle empty dictionary', () => { + // GIVEN an empty dictionary + const dict = {}; + + // WHEN reading a key from empty dictionary + const result = IterableUtil.readBoolean(dict, 'testKey'); + + // THEN it should return false + expect(result).toBe(false); + }); + + it('should handle dictionary with multiple keys', () => { + // GIVEN a dictionary with multiple keys + const dict = { + key1: true, + key2: false, + key3: 'string', + key4: 123 + }; + + // WHEN reading different keys + const result1 = IterableUtil.readBoolean(dict, 'key1'); + const result2 = IterableUtil.readBoolean(dict, 'key2'); + const result3 = IterableUtil.readBoolean(dict, 'key3'); + const result4 = IterableUtil.readBoolean(dict, 'key4'); + const result5 = IterableUtil.readBoolean(dict, 'nonExistentKey'); + + // THEN it should return correct values + expect(result1).toBe(true); + expect(result2).toBe(false); + expect(result3).toBe('string'); // truthy string is returned as-is + expect(result4).toBe(123); // truthy number is returned as-is + expect(result5).toBe(false); // key doesn't exist + }); + + it('should handle special boolean values', () => { + // GIVEN a dictionary with special boolean values + const dict = { + trueValue: true, + falseValue: false + }; + + // WHEN reading boolean values + const trueResult = IterableUtil.readBoolean(dict, 'trueValue'); + const falseResult = IterableUtil.readBoolean(dict, 'falseValue'); + + // THEN it should return the actual boolean values + expect(trueResult).toBe(true); + expect(falseResult).toBe(false); + }); + + it('should handle case sensitivity in keys', () => { + // GIVEN a dictionary with case-sensitive keys + const dict = { TestKey: true, testkey: false }; + + // WHEN reading with different case + const result1 = IterableUtil.readBoolean(dict, 'TestKey'); + const result2 = IterableUtil.readBoolean(dict, 'testkey'); + const result3 = IterableUtil.readBoolean(dict, 'TESTKEY'); + + // THEN it should be case sensitive + expect(result1).toBe(true); + expect(result2).toBe(false); + expect(result3).toBe(false); // key doesn't exist + }); + }); +}); diff --git a/src/core/enums/IterableAuthFailureReason.ts b/src/core/enums/IterableAuthFailureReason.ts index a61f7fa7e..51c610c4f 100644 --- a/src/core/enums/IterableAuthFailureReason.ts +++ b/src/core/enums/IterableAuthFailureReason.ts @@ -2,6 +2,10 @@ * The reason for the failure of an authentication attempt. * * This is generally related to JWT token validation. + * + * FIXME: Android returns the string (EG: `'AUTH_TOKEN_EXPIRATION_INVALID'`), + * but iOS returns the enum value (EG: `0`). These should be standardized so + * that they both return the same type on either platform. */ export enum IterableAuthFailureReason { /** diff --git a/src/core/enums/IterableRetryBackoff.ts b/src/core/enums/IterableRetryBackoff.ts index 526b58eaf..2d0147a3b 100644 --- a/src/core/enums/IterableRetryBackoff.ts +++ b/src/core/enums/IterableRetryBackoff.ts @@ -1,17 +1,17 @@ -/* eslint-disable tsdoc/syntax */ - /** * The type of backoff to use when retrying a request. */ export enum IterableRetryBackoff { /** * Linear backoff (each retry will wait for a fixed interval) - * TODO: check with @Ayyanchira if this is correct + * + * EG: 2 seconds, 4 seconds, 6 seconds, 8 seconds, etc. */ - LINEAR = 'LINEAR', + linear = 'LINEAR', /** * Exponential backoff (each retry will wait for an interval that increases exponentially) - * TODO: check with @Ayyanchira if this is correct + * + * EG: 2 seconds, 4 seconds, 8 seconds, 16 seconds, etc. */ - EXPONENTIAL = 'EXPONENTIAL', + exponential = 'EXPONENTIAL', } diff --git a/src/core/hooks/useDeviceOrientation.test.tsx b/src/core/hooks/useDeviceOrientation.test.tsx new file mode 100644 index 000000000..68bbf7262 --- /dev/null +++ b/src/core/hooks/useDeviceOrientation.test.tsx @@ -0,0 +1,424 @@ +import { renderHook, act } from '@testing-library/react-native'; + +import { useDeviceOrientation, type IterableDeviceOrientation } from './useDeviceOrientation'; + +describe('useDeviceOrientation', () => { + let useWindowDimensionsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + // Spy on useWindowDimensions + useWindowDimensionsSpy = jest.spyOn(require('react-native'), 'useWindowDimensions'); + }); + + afterEach(() => { + useWindowDimensionsSpy.mockRestore(); + }); + + describe('initial state', () => { + it('should return portrait orientation for portrait screen dimensions', () => { + // GIVEN screen dimensions in portrait mode + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return portrait orientation + expect(result.current).toEqual({ + height: 800, + width: 400, + isPortrait: true, + }); + }); + + it('should return landscape orientation for landscape screen dimensions', () => { + // GIVEN screen dimensions in landscape mode + useWindowDimensionsSpy.mockReturnValue({ + height: 400, + width: 800, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return landscape orientation + expect(result.current).toEqual({ + height: 400, + width: 800, + isPortrait: false, + }); + }); + + it('should return portrait orientation for square screen dimensions', () => { + // GIVEN square screen dimensions (height >= width should be portrait) + useWindowDimensionsSpy.mockReturnValue({ + height: 500, + width: 500, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return portrait orientation + expect(result.current).toEqual({ + height: 500, + width: 500, + isPortrait: true, + }); + }); + + it('should handle edge case where height is slightly larger than width', () => { + // GIVEN screen dimensions where height is slightly larger + useWindowDimensionsSpy.mockReturnValue({ + height: 401, + width: 400, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return portrait orientation + expect(result.current).toEqual({ + height: 401, + width: 400, + isPortrait: true, + }); + }); + }); + + describe('orientation changes', () => { + it('should update orientation when screen rotates from portrait to landscape', () => { + // GIVEN initial portrait dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + const { result, rerender } = renderHook(() => useDeviceOrientation()); + + // THEN initial state should be portrait + expect(result.current.isPortrait).toBe(true); + + // WHEN screen rotates to landscape + useWindowDimensionsSpy.mockReturnValue({ + height: 400, + width: 800, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + // THEN orientation should update to landscape + expect(result.current).toEqual({ + height: 400, + width: 800, + isPortrait: false, + }); + }); + + it('should update orientation when screen rotates from landscape to portrait', () => { + // GIVEN initial landscape dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 400, + width: 800, + scale: 1, + fontScale: 1, + }); + + const { result, rerender } = renderHook(() => useDeviceOrientation()); + + // THEN initial state should be landscape + expect(result.current.isPortrait).toBe(false); + + // WHEN screen rotates to portrait + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + // THEN orientation should update to portrait + expect(result.current).toEqual({ + height: 800, + width: 400, + isPortrait: true, + }); + }); + + it('should handle multiple orientation changes', () => { + // GIVEN initial portrait dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + const { result, rerender } = renderHook(() => useDeviceOrientation()); + + // THEN initial state should be portrait + expect(result.current.isPortrait).toBe(true); + + // WHEN rotating to landscape + useWindowDimensionsSpy.mockReturnValue({ + height: 400, + width: 800, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + expect(result.current.isPortrait).toBe(false); + + // WHEN rotating back to portrait + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + expect(result.current.isPortrait).toBe(true); + + // WHEN rotating to landscape again + useWindowDimensionsSpy.mockReturnValue({ + height: 400, + width: 800, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + expect(result.current.isPortrait).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle zero dimensions', () => { + // GIVEN zero dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 0, + width: 0, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return portrait (height >= width) + expect(result.current).toEqual({ + height: 0, + width: 0, + isPortrait: true, + }); + }); + + it('should handle very large dimensions', () => { + // GIVEN very large dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 10000, + width: 5000, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return portrait + expect(result.current).toEqual({ + height: 10000, + width: 5000, + isPortrait: true, + }); + }); + + it('should handle negative dimensions', () => { + // GIVEN negative dimensions (edge case) + useWindowDimensionsSpy.mockReturnValue({ + height: -100, + width: -200, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return landscape (height >= width, -100 >= -200) + expect(result.current).toEqual({ + height: -100, + width: -200, + isPortrait: true, + }); + }); + + it('should handle decimal dimensions', () => { + // GIVEN decimal dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800.5, + width: 400.3, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should return portrait + expect(result.current).toEqual({ + height: 800.5, + width: 400.3, + isPortrait: true, + }); + }); + }); + + describe('hook behavior', () => { + it('should maintain consistent state across re-renders with same dimensions', () => { + // GIVEN consistent dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + const { result, rerender } = renderHook(() => useDeviceOrientation()); + + const initialResult = result.current; + + // WHEN component re-renders with same dimensions + act(() => { + rerender(() => useDeviceOrientation()); + }); + + // THEN state should remain consistent + expect(result.current).toEqual(initialResult); + }); + + it('should return new object reference when dimensions change', () => { + // GIVEN initial dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + const { result, rerender } = renderHook(() => useDeviceOrientation()); + + const initialResult = result.current; + + // WHEN dimensions change + useWindowDimensionsSpy.mockReturnValue({ + height: 400, + width: 800, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + // THEN new object reference should be returned + expect(result.current).not.toBe(initialResult); + expect(result.current).toEqual({ + height: 400, + width: 800, + isPortrait: false, + }); + }); + + it('should handle rapid dimension changes', () => { + // GIVEN initial dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + const { result, rerender } = renderHook(() => useDeviceOrientation()); + + // WHEN rapid dimension changes occur + const dimensions = [ + { height: 400, width: 800 }, // landscape + { height: 800, width: 400 }, // portrait + { height: 600, width: 600 }, // square + { height: 300, width: 900 }, // landscape + ]; + + dimensions.forEach((dim) => { + useWindowDimensionsSpy.mockReturnValue({ + ...dim, + scale: 1, + fontScale: 1, + }); + + act(() => { + rerender(() => useDeviceOrientation()); + }); + + expect(result.current.height).toBe(dim.height); + expect(result.current.width).toBe(dim.width); + expect(result.current.isPortrait).toBe(dim.height >= dim.width); + }); + }); + }); + + describe('type safety', () => { + it('should return correct IterableDeviceOrientation interface', () => { + // GIVEN screen dimensions + useWindowDimensionsSpy.mockReturnValue({ + height: 800, + width: 400, + scale: 1, + fontScale: 1, + }); + + // WHEN the hook is rendered + const { result } = renderHook(() => useDeviceOrientation()); + + // THEN it should match the interface + const orientation: IterableDeviceOrientation = result.current; + expect(typeof orientation.height).toBe('number'); + expect(typeof orientation.width).toBe('number'); + expect(typeof orientation.isPortrait).toBe('boolean'); + }); + }); +}); diff --git a/src/inApp/classes/IterableHtmlInAppContent.test.ts b/src/inApp/classes/IterableHtmlInAppContent.test.ts new file mode 100644 index 000000000..3a836472a --- /dev/null +++ b/src/inApp/classes/IterableHtmlInAppContent.test.ts @@ -0,0 +1,402 @@ +import { IterableEdgeInsets } from '../../core'; + +import { IterableInAppContentType } from '../enums'; +import { IterableHtmlInAppContent } from './IterableHtmlInAppContent'; +import type { IterableHtmlInAppContentRaw } from '../types'; + +describe('IterableHtmlInAppContent', () => { + describe('constructor', () => { + it('should create instance with valid parameters', () => { + // GIVEN valid parameters + const edgeInsets = new IterableEdgeInsets(10, 20, 30, 40); + const html = '
Hello World
'; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should have the correct properties + expect(content.edgeInsets).toBe(edgeInsets); + expect(content.html).toBe(html); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance with empty HTML', () => { + // GIVEN empty HTML + const edgeInsets = new IterableEdgeInsets(0, 0, 0, 0); + const html = ''; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should have empty HTML + expect(content.html).toBe(''); + expect(content.edgeInsets).toBe(edgeInsets); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance with complex HTML', () => { + // GIVEN complex HTML content + const edgeInsets = new IterableEdgeInsets(5, 10, 15, 20); + const html = ` + + + Test + + + +
+

Welcome

+

This is a test message

+
+ + + `; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should have the complex HTML + expect(content.html).toBe(html); + expect(content.edgeInsets).toBe(edgeInsets); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance with HTML containing special characters', () => { + // GIVEN HTML with special characters + const edgeInsets = new IterableEdgeInsets(1, 2, 3, 4); + const html = '
Hello & Welcome! "Test" <tag>
'; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should preserve special characters + expect(content.html).toBe(html); + expect(content.edgeInsets).toBe(edgeInsets); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance with HTML containing JavaScript', () => { + // GIVEN HTML with JavaScript + const edgeInsets = new IterableEdgeInsets(10, 10, 10, 10); + const html = ` +
+ +

Content with script

+
+ `; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should preserve the JavaScript + expect(content.html).toBe(html); + expect(content.edgeInsets).toBe(edgeInsets); + expect(content.type).toBe(IterableInAppContentType.html); + }); + }); + + describe('fromDict', () => { + it('should create instance from valid dictionary', () => { + // GIVEN a valid dictionary + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: 10, + left: 20, + bottom: 30, + right: 40, + }, + html: '
Hello World
', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should have the correct properties + expect(content.html).toBe('
Hello World
'); + expect(content.edgeInsets.top).toBe(10); + expect(content.edgeInsets.left).toBe(20); + expect(content.edgeInsets.bottom).toBe(30); + expect(content.edgeInsets.right).toBe(40); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance from dictionary with empty HTML', () => { + // GIVEN a dictionary with empty HTML + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: 0, + left: 0, + bottom: 0, + right: 0, + }, + html: '', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should have empty HTML + expect(content.html).toBe(''); + expect(content.edgeInsets.top).toBe(0); + expect(content.edgeInsets.left).toBe(0); + expect(content.edgeInsets.bottom).toBe(0); + expect(content.edgeInsets.right).toBe(0); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance from dictionary with complex HTML', () => { + // GIVEN a dictionary with complex HTML + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: 5, + left: 10, + bottom: 15, + right: 20, + }, + html: ` + + Test + +
+

Title

+

Paragraph with bold text

+
+ + + `, + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should have the complex HTML + expect(content.html).toBe(dict.html); + expect(content.edgeInsets.top).toBe(5); + expect(content.edgeInsets.left).toBe(10); + expect(content.edgeInsets.bottom).toBe(15); + expect(content.edgeInsets.right).toBe(20); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance from dictionary with negative edge insets', () => { + // GIVEN a dictionary with negative edge insets + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: -5, + left: -10, + bottom: -15, + right: -20, + }, + html: '
Negative insets
', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should have negative edge insets + expect(content.html).toBe('
Negative insets
'); + expect(content.edgeInsets.top).toBe(-5); + expect(content.edgeInsets.left).toBe(-10); + expect(content.edgeInsets.bottom).toBe(-15); + expect(content.edgeInsets.right).toBe(-20); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should create instance from dictionary with decimal edge insets', () => { + // GIVEN a dictionary with decimal edge insets + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: 1.5, + left: 2.7, + bottom: 3.9, + right: 4.1, + }, + html: '
Decimal insets
', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should have decimal edge insets + expect(content.html).toBe('
Decimal insets
'); + expect(content.edgeInsets.top).toBe(1.5); + expect(content.edgeInsets.left).toBe(2.7); + expect(content.edgeInsets.bottom).toBe(3.9); + expect(content.edgeInsets.right).toBe(4.1); + expect(content.type).toBe(IterableInAppContentType.html); + }); + }); + + describe('property access', () => { + it('should allow property modification', () => { + // GIVEN an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent( + new IterableEdgeInsets(10, 20, 30, 40), + '
Original
' + ); + + // WHEN modifying properties + const newEdgeInsets = new IterableEdgeInsets(100, 200, 300, 400); + content.edgeInsets = newEdgeInsets; + content.html = '
Modified
'; + + // THEN the properties should be updated + expect(content.edgeInsets).toBe(newEdgeInsets); + expect(content.html).toBe('
Modified
'); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should maintain type property as html', () => { + // GIVEN an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent( + new IterableEdgeInsets(10, 20, 30, 40), + '
Test
' + ); + + // THEN the type should always be html + expect(content.type).toBe(IterableInAppContentType.html); + + // AND it should remain html even after property changes + content.html = '
Changed
'; + expect(content.type).toBe(IterableInAppContentType.html); + }); + }); + + describe('edge cases', () => { + it('should handle HTML with only whitespace', () => { + // GIVEN HTML with only whitespace + const edgeInsets = new IterableEdgeInsets(10, 20, 30, 40); + const html = ' \n\t '; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should preserve the whitespace + expect(content.html).toBe(' \n\t '); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should handle HTML with unicode characters', () => { + // GIVEN HTML with unicode characters + const edgeInsets = new IterableEdgeInsets(5, 5, 5, 5); + const html = '
Hello 世界 🌍 🚀
'; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should preserve unicode characters + expect(content.html).toBe('
Hello 世界 🌍 🚀
'); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should handle very long HTML content', () => { + // GIVEN very long HTML content + const edgeInsets = new IterableEdgeInsets(1, 1, 1, 1); + const longHtml = '
' + 'x'.repeat(10000) + '
'; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, longHtml); + + // THEN it should handle the long content + expect(content.html).toBe(longHtml); + expect(content.html.length).toBe(longHtml.length); // Should match the original length + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should handle HTML with malformed tags', () => { + // GIVEN HTML with malformed tags + const edgeInsets = new IterableEdgeInsets(10, 10, 10, 10); + const html = '
Unclosed tag

Another unclosed Nested

'; + + // WHEN creating an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent(edgeInsets, html); + + // THEN it should preserve the malformed HTML + expect(content.html).toBe(html); + expect(content.type).toBe(IterableInAppContentType.html); + }); + }); + + describe('interface compliance', () => { + it('should implement IterableInAppContent interface', () => { + // GIVEN an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent( + new IterableEdgeInsets(10, 20, 30, 40), + '
Test
' + ); + + // THEN it should have the required type property + expect(content).toHaveProperty('type'); + expect(typeof content.type).toBe('number'); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should be assignable to IterableInAppContent', () => { + // GIVEN an IterableHtmlInAppContent instance + const content = new IterableHtmlInAppContent( + new IterableEdgeInsets(10, 20, 30, 40), + '
Test
' + ); + + // WHEN assigning to IterableInAppContent + const inAppContent: { type: IterableInAppContentType } = content; + + // THEN it should work without type errors + expect(inAppContent.type).toBe(IterableInAppContentType.html); + }); + }); + + describe('fromDict with edge cases', () => { + it('should handle dictionary with NaN edge insets', () => { + // GIVEN a dictionary with NaN edge insets + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: NaN, + left: NaN, + bottom: NaN, + right: NaN, + }, + html: '
NaN insets
', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should handle NaN values + expect(content.html).toBe('
NaN insets
'); + expect(content.edgeInsets.top).toBeNaN(); + expect(content.edgeInsets.left).toBeNaN(); + expect(content.edgeInsets.bottom).toBeNaN(); + expect(content.edgeInsets.right).toBeNaN(); + expect(content.type).toBe(IterableInAppContentType.html); + }); + + it('should handle dictionary with Infinity edge insets', () => { + // GIVEN a dictionary with Infinity edge insets + const dict: IterableHtmlInAppContentRaw = { + edgeInsets: { + top: Infinity, + left: -Infinity, + bottom: Infinity, + right: -Infinity, + }, + html: '
Infinity insets
', + }; + + // WHEN creating from dictionary + const content = IterableHtmlInAppContent.fromDict(dict); + + // THEN it should handle Infinity values + expect(content.html).toBe('
Infinity insets
'); + expect(content.edgeInsets.top).toBe(Infinity); + expect(content.edgeInsets.left).toBe(-Infinity); + expect(content.edgeInsets.bottom).toBe(Infinity); + expect(content.edgeInsets.right).toBe(-Infinity); + expect(content.type).toBe(IterableInAppContentType.html); + }); + }); +}); diff --git a/src/inApp/classes/IterableInAppManager.test.ts b/src/inApp/classes/IterableInAppManager.test.ts new file mode 100644 index 000000000..ce0fc6a48 --- /dev/null +++ b/src/inApp/classes/IterableInAppManager.test.ts @@ -0,0 +1,396 @@ +import { IterableInAppDeleteSource, IterableInAppLocation } from '../enums'; +import { IterableInAppManager } from './IterableInAppManager'; +import { IterableInAppMessage } from './IterableInAppMessage'; + +describe('IterableInAppManager', () => { + let manager: IterableInAppManager; + + beforeEach(() => { + manager = new IterableInAppManager(); + }); + + describe('constructor', () => { + it('should create an instance', () => { + // WHEN creating a new manager + const newManager = new IterableInAppManager(); + + // THEN it should be an instance of IterableInAppManager + expect(newManager).toBeInstanceOf(IterableInAppManager); + }); + }); + + describe('method signatures', () => { + it('should have getMessages method', () => { + // THEN the manager should have getMessages method + expect(typeof manager.getMessages).toBe('function'); + }); + + it('should have getInboxMessages method', () => { + // THEN the manager should have getInboxMessages method + expect(typeof manager.getInboxMessages).toBe('function'); + }); + + it('should have showMessage method', () => { + // THEN the manager should have showMessage method + expect(typeof manager.showMessage).toBe('function'); + }); + + it('should have removeMessage method', () => { + // THEN the manager should have removeMessage method + expect(typeof manager.removeMessage).toBe('function'); + }); + + it('should have setReadForMessage method', () => { + // THEN the manager should have setReadForMessage method + expect(typeof manager.setReadForMessage).toBe('function'); + }); + + it('should have getHtmlContentForMessage method', () => { + // THEN the manager should have getHtmlContentForMessage method + expect(typeof manager.getHtmlContentForMessage).toBe('function'); + }); + + it('should have setAutoDisplayPaused method', () => { + // THEN the manager should have setAutoDisplayPaused method + expect(typeof manager.setAutoDisplayPaused).toBe('function'); + }); + }); + + describe('getInboxMessages', () => { + it('should be a function', () => { + // THEN the method should be a function + expect(typeof manager.getInboxMessages).toBe('function'); + }); + + it('should have the correct method name', () => { + // THEN the method should be named getInboxMessages + expect(manager.getInboxMessages.name).toBe('getInboxMessages'); + }); + + it('should be a different method from getMessages', () => { + // THEN getInboxMessages should be different from getMessages + expect(manager.getInboxMessages).not.toBe(manager.getMessages); + expect(manager.getInboxMessages.name).not.toBe(manager.getMessages.name); + }); + + it('should return a Promise when called', async () => { + // WHEN calling getInboxMessages + const result = manager.getInboxMessages(); + + // THEN it should return a Promise + expect(result).toBeInstanceOf(Promise); + }); + + it('should return empty array when no inbox messages exist', async () => { + // GIVEN no messages are set in the mock + const { MockRNIterableAPI } = await import('../../__mocks__/MockRNIterableAPI'); + MockRNIterableAPI.setMessages([]); + + // WHEN calling getInboxMessages + const result = await manager.getInboxMessages(); + + // THEN it should return empty array + expect(result).toEqual([]); + }); + + it('should return only inbox messages when mixed messages exist', async () => { + // GIVEN mixed messages with some marked for inbox + const { MockRNIterableAPI } = await import('../../__mocks__/MockRNIterableAPI'); + const mockMessages = [ + { messageId: 'msg1', campaignId: 1, saveToInbox: true } as IterableInAppMessage, + { messageId: 'msg2', campaignId: 2, saveToInbox: false } as IterableInAppMessage, + { messageId: 'msg3', campaignId: 3, saveToInbox: true } as IterableInAppMessage, + ]; + MockRNIterableAPI.setMessages(mockMessages); + + // WHEN calling getInboxMessages + const result = await manager.getInboxMessages(); + + // THEN it should return only inbox messages + expect(result).toHaveLength(2); + expect(result?.[0]?.messageId).toBe('msg1'); + expect(result?.[1]?.messageId).toBe('msg3'); + }); + + it('should return all messages when all are marked for inbox', async () => { + // GIVEN all messages are marked for inbox + const { MockRNIterableAPI } = await import('../../__mocks__/MockRNIterableAPI'); + const mockMessages = [ + { messageId: 'msg1', campaignId: 1, saveToInbox: true } as IterableInAppMessage, + { messageId: 'msg2', campaignId: 2, saveToInbox: true } as IterableInAppMessage, + ]; + MockRNIterableAPI.setMessages(mockMessages); + + // WHEN calling getInboxMessages + const result = await manager.getInboxMessages(); + + // THEN it should return all messages + expect(result).toHaveLength(2); + expect(result).toEqual(mockMessages); + }); + + it('should handle undefined messages gracefully', async () => { + // GIVEN messages are undefined + const { MockRNIterableAPI } = await import('../../__mocks__/MockRNIterableAPI'); + MockRNIterableAPI.setMessages(undefined as unknown as IterableInAppMessage[]); + + // WHEN calling getInboxMessages + const result = await manager.getInboxMessages(); + + // THEN it should return empty array + expect(result).toEqual([]); + }); + }); + + describe('showMessage parameters', () => { + it('should accept IterableInAppMessage and boolean parameters', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling showMessage with valid parameters + // THEN it should not throw an error + expect(() => { + manager.showMessage(mockMessage, true); + manager.showMessage(mockMessage, false); + }).not.toThrow(); + }); + }); + + describe('removeMessage parameters', () => { + it('should accept IterableInAppMessage, IterableInAppLocation, and IterableInAppDeleteSource parameters', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling removeMessage with valid parameters + // THEN it should not throw an error + expect(() => { + manager.removeMessage( + mockMessage, + IterableInAppLocation.inApp, + IterableInAppDeleteSource.deleteButton + ); + manager.removeMessage( + mockMessage, + IterableInAppLocation.inbox, + IterableInAppDeleteSource.inboxSwipe + ); + manager.removeMessage( + mockMessage, + IterableInAppLocation.inApp, + IterableInAppDeleteSource.unknown + ); + }).not.toThrow(); + }); + }); + + describe('setReadForMessage parameters', () => { + it('should accept IterableInAppMessage and boolean parameters', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling setReadForMessage with valid parameters + // THEN it should not throw an error + expect(() => { + manager.setReadForMessage(mockMessage, true); + manager.setReadForMessage(mockMessage, false); + }).not.toThrow(); + }); + }); + + describe('getHtmlContentForMessage', () => { + it('should be a function', () => { + // THEN the method should be a function + expect(typeof manager.getHtmlContentForMessage).toBe('function'); + }); + + it('should return a Promise when called', async () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling getHtmlContentForMessage + const result = manager.getHtmlContentForMessage(mockMessage); + + // THEN it should return a Promise + expect(result).toBeInstanceOf(Promise); + }); + + it('should return HTML content for a message', async () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling getHtmlContentForMessage + const result = await manager.getHtmlContentForMessage(mockMessage); + + // THEN it should return HTML content + expect(result).toEqual({ + edgeInsets: { top: 10, left: 20, bottom: 30, right: 40 }, + html: '
Mock HTML content for message test-message-id
', + }); + }); + + it('should handle different message IDs', async () => { + // GIVEN different mock messages + const message1 = { messageId: 'msg1', campaignId: 1 } as IterableInAppMessage; + const message2 = { messageId: 'msg2', campaignId: 2 } as IterableInAppMessage; + + // WHEN calling getHtmlContentForMessage with different messages + const result1 = await manager.getHtmlContentForMessage(message1); + const result2 = await manager.getHtmlContentForMessage(message2); + + // THEN it should return different HTML content for each message + expect(result1).toEqual({ + edgeInsets: { top: 10, left: 20, bottom: 30, right: 40 }, + html: '
Mock HTML content for message msg1
', + }); + expect(result2).toEqual({ + edgeInsets: { top: 10, left: 20, bottom: 30, right: 40 }, + html: '
Mock HTML content for message msg2
', + }); + }); + }); + + describe('setAutoDisplayPaused parameters', () => { + it('should accept boolean parameter', () => { + // WHEN calling setAutoDisplayPaused with valid parameters + // THEN it should not throw an error + expect(() => { + manager.setAutoDisplayPaused(true); + manager.setAutoDisplayPaused(false); + }).not.toThrow(); + }); + }); + + describe('enum values', () => { + it('should have correct IterableInAppLocation enum values', () => { + // THEN the enum values should be correct + expect(IterableInAppLocation.inApp).toBe(0); + expect(IterableInAppLocation.inbox).toBe(1); + }); + + it('should have correct IterableInAppDeleteSource enum values', () => { + // THEN the enum values should be correct + expect(IterableInAppDeleteSource.inboxSwipe).toBe(0); + expect(IterableInAppDeleteSource.deleteButton).toBe(1); + expect(IterableInAppDeleteSource.unknown).toBe(100); + }); + }); + + describe('method return types', () => { + it('should return Promise for async methods', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling async methods that don't require native modules + const showMessagePromise = manager.showMessage(mockMessage, true); + + // THEN they should return Promises + expect(showMessagePromise).toBeInstanceOf(Promise); + }); + + it('should return void for sync methods', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling sync methods + const removeMessageResult = manager.removeMessage( + mockMessage, + IterableInAppLocation.inApp, + IterableInAppDeleteSource.deleteButton + ); + const setReadResult = manager.setReadForMessage(mockMessage, true); + const setAutoDisplayResult = manager.setAutoDisplayPaused(true); + + // THEN they should return undefined (void) + expect(removeMessageResult).toBeUndefined(); + expect(setReadResult).toBeUndefined(); + expect(setAutoDisplayResult).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should handle null message parameters', () => { + // WHEN calling methods with null message + // THEN it should throw appropriate errors + expect(() => { + manager.removeMessage(null as unknown as IterableInAppMessage, IterableInAppLocation.inApp, IterableInAppDeleteSource.unknown); + }).toThrow(); + + expect(() => { + manager.setReadForMessage(null as unknown as IterableInAppMessage, true); + }).toThrow(); + + expect(() => { + manager.getHtmlContentForMessage(null as unknown as IterableInAppMessage); + }).toThrow(); + }); + + it('should handle undefined message parameters', () => { + // WHEN calling methods with undefined message + // THEN it should throw appropriate errors + expect(() => { + manager.removeMessage(undefined as unknown as IterableInAppMessage, IterableInAppLocation.inApp, IterableInAppDeleteSource.unknown); + }).toThrow(); + + expect(() => { + manager.setReadForMessage(undefined as unknown as IterableInAppMessage, true); + }).toThrow(); + + expect(() => { + manager.getHtmlContentForMessage(undefined as unknown as IterableInAppMessage); + }).toThrow(); + }); + }); + + describe('parameter validation', () => { + it('should handle invalid enum values gracefully', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling removeMessage with invalid enum values + // THEN it should not throw an error (values are passed through) + expect(() => { + manager.removeMessage(mockMessage, 999 as IterableInAppLocation, 888 as IterableInAppDeleteSource); + }).not.toThrow(); + }); + + it('should handle invalid boolean parameters', () => { + // GIVEN a mock message + const mockMessage = { + messageId: 'test-message-id', + campaignId: 123, + } as IterableInAppMessage; + + // WHEN calling methods with invalid boolean parameters + // THEN it should not throw an error (values are passed through) + expect(() => { + manager.showMessage(mockMessage, 'true' as unknown as boolean); + manager.setReadForMessage(mockMessage, 'false' as unknown as boolean); + manager.setAutoDisplayPaused('true' as unknown as boolean); + }).not.toThrow(); + }); + }); +}); diff --git a/src/inApp/classes/IterableInAppManager.ts b/src/inApp/classes/IterableInAppManager.ts index 3d6a3cbf8..ae34f80d1 100644 --- a/src/inApp/classes/IterableInAppManager.ts +++ b/src/inApp/classes/IterableInAppManager.ts @@ -22,7 +22,7 @@ import { IterableInAppMessage } from './IterableInAppMessage'; * console.log('Messages:', messages); * }); * - * // You can also access an instance on `Iterable.inAppManager.inAppManager` + * // You can also access an instance on `Iterable.inAppManager` * Iterable.inAppManager.getMessages().then(messages => { * console.log('Messages:', messages); * }); diff --git a/src/inApp/classes/IterableInAppMessage.ts b/src/inApp/classes/IterableInAppMessage.ts index f372043b6..c77b08e63 100644 --- a/src/inApp/classes/IterableInAppMessage.ts +++ b/src/inApp/classes/IterableInAppMessage.ts @@ -133,7 +133,6 @@ export class IterableInAppMessage { * * @param viewToken - The `ViewToken` containing the in-app message data. * @returns A new instance of `IterableInAppMessage` populated with data from the `viewToken`. - * @throws Error if the viewToken or its item or inAppMessage is null/undefined. */ static fromViewToken(viewToken: ViewToken) { const inAppMessage = viewToken?.item?.inAppMessage as IterableInAppMessage; diff --git a/src/inbox/components/HeaderBackButton.test.tsx b/src/inbox/components/HeaderBackButton.test.tsx new file mode 100644 index 000000000..76917e0c0 --- /dev/null +++ b/src/inbox/components/HeaderBackButton.test.tsx @@ -0,0 +1,243 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render } from '@testing-library/react-native'; +import { PixelRatio } from 'react-native'; +import { HeaderBackButton, ICON_MARGIN, ICON_SIZE } from './HeaderBackButton'; + +describe('HeaderBackButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { getByTestId } = render(); + expect(getByTestId('back-button')).toBeTruthy(); + }); + + it('should render with default back arrow image', () => { + const { UNSAFE_getByType } = render(); + const image = UNSAFE_getByType('Image' as any); + expect(image).toBeTruthy(); + expect(image.props.source).toMatchObject({ + uri: expect.stringContaining('data:image/png;base64'), + width: PixelRatio.getPixelSizeForLayoutSize(ICON_SIZE), + height: PixelRatio.getPixelSizeForLayoutSize(ICON_SIZE), + }); + }); + + it('should render without label by default', () => { + const { queryByText } = render(); + expect(queryByText(/./)).toBeNull(); + }); + + it('should render with label when provided', () => { + const label = 'Back'; + const { getByText } = render(); + expect(getByText(label)).toBeTruthy(); + }); + + it('should render with custom label text', () => { + const customLabel = 'Go Back to Home'; + const { getByText } = render(); + expect(getByText(customLabel)).toBeTruthy(); + }); + }); + + describe('Custom Image Props', () => { + it('should render with custom imageUri', () => { + const customUri = 'https://example.com/custom-back-icon.png'; + const { UNSAFE_getByType } = render( + + ); + const image = UNSAFE_getByType('Image' as any); + expect(image.props.source).toMatchObject({ + uri: customUri, + }); + }); + + it('should render with custom imageSource', () => { + const customSource = { uri: 'https://example.com/icon.png' }; + const { UNSAFE_getByType } = render( + + ); + const image = UNSAFE_getByType('Image' as any); + expect(image.props.source).toEqual(customSource); + }); + + it('should prioritize imageSource over imageUri when both are provided', () => { + const customUri = 'https://example.com/custom-back-icon.png'; + const customSource = { uri: 'https://example.com/icon.png' }; + const { UNSAFE_getByType } = render( + + ); + const image = UNSAFE_getByType('Image' as any); + expect(image.props.source).toEqual(customSource); + }); + }); + + describe('Image Properties', () => { + it('should render image with correct properties', () => { + const { UNSAFE_getByType } = render(); + const image = UNSAFE_getByType('Image' as any); + + expect(image.props.resizeMode).toBe('contain'); + expect(image.props.fadeDuration).toBe(0); + expect(image.props.height).toBe(ICON_SIZE); + expect(image.props.width).toBe(ICON_SIZE); + expect(image.props.resizeMethod).toBe('scale'); + expect(image.props.tintColor).toBeTruthy(); + }); + + it('should apply correct style to image', () => { + const { UNSAFE_getByType } = render(); + const image = UNSAFE_getByType('Image' as any); + + expect(image.props.style).toMatchObject({ + height: ICON_SIZE, + margin: ICON_MARGIN, + width: ICON_SIZE, + }); + }); + }); + + describe('Touch Interaction', () => { + it('should call onPress when button is pressed', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent.press(getByTestId('back-button')); + expect(onPressMock).toHaveBeenCalledTimes(1); + }); + + it('should call onPressIn when touch starts', () => { + const onPressInMock = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent(getByTestId('back-button'), 'pressIn'); + expect(onPressInMock).toHaveBeenCalledTimes(1); + }); + + it('should call onPressOut when touch ends', () => { + const onPressOutMock = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent(getByTestId('back-button'), 'pressOut'); + expect(onPressOutMock).toHaveBeenCalledTimes(1); + }); + + it('should not trigger onPress when disabled', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent.press(getByTestId('back-button')); + expect(onPressMock).not.toHaveBeenCalled(); + }); + }); + + describe('Platform-specific behavior', () => { + it('should export correct icon size constant', () => { + // ICON_SIZE is evaluated at module load time based on Platform.OS + expect(ICON_SIZE).toBeDefined(); + expect([21, 24]).toContain(ICON_SIZE); + }); + + it('should export correct icon margin constant', () => { + // ICON_MARGIN is evaluated at module load time based on Platform.OS + expect(ICON_MARGIN).toBeDefined(); + expect([3, 8]).toContain(ICON_MARGIN); + }); + + it('should use consistent icon size in image props', () => { + const { UNSAFE_getByType } = render(); + const image = UNSAFE_getByType('Image' as any); + + expect(image.props.height).toBe(ICON_SIZE); + expect(image.props.width).toBe(ICON_SIZE); + }); + }); + + describe('Accessibility', () => { + it('should accept accessibility props', () => { + const { getByTestId } = render( + + ); + + const button = getByTestId('back-button'); + expect(button.props.accessible).toBe(true); + expect(button.props.accessibilityLabel).toBe('Navigate back'); + expect(button.props.accessibilityHint).toBe('Returns to previous screen'); + }); + }); + + describe('Component Structure', () => { + it('should render View with correct flex direction', () => { + const { UNSAFE_getAllByType } = render(); + const views = UNSAFE_getAllByType('View' as any); + + // Find the view with returnButton style + const returnButtonView = views.find( + (view) => + view.props.style?.flexDirection === 'row' && + view.props.style?.alignItems === 'center' + ); + expect(returnButtonView).toBeTruthy(); + }); + + it('should render label text with correct style when provided', () => { + const { getByText } = render(); + const labelElement = getByText('Back'); + + expect(labelElement.props.style).toMatchObject({ + fontSize: 20, + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty string label', () => { + const { queryByText } = render(); + // Empty string should not render text + expect(queryByText('')).toBeNull(); + }); + + it('should handle multiple props correctly', () => { + const onPressMock = jest.fn(); + const label = 'Custom Back'; + const customUri = 'https://example.com/icon.png'; + + const { getByText, getByTestId } = render( + + ); + + expect(getByText(label)).toBeTruthy(); + expect(getByTestId('back-button').props.accessible).toBe(true); + + fireEvent.press(getByTestId('back-button')); + expect(onPressMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/inbox/components/HeaderBackButton.tsx b/src/inbox/components/HeaderBackButton.tsx new file mode 100644 index 000000000..15687ac4e --- /dev/null +++ b/src/inbox/components/HeaderBackButton.tsx @@ -0,0 +1,94 @@ +import { + Image, + PixelRatio, + Platform, + StyleSheet, + Text, + TouchableWithoutFeedback, + View, + type ImageSourcePropType, + type TouchableWithoutFeedbackProps, +} from 'react-native'; +import { ITERABLE_INBOX_COLORS } from '../constants/colors'; + +// Base64 encoded back arrow icons +// [Original image](https://github.com/react-navigation/react-navigation/blob/main/packages/elements/src/assets/back-icon%404x.ios.png) +const backArrowLarge = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAABUCAYAAADEZ7TLAAAC8ElEQVR42u2bWchOaxTH/xfnHMcZnHkwRobwJfdIkkSSkpKSEikREcla+81X5tR34c61O8kQIknmKUnGyCzzjczzz817Kfm+9dbasn/13P/X/1n72XuvZy2VGmeQnA1yrsh4LGe/DNda2qnUNPOdjBY572XwiXVeNZpUStbxvZyNMvjsci7Ud6Jk4o3NMvjCVStT2vwgY2srxCPnYHnEO9tl0Mr1WOmspZ2cHTJow7qY7fyPcnbJoI1rvdJoob2M3QHxr9RM/yznf5KxJyAeOQsyxe8NiS9YpRTW8LOMfUHnl2c5/4uMA0HnlymF1fwq53BIvLEky/kOco4G06ZQCqv4Tc7xoPOLs5z/XcaJoPhFSmElf8g5+XWe8yv4S86p4GkzLytt/pZzOuj8nCzn/5FzJuj8rCzn/5VzLiD+gwpmKoXl/CfjfEi8M0MpGB1lXAw6Py0rbTrJuBR4WN+rYGqW+C5yLofEG1OUgtNVzpVA2rxTjclZzneTcy0k3pmUJb67jOuBtHkrZ6JSKOgh42ZIfMGELPE95dwKpM0bFYzPEt9bxu2QeGdcVs73kXEnkDav5YzNEt9Xzt1Q0ckYkyW+v4z7QfGjlUKNJjkPAmnzUgUjs8QPkPEw4PwLFYzIEj9QzqOA889VMDwr5/8PiTeeqWCY0jDWB5x/KmeoMgkcl0/kDFGA/ABqDFadKoVCD7HxsFEPcXWMluVFVn1KtJmCfnLuBYMYpTrV53T2D012EL0a+UtZ/dSHyirOjUaVVarCVqi0aFxtVGmxKu4GgujcyPJ6dcGResVkTFed6pIvdM1qnM2+Zs2/6HZmK0h+q0HB3Owg/gw3ezjzVadqtylLw1Ney5lxLLgTlr0THWQcCe5ETUHibZfGoeDp1Kwg+Y2vzlIFyW89LlihIPnN385qpdJC+3D7vbFQQfIHIGo0lWEEZWdkBKUaAmrgGNa2+BhWfhBbWhnAgTLOUW4KfmLkT7K2Zhi0GsetBqK/dT4Csa/f2ZlrPiMAAAAASUVORK5CYII='; + +export const ICON_SIZE = Platform.OS === 'ios' ? 21 : 24; +export const ICON_MARGIN = Platform.OS === 'ios' ? 8 : 3; +const ICON_COLOR = ITERABLE_INBOX_COLORS.BUTTON_PRIMARY_TEXT; + +const styles = StyleSheet.create({ + icon: { + height: ICON_SIZE, + margin: ICON_MARGIN, + width: ICON_SIZE, + }, + + returnButton: { + alignItems: 'center', + display: 'flex', + flexDirection: 'row', + }, + + returnButtonText: { + color: ITERABLE_INBOX_COLORS.BUTTON_PRIMARY_TEXT, + fontSize: 20, + }, +}); + +/** + * Props for the HeaderBackButton component. + */ +export interface HeaderBackButtonProps extends TouchableWithoutFeedbackProps { + /** + * The text to display next to the back arrow. + */ + label?: string; + /** + * The URI of the image to display. + * + * This defaults to a base64 encoded version of [the back arrow used in react-navigation/elements](https://github.com/react-navigation/react-navigation/blob/main/packages/elements/src/assets/back-icon%404x.ios.png) + */ + imageUri?: string; + /** + * The source of the image to display. + */ + imageSource?: ImageSourcePropType; +} + +/** + * A back arrow button used in a header + * + * @returns A button with a back arrow + */ +export const HeaderBackButton = ({ + label, + imageUri = backArrowLarge, + imageSource = { + uri: imageUri, + width: PixelRatio.getPixelSizeForLayoutSize(ICON_SIZE), + height: PixelRatio.getPixelSizeForLayoutSize(ICON_SIZE), + }, + ...props +}: HeaderBackButtonProps) => { + return ( + + + + {label && {label}} + + + ); +}; diff --git a/src/inbox/components/IterableInboxMessageDisplay.tsx b/src/inbox/components/IterableInboxMessageDisplay.tsx index 7e6798c73..d42306a04 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -1,13 +1,12 @@ import { useEffect, useState } from 'react'; import { Linking, + Platform, ScrollView, StyleSheet, Text, - TouchableWithoutFeedback, View, } from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { @@ -23,9 +22,9 @@ import { IterableInAppCloseSource, IterableInAppLocation, } from '../../inApp'; - import { ITERABLE_INBOX_COLORS } from '../constants'; import { type IterableInboxRowViewModel } from '../types'; +import { HeaderBackButton } from './HeaderBackButton'; /** * Props for the IterableInboxMessageDisplay component. @@ -86,6 +85,7 @@ export const IterableInboxMessageDisplay = ({ header: { flexDirection: 'row', + height: Platform.OS === 'ios' ? 44 : 56, justifyContent: 'center', width: '100%', }, @@ -119,11 +119,6 @@ export const IterableInboxMessageDisplay = ({ fontWeight: 'bold', }, - returnButton: { - alignItems: 'center', - flexDirection: 'row', - }, - returnButtonContainer: { alignItems: 'center', flexDirection: 'row', @@ -133,17 +128,6 @@ export const IterableInboxMessageDisplay = ({ width: '25%', ...(isPortrait ? {} : { marginLeft: 80 }), }, - - returnButtonIcon: { - color: ITERABLE_INBOX_COLORS.BUTTON_PRIMARY_TEXT, - fontSize: 40, - paddingLeft: 0, - }, - - returnButtonText: { - color: ITERABLE_INBOX_COLORS.BUTTON_PRIMARY_TEXT, - fontSize: 20, - }, }); const JS = ` @@ -222,7 +206,8 @@ export const IterableInboxMessageDisplay = ({ - { returnToInbox(); Iterable.trackInAppClose( @@ -231,15 +216,7 @@ export const IterableInboxMessageDisplay = ({ IterableInAppCloseSource.back ); }} - > - - - Inbox - - + /> diff --git a/src/itblBuildInfo.ts b/src/itblBuildInfo.ts index 097bd43d2..fd6c8af1b 100644 --- a/src/itblBuildInfo.ts +++ b/src/itblBuildInfo.ts @@ -3,5 +3,5 @@ * It contains the version of the package */ export const buildInfo = { - version: '2.1.0-beta.1', + version: '2.2.0', }; diff --git a/yarn.lock b/yarn.lock index 9445d2b60..8e584876d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,19 +5,28 @@ __metadata: version: 6 cacheKey: 8 -"@ark/schema@npm:0.49.0": - version: 0.49.0 - resolution: "@ark/schema@npm:0.49.0" +"@ark/regex@npm:0.0.0": + version: 0.0.0 + resolution: "@ark/regex@npm:0.0.0" dependencies: - "@ark/util": 0.49.0 - checksum: 9901123581afa0eef63305fc47a1a725ff17c8958a80694464b0d08d3c398be26160763fed91864b8f8fb9553f3bf57d7075e436b6f7902220074f86310ee9d8 + "@ark/util": 0.50.0 + checksum: b89d50a610393a4025a0e2cb4444c16c4f2fb16708ee6e4afe36160ee3503c3a7a5df8a7477bbf4b75099509329fc62f388f64819002d2f93642b2188618b5e5 languageName: node linkType: hard -"@ark/util@npm:0.49.0": - version: 0.49.0 - resolution: "@ark/util@npm:0.49.0" - checksum: 01ae677327cd585d9bbdc9373d5d5d70e10a14be151976c7d86f27cc7289d6e4d51e3da3993c69aed1657f3aa4abe409834e6338a7a7391a30209fa34c066c14 +"@ark/schema@npm:0.50.0": + version: 0.50.0 + resolution: "@ark/schema@npm:0.50.0" + dependencies: + "@ark/util": 0.50.0 + checksum: 6a080104865ec4a0be91d6bffab95f69923f4a85b6087f67cf04555b30b65544084eeebbfa4cf9759ec27b964b0fc4dc7e19603b055b472a096463f13c084343 + languageName: node + linkType: hard + +"@ark/util@npm:0.50.0": + version: 0.50.0 + resolution: "@ark/util@npm:0.50.0" + checksum: 50aa1d506bbf70ef502f0f424370ab831fcb891f5a71fdec51c46d06c504eab751ccfa2920bbeae7c97d22bcfc71c755a29121489984eacf052040c61c696fc9 languageName: node linkType: hard @@ -1807,15 +1816,15 @@ __metadata: linkType: hard "@gerrit0/mini-shiki@npm:^3.12.0": - version: 3.13.0 - resolution: "@gerrit0/mini-shiki@npm:3.13.0" + version: 3.13.1 + resolution: "@gerrit0/mini-shiki@npm:3.13.1" dependencies: "@shikijs/engine-oniguruma": ^3.13.0 "@shikijs/langs": ^3.13.0 "@shikijs/themes": ^3.13.0 "@shikijs/types": ^3.13.0 "@shikijs/vscode-textmate": ^10.0.2 - checksum: 748d28e2dce67fac31cf36e97d849fc2bc60762b98a13c7bb50b6be181656c12ea58c5c6af7955fee99018b53fc9fd72dbf3a0552de7ad5845688b6c03312270 + checksum: e8a76c6091deb7ed67906e3966301ea7d51ecaad09fc1e189cd15df4f0b4f9f65e643b1f5f4545961b2552ba4fa45c4fe27020ea32ab82d10b26fe0ab5418c93 languageName: node linkType: hard @@ -1875,9 +1884,9 @@ __metadata: linkType: hard "@inquirer/figures@npm:^1.0.3": - version: 1.0.13 - resolution: "@inquirer/figures@npm:1.0.13" - checksum: 1042cbefad8c69b004396ce6be2d0b135c303317d870ddd0cee75bac429fc7c7f577bac9e3c1ec1cd3668a709f49a591edb2f714193778e7d7b140a622f2a1ef + version: 1.0.14 + resolution: "@inquirer/figures@npm:1.0.14" + checksum: 37eec986f119eabb6c231c8c1481c6a48ab2347e9f57b2d6442161f7b83936678221fccb7ead60582026c2ae20d457467d0727c485ff53aee2cf965077b0f51b languageName: node linkType: hard @@ -1938,7 +1947,7 @@ __metadata: "@babel/core": ^7.25.2 "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.25.0 - "@react-native-community/cli": 18.0.0 + "@react-native-community/cli": 18.0.1 "@react-native-community/cli-platform-android": 18.0.0 "@react-native-community/cli-platform-ios": 18.0.0 "@react-native/babel-preset": 0.79.3 @@ -1959,7 +1968,6 @@ __metadata: react-native-gesture-handler: ^2.26.0 react-native-safe-area-context: ^5.4.0 react-native-screens: ^4.10.0 - react-native-vector-icons: ^10.2.0 react-native-webview: ^13.14.1 react-test-renderer: 19.0.0 languageName: unknown @@ -1971,6 +1979,7 @@ __metadata: dependencies: "@commitlint/config-conventional": ^19.6.0 "@evilmartians/lefthook": ^1.5.0 + "@react-native-community/cli": 18.0.0 "@react-native/babel-preset": 0.79.3 "@react-native/eslint-config": 0.79.3 "@react-native/metro-config": 0.79.3 @@ -1981,7 +1990,6 @@ __metadata: "@testing-library/react-native": ^13.3.3 "@types/jest": ^29.5.5 "@types/react": ^19.0.0 - "@types/react-native-vector-icons": ^6.4.18 "@typescript-eslint/eslint-plugin": ^8.13.0 "@typescript-eslint/parser": ^8.13.0 commitlint: ^19.6.1 @@ -2000,7 +2008,6 @@ __metadata: react-native-gesture-handler: ^2.26.0 react-native-safe-area-context: ^5.4.0 react-native-screens: ^4.10.0 - react-native-vector-icons: ^10.2.0 react-native-webview: ^13.14.1 react-test-renderer: 19.0.0 release-it: ^17.10.0 @@ -2014,7 +2021,6 @@ __metadata: react: "*" react-native: "*" react-native-safe-area-context: "*" - react-native-vector-icons: "*" react-native-webview: "*" peerDependenciesMeta: expo: @@ -2606,6 +2612,18 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-clean@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-clean@npm:18.0.1" + dependencies: + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + execa: ^5.0.0 + fast-glob: ^3.3.2 + checksum: f2bd017b172e1ea23f91c717eefad145deb175c501b1b041bf91efffdfebfeedef7f33ac1cd5ab98dde8d4ccde520b3060422840cd6e6e24efb70b1b0aa72a9e + languageName: node + linkType: hard + "@react-native-community/cli-config-android@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-config-android@npm:18.0.0" @@ -2618,6 +2636,18 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-config-android@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-config-android@npm:18.0.1" + dependencies: + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + fast-glob: ^3.3.2 + fast-xml-parser: ^4.4.1 + checksum: 5343fef8b5feb32e8104a416048e7675dcf5a83de3af2ed0f00dcb5bbb3360dca665d93a973a7379de2f6ff8e0bc6608f763cc272784b6dc1dace6b97b947af2 + languageName: node + linkType: hard + "@react-native-community/cli-config-apple@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-config-apple@npm:18.0.0" @@ -2630,6 +2660,18 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-config-apple@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-config-apple@npm:18.0.1" + dependencies: + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + execa: ^5.0.0 + fast-glob: ^3.3.2 + checksum: 4c8716a0941af2c5f9910df71245df1f4cbce37cdbca55baa5b6aaff55f0b5fee5f24488146df0d225c157b0d339f76df94ddcf0f19e4374c67f72383ebd0fd7 + languageName: node + linkType: hard + "@react-native-community/cli-config@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-config@npm:18.0.0" @@ -2644,6 +2686,20 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-config@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-config@npm:18.0.1" + dependencies: + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + cosmiconfig: ^9.0.0 + deepmerge: ^4.3.0 + fast-glob: ^3.3.2 + joi: ^17.2.1 + checksum: b67d691e8ef47307a9079d42243e6126f780a16730ffedd3fca000cfb5719966f6d409b284012bd8b424df9af12d3f188fe57e64c6880c9e61ba51192ff78742 + languageName: node + linkType: hard + "@react-native-community/cli-doctor@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-doctor@npm:18.0.0" @@ -2667,6 +2723,29 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-doctor@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-doctor@npm:18.0.1" + dependencies: + "@react-native-community/cli-config": 18.0.1 + "@react-native-community/cli-platform-android": 18.0.1 + "@react-native-community/cli-platform-apple": 18.0.1 + "@react-native-community/cli-platform-ios": 18.0.1 + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + command-exists: ^1.2.8 + deepmerge: ^4.3.0 + envinfo: ^7.13.0 + execa: ^5.0.0 + node-stream-zip: ^1.9.1 + ora: ^5.4.1 + semver: ^7.5.2 + wcwidth: ^1.0.1 + yaml: ^2.2.1 + checksum: 605b08c443456a65a44540aad224b282206f872fef4b43e0027a162eef5f2dddc028d20268241c862618175b27c5718ffbd22b0d3d73aee0b252589cc145b6eb + languageName: node + linkType: hard + "@react-native-community/cli-platform-android@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-platform-android@npm:18.0.0" @@ -2680,6 +2759,19 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-platform-android@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-platform-android@npm:18.0.1" + dependencies: + "@react-native-community/cli-config-android": 18.0.1 + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + execa: ^5.0.0 + logkitty: ^0.7.1 + checksum: 25a413e68cc2d41367a0445861fca37142ffd5c475a7983b4423e1d12d0014389ba632035bcd92ef5cd99df1087ce3554c275422fcb1b2197eb29b747e2aa978 + languageName: node + linkType: hard + "@react-native-community/cli-platform-apple@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-platform-apple@npm:18.0.0" @@ -2693,6 +2785,19 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-platform-apple@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-platform-apple@npm:18.0.1" + dependencies: + "@react-native-community/cli-config-apple": 18.0.1 + "@react-native-community/cli-tools": 18.0.1 + chalk: ^4.1.2 + execa: ^5.0.0 + fast-xml-parser: ^4.4.1 + checksum: 8efaa76b43521afca9bc6eb423b758839e38cee7b4cf3927bc0b6b3d348ad9c98bc8f33366f780f59c8604d02e487de2f4554814ca354700cff01e09430ba365 + languageName: node + linkType: hard + "@react-native-community/cli-platform-ios@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-platform-ios@npm:18.0.0" @@ -2702,6 +2807,15 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-platform-ios@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-platform-ios@npm:18.0.1" + dependencies: + "@react-native-community/cli-platform-apple": 18.0.1 + checksum: 2eb0b662e9371721f524f242cfa04bccc62785d841ab110a3eef162a632216f7a5546d59afa0647bc4c3f7e0de305c030f96fd07119509df3cdef35e5f01f997 + languageName: node + linkType: hard + "@react-native-community/cli-server-api@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-server-api@npm:18.0.0" @@ -2720,6 +2834,24 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-server-api@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-server-api@npm:18.0.1" + dependencies: + "@react-native-community/cli-tools": 18.0.1 + body-parser: ^1.20.3 + compression: ^1.7.1 + connect: ^3.6.5 + errorhandler: ^1.5.1 + nocache: ^3.0.1 + open: ^6.2.0 + pretty-format: ^26.6.2 + serve-static: ^1.13.1 + ws: ^6.2.3 + checksum: ba0543bd6b7debdd2ca6e04075959ca1b04a9f4b5d883638112d0dbab2ee6b6f187880a44fb171ab3d59281dbd951914ada765811e089365f76abbcc8485c22c + languageName: node + linkType: hard + "@react-native-community/cli-tools@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-tools@npm:18.0.0" @@ -2738,6 +2870,24 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-tools@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-tools@npm:18.0.1" + dependencies: + "@vscode/sudo-prompt": ^9.0.0 + appdirsjs: ^1.2.4 + chalk: ^4.1.2 + execa: ^5.0.0 + find-up: ^5.0.0 + launch-editor: ^2.9.1 + mime: ^2.4.1 + ora: ^5.4.1 + prompts: ^2.4.2 + semver: ^7.5.2 + checksum: b2f40e9d8e442aacb5914ebb1ca00a729878184b2da96a3fb21c51d0050fb5b1f97789e6d6dfd39af269e840b74027de5716cab17b5ef983aa6a778e03e77f2c + languageName: node + linkType: hard + "@react-native-community/cli-types@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli-types@npm:18.0.0" @@ -2747,6 +2897,15 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli-types@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli-types@npm:18.0.1" + dependencies: + joi: ^17.2.1 + checksum: 26c5a92d31021fb54ec4ea700736105e24b48db8369ef5c75de9490faeaef96fa9f6a39fa298466854f63d71941c85404c2713ed1c4323c8b04cd519de511699 + languageName: node + linkType: hard + "@react-native-community/cli@npm:18.0.0": version: 18.0.0 resolution: "@react-native-community/cli@npm:18.0.0" @@ -2772,6 +2931,31 @@ __metadata: languageName: node linkType: hard +"@react-native-community/cli@npm:18.0.1": + version: 18.0.1 + resolution: "@react-native-community/cli@npm:18.0.1" + dependencies: + "@react-native-community/cli-clean": 18.0.1 + "@react-native-community/cli-config": 18.0.1 + "@react-native-community/cli-doctor": 18.0.1 + "@react-native-community/cli-server-api": 18.0.1 + "@react-native-community/cli-tools": 18.0.1 + "@react-native-community/cli-types": 18.0.1 + chalk: ^4.1.2 + commander: ^9.4.1 + deepmerge: ^4.3.0 + execa: ^5.0.0 + find-up: ^5.0.0 + fs-extra: ^8.1.0 + graceful-fs: ^4.1.3 + prompts: ^2.4.2 + semver: ^7.5.2 + bin: + rnc-cli: build/bin.js + checksum: 86b3154ce5fb27b654888e55529dab21ca0625b9c47143071d09bd3ee7741f63e8524b07c6c901734d7c9e33790990f1d63da541adf60f1279631cc33e9b25c2 + languageName: node + linkType: hard + "@react-native/assets-registry@npm:0.79.3": version: 0.79.3 resolution: "@react-native/assets-registry@npm:0.79.3" @@ -3008,8 +3192,8 @@ __metadata: linkType: hard "@react-navigation/bottom-tabs@npm:^7.0.0": - version: 7.4.8 - resolution: "@react-navigation/bottom-tabs@npm:7.4.8" + version: 7.4.9 + resolution: "@react-navigation/bottom-tabs@npm:7.4.9" dependencies: "@react-navigation/elements": ^2.6.5 color: ^4.2.3 @@ -3019,7 +3203,7 @@ __metadata: react-native: "*" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: b983a9fbb81b88609df1947a310ebc64008eff37421b481c57262bff2dc9e68f116ff44bb7c173ab4ecdb5d0b6dcbb32928d43c9ecdd656bb11e9d97a3089d03 + checksum: 6476a8bc5851be828f044447b843563a802288c87dedc17f23a1ad7001009a6ff3d506acafb3a65e9cf71948ce044527d6357ee4c9707c91b4024a08b3a8f2ac languageName: node linkType: hard @@ -3061,8 +3245,8 @@ __metadata: linkType: hard "@react-navigation/native-stack@npm:^7.0.0": - version: 7.3.27 - resolution: "@react-navigation/native-stack@npm:7.3.27" + version: 7.3.28 + resolution: "@react-navigation/native-stack@npm:7.3.28" dependencies: "@react-navigation/elements": ^2.6.5 warn-once: ^0.1.1 @@ -3072,7 +3256,7 @@ __metadata: react-native: "*" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 7719e78b86e3465a8a51ef302a54c059aa0e7ff38d671c898f71e91265a71843f6fc17ef783ff08e80b00c4f16902cba58f41fdd06efbc735b11a090d7f371d0 + checksum: 7cefd3b10f531de8abacb77e5152727a320e5d68e41c721bb82bb8202184fe0850f17416dd1be2229bc513577f8dbcfd6f3c131f92ce7f3f62842c76995bfe6b languageName: node linkType: hard @@ -3102,8 +3286,8 @@ __metadata: linkType: hard "@react-navigation/stack@npm:^7.4.2": - version: 7.4.9 - resolution: "@react-navigation/stack@npm:7.4.9" + version: 7.4.10 + resolution: "@react-navigation/stack@npm:7.4.10" dependencies: "@react-navigation/elements": ^2.6.5 color: ^4.2.3 @@ -3114,7 +3298,7 @@ __metadata: react-native-gesture-handler: ">= 2.0.0" react-native-safe-area-context: ">= 4.0.0" react-native-screens: ">= 4.0.0" - checksum: 2efe2b33cea7a789d47f4721441d3cd66036b8425dbb1abbb4551560b34b8b83e852e9a8b5747d2a6fc4d3ef8ee41c24726e3205731534bf1445e6f77736b4ce + checksum: 4516f6263be71df7060cd590ba763c768aa86aee723fae46a95cbda22bb3737143bf73597bb2f2f1633ca4c88e782f070d8a8db61ef8016b82d5a046b734be77 languageName: node linkType: hard @@ -3409,11 +3593,11 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 24.7.0 - resolution: "@types/node@npm:24.7.0" + version: 24.8.1 + resolution: "@types/node@npm:24.8.1" dependencies: undici-types: ~7.14.0 - checksum: 154e6113dae3e551386d37d9e84e15bbf2a81ee14700ce42815f123ff35904363ab86a5650f98b555a892f1502b45a0aaa91666a979ec8860d95b09179d7100f + checksum: 55fca8f900a017c207e4e413956f3fdd17178c3d5ffc8fd4bb7b0b5136865ca06076b1335e1e5e6f3269cace491dc1f4ecc1ab4410ff4acf25c9087585560c86 languageName: node linkType: hard @@ -3424,25 +3608,6 @@ __metadata: languageName: node linkType: hard -"@types/react-native-vector-icons@npm:^6.4.18": - version: 6.4.18 - resolution: "@types/react-native-vector-icons@npm:6.4.18" - dependencies: - "@types/react": "*" - "@types/react-native": ^0.70 - checksum: 1ef458cb5e7a37f41eb400e3153940b1b152e4df76a7c06c7a47c712dbfe46e14b9999f04dde1bd074f338f850e161c6c925174ddea33386b74f8112c940065b - languageName: node - linkType: hard - -"@types/react-native@npm:^0.70": - version: 0.70.19 - resolution: "@types/react-native@npm:0.70.19" - dependencies: - "@types/react": "*" - checksum: 79b504fa56340631079e7c20ea0d9412ec14147b76d0ce189f4403936f529ef1e6fd031383afab117846c5ae039123bcf3afc948bae4432269c6780282726f71 - languageName: node - linkType: hard - "@types/react-test-renderer@npm:^19.0.0": version: 19.1.0 resolution: "@types/react-test-renderer@npm:19.1.0" @@ -3531,23 +3696,23 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^8.13.0": - version: 8.46.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.46.0" + version: 8.46.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.46.1" dependencies: "@eslint-community/regexpp": ^4.10.0 - "@typescript-eslint/scope-manager": 8.46.0 - "@typescript-eslint/type-utils": 8.46.0 - "@typescript-eslint/utils": 8.46.0 - "@typescript-eslint/visitor-keys": 8.46.0 + "@typescript-eslint/scope-manager": 8.46.1 + "@typescript-eslint/type-utils": 8.46.1 + "@typescript-eslint/utils": 8.46.1 + "@typescript-eslint/visitor-keys": 8.46.1 graphemer: ^1.4.0 ignore: ^7.0.0 natural-compare: ^1.4.0 ts-api-utils: ^2.1.0 peerDependencies: - "@typescript-eslint/parser": ^8.46.0 + "@typescript-eslint/parser": ^8.46.1 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: b3a33bbdeffeefc5798abde387b440cfbc1c0ec6778ed2fe16238f10adae28193015ecf923f305bf9a67fcb108dced47216c9dbc6778736b6db5a97e71e212af + checksum: c2c3191632bdf62b2202e2a1c81df08e17d8128b5d5008a808a6dd39143fcc53ce4d9a7ab613aa43cac1748246e7f752b3d8d0aef1f77f605079797427db40a9 languageName: node linkType: hard @@ -3588,31 +3753,31 @@ __metadata: linkType: hard "@typescript-eslint/parser@npm:^8.13.0": - version: 8.46.0 - resolution: "@typescript-eslint/parser@npm:8.46.0" + version: 8.46.1 + resolution: "@typescript-eslint/parser@npm:8.46.1" dependencies: - "@typescript-eslint/scope-manager": 8.46.0 - "@typescript-eslint/types": 8.46.0 - "@typescript-eslint/typescript-estree": 8.46.0 - "@typescript-eslint/visitor-keys": 8.46.0 + "@typescript-eslint/scope-manager": 8.46.1 + "@typescript-eslint/types": 8.46.1 + "@typescript-eslint/typescript-estree": 8.46.1 + "@typescript-eslint/visitor-keys": 8.46.1 debug: ^4.3.4 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 9447250aa770eee131d81475784404b2b07caacf9bae8cef38b9ee639d8225504849a5586b5746b575f2c5dfbc9c612eb742acd8612bb1c425245f324f574613 + checksum: 0e4ae0b7a33f1dabc6d027ed299463d901ea48aa20373692a5f67ba2848f14ea322a6a0fed1c86f8936002fc3262d6d7f7e439ea4e5fdf6871a1c0571f011acf languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/project-service@npm:8.46.0" +"@typescript-eslint/project-service@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/project-service@npm:8.46.1" dependencies: - "@typescript-eslint/tsconfig-utils": ^8.46.0 - "@typescript-eslint/types": ^8.46.0 + "@typescript-eslint/tsconfig-utils": ^8.46.1 + "@typescript-eslint/types": ^8.46.1 debug: ^4.3.4 peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: ae8365cdbae5c8ee622727295f7cb59c42ccb0a4672d72692f2f31b26a052b7a9e46f58326740ca8d471a7e85998b885858be6c21921d465ce57de1d3ea7355f + checksum: c03bc00fd678ac920e51110546495467d6939dbc7a3d08c2e08f709a0e429924eb8fbefebf42abf246e84a569584931a42783c4926bcbdbf8adb872975c062d1 languageName: node linkType: hard @@ -3646,22 +3811,22 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/scope-manager@npm:8.46.0" +"@typescript-eslint/scope-manager@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/scope-manager@npm:8.46.1" dependencies: - "@typescript-eslint/types": 8.46.0 - "@typescript-eslint/visitor-keys": 8.46.0 - checksum: 0995be736f153314b7744594b7b5e27e63cf7b00b64b3a8cf23b4f01fc9cc01b9e652e433da438fe93efe63e505d61adb5c25319fe25e9f0ccdfea1ad7848fba + "@typescript-eslint/types": 8.46.1 + "@typescript-eslint/visitor-keys": 8.46.1 + checksum: ab2789a571c4db5d12292e993f66f720af1f2584d950959abf007296906a038e48a443206896c535b9b4f7d225658f5886910d78ea804ed22829079d82e7ba09 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.46.0, @typescript-eslint/tsconfig-utils@npm:^8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.0" +"@typescript-eslint/tsconfig-utils@npm:8.46.1, @typescript-eslint/tsconfig-utils@npm:^8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.1" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: d4516fb18c577a47f614efe6233354efefc582eaa4e915ae3d20c23f3b17e098b254594aa26d9c51eec1116d18665f06d9ed51229600df3ce3daecae83c76865 + checksum: 3251b631db3399e491ef5da5dee782e5eb30503d017bfc3736825448d7fb557956467d5ed500908f9cf92c4c87b1960f12db70986d1735009fd9816ba0bd7d6e languageName: node linkType: hard @@ -3682,19 +3847,19 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/type-utils@npm:8.46.0" +"@typescript-eslint/type-utils@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/type-utils@npm:8.46.1" dependencies: - "@typescript-eslint/types": 8.46.0 - "@typescript-eslint/typescript-estree": 8.46.0 - "@typescript-eslint/utils": 8.46.0 + "@typescript-eslint/types": 8.46.1 + "@typescript-eslint/typescript-estree": 8.46.1 + "@typescript-eslint/utils": 8.46.1 debug: ^4.3.4 ts-api-utils: ^2.1.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 864f7bc0df053089d09bc757abf4f728f6fc942e162baa727f24cf68d1d79f53ccd1dff151e74b0e43c25dc53d5ce32f916a2218786d365e1027d99c6799d6d9 + checksum: aa1f7a0eaedc12f50e35105274a868add5bce1e9bc55fbdfe69a13e8b0538982787f34f56f1964f59059049aa797d53f2be50bf1da9dbad1a661e58e0d9eb33c languageName: node linkType: hard @@ -3719,10 +3884,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.46.0, @typescript-eslint/types@npm:^8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/types@npm:8.46.0" - checksum: 71b7e0845da160cbd8ef1a5f853a1b8626f5bd00a1db56b75218eb94d5f3433f7815635e70df52118657c57109458f2e0d2bec8dcca0c620af10c66205fe54cd +"@typescript-eslint/types@npm:8.46.1, @typescript-eslint/types@npm:^8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/types@npm:8.46.1" + checksum: 28ded6e2f952ccc54f54f9d880237dfccc814a8601cc56cbfbec9879e695ad831023d07bc8989ce4b9ca8891d50bb3f19af80f50a9512ee1600013b7b84b1d77 languageName: node linkType: hard @@ -3782,14 +3947,14 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.46.0" +"@typescript-eslint/typescript-estree@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.46.1" dependencies: - "@typescript-eslint/project-service": 8.46.0 - "@typescript-eslint/tsconfig-utils": 8.46.0 - "@typescript-eslint/types": 8.46.0 - "@typescript-eslint/visitor-keys": 8.46.0 + "@typescript-eslint/project-service": 8.46.1 + "@typescript-eslint/tsconfig-utils": 8.46.1 + "@typescript-eslint/types": 8.46.1 + "@typescript-eslint/visitor-keys": 8.46.1 debug: ^4.3.4 fast-glob: ^3.3.2 is-glob: ^4.0.3 @@ -3798,7 +3963,7 @@ __metadata: ts-api-utils: ^2.1.0 peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 70f5523d266097c96e5de2cf28c86c5bb3c9d4f48ba129a9c13e620733d395008dc809c77f1af19fc4617133c0665bf65a6a688fbf40da29d5a6ebe137ea41ae + checksum: d5968a1b9fa8f9469b260b9f0d85cbf16aecd65737a2e78dea0a8b00114370973b9acc6d619ebdec7d8a5bfceb7649d6726e902b462fe003ea627b2b13bca25a languageName: node linkType: hard @@ -3816,18 +3981,18 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.46.0, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0": - version: 8.46.0 - resolution: "@typescript-eslint/utils@npm:8.46.0" +"@typescript-eslint/utils@npm:8.46.1, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.46.1 + resolution: "@typescript-eslint/utils@npm:8.46.1" dependencies: "@eslint-community/eslint-utils": ^4.7.0 - "@typescript-eslint/scope-manager": 8.46.0 - "@typescript-eslint/types": 8.46.0 - "@typescript-eslint/typescript-estree": 8.46.0 + "@typescript-eslint/scope-manager": 8.46.1 + "@typescript-eslint/types": 8.46.1 + "@typescript-eslint/typescript-estree": 8.46.1 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 63c9f4df8f823ef7f83fe2c53f85fd5e278d60240d41414f69c8ecb37061fec74ad34851faf28283042a1a0b983ddca57dbd97a7e653073068c7f22e919f84ea + checksum: 2268b31a50960825556ba9bd22a231b97aa65fa489b8ddd697931224448efc9f1e429492303de99f5abbfbfca58fb6495834451fdfbcaa9c4c1446d2f557c702 languageName: node linkType: hard @@ -3879,13 +4044,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.46.0": - version: 8.46.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.46.0" +"@typescript-eslint/visitor-keys@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.46.1" dependencies: - "@typescript-eslint/types": 8.46.0 + "@typescript-eslint/types": 8.46.1 eslint-visitor-keys: ^4.2.1 - checksum: 888adc68bd8d80adb185520f2016b81a934f793db323cd62452027fad2e76a5ab64ed9500c4e5a2be2e5d2458e071776ea86a62e40e32faa4348ca4ab84dddda + checksum: 18ce08a42cf0e0ddbb3c48a9084d320a67991311830e29cf79f33ecfdadf4680f8d10807e86551b49df55ccf023c24868ba9c85cc688a6075374f14b6fff59c4 languageName: node linkType: hard @@ -4166,12 +4331,13 @@ __metadata: linkType: hard "arktype@npm:^2.1.15": - version: 2.1.22 - resolution: "arktype@npm:2.1.22" + version: 2.1.23 + resolution: "arktype@npm:2.1.23" dependencies: - "@ark/schema": 0.49.0 - "@ark/util": 0.49.0 - checksum: 46947539b550912f709908bcb973114607a8d61124f7f4ea1090bcaab85ca5c49d68afd6928bf05ce80fe403b6906e7d31d58ed346b408bb8519b9ffdf08e0cb + "@ark/regex": 0.0.0 + "@ark/schema": 0.50.0 + "@ark/util": 0.50.0 + checksum: 5874ef1c0140aff0a99cd88537e11851b4d0a1a49ee7b097eb766c941f9de6bbd04427bbda8023c69171db0ee02c7d99f08e59114b63fa83a93c4130964fb616 languageName: node linkType: hard @@ -4530,11 +4696,11 @@ __metadata: linkType: hard "baseline-browser-mapping@npm:^2.8.9": - version: 2.8.14 - resolution: "baseline-browser-mapping@npm:2.8.14" + version: 2.8.17 + resolution: "baseline-browser-mapping@npm:2.8.17" bin: baseline-browser-mapping: dist/cli.js - checksum: 422a3c25169ef6ffb89d2fab297f92c72496e0e87bcff6c7af3fbe917a9ee4ca3092ea8bd0ca128d915b2c1b2a0c7921edacdefb701e347d87158f2fa5b2bb1a + checksum: 2ff31d36b475b628b551e0b29b2fb1ac36f903b99392da4da208ef718beefdc659b24b612a2922664fcffa1fc9bf733bb52d2e29756dcf09c54d764c64f0b964 languageName: node linkType: hard @@ -4627,7 +4793,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.20.4, browserslist@npm:^4.24.0, browserslist@npm:^4.25.3": +"browserslist@npm:^4.20.4, browserslist@npm:^4.24.0, browserslist@npm:^4.26.3": version: 4.26.3 resolution: "browserslist@npm:4.26.3" dependencies: @@ -4802,9 +4968,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001746": - version: 1.0.30001749 - resolution: "caniuse-lite@npm:1.0.30001749" - checksum: 0a2692a7d51e4f4cecd2e8714e1d3d9982479fb59fa2fc8d6a462844bb7f5243ffe0bc94b25a1ff944f63bb2372ff5f6d01ef422729ca3c262975f1b91d78c07 + version: 1.0.30001751 + resolution: "caniuse-lite@npm:1.0.30001751" + checksum: d11e25c44e40c21e7b7492a25c9fd60f4c04e94aa265573f7c487666f5e1b5ca3ed09d09560336f959237063616255cb294d415511bb6cf0486eb2cb6a3a4318 languageName: node linkType: hard @@ -4988,17 +5154,6 @@ __metadata: languageName: node linkType: hard -"cliui@npm:^7.0.2": - version: 7.0.4 - resolution: "cliui@npm:7.0.4" - dependencies: - string-width: ^4.2.0 - strip-ansi: ^6.0.0 - wrap-ansi: ^7.0.0 - checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f - languageName: node - linkType: hard - "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -5025,9 +5180,9 @@ __metadata: linkType: hard "collect-v8-coverage@npm:^1.0.0": - version: 1.0.2 - resolution: "collect-v8-coverage@npm:1.0.2" - checksum: c10f41c39ab84629d16f9f6137bc8a63d332244383fc368caf2d2052b5e04c20cd1fd70f66fcf4e2422b84c8226598b776d39d5f2d2a51867cc1ed5d1982b4da + version: 1.0.3 + resolution: "collect-v8-coverage@npm:1.0.3" + checksum: ed1d1ebc9c05e7263fffa3ad6440031db6a1fdd9f574435aa689effcdfe9f2b93aba8ec600f9c7b99124cd6ff5d9415c17961d84ae829a72251a4fe668a49b63 languageName: node linkType: hard @@ -5431,11 +5586,11 @@ __metadata: linkType: hard "core-js-compat@npm:^3.43.0": - version: 3.45.1 - resolution: "core-js-compat@npm:3.45.1" + version: 3.46.0 + resolution: "core-js-compat@npm:3.46.0" dependencies: - browserslist: ^4.25.3 - checksum: 817286f6b7deb90278fd1f46131664fda36b74983e2fc4859a36ae85ef9361868b307964eea0e364251763e415eab7589e9abe2a4ec4d1672c2870f03c52b1ac + browserslist: ^4.26.3 + checksum: 16d381c51e34d38ecc65d429d5a5c1dbd198f70b5a0a6256a3a41dcb8523e07f0a8682f6349298a55ff6e9d039e131d67b07fe863047a28672ae5f10373c57cf languageName: node linkType: hard @@ -5447,15 +5602,15 @@ __metadata: linkType: hard "cosmiconfig-typescript-loader@npm:^6.1.0": - version: 6.1.0 - resolution: "cosmiconfig-typescript-loader@npm:6.1.0" + version: 6.2.0 + resolution: "cosmiconfig-typescript-loader@npm:6.2.0" dependencies: - jiti: ^2.4.1 + jiti: ^2.6.1 peerDependencies: "@types/node": "*" cosmiconfig: ">=9" typescript: ">=5" - checksum: 45114854faaa97178abd2ccad511363faa57c03321c7e39ad16619c63842b3f6147dd20118f9f07c9530a242a39c3107c791708bb0b987dad374e71f23f9468b + checksum: 2680bb585de1185aa23ba678cb0426cba1be8fa0a9d286f71c2ce5bd63f23e5b8f726161673a16babb2aa0e7d033fda8774268a025fb63f548d1c75977292212 languageName: node linkType: hard @@ -5907,9 +6062,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.5.227": - version: 1.5.233 - resolution: "electron-to-chromium@npm:1.5.233" - checksum: 84c36a12b6099ef2584cee8e181f01e8efc4d1d81f1e5802c8beaae18d50ca03e9706267f6b93f3b95716ed084b5b628dfe340accf0d8b1670f714a90bccc4c0 + version: 1.5.237 + resolution: "electron-to-chromium@npm:1.5.237" + checksum: 5905e2808dc6243ced0a83537afbafedec20c063feb6403a678b612a7855d79bc6ecb7d094bdab71f54173cf2ae5d1d8070b0c31572025001c94de62af84f5f8 languageName: node linkType: hard @@ -5921,9 +6076,9 @@ __metadata: linkType: hard "emoji-regex@npm:^10.3.0": - version: 10.5.0 - resolution: "emoji-regex@npm:10.5.0" - checksum: 3a5164bfc2ac4685aa2fda613bb2b58d1d4e05b6ace9d87f8e119fe8cd39779875adfe1919b64f06f5dcd2b522238ad23b50caaaff7fb600bd53c84ff86e4b61 + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 8785f6a7ec4559c931bd6640f748fe23791f5af4c743b131d458c5551b4aa7da2a9cd882518723cb3859e8b0b59b0cc08f2ce0f8e65c61a026eed71c2dc407d5 languageName: node linkType: hard @@ -5988,11 +6143,11 @@ __metadata: linkType: hard "envinfo@npm:^7.13.0": - version: 7.17.0 - resolution: "envinfo@npm:7.17.0" + version: 7.19.0 + resolution: "envinfo@npm:7.19.0" bin: envinfo: dist/cli.js - checksum: d09e6d2d5dea999f9b5e1a8c496337b5e470f843c046843603e28132a7f391eef18589735c5bc8cc529a3cd8848bd1d4750fe8851f5de7b9d0d6b1d2f415adf9 + checksum: ae27a34200fab30c6898867b63024b016bf883f8a166854055be5ccda34d7e7fc81b5048df21f7f9acaf8f6ce49cf91247c5a58df8bb054ed08ccdab9ab12fe8 languageName: node linkType: hard @@ -6642,9 +6797,9 @@ __metadata: linkType: hard "exponential-backoff@npm:^3.1.1": - version: 3.1.2 - resolution: "exponential-backoff@npm:3.1.2" - checksum: 7e191e3dd6edd8c56c88f2c8037c98fbb8034fe48778be53ed8cb30ccef371a061a4e999a469aab939b92f8f12698f3b426d52f4f76b7a20da5f9f98c3cbc862 + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 471fdb70fd3d2c08a74a026973bdd4105b7832911f610ca67bbb74e39279411c1eed2f2a110c9d41c2edd89459ba58fdaba1c174beed73e7a42d773882dcff82 languageName: node linkType: hard @@ -8891,7 +9046,7 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^2.4.1": +"jiti@npm:^2.6.1": version: 2.6.1 resolution: "jiti@npm:2.6.1" bin: @@ -8928,14 +9083,14 @@ __metadata: linkType: hard "js-yaml@npm:^3.13.1": - version: 3.14.1 - resolution: "js-yaml@npm:3.14.1" + version: 3.14.2 + resolution: "js-yaml@npm:3.14.2" dependencies: argparse: ^1.0.7 esprima: ^4.0.0 bin: js-yaml: bin/js-yaml.js - checksum: bef146085f472d44dee30ec34e5cf36bf89164f5d585435a3d3da89e52622dff0b188a580e4ad091c3341889e14cb88cac6e4deb16dc5b1e9623bb0601fc255c + checksum: 626fc207734a3452d6ba84e1c8c226240e6d431426ed94d0ab043c50926d97c509629c08b1d636f5d27815833b7cfd225865631da9fb33cb957374490bf3e90b languageName: node linkType: hard @@ -9092,9 +9247,9 @@ __metadata: linkType: hard "ky@npm:^1.2.0": - version: 1.11.0 - resolution: "ky@npm:1.11.0" - checksum: 2d7eded44f9f7a363b264d1ecd2c7ffd83e9868ef70ea1e4cee05d36560198080008dd02fcc631841d1e9df8b23f270a4188ac5c7c085388e3b321aba7ec13f3 + version: 1.12.0 + resolution: "ky@npm:1.12.0" + checksum: 55741f2c9f7fb93b4aa235520de4262dab0d7455dc989ffb32df7f0fb470f15a8d2d509db779859cc1de975578a3ef7da29066cbda2fdc05996bcfceb79f3aa6 languageName: node linkType: hard @@ -10353,8 +10508,8 @@ __metadata: linkType: hard "node-gyp@npm:latest": - version: 11.4.2 - resolution: "node-gyp@npm:11.4.2" + version: 11.5.0 + resolution: "node-gyp@npm:11.5.0" dependencies: env-paths: ^2.2.0 exponential-backoff: ^3.1.1 @@ -10368,7 +10523,7 @@ __metadata: which: ^5.0.0 bin: node-gyp: bin/node-gyp.js - checksum: d8041cee7ec60c86fb2961d77c12a2d083a481fb28b08e6d9583153186c0e7766044dc30bdb1f3ac01ddc5763b83caeed3d1ea35787ec4ffd8cc4aeedfc34f2b + checksum: 6cc29b9d454d9a684c8fe299668db618875bb4282e37717ca5b79689cc5ce99cd553c70944bb367979f2eba40ad6a50afaf7b12a6b214172edc7377384efa051 languageName: node linkType: hard @@ -10380,9 +10535,9 @@ __metadata: linkType: hard "node-releases@npm:^2.0.21": - version: 2.0.23 - resolution: "node-releases@npm:2.0.23" - checksum: dc3194ffdf04975f8525a5e175c03f5a95cecd7607b6b0e80d28aaa03900706d920722b5f2ae2e8e28e029e6ae75f0d0f7eae87e8ee2a363c704785e3118f13d + version: 2.0.25 + resolution: "node-releases@npm:2.0.25" + checksum: 9a23149cf3f6778e62440b1f26f91927aff06c3606a29996f3d196c7c0f5e31c17c24c324b5ef1f571cebef6b5a8db9adce9c09381ca271bc6422aac91463f75 languageName: node linkType: hard @@ -11179,7 +11334,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -11480,16 +11635,6 @@ __metadata: languageName: node linkType: hard -"react-native-is-edge-to-edge@npm:^1.2.1": - version: 1.2.1 - resolution: "react-native-is-edge-to-edge@npm:1.2.1" - peerDependencies: - react: "*" - react-native: "*" - checksum: 8fb6d8ab7b953c7d7cec8c987cef24f1c5348a293a85cb49c7c53b54ef110c0ca746736ae730e297603c8c76020df912e93915fb17518c4f2f91143757177aba - languageName: node - linkType: hard - "react-native-monorepo-config@npm:^0.1.8": version: 0.1.10 resolution: "react-native-monorepo-config@npm:0.1.10" @@ -11511,31 +11656,15 @@ __metadata: linkType: hard "react-native-screens@npm:^4.10.0": - version: 4.16.0 - resolution: "react-native-screens@npm:4.16.0" + version: 4.17.1 + resolution: "react-native-screens@npm:4.17.1" dependencies: react-freeze: ^1.0.0 - react-native-is-edge-to-edge: ^1.2.1 warn-once: ^0.1.0 peerDependencies: react: "*" react-native: "*" - checksum: 71bebbead1d8f886b80b70cf9d69b0179e035fb425fae84fbcbb2930167220cb90c2ee70b26d3fd94f940fa3e6ce325b0ec2e283d039d5abb29bf6898c58e485 - languageName: node - linkType: hard - -"react-native-vector-icons@npm:^10.2.0": - version: 10.3.0 - resolution: "react-native-vector-icons@npm:10.3.0" - dependencies: - prop-types: ^15.7.2 - yargs: ^16.1.1 - bin: - fa-upgrade.sh: bin/fa-upgrade.sh - fa5-upgrade: bin/fa5-upgrade.sh - fa6-upgrade: bin/fa6-upgrade.sh - generate-icon: bin/generate-icon.js - checksum: 5c431fd9a8e6efd355e34ed28ca7fa7eed30e89362280cbd1e474e6d16148c6c37f5c950a525ec0b428c79dc74b9fb7a61171fc509b6ab253e111456f3e49b71 + checksum: 7c17118bc1313acd6001e63bf1d6c6a95ca5250c9a06450cceec50768571648d2d5f3e17ed19fb757d176a65bbe80fcba142b937a92cbbc795a6da71243c375e languageName: node linkType: hard @@ -12796,9 +12925,9 @@ __metadata: linkType: hard "strip-indent@npm:^4.0.0": - version: 4.1.0 - resolution: "strip-indent@npm:4.1.0" - checksum: 10cb47506bb3a73ca369c88ae07ef37a2d2fca0906abb23a6a0f9f68bbced5c492176679a44b6b4a490c804009cc6432101f16e03d1a692fa00d77b16d651695 + version: 4.1.1 + resolution: "strip-indent@npm:4.1.1" + checksum: d322bfdc59855006791a4aebe2a66e0892eab7004a5c064d74b86a0c6ecff2818974c9a5eda54b16d8af6aadbc90a6c02635ffcbec11ab33dd8979b1a6346fc0 languageName: node linkType: hard @@ -12872,15 +13001,15 @@ __metadata: linkType: hard "tar@npm:^7.4.3": - version: 7.5.1 - resolution: "tar@npm:7.5.1" + version: 7.5.2 + resolution: "tar@npm:7.5.2" dependencies: "@isaacs/fs-minipass": ^4.0.0 chownr: ^3.0.0 minipass: ^7.1.2 minizlib: ^3.1.0 yallist: ^5.0.0 - checksum: dbd55d4c3bd9e3c69aed137d9dc9fcb8f86afd103c28d97d52728ca80708f4c84b07e0a01d0bf1c8e820be84d37632325debf19f672a06e0c605c57a03636fd0 + checksum: 192559b0e7af17d57c7747592ef22c14d5eba2d9c35996320ccd20c3e2038160fe8d928fc5c08b2aa1b170c4d0a18c119441e81eae8f227ca2028d5bcaa6bf23 languageName: node linkType: hard @@ -13266,8 +13395,8 @@ __metadata: linkType: hard "typedoc@npm:^0.28.13": - version: 0.28.13 - resolution: "typedoc@npm:0.28.13" + version: 0.28.14 + resolution: "typedoc@npm:0.28.14" dependencies: "@gerrit0/mini-shiki": ^3.12.0 lunr: ^2.3.9 @@ -13278,7 +13407,7 @@ __metadata: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x bin: typedoc: bin/typedoc - checksum: 238b567661d4118eaf1bc61696ce2129dc0f0d4bd9b0928942bdd40ab9165df842143a04bc1fd8c7c1c2a8978ad2b48118f4bfcd753be322476568a8cc27e355 + checksum: d579280e58f50dfdb4c0017ab1ed5807a98c468ebd2aca4ae4d725194c3b575de2524543d142382c0fa7cd1b7a7b3732245f459677406365c7ba7151b3c87ebc languageName: node linkType: hard @@ -13477,11 +13606,11 @@ __metadata: linkType: hard "use-latest-callback@npm:^0.2.4": - version: 0.2.5 - resolution: "use-latest-callback@npm:0.2.5" + version: 0.2.6 + resolution: "use-latest-callback@npm:0.2.6" peerDependencies: react: ">=16.8" - checksum: 8008a9c6635fa107ea3e84aba53c8f5334ea81bfe25a6866d76294045f53a34f9ad81ea7e2db595ceb1acf75064050b9cb7e800adee02e8a833b2f17ccdef88e + checksum: 67a245bf91b23ef0d2d2c8a52845da62e006867bd9d93a99ca4d2f859101fcd54c7afd4f5a3b8bb5d24283f516e7e41bd8226250ee39affc33bd1cfd622a5cfb languageName: node linkType: hard @@ -13888,7 +14017,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.9": +"yargs-parser@npm:^20.2.9": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 @@ -13914,21 +14043,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^16.1.1": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" - dependencies: - cliui: ^7.0.2 - escalade: ^3.1.1 - get-caller-file: ^2.0.5 - require-directory: ^2.1.1 - string-width: ^4.2.0 - y18n: ^5.0.5 - yargs-parser: ^20.2.2 - checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 - languageName: node - linkType: hard - "yargs@npm:^17.0.0, yargs@npm:^17.3.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2": version: 17.7.2 resolution: "yargs@npm:17.7.2"