diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0708a142d5..bb87712849 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,10 +8,17 @@
## Unreleased
+### Features
+
+- Adds the `FeedbackButton` component that shows the Feedback Widget ([#4378](https://github.com/getsentry/sentry-react-native/pull/4378))
+- Adds the `ScreenshotButton` component that takes a screenshot ([#4714](https://github.com/getsentry/sentry-react-native/issues/4714))
+- Add Feedback Widget theming ([#4677](https://github.com/getsentry/sentry-react-native/pull/4677))
+
### Fixes
- crashedLastRun now returns the correct value ([#4829](https://github.com/getsentry/sentry-react-native/pull/4829))
- Use engine-specific promise rejection tracking ([#4826](https://github.com/getsentry/sentry-react-native/pull/4826))
+- Fixes Feedback Widget accessibility issue on iOS ([#4739](https://github.com/getsentry/sentry-react-native/pull/4739))
## 6.14.0
diff --git a/dev-packages/e2e-tests/maestro/feedback.yml b/dev-packages/e2e-tests/maestro/feedback.yml
new file mode 100644
index 0000000000..ce7d79b89d
--- /dev/null
+++ b/dev-packages/e2e-tests/maestro/feedback.yml
@@ -0,0 +1,19 @@
+appId: ${APP_ID}
+jsEngine: graaljs
+---
+- runFlow: utils/launchTestAppClear.yml
+
+
+# The following tests are happy path tests for the feedback widget on both iOS and Android.
+# They verify that the feedback form can be opened, filled out, and submitted successfully.
+# The tests are separate because iOS tests work better with `testID` and Android tests work better with `text`.
+
+- runFlow:
+ file: feedback/happyFlow-ios.yml
+ when:
+ platform: iOS
+
+- runFlow:
+ file: feedback/happyFlow-android.yml
+ when:
+ platform: Android
diff --git a/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml b/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml
new file mode 100644
index 0000000000..221b0cbf84
--- /dev/null
+++ b/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml
@@ -0,0 +1,39 @@
+# This is a happy path test for the feedback widget on Android.
+# It verifies that the feedback form can be opened, filled out, and submitted successfully
+appId: ${APP_ID}
+jsEngine: graaljs
+---
+
+# Show feedback button
+- tapOn: 'Feedback'
+
+# Open feedback widget
+- tapOn: 'Report a Bug'
+
+# Assert that the feedback form is visible
+- extendedWaitUntil:
+ visible: 'Report a Bug'
+ timeout: 5_000
+
+# Fill out name field
+- tapOn: 'Your Name'
+- inputText: 'John Doe'
+
+# Fill out email field
+- tapOn: 'your.email@example.org'
+- inputText: 'test@email.com'
+
+# Fill out message field
+- tapOn: "What's the bug? What did you expect?"
+- inputText: 'This is a test feedback message from CI e2e tests'
+
+# Submit feedback
+- scrollUntilVisible:
+ element:
+ text: 'Send Bug Report'
+- tapOn: 'Send Bug Report'
+- assertVisible: 'Thank you for your report!'
+- tapOn: 'OK'
+
+# Verify feedback form is closed and the home screen is visible
+- assertVisible: 'Welcome to React Native'
diff --git a/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml b/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml
new file mode 100644
index 0000000000..7f8c3340b1
--- /dev/null
+++ b/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml
@@ -0,0 +1,45 @@
+# This is a happy path test for the feedback widget on iOS.
+# It verifies that the feedback form can be opened, filled out, and submitted successfully
+appId: ${APP_ID}
+jsEngine: graaljs
+---
+
+# Show feedback button
+- tapOn: 'Feedback'
+
+# Open feedback widget
+- tapOn:
+ id: 'sentry-feedback-button'
+
+# Assert that the feedback form is visible
+- extendedWaitUntil:
+ visible:
+ id: 'sentry-feedback-form-title'
+ timeout: 5_000
+
+# Fill out name field
+- tapOn:
+ id: 'sentry-feedback-name-input'
+- inputText: 'John Doe'
+
+# Fill out email field
+- tapOn:
+ id: 'sentry-feedback-email-input'
+- inputText: 'test@email.com'
+
+# Fill out message field
+- tapOn:
+ id: 'sentry-feedback-message-input'
+- inputText: 'This is a test feedback message from CI e2e tests'
+
+# Submit feedback
+- scrollUntilVisible:
+ element:
+ id: 'sentry-feedback-submit-button'
+- tapOn:
+ id: 'sentry-feedback-submit-button'
+- assertVisible: 'Thank you for your report!'
+- tapOn: 'OK'
+
+# Verify feedback form is closed and the home screen is visible
+- assertVisible: 'Welcome to React Native'
diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
index cc0f8ce66e..48660f1a56 100755
--- a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
+++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
@@ -39,6 +39,7 @@ const e2eComponentPatch = '';
const lastImportRex = /^([^]*)(import\s+[^;]*?;$)/m;
const patchRex = '@sentry/react-native';
const headerComponentRex = / m + initPatch)
- .replace(headerComponentRex, m => e2eComponentPatch + m);
+ .replace(headerComponentRex, m => e2eComponentPatch + m)
+ .replace(exportDefaultRex, 'export default Sentry.wrap(App);');
fs.writeFileSync(appPath, patched);
logger.info('Patched RN App.(js|tsx) successfully!');
diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx
index 173286e57b..d84108116c 100644
--- a/dev-packages/e2e-tests/src/EndToEndTests.tsx
+++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx
@@ -62,6 +62,11 @@ const EndToEndTestsScreen = (): JSX.Element => {
name: 'Unhandled Promise Rejection',
action: async () => await Promise.reject(new Error('Unhandled Promise Rejection')),
},
+ {
+ id: 'feedback',
+ name: 'Feedback',
+ action: () => Sentry.showFeedbackButton(),
+ },
{
id: 'close',
name: 'Close',
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 8f6c814db6..b69f2078a9 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;
@@ -1038,6 +1039,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..fbb546db8d
--- /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..277d172290 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,11 +256,24 @@ export class FeedbackWidget extends React.Component {
+ feedbackAlertDialog(text.errorTitle, text.captureScreenshotError);
+ }, 100);
+ } else if (screenshot) {
+ this._setCapturedScreenshot(screenshot);
+ }
+
return (
-
+
- {text.formTitle}
+ {text.formTitle}
{config.showBranding && (
this.setState({ name: value })}
@@ -257,6 +307,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}
+ {text.submitButtonLabel}
@@ -305,6 +366,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 +398,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 20f20faea2..e554715586 100644
--- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
+++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx
@@ -1,25 +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, isNativeDriverSupportedForColorAnimations } from './utils';
+import { isWeb } from '../utils/environment';
+import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
-const PULL_DOWN_CLOSE_THRESHOLD = 200;
-const SLIDE_ANIMATION_DURATION = 200;
-const BACKGROUND_ANIMATION_DURATION = 200;
+export const PULL_DOWN_CLOSE_THRESHOLD = 200;
+export const SLIDE_ANIMATION_DURATION = 200;
+export const BACKGROUND_ANIMATION_DURATION = 200;
-const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
+abstract class FeedbackManager {
+ protected static _isVisible = false;
+ protected static _setVisibility: (visible: boolean) => void;
-class FeedbackWidgetManager {
- private static _isVisible = false;
- private 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;
@@ -40,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}()'.`);
}
}
@@ -51,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.`);
}
}
@@ -60,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: useNativeDriverForColorAnimations,
- 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: useNativeDriverForColorAnimations,
- 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 => {
@@ -235,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..9e90ed785f
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
@@ -0,0 +1,223 @@
+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, isNativeDriverSupportedForColorAnimations } from './utils';
+
+const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
+
+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: useNativeDriverForColorAnimations,
+ 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: useNativeDriverForColorAnimations,
+ 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,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAA8KADAAQAAAABAAAA8AAAAAAm0kfIAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAAh3klEQVR4Ae2dB7RlVXnHjcBQpFepMzh0HMqAHWNBNGBBjSV2Y6LG2KIus6LR2BJTLdhQ0ayFsFDJYmGwLYwFBKX3GdrAFAZmpFdRQWJ+v5m3J5fHO+/de+7d++w7831r/d49795z9v72/+xvt3PuPY96VFgoEAqEAqFAKBAKhAKhQCgQCgygwB8NsG+JXWeRySawPujbxpBsPTbS/w+wfT/8DtzvbngQajf932gCy7kh/AFKmDr9FtQtaXUn2/8LNVuqB+qmfmI9UbffgP4/BJZNc//fg+W0nqzVZqDUZPvhzJNge7ByHwL6aIXbuuf/a9k+B64GA+EEWAQ1mxXvCXAAzJ/YtrxWvl7rDWgro//7mqz3/7Sdjmnaz2MfDQvgXFgK/v9VuA1qti1wzjqhbgfBE2EPMDgvBAPVMlwGNvIbwE1gOa+Atdp6T3gXBfXkvAiOgseBwbgZeBKsYJtCqsS+Z1BrVvp7wJNny/wN+BwshWTpuPR/V6+W50/gpXAgWKbHQConm8XMhlDdHLkY9MdOcAevNVjvOdsOh6wbL4THg+de7dTNQNXsda0L9rj3gceL5bsX7KFPBgN6JZwJvdabX+/7Y7NtAUqaQXgw2BM9FnaA+WDLmk4KmwPbco44D86B88HW15PaxQnqzfNQfDBwnwdWQod+NZmjFnX7JajZJaD1lmH1O/n/pjzV6DBQt2fCvmBj3tYs453gNMty/gJs+G+G6yBsBgU8MVuCJ+JV8C2wdbQHGAXOgXrTOZX/nw42Fl2ZedswOTJwqNfrX43b+ngczIMup1UG7+Hwfcil049J+zvwEdgawmZQYEc+fx38FBzS5DoxvekaxLbiXdmBZHwiTG5cen2sbdvh9dfBkUJX9iwyzhm8vZovJa+/hW3BHn6YXp7D105zaGzL7hDGYUuvgLm3P0V+pVtYRxvayyF3+XKk/3v8fi84z+zCPkCmOco1VZo2WLeC06/Pw+SGK51LPqrXhpl3Ti5Vb4F348Oj4a3wfHAIXXpI64LYbXA5lDRHHG8A5/njZvZCG4OVelEB53vrjCvLb4J9CuRrFpbVOrI5mPcuE9vb82r5XRzr9Y9/135zdfXZ8Bm4ClLL18VQ0t7EFUh7kxInwsZwNnwcrodU9nF7tWf6NFjBS+jmvHc/+ALcBKX1SnXTgLXR+hEcBRvBOmX2sC+FM6D0SWjKz0bEBsVeJbeZh8PPZaA/qWI0+Vbz+z/D//kwyhEayU1pXon4JNwFtehmEB8J1duwK4620Iqu/QO8FuZALWZQHQAXgwtoOU0dnOeLVqL3Wp3T6P9uRZL7Q4nphw3dr8HzswXUoNsR+KHp2+mrtir94zCpraXgdajxEXgVzIGazOGzC2kbFnDK3sr5m/P9cbftKMA8GKZ+9KuB9cchdIlRUr8+ud9h8FFwYS2NRGpoXHBndObw532wEuyBasN58JUwG3KbvccvwTxr02FQf5wP/gRKzAP3JZ/FUJNu9rxJsxvYfgc4KtHWqiB+OwXyDpdU2Jpe00mwYuwJuW1rMrgFatJgGF+WUpYSvaIjpGH8LHHs7fhoXS8xkiOb/m2YIdKLyMbrnZv3n13RPVNLme6EKpG5K7hri5UqiwFoY1uz2Th/EP6mNicHCeAUEJbB4H0PHOY/Fdvv8M2FGF9zmz39Akhfa8udX870LcNCMLhym4tXniMb2pptJ5x7ATwPemOhU58HCWBPpvOAPwWD92ng5L7ESSabVubw/jwoEVTOG13tdrg17uaaxoVgmXKbq/YXgSvRtZtfTnk37AWDxE62cg3qxK548jp4Jnjd1+CtpjXCl8nmMPBGsHfMbeZxKdyRO6MC6dsrroASjXMK4HT5rUDxWmWhFi7qPQeOBlfqO7dBA9ivAO4NKSBqDl7FtSLqa4mKaG91GbiQNe6mbiV6X3UycC8BR0s1W29dfz2OGgdjZQ4fjgeDYRy4Aj/fBd7e2Ss+/2a1fyF1K+M4aDSVj+fg+2shXftkM7vNIocT4D6Yyqca3zsOXw+ATm2Qk/TnePo2GPburVIF/m8y+mcoPbdywWw3KHHpimxGbseSopUzjbJGnsEUCdrbm583dOw8xec1vuX5tcE5F5yqdWL9DqG3wbungnMAW8PaTWG9qaJ08KqLJ9TG41b/GTO7GX/VzRXhkqMWZfoxfB/u9Z8xsM3w8Ug4oktfZ+qBPYlO1u15nbxvBaVPLFkOZAbv1+C70EUQeU3TrzHa0NmjbALjYCtx8otwBjgFKG32wK7gO5x+PLhIWrt5951+/gD0vzpzOPMx8OTWOA+Z7JPBewzMha5tRxz4BFgpJ/tZ2/+e3w/DttC17YMDXwaHpbXpNJU/V+PnU8DRaXGbqQfeHI8OgcOgEwf7VMR554/h0/AtWAaK3aU5fF8Ep8J14OjFmwFqsntw5jT4DOhnDSvo9v4L4b/gJtgBamhYcGNKswd21OB1c/Usak0LUg6TDYBN4dmwIdRkLnrcCPYcVjpvBDgDFDFdT0xl4K1OTP2WT3AVr3fA28Gh1tawK3jiS5oNnUGxYgL1OgMuA+e9XZvnzJ73+gmu5dWG8A0Tr9vz6gKh+9VizoVfCI78iltTAKfe62A8svftyrweaYB6J5VzS0+mPi8Bb5q4EgwOW+zJlsow+f3S/1vZ9PvbYJBYQeeA2u4JNkYlzAVLA8JgXQBqtxhqst5zpm63wYkTr9aFvUDdZoMNoesLBpC9oOUzwEub+Xp/RCejhOlasqfi1HvgZdCF3U+mF8NXwIrm/wZtWCiQFJjHxpNhZ3CU6GKrC0td2LvI9CS4vYvMp8rzUN48AWwVS3If+Z0CrwB7qM3B+fcsCAsFehWwTmwF9rw7gPXlfXAFlKyz5nUWPBeqsTfiyQrILYRD45THJWx/AA6CjSEsFBhUgS05wLWGKyHVq946lt7L8fpXgzo77P5Nc2DTnQ87DptBH8c7jHceqOBfhJPgfggLBQZVwLp0F1iHDNB3wh4wXT3n45GZQ/kqbB+8OBlytFJTpXk5eb0JXIwICwWGUSCt6ziCc068CKaqczneO568bDCKmStoU9n+vFmqNXGh6vPgfPtBCAsFhlHAwNRctf5P+BncCyVsNpnsWyKjlEdTALuiV6o3PJO8vgERvOmsxOuoFPC6tzeEXDSqBGdIx3hab4Z9RvpxUwB7ja3E9cmbycfVO4UOCwVyKGAPbB0rYS6guSJezJoC2Avkue++cqhzGlxXrLSR0bqogJ3RuXBBgcLvRB5zC+SzJoumAH48e+yyZq98Gy40lBqq5ytFpFy7At4y6lA6t3m5qsTIdU05mgLY3lFym9/k8FbJsFAgpwLWZYMrt9nbF13LaQrg3XBkm8ylVVQXF27KnE8kHwrcjgTLC8jg3WDGTjFrCuDH4cF6kLMX9nrdDZnzIPmwUGBV77sksw7GivE0J3M+D0u+KYDTqrBBlssc0pS6QyZXGSLd8VEgd11LseI3zopZUwBfhQd+BS6X2UBcCl5sDwsFSihgfV4AOQPML+JcU6IwKY+mAF7IDjemnTK8WtDzIGcjkcHtSHKMFfBuLO/6y9lpLCb9K0pq1BTAtlQ5F5ccPv8Kiq7YlRQ28qpOATsNF01z98D+CEExawpgvxm0LKMXd5O23zjKuUiW0f1IegwVMIDtgf22Ui6zl0/rR7ny6Dtdv9vorY4G2Sixd38XeLdXWChQUgHr3DfBQBtlnTat78JzoBrzG0lfg1EX9ATS9A6ssFCgCwVeSKb2xNZrp3LD1O/e419JWmklms3uzeH1UeDdUqmQvQ6n9wZ5vYe03gphoUAXChhg9sKfAKdxg9Tdyfv2xoILV7uDVjSIvVmjyXTYCbkrxXNhWxjGOYctX4fvwK0QFgp0oYALp9Y/v6zjKLPtvfgpFgzeY6Day6IW9G3Q2xNPbo1m+t8FBAtpQxAWCtSggL86cyy4Kj1T/W363OD9C8h9kwhZDGf2vnvB6+F0mKpAvUOK9Lkrct+HN8AcmK7H5+OwUKCYArPIaTYcDB+GqTqoqeq0dXsFfAmOgM2hM+u35XAoLddNvBqIj4HNYM7EtkMKC7cUvIa8ErzudjY4vEg3baT9eCssFOhEAeugPe+yCZZP/P9qXh0t7gRzwP00p3/ucyMsAW9COgeuBYO8M0sO9uOA+xqg2tHg3MFfHzgA9gZbtGXgsMLWTK6CsFCgZgVSvfb778+E34KjzQPB9xxFWo9lIXiPhJdXw0KBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgZEqMMiPx48040gsFFjbFWgbXD5WZcsJtuJ1C/BxpD6W4g7wMaI+fsVnyNwPYaFA7QoYC6kuW5+3Buv5Q3A3WK99SsPv4QaowgYJYAPUoLRwT4CDYP4Ej+NVuxPOBR8/YeFPgF9CWChQqwLWaev27vAUmAc+LuiJYF3XFsH5cCvYOR0DBvRYmYX5M/gB+OAyWyUfWObzVn1mktha/QYspO+fBIdArw3SaPQeF9uhwKgU6K2Ddj4fggvBOm3dtQ5bl1O9ti5b133fB5x9EFJws1mn9RbSHvdzcA04JE4Fm+nVJxr6FLevwkthY9B60179TvwNBcookOqeI8RXwfdgOfQG7Ez1ejH7/wQ+Cr0dVEqbt+uwjXBDB48HW6CZCtb7+eRnqzqUfjlsCmGhQJcK2Hu+Bi6A3jo76LYxcRw45F4fqrO5eHQiOISwcJODctACG8QvhrBQoEsFHA0OG7wpFhxWfx327LJATXk/jQ8GDdKZ9rdB2K0pw3g/FCiggItQM9XTQT53Zfot4JWZoubqW5P5lPIXNn04xPtP5tiXDXF8HBoKDKpA79zU+ueQd5TmSvYr4cBRJjpMWg4H/hGWwiAtUb/7/g/p2kBM14DwcVgoMDIFXHs5HE4BLwH1W1f73c9e2NVpg7mYNQWQQ2fnqrMzebI36T4JXAkMCwVKKLAVmbwAjgK3DcxRmoF7MOw3ykRnSqspgB/gQFfYctksEvZGkHRZKVc+kW4okBRw0ckFp1Sve4fVaZ9hXx1V7jNsIoMc3xTA9pA6k8s2IWHnIRHAuRSOdCcr4GjPOaqXRnPZLiRcdDW6KYDtHXUmlzkfiSF0LnUj3akU2Hyiztl55DKvrngrZjFrCuAdM3vg8MU8HEqHhQIlFNiQTLaBHENn/U9z6h38p5Q1BbA3buS2dCE8dz6RfiigAinAcqmRGgZXo4tZUwAXcyAyCgVCgfYKRAC31y6ODAU6VyACuPNTEA6EAu0ViABur10cGQp0rkAEcOenIBwIBdorEAHcXrs4MhToXIEI4M5PQTgQCrRXIAK4vXZxZCjQuQIRwJ2fgnAgFGivQARwe+3iyFCgcwUigDs/BeFAKNBegQjg9trFkaFA5wpEAHd+CsKBUKC9AhHA7bWLI0OBzhWIAO78FIQDoUB7BSKA22sXR4YCnSsQAdz5KQgHQoH2CkQAt9cujgwFOlcgArjzUxAOhALtFYgAbq9dHBkKdK5ABHDnpyAcCAXaKxAB3F67ODIU6FyBCODOT0E4EAq0VyACuL12cWQo0LkCTQFc4hGJTXl3Lko4EAoMoUDRet2UmU9xy23m4dMZwkKBEgpY1x4qkNFvC+SxJoumAF7AHnet2Wv0GwbvRXD/6JOOFEOBKRW4l3cvht9N+elo3vTB4VeOJqn+UmkK4Ks4/Kb+kmi1130cdQFEALeSLw5qoYDPBb4Uco4ul5L+QihmTQFsD7wigxfpAVPrk7YtYokhTYZiRJJjqID1zVFfzgf3+WCz9ADxIhI1BbCtyKIMHvgENwu5GJZDTjFJPiwUWKOAo74L4eY174x2w57den3raJNtn9prJxyy1xwlDp1f1d6tODIUaK2AI7+vwu0wyjptWifAIVCNzcWTz8CoC3pcNSUMR9Y1BRwBPgPOAeu1K9PD1O/e448irersmXh0LqRC9jqc3hvk1YWxN0BYKNCVAhuQ8fvBNZ5B6u7kfVMs+HoWzAEtPeh79X+Z/643Q/q38bnDjV1gJ2iaM/PRjKZgx8L3IOclqhkdiR3WaQUMuJVgQO4Lj4E2ltZzfsHBjlS9fJTzElUbH9cc80q2fg4PgBeqJ7dGTf+7yiw3wIdge9CKtlKrs4y/ocDDFNiZ/z4By8EFKBdUm+rx5PddiPW9n8FLoHrbBA83hWfBt2FygZr+v5t9T4TnwlYQgYsIYVUo4OhzC9gG3gxeYmqqx5PfX8S+H4UDwYWxzqzfzNMNF2fgqT2whTeotwaHIZuDZkGvgutgKZwPCrMMbOXCQoFaFHBkaAejnQR2Lq8Gr+PuDtbrZHeyYdDK1WC9dsjstNAheWc2SI/ovgao9gww+A3gvWAOuDjg3OJaWAJLJ+AlLBSoVoFUr53eHQrOY2fD3rADOG20Pl8/wWJeDeiwUCAUqESBQTqySlwON0KBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBKRWI39ydUpZ4MxQYXoG2weWjVB4LO8JO4C/Y+7iVO+BG8CnlPqnBx1DcC2GhwDgoYH22LsvO4PO8fJDZr+Am8LErPqlhIVRhgwSwAboZWLAnwrwJDuDVx1JoPpbiYrgGfGzj8fAD+AOEhQI1KrAhTtkh+SykJ0+87sfrfEjPDvORQZfA7eDzlD4FS2GsbBe8fQdcCLZCBqUPdvK1F99L7/+Q7SMgLBSoUQED1ED9HCyDVKdT/Z1cr/0/BfActqu23t75OXh6MvjA799Cb8Gm23b4vAR+DG8Ge3CtN+3V78TfUKCMAqnuOTy2Q7oIfFiZQ+Xp6nL6zOC+Ba6Dr8MzIFlKO/3f+eumeHA4fBccHqdC9PM6uRVz3vBW2BbCQoEuFXD95p3gVK+futy0jzFxCvwxzILqzEeHpjmshZgclE0Fa3p/AWm8vrpShkPrmgKvo8DXQlM97ef9FAv23KeCa0HV2XPxqJ/CDLLPaaS5f3UlDYfWJQW+RmEHqbP97Pt+0nT1uqg9eprc7H1fMs3nbT86mANf3vbgOC4UaKFA79z0SI53lXnU9goStG5XYT6p/Fjwem4/rc+g+5xFuo+DtEzPZlgokFWBLUndIPsp3AeD1tl+9v846W4MxaypB7aFOgxyLTjNIe0nwiYQFgqUUMAF2SeB13q9R8GAHLXNI8EcvXujn00B7JDDa725zIvnXn+LAM6lcKQ7lQIG7W8mPugdVk+1b5v3duEgbwgpZk0BbCviHVe5zMC1tSo63MhVmEh3LBTYDC/tNHLWOWNmz5JqNAXwgTiRc0XNIYxDaF/DQoESChjA1rmcAbwj6T++RGFSHk0BnOa+OeYJKe+t00a8hgIFFNiAPEp0GN7hVcyaAviOCQ9yzBNS4bwQ7k3kYaFACQVydka9/ntjRzFrCuAFeHBbZi9sHLx7JXrizEJH8muvAk0BXKrEfjHiwVKZRT6hwNqmQFMAe4OF3//NbX7LaW7uTCL9UGBtVaApgB0+5/4lDYfQR4MX1sNCgVCghQJNAXw1aa1okd6gh3h3zFPA+67DQoFQYEAFmgJ4FumUuk/5j8nr+QP6HbuHAqEACjQF8PV8dnMhheaQz4vAe6+T5bx8lfKI11Bg7BVo6mUvoWTLC5TOa3MG66HwXrBBORdy3odN8mGhwNqhQFMP7OWdGwsU0eA1iJ0L+93j90AsaiFCWCjQjwJNPbDHuoh1E+T8UoP59A6XX8z/BrR3aZ0NYaFAKDCNAtNd6/UHu9IN4NMkMfKPdiLFZ8Hh4Ahhf/De7LtAn5qstyFo2ifef6QC64pujvLe/sjij/wdr+CcNPJUGxKc6eT50zcnNxyb4+00J05pn8+G790JS8BRgY3OrbAMbgFvUr8Mfg2Wx/1rte1wzAbKUc2u4De+HG3kNnURnzCgbt7r7v8XwUMT2zXrpmaJ3djeZsJvXvoyp4R7wZv72nu4nVwAPg6sl/2a58Lz4Ij3Brgf/N7y5aD5+ZTnxw+mM+ejZ8F0Q+3pjs/1mT3xpaBYfrdYwU4HRajNPJF+Q2VPUM99wFHFPLBXKGn3kJm6ub7huT8GUiPJZlXmjz54n7xaqdveoHZ+1XWQ4GD3YmaQzRRT0zmzkg8NWs+TndOnYAm0NiuZAWxroHO18kN8ex5o9tDDiLgqkRH90Y/94BNgY1Obfg71ngRa04Lm6k/L/p1Fdl6Z+DzYK9WmWwl/7qbcBvBc0FqdH1vAN8HtoNMO90o4P2ge9+GXrdfP4S3wGNC6COSUp6OWV8OZ4G2pD8Kg5cq9v5VkBXwfXgbJUhnS/yVeU55bkNk7wJGCuvn1vNw61Ji+o0njztHSN+AZ0Mr8nZ+zwQRrLOhknxbi51vB+WZX5hzN4HV4WmujN1k3z/HLocvvaO9I/u+Ea2Cyf+vy/87hT4Gnw8OmDw43ZzLH41bIvWDLmXbu8HNPsK24gbs72CNfAV2YP93yMXAYqE/JNzars+Sbi0M21teBw/0u7AVk+ndgXQtbrYDnx9HcHmAcOkd2EXeV9RPAjr2XwzxwPlerGSjJtp/YsDdeU9j0YYHXo8nDqUeyXt/Se7W89vq2K07ZYF8Fd3XgoCOnIzvIt+Ys0/kxVvcFG9fF8Gvo6zu/tgDOlRwKevnDlnocbDOcdDneeXFJeyqZvQZcMR1H2wqnHb1cXNj5o8jPIfy41K/C8qzKzlhMvfAS3+mnB151JH+Meu1psCGklsH3ajTncjY6Z4Ktlds5bRaJHw7vgiPA/8fRrCD2vjZ8zr1ym+fJEcu74QnwsDke/4f9vwLGnNfDr4UL4fcOj/sxD/Ta60/htH4OqGSf3fHDSrFRAX9sDJ1iOO/1+q6t5biac9CDod/6MUw5/ZnXA2E+uD3OuuF+ETuAXFaN8Po9QUlUr8mdCMuKuDl8JlYIK4YjhhLmYsMDExnZ6I2r2QsbVP3Wj2HKqU42fqm3H2fdhtFhkGNns/PeHjDoCVJku+4vwzgEsT2hi28lAtjgtdey8o+7bUsBvANq0PrRptybcJCNbLp23yaNde2Y3SjwXAvd5gTdw3FfgZ9D7nklWQxlLmSVGkIbwIdAzZfa+hXThaxSQ2gD2MtuEcD9np1HPcrr5a5Itwpgh9N3w6fhePBOmRotDfu9pOQQLbc59LPn0lLeq/8br7/Jd8tSYjhrw7fFeEnUqbfp/KzqKNr0wHrvib0UPgOnwr1Qm6XKdzOOPVTAOYVN15xT3gWyHXkWyXfLkirLyDPpSdAOwFXvsP4USOfnTndvG8DpxF5BGu8Bh9M1ngQblgsgLZCwmc2siK4PrBI2Wy5lEr6DbC6GElMkL/GdD76G9afACnbzZpvWAdybzUr+eQv8Gyzp/aCCbQPYilgigO3lF8DaEMC3UI7LoEQA/4Z8LgdvugnrTwHjzPvFRxLAnmRbhK+Bi1uLoWtLIwRXn0tUQstrD+y0wvWBcbWk2ywKUEo3A9hG9nfjKloHfrums+r8tB1CT+Wzc6bj4auwFByGpQrBZlFznpCGZpewXaJyGMBWxEUwrqZuNkDnwUIoEcTpPC0jv7DpFTCeHB2dC4vddZSrs578+8DbvByy3gZeh90a/Ky0OS//VzgdHiyQueI6DHTV27tkLPc42ndw+j/ASlKiAbaRcNqxO3htc3MIm1oBtbKD/DxcM/Uuw79rr74xeHngFXAOWBESOpG2c77+E/l0YVZCRc5Ztpxpv7EL0cjTG26+BznLtjakPb/3/IyyB07pKpLDSQPVWy9/AWeBvaCtrPOrHGa+qad3+PcNyNZKTVMAexPLvgfsPLFfr28Tb1Xz0uvbL/HqRPC8lTaHhtaNvWG70plXnF86P8bPqXAyONJdZTkCOKVtMDmU/hV4uekGSCfmHrZHPcRMwWten4WfgvOrLszK6BRilwmSb134MlOe+uYK+tnwabgISqzak80jzIbDc+YoZttHfLpuvuH5caHvNDgGloId5CrLGcApj/S6gg0XthbA1bAD2FPpnBVoQ2hrHm+r5ILVsfBN8Lp0F4FjngbAVWBD5XRCfH+YMnL4yM1WXd0cIX0BbOH1vSvdDF5HTdYJb4O1fkquURtJV22eCy+FGrxfBEezBm8X54dsV2ecMvfE/DV8BAw4hwptWcKx/w7Oo5yD12KprE/GoW9D2/LlOu5KfPp7cLhfkyXdjsSpH0Gu8tecrsF7DrwZdoIpLQk15YcF3tyGPDYAeyad3A8OAldy7R0UeH9wZdIV7R1As3CXghXwanDF1JbbXvcBqM3U+SnwTrBcs+FgsJcpaV4iuhwcBamdui0FdVszLGO7FrORfz68Eeyd94T5YJ2p0ayvw8SUo9SF4Pmxfl8ATkE9b45WH2HDZPaIxEbwhhV6VzBYdVhBdgSHoCnIPanOL5fDSrCAN0OtpsaWw0p3EDjct+GynDZUU54Y3h+16Yc63QRWFLVzSlOrJd1svG3YbfjUS93UTx37NYfk+4Ajvty2iAy+BJ7vfuPL/SyP56UXpzdhFSjQ74mswNWqXBilbvtSMhvS3HyvpII1zRdLlrt0XlaasMEVGKVuWw2efasjZrU6quVBEcAthYvDQoEaFIgAruEshA+hQEsFIoBbCheHhQI1KBABXMNZCB9CgZYKRAC3FC4OCwVqUCACuIazED6EAi0ViABuKVwcFgrUoEAEcA1nIXwIBVoqEAHcUrg4LBSoQYEI4BrOQvgQCrRUIAK4pXBx2NgpMMr7qqcqfLrtc/2pPsz1XgRwLmUj3doU8NtMOb/dkxqIot/wigCurZqFP7kU8NdRzof7c2VAun4V0O/yFrMI4GJSR0YdK+APAvhjBv4YRC67gYT9gYliFgFcTOrIqGMFHD77m2mD/BDAoC77oxNFLQK4qNyRWYcK+ONw58HSTD74yzBnwpWZ0o9kQ4FQAAU+CTfCqH+Z41OkOQfCQoFQIJMCrhTPg9Nh1AHsjxYWN38gLiwUWJcUuIXCOlfdE7YbouA2ADYI/pqnv6d9CvjrkUUtArio3JFZJQo4hHZVei5s2+NTCsqetxo3DV5/7fI0+CxcB8V/mjcCGNXD1ikFDDyD159/dWV6IzBw/TG6fleRvRR1O3wX/AnZX4DBa9phoUAoUFiB55FfegKEgeilpvS75Aa32+k9g/9s+EtIDxpgsxtbv5tsI9dQoCoFDF574jvBAPVH4A8Ff5xdWwneBHLZBD4xwbm0+3Zq0eV3Kn9kXpECW+CLc2J74O3AR/1sCd5DbbD6VAuv9frq0DssFAgFKlEgOrJKTkS4EQqsUwr8H4DFXUwZwWxSAAAAAElFTkSuQmCC';
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 55db90a738..fdd04e8ac8 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 1749cf0038..21e10bff1e 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';
@@ -115,7 +116,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', () => {
@@ -131,7 +132,7 @@ describe('FeedbackWidgetManager', () => {
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
-
+
it('showFeedbackWidget adds the feedbackIntegration to the client', () => {
mockedIsModalSupported.mockReturnValue(true);
@@ -140,3 +141,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..3115f5ccd9
--- /dev/null
+++ b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap
@@ -0,0 +1,256 @@
+// 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..a3841f1597 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`] = `
Report a Bug
@@ -53,6 +55,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -80,6 +83,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
"height": 50,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
@@ -234,6 +241,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
exports[`FeedbackWidget matches the snapshot with custom styles and screenshot button 1`] = `
Report a Bug
@@ -285,6 +294,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -312,6 +322,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b
"height": 50,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
@@ -523,6 +537,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b
exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
Feedback Form
@@ -580,6 +596,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -612,6 +629,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Submit Button Label
@@ -786,6 +807,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = `
exports[`FeedbackWidget matches the snapshot with custom texts and screenshot button 1`] = `
Feedback Form
@@ -843,6 +866,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -875,6 +899,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Submit Button Label
@@ -1112,6 +1140,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu
exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
Report a Bug
@@ -1169,6 +1199,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -1201,6 +1232,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
@@ -1375,6 +1410,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = `
exports[`FeedbackWidget matches the snapshot with default configuration and screenshot button 1`] = `
Report a Bug
@@ -1432,6 +1469,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration and scre
style={
{
"height": 40,
+ "tintColor": "rgba(54, 45, 89, 1)",
"width": 40,
}
}
@@ -1464,6 +1502,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration and scre
"paddingHorizontal": 10,
}
}
+ testID="sentry-feedback-name-input"
value="Test User"
/>
Send Bug Report
diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap
new file mode 100644
index 0000000000..521573f3ad
--- /dev/null
+++ b/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap
@@ -0,0 +1,3102 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeedbackButtonManager the Feedback Button matches the snapshot with custom dark theme 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 5d679fc5d4..fe6a630adb 100644
--- a/samples/expo/app/(tabs)/index.tsx
+++ b/samples/expo/app/(tabs)/index.tsx
@@ -78,6 +78,12 @@ export default function TabOneScreen() {
Sentry.showFeedbackWidget();
}}
/>
+