diff --git a/CHANGELOG.md b/CHANGELOG.md index 620d4fb35c..0f44788ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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 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 e6c2ecce8c..780084932a 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 @@ -157,6 +157,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(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 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/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts index 5feeff4957..94df799d21 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.styles.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts @@ -63,6 +63,20 @@ const defaultStyles = (theme: FeedbackWidgetTheme): FeedbackWidgetStyles => { 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, @@ -132,6 +146,8 @@ export const defaultButtonStyles = (theme: FeedbackWidgetTheme): FeedbackButtonS }; }; +export const defaultScreenshotButtonStyles = defaultButtonStyles; + export const modalWrapper: ViewStyle = { position: 'absolute', top: 0, diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx index a3d7e8489e..c1a687a688 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.tsx +++ b/packages/core/src/js/feedback/FeedbackWidget.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { SendFeedbackParams } from '@sentry/core'; import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/core'; import * as React from 'react'; @@ -15,13 +16,15 @@ 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 { lazyLoadFeedbackIntegration } from './lazy'; +import { getCapturedScreenshot } from './ScreenshotButton'; import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils'; /** @@ -69,6 +72,20 @@ export class FeedbackWidget extends React.Component void = () => { const { name, email, description } = this.state; const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props; @@ -123,7 +140,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 @@ -238,6 +255,11 @@ export class FeedbackWidget extends React.Component @@ -294,7 +316,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 && ( + { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { hideFeedbackButton, showScreenshotButton } = require('./FeedbackWidgetManager'); + hideFeedbackButton(); + onCancel(); + showScreenshotButton(); + }}> + {text.captureScreenshotButtonLabel} + + )} {text.submitButtonLabel} @@ -323,6 +356,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 }; }; @@ -337,4 +388,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 68b09f7c4f..aaf03e8642 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 */ @@ -169,6 +180,21 @@ export interface FeedbackButtonTextConfiguration { 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 */ @@ -258,6 +284,8 @@ export interface FeedbackWidgetStyles { screenshotContainer?: ViewStyle; screenshotThumbnail?: ImageStyle; screenshotText?: TextStyle; + takeScreenshotButton?: ViewStyle; + takeScreenshotText?: TextStyle; titleContainer?: ViewStyle; sentryLogo?: ImageStyle; } @@ -278,6 +306,22 @@ export interface FeedbackButtonStyles { 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 18224cab16..39c6d81839 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -3,14 +3,15 @@ import * as React from 'react'; import type { NativeEventSubscription, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import { Animated, Appearance, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; -import { notWeb } from '../utils/environment'; +import { isWeb,notWeb } from '../utils/environment'; import { FeedbackButton } from './FeedbackButton'; import { FeedbackWidget } from './FeedbackWidget'; import { modalSheetContainer, modalWrapper, topSpacer } from './FeedbackWidget.styles'; import { getTheme } from './FeedbackWidget.theme'; import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; -import { getFeedbackButtonOptions, getFeedbackOptions } from './integration'; -import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration } from './lazy'; +import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration'; +import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy'; +import { ScreenshotButton } from './ScreenshotButton'; import { isModalSupported } from './utils'; const PULL_DOWN_CLOSE_THRESHOLD = 200; @@ -76,6 +77,12 @@ class FeedbackButtonManager extends FeedbackManager { } } +class ScreenshotButtonManager extends FeedbackManager { + protected static get _feedbackComponentName(): string { + return 'ScreenshotButton'; + } +} + interface FeedbackWidgetProviderProps { children: React.ReactNode; styles?: FeedbackWidgetStyles; @@ -83,6 +90,7 @@ interface FeedbackWidgetProviderProps { interface FeedbackWidgetProviderState { isButtonVisible: boolean; + isScreenshotButtonVisible: boolean; isVisible: boolean; backgroundOpacity: Animated.Value; panY: Animated.Value; @@ -92,6 +100,7 @@ interface FeedbackWidgetProviderState { 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), @@ -135,6 +144,7 @@ class FeedbackWidgetProvider extends React.Component {this.props.children} - {isButtonVisible && } + {isButtonVisible && } + {isScreenshotButtonVisible && } {isVisible && @@ -267,6 +278,10 @@ class FeedbackWidgetProvider extends React.Component { + this.setState({ isScreenshotButtonVisible: visible }); + }; + private _handleClose = (): void => { FeedbackWidgetManager.hide(); }; @@ -294,4 +309,21 @@ const resetFeedbackButtonManager = (): void => { FeedbackButtonManager.reset(); }; -export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, FeedbackWidgetProvider, resetFeedbackButtonManager, resetFeedbackWidgetManager }; +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, FeedbackWidgetProvider, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; diff --git a/packages/core/src/js/feedback/ScreenshotButton.tsx b/packages/core/src/js/feedback/ScreenshotButton.tsx new file mode 100644 index 0000000000..105b2ab104 --- /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, showScreenshotButton } from './FeedbackWidgetManager'; +import { screenshotIcon } from './icons'; +import { lazyLoadFeedbackIntegration } from './lazy'; + +let capturedScreenshot: Screenshot | 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]; + showFeedbackWidget(); + } else { + showScreenshotButton(); + } + }, 100); +}; + +export const getCapturedScreenshot = (): Screenshot | 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 0e60a49fb2..948d59c19b 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -1,4 +1,4 @@ -import type { FeedbackButtonProps, FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types'; import { feedbackAlertDialog } from './utils'; const FORM_TITLE = 'Report a Bug'; @@ -12,11 +12,13 @@ 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 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.'; @@ -61,6 +63,7 @@ export const defaultConfiguration: Partial = { showEmail: true, showName: true, enableScreenshot: false, + enableTakeScreenshot: false, // FeedbackTextConfiguration cancelButtonLabel: CANCEL_BUTTON_LABEL, @@ -79,6 +82,7 @@ export const defaultConfiguration: Partial = { successMessageText: SUCCESS_MESSAGE_TEXT, addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL, removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, + captureScreenshotButtonLabel: CAPTURE_SCREENSHOT_LABEL, genericError: GENERIC_ERROR_TEXT, }; @@ -86,3 +90,8 @@ 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 index 3404bf6347..b73ecdde86 100644 --- a/packages/core/src/js/feedback/icons.ts +++ b/packages/core/src/js/feedback/icons.ts @@ -1,2 +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 f6c2ebcba9..d450422aa3 100644 --- a/packages/core/src/js/feedback/integration.ts +++ b/packages/core/src/js/feedback/integration.ts @@ -1,13 +1,14 @@ import { type Integration, getClient } from '@sentry/core'; import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; -import type { FeedbackButtonProps, FeedbackWidgetProps } from './FeedbackWidget.types'; +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; @@ -16,17 +17,26 @@ type FeedbackIntegration = Integration & { export const feedbackIntegration = ( initOptions: FeedbackWidgetProps & { buttonOptions?: FeedbackButtonProps; + screenshotButtonOptions?: ScreenshotButtonProps; colorScheme?: 'system' | 'light' | 'dark'; themeLight?: Partial; themeDark?: Partial; } = {}, ): FeedbackIntegration => { - const { buttonOptions, colorScheme, themeLight: lightTheme, themeDark: darkTheme, ...widgetOptions } = initOptions; + const { + buttonOptions, + screenshotButtonOptions, + colorScheme, + themeLight: lightTheme, + themeDark: darkTheme, + ...widgetOptions + } = initOptions; return { name: MOBILE_FEEDBACK_INTEGRATION_NAME, options: widgetOptions, buttonOptions: buttonOptions || {}, + screenshotButtonOptions: screenshotButtonOptions || {}, colorScheme: colorScheme || 'system', themeLight: lightTheme || {}, themeDark: darkTheme || {}, @@ -55,6 +65,15 @@ export const getFeedbackButtonOptions = (): Partial => { 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) { diff --git a/packages/core/src/js/feedback/lazy.ts b/packages/core/src/js/feedback/lazy.ts index bf60820779..c3d2b2727d 100644 --- a/packages/core/src/js/feedback/lazy.ts +++ b/packages/core/src/js/feedback/lazy.ts @@ -38,3 +38,16 @@ export function lazyLoadAutoInjectFeedbackButtonIntegration(): void { 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/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/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx index 8449f0e366..e62648a2d9 100644 --- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -1,7 +1,7 @@ import { getClient, logger, setCurrentClient } from '@sentry/core'; import { render } from '@testing-library/react-native'; import * as React from 'react'; -import { Appearance,Text } from 'react-native'; +import { Appearance, Text } from 'react-native'; import { defaultConfiguration } from '../../src/js/feedback/defaults'; import { FeedbackWidgetProvider, hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; diff --git a/packages/core/test/feedback/ScreenshotButton.test.tsx b/packages/core/test/feedback/ScreenshotButton.test.tsx new file mode 100644 index 0000000000..9bcb29389e --- /dev/null +++ b/packages/core/test/feedback/ScreenshotButton.test.tsx @@ -0,0 +1,219 @@ +import { getClient, setCurrentClient } from '@sentry/core'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text } from 'react-native'; + +import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; +import type { ScreenshotButtonProps, ScreenshotButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; +import { FeedbackWidgetProvider, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager, showFeedbackButton } from '../../src/js/feedback/FeedbackWidgetManager'; +import { 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(), + }, +})); + +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 the capture button is still visible', async () => { + mockCaptureScreenshot.mockResolvedValue([]); + + 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(() => { + const captureButton = queryByText('Take Screenshot'); + expect(captureButton).not.toBeNull(); + }); + }); +}); 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/_layout.tsx b/samples/expo/app/_layout.tsx index e6401ae96a..1daaad0f3e 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -59,6 +59,8 @@ Sentry.init({ navigationIntegration, Sentry.reactNativeTracingIntegration(), Sentry.feedbackIntegration({ + enableScreenshot: true, + enableTakeScreenshot: true, imagePicker: ImagePicker, buttonOptions: { styles: { diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 21a913fddc..cddbe14e3a 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -115,6 +115,8 @@ Sentry.init({ }), Sentry.feedbackIntegration({ imagePicker: ImagePicker, + enableScreenshot: true, + enableTakeScreenshot: true, styles:{ submitButton: { backgroundColor: '#6a1b9a', @@ -132,6 +134,13 @@ Sentry.init({ }, }, }, + screenshotButtonOptions: { + styles: { + triggerButton: { + marginBottom: 75, // Place above the tab bar + }, + }, + }, }), ); return integrations.filter(i => i.name !== 'Dedupe');