diff --git a/.eslintrc.js b/.eslintrc.js index e0f808c23..18e154b5a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { root: true, + ignorePatterns: ['coverage/**/*'], extends: [ '@react-native', 'plugin:react/recommended', diff --git a/README.md b/README.md index dd3918d21..240239f1d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ Iterable. It supports JavaScript and TypeScript. - [Iterable's React Native SDK](#iterables-react-native-sdk) - [Requirements](#requirements) + - [React Native](#react-native) + - [UI Components require additional peer dependencies](#ui-components-require-additional-peer-dependencies) + - [Optional peer dependencies for enhanced UI](#optional-peer-dependencies-for-enhanced-ui) + - [iOS](#ios) + - [Android](#android) - [Architecture Support](#architecture-support) - [Installation](#installation) - [Features](#features) @@ -34,22 +39,24 @@ Iterable. It supports JavaScript and TypeScript. Iterable's React Native SDK relies on: -- **React Native** - - [React Native 0.75+](https://github.com/facebook/react-native) - - [React 18.1+](https://github.com/facebook/react) +### React Native + - [React Native 0.75+](https://github.com/facebook/react-native) + - [React 18.1+](https://github.com/facebook/react) - _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) +#### UI Components require additional peer dependencies + - [React Navigation 6+](https://github.com/react-navigation/react-navigation) -- **iOS** +#### Optional peer dependencies for enhanced UI + - [React Native WebView 13+](https://github.com/react-native-webview/react-native-webview) - Required only for inbox message display functionality. If not installed, the SDK will show a fallback message. + - [React Native Safe Area Context 4+](https://github.com/th3rdwave/react-native-safe-area-context) - Provides proper safe area handling for the inbox component. If not installed, the SDK will use fallback View components. + - [React Native Vector Icons 10+](https://github.com/oblador/react-native-vector-icons) - Provides enhanced icons for the inbox component. If not installed, the SDK will use fallback Unicode symbols. + +### iOS - Xcode 12+ - [Deployment target 13.4+](https://help.apple.com/xcode/mac/current/#/deve69552ee5) - [Iterable's iOS SDK](https://github.com/Iterable/iterable-swift-sdk) - -- **Android** + - Swift 5 +### Android - [`minSdkVersion` 21+, `compileSdkVersion` 31+](https://medium.com/androiddevelopers/picking-your-compilesdkversion-minsdkversion-targetsdkversion-a098a0341ebd) - [Iterable's Android SDK](https://github.com/Iterable/iterable-android-sdk) diff --git a/package.json b/package.json index ef832a4ad..98fd2b463 100644 --- a/package.json +++ b/package.json @@ -93,9 +93,9 @@ "react-native": "0.79.3", "react-native-builder-bob": "^0.40.4", "react-native-gesture-handler": "^2.26.0", - "react-native-safe-area-context": "^5.4.0", + "react-native-safe-area-context": "^5.6.1", "react-native-screens": "^4.10.0", - "react-native-vector-icons": "^10.2.0", + "react-native-vector-icons": "^10.3.0", "react-native-webview": "^13.14.1", "react-test-renderer": "19.0.0", "release-it": "^17.10.0", @@ -111,14 +111,20 @@ "peerDependencies": { "@react-navigation/native": "*", "react": "*", - "react-native": "*", - "react-native-safe-area-context": "*", - "react-native-vector-icons": "*", - "react-native-webview": "*" + "react-native": "*" }, "peerDependenciesMeta": { "expo": { "optional": true + }, + "react-native-safe-area-context": { + "optional": true + }, + "react-native-vector-icons": { + "optional": true + }, + "react-native-webview": { + "optional": true } }, "sideEffects": false, diff --git a/src/core/index.ts b/src/core/index.ts index 250860473..041c5d7af 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,3 +2,4 @@ export * from './classes'; export * from './enums'; export * from './hooks'; export * from './types'; +export * from './utils/SafeAreaContext'; diff --git a/src/core/utils/SafeAreaContext.tsx b/src/core/utils/SafeAreaContext.tsx new file mode 100644 index 000000000..a1a7ebc7f --- /dev/null +++ b/src/core/utils/SafeAreaContext.tsx @@ -0,0 +1,127 @@ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +import React from 'react'; +import { View, type ViewStyle } from 'react-native'; + +/** + * Error thrown when react-native-safe-area-context is required but not available + */ +export class SafeAreaContextNotAvailableError extends Error { + constructor(componentName: string) { + super( + `react-native-safe-area-context is required for ${componentName} but is not installed. ` + + 'Please install it by running: npm install react-native-safe-area-context ' + + 'or yarn add react-native-safe-area-context' + ); + this.name = 'SafeAreaContextNotAvailableError'; + } +} + +/** + * Conditionally imports and returns SafeAreaView from react-native-safe-area-context + * @throws \{SafeAreaContextNotAvailableError\} When the library is not available + */ +export const getSafeAreaView = () => { + try { + const { SafeAreaView } = require('react-native-safe-area-context'); + return SafeAreaView; + } catch { + throw new SafeAreaContextNotAvailableError('SafeAreaView'); + } +}; + +/** + * Conditionally imports and returns SafeAreaProvider from react-native-safe-area-context + * @throws \{SafeAreaContextNotAvailableError\} When the library is not available + */ +export const getSafeAreaProvider = () => { + try { + const { SafeAreaProvider } = require('react-native-safe-area-context'); + return SafeAreaProvider; + } catch { + throw new SafeAreaContextNotAvailableError('SafeAreaProvider'); + } +}; + +/** + * Conditionally imports and returns useSafeAreaInsets from react-native-safe-area-context + * @throws \{SafeAreaContextNotAvailableError\} When the library is not available + */ +export const getUseSafeAreaInsets = () => { + try { + const { useSafeAreaInsets } = require('react-native-safe-area-context'); + return useSafeAreaInsets; + } catch { + throw new SafeAreaContextNotAvailableError('useSafeAreaInsets'); + } +}; + +/** + * Conditionally imports and returns useSafeAreaFrame from react-native-safe-area-context + * @throws \{SafeAreaContextNotAvailableError\} When the library is not available + */ +export const getUseSafeAreaFrame = () => { + try { + const { useSafeAreaFrame } = require('react-native-safe-area-context'); + return useSafeAreaFrame; + } catch { + throw new SafeAreaContextNotAvailableError('useSafeAreaFrame'); + } +}; + +/** + * A conditional SafeAreaView component that only loads react-native-safe-area-context when needed + */ +export interface ConditionalSafeAreaViewProps { + style?: ViewStyle; + children: React.ReactNode; + edges?: string[]; + mode?: 'padding' | 'margin'; +} + +export const ConditionalSafeAreaView: React.FC< + ConditionalSafeAreaViewProps +> = ({ style, children, edges, mode }) => { + try { + const SafeAreaView = getSafeAreaView(); + return ( + + {children} + + ); + } catch { + // Fallback to regular View if SafeAreaView is not available + console.warn( + 'SafeAreaView is not available. Falling back to regular View. ' + + 'Install react-native-safe-area-context for proper safe area handling.' + ); + return {children}; + } +}; + +/** + * A conditional SafeAreaProvider component that only loads react-native-safe-area-context when needed + */ +export interface ConditionalSafeAreaProviderProps { + children: React.ReactNode; + initialMetrics?: unknown; +} + +export const ConditionalSafeAreaProvider: React.FC< + ConditionalSafeAreaProviderProps +> = ({ children, initialMetrics }) => { + try { + const SafeAreaProvider = getSafeAreaProvider(); + return ( + + {children} + + ); + } catch { + // Fallback to Fragment if SafeAreaProvider is not available + console.warn( + 'SafeAreaProvider is not available. Falling back to Fragment. ' + + 'Install react-native-safe-area-context for proper safe area handling.' + ); + return <>{children}; + } +}; diff --git a/src/inbox/components/IterableInbox.tsx b/src/inbox/components/IterableInbox.tsx index 545403e03..5752f8e1f 100644 --- a/src/inbox/components/IterableInbox.tsx +++ b/src/inbox/components/IterableInbox.tsx @@ -9,9 +9,8 @@ import { Text, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - import { useAppStateListener, useDeviceOrientation } from '../../core'; +import { ConditionalSafeAreaView } from '../../core/utils/SafeAreaContext'; // expo throws an error if this is not imported directly due to circular // dependencies // See: https://github.com/expo/expo/issues/35100 @@ -500,7 +499,7 @@ export const IterableInbox = ({ ); return safeAreaMode ? ( - {inboxAnimatedView} + {inboxAnimatedView} ) : ( {inboxAnimatedView} ); diff --git a/src/inbox/components/IterableInboxIcon.tsx b/src/inbox/components/IterableInboxIcon.tsx new file mode 100644 index 000000000..fcb944043 --- /dev/null +++ b/src/inbox/components/IterableInboxIcon.tsx @@ -0,0 +1,41 @@ +import { Text, StyleSheet, type TextStyle } from 'react-native'; + +/** + * Props for the IterableInboxIcon component. + */ +export interface IterableInboxIconProps { + /** + * The name of the icon to display. + */ + name: string; + /** + * The style to apply to the icon. + */ + style?: TextStyle; +} + +/** + * A fallback icon component that uses Unicode symbols instead of vector icons. + * This allows the inbox to work without requiring react-native-vector-icons. + */ +export const IterableInboxIcon = ({ name, style }: IterableInboxIconProps) => { + // Map of common icon names to Unicode symbols + const iconMap: Record = { + 'chevron-back-outline': '‹', + 'chevron-back': '‹', + 'arrow-back': '←', + 'back': '←', + }; + + const iconSymbol = iconMap[name] || '?'; + + return {iconSymbol}; +}; + +const styles = StyleSheet.create({ + icon: { + fontSize: 24, + fontWeight: 'bold', + textAlign: 'center', + }, +}); diff --git a/src/inbox/components/IterableInboxIconUtils.ts b/src/inbox/components/IterableInboxIconUtils.ts new file mode 100644 index 000000000..12f25a108 --- /dev/null +++ b/src/inbox/components/IterableInboxIconUtils.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ +import { type TextStyle } from 'react-native'; + +// Type for the vector icon component +type VectorIconComponent = React.ComponentType<{ + name: string; + style?: TextStyle; +}>; + +/** + * Attempts to load the react-native-vector-icons module. + * Returns null if the module is not available. + */ +export function tryLoadVectorIcons(): VectorIconComponent | null { + try { + return require('react-native-vector-icons/Ionicons').default; + } catch { + return null; + } +} diff --git a/src/inbox/components/IterableInboxMessageDisplay.tsx b/src/inbox/components/IterableInboxMessageDisplay.tsx index 7e6798c73..f70e5ffc6 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -7,8 +7,12 @@ import { TouchableWithoutFeedback, View, } from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import { WebView, type WebViewMessageEvent } from 'react-native-webview'; +import { IterableInboxSmartIcon } from './IterableInboxSmartIcon'; +import { + loadWebView, + FallbackWebView, + WebViewNotAvailableError, +} from '../../utils/WebViewLoader'; import { IterableAction, @@ -78,6 +82,13 @@ export const IterableInboxMessageDisplay = ({ const messageTitle = rowViewModel.inAppMessage.inboxMetadata?.title; const [inAppContent, setInAppContent] = useState(null); + const [WebViewComponent, setWebViewComponent] = useState void; + injectedJavaScript?: string; + }> | null>(null); const styles = StyleSheet.create({ contentContainer: { @@ -172,7 +183,21 @@ export const IterableInboxMessageDisplay = ({ }; }); - function handleInAppLinkAction(event: WebViewMessageEvent) { + // Load WebView component dynamically + useEffect(() => { + try { + const WebView = loadWebView(); + setWebViewComponent(() => WebView); + } catch (error) { + if (error instanceof WebViewNotAvailableError) { + setWebViewComponent(() => FallbackWebView); + } else { + setWebViewComponent(() => FallbackWebView); + } + } + }, []); + + function handleInAppLinkAction(event: { nativeEvent: { data: string } }) { const URL = event.nativeEvent.data; const action = new IterableAction('openUrl', URL, ''); @@ -233,7 +258,7 @@ export const IterableInboxMessageDisplay = ({ }} > - @@ -253,13 +278,13 @@ export const IterableInboxMessageDisplay = ({ - {inAppContent && ( + {inAppContent && WebViewComponent && ( - handleInAppLinkAction(event)} + onMessage={handleInAppLinkAction} injectedJavaScript={JS} /> diff --git a/src/inbox/components/IterableInboxSmartIcon.tsx b/src/inbox/components/IterableInboxSmartIcon.tsx new file mode 100644 index 000000000..f7f695c23 --- /dev/null +++ b/src/inbox/components/IterableInboxSmartIcon.tsx @@ -0,0 +1,36 @@ +import { type TextStyle } from 'react-native'; + +import { IterableInboxIcon } from './IterableInboxIcon'; +import { tryLoadVectorIcons } from './IterableInboxIconUtils'; + +/** + * Props for the IterableInboxSmartIcon component. + */ +export interface IterableInboxSmartIconProps { + /** + * The name of the icon to display. + */ + name: string; + /** + * The style to apply to the icon. + */ + style?: TextStyle; +} + +/** + * A smart icon component that attempts to use react-native-vector-icons if available, + * otherwise falls back to Unicode symbols. + */ +export const IterableInboxSmartIcon = ({ + name, + style, +}: IterableInboxSmartIconProps) => { + const VectorIcon = tryLoadVectorIcons(); + + if (VectorIcon) { + return ; + } + + // Fallback to Unicode symbols + return ; +}; diff --git a/src/inbox/components/index.ts b/src/inbox/components/index.ts index f00e88241..b294f06fc 100644 --- a/src/inbox/components/index.ts +++ b/src/inbox/components/index.ts @@ -1,5 +1,7 @@ export * from './IterableInbox'; export * from './IterableInboxEmptyState'; +export * from './IterableInboxIcon'; +export * from './IterableInboxSmartIcon'; export * from './IterableInboxMessageCell'; export * from './IterableInboxMessageDisplay'; export * from './IterableInboxMessageList'; diff --git a/src/index.tsx b/src/index.tsx index 885cd74bd..122c1718d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -44,11 +44,15 @@ export { IterableInbox, IterableInboxDataModel, IterableInboxEmptyState, + IterableInboxIcon, IterableInboxMessageCell, + IterableInboxSmartIcon, type IterableInboxCustomizations, type IterableInboxEmptyStateProps, + type IterableInboxIconProps, type IterableInboxImpressionRowInfo, type IterableInboxMessageCellProps, type IterableInboxProps, type IterableInboxRowViewModel, + type IterableInboxSmartIconProps, } from './inbox'; diff --git a/src/utils/WebViewLoader.tsx b/src/utils/WebViewLoader.tsx new file mode 100644 index 000000000..56e054533 --- /dev/null +++ b/src/utils/WebViewLoader.tsx @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-var-requires, +@typescript-eslint/no-require-imports, react-native/no-inline-styles, react-native/no-color-literals */ +/** + * Utility for dynamically loading react-native-webview + * This allows the SDK to work without requiring react-native-webview as a dependency + * when WebView functionality is not needed. + */ + +import { View, Text } from 'react-native'; + +/** + * Error thrown when react-native-webview is not available but is required + */ +export class WebViewNotAvailableError extends Error { + constructor() { + super( + 'react-native-webview is required but not installed. Please install it using: npm install react-native-webview or yarn add react-native-webview' + ); + this.name = 'WebViewNotAvailableError'; + } +} + +/** + * Dynamically loads the WebView component from react-native-webview + * @returns The WebView component + * @throws \{WebViewNotAvailableError\} When react-native-webview is not installed + */ +export function loadWebView(): React.ComponentType<{ + originWhiteList?: string[]; + source?: { html: string }; + style?: object; + onMessage?: (event: { nativeEvent: { data: string } }) => void; + injectedJavaScript?: string; +}> { + try { + // Try to require react-native-webview dynamically + const { WebView } = require('react-native-webview'); + return WebView; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + throw new WebViewNotAvailableError(); + } +} + +/** + * Dynamically loads the WebViewMessageEvent type from react-native-webview + * @returns The WebViewMessageEvent type + * @throws \{WebViewNotAvailableError\} When react-native-webview is not installed + */ +export function loadWebViewMessageEventType(): { + nativeEvent: { data: string }; +} { + try { + // Try to require the type from react-native-webview + const { WebViewMessageEvent } = require('react-native-webview'); + return WebViewMessageEvent; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + throw new WebViewNotAvailableError(); + } +} + +/** + * Checks if react-native-webview is available without throwing an error + * @returns true if react-native-webview is available, false otherwise + */ +export function isWebViewAvailable(): boolean { + try { + require('react-native-webview'); + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return false; + } +} + +/** + * Fallback WebView component that shows an error message + * Used when react-native-webview is not available + */ +interface FallbackWebViewProps { + style?: object; +} + +export const FallbackWebView = ({ style }: FallbackWebViewProps) => { + return ( + + + + WebView Not Available + + + react-native-webview is required to display this content. + + + Please install: npm install react-native-webview + + + + ); +}; diff --git a/src/utils/__tests__/WebViewLoader.test.ts b/src/utils/__tests__/WebViewLoader.test.ts new file mode 100644 index 000000000..221d9d900 --- /dev/null +++ b/src/utils/__tests__/WebViewLoader.test.ts @@ -0,0 +1,79 @@ +import { + loadWebView, + loadWebViewMessageEventType, + isWebViewAvailable, +} from '../WebViewLoader'; + +// Mock react-native-webview +jest.mock('react-native-webview', () => ({ + WebView: 'MockWebView', + WebViewMessageEvent: 'MockWebViewMessageEvent', +})); + +describe('WebViewLoader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isWebViewAvailable', () => { + it('should return true when react-native-webview is available', () => { + expect(isWebViewAvailable()).toBe(true); + }); + }); + + describe('loadWebView', () => { + it('should load WebView component when available', () => { + const WebView = loadWebView(); + expect(WebView).toBe('MockWebView'); + }); + }); + + describe('loadWebViewMessageEventType', () => { + it('should load WebViewMessageEvent type when available', () => { + const WebViewMessageEvent = loadWebViewMessageEventType(); + expect(WebViewMessageEvent).toBe('MockWebViewMessageEvent'); + }); + }); +}); + +describe('WebViewLoader without react-native-webview', () => { + beforeEach(() => { + // Clear the module cache and mock + jest.resetModules(); + jest.doMock('react-native-webview', () => { + throw new Error('Module not found'); + }); + }); + + afterEach(() => { + jest.resetModules(); + }); + + describe('isWebViewAvailable', () => { + it('should return false when react-native-webview is not available', async () => { + const { isWebViewAvailable: isAvailable } = await import( + '../WebViewLoader' + ); + expect(isAvailable()).toBe(false); + }); + }); + + describe('loadWebView', () => { + it('should throw WebViewNotAvailableError when react-native-webview is not available', async () => { + const { loadWebView: loadWebViewFn } = await import('../WebViewLoader'); + expect(() => loadWebViewFn()).toThrow( + 'react-native-webview is required but not installed' + ); + }); + }); + + describe('loadWebViewMessageEventType', () => { + it('should throw WebViewNotAvailableError when react-native-webview is not available', async () => { + const { loadWebViewMessageEventType: loadWebViewMessageEventTypeFn } = + await import('../WebViewLoader'); + expect(() => loadWebViewMessageEventTypeFn()).toThrow( + 'react-native-webview is required but not installed' + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d9961ac53..28f288534 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3146,9 +3146,9 @@ __metadata: react-native: 0.79.3 react-native-builder-bob: ^0.40.4 react-native-gesture-handler: ^2.26.0 - react-native-safe-area-context: ^5.4.0 + react-native-safe-area-context: ^5.6.1 react-native-screens: ^4.10.0 - react-native-vector-icons: ^10.2.0 + react-native-vector-icons: ^10.3.0 react-native-webview: ^13.14.1 react-test-renderer: 19.0.0 release-it: ^17.10.0 @@ -3161,12 +3161,15 @@ __metadata: "@react-navigation/native": "*" react: "*" react-native: "*" - react-native-safe-area-context: "*" - react-native-vector-icons: "*" - react-native-webview: "*" peerDependenciesMeta: expo: optional: true + react-native-safe-area-context: + optional: true + react-native-vector-icons: + optional: true + react-native-webview: + optional: true languageName: unknown linkType: soft @@ -12544,7 +12547,7 @@ __metadata: languageName: node linkType: hard -"react-native-safe-area-context@npm:^5.4.0": +"react-native-safe-area-context@npm:^5.6.1": version: 5.6.1 resolution: "react-native-safe-area-context@npm:5.6.1" peerDependencies: @@ -12597,6 +12600,21 @@ __metadata: languageName: node linkType: hard +"react-native-vector-icons@npm:^10.3.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 + languageName: node + linkType: hard + "react-native-webview@npm:^13.13.1": version: 13.14.1 resolution: "react-native-webview@npm:13.14.1"