From af85e8d3b273f8833e8810d52e98f24d64f8c9e7 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 9 Oct 2025 22:06:10 -0700 Subject: [PATCH 1/4] feat: add IterableInboxIcon and IterableInboxSmartIcon components for enhanced icon support --- .eslintrc.js | 1 + README.md | 29 ++++++++----- package.json | 4 +- src/inbox/components/IterableInboxIcon.tsx | 41 +++++++++++++++++++ .../components/IterableInboxIconUtils.ts | 20 +++++++++ .../IterableInboxMessageDisplay.tsx | 4 +- .../components/IterableInboxSmartIcon.tsx | 36 ++++++++++++++++ src/inbox/components/index.ts | 2 + src/index.tsx | 4 ++ 9 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 src/inbox/components/IterableInboxIcon.tsx create mode 100644 src/inbox/components/IterableInboxIconUtils.ts create mode 100644 src/inbox/components/IterableInboxSmartIcon.tsx 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..c5aa284f6 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) + - [React Native Safe Area Context 4+](https://github.com/th3rdwave/react-native-safe-area-context) + - [React Native WebView 13+](https://github.com/react-native-webview/react-native-webview) -- **iOS** +#### Optional peer dependencies for enhanced UI + - [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..67330d521 100644 --- a/package.json +++ b/package.json @@ -113,12 +113,14 @@ "react": "*", "react-native": "*", "react-native-safe-area-context": "*", - "react-native-vector-icons": "*", "react-native-webview": "*" }, "peerDependenciesMeta": { "expo": { "optional": true + }, + "react-native-vector-icons": { + "optional": true } }, "sideEffects": false, 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..629c0787d 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -7,7 +7,7 @@ import { TouchableWithoutFeedback, View, } from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; +import { IterableInboxSmartIcon } from './IterableInboxSmartIcon'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { @@ -233,7 +233,7 @@ export const IterableInboxMessageDisplay = ({ }} > - 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'; From 527f57d6b4aae57feddcbd3dddcbc2ac09fdb943 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 9 Oct 2025 22:25:18 -0700 Subject: [PATCH 2/4] chore: update react-native-safe-area-context dependency to optional --- README.md | 2 +- package.json | 6 +- src/__tests__/SafeAreaContext.test.tsx | 98 +++++++++++++++++++ src/core/index.ts | 1 + src/core/utils/SafeAreaContext.tsx | 127 +++++++++++++++++++++++++ src/index.tsx | 11 +++ 6 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/SafeAreaContext.test.tsx create mode 100644 src/core/utils/SafeAreaContext.tsx diff --git a/README.md b/README.md index c5aa284f6..7a856fdbd 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ 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 WebView 13+](https://github.com/react-native-webview/react-native-webview) #### Optional peer dependencies for enhanced UI + - [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 diff --git a/package.json b/package.json index 67330d521..2c7b51533 100644 --- a/package.json +++ b/package.json @@ -93,9 +93,7 @@ "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-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", @@ -112,13 +110,15 @@ "@react-navigation/native": "*", "react": "*", "react-native": "*", - "react-native-safe-area-context": "*", "react-native-webview": "*" }, "peerDependenciesMeta": { "expo": { "optional": true }, + "react-native-safe-area-context": { + "optional": true + }, "react-native-vector-icons": { "optional": true } diff --git a/src/__tests__/SafeAreaContext.test.tsx b/src/__tests__/SafeAreaContext.test.tsx new file mode 100644 index 000000000..79c029313 --- /dev/null +++ b/src/__tests__/SafeAreaContext.test.tsx @@ -0,0 +1,98 @@ +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import { + ConditionalSafeAreaView, + ConditionalSafeAreaProvider, + getSafeAreaView, + getSafeAreaProvider, + SafeAreaContextNotAvailableError, +} from '../core/utils/SafeAreaContext'; + +// Mock react-native-safe-area-context +jest.mock('react-native-safe-area-context', () => { + throw new Error('Module not found'); +}); + +describe('SafeAreaContext', () => { + describe('ConditionalSafeAreaView', () => { + it('should fallback to View when react-native-safe-area-context is not available', () => { + const { getByText } = render( + + Test content + + ); + + // Should render the children + expect(getByText('Test content')).toBeTruthy(); + }); + + it('should log warning when falling back to View', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + render( + + Test content + + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'SafeAreaView is not available. Falling back to regular View.' + ) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('ConditionalSafeAreaProvider', () => { + it('should fallback to Fragment when react-native-safe-area-context is not available', () => { + const { getByText } = render( + + Test content + + ); + + // Should render the children directly + expect(getByText('Test content')).toBeTruthy(); + }); + + it('should log warning when falling back to Fragment', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + render( + + Test content + + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'SafeAreaProvider is not available. Falling back to Fragment.' + ) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('getSafeAreaView', () => { + it('should throw SafeAreaContextNotAvailableError when library is not available', () => { + expect(() => getSafeAreaView()).toThrow(SafeAreaContextNotAvailableError); + expect(() => getSafeAreaView()).toThrow( + 'react-native-safe-area-context is required for SafeAreaView but is not installed' + ); + }); + }); + + describe('getSafeAreaProvider', () => { + it('should throw SafeAreaContextNotAvailableError when library is not available', () => { + expect(() => getSafeAreaProvider()).toThrow( + SafeAreaContextNotAvailableError + ); + expect(() => getSafeAreaProvider()).toThrow( + 'react-native-safe-area-context is required for SafeAreaProvider but is not installed' + ); + }); + }); +}); 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/index.tsx b/src/index.tsx index 122c1718d..e0cedc64f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,17 @@ export { type IterableDeviceOrientation, } from './core/hooks'; export { type IterableEdgeInsetDetails } from './core/types'; +export { + ConditionalSafeAreaView, + ConditionalSafeAreaProvider, + getSafeAreaView, + getSafeAreaProvider, + getUseSafeAreaInsets, + getUseSafeAreaFrame, + SafeAreaContextNotAvailableError, + type ConditionalSafeAreaViewProps, + type ConditionalSafeAreaProviderProps, +} from './core/utils/SafeAreaContext'; export { IterableHtmlInAppContent, IterableInAppCloseSource, From c1ad97baf8ae37ebdffcb35e8afe6cd39f216021 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 9 Oct 2025 22:26:14 -0700 Subject: [PATCH 3/4] refactor: remove SafeAreaContext exports from index.tsx --- src/index.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index e0cedc64f..122c1718d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,17 +25,6 @@ export { type IterableDeviceOrientation, } from './core/hooks'; export { type IterableEdgeInsetDetails } from './core/types'; -export { - ConditionalSafeAreaView, - ConditionalSafeAreaProvider, - getSafeAreaView, - getSafeAreaProvider, - getUseSafeAreaInsets, - getUseSafeAreaFrame, - SafeAreaContextNotAvailableError, - type ConditionalSafeAreaViewProps, - type ConditionalSafeAreaProviderProps, -} from './core/utils/SafeAreaContext'; export { IterableHtmlInAppContent, IterableInAppCloseSource, From aefbecc5ca55f63418da814a6eb3f90aecdca24b Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 9 Oct 2025 22:55:26 -0700 Subject: [PATCH 4/4] feat: implement dynamic loading for react-native-webview --- README.md | 2 +- package.json | 8 +- src/__tests__/SafeAreaContext.test.tsx | 98 ------------- src/inbox/components/IterableInbox.tsx | 5 +- .../IterableInboxMessageDisplay.tsx | 35 ++++- src/utils/WebViewLoader.tsx | 131 ++++++++++++++++++ src/utils/__tests__/WebViewLoader.test.ts | 79 +++++++++++ yarn.lock | 30 +++- 8 files changed, 273 insertions(+), 115 deletions(-) delete mode 100644 src/__tests__/SafeAreaContext.test.tsx create mode 100644 src/utils/WebViewLoader.tsx create mode 100644 src/utils/__tests__/WebViewLoader.test.ts diff --git a/README.md b/README.md index 7a856fdbd..240239f1d 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ 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 WebView 13+](https://github.com/react-native-webview/react-native-webview) #### 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. diff --git a/package.json b/package.json index 2c7b51533..98fd2b463 100644 --- a/package.json +++ b/package.json @@ -93,7 +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.6.1", "react-native-screens": "^4.10.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", @@ -109,8 +111,7 @@ "peerDependencies": { "@react-navigation/native": "*", "react": "*", - "react-native": "*", - "react-native-webview": "*" + "react-native": "*" }, "peerDependenciesMeta": { "expo": { @@ -121,6 +122,9 @@ }, "react-native-vector-icons": { "optional": true + }, + "react-native-webview": { + "optional": true } }, "sideEffects": false, diff --git a/src/__tests__/SafeAreaContext.test.tsx b/src/__tests__/SafeAreaContext.test.tsx deleted file mode 100644 index 79c029313..000000000 --- a/src/__tests__/SafeAreaContext.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Text } from 'react-native'; -import { render } from '@testing-library/react-native'; -import { - ConditionalSafeAreaView, - ConditionalSafeAreaProvider, - getSafeAreaView, - getSafeAreaProvider, - SafeAreaContextNotAvailableError, -} from '../core/utils/SafeAreaContext'; - -// Mock react-native-safe-area-context -jest.mock('react-native-safe-area-context', () => { - throw new Error('Module not found'); -}); - -describe('SafeAreaContext', () => { - describe('ConditionalSafeAreaView', () => { - it('should fallback to View when react-native-safe-area-context is not available', () => { - const { getByText } = render( - - Test content - - ); - - // Should render the children - expect(getByText('Test content')).toBeTruthy(); - }); - - it('should log warning when falling back to View', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - render( - - Test content - - ); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'SafeAreaView is not available. Falling back to regular View.' - ) - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('ConditionalSafeAreaProvider', () => { - it('should fallback to Fragment when react-native-safe-area-context is not available', () => { - const { getByText } = render( - - Test content - - ); - - // Should render the children directly - expect(getByText('Test content')).toBeTruthy(); - }); - - it('should log warning when falling back to Fragment', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - - render( - - Test content - - ); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'SafeAreaProvider is not available. Falling back to Fragment.' - ) - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('getSafeAreaView', () => { - it('should throw SafeAreaContextNotAvailableError when library is not available', () => { - expect(() => getSafeAreaView()).toThrow(SafeAreaContextNotAvailableError); - expect(() => getSafeAreaView()).toThrow( - 'react-native-safe-area-context is required for SafeAreaView but is not installed' - ); - }); - }); - - describe('getSafeAreaProvider', () => { - it('should throw SafeAreaContextNotAvailableError when library is not available', () => { - expect(() => getSafeAreaProvider()).toThrow( - SafeAreaContextNotAvailableError - ); - expect(() => getSafeAreaProvider()).toThrow( - 'react-native-safe-area-context is required for SafeAreaProvider but is not installed' - ); - }); - }); -}); 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/IterableInboxMessageDisplay.tsx b/src/inbox/components/IterableInboxMessageDisplay.tsx index 629c0787d..f70e5ffc6 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -8,7 +8,11 @@ import { View, } from 'react-native'; import { IterableInboxSmartIcon } from './IterableInboxSmartIcon'; -import { WebView, type WebViewMessageEvent } from 'react-native-webview'; +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, ''); @@ -253,13 +278,13 @@ export const IterableInboxMessageDisplay = ({ - {inAppContent && ( + {inAppContent && WebViewComponent && ( - handleInAppLinkAction(event)} + onMessage={handleInAppLinkAction} injectedJavaScript={JS} /> 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"