diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfc2bf6f8..db02dfd307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- User Feedback Widget Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435)) + + To collect user feedback from inside your application call `Sentry.showFeedbackWidget()` or add the `FeedbackWidget` component. + + ```jsx + import { FeedbackWidget } from "@sentry/react-native"; + ... + + ``` + ## 6.8.0 ### Features diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 486ae72c48..38ce09c84c 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -10,6 +10,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.AssetManager; +import android.net.Uri; import android.util.SparseIntArray; import androidx.core.app.FrameMetricsAggregator; import androidx.fragment.app.FragmentActivity; @@ -72,6 +73,7 @@ import io.sentry.vendor.Base64; import java.io.BufferedInputStream; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.FileReader; @@ -970,6 +972,39 @@ public String fetchNativePackageName() { return packageInfo.packageName; } + public void getDataFromUri(String uri, Promise promise) { + try { + Uri contentUri = Uri.parse(uri); + try (InputStream is = + getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { + if (is == null) { + String msg = "File not found for uri: " + uri; + logger.log(SentryLevel.ERROR, msg); + promise.reject(new Exception(msg)); + return; + } + + ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream(); + int bufferSize = 1024; + byte[] buffer = new byte[bufferSize]; + int len; + while ((len = is.read(buffer)) != -1) { + byteBuffer.write(buffer, 0, len); + } + byte[] byteArray = byteBuffer.toByteArray(); + WritableArray jsArray = Arguments.createArray(); + for (byte b : byteArray) { + jsArray.pushInt(b & 0xFF); + } + promise.resolve(jsArray); + } + } catch (IOException e) { + String msg = "Error reading uri: " + uri + ": " + e.getMessage(); + logger.log(SentryLevel.ERROR, msg); + promise.reject(new Exception(msg)); + } + } + public void crashedLastRun(Promise promise) { promise.resolve(Sentry.isCrashedLastRun()); } diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 6ea8542e8b..92ef2c0614 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -177,4 +177,9 @@ public void crashedLastRun(Promise promise) { public void getNewScreenTimeToDisplay(Promise promise) { this.impl.getNewScreenTimeToDisplay(promise); } + + @Override + public void getDataFromUri(String uri, Promise promise) { + this.impl.getDataFromUri(uri, promise); + } } diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 57fcbf0a73..7896d45fde 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -152,6 +152,11 @@ public String fetchNativePackageName() { return this.impl.fetchNativePackageName(); } + @ReactMethod + public void getDataFromUri(String uri, Promise promise) { + this.impl.getDataFromUri(uri, promise); + } + @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 16b7bb72de..de7f82be35 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -769,6 +769,35 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys #endif } +RCT_EXPORT_METHOD(getDataFromUri + : (NSString *_Nonnull)uri resolve + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + NSURL *fileURL = [NSURL URLWithString:uri]; + if (![fileURL isFileURL]) { + reject(@"SentryReactNative", @"The provided URI is not a valid file:// URL", nil); + return; + } + NSError *error = nil; + NSData *fileData = [NSData dataWithContentsOfURL:fileURL options:0 error:&error]; + if (error || !fileData) { + reject(@"SentryReactNative", @"Failed to read file data", error); + return; + } + NSMutableArray *byteArray = [NSMutableArray arrayWithCapacity:fileData.length]; + const unsigned char *bytes = (const unsigned char *)fileData.bytes; + + for (NSUInteger i = 0; i < fileData.length; i++) { + [byteArray addObject:@(bytes[i])]; + } + resolve(byteArray); +#else + resolve(nil); +#endif +} + RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId) { #if SENTRY_TARGET_REPLAY_SUPPORTED diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 2d553e548f..125dc3b082 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -48,6 +48,7 @@ export interface Spec extends TurboModule { captureReplay(isHardCrash: boolean): Promise; getCurrentReplayId(): string | undefined | null; crashedLastRun(): Promise; + getDataFromUri(uri: string): Promise; } export type NativeStackFrame = { diff --git a/packages/core/src/js/feedback/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts new file mode 100644 index 0000000000..aebdb181e3 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts @@ -0,0 +1,128 @@ +import type { ViewStyle } from 'react-native'; + +import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; + +const PURPLE = 'rgba(88, 74, 192, 1)'; +const FOREGROUND_COLOR = '#2b2233'; +const BACKGROUND_COLOR = '#ffffff'; +const BORDER_COLOR = 'rgba(41, 35, 47, 0.13)'; + +const defaultStyles: FeedbackWidgetStyles = { + container: { + flex: 1, + padding: 20, + backgroundColor: BACKGROUND_COLOR, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'left', + flex: 1, + color: FOREGROUND_COLOR, + }, + label: { + marginBottom: 4, + fontSize: 16, + color: FOREGROUND_COLOR, + }, + input: { + height: 50, + borderColor: BORDER_COLOR, + borderWidth: 1, + borderRadius: 5, + paddingHorizontal: 10, + marginBottom: 15, + fontSize: 16, + color: FOREGROUND_COLOR, + }, + textArea: { + height: 100, + textAlignVertical: 'top', + color: FOREGROUND_COLOR, + }, + screenshotButton: { + backgroundColor: BACKGROUND_COLOR, + padding: 15, + borderRadius: 5, + alignItems: 'center', + flex: 1, + borderWidth: 1, + borderColor: BORDER_COLOR, + }, + screenshotContainer: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + marginBottom: 20, + }, + screenshotThumbnail: { + width: 50, + height: 50, + borderRadius: 5, + marginRight: 10, + }, + screenshotText: { + color: FOREGROUND_COLOR, + fontSize: 16, + }, + submitButton: { + backgroundColor: PURPLE, + paddingVertical: 15, + borderRadius: 5, + alignItems: 'center', + marginBottom: 10, + }, + submitText: { + color: BACKGROUND_COLOR, + fontSize: 18, + }, + cancelButton: { + backgroundColor: BACKGROUND_COLOR, + padding: 15, + borderRadius: 5, + alignItems: 'center', + borderWidth: 1, + borderColor: BORDER_COLOR, + }, + cancelText: { + color: FOREGROUND_COLOR, + fontSize: 16, + }, + titleContainer: { + flexDirection: 'row', + width: '100%', + }, + sentryLogo: { + width: 40, + height: 40, + }, +}; + +export const modalWrapper: ViewStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, +}; + +export const modalSheetContainer: ViewStyle = { + backgroundColor: '#ffffff', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + overflow: 'hidden', + alignSelf: 'stretch', + shadowColor: '#000', + shadowOffset: { width: 0, height: -3 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 5, + flex: 1, +}; + +export const topSpacer: ViewStyle = { + height: 64, // magic number +}; + +export default defaultStyles; diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx new file mode 100644 index 0000000000..2d81a9e7e6 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidget.tsx @@ -0,0 +1,319 @@ +import type { SendFeedbackParams } from '@sentry/core'; +import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/core'; +import * as React from 'react'; +import type { KeyboardTypeOptions } from 'react-native'; +import { + Image, + Keyboard, + Text, + TextInput, + TouchableOpacity, + TouchableWithoutFeedback, + View +} from 'react-native'; + +import { isWeb, notWeb } from '../utils/environment'; +import { NATIVE } from '../wrapper'; +import { sentryLogo } from './branding'; +import { defaultConfiguration } from './defaults'; +import defaultStyles from './FeedbackWidget.styles'; +import type { FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackWidgetProps, FeedbackWidgetState, FeedbackWidgetStyles, ImagePickerConfiguration } from './FeedbackWidget.types'; +import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils'; + +/** + * @beta + * Implements a feedback form screen that sends feedback to Sentry using Sentry.captureFeedback. + */ +export class FeedbackWidget extends React.Component { + public static defaultProps: Partial = { + ...defaultConfiguration + } + + private static _savedState: Omit = { + name: '', + email: '', + description: '', + filename: undefined, + attachment: undefined, + attachmentUri: undefined, + }; + + private _didSubmitForm: boolean = false; + + public constructor(props: FeedbackWidgetProps) { + super(props); + + const currentUser = { + useSentryUser: { + email: this.props?.useSentryUser?.email || getCurrentScope()?.getUser()?.email || '', + name: this.props?.useSentryUser?.name || getCurrentScope()?.getUser()?.name || '', + } + } + + this.state = { + isVisible: true, + name: FeedbackWidget._savedState.name || currentUser.useSentryUser.name, + email: FeedbackWidget._savedState.email || currentUser.useSentryUser.email, + description: FeedbackWidget._savedState.description || '', + filename: FeedbackWidget._savedState.filename || undefined, + attachment: FeedbackWidget._savedState.attachment || undefined, + attachmentUri: FeedbackWidget._savedState.attachmentUri || undefined, + }; + } + + public handleFeedbackSubmit: () => void = () => { + const { name, email, description } = this.state; + const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props; + const text: FeedbackTextConfiguration = this.props; + + const trimmedName = name?.trim(); + const trimmedEmail = email?.trim(); + const trimmedDescription = description?.trim(); + + if ((this.props.isNameRequired && !trimmedName) || (this.props.isEmailRequired && !trimmedEmail) || !trimmedDescription) { + feedbackAlertDialog(text.errorTitle, text.formError); + return; + } + + if (this.props.shouldValidateEmail && (this.props.isEmailRequired || trimmedEmail.length > 0) && !isValidEmail(trimmedEmail)) { + feedbackAlertDialog(text.errorTitle, text.emailError); + return; + } + + const attachments = this.state.filename && this.state.attachment + ? [ + { + filename: this.state.filename, + data: this.state.attachment, + }, + ] + : undefined; + + const eventId = lastEventId(); + const userFeedback: SendFeedbackParams = { + message: trimmedDescription, + name: trimmedName, + email: trimmedEmail, + associatedEventId: eventId, + }; + + try { + if (!onFormSubmitted) { + this.setState({ isVisible: false }); + } + captureFeedback(userFeedback, attachments ? { attachments } : undefined); + onSubmitSuccess({ name: trimmedName, email: trimmedEmail, message: trimmedDescription, attachments: attachments }); + feedbackAlertDialog(text.successMessageText , ''); + onFormSubmitted(); + this._didSubmitForm = true; + } catch (error) { + const errorString = `Feedback form submission failed: ${error}`; + onSubmitError(new Error(errorString)); + feedbackAlertDialog(text.errorTitle, text.genericError); + logger.error(`Feedback form submission failed: ${error}`); + } + }; + + public onScreenshotButtonPress: () => void = async () => { + if (!this.state.filename && !this.state.attachment) { + const imagePickerConfiguration: ImagePickerConfiguration = this.props; + if (imagePickerConfiguration.imagePicker) { + const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync + // expo-image-picker library is available + ? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], base64: isWeb() }) + // react-native-image-picker library is available + : imagePickerConfiguration.imagePicker.launchImageLibrary + ? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo', includeBase64: isWeb() }) + : null; + if (!launchImageLibrary) { + logger.warn('No compatible image picker library found. Please provide a valid image picker library.'); + if (__DEV__) { + feedbackAlertDialog( + 'Development note', + 'No compatible image picker library found. Please provide a compatible version of `expo-image-picker` or `react-native-image-picker`.', + ); + } + return; + } + + const result = await launchImageLibrary(); + if (result.assets && result.assets.length > 0) { + if (isWeb()) { + const filename = result.assets[0].fileName; + const imageUri = result.assets[0].uri; + const base64 = result.assets[0].base64; + const data = base64ToUint8Array(base64); + if (data != null) { + this.setState({ filename, attachment: data, attachmentUri: imageUri }); + } else { + logger.error('Failed to read image data on the web'); + } + } else { + const filename = result.assets[0].fileName; + const imageUri = result.assets[0].uri; + NATIVE.getDataFromUri(imageUri).then((data) => { + if (data != null) { + this.setState({ filename, attachment: data, attachmentUri: imageUri }); + } else { + logger.error('Failed to read image data from uri:', imageUri); + } + }).catch((error) => { + logger.error('Failed to read image data from uri:', imageUri, 'error: ', error); + }); + } + } + } else { + // Defaulting to the onAddScreenshot callback + const { onAddScreenshot } = { ...defaultConfiguration, ...this.props }; + onAddScreenshot((uri: string) => { + NATIVE.getDataFromUri(uri).then((data) => { + if (data != null) { + this.setState({ filename: 'feedback_screenshot', attachment: data, attachmentUri: uri }); + } else { + logger.error('Failed to read image data from uri:', uri); + } + }) + .catch((error) => { + logger.error('Failed to read image data from uri:', uri, 'error: ', error); + }); + }); + } + } else { + this.setState({ filename: undefined, attachment: undefined, attachmentUri: undefined }); + } + } + + /** + * Save the state before unmounting the component. + */ + public componentWillUnmount(): void { + if (this._didSubmitForm) { + this._clearFormState(); + this._didSubmitForm = false; + } else { + this._saveFormState(); + } + } + + /** + * Renders the feedback form screen. + */ + public render(): React.ReactNode { + const { name, email, description } = this.state; + const { onFormClose } = this.props; + const config: FeedbackGeneralConfiguration = this.props; + const imagePickerConfiguration: ImagePickerConfiguration = this.props; + const text: FeedbackTextConfiguration = this.props; + const styles: FeedbackWidgetStyles = { ...defaultStyles, ...this.props.styles }; + const onCancel = (): void => { + if (onFormClose) { + onFormClose(); + } else { + this.setState({ isVisible: false }); + } + } + + if (!this.state.isVisible) { + return null; + } + + return ( + + + + {text.formTitle} + {config.showBranding && ( + + )} + + + {config.showName && ( + <> + + {text.nameLabel} + {config.isNameRequired && ` ${text.isRequiredLabel}`} + + this.setState({ name: value })} + /> + + )} + + {config.showEmail && ( + <> + + {text.emailLabel} + {config.isEmailRequired && ` ${text.isRequiredLabel}`} + + this.setState({ email: value })} + /> + + )} + + + {text.messageLabel} + {` ${text.isRequiredLabel}`} + + this.setState({ description: value })} + multiline + /> + {(config.enableScreenshot || imagePickerConfiguration.imagePicker) && ( + + {this.state.attachmentUri && ( + + )} + + + {!this.state.filename && !this.state.attachment + ? text.addScreenshotButtonLabel + : text.removeScreenshotButtonLabel} + + + + )} + + {text.submitButtonLabel} + + + + {text.cancelButtonLabel} + + + + ); + } + + private _saveFormState = (): void => { + FeedbackWidget._savedState = { ...this.state }; + }; + + private _clearFormState = (): void => { + FeedbackWidget._savedState = { + name: '', + email: '', + description: '', + filename: undefined, + attachment: undefined, + attachmentUri: undefined, + }; + }; +} diff --git a/packages/core/src/js/feedback/FeedbackWidget.types.ts b/packages/core/src/js/feedback/FeedbackWidget.types.ts new file mode 100644 index 0000000000..af08c2ffc3 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidget.types.ts @@ -0,0 +1,261 @@ +import type { FeedbackFormData } from '@sentry/core'; +import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'; + +/** + * The props for the feedback form + */ +export interface FeedbackWidgetProps + extends FeedbackGeneralConfiguration, + FeedbackTextConfiguration, + FeedbackCallbacks, + ImagePickerConfiguration { + styles?: FeedbackWidgetStyles; +} + +/** + * General feedback configuration + */ +export interface FeedbackGeneralConfiguration { + /** + * Show the Sentry branding + * + * @default true + */ + showBranding?: boolean; + + /** + * Should the email field be required? + */ + isEmailRequired?: boolean; + + /** + * Should the email field be validated? + */ + shouldValidateEmail?: boolean; + + /** + * Should the name field be required? + */ + isNameRequired?: boolean; + + /** + * Should the email input field be visible? Note: email will still be collected if set via `Sentry.setUser()` + */ + showEmail?: boolean; + + /** + * Should the name input field be visible? Note: name will still be collected if set via `Sentry.setUser()` + */ + showName?: boolean; + + /** + * This flag determines whether the "Add Screenshot" button is displayed + * @default false + */ + enableScreenshot?: boolean; + + /** + * Fill in email/name input fields with Sentry user context if it exists. + * The value of the email/name keys represent the properties of your user context. + */ + useSentryUser?: { + email: string; + name: string; + }; +} + +/** + * All of the different text labels that can be customized + */ +export interface FeedbackTextConfiguration { + /** + * The label for the Feedback form cancel button that closes dialog + */ + cancelButtonLabel?: string; + + /** + * The label for the Feedback form submit button that sends feedback + */ + submitButtonLabel?: string; + + /** + * The title of the Feedback form + */ + formTitle?: string; + + /** + * Label for the email input + */ + emailLabel?: string; + + /** + * Placeholder text for Feedback email input + */ + emailPlaceholder?: string; + + /** + * Label for the message input + */ + messageLabel?: string; + + /** + * Placeholder text for Feedback message input + */ + messagePlaceholder?: string; + + /** + * Label for the name input + */ + nameLabel?: string; + + /** + * Message after feedback was sent successfully + */ + successMessageText?: string; + + /** + * Placeholder text for Feedback name input + */ + namePlaceholder?: string; + + /** + * Text which indicates that a field is required + */ + isRequiredLabel?: string; + + /** + * The label for the button that adds a screenshot and renders the image editor + */ + addScreenshotButtonLabel?: string; + + /** + * The label for the button that removes a screenshot and hides the image editor + */ + removeScreenshotButtonLabel?: string; + + /** + * The title of the error dialog + */ + errorTitle?: string; + + /** + * The error message when the form is invalid + */ + formError?: string; + + /** + * The error message when the email is invalid + */ + emailError?: string; + + /** + * Message when there is a generic error + */ + genericError?: string; +} + +/** + * The public callbacks available for the feedback integration + */ +export interface FeedbackCallbacks { + /** + * Callback when form is opened + */ + onFormOpen?: () => void; + + /** + * Callback when form is closed and not submitted + */ + onFormClose?: () => void; + + /** + * Callback when a screenshot is added + */ + onAddScreenshot?: (addScreenshot: (uri: string) => void) => void; + + /** + * Callback when feedback is successfully submitted + * + * After this you'll see a SuccessMessage on the screen for a moment. + */ + onSubmitSuccess?: (data: FeedbackFormData) => void; + + /** + * Callback when feedback is unsuccessfully submitted + */ + onSubmitError?: (error: Error) => void; + + /** + * Callback when the feedback form is submitted successfully, and the SuccessMessage is complete, or dismissed + */ + onFormSubmitted?: () => void; +} + +/** + * Image Picker configuration interface compatible with: + * - `react-native-image-picker`: 7.2, 8.0 + * - `expo-image-picker`: 16.0` + */ +export interface ImagePickerConfiguration { + imagePicker?: ImagePicker; +} + +interface ImagePickerResponse { + assets?: ImagePickerAsset[]; +} + +interface ImagePickerAsset { + fileName?: string; + uri?: string; + base64?: string; +} + +interface ExpoImageLibraryOptions { + mediaTypes?: 'images'[]; + base64?: boolean; +} + +interface ReactNativeImageLibraryOptions { + mediaType: 'photo'; + includeBase64?: boolean; +} + +export interface ImagePicker { + launchImageLibraryAsync?: (options?: ExpoImageLibraryOptions) => Promise; + + launchImageLibrary?: (options: ReactNativeImageLibraryOptions) => Promise; +} + +/** + * The styles for the feedback form + */ +export interface FeedbackWidgetStyles { + container?: ViewStyle; + title?: TextStyle; + label?: TextStyle; + input?: TextStyle; + textArea?: TextStyle; + submitButton?: ViewStyle; + submitText?: TextStyle; + cancelButton?: ViewStyle; + cancelText?: TextStyle; + screenshotButton?: ViewStyle; + screenshotContainer?: ViewStyle; + screenshotThumbnail?: ImageStyle; + screenshotText?: TextStyle; + titleContainer?: ViewStyle; + sentryLogo?: ImageStyle; +} + +/** + * The state of the feedback form + */ +export interface FeedbackWidgetState { + isVisible: boolean; + name: string; + email: string; + description: string; + filename?: string; + attachment?: string | Uint8Array; + attachmentUri?: string; +} diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx new file mode 100644 index 0000000000..d10c356794 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -0,0 +1,214 @@ +import { logger } from '@sentry/core'; +import * as React from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import { Animated, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; + +import { notWeb } from '../utils/environment'; +import { FeedbackWidget } from './FeedbackWidget'; +import { modalSheetContainer, modalWrapper, topSpacer } from './FeedbackWidget.styles'; +import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; +import { getFeedbackOptions } from './integration'; +import { isModalSupported } from './utils'; + +const PULL_DOWN_CLOSE_THRESHOLD = 200; +const SLIDE_ANIMATION_DURATION = 200; +const BACKGROUND_ANIMATION_DURATION = 200; + +class FeedbackWidgetManager { + private static _isVisible = false; + private static _setVisibility: (visible: boolean) => void; + + public static initialize(setVisibility: (visible: boolean) => void): void { + this._setVisibility = setVisibility; + } + + public static show(): void { + if (this._setVisibility) { + this._isVisible = true; + this._setVisibility(true); + } + } + + public static hide(): void { + if (this._setVisibility) { + this._isVisible = false; + this._setVisibility(false); + } + } + + public static isFormVisible(): boolean { + return this._isVisible; + } +} + +interface FeedbackWidgetProviderProps { + children: React.ReactNode; + styles?: FeedbackWidgetStyles; +} + +interface FeedbackWidgetProviderState { + isVisible: boolean; + backgroundOpacity: Animated.Value; + panY: Animated.Value; + isScrollAtTop: boolean; +} + +class FeedbackWidgetProvider extends React.Component { + public state: FeedbackWidgetProviderState = { + isVisible: false, + backgroundOpacity: new Animated.Value(0), + panY: new Animated.Value(Dimensions.get('screen').height), + isScrollAtTop: true, + }; + + 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); + FeedbackWidgetManager.initialize(this._setVisibilityFunction); + } + + /** + * 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 { 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} + {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 _handleClose = (): void => { + FeedbackWidgetManager.hide(); + }; +} + +const showFeedbackWidget = (): void => { + FeedbackWidgetManager.show(); +}; + +export { showFeedbackWidget, FeedbackWidgetProvider }; diff --git a/packages/core/src/js/feedback/branding.ts b/packages/core/src/js/feedback/branding.ts new file mode 100644 index 0000000000..e69dd1c79f --- /dev/null +++ b/packages/core/src/js/feedback/branding.ts @@ -0,0 +1,5 @@ +/** + * Base64 encoded PNG image of the Sentry logo (source https://sentry.io/branding/) + */ +export const sentryLogo = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAC4BAMAAABUVL5sAAAAD1BMVEUAAAA2Llo3LVk4LFg2LVmgcvrUAAAABHRSTlMAgL9ARyeO/QAAA7pJREFUeNrtWltynDAQFBIHIFUcgCQ6AIk5AAjd/0ypxFk3ouUCNJofl/rHy75a09MzI7Q2DQ0NDQ0NDQ0NDQ0N9eF+xPh9MKpwPv6FLss7R9yMImz8j8nowb9IglFDF6N+KCNIdqMFcGjpBbVUXdzHqKsXUhJGNb2g1vz6azRgX8XulUwMtVZjFsWkvFz10s0ooEO+9Vpxj/6rp9eI5VsdE6eJcDCxilqICiZWMHBSMkBVA2vq1SVrVyr6Ea5VMjHqjytTwcAgRVJ0DAy9VoWUTNT3tQysZGIYmGNTKHfK0q6oFkysYWCuHB0DaxX9yHLx5JKrhVC0TGwjWNRMvNAuW6PoR4TCepnqLLuiicEyKJq4g2C6Rc+hsInlgtHCF41OrD65INisObksbKy2/YJg9U08sGBTbRN3YaBQQu2i74/mcQil6vZr5C0dQpGbGKJvdOA1VDVxd5LHooPR5BJPdwql5vaLeq9FQZKJpa1kOoUSKpq45+m+ZK93aUpSwRyuafslUguCIZRZamKoBYQT80QmlqnF38p6bSIDcyg2q5fw/uo8dx0upZMLLdadQ1kgkNDEOOGiYYXjVKGJ8V00rEggcErurxAKCwQTi9RCAW7UFcUm5vPAOFNXFBY9DggQyp76jnIkOyBwaeqtyMScEu7w4JRNLnyQOjyvACcWpR145g6PV1fp9mvE0jMd3tWZXDR3/Ud2cSXZfmHhvNpEoFlgYsjFHX4AJc3kXXSTyEfDTrz94ptE1qvS9ouG1Ud2sQT5PVcHg3FL78FIYUpqxWK1yLzMxNzhHVaLzItMzB0eB/S4CDRHC+AzFTjhAiSSHx9tpgJXqnmhXi7VizM/F5v4V5oVqOIp81PpEW4Xt7PUA0kEe5WZ2PLt7ZopDg8Seue9GpxoU0WrHyFPgYlzmyrKPDxcpFeX3YRS5mGvxybmsC2tPhLJQxPzdsfliwMeLjAx9wcujoFIaEAX/KSYXz0s+9TE/E7LX0yF8lQvitl99sVjSgITl/yk6Lk48JjfGadnanHml8xjMvFTA+eL42CRwDKEZwbm4rBMyAmdH6UEz8HDTPj4d4ie1EJxJCQg56DXaxKOl0iGz0jcdebZluzhbFSA1yEZ2JzbHZKQe3I/EK4CErTHbwn84ZP+8Poxqrd/+I2cXJAw0v9VAkBiI3DhLryZEe6SXNeJk5HcHFu+Aom5wiIn2a7niZiE1WKMUhIOhNFJSQZzh0VG8tPcQufLSQI46sO9vcM0NDQ0NDQ0NDQ0NHxF/AFGJOBYBWrb5gAAAABJRU5ErkJggg=='; diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts new file mode 100644 index 0000000000..90d9534874 --- /dev/null +++ b/packages/core/src/js/feedback/defaults.ts @@ -0,0 +1,82 @@ +import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import { feedbackAlertDialog } from './utils'; + +const FORM_TITLE = 'Report a Bug'; +const NAME_PLACEHOLDER = 'Your Name'; +const NAME_LABEL = 'Name'; +const EMAIL_PLACEHOLDER = 'your.email@example.org'; +const EMAIL_LABEL = 'Email'; +const MESSAGE_PLACEHOLDER = "What's the bug? What did you expect?"; +const MESSAGE_LABEL = 'Description'; +const IS_REQUIRED_LABEL = '(required)'; +const SUBMIT_BUTTON_LABEL = 'Send Bug Report'; +const CANCEL_BUTTON_LABEL = 'Cancel'; +const ERROR_TITLE = 'Error'; +const FORM_ERROR = 'Please fill out all required fields.'; +const EMAIL_ERROR = 'Please enter a valid email address.'; +const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; +const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; +const GENERIC_ERROR_TEXT = 'Unable to send feedback due to an unexpected error.'; + +export const defaultConfiguration: Partial = { + // FeedbackCallbacks + onFormOpen: () => { + // Does nothing by default + }, + onFormClose: () => { + if (__DEV__) { + feedbackAlertDialog( + 'Development note', + 'onFormClose callback is not implemented. By default the form is just unmounted.', + ); + } + }, + onAddScreenshot: (_: (uri: string) => void) => { + if (__DEV__) { + feedbackAlertDialog('Development note', 'onAddScreenshot callback is not implemented.'); + } + }, + onSubmitSuccess: () => { + // Does nothing by default + }, + onSubmitError: () => { + // Does nothing by default + }, + onFormSubmitted: () => { + if (__DEV__) { + feedbackAlertDialog( + 'Development note', + 'onFormSubmitted callback is not implemented. By default the form is just unmounted.', + ); + } + }, + + // FeedbackGeneralConfiguration + showBranding: true, + isEmailRequired: false, + shouldValidateEmail: true, + isNameRequired: false, + showEmail: true, + showName: true, + enableScreenshot: false, + + // FeedbackTextConfiguration + cancelButtonLabel: CANCEL_BUTTON_LABEL, + emailLabel: EMAIL_LABEL, + emailPlaceholder: EMAIL_PLACEHOLDER, + formTitle: FORM_TITLE, + isRequiredLabel: IS_REQUIRED_LABEL, + messageLabel: MESSAGE_LABEL, + messagePlaceholder: MESSAGE_PLACEHOLDER, + nameLabel: NAME_LABEL, + namePlaceholder: NAME_PLACEHOLDER, + submitButtonLabel: SUBMIT_BUTTON_LABEL, + errorTitle: ERROR_TITLE, + formError: FORM_ERROR, + emailError: EMAIL_ERROR, + successMessageText: SUCCESS_MESSAGE_TEXT, + addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL, + removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, + genericError: GENERIC_ERROR_TEXT, +}; diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts new file mode 100644 index 0000000000..4a040de350 --- /dev/null +++ b/packages/core/src/js/feedback/integration.ts @@ -0,0 +1,22 @@ +import type { Integration } from '@sentry/core'; + +import type { FeedbackWidgetProps } from './FeedbackWidget.types'; + +export const FEEDBACK_FORM_INTEGRATION_NAME = 'MobileFeedback'; + +type FeedbackIntegration = Integration & { + options: Partial; +}; + +let savedOptions: Partial = {}; + +export const feedbackIntegration = (initOptions: FeedbackWidgetProps = {}): FeedbackIntegration => { + savedOptions = initOptions; + + return { + name: FEEDBACK_FORM_INTEGRATION_NAME, + options: savedOptions, + }; +}; + +export const getFeedbackOptions = (): Partial => savedOptions; diff --git a/packages/core/src/js/feedback/utils.ts b/packages/core/src/js/feedback/utils.ts new file mode 100644 index 0000000000..9c2826981d --- /dev/null +++ b/packages/core/src/js/feedback/utils.ts @@ -0,0 +1,46 @@ +import { Alert } from 'react-native'; + +import { isFabricEnabled, isWeb } from '../utils/environment'; +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import { ReactNativeLibraries } from './../utils/rnlibraries'; + +declare global { + // Declaring atob function to be used in web environment + function atob(encodedString: string): string; +} + +/** + * Modal is not supported in React Native < 0.71 with Fabric renderer. + * ref: https://github.com/facebook/react-native/issues/33652 + */ +export function isModalSupported(): boolean { + const { major, minor } = ReactNativeLibraries.ReactNativeVersion?.version || {}; + return !(isFabricEnabled() && major === 0 && minor < 71); +} + +export const isValidEmail = (email: string): boolean => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailRegex.test(email); +}; + +/** + * Converts base64 string to Uint8Array on the web + * @param base64 base64 string + * @returns Uint8Array data + */ +export const base64ToUint8Array = (base64: string): Uint8Array => { + if (typeof atob !== 'function' || !isWeb()) { + throw new Error('atob is not available in this environment.'); + } + + const binaryString = atob(base64); + return new Uint8Array([...binaryString].map(char => char.charCodeAt(0))); +}; + +export const feedbackAlertDialog = (title: string, message: string): void => { + if (isWeb() && typeof RN_GLOBAL_OBJ.alert !== 'undefined') { + RN_GLOBAL_OBJ.alert(`${title}\n${message}`); + } else { + Alert.alert(title, message); + } +}; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index e7c5411613..4508684156 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -84,3 +84,6 @@ export { export type { TimeToDisplayProps } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; + +export { FeedbackWidget } from './feedback/FeedbackWidget'; +export { showFeedbackWidget } from './feedback/FeedbackWidgetManager'; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index dfc9e2c3e1..f5fabb397e 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -13,6 +13,7 @@ export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; +export { feedbackIntegration } from '../feedback/integration'; export { browserReplayIntegration } from '../replay/browserReplay'; export { appStartIntegration } from '../tracing/integrations/appStart'; export { nativeFramesIntegration, createNativeFramesIntegrations } from '../tracing/integrations/nativeFrames'; diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 3c6fdff90c..5edba50b48 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -8,6 +8,7 @@ import { import * as React from 'react'; import { ReactNativeClient } from './client'; +import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetManager'; import { getDevServer } from './integrations/debugsymbolicatorutils'; import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; @@ -163,7 +164,9 @@ export function wrap

>( return ( - + + + ); diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index c1a4ae5dbb..03327bac36 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -25,6 +25,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { __BUNDLE_START_TIME__?: number; nativePerformanceNow?: () => number; TextEncoder?: TextEncoder; + alert?: (message: string) => void; } type TextEncoder = { diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 4db45f0855..9b37a9b87b 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -120,6 +120,8 @@ interface SentryNativeWrapper { crashedLastRun(): Promise; getNewScreenTimeToDisplay(): Promise; + + getDataFromUri(uri: string): Promise; } const EOL = utf8ToBytes('\n'); @@ -702,6 +704,19 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.getNewScreenTimeToDisplay(); }, + async getDataFromUri(uri: string): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return null; + } + try { + const data: number[] = await RNSentry.getDataFromUri(uri); + return new Uint8Array(data); + } catch (error) { + logger.error('Error:', error); + return null; + } + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/packages/core/test/feedback/FeedbackWidget.test.tsx b/packages/core/test/feedback/FeedbackWidget.test.tsx new file mode 100644 index 0000000000..fb5a394fa3 --- /dev/null +++ b/packages/core/test/feedback/FeedbackWidget.test.tsx @@ -0,0 +1,404 @@ +import { captureFeedback } from '@sentry/core'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; +import { Alert } from 'react-native'; + +import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; +import type { FeedbackWidgetProps, FeedbackWidgetStyles, ImagePicker } from '../../src/js/feedback/FeedbackWidget.types'; + +const mockOnFormClose = jest.fn(); +const mockOnAddScreenshot = jest.fn(); +const mockOnSubmitSuccess = jest.fn(); +const mockOnFormSubmitted = jest.fn(); +const mockOnSubmitError = jest.fn(); +const mockGetUser = jest.fn(() => ({ + email: 'test@example.com', + name: 'Test User', +})); + +jest.spyOn(Alert, 'alert'); + +jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), + captureFeedback: jest.fn(), + getCurrentScope: jest.fn(() => ({ + getUser: mockGetUser, + })), + lastEventId: jest.fn(), +})); + +const defaultProps: FeedbackWidgetProps = { + onFormClose: mockOnFormClose, + onAddScreenshot: mockOnAddScreenshot, + onSubmitSuccess: mockOnSubmitSuccess, + onFormSubmitted: mockOnFormSubmitted, + onSubmitError: mockOnSubmitError, + addScreenshotButtonLabel: 'Add Screenshot', + formTitle: 'Feedback Form', + nameLabel: 'Name Label', + namePlaceholder: 'Name Placeholder', + emailLabel: 'Email Label', + emailPlaceholder: 'Email Placeholder', + messageLabel: 'Message Label', + messagePlaceholder: 'Message Placeholder', + submitButtonLabel: 'Submit Button Label', + cancelButtonLabel: 'Cancel Button Label', + isRequiredLabel: '(is required label)', + errorTitle: 'Error', + formError: 'Please fill out all required fields.', + emailError: 'The email address is not valid.', + successMessageText: 'Feedback success', + genericError: 'Generic error', +}; + +const customStyles: FeedbackWidgetStyles = { + container: { + backgroundColor: '#ffffff', + }, + title: { + fontSize: 20, + color: '#ff0000', + }, + label: { + fontSize: 15, + color: '#00ff00', + }, + input: { + height: 50, + borderColor: '#0000ff', + fontSize: 13, + color: '#000000', + }, + textArea: { + height: 50, + color: '#00ff00', + }, + submitButton: { + backgroundColor: '#ffff00', + }, + submitText: { + color: '#ff0000', + fontSize: 12, + }, + cancelButton: { + paddingVertical: 10, + }, + cancelText: { + color: '#ff0000', + fontSize: 10, + }, + screenshotButton: { + backgroundColor: '#00ff00', + }, + screenshotText: { + color: '#0000ff', + fontSize: 13, + }, +}; + +describe('FeedbackWidget', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('matches the snapshot with default configuration', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles', () => { + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with default configuration and screenshot button', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts and screenshot button', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles and screenshot button', () => { + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correctly', () => { + const { getByPlaceholderText, getByText, getByTestId, queryByText } = render(); + + expect(getByText(defaultProps.formTitle)).toBeTruthy(); + expect(getByTestId('sentry-logo')).toBeTruthy(); // default showBranding is true + expect(getByText(defaultProps.nameLabel)).toBeTruthy(); + expect(getByPlaceholderText(defaultProps.namePlaceholder)).toBeTruthy(); + expect(getByText(defaultProps.emailLabel)).toBeTruthy(); + expect(getByPlaceholderText(defaultProps.emailPlaceholder)).toBeTruthy(); + expect(getByText(`${defaultProps.messageLabel } ${ defaultProps.isRequiredLabel}`)).toBeTruthy(); + expect(getByPlaceholderText(defaultProps.messagePlaceholder)).toBeTruthy(); + expect(queryByText(defaultProps.addScreenshotButtonLabel)).toBeNull(); // default false + expect(getByText(defaultProps.submitButtonLabel)).toBeTruthy(); + expect(getByText(defaultProps.cancelButtonLabel)).toBeTruthy(); + }); + + it('renders attachment button when the enableScreenshot is true', () => { + const { getByText } = render(); + + expect(getByText(defaultProps.addScreenshotButtonLabel)).toBeTruthy(); + }); + + it('does not render the sentry logo when showBranding is false', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('sentry-logo')).toBeNull(); + }); + + it('name and email are prefilled when sentry user is set', () => { + const { getByPlaceholderText } = render(); + + const nameInput = getByPlaceholderText(defaultProps.namePlaceholder); + const emailInput = getByPlaceholderText(defaultProps.emailPlaceholder); + + expect(nameInput.props.value).toBe('Test User'); + expect(emailInput.props.value).toBe('test@example.com'); + }); + + it('ensure getUser is called only after the component is rendered', () => { + // Ensure getUser is not called before render + expect(mockGetUser).not.toHaveBeenCalled(); + + // Render the component + render(); + + // After rendering, check that getUser was called twice (email and name) + expect(mockGetUser).toHaveBeenCalledTimes(2); + }); + + it('shows an error message if required fields are empty', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith(defaultProps.errorTitle, defaultProps.formError); + }); + }); + + it('shows an error message if the email is not valid and the email is required', async () => { + const withEmailProps = {...defaultProps, ...{isEmailRequired: true}}; + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'not-an-email'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith(defaultProps.errorTitle, defaultProps.emailError); + }); + }); + + it('calls captureFeedback when the form is submitted successfully', async () => { + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(captureFeedback).toHaveBeenCalledWith({ + message: 'This is a feedback message.', + name: 'John Doe', + email: 'john.doe@example.com', + }, undefined); + }); + }); + + it('shows success message when the form is submitted successfully', async () => { + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith(defaultProps.successMessageText, ''); + }); + }); + + it('shows an error message when there is a an error in captureFeedback', async () => { + (captureFeedback as jest.Mock).mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith(defaultProps.errorTitle, defaultProps.genericError); + }); + }); + + it('calls onSubmitError when there is an error', async () => { + (captureFeedback as jest.Mock).mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(mockOnSubmitError).toHaveBeenCalled(); + }); + }); + + it('calls onSubmitSuccess when the form is submitted successfully', async () => { + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(mockOnSubmitSuccess).toHaveBeenCalled(); + }); + }); + + it('calls onFormSubmitted when the form is submitted successfully', async () => { + const { getByPlaceholderText, getByText } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + + await waitFor(() => { + expect(mockOnFormSubmitted).toHaveBeenCalled(); + }); + }); + + it('calls onAddScreenshot when the screenshot button is pressed and no image picker library is integrated', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); + + await waitFor(() => { + expect(mockOnAddScreenshot).toHaveBeenCalled(); + }); + }); + + it('calls launchImageLibraryAsync when the expo-image-picker library is integrated', async () => { + const mockLaunchImageLibrary = jest.fn().mockResolvedValue({ + assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }], + }); + const mockImagePicker: jest.Mocked = { + launchImageLibraryAsync: mockLaunchImageLibrary, + }; + + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); + + await waitFor(() => { + expect(mockLaunchImageLibrary).toHaveBeenCalled(); + }); + }); + + it('calls launchImageLibrary when the react-native-image-picker library is integrated', async () => { + const mockLaunchImageLibrary = jest.fn().mockResolvedValue({ + assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }], + }); + const mockImagePicker: jest.Mocked = { + launchImageLibrary: mockLaunchImageLibrary, + }; + + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); + + await waitFor(() => { + expect(mockLaunchImageLibrary).toHaveBeenCalled(); + }); + }); + + it('calls onFormClose when the cancel button is pressed', () => { + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.cancelButtonLabel)); + + expect(mockOnFormClose).toHaveBeenCalled(); + }); + + it('onUnmount the input is saved and restored when the form reopens', async () => { + const { getByPlaceholderText, unmount } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + unmount(); + const { queryByPlaceholderText } = render(); + + expect(queryByPlaceholderText(defaultProps.namePlaceholder).props.value).toBe('John Doe'); + expect(queryByPlaceholderText(defaultProps.emailPlaceholder).props.value).toBe('john.doe@example.com'); + expect(queryByPlaceholderText(defaultProps.messagePlaceholder).props.value).toBe('This is a feedback message.'); + }); + + it('onCancel the input is saved and restored when the form reopens', async () => { + const { getByPlaceholderText, getByText, unmount } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.cancelButtonLabel)); + unmount(); + const { queryByPlaceholderText } = render(); + + expect(queryByPlaceholderText(defaultProps.namePlaceholder).props.value).toBe('John Doe'); + expect(queryByPlaceholderText(defaultProps.emailPlaceholder).props.value).toBe('john.doe@example.com'); + expect(queryByPlaceholderText(defaultProps.messagePlaceholder).props.value).toBe('This is a feedback message.'); + }); + + it('onSubmit the saved input is cleared and not restored when the form reopens', async () => { + const { getByPlaceholderText, getByText, unmount } = render(); + + fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe'); + fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com'); + fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.'); + + fireEvent.press(getByText(defaultProps.submitButtonLabel)); + unmount(); + const { queryByPlaceholderText } = render(); + + expect(queryByPlaceholderText(defaultProps.namePlaceholder).props.value).toBe('Test User'); + expect(queryByPlaceholderText(defaultProps.emailPlaceholder).props.value).toBe('test@example.com'); + expect(queryByPlaceholderText(defaultProps.messagePlaceholder).props.value).toBe(''); + }); +}); diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx new file mode 100644 index 0000000000..2d9bfcc926 --- /dev/null +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -0,0 +1,96 @@ +import { logger } from '@sentry/core'; +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text } from 'react-native'; + +import { defaultConfiguration } from '../../src/js/feedback/defaults'; +import { FeedbackWidgetProvider, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; +import { feedbackIntegration } from '../../src/js/feedback/integration'; +import { isModalSupported } from '../../src/js/feedback/utils'; + +jest.mock('../../src/js/feedback/utils', () => ({ + isModalSupported: jest.fn(), +})); + +const mockedIsModalSupported = isModalSupported as jest.MockedFunction; + +beforeEach(() => { + logger.error = jest.fn(); +}); + +describe('FeedbackWidgetManager', () => { + it('showFeedbackWidget displays the form when FeedbackWidgetProvider is used', () => { + mockedIsModalSupported.mockReturnValue(true); + const { getByText, getByTestId } = render( + + App Components + + ); + + showFeedbackWidget(); + + expect(getByTestId('feedback-form-modal')).toBeTruthy(); + expect(getByText('App Components')).toBeTruthy(); + }); + + it('showFeedbackWidget does not display the form when Modal is not available', () => { + mockedIsModalSupported.mockReturnValue(false); + const { getByText, queryByTestId } = render( + + App Components + + ); + + showFeedbackWidget(); + + expect(queryByTestId('feedback-form-modal')).toBeNull(); + expect(getByText('App Components')).toBeTruthy(); + expect(logger.error).toHaveBeenLastCalledWith( + 'FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.', + ); + }); + + it('showFeedbackWidget does not throw an error when FeedbackWidgetProvider is not used', () => { + expect(() => { + showFeedbackWidget(); + }).not.toThrow(); + }); + + it('showFeedbackWidget displays the form with the feedbackIntegration options', () => { + mockedIsModalSupported.mockReturnValue(true); + const { getByPlaceholderText, getByText } = render( + + App Components + + ); + + feedbackIntegration({ + messagePlaceholder: 'Custom Message Placeholder', + submitButtonLabel: 'Custom Submit Button', + }); + + showFeedbackWidget(); + + expect(getByPlaceholderText('Custom Message Placeholder')).toBeTruthy(); + expect(getByText('Custom Submit Button')).toBeTruthy(); + }); + + it('showFeedbackWidget displays the form with the feedbackIntegration options merged with the defaults', () => { + mockedIsModalSupported.mockReturnValue(true); + const { getByPlaceholderText, getByText, queryByText } = render( + + App Components + + ); + + feedbackIntegration({ + submitButtonLabel: 'Custom Submit Button', + }), + + showFeedbackWidget(); + + expect(queryByText(defaultConfiguration.submitButtonLabel)).toBeFalsy(); // overridden value + expect(getByText('Custom Submit Button')).toBeTruthy(); // overridden value + expect(getByPlaceholderText(defaultConfiguration.messagePlaceholder)).toBeTruthy(); // default configuration value + }); +}); diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap new file mode 100644 index 0000000000..9f71d72ceb --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap @@ -0,0 +1,1700 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackWidget matches the snapshot with custom styles 1`] = ` + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + +`; + +exports[`FeedbackWidget matches the snapshot with custom styles and screenshot button 1`] = ` + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + + Add a screenshot + + + + + + Send Bug Report + + + + + Cancel + + + +`; + +exports[`FeedbackWidget matches the snapshot with custom texts 1`] = ` + + + + Feedback Form + + + + + Name Label + + + + Email Label + + + + Message Label + (is required label) + + + + + Submit Button Label + + + + + Cancel Button Label + + + +`; + +exports[`FeedbackWidget matches the snapshot with custom texts and screenshot button 1`] = ` + + + + Feedback Form + + + + + Name Label + + + + Email Label + + + + Message Label + (is required label) + + + + + + Add Screenshot + + + + + + Submit Button Label + + + + + Cancel Button Label + + + +`; + +exports[`FeedbackWidget matches the snapshot with default configuration 1`] = ` + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + +`; + +exports[`FeedbackWidget matches the snapshot with default configuration and screenshot button 1`] = ` + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + + Add a screenshot + + + + + + Send Bug Report + + + + + Cancel + + + +`; diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 82b2b9194c..fe6a611394 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -59,6 +59,7 @@ const NATIVE: MockInterface = { crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), + getDataFromUri: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); diff --git a/samples/expo/app.json b/samples/expo/app.json index 694ba5382f..1f1c89980d 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -54,6 +54,12 @@ } } ], + [ + "expo-image-picker", + { + "photosPermission": "The app accesses your photos to let you share them with your friends." + } + ], [ "expo-router", { diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index c6c5cc0b5a..947b22e471 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -58,6 +58,12 @@ export default function TabOneScreen() { Sentry.nativeCrash(); }} /> +