diff --git a/CHANGELOG.md b/CHANGELOG.md index 819f1e1a2e..cc585b509a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ - Add Expo Updates Event Context ([#4767](https://github.com/getsentry/sentry-react-native/pull/4767)) - Automatically collects `updateId`, `channel`, Emergency Launch Reason and other Expo Updates constants +- Adds the `FeedbackButton` component that shows the Feedback Widget ([#4378](https://github.com/getsentry/sentry-react-native/pull/4378)) +- Add Feedback Widget theming ([#4677](https://github.com/getsentry/sentry-react-native/pull/4677)) +- Adds the `ScreenshotButton` component that takes a screenshot ([#4714](https://github.com/getsentry/sentry-react-native/issues/4714)) ### Fixes @@ -53,6 +56,7 @@ - Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4680](https://github.com/getsentry/sentry-react-native/pull/4680)) - Avoid loading Sentry native components in Expo Go ([#4696](https://github.com/getsentry/sentry-react-native/pull/4696)) - Avoid silent failure when JS bundle was not created due to Sentry Xcode scripts failure ([#4690](https://github.com/getsentry/sentry-react-native/pull/4690)) +- Fixes Feedback Widget accessibility issue on iOS ([#4739](https://github.com/getsentry/sentry-react-native/pull/4739)) - Prevent crash on iOS during profiling stop when debug images are missing ([#4738](https://github.com/getsentry/sentry-react-native/pull/4738)) - Attach only App Starts within the 60s threshold (fixed comparison units, use ms) ([#4746](https://github.com/getsentry/sentry-react-native/pull/4746)) - Add missing `popTimeToDisplayFor` in to the Android Old Arch Native interface([#4751](https://github.com/getsentry/sentry-react-native/pull/4751)) 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 8a6beaa024..28f0fcb45c 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 @@ -20,6 +20,7 @@ import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.ReadableType; @@ -1027,6 +1028,15 @@ public void getDataFromUri(String uri, Promise promise) { } } + public void encodeToBase64(ReadableArray array, Promise promise) { + byte[] bytes = new byte[array.size()]; + for (int i = 0; i < array.size(); i++) { + bytes[i] = (byte) array.getInt(i); + } + String base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.DEFAULT); + promise.resolve(base64String); + } + 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 822ebc745c..5b14f05c92 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 @@ -183,6 +183,11 @@ public void getDataFromUri(String uri, Promise promise) { this.impl.getDataFromUri(uri, promise); } + @Override + public void encodeToBase64(ReadableArray array, Promise promise) { + this.impl.encodeToBase64(array, promise); + } + @Override public void popTimeToDisplayFor(String key, Promise promise) { this.impl.popTimeToDisplayFor(key, 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 92a9a38e8c..103afeb890 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 @@ -183,6 +183,11 @@ public void getDataFromUri(String uri, Promise promise) { this.impl.getDataFromUri(uri, promise); } + @ReactMethod + public void encodeToBase64(ReadableArray array, Promise promise) { + this.impl.encodeToBase64(array, promise); + } + @ReactMethod public void popTimeToDisplayFor(String key, Promise promise) { this.impl.popTimeToDisplayFor(key, promise); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 3afd510392..6b3070be11 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -970,4 +970,28 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys return @YES; // The return ensures that the method is synchronous } +RCT_EXPORT_METHOD(encodeToBase64 + : (NSArray *)array resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + NSUInteger count = array.count; + uint8_t *bytes = (uint8_t *)malloc(count); + + if (!bytes) { + reject(@"encodeToBase64", @"Memory allocation failed", nil); + return; + } + + for (NSUInteger i = 0; i < count; i++) { + bytes[i] = (uint8_t)[array[i] unsignedCharValue]; + } + + NSData *data = [NSData dataWithBytes:bytes length:count]; + free(bytes); + + NSString *base64String = [data base64EncodedStringWithOptions:0]; + resolve(base64String); +} + @end diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 638623a354..5b00b62116 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -51,6 +51,7 @@ export interface Spec extends TurboModule { getDataFromUri(uri: string): Promise; popTimeToDisplayFor(key: string): Promise; setActiveSpanId(spanId: string): boolean; + encodeToBase64(data: number[]): Promise; } export type NativeStackFrame = { diff --git a/packages/core/src/js/feedback/FeedbackButton.tsx b/packages/core/src/js/feedback/FeedbackButton.tsx new file mode 100644 index 0000000000..da27da8344 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackButton.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import type { NativeEventSubscription} from 'react-native'; +import { Appearance, Image, Text, TouchableOpacity } from 'react-native'; + +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'; + +/** + * @beta + * Implements a feedback button that opens the FeedbackForm. + */ +export class FeedbackButton extends React.Component { + private _themeListener: NativeEventSubscription; + + public constructor(props: FeedbackButtonProps) { + super(props); + lazyLoadFeedbackIntegration(); + } + + /** + * Adds a listener for theme changes. + */ + public componentDidMount(): void { + this._themeListener = Appearance.addChangeListener(() => { + this.forceUpdate(); + }); + } + + /** + * Removes the theme listener. + */ + public componentWillUnmount(): void { + if (this._themeListener) { + this._themeListener.remove(); + } + } + + /** + * Renders the feedback button. + */ + public render(): React.ReactNode { + const theme = getTheme(); + const text: FeedbackButtonTextConfiguration = { ...defaultButtonConfiguration, ...this.props }; + const styles: FeedbackButtonStyles = { + triggerButton: { ...defaultButtonStyles(theme).triggerButton, ...this.props.styles?.triggerButton }, + triggerText: { ...defaultButtonStyles(theme).triggerText, ...this.props.styles?.triggerText }, + triggerIcon: { ...defaultButtonStyles(theme).triggerIcon, ...this.props.styles?.triggerIcon }, + }; + + return ( + + + {text.triggerLabel} + + ); + } +} diff --git a/packages/core/src/js/feedback/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts index aebdb181e3..94df799d21 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.styles.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts @@ -1,104 +1,153 @@ import type { ViewStyle } from 'react-native'; -import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; +import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; +import type { FeedbackButtonStyles, 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 = (theme: FeedbackWidgetTheme): FeedbackWidgetStyles => { + return { + container: { + flex: 1, + padding: 20, + backgroundColor: theme.background, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'left', + flex: 1, + color: theme.foreground, + }, + label: { + marginBottom: 4, + fontSize: 16, + color: theme.foreground, + }, + input: { + height: 50, + borderColor: theme.border, + borderWidth: 1, + borderRadius: 5, + paddingHorizontal: 10, + marginBottom: 15, + fontSize: 16, + color: theme.foreground, + }, + textArea: { + height: 100, + textAlignVertical: 'top', + color: theme.foreground, + }, + screenshotButton: { + backgroundColor: theme.background, + padding: 15, + borderRadius: 5, + alignItems: 'center', + flex: 1, + borderWidth: 1, + borderColor: theme.border, + }, + screenshotContainer: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + marginBottom: 20, + }, + screenshotThumbnail: { + width: 50, + height: 50, + borderRadius: 5, + marginRight: 10, + }, + screenshotText: { + color: theme.foreground, + fontSize: 16, + }, + takeScreenshotButton: { + backgroundColor: theme.background, + padding: 15, + borderRadius: 5, + alignItems: 'center', + borderWidth: 1, + borderColor: theme.border, + marginTop: -10, + marginBottom: 20, + }, + takeScreenshotText: { + color: theme.foreground, + fontSize: 16, + }, + submitButton: { + backgroundColor: theme.accentBackground, + paddingVertical: 15, + borderRadius: 5, + alignItems: 'center', + marginBottom: 10, + }, + submitText: { + color: theme.accentForeground, + fontSize: 18, + }, + cancelButton: { + backgroundColor: theme.background, + padding: 15, + borderRadius: 5, + alignItems: 'center', + borderWidth: 1, + borderColor: theme.border, + }, + cancelText: { + color: theme.foreground, + fontSize: 16, + }, + titleContainer: { + flexDirection: 'row', + width: '100%', + }, + sentryLogo: { + width: 40, + height: 40, + tintColor: theme.sentryLogo, + }, + }; +}; -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 defaultButtonStyles = (theme: FeedbackWidgetTheme): FeedbackButtonStyles => { + return { + triggerButton: { + position: 'absolute', + bottom: 30, + right: 30, + backgroundColor: theme.background, + padding: 15, + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + elevation: 5, + shadowColor: theme.border, + shadowOffset: { width: 1, height: 2 }, + shadowOpacity: 0.5, + shadowRadius: 3, + flexDirection: 'row', + borderWidth: 1, + borderColor: theme.border, + }, + triggerText: { + color: theme.foreground, + fontSize: 18, + }, + triggerIcon: { + width: 24, + height: 24, + padding: 2, + marginEnd: 6, + tintColor: theme.sentryLogo, + }, + }; }; +export const defaultScreenshotButtonStyles = defaultButtonStyles; + export const modalWrapper: ViewStyle = { position: 'absolute', top: 0, @@ -107,18 +156,20 @@ export const modalWrapper: ViewStyle = { 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 modalSheetContainer = (theme: FeedbackWidgetTheme): ViewStyle => { + return { + backgroundColor: theme.background, + 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 = { diff --git a/packages/core/src/js/feedback/FeedbackWidget.theme.ts b/packages/core/src/js/feedback/FeedbackWidget.theme.ts new file mode 100644 index 0000000000..aa8711a934 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidget.theme.ts @@ -0,0 +1,71 @@ +import { Appearance } from 'react-native'; + +import { getColorScheme, getFeedbackDarkTheme, getFeedbackLightTheme } from './integration'; + +/** + * Get the theme for the feedback widget based on the current color scheme + */ +export function getTheme(): FeedbackWidgetTheme { + const userTheme = getColorScheme(); + const colorScheme = userTheme === 'system' ? Appearance.getColorScheme() : userTheme; + const lightTheme = { ...LightTheme, ...getFeedbackLightTheme() }; + const darkTheme = { ...DarkTheme, ...getFeedbackDarkTheme() }; + return colorScheme === 'dark' ? darkTheme : lightTheme; +} + +export interface FeedbackWidgetTheme { + /** + * Background color for surfaces + */ + background: string; + + /** + * Foreground color (i.e. text color) + */ + foreground: string; + + /** + * Foreground color for accented elements + */ + accentForeground?: string; + + /** + * Background color for accented elements + */ + accentBackground?: string; + + /** + * Border color + */ + border?: string; + + /** + * Color for feedback icon + */ + feedbackIcon?: string; + + /** + * Color for Sentry logo + */ + sentryLogo?: string; +} + +export const LightTheme: FeedbackWidgetTheme = { + accentBackground: 'rgba(88, 74, 192, 1)', + accentForeground: '#ffffff', + foreground: '#2b2233', + background: '#ffffff', + border: 'rgba(41, 35, 47, 0.13)', + feedbackIcon: 'rgba(54, 45, 89, 1)', + sentryLogo: 'rgba(54, 45, 89, 1)', +}; + +export const DarkTheme: FeedbackWidgetTheme = { + accentBackground: 'rgba(88, 74, 192, 1)', + accentForeground: '#ffffff', + foreground: '#ebe6ef', + background: '#29232f', + border: 'rgba(235, 230, 239, 0.15)', + feedbackIcon: '#ffffff', + sentryLogo: '#ffffff', +}; diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx index 7448d9d8b4..fa4c906408 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.tsx +++ b/packages/core/src/js/feedback/FeedbackWidget.tsx @@ -1,8 +1,11 @@ +/* eslint-disable max-lines */ 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 type { KeyboardTypeOptions , + NativeEventSubscription} from 'react-native'; import { + Appearance, Image, Keyboard, Text, @@ -13,12 +16,16 @@ import { } from 'react-native'; import { isWeb, notWeb } from '../utils/environment'; -import { getDataFromUri } from '../wrapper'; +import type { Screenshot } from '../wrapper'; +import { getDataFromUri, NATIVE } from '../wrapper'; import { sentryLogo } from './branding'; 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'; /** @@ -39,6 +46,8 @@ export class FeedbackWidget extends React.Component void = () => { const { name, email, description } = this.state; const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props; @@ -118,7 +141,7 @@ export class FeedbackWidget extends React.Component void = async () => { - if (!this.state.filename && !this.state.attachment) { + if (!this._hasScreenshot()) { const imagePickerConfiguration: ImagePickerConfiguration = this.props; if (imagePickerConfiguration.imagePicker) { const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync @@ -187,7 +210,16 @@ export class FeedbackWidget extends React.Component { + this.forceUpdate(); + }); + } + + /** + * Save the state before unmounting the component and remove the theme listener. */ public componentWillUnmount(): void { if (this._didSubmitForm) { @@ -196,18 +228,22 @@ export class FeedbackWidget extends React.Component { if (onFormClose) { onFormClose(); @@ -220,8 +256,21 @@ export class FeedbackWidget extends React.Component { + feedbackAlertDialog(text.errorTitle, text.captureScreenshotError); + }, 100); + } else if (screenshot) { + this._setCapturedScreenshot(screenshot); + } + return ( - + {text.formTitle} @@ -276,7 +325,7 @@ export class FeedbackWidget extends React.Component this.setState({ description: value })} multiline /> - {(config.enableScreenshot || imagePickerConfiguration.imagePicker) && ( + {(config.enableScreenshot || imagePickerConfiguration.imagePicker || this._hasScreenshot()) && ( {this.state.attachmentUri && ( - {!this.state.filename && !this.state.attachment + {!this._hasScreenshot() ? text.addScreenshotButtonLabel : text.removeScreenshotButtonLabel} )} + {notWeb() && config.enableTakeScreenshot && !this.state.attachmentUri && ( + { + hideFeedbackButton(); + onCancel(); + showScreenshotButton(); + }}> + {text.captureScreenshotButtonLabel} + + )} {text.submitButtonLabel} @@ -305,6 +363,24 @@ export class FeedbackWidget extends React.Component { + if (screenshot.data != null) { + logger.debug('Setting captured screenshot:', screenshot.filename); + NATIVE.encodeToBase64(screenshot.data).then((base64String) => { + if (base64String != null) { + const dataUri = `data:${screenshot.contentType};base64,${base64String}`; + this.setState({ filename: screenshot.filename, attachment: screenshot.data, attachmentUri: dataUri }); + } else { + logger.error('Failed to read image data from:', screenshot.filename); + } + }).catch((error) => { + logger.error('Failed to read image data from:', screenshot.filename, 'error: ', error); + }); + } else { + logger.error('Failed to read image data from:', screenshot.filename); + } + } + private _saveFormState = (): void => { FeedbackWidget._savedState = { ...this.state }; }; @@ -319,4 +395,8 @@ export class FeedbackWidget extends React.Component { + return this.state.filename !== undefined && this.state.attachment !== undefined && this.state.attachmentUri !== undefined; + } } diff --git a/packages/core/src/js/feedback/FeedbackWidget.types.ts b/packages/core/src/js/feedback/FeedbackWidget.types.ts index af08c2ffc3..22b6b0911f 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.types.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.types.ts @@ -54,6 +54,12 @@ export interface FeedbackGeneralConfiguration { */ enableScreenshot?: boolean; + /** + * This flag determines whether the "Take Screenshot" button is displayed + * @default false + */ + enableTakeScreenshot?: 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. @@ -124,15 +130,20 @@ export interface FeedbackTextConfiguration { isRequiredLabel?: string; /** - * The label for the button that adds a screenshot and renders the image editor + * The label for the button that adds a screenshot */ addScreenshotButtonLabel?: string; /** - * The label for the button that removes a screenshot and hides the image editor + * The label for the button that removes a screenshot */ removeScreenshotButtonLabel?: string; + /** + * The label for the button that shows the capture screenshot button + */ + captureScreenshotButtonLabel?: string; + /** * The title of the error dialog */ @@ -148,12 +159,47 @@ export interface FeedbackTextConfiguration { */ emailError?: string; + /** + * The error message when the capture screenshot fails + */ + captureScreenshotError?: string; + /** * Message when there is a generic error */ genericError?: string; } +/** + * The FeedbackButton text labels that can be customized + */ +export interface FeedbackButtonTextConfiguration { + /** + * The label for the Feedback widget button that opens the dialog + */ + triggerLabel?: string; + + /** + * The aria label for the Feedback widget button that opens the dialog + */ + triggerAriaLabel?: string; +} + +/** + * The ScreenshotButton text labels that can be customized + */ +export interface ScreenshotButtonTextConfiguration { + /** + * The label for the Screenshot button + */ + triggerLabel?: string; + + /** + * The aria label for the Screenshot button + */ + triggerAriaLabel?: string; +} + /** * The public callbacks available for the feedback integration */ @@ -243,10 +289,44 @@ export interface FeedbackWidgetStyles { screenshotContainer?: ViewStyle; screenshotThumbnail?: ImageStyle; screenshotText?: TextStyle; + takeScreenshotButton?: ViewStyle; + takeScreenshotText?: TextStyle; titleContainer?: ViewStyle; sentryLogo?: ImageStyle; } +/** + * The props for the feedback button + */ +export interface FeedbackButtonProps extends FeedbackButtonTextConfiguration { + styles?: FeedbackButtonStyles; +} + +/** + * The styles for the feedback button + */ +export interface FeedbackButtonStyles { + triggerButton?: ViewStyle; + triggerText?: TextStyle; + triggerIcon?: ImageStyle; +} + +/** + * The props for the screenshot button + */ +export interface ScreenshotButtonProps extends ScreenshotButtonTextConfiguration { + styles?: ScreenshotButtonStyles; +} + +/** + * The styles for the screenshot button + */ +export interface ScreenshotButtonStyles { + triggerButton?: ViewStyle; + triggerText?: TextStyle; + triggerIcon?: ImageStyle; +} + /** * The state of the feedback form */ diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index 856298382e..e554715586 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -1,23 +1,19 @@ 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 { lazyLoadAutoInjectFeedbackIntegration } from './lazy'; -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; + +import { isWeb } from '../utils/environment'; +import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy'; + +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; + protected static _setVisibility: (visible: boolean) => void; + + protected static get _feedbackComponentName(): string { + throw new Error('Subclasses must override feedbackComponentName'); + } public static initialize(setVisibility: (visible: boolean) => void): void { this._setVisibility = setVisibility; @@ -38,7 +34,7 @@ class FeedbackWidgetManager { } else { // This message should be always shown otherwise it's not possible to use the widget. // eslint-disable-next-line no-console - console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.'); + console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' to be called before 'show${this._feedbackComponentName}()'.`); } } @@ -49,7 +45,7 @@ class FeedbackWidgetManager { } else { // This message should be always shown otherwise it's not possible to use the widget. // eslint-disable-next-line no-console - console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` before interacting with the widget.'); + console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' before interacting with the widget.`); } } @@ -58,170 +54,40 @@ class FeedbackWidgetManager { } } -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); +/** + * 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'; } +} +/** + * Provides functionality to show and hide the feedback button. + */ +export class FeedbackButtonManager extends FeedbackManager { /** - * Animates the background opacity when the modal is shown. + * Returns the name of the feedback component. */ - 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); - } + protected static get _feedbackComponentName(): string { + return 'FeedbackButton'; } +} +/** + * 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 { 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 && - - - - - - - - - - - } - - ); + 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 _handleClose = (): void => { - FeedbackWidgetManager.hide(); - }; } const showFeedbackWidget = (): void => { @@ -233,4 +99,34 @@ const resetFeedbackWidgetManager = (): void => { FeedbackWidgetManager.reset(); }; -export { showFeedbackWidget, FeedbackWidgetProvider, resetFeedbackWidgetManager }; +const showFeedbackButton = (): void => { + lazyLoadAutoInjectFeedbackButtonIntegration(); + FeedbackButtonManager.show(); +}; + +const hideFeedbackButton = (): void => { + FeedbackButtonManager.hide(); +}; + +const resetFeedbackButtonManager = (): void => { + FeedbackButtonManager.reset(); +}; + +const showScreenshotButton = (): void => { + if (isWeb()) { + logger.warn('ScreenshotButton is not supported on Web.'); + return; + } + lazyLoadAutoInjectScreenshotButtonIntegration(); + ScreenshotButtonManager.show(); +}; + +const hideScreenshotButton = (): void => { + ScreenshotButtonManager.hide(); +}; + +const resetScreenshotButtonManager = (): void => { + ScreenshotButtonManager.reset(); +}; + +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/feedback/ScreenshotButton.tsx b/packages/core/src/js/feedback/ScreenshotButton.tsx new file mode 100644 index 0000000000..18cfa19239 --- /dev/null +++ b/packages/core/src/js/feedback/ScreenshotButton.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import type { NativeEventSubscription} from 'react-native'; +import { Appearance, Image, Text, TouchableOpacity } from 'react-native'; + +import type { Screenshot } from '../wrapper'; +import { NATIVE } from '../wrapper'; +import { defaultScreenshotButtonConfiguration } from './defaults'; +import { defaultScreenshotButtonStyles } from './FeedbackWidget.styles'; +import { getTheme } from './FeedbackWidget.theme'; +import type { ScreenshotButtonProps, ScreenshotButtonStyles, ScreenshotButtonTextConfiguration } from './FeedbackWidget.types'; +import { hideScreenshotButton, showFeedbackWidget } from './FeedbackWidgetManager'; +import { screenshotIcon } from './icons'; +import { lazyLoadFeedbackIntegration } from './lazy'; + +let capturedScreenshot: Screenshot | 'ErrorCapturingScreenshot' | undefined; + +const takeScreenshot = async (): Promise => { + hideScreenshotButton(); + setTimeout(async () => { // Delay capture to allow the button to hide + const screenshots: Screenshot[] | null = await NATIVE.captureScreenshot(); + if (screenshots && screenshots.length > 0) { + capturedScreenshot = screenshots[0]; + } else { + capturedScreenshot = 'ErrorCapturingScreenshot'; + } + showFeedbackWidget(); + }, 100); +}; + +export const getCapturedScreenshot = (): Screenshot | 'ErrorCapturingScreenshot' | undefined => { + const screenshot = capturedScreenshot; + capturedScreenshot = undefined; + return screenshot; +} + +/** + * @beta + * Implements a screenshot button that takes a screenshot. + */ +export class ScreenshotButton extends React.Component { + private _themeListener: NativeEventSubscription; + + public constructor(props: ScreenshotButtonProps) { + super(props); + lazyLoadFeedbackIntegration(); + } + + /** + * Adds a listener for theme changes. + */ + public componentDidMount(): void { + this._themeListener = Appearance.addChangeListener(() => { + this.forceUpdate(); + }); + } + + /** + * Removes the theme listener. + */ + public componentWillUnmount(): void { + if (this._themeListener) { + this._themeListener.remove(); + } + } + + /** + * Renders the screenshot button. + */ + public render(): React.ReactNode { + const theme = getTheme(); + const text: ScreenshotButtonTextConfiguration = { ...defaultScreenshotButtonConfiguration, ...this.props }; + const styles: ScreenshotButtonStyles = { + triggerButton: { ...defaultScreenshotButtonStyles(theme).triggerButton, ...this.props.styles?.triggerButton }, + triggerText: { ...defaultScreenshotButtonStyles(theme).triggerText, ...this.props.styles?.triggerText }, + triggerIcon: { ...defaultScreenshotButtonStyles(theme).triggerIcon, ...this.props.styles?.triggerIcon }, + }; + + return ( + + + {text.triggerLabel} + + ); + } +} diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index 90d9534874..2158b69a41 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -1,4 +1,4 @@ -import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types'; import { feedbackAlertDialog } from './utils'; const FORM_TITLE = 'Report a Bug'; @@ -11,11 +11,15 @@ const MESSAGE_LABEL = 'Description'; const IS_REQUIRED_LABEL = '(required)'; const SUBMIT_BUTTON_LABEL = 'Send Bug Report'; const CANCEL_BUTTON_LABEL = 'Cancel'; +const TRIGGER_LABEL = 'Report a Bug'; +const TRIGGER_SCREENSHOT_LABEL = 'Take Screenshot'; const ERROR_TITLE = 'Error'; const FORM_ERROR = 'Please fill out all required fields.'; const EMAIL_ERROR = 'Please enter a valid email address.'; +const CAPTURE_SCREENSHOT_ERROR = 'Error capturing screenshot. Please try again.'; const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +const CAPTURE_SCREENSHOT_LABEL = 'Take a screenshot'; const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; const GENERIC_ERROR_TEXT = 'Unable to send feedback due to an unexpected error.'; @@ -60,6 +64,7 @@ export const defaultConfiguration: Partial = { showEmail: true, showName: true, enableScreenshot: false, + enableTakeScreenshot: false, // FeedbackTextConfiguration cancelButtonLabel: CANCEL_BUTTON_LABEL, @@ -75,8 +80,20 @@ export const defaultConfiguration: Partial = { errorTitle: ERROR_TITLE, formError: FORM_ERROR, emailError: EMAIL_ERROR, + captureScreenshotError: CAPTURE_SCREENSHOT_ERROR, successMessageText: SUCCESS_MESSAGE_TEXT, addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL, removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, + captureScreenshotButtonLabel: CAPTURE_SCREENSHOT_LABEL, genericError: GENERIC_ERROR_TEXT, }; + +export const defaultButtonConfiguration: Partial = { + triggerLabel: TRIGGER_LABEL, + triggerAriaLabel: '', +}; + +export const defaultScreenshotButtonConfiguration: Partial = { + triggerLabel: TRIGGER_SCREENSHOT_LABEL, + triggerAriaLabel: '', +}; diff --git a/packages/core/src/js/feedback/icons.ts b/packages/core/src/js/feedback/icons.ts new file mode 100644 index 0000000000..b73ecdde86 --- /dev/null +++ b/packages/core/src/js/feedback/icons.ts @@ -0,0 +1,32 @@ +export const feedbackIcon = + 'data:image/png;base64,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'; + +/** + * Source: https://github.com/tabler/tabler-icons/blob/b54c86433ed5121e2590bf09f0faf746bb5aba66/icons/outline/screenshot.svg + * + * MIT License + * + * Copyright (c) 2020-2024 Paweł Kuna + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * TODO: Replace with another screenshot icon when available + */ +export const screenshotIcon = + 'data:image/png;base64,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'; diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts index 96280cf967..d450422aa3 100644 --- a/packages/core/src/js/feedback/integration.ts +++ b/packages/core/src/js/feedback/integration.ts @@ -1,27 +1,102 @@ import { type Integration, getClient } from '@sentry/core'; -import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; +import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types'; export const MOBILE_FEEDBACK_INTEGRATION_NAME = 'MobileFeedback'; type FeedbackIntegration = Integration & { options: Partial; + buttonOptions: Partial; + screenshotButtonOptions: Partial; + colorScheme?: 'system' | 'light' | 'dark'; + themeLight: Partial; + themeDark: Partial; }; -export const feedbackIntegration = (initOptions: FeedbackWidgetProps = {}): FeedbackIntegration => { +export const feedbackIntegration = ( + initOptions: FeedbackWidgetProps & { + buttonOptions?: FeedbackButtonProps; + screenshotButtonOptions?: ScreenshotButtonProps; + colorScheme?: 'system' | 'light' | 'dark'; + themeLight?: Partial; + themeDark?: Partial; + } = {}, +): FeedbackIntegration => { + const { + buttonOptions, + screenshotButtonOptions, + colorScheme, + themeLight: lightTheme, + themeDark: darkTheme, + ...widgetOptions + } = initOptions; + return { name: MOBILE_FEEDBACK_INTEGRATION_NAME, - options: initOptions, + options: widgetOptions, + buttonOptions: buttonOptions || {}, + screenshotButtonOptions: screenshotButtonOptions || {}, + colorScheme: colorScheme || 'system', + themeLight: lightTheme || {}, + themeDark: darkTheme || {}, }; }; +const _getClientIntegration = (): FeedbackIntegration => { + return getClient()?.getIntegrationByName>(MOBILE_FEEDBACK_INTEGRATION_NAME); +}; + export const getFeedbackOptions = (): Partial => { - const integration = getClient()?.getIntegrationByName>( - MOBILE_FEEDBACK_INTEGRATION_NAME, - ); + const integration = _getClientIntegration(); if (!integration) { return {}; } return integration.options; }; + +export const getFeedbackButtonOptions = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.buttonOptions; +}; + +export const getScreenshotButtonOptions = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.screenshotButtonOptions; +}; + +export const getColorScheme = (): 'system' | 'light' | 'dark' => { + const integration = _getClientIntegration(); + if (!integration) { + return 'system'; + } + + return integration.colorScheme; +}; + +export const getFeedbackLightTheme = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.themeLight; +}; + +export const getFeedbackDarkTheme = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.themeDark; +}; diff --git a/packages/core/src/js/feedback/lazy.ts b/packages/core/src/js/feedback/lazy.ts index ba02365c2f..c3d2b2727d 100644 --- a/packages/core/src/js/feedback/lazy.ts +++ b/packages/core/src/js/feedback/lazy.ts @@ -25,3 +25,29 @@ export function lazyLoadAutoInjectFeedbackIntegration(): void { getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_INTEGRATION_NAME }); } } + +export const AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileFeedbackButton'; + +/** + * Lazy loads the auto inject feedback button integration if it is not already loaded. + */ +export function lazyLoadAutoInjectFeedbackButtonIntegration(): void { + const integration = getClient()?.getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME); + if (!integration) { + // Lazy load the integration to track usage + getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME }); + } +} + +export const AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileScreenshotButton'; + +/** + * Lazy loads the auto inject screenshot button integration if it is not already loaded. + */ +export function lazyLoadAutoInjectScreenshotButtonIntegration(): void { + const integration = getClient()?.getIntegrationByName(AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME); + if (!integration) { + // Lazy load the integration to track usage + getClient()?.addIntegration({ name: AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME }); + } +} diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 3aa2bdb71d..aef7733170 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -87,7 +87,8 @@ export type { TimeToDisplayProps } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; +export { FeedbackButton } from './feedback/FeedbackButton'; export { FeedbackWidget } from './feedback/FeedbackWidget'; -export { showFeedbackWidget } from './feedback/FeedbackWidgetManager'; +export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton } from './feedback/FeedbackWidgetManager'; export { getDataFromUri } from './wrapper'; 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/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 598ea7c9db..eb8faf9581 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -125,6 +125,8 @@ interface SentryNativeWrapper { popTimeToDisplayFor(key: string): Promise; setActiveSpanId(spanId: string): void; + + encodeToBase64(data: Uint8Array): Promise; } const EOL = utf8ToBytes('\n'); @@ -746,6 +748,21 @@ export const NATIVE: SentryNativeWrapper = { } }, + async encodeToBase64(data: Uint8Array): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + try { + const byteArray = Array.from(data); + const base64 = await RNSentry.encodeToBase64(byteArray); + return base64 || null; + } catch (error) { + logger.error('Error:', error); + return Promise.resolve(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/FeedbackButton.test.tsx b/packages/core/test/feedback/FeedbackButton.test.tsx new file mode 100644 index 0000000000..579bccc7ca --- /dev/null +++ b/packages/core/test/feedback/FeedbackButton.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; + +import { FeedbackButton } from '../../src/js/feedback/FeedbackButton'; +import type { FeedbackButtonProps, FeedbackButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; +import { showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; + +jest.mock('../../src/js/feedback/FeedbackWidgetManager', () => ({ + ...jest.requireActual('../../src/js/feedback/FeedbackWidgetManager'), + showFeedbackWidget: jest.fn(), +})); + +const customTextProps: FeedbackButtonProps = { + triggerLabel: 'Give Feedback', +}; + +export const customStyles: FeedbackButtonStyles = { + triggerButton: { + backgroundColor: '#ffffff', + }, + triggerText: { + color: '#ff0000', + }, +}; + +describe('FeedbackButton', () => { + 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('shows the feedback widget when pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(customTextProps.triggerLabel)); + + await waitFor(() => { + expect(showFeedbackWidget).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx index ee317d2650..6dd4a46f47 100644 --- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -1,12 +1,13 @@ import { getClient, logger, setCurrentClient } from '@sentry/core'; import { render } from '@testing-library/react-native'; import * as React from 'react'; -import { Text } from 'react-native'; +import { Appearance, Text } from 'react-native'; import { defaultConfiguration } from '../../src/js/feedback/defaults'; -import { FeedbackWidgetProvider, resetFeedbackWidgetManager, 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_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; +import { AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME,AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; import { isModalSupported } from '../../src/js/feedback/utils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; @@ -114,7 +115,7 @@ describe('FeedbackWidgetManager', () => { showFeedbackWidget(); - expect(consoleWarnSpy).toHaveBeenLastCalledWith('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.'); + expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackWidget requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackWidget()'.`); }); it('showFeedbackWidget does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { @@ -130,7 +131,7 @@ describe('FeedbackWidgetManager', () => { expect(consoleWarnSpy).not.toHaveBeenCalled(); }); - + it('showFeedbackWidget adds the feedbackIntegration to the client', () => { mockedIsModalSupported.mockReturnValue(true); @@ -139,3 +140,363 @@ describe('FeedbackWidgetManager', () => { expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_INTEGRATION_NAME)).toBeDefined(); }); }); + +describe('FeedbackButtonManager', () => { + let listener: (preferences: Appearance.AppearancePreferences) => void; + + afterEach(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + consoleWarnSpy.mockReset(); + resetFeedbackButtonManager(); + + jest.spyOn(Appearance, 'addChangeListener').mockImplementation((cb) => { + listener = cb; + return { remove: jest.fn() }; + }); + }); + + it('showFeedbackButton displays the button when FeedbackWidgetProvider is used', () => { + const { getByText } = render( + + App Components + + ); + + showFeedbackButton(); + + expect(getByText('Report a Bug')).toBeTruthy(); + }); + + it('hideFeedbackButton hides the button', () => { + const { queryByText } = render( + + App Components + + ); + + showFeedbackButton(); + hideFeedbackButton(); + + expect(queryByText('Report a Bug')).toBeNull(); + }); + + it('showFeedbackButton does not throw an error when FeedbackWidgetProvider is not used', () => { + expect(() => { + showFeedbackButton(); + }).not.toThrow(); + }); + + it('showFeedbackButton warns about missing feedback provider', () => { + showFeedbackButton(); + + expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackButton requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackButton()'.`); + }); + + it('showFeedbackButton does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { + render( + + App Components + + ); + + showFeedbackButton(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('showFeedbackButton adds the feedbackIntegration to the client', () => { + showFeedbackButton(); + + expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME)).toBeDefined(); + }); + + it('the Feedback Widget matches the snapshot with default configuration and system light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with default configuration and system dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with default configuration and dynamically changed theme', () => { + const component = ( + + App Components + + ); + + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render(component); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackWidget(); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + listener({ colorScheme: 'dark' }); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with custom light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'light', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with custom dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'dark', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with system light custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with system dark custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with default configuration and system light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with default configuration and system dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with default configuration and dynamically changed theme', () => { + const component = ( + + App Components + + ); + + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render(component); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackButton(); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + listener({ colorScheme: 'dark' }); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with custom light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'light', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with custom dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'dark', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with system light custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with system dark custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/packages/core/test/feedback/ScreenshotButton.test.tsx b/packages/core/test/feedback/ScreenshotButton.test.tsx new file mode 100644 index 0000000000..2419860d1c --- /dev/null +++ b/packages/core/test/feedback/ScreenshotButton.test.tsx @@ -0,0 +1,221 @@ +import { getClient, setCurrentClient } from '@sentry/core'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; +import { Alert, Text } from 'react-native'; + +import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; +import type { ScreenshotButtonProps, ScreenshotButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; +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'; +import { NATIVE } from '../../src/js/wrapper'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +jest.mock('../../src/js/wrapper', () => ({ + NATIVE: { + captureScreenshot: jest.fn(), + encodeToBase64: jest.fn(), + }, +})); + +jest.spyOn(Alert, 'alert'); + +const mockScreenshot: Screenshot = { + filename: 'test-screenshot.png', + contentType: 'image/png', + data: new Uint8Array([1, 2, 3]), +}; + +const mockBase64Image = 'mockBase64ImageString'; + +const mockCaptureScreenshot = NATIVE.captureScreenshot as jest.Mock; +const mockEncodeToBase64 = NATIVE.encodeToBase64 as jest.Mock; + +describe('ScreenshotButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + FeedbackWidget.reset(); + getCapturedScreenshot(); // cleans up stored screenshot if any + resetFeedbackWidgetManager(); + resetFeedbackButtonManager(); + resetScreenshotButtonManager(); + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + }); + + it('matches the snapshot with default configuration', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts', () => { + const defaultProps: ScreenshotButtonProps = { + triggerLabel: 'Take Screenshot', + }; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles', () => { + const customStyles: ScreenshotButtonStyles = { + triggerButton: { + backgroundColor: '#ffffff', + }, + triggerText: { + color: '#ff0000', + }, + }; + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('the take screenshot button is visible in the feedback widget when enabled', async () => { + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + + const takeScreenshotButton = getByText('Take a screenshot'); + expect(takeScreenshotButton).toBeTruthy(); + }); + + + it('the capture screenshot button is shown when tapping the Take a screenshot button in the feedback widget', async () => { + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + + const captureButton = getByText('Take Screenshot'); + expect(captureButton).toBeTruthy(); + }); + + it('a screenshot is captured when tapping the Take Screenshot button', async () => { + mockCaptureScreenshot.mockResolvedValue([mockScreenshot]); + mockEncodeToBase64.mockResolvedValue(mockBase64Image); + + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + fireEvent.press(getByText('Take Screenshot')); + + await waitFor(() => { + expect(mockCaptureScreenshot).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockEncodeToBase64).toHaveBeenCalled(); + }); + }); + + it('the feedback widget ui is updated when a screenshot is captured', async () => { + mockCaptureScreenshot.mockResolvedValue([mockScreenshot]); + mockEncodeToBase64.mockResolvedValue(mockBase64Image); + + const { getByText, queryByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + fireEvent.press(getByText('Take Screenshot')); + + await waitFor(() => { + expect(mockCaptureScreenshot).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockEncodeToBase64).toHaveBeenCalled(); + }); + + await waitFor(() => { + const captureButton = queryByText('Take Screenshot'); + expect(captureButton).toBeNull(); + }); + + await waitFor(() => { + const takeScreenshotButtonAfterCapture = queryByText('Take a screenshot'); + expect(takeScreenshotButtonAfterCapture).toBeNull(); + }); + + await waitFor(() => { + const removeScreenshotButtonAfterCapture = queryByText('Remove screenshot'); + expect(removeScreenshotButtonAfterCapture).toBeTruthy(); + }); + }); + + it('when the capture fails an error message is shown', async () => { + mockCaptureScreenshot.mockResolvedValue([]); + + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + fireEvent.press(getByText('Take Screenshot')); + + await waitFor(() => { + expect(mockCaptureScreenshot).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Error capturing screenshot. Please try again.'); + }); + }); +}); diff --git a/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap new file mode 100644 index 0000000000..5b1ab60eb4 --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackButton matches the snapshot with custom styles 1`] = ` + + + + Report a Bug + + +`; + +exports[`FeedbackButton matches the snapshot with custom texts 1`] = ` + + + + Give Feedback + + +`; + +exports[`FeedbackButton matches the snapshot with default configuration 1`] = ` + + + + Report a Bug + + +`; diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap index 9f71d72ceb..737269302c 100644 --- a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap +++ b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap @@ -2,6 +2,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = ` + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with custom light theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and dynamically changed theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and system dark theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and system light theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with system dark custom theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with system light custom theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with custom dark theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with custom light theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and dynamically changed theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and system dark theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and system light theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with system dark custom theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with system light custom theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; diff --git a/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap new file mode 100644 index 0000000000..2cd6455502 --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ScreenshotButton matches the snapshot with custom styles 1`] = ` + + + + Take Screenshot + + +`; + +exports[`ScreenshotButton matches the snapshot with custom texts 1`] = ` + + + + Take Screenshot + + +`; + +exports[`ScreenshotButton matches the snapshot with default configuration 1`] = ` + + + + Take Screenshot + + +`; diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 5cc672a5b7..c26c384e26 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -62,6 +62,7 @@ const NATIVE: MockInterface = { getDataFromUri: jest.fn(), popTimeToDisplayFor: jest.fn(), setActiveSpanId: jest.fn(), + encodeToBase64: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index 8b651d2d54..b01f2e6c2b 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -69,6 +69,12 @@ export default function TabOneScreen() { Sentry.showFeedbackWidget(); }} /> +