UNPKG

@oxyhq/services

Version:

Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀

444 lines (428 loc) • 14.5 kB
"use strict"; import { useCallback, useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; import { View, Text, StyleSheet, Platform, Animated, StatusBar, AppState, Keyboard } from 'react-native'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { OxyContextProvider, useOxy } from '../context/OxyContext'; import OxyRouter from '../navigation/OxyRouter'; import { FontLoader, setupFonts } from './FontLoader'; import { Toaster } from '../../lib/sonner'; import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'; // Import bottom sheet components directly - no longer a peer dependency import { BottomSheetModal, BottomSheetBackdrop, BottomSheetModalProvider, BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { useWindowDimensions } from 'react-native'; // Initialize fonts automatically import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; setupFonts(); /** * Enhanced OxyProvider component * * This component serves two purposes: * 1. As a context provider for authentication and session management across the app * 2. As a UI component for authentication and account management using a bottom sheet */ const OxyProvider = props => { const { oxyServices, children, contextOnly = false, onAuthStateChange, storageKeyPrefix, showInternalToaster = true, baseURL, // Add support for baseURL ...bottomSheetProps } = props; // Create typed internal bottom sheet controller ref const internalBottomSheetRef = useRef(null); // Initialize React Query Client (use provided client or create a default one once) const queryClientRef = useRef(null); if (!queryClientRef.current) { queryClientRef.current = props.queryClient ?? new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: 5 * 60_000, retry: 2, refetchOnReconnect: true, refetchOnWindowFocus: false }, mutations: { retry: 1 } } }); } // Hook React Query focus manager into React Native AppState useEffect(() => { const subscription = AppState.addEventListener('change', state => { focusManager.setFocused(state === 'active'); }); return () => { subscription.remove(); }; }, []); // Mirror internal controller to external ref if provided (back-compat) useEffect(() => { if (props.bottomSheetRef) { props.bottomSheetRef.current = internalBottomSheetRef.current; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.bottomSheetRef]); // If contextOnly is true, we just provide the context without the bottom sheet UI if (contextOnly) { return /*#__PURE__*/_jsx(QueryClientProvider, { client: queryClientRef.current, children: /*#__PURE__*/_jsx(OxyContextProvider, { oxyServices: oxyServices, baseURL: baseURL, storageKeyPrefix: storageKeyPrefix, onAuthStateChange: onAuthStateChange, children: children }) }); } // Otherwise, provide both the context and the bottom sheet UI return /*#__PURE__*/_jsx(QueryClientProvider, { client: queryClientRef.current, children: /*#__PURE__*/_jsx(OxyContextProvider, { oxyServices: oxyServices, baseURL: baseURL, storageKeyPrefix: storageKeyPrefix, onAuthStateChange: onAuthStateChange, bottomSheetRef: internalBottomSheetRef, children: /*#__PURE__*/_jsx(FontLoader, { children: /*#__PURE__*/_jsxs(GestureHandlerRootView, { style: styles.gestureHandlerRoot, children: [/*#__PURE__*/_jsxs(BottomSheetModalProvider, { children: [/*#__PURE__*/_jsx(StatusBar, { translucent: true, backgroundColor: "transparent" }), /*#__PURE__*/_jsxs(SafeAreaProvider, { children: [/*#__PURE__*/_jsx(OxyBottomSheet, { ...bottomSheetProps, ref: internalBottomSheetRef, oxyServices: oxyServices }), children] })] }), !showInternalToaster && /*#__PURE__*/_jsx(View, { style: styles.toasterContainer, children: /*#__PURE__*/_jsx(Toaster, { position: "bottom-center", swipeToDismissDirection: "left", offset: 15 }) })] }) }) }) }); }; /** * OxyBottomSheet component - A bottom sheet-based authentication and account management UI * * This is the original OxyProvider UI functionality, now extracted into its own component * and reimplemented using BottomSheetModal for better Android compatibility */ const OxyBottomSheet = /*#__PURE__*/forwardRef(({ oxyServices: providedOxyServices, initialScreen = 'SignIn', onClose, onAuthenticated, theme = 'light', customStyles = {}, autoPresent = false, showInternalToaster = true, appInsets }, ref) => { // Helper function to determine if native driver should be used const shouldUseNativeDriver = () => { return Platform.OS === 'ios'; }; // Get window dimensions for max height calculation const { height: windowHeight } = useWindowDimensions(); // Get oxyServices from context if not provided as prop const contextOxy = useOxy(); const oxyServices = providedOxyServices || contextOxy?.oxyServices; // Use the internal ref (which is passed as a prop from OxyProvider) const modalRef = useRef(null); const isOpenRef = useRef(false); const navigationRef = useRef(null); // Remove contentHeight, containerWidth, and snap point state/logic // Animation values - keep for content fade/slide const fadeAnim = useRef(new Animated.Value(Platform.OS === 'android' ? 1 : 0)).current; const slideAnim = useRef(new Animated.Value(Platform.OS === 'android' ? 0 : 50)).current; // Expose a clean, typed imperative API useImperativeHandle(ref, () => ({ present: () => { if (!isOpenRef.current) modalRef.current?.present?.(); }, dismiss: () => modalRef.current?.dismiss?.(), expand: () => { // Ensure presented, then animate content in if (!isOpenRef.current) modalRef.current?.present?.(); Animated.parallel([Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: shouldUseNativeDriver() }), Animated.spring(slideAnim, { toValue: 0, friction: 8, tension: 40, useNativeDriver: shouldUseNativeDriver() })]).start(); }, collapse: () => modalRef.current?.collapse?.(), snapToIndex: index => modalRef.current?.snapToIndex?.(index), snapToPosition: position => modalRef.current?.snapToPosition?.(position), navigate: (screen, props) => { if (navigationRef.current) { navigationRef.current(screen, props); return; } if (typeof document !== 'undefined') { const event = new CustomEvent('oxy:navigate', { detail: { screen, props } }); document.dispatchEvent(event); } else { globalThis.oxyNavigateEvent = { screen, props }; } } }), [fadeAnim, slideAnim]); const insets = useSafeAreaInsets(); // Track keyboard state for dynamic padding (not for bottomInset - library handles that) const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { const showSubscription = Keyboard.addListener('keyboardDidShow', e => { setKeyboardHeight(e.endCoordinates.height); }); const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { setKeyboardHeight(0); }); return () => { showSubscription.remove(); hideSubscription.remove(); }; }, []); // Calculate max height for dynamic sizing (screen height minus insets and margin) // Note: keyboardBehavior="interactive" handles keyboard positioning automatically const maxHeight = useMemo(() => { const topInset = (insets?.top ?? 0) + (appInsets?.top ?? 0); const bottomInset = (insets?.bottom ?? 0) + (appInsets?.bottom ?? 0); return windowHeight - topInset - bottomInset - 20; // 20px margin }, [windowHeight, insets?.top, insets?.bottom, appInsets?.top, appInsets?.bottom]); // Present the modal when component mounts, but only if autoPresent is true useEffect(() => { if (autoPresent && modalRef.current) { const timer = setTimeout(() => { modalRef.current?.present(); Animated.parallel([Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: shouldUseNativeDriver() }), Animated.spring(slideAnim, { toValue: 0, friction: 8, tension: 40, useNativeDriver: shouldUseNativeDriver() })]).start(); }, 100); return () => clearTimeout(timer); } }, [modalRef, autoPresent]); // Close the bottom sheet with animation (unchanged) const handleClose = useCallback(() => { Animated.timing(fadeAnim, { toValue: 0, duration: Platform.OS === 'android' ? 100 : 200, useNativeDriver: shouldUseNativeDriver() }).start(() => { modalRef.current?.dismiss(); if (onClose) { setTimeout(() => { onClose(); }, Platform.OS === 'android' ? 150 : 100); } }); }, [onClose]); // Handle authentication success (unchanged) const handleAuthenticated = useCallback(user => { fadeAnim.stopAnimation(); slideAnim.stopAnimation(); if (onAuthenticated) { onAuthenticated(user); } modalRef.current?.dismiss(); if (onClose) { setTimeout(() => { onClose(); }, 100); } }, [onAuthenticated, onClose]); // Backdrop rendering (unchanged) const renderBackdrop = useCallback(props => /*#__PURE__*/_jsx(BottomSheetBackdrop, { ...props, disappearsOnIndex: -1, appearsOnIndex: 0, opacity: 0.5 }), []); // Modernized BottomSheetModal usage return /*#__PURE__*/_jsxs(BottomSheetModal, { ref: modalRef, index: 0, enableDynamicSizing: true, maxDynamicContentSize: maxHeight, enablePanDownToClose: true, backdropComponent: renderBackdrop, backgroundStyle: [{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderTopLeftRadius: 35, borderTopRightRadius: 35 }], handleIndicatorStyle: { backgroundColor: customStyles.handleColor || (theme === 'light' ? '#CCCCCC' : '#444444'), width: 40, height: 4 }, handleStyle: { position: 'absolute', top: 0, left: 0, right: 0 }, style: styles.bottomSheetContainer, keyboardBehavior: Platform.OS === 'ios' ? 'extend' : 'interactive', keyboardBlurBehavior: "restore", android_keyboardInputMode: "adjustResize", enableOverDrag: false, enableContentPanningGesture: true, enableHandlePanningGesture: true, overDragResistanceFactor: 2.5, enableBlurKeyboardOnGesture: true, detached: true, topInset: (insets?.top ?? 0) + (appInsets?.top ?? 0), bottomInset: (insets?.bottom ?? 0) + (appInsets?.bottom ?? 0), onChange: index => { isOpenRef.current = index !== -1; }, onDismiss: () => { isOpenRef.current = false; }, children: [/*#__PURE__*/_jsx(BottomSheetScrollView, { style: [styles.contentContainer], contentContainerStyle: [styles.scrollContentContainer, { paddingBottom: keyboardHeight > 0 ? keyboardHeight : 0 }], showsVerticalScrollIndicator: true, bounces: false, nestedScrollEnabled: true, keyboardShouldPersistTaps: "handled", keyboardDismissMode: "on-drag", children: /*#__PURE__*/_jsx(Animated.View, { style: [styles.animatedContent, Platform.OS === 'android' ? { opacity: 1 } : { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }], children: /*#__PURE__*/_jsx(View, { style: [styles.centeredContentWrapper, { paddingBottom: (insets?.bottom ?? 0) + (appInsets?.bottom ?? 0) }], children: oxyServices ? /*#__PURE__*/_jsx(OxyRouter, { oxyServices: oxyServices, initialScreen: initialScreen, onClose: handleClose, onAuthenticated: handleAuthenticated, theme: theme, navigationRef: navigationRef, containerWidth: 800 // static, since dynamic sizing is used }) : /*#__PURE__*/_jsx(View, { style: styles.errorContainer, children: /*#__PURE__*/_jsx(Text, { children: "OxyServices not available" }) }) }) }) }), showInternalToaster && /*#__PURE__*/_jsx(View, { style: styles.toasterContainer, children: /*#__PURE__*/_jsx(Toaster, { position: "bottom-center", swipeToDismissDirection: "left" }) })] }); }); const styles = StyleSheet.create({ bottomSheetContainer: { maxWidth: 800, width: '100%', alignSelf: 'center', marginHorizontal: 'auto' }, contentContainer: { width: '100%', borderTopLeftRadius: 35, borderTopRightRadius: 35 }, scrollContentContainer: { // Content will size naturally, ScrollView handles overflow // paddingBottom is set dynamically based on keyboard height }, centeredContentWrapper: { width: '100%', alignSelf: 'center' }, animatedContent: { width: '100%' }, indicator: { width: 40, height: 4, marginTop: 8, marginBottom: 8, borderRadius: 35 }, errorContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, gestureHandlerRoot: { flex: 1, position: 'relative', backgroundColor: 'transparent', ...Platform.select({ android: { height: '100%', width: '100%' } }) }, toasterContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9999, elevation: 9999, // For Android pointerEvents: 'box-none' // Allow touches to pass through to underlying components } }); export default OxyProvider; //# sourceMappingURL=OxyProvider.js.map