diff --git a/packages/core/src/js/feedback/FeedbackButton.tsx b/packages/core/src/js/feedback/FeedbackButton.tsx index d01a8b4bc1..da27da8344 100644 --- a/packages/core/src/js/feedback/FeedbackButton.tsx +++ b/packages/core/src/js/feedback/FeedbackButton.tsx @@ -6,15 +6,10 @@ import { defaultButtonConfiguration } from './defaults'; import { defaultButtonStyles } from './FeedbackWidget.styles'; import { getTheme } from './FeedbackWidget.theme'; import type { FeedbackButtonProps, FeedbackButtonStyles, FeedbackButtonTextConfiguration } from './FeedbackWidget.types'; +import { showFeedbackWidget } from './FeedbackWidgetManager'; import { feedbackIcon } from './icons'; import { lazyLoadFeedbackIntegration } from './lazy'; -const showFeedbackWidget = (): void => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { showFeedbackWidget } = require('./FeedbackWidgetManager'); - showFeedbackWidget(); -}; - /** * @beta * Implements a feedback button that opens the FeedbackForm. diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx index c1a687a688..75870fbe88 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.tsx +++ b/packages/core/src/js/feedback/FeedbackWidget.tsx @@ -23,6 +23,7 @@ import { defaultConfiguration } from './defaults'; import defaultStyles from './FeedbackWidget.styles'; import { getTheme } from './FeedbackWidget.theme'; import type { FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackWidgetProps, FeedbackWidgetState, FeedbackWidgetStyles, ImagePickerConfiguration } from './FeedbackWidget.types'; +import { hideFeedbackButton, showScreenshotButton } from './FeedbackWidgetManager'; import { lazyLoadFeedbackIntegration } from './lazy'; import { getCapturedScreenshot } from './ScreenshotButton'; import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils'; @@ -335,8 +336,6 @@ export class FeedbackWidget extends React.Component { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { hideFeedbackButton, showScreenshotButton } = require('./FeedbackWidgetManager'); hideFeedbackButton(); onCancel(); showScreenshotButton(); diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index 39c6d81839..e554715586 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -1,22 +1,11 @@ import { logger } from '@sentry/core'; -import * as React from 'react'; -import type { NativeEventSubscription, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import { Animated, Appearance, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; -import { isWeb,notWeb } from '../utils/environment'; -import { FeedbackButton } from './FeedbackButton'; -import { FeedbackWidget } from './FeedbackWidget'; -import { modalSheetContainer, modalWrapper, topSpacer } from './FeedbackWidget.styles'; -import { getTheme } from './FeedbackWidget.theme'; -import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; -import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration'; +import { isWeb } from '../utils/environment'; import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy'; -import { ScreenshotButton } from './ScreenshotButton'; -import { isModalSupported } from './utils'; -const PULL_DOWN_CLOSE_THRESHOLD = 200; -const SLIDE_ANIMATION_DURATION = 200; -const BACKGROUND_ANIMATION_DURATION = 200; +export const PULL_DOWN_CLOSE_THRESHOLD = 200; +export const SLIDE_ANIMATION_DURATION = 200; +export const BACKGROUND_ANIMATION_DURATION = 200; abstract class FeedbackManager { protected static _isVisible = false; @@ -65,226 +54,40 @@ abstract class FeedbackManager { } } -class FeedbackWidgetManager extends FeedbackManager { +/** + * Provides functionality to show and hide the feedback widget. + */ +export class FeedbackWidgetManager extends FeedbackManager { + /** + * Returns the name of the feedback component. + */ protected static get _feedbackComponentName(): string { return 'FeedbackWidget'; } } -class FeedbackButtonManager extends FeedbackManager { +/** + * Provides functionality to show and hide the feedback button. + */ +export class FeedbackButtonManager extends FeedbackManager { + /** + * Returns the name of the feedback component. + */ protected static get _feedbackComponentName(): string { return 'FeedbackButton'; } } -class ScreenshotButtonManager extends FeedbackManager { - protected static get _feedbackComponentName(): string { - return 'ScreenshotButton'; - } -} - -interface FeedbackWidgetProviderProps { - children: React.ReactNode; - styles?: FeedbackWidgetStyles; -} - -interface FeedbackWidgetProviderState { - isButtonVisible: boolean; - isScreenshotButtonVisible: boolean; - isVisible: boolean; - backgroundOpacity: Animated.Value; - panY: Animated.Value; - isScrollAtTop: boolean; -} - -class FeedbackWidgetProvider extends React.Component { - public state: FeedbackWidgetProviderState = { - isButtonVisible: false, - isScreenshotButtonVisible: false, - isVisible: false, - backgroundOpacity: new Animated.Value(0), - panY: new Animated.Value(Dimensions.get('screen').height), - isScrollAtTop: true, - }; - - private _themeListener: NativeEventSubscription; - - private _panResponder = PanResponder.create({ - onStartShouldSetPanResponder: (_, gestureState) => { - return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; - }, - onMoveShouldSetPanResponder: (_, gestureState) => { - return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; - }, - onPanResponderMove: (_, gestureState) => { - if (gestureState.dy > 0) { - this.state.panY.setValue(gestureState.dy); - } - }, - onPanResponderRelease: (_, gestureState) => { - if (gestureState.dy > PULL_DOWN_CLOSE_THRESHOLD) { - // Close on swipe below a certain threshold - Animated.timing(this.state.panY, { - toValue: Dimensions.get('screen').height, - duration: SLIDE_ANIMATION_DURATION, - useNativeDriver: true, - }).start(() => { - this._handleClose(); - }); - } else { - // Animate it back to the original position - Animated.spring(this.state.panY, { - toValue: 0, - useNativeDriver: true, - }).start(); - } - }, - }); - - public constructor(props: FeedbackWidgetProviderProps) { - super(props); - FeedbackButtonManager.initialize(this._setButtonVisibilityFunction); - ScreenshotButtonManager.initialize(this._setScreenshotButtonVisibilityFunction); - FeedbackWidgetManager.initialize(this._setVisibilityFunction); - } - - /** - * Add a listener to the theme change event. - */ - public componentDidMount(): void { - this._themeListener = Appearance.addChangeListener(() => { - this.forceUpdate(); - }); - } - - /** - * Clean up the theme listener. - */ - public componentWillUnmount(): void { - if (this._themeListener) { - this._themeListener.remove(); - } - } - - /** - * Animates the background opacity when the modal is shown. - */ - public componentDidUpdate(_prevProps: any, prevState: FeedbackWidgetProviderState): void { - if (!prevState.isVisible && this.state.isVisible) { - Animated.parallel([ - Animated.timing(this.state.backgroundOpacity, { - toValue: 1, - duration: BACKGROUND_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.in(Easing.quad), - }), - Animated.timing(this.state.panY, { - toValue: 0, - duration: SLIDE_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.in(Easing.quad), - }) - ]).start(() => { - logger.info('FeedbackWidgetProvider componentDidUpdate'); - }); - } else if (prevState.isVisible && !this.state.isVisible) { - this.state.backgroundOpacity.setValue(0); - } - } - +/** + * Provides functionality to show and hide the screenshot button. + */ +export class ScreenshotButtonManager extends FeedbackManager { /** - * Renders the feedback form modal. + * Returns the name of the feedback component. */ - public render(): React.ReactNode { - if (!isModalSupported()) { - logger.error('FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.'); - return <>{this.props.children}; - } - - const theme = getTheme(); - - const { isButtonVisible, isScreenshotButtonVisible, isVisible, backgroundOpacity } = this.state; - - const backgroundColor = backgroundOpacity.interpolate({ - inputRange: [0, 1], - outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'], - }); - - // Wrapping the `Modal` component in a `View` component is necessary to avoid - // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035 - return ( - <> - {this.props.children} - {isButtonVisible && } - {isScreenshotButtonVisible && } - {isVisible && - - - - - - - - - - - } - - ); + protected static get _feedbackComponentName(): string { + return 'ScreenshotButton'; } - - private _handleScroll = (event: NativeSyntheticEvent): void => { - this.setState({ isScrollAtTop: event.nativeEvent.contentOffset.y <= 0 }); - }; - - private _setVisibilityFunction = (visible: boolean): void => { - const updateState = (): void => { - this.setState({ isVisible: visible }); - }; - if (!visible) { - Animated.parallel([ - Animated.timing(this.state.panY, { - toValue: Dimensions.get('screen').height, - duration: SLIDE_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.out(Easing.quad), - }), - Animated.timing(this.state.backgroundOpacity, { - toValue: 0, - duration: BACKGROUND_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.out(Easing.quad), - }) - ]).start(() => { - // Change of the state unmount the component - // which would cancel the animation - updateState(); - }); - } else { - updateState(); - } - }; - - private _setButtonVisibilityFunction = (visible: boolean): void => { - this.setState({ isButtonVisible: visible }); - }; - - private _setScreenshotButtonVisibilityFunction = (visible: boolean): void => { - this.setState({ isScreenshotButtonVisible: visible }); - }; - - private _handleClose = (): void => { - FeedbackWidgetManager.hide(); - }; } const showFeedbackWidget = (): void => { @@ -326,4 +129,4 @@ const resetScreenshotButtonManager = (): void => { ScreenshotButtonManager.reset(); }; -export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, FeedbackWidgetProvider, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; +export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx new file mode 100644 index 0000000000..fadebf0b17 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx @@ -0,0 +1,221 @@ +import { logger } from '@sentry/core'; +import * as React from 'react'; +import { type NativeEventSubscription, type NativeScrollEvent,type NativeSyntheticEvent, Animated, Appearance, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; + +import { notWeb } from '../utils/environment'; +import { FeedbackButton } from './FeedbackButton'; +import { FeedbackWidget } from './FeedbackWidget'; +import { modalSheetContainer,modalWrapper, topSpacer } from './FeedbackWidget.styles'; +import { getTheme } from './FeedbackWidget.theme'; +import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; +import { BACKGROUND_ANIMATION_DURATION,FeedbackButtonManager, FeedbackWidgetManager, PULL_DOWN_CLOSE_THRESHOLD, ScreenshotButtonManager, SLIDE_ANIMATION_DURATION } from './FeedbackWidgetManager'; +import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration'; +import { ScreenshotButton } from './ScreenshotButton'; +import { isModalSupported } from './utils'; + +export interface FeedbackWidgetProviderProps { + children: React.ReactNode; + styles?: FeedbackWidgetStyles; +} + +export interface FeedbackWidgetProviderState { + isButtonVisible: boolean; + isScreenshotButtonVisible: boolean; + isVisible: boolean; + backgroundOpacity: Animated.Value; + panY: Animated.Value; + isScrollAtTop: boolean; +} + +/** + * FeedbackWidgetProvider is a component that wraps the feedback widget and provides + * functionality to show and hide the widget. It also manages the visibility of the + * feedback button and screenshot button. + */ +export class FeedbackWidgetProvider extends React.Component { + public state: FeedbackWidgetProviderState = { + isButtonVisible: false, + isScreenshotButtonVisible: false, + isVisible: false, + backgroundOpacity: new Animated.Value(0), + panY: new Animated.Value(Dimensions.get('screen').height), + isScrollAtTop: true, + }; + + private _themeListener: NativeEventSubscription; + + private _panResponder = PanResponder.create({ + onStartShouldSetPanResponder: (_, gestureState) => { + return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; + }, + onMoveShouldSetPanResponder: (_, gestureState) => { + return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; + }, + onPanResponderMove: (_, gestureState) => { + if (gestureState.dy > 0) { + this.state.panY.setValue(gestureState.dy); + } + }, + onPanResponderRelease: (_, gestureState) => { + if (gestureState.dy > PULL_DOWN_CLOSE_THRESHOLD) { + // Close on swipe below a certain threshold + Animated.timing(this.state.panY, { + toValue: Dimensions.get('screen').height, + duration: SLIDE_ANIMATION_DURATION, + useNativeDriver: true, + }).start(() => { + this._handleClose(); + }); + } else { + // Animate it back to the original position + Animated.spring(this.state.panY, { + toValue: 0, + useNativeDriver: true, + }).start(); + } + }, + }); + + public constructor(props: FeedbackWidgetProviderProps) { + super(props); + FeedbackButtonManager.initialize(this._setButtonVisibilityFunction); + ScreenshotButtonManager.initialize(this._setScreenshotButtonVisibilityFunction); + FeedbackWidgetManager.initialize(this._setVisibilityFunction); + } + + /** + * Add a listener to the theme change event. + */ + public componentDidMount(): void { + this._themeListener = Appearance.addChangeListener(() => { + this.forceUpdate(); + }); + } + + /** + * Clean up the theme listener. + */ + public componentWillUnmount(): void { + if (this._themeListener) { + this._themeListener.remove(); + } + } + + /** + * Animates the background opacity when the modal is shown. + */ + public componentDidUpdate(_prevProps: any, prevState: FeedbackWidgetProviderState): void { + if (!prevState.isVisible && this.state.isVisible) { + Animated.parallel([ + Animated.timing(this.state.backgroundOpacity, { + toValue: 1, + duration: BACKGROUND_ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.in(Easing.quad), + }), + Animated.timing(this.state.panY, { + toValue: 0, + duration: SLIDE_ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.in(Easing.quad), + }) + ]).start(() => { + logger.info('FeedbackWidgetProvider componentDidUpdate'); + }); + } else if (prevState.isVisible && !this.state.isVisible) { + this.state.backgroundOpacity.setValue(0); + } + } + + /** + * Renders the feedback form modal. + */ + public render(): React.ReactNode { + if (!isModalSupported()) { + logger.error('FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.'); + return <>{this.props.children}; + } + + const theme = getTheme(); + + const { isButtonVisible, isScreenshotButtonVisible, isVisible, backgroundOpacity } = this.state; + + const backgroundColor = backgroundOpacity.interpolate({ + inputRange: [0, 1], + outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'], + }); + + // Wrapping the `Modal` component in a `View` component is necessary to avoid + // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035 + return ( + <> + {this.props.children} + {isButtonVisible && } + {isScreenshotButtonVisible && } + {isVisible && + + + + + + + + + + } + + ); + } + + private _handleScroll = (event: NativeSyntheticEvent): void => { + this.setState({ isScrollAtTop: event.nativeEvent.contentOffset.y <= 0 }); + }; + + private _setVisibilityFunction = (visible: boolean): void => { + const updateState = (): void => { + this.setState({ isVisible: visible }); + }; + if (!visible) { + Animated.parallel([ + Animated.timing(this.state.panY, { + toValue: Dimensions.get('screen').height, + duration: SLIDE_ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.out(Easing.quad), + }), + Animated.timing(this.state.backgroundOpacity, { + toValue: 0, + duration: BACKGROUND_ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.out(Easing.quad), + }) + ]).start(() => { + // Change of the state unmount the component + // which would cancel the animation + updateState(); + }); + } else { + updateState(); + } + }; + + private _setButtonVisibilityFunction = (visible: boolean): void => { + this.setState({ isButtonVisible: visible }); + }; + + private _setScreenshotButtonVisibilityFunction = (visible: boolean): void => { + this.setState({ isScreenshotButtonVisible: visible }); + }; + + private _handleClose = (): void => { + FeedbackWidgetManager.hide(); + }; +} diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 5edba50b48..7d085a6725 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -8,7 +8,7 @@ import { import * as React from 'react'; import { ReactNativeClient } from './client'; -import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetProvider'; import { getDevServer } from './integrations/debugsymbolicatorutils'; import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx index e62648a2d9..6dd4a46f47 100644 --- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -4,7 +4,8 @@ import * as React from 'react'; import { Appearance, Text } from 'react-native'; import { defaultConfiguration } from '../../src/js/feedback/defaults'; -import { FeedbackWidgetProvider, hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; +import { hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider'; import { feedbackIntegration } from '../../src/js/feedback/integration'; import { AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME,AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; import { isModalSupported } from '../../src/js/feedback/utils'; diff --git a/packages/core/test/feedback/ScreenshotButton.test.tsx b/packages/core/test/feedback/ScreenshotButton.test.tsx index 9bcb29389e..1903f94ed5 100644 --- a/packages/core/test/feedback/ScreenshotButton.test.tsx +++ b/packages/core/test/feedback/ScreenshotButton.test.tsx @@ -5,7 +5,8 @@ import { Text } from 'react-native'; import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; import type { ScreenshotButtonProps, ScreenshotButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; -import { FeedbackWidgetProvider, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager, showFeedbackButton } from '../../src/js/feedback/FeedbackWidgetManager'; +import { resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager, showFeedbackButton } from '../../src/js/feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider'; import { feedbackIntegration } from '../../src/js/feedback/integration'; import { getCapturedScreenshot, ScreenshotButton } from '../../src/js/feedback/ScreenshotButton'; import type { Screenshot } from '../../src/js/wrapper';