diff --git a/packages/core/src/js/feedback/FeedbackForm.styles.ts b/packages/core/src/js/feedback/FeedbackForm.styles.ts index 0ab35584d..6ee380ed0 100644 --- a/packages/core/src/js/feedback/FeedbackForm.styles.ts +++ b/packages/core/src/js/feedback/FeedbackForm.styles.ts @@ -38,6 +38,17 @@ const defaultStyles: FeedbackFormStyles = { textAlignVertical: 'top', color: FORGROUND_COLOR, }, + screenshotButton: { + backgroundColor: '#eee', + padding: 15, + borderRadius: 5, + marginBottom: 20, + alignItems: 'center', + }, + screenshotText: { + color: '#333', + fontSize: 16, + }, submitButton: { backgroundColor: PURPLE, paddingVertical: 15, diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx index c978db83e..0e9807b21 100644 --- a/packages/core/src/js/feedback/FeedbackForm.tsx +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -15,6 +15,7 @@ import { View } from 'react-native'; +import { base64ToUint8Array } from '../utils/base64ToUint8Array'; import { defaultConfiguration } from './defaults'; import defaultStyles from './FeedbackForm.styles'; import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types'; @@ -64,6 +65,17 @@ export class FeedbackForm extends React.Component void = () => { + if (!this.state.filename && !this.state.attachment) { + const { onFileChosen } = { ...defaultConfiguration, ...this.props }; + onFileChosen((filename: string, attachement: string | Uint8Array) => { + this.setState({ filename, attachment: attachement }); + }); + } else { + this.setState({ filename: undefined, attachment: undefined }); + } + } + /** * Renders the feedback form screen. */ @@ -148,6 +171,16 @@ export class FeedbackForm extends React.Component + {config.enableScreenshot && ( + + + {!this.state.filename && !this.state.attachment + ? text.addScreenshotButtonLabel + : text.removeScreenshotButtonLabel} + + + )} + {text.submitButtonLabel} diff --git a/packages/core/src/js/feedback/FeedbackForm.types.ts b/packages/core/src/js/feedback/FeedbackForm.types.ts index 84078d8a6..e33290e59 100644 --- a/packages/core/src/js/feedback/FeedbackForm.types.ts +++ b/packages/core/src/js/feedback/FeedbackForm.types.ts @@ -33,6 +33,12 @@ export interface FeedbackGeneralConfiguration { */ showName?: boolean; + /** + * Should the screen shots field be included? + * Screen shots cannot be marked as required + */ + enableScreenshot?: boolean; + /** * Fill in email/name input fields with Sentry user context if it exists. * The value of the email/name keys represent the properties of your user context. @@ -102,6 +108,16 @@ export interface FeedbackTextConfiguration { */ isRequiredLabel?: string; + /** + * The label for the button that adds a screenshot and renders the image editor + */ + addScreenshotButtonLabel?: string; + + /** + * The label for the button that removes a screenshot and hides the image editor + */ + removeScreenshotButtonLabel?: string; + /** * The title of the error dialog */ @@ -126,6 +142,11 @@ export interface FeedbackCallbacks { * Callback when form is closed and not submitted */ onFormClose?: () => void; + + /** + * Callback when a file is chosen for attachment + */ + onFileChosen?: (attachFile: (filename: string, base64Attachment: string | Uint8Array) => void) => void; } export interface FeedbackFormStyles { @@ -138,6 +159,8 @@ export interface FeedbackFormStyles { submitText?: TextStyle; cancelButton?: ViewStyle; cancelText?: TextStyle; + screenshotButton?: ViewStyle; + screenshotText?: TextStyle; } export interface FeedbackFormState { @@ -145,4 +168,6 @@ export interface FeedbackFormState { name: string; email: string; description: string; + filename?: string; + attachment?: string | Uint8Array; } diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index ae8a3e957..2934a1257 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -16,6 +16,8 @@ const ERROR_TITLE = 'Error'; const FORM_ERROR = 'Please fill out all required fields.'; const EMAIL_ERROR = 'Please enter a valid email address.'; const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; +const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; export const defaultConfiguration: Partial = { // FeedbackCallbacks @@ -27,6 +29,11 @@ export const defaultConfiguration: Partial = { ); } }, + onFileChosen: (_: (filename: string, base64Attachment: string | Uint8Array) => void) => { + if (__DEV__) { + Alert.alert('Development note', 'onFileChosen callback is not implemented.'); + } + }, // FeedbackGeneralConfiguration isEmailRequired: false, @@ -34,6 +41,7 @@ export const defaultConfiguration: Partial = { isNameRequired: false, showEmail: true, showName: true, + enableScreenshot: false, // FeedbackTextConfiguration cancelButtonLabel: CANCEL_BUTTON_LABEL, @@ -50,4 +58,6 @@ export const defaultConfiguration: Partial = { formError: FORM_ERROR, emailError: EMAIL_ERROR, successMessageText: SUCCESS_MESSAGE_TEXT, + addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL, + removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, }; diff --git a/packages/core/src/js/utils/base64ToUint8Array.ts b/packages/core/src/js/utils/base64ToUint8Array.ts new file mode 100644 index 000000000..a6dd45d1f --- /dev/null +++ b/packages/core/src/js/utils/base64ToUint8Array.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-bitwise */ +export const base64ToUint8Array = (base64?: string): Uint8Array | undefined => { + if (!base64) return undefined; + + const cleanedBase64 = base64.replace(/^data:.*;base64,/, ''); // Remove any prefix before the base64 string + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const bytes: number[] = []; + + let buffer = 0; + let bits = 0; + + for (const char of cleanedBase64) { + if (char === '=') break; + + const value = chars.indexOf(char); // Validate each character + if (value === -1) return undefined; + + buffer = (buffer << 6) | value; // Shift 6 bits to the left and add the value + bits += 6; + + if (bits >= 8) { + // Add a byte when we have 8 or more bits + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + + return new Uint8Array(bytes); +}; diff --git a/packages/core/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx index 272072f41..1cfe546ba 100644 --- a/packages/core/test/feedback/FeedbackForm.test.tsx +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -7,6 +7,7 @@ import { FeedbackForm } from '../../src/js/feedback/FeedbackForm'; import type { FeedbackFormProps } from '../../src/js/feedback/FeedbackForm.types'; const mockOnFormClose = jest.fn(); +const mockOnFileChosen = jest.fn(); jest.spyOn(Alert, 'alert'); @@ -23,6 +24,8 @@ jest.mock('@sentry/core', () => ({ const defaultProps: FeedbackFormProps = { onFormClose: mockOnFormClose, + onFileChosen: mockOnFileChosen, + addScreenshotButtonLabel: 'Add Screenshot', formTitle: 'Feedback Form', nameLabel: 'Name', namePlaceholder: 'Name Placeholder', @@ -45,7 +48,7 @@ describe('FeedbackForm', () => { }); it('renders correctly', () => { - const { getByPlaceholderText, getByText } = render(); + const { getByPlaceholderText, getByText, queryByText } = render(); expect(getByText(defaultProps.formTitle)).toBeTruthy(); expect(getByText(defaultProps.nameLabel)).toBeTruthy(); @@ -54,10 +57,17 @@ describe('FeedbackForm', () => { expect(getByPlaceholderText(defaultProps.emailPlaceholder)).toBeTruthy(); expect(getByText(`${defaultProps.messageLabel } ${ defaultProps.isRequiredLabel}`)).toBeTruthy(); expect(getByPlaceholderText(defaultProps.messagePlaceholder)).toBeTruthy(); + expect(queryByText(defaultProps.addScreenshotButtonLabel)).toBeNull(); // default false expect(getByText(defaultProps.submitButtonLabel)).toBeTruthy(); expect(getByText(defaultProps.cancelButtonLabel)).toBeTruthy(); }); + it('renders attachment button when the enableScreenshot is true', () => { + const { getByText } = render(); + + expect(getByText(defaultProps.addScreenshotButtonLabel)).toBeTruthy(); + }); + it('name and email are prefilled when sentry user is set', () => { const { getByPlaceholderText } = render(); @@ -107,7 +117,7 @@ describe('FeedbackForm', () => { message: 'This is a feedback message.', name: 'John Doe', email: 'john.doe@example.com', - }); + }, undefined); }); }); @@ -139,6 +149,16 @@ describe('FeedbackForm', () => { }); }); + it('calls onFileChosen when the screenshot button is pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); + + await waitFor(() => { + expect(mockOnFileChosen).toHaveBeenCalled(); + }); + }); + it('calls onFormClose when the cancel button is pressed', () => { const { getByText } = render(); diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 4c0069d09..ddba32fbb 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -30,6 +30,7 @@ "react": "18.3.1", "react-native": "0.76.3", "react-native-gesture-handler": "^2.21.1", + "react-native-image-picker": "^7.2.2", "react-native-reanimated": "3.16.1", "react-native-safe-area-context": "4.14.0", "react-native-screens": "4.1.0", diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 0e3f0285a..962a2c945 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -38,6 +38,8 @@ import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; +import { launchImageLibrary } from 'react-native-image-picker'; + if (typeof setImmediate === 'undefined') { require('setimmediate'); } @@ -141,6 +143,22 @@ const Stack = isMobileOs : createStackNavigator(); const Tab = createBottomTabNavigator(); +const handleChooseFile = (attachFile: (filename: string, base64Attachment: string | Uint8Array) => void): void => { + launchImageLibrary({ mediaType: 'photo', includeBase64: true }, (response) => { + if (response.didCancel) { + console.log('User cancelled image picker'); + } else if (response.errorCode) { + console.log('ImagePicker Error: ', response.errorMessage); + } else if (response.assets && response.assets.length > 0) { + const filename = response.assets[0].fileName; + const base64String = response.assets[0].base64; + if (filename && base64String) { + attachFile(filename, base64String); + } + } + }); +}; + const ErrorsTabNavigator = Sentry.withProfiler( () => { return ( @@ -159,6 +177,8 @@ const ErrorsTabNavigator = Sentry.withProfiler( {(props) => (