Skip to content

Commit ef4be9e

Browse files
feat(feedback): Support web environments (#4558)
* Save form state for unsubmitted data * Show selected screenshot * Use image uri instead of UInt8Array in onAddScreenshot callback * Omit isVisible from state * Save/clear form state on unmount * Pass the missing attachment parameter in the onSubmitSuccess * Use only the uri parameter for the onAddScreenshot callback * Handle attachments on the web * Use window for showing alerts on the web * Disable keyboard handling on the web * Use instance variable for _didSubmitForm * Fixed callback function parameter name for clarity Co-authored-by: Krystof Woldrich <[email protected]> * Fixes lint issue * Use RN_GLOBAL_OBJ for web alert --------- Co-authored-by: Krystof Woldrich <[email protected]>
1 parent 8ff7db3 commit ef4be9e

File tree

7 files changed

+72
-25
lines changed

7 files changed

+72
-25
lines changed

Diff for: packages/core/src/js/feedback/FeedbackWidget.tsx

+28-16
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/c
33
import * as React from 'react';
44
import type { KeyboardTypeOptions } from 'react-native';
55
import {
6-
Alert,
76
Image,
87
Keyboard,
98
KeyboardAvoidingView,
@@ -17,12 +16,13 @@ import {
1716
View
1817
} from 'react-native';
1918

19+
import { isWeb, notWeb } from '../utils/environment';
2020
import { NATIVE } from '../wrapper';
2121
import { sentryLogo } from './branding';
2222
import { defaultConfiguration } from './defaults';
2323
import defaultStyles from './FeedbackWidget.styles';
2424
import type { FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackWidgetProps, FeedbackWidgetState, FeedbackWidgetStyles, ImagePickerConfiguration } from './FeedbackWidget.types';
25-
import { isValidEmail } from './utils';
25+
import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils';
2626

2727
/**
2828
* @beta
@@ -75,12 +75,12 @@ export class FeedbackWidget extends React.Component<FeedbackWidgetProps, Feedbac
7575
const trimmedDescription = description?.trim();
7676

7777
if ((this.props.isNameRequired && !trimmedName) || (this.props.isEmailRequired && !trimmedEmail) || !trimmedDescription) {
78-
Alert.alert(text.errorTitle, text.formError);
78+
feedbackAlertDialog(text.errorTitle, text.formError);
7979
return;
8080
}
8181

8282
if (this.props.shouldValidateEmail && (this.props.isEmailRequired || trimmedEmail.length > 0) && !isValidEmail(trimmedEmail)) {
83-
Alert.alert(text.errorTitle, text.emailError);
83+
feedbackAlertDialog(text.errorTitle, text.emailError);
8484
return;
8585
}
8686

@@ -107,13 +107,13 @@ export class FeedbackWidget extends React.Component<FeedbackWidgetProps, Feedbac
107107
}
108108
captureFeedback(userFeedback, attachments ? { attachments } : undefined);
109109
onSubmitSuccess({ name: trimmedName, email: trimmedEmail, message: trimmedDescription, attachments: attachments });
110-
Alert.alert(text.successMessageText);
110+
feedbackAlertDialog(text.successMessageText , '');
111111
onFormSubmitted();
112112
this._didSubmitForm = true;
113113
} catch (error) {
114114
const errorString = `Feedback form submission failed: ${error}`;
115115
onSubmitError(new Error(errorString));
116-
Alert.alert(text.errorTitle, text.genericError);
116+
feedbackAlertDialog(text.errorTitle, text.genericError);
117117
logger.error(`Feedback form submission failed: ${error}`);
118118
}
119119
};
@@ -124,15 +124,15 @@ export class FeedbackWidget extends React.Component<FeedbackWidgetProps, Feedbac
124124
if (imagePickerConfiguration.imagePicker) {
125125
const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync
126126
// expo-image-picker library is available
127-
? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'] })
127+
? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], base64: isWeb() })
128128
// react-native-image-picker library is available
129129
: imagePickerConfiguration.imagePicker.launchImageLibrary
130-
? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo' })
130+
? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo', includeBase64: isWeb() })
131131
: null;
132132
if (!launchImageLibrary) {
133133
logger.warn('No compatible image picker library found. Please provide a valid image picker library.');
134134
if (__DEV__) {
135-
Alert.alert(
135+
feedbackAlertDialog(
136136
'Development note',
137137
'No compatible image picker library found. Please provide a compatible version of `expo-image-picker` or `react-native-image-picker`.',
138138
);
@@ -142,18 +142,29 @@ export class FeedbackWidget extends React.Component<FeedbackWidgetProps, Feedbac
142142

143143
const result = await launchImageLibrary();
144144
if (result.assets && result.assets.length > 0) {
145-
const filename = result.assets[0].fileName;
146-
const imageUri = result.assets[0].uri;
147-
NATIVE.getDataFromUri(imageUri).then((data) => {
145+
if (isWeb()) {
146+
const filename = result.assets[0].fileName;
147+
const imageUri = result.assets[0].uri;
148+
const base64 = result.assets[0].base64;
149+
const data = base64ToUint8Array(base64);
148150
if (data != null) {
149151
this.setState({ filename, attachment: data, attachmentUri: imageUri });
150152
} else {
151-
logger.error('Failed to read image data from uri:', imageUri);
153+
logger.error('Failed to read image data on the web');
152154
}
153-
})
154-
.catch((error) => {
155+
} else {
156+
const filename = result.assets[0].fileName;
157+
const imageUri = result.assets[0].uri;
158+
NATIVE.getDataFromUri(imageUri).then((data) => {
159+
if (data != null) {
160+
this.setState({ filename, attachment: data, attachmentUri: imageUri });
161+
} else {
162+
logger.error('Failed to read image data from uri:', imageUri);
163+
}
164+
}).catch((error) => {
155165
logger.error('Failed to read image data from uri:', imageUri, 'error: ', error);
156166
});
167+
}
157168
}
158169
} else {
159170
// Defaulting to the onAddScreenshot callback
@@ -215,9 +226,10 @@ export class FeedbackWidget extends React.Component<FeedbackWidgetProps, Feedbac
215226
<KeyboardAvoidingView
216227
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
217228
style={[styles.container, { padding: 0 }]}
229+
enabled={notWeb()}
218230
>
219231
<ScrollView bounces={false}>
220-
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
232+
<TouchableWithoutFeedback onPress={notWeb() ? Keyboard.dismiss: undefined}>
221233
<View style={styles.container}>
222234
<View style={styles.titleContainer}>
223235
<Text style={styles.title}>{text.formTitle}</Text>

Diff for: packages/core/src/js/feedback/FeedbackWidget.types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -207,14 +207,17 @@ interface ImagePickerResponse {
207207
interface ImagePickerAsset {
208208
fileName?: string;
209209
uri?: string;
210+
base64?: string;
210211
}
211212

212213
interface ExpoImageLibraryOptions {
213214
mediaTypes?: 'images'[];
215+
base64?: boolean;
214216
}
215217

216218
interface ReactNativeImageLibraryOptions {
217219
mediaType: 'photo';
220+
includeBase64?: boolean;
218221
}
219222

220223
export interface ImagePicker {

Diff for: packages/core/src/js/feedback/FeedbackWidgetManager.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { logger } from '@sentry/core';
22
import * as React from 'react';
33
import { Animated, Dimensions, Easing, KeyboardAvoidingView, Modal, PanResponder, Platform } from 'react-native';
44

5+
import { notWeb } from '../utils/environment';
56
import { FeedbackWidget } from './FeedbackWidget';
67
import { modalBackground, modalSheetContainer, modalWrapper } from './FeedbackWidget.styles';
78
import type { FeedbackWidgetStyles } from './FeedbackWidget.types';
@@ -61,10 +62,10 @@ class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProviderProps
6162
private _panResponder = PanResponder.create({
6263
onStartShouldSetPanResponder: (evt, _gestureState) => {
6364
// On Android allow pulling down only from the top to avoid breaking native gestures
64-
return Platform.OS !== 'android' || evt.nativeEvent.pageY < PULL_DOWN_ANDROID_ACTIVATION_HEIGHT;
65+
return notWeb() && (Platform.OS !== 'android' || evt.nativeEvent.pageY < PULL_DOWN_ANDROID_ACTIVATION_HEIGHT);
6566
},
6667
onMoveShouldSetPanResponder: (evt, _gestureState) => {
67-
return Platform.OS !== 'android' || evt.nativeEvent.pageY < PULL_DOWN_ANDROID_ACTIVATION_HEIGHT;
68+
return notWeb() && (Platform.OS !== 'android' || evt.nativeEvent.pageY < PULL_DOWN_ANDROID_ACTIVATION_HEIGHT);
6869
},
6970
onPanResponderMove: (_, gestureState) => {
7071
if (gestureState.dy > 0) {
@@ -147,6 +148,7 @@ class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProviderProps
147148
<KeyboardAvoidingView
148149
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
149150
style={modalBackground}
151+
enabled={notWeb()}
150152
>
151153
<Animated.View
152154
style={[modalSheetContainer, { transform: [{ translateY: this.state.panY }] }]}

Diff for: packages/core/src/js/feedback/defaults.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { Alert } from 'react-native';
2-
31
import type { FeedbackWidgetProps } from './FeedbackWidget.types';
2+
import { feedbackAlertDialog } from './utils';
43

54
const FORM_TITLE = 'Report a Bug';
65
const NAME_PLACEHOLDER = 'Your Name';
@@ -27,15 +26,15 @@ export const defaultConfiguration: Partial<FeedbackWidgetProps> = {
2726
},
2827
onFormClose: () => {
2928
if (__DEV__) {
30-
Alert.alert(
29+
feedbackAlertDialog(
3130
'Development note',
3231
'onFormClose callback is not implemented. By default the form is just unmounted.',
3332
);
3433
}
3534
},
3635
onAddScreenshot: (_: (uri: string) => void) => {
3736
if (__DEV__) {
38-
Alert.alert('Development note', 'onAddScreenshot callback is not implemented.');
37+
feedbackAlertDialog('Development note', 'onAddScreenshot callback is not implemented.');
3938
}
4039
},
4140
onSubmitSuccess: () => {
@@ -46,7 +45,7 @@ export const defaultConfiguration: Partial<FeedbackWidgetProps> = {
4645
},
4746
onFormSubmitted: () => {
4847
if (__DEV__) {
49-
Alert.alert(
48+
feedbackAlertDialog(
5049
'Development note',
5150
'onFormSubmitted callback is not implemented. By default the form is just unmounted.',
5251
);

Diff for: packages/core/src/js/feedback/utils.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
import { isFabricEnabled } from '../utils/environment';
1+
import { Alert } from 'react-native';
2+
3+
import { isFabricEnabled, isWeb } from '../utils/environment';
4+
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
25
import { ReactNativeLibraries } from './../utils/rnlibraries';
36

7+
declare global {
8+
// Declaring atob function to be used in web environment
9+
function atob(encodedString: string): string;
10+
}
11+
412
/**
513
* Modal is not supported in React Native < 0.71 with Fabric renderer.
614
* ref: https://github.com/facebook/react-native/issues/33652
@@ -14,3 +22,25 @@ export const isValidEmail = (email: string): boolean => {
1422
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
1523
return emailRegex.test(email);
1624
};
25+
26+
/**
27+
* Converts base64 string to Uint8Array on the web
28+
* @param base64 base64 string
29+
* @returns Uint8Array data
30+
*/
31+
export const base64ToUint8Array = (base64: string): Uint8Array => {
32+
if (typeof atob !== 'function' || !isWeb()) {
33+
throw new Error('atob is not available in this environment.');
34+
}
35+
36+
const binaryString = atob(base64);
37+
return new Uint8Array([...binaryString].map(char => char.charCodeAt(0)));
38+
};
39+
40+
export const feedbackAlertDialog = (title: string, message: string): void => {
41+
if (isWeb() && typeof RN_GLOBAL_OBJ.alert !== 'undefined') {
42+
RN_GLOBAL_OBJ.alert(`${title}\n${message}`);
43+
} else {
44+
Alert.alert(title, message);
45+
}
46+
};

Diff for: packages/core/src/js/utils/worldwide.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal {
2525
__BUNDLE_START_TIME__?: number;
2626
nativePerformanceNow?: () => number;
2727
TextEncoder?: TextEncoder;
28+
alert?: (message: string) => void;
2829
}
2930

3031
type TextEncoder = {

Diff for: packages/core/test/feedback/FeedbackWidget.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('FeedbackWidget', () => {
235235
fireEvent.press(getByText(defaultProps.submitButtonLabel));
236236

237237
await waitFor(() => {
238-
expect(Alert.alert).toHaveBeenCalledWith(defaultProps.successMessageText);
238+
expect(Alert.alert).toHaveBeenCalledWith(defaultProps.successMessageText, '');
239239
});
240240
});
241241

0 commit comments

Comments
 (0)