Skip to content

Feedback UI: Use Image Picker libraries from integrations #4524

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fe3dc15
Disable bouncing
antonis Jan 30, 2025
72eef2d
Add modal ui appearance
antonis Jan 30, 2025
ccc808b
Update snapshot tests
antonis Jan 30, 2025
fd47fd2
Fix bottom margin
antonis Jan 30, 2025
dcc5d3b
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Jan 31, 2025
9ecd8a2
Fix sheet height
antonis Jan 31, 2025
05f94f8
Remove extra modal border
antonis Jan 31, 2025
d88a599
Do not expose modal styles
antonis Jan 31, 2025
ce1de86
Animate background color
antonis Jan 31, 2025
a7a4e56
Avoid keyboard in modal
antonis Jan 31, 2025
8779886
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Feb 3, 2025
ae80f7d
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Feb 7, 2025
488658e
Merge branch 'feedback-ui' into antonis/feedback-modal-ui
antonis Feb 10, 2025
c4d502e
Use Image Picker interface matching `expo-image-picker` and `react-na…
antonis Feb 11, 2025
1b74b45
Update samples to pass the ImagePicker library implementation
antonis Feb 11, 2025
1d8a0db
Merge branch 'feedback-ui' into antonis/feedback-ui-imagepicker-integ…
antonis Feb 11, 2025
a0f4a77
Get image data from uri
antonis Feb 11, 2025
357dea8
Add early return and dev note
antonis Feb 11, 2025
192220b
Adds tests
antonis Feb 11, 2025
c3991ff
Adds sample expo plugin configuration
antonis Feb 11, 2025
91e96de
Merge branch 'feedback-ui' into antonis/feedback-ui-imagepicker-integ…
antonis Feb 11, 2025
6666cf6
Update media type for expo
antonis Feb 12, 2025
d1e5107
Update media type for rn
antonis Feb 12, 2025
d780fc1
Add native implementation for getDataFromUri
antonis Feb 13, 2025
85dff80
Bumped to the latest react-native-image-picker version 8
antonis Feb 13, 2025
e92fea6
Add missing null in return type
antonis Feb 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions packages/core/src/js/feedback/FeedbackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import {
import { sentryLogo } from './branding';
import { defaultConfiguration } from './defaults';
import defaultStyles from './FeedbackForm.styles';
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types';
import { isValidEmail } from './utils';
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles, FeedbackGeneralConfiguration, FeedbackTextConfiguration, ImagePickerConfiguration } from './FeedbackForm.types';
import { getDataFromUri, isValidEmail } from './utils';

/**
* @beta
Expand Down Expand Up @@ -100,12 +100,46 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
}
};

public onScreenshotButtonPress: () => void = () => {
public onScreenshotButtonPress: () => void = async () => {
if (!this.state.filename && !this.state.attachment) {
const { onAddScreenshot } = { ...defaultConfiguration, ...this.props };
onAddScreenshot((filename: string, attachement: Uint8Array) => {
this.setState({ filename, attachment: attachement });
});
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
if (imagePickerConfiguration.imagePicker) {
const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync
// expo-image-picker library is available
? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'] })
// react-native-image-picker library is available
: imagePickerConfiguration.imagePicker.launchImageLibrary
? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo' })
: null;
if (!launchImageLibrary) {
logger.warn('No compatible image picker library found. Please provide a valid image picker library.');
if (__DEV__) {
Alert.alert(
'Development note',
'No compatible image picker library found. Please provide a compatible version of `expo-image-picker` or `react-native-image-picker`.',
);
}
return;
}

const result = await launchImageLibrary();
if (result.assets && result.assets.length > 0) {
const filename = result.assets[0].fileName;
const imageUri = result.assets[0].uri;
getDataFromUri(imageUri).then((data) => {
this.setState({ filename, attachment: data });
})
.catch((error) => {
logger.error("Error:", error);
});
}
} else {
// Defaulting to the onAddScreenshot callback
const { onAddScreenshot } = { ...defaultConfiguration, ...this.props };
onAddScreenshot((filename: string, attachement: Uint8Array) => {
this.setState({ filename, attachment: attachement });
});
}
} else {
this.setState({ filename: undefined, attachment: undefined });
}
Expand All @@ -118,6 +152,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
const { name, email, description } = this.state;
const { onFormClose } = this.props;
const config: FeedbackGeneralConfiguration = this.props;
const imagePickerConfiguration: ImagePickerConfiguration = this.props;
const text: FeedbackTextConfiguration = this.props;
const styles: FeedbackFormStyles = { ...defaultStyles, ...this.props.styles };
const onCancel = (): void => {
Expand Down Expand Up @@ -191,7 +226,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
onChangeText={(value) => this.setState({ description: value })}
multiline
/>
{config.enableScreenshot && (
{(config.enableScreenshot || imagePickerConfiguration.imagePicker) && (
<TouchableOpacity style={styles.screenshotButton} onPress={this.onScreenshotButtonPress}>
<Text style={styles.screenshotText}>
{!this.state.filename && !this.state.attachment
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/js/feedback/FeedbackForm.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { ImageStyle, TextStyle, ViewStyle } from 'react-native';
/**
* The props for the feedback form
*/
export interface FeedbackFormProps extends FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackCallbacks {
export interface FeedbackFormProps
extends FeedbackGeneralConfiguration,
FeedbackTextConfiguration,
FeedbackCallbacks,
ImagePickerConfiguration {
styles?: FeedbackFormStyles;
}

Expand Down Expand Up @@ -187,6 +191,36 @@ export interface FeedbackCallbacks {
onFormSubmitted?: () => void;
}

/**
* Image Picker configuration interfact matching `expo-image-picker` and `react-native-image-picker`
*/
export interface ImagePickerConfiguration {
imagePicker?: ImagePicker;
}

interface ImagePickerResponse {
assets?: ImagePickerAsset[];
}

interface ImagePickerAsset {
fileName?: string;
uri?: string;
}

interface ExpoImageLibraryOptions {
mediaTypes?: 'images'[];
}

interface ReactNativeImageLibraryOptions {
mediaType: 'photo';
}

export interface ImagePicker {
launchImageLibraryAsync?: (options?: ExpoImageLibraryOptions) => Promise<ImagePickerResponse>;

launchImageLibrary?: (options: ReactNativeImageLibraryOptions) => Promise<ImagePickerResponse>;
}

/**
* The styles for the feedback form
*/
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/js/feedback/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,26 @@ export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(email);
};

/**
* Reads a file from a URI and returns a UInt8Array of its data.
* @param uri The file URI.
* @returns A Promise resolving to a UInt8Array.
*/
export async function getDataFromUri(uri: string): Promise<Uint8Array> {
const response = await fetch(uri);
const blob = await response.blob();

return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result instanceof ArrayBuffer) {
resolve(new Uint8Array(reader.result));
} else {
reject(new Error('Failed to read file as UInt8Array'));
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(blob);
});
}
38 changes: 36 additions & 2 deletions packages/core/test/feedback/FeedbackForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from 'react';
import { Alert } from 'react-native';

import { FeedbackForm } from '../../src/js/feedback/FeedbackForm';
import type { FeedbackFormProps, FeedbackFormStyles } from '../../src/js/feedback/FeedbackForm.types';
import type { FeedbackFormProps, FeedbackFormStyles, ImagePicker } from '../../src/js/feedback/FeedbackForm.types';

const mockOnFormClose = jest.fn();
const mockOnAddScreenshot = jest.fn();
Expand Down Expand Up @@ -303,7 +303,7 @@ describe('FeedbackForm', () => {
});
});

it('calls onAddScreenshot when the screenshot button is pressed', async () => {
it('calls onAddScreenshot when the screenshot button is pressed and no image picker library is integrated', async () => {
const { getByText } = render(<FeedbackForm {...defaultProps} enableScreenshot={true} />);

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
Expand All @@ -313,6 +313,40 @@ describe('FeedbackForm', () => {
});
});

it('calls launchImageLibraryAsync when the expo-image-picker library is integrated', async () => {
const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
});
const mockImagePicker: jest.Mocked<ImagePicker> = {
launchImageLibraryAsync: mockLaunchImageLibrary,
};

const { getByText } = render(<FeedbackForm {...defaultProps} imagePicker={ mockImagePicker } />);

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));

await waitFor(() => {
expect(mockLaunchImageLibrary).toHaveBeenCalled();
});
});

it('calls launchImageLibrary when the react-native-image-picker library is integrated', async () => {
const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
});
const mockImagePicker: jest.Mocked<ImagePicker> = {
launchImageLibrary: mockLaunchImageLibrary,
};

const { getByText } = render(<FeedbackForm {...defaultProps} imagePicker={ mockImagePicker } />);

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));

await waitFor(() => {
expect(mockLaunchImageLibrary).toHaveBeenCalled();
});
});

it('calls onFormClose when the cancel button is pressed', () => {
const { getByText } = render(<FeedbackForm {...defaultProps} />);

Expand Down
6 changes: 6 additions & 0 deletions samples/expo/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
"organization": "sentry-sdks"
}
],
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you share them with your friends."
}
],
[
"expo-router",
{
Expand Down
6 changes: 6 additions & 0 deletions samples/expo/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export default function TabOneScreen() {
Sentry.nativeCrash();
}}
/>
<Button
title="Show feedback form"
onPress={() => {
Sentry.showFeedbackForm();
}}
/>
<Button
title="Set Scope Properties"
onPress={() => {
Expand Down
4 changes: 4 additions & 0 deletions samples/expo/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ErrorEvent } from '@sentry/core';
import { isExpoGo } from '../utils/isExpoGo';
import { LogBox } from 'react-native';
import { isWeb } from '../utils/isWeb';
import * as ImagePicker from 'expo-image-picker';

export {
// Catch any errors thrown by the Layout component.
Expand Down Expand Up @@ -57,6 +58,9 @@ Sentry.init({
}),
navigationIntegration,
Sentry.reactNativeTracingIntegration(),
Sentry.feedbackIntegration({
imagePicker: ImagePicker,
}),
);
if (isWeb()) {
integrations.push(Sentry.browserReplayIntegration());
Expand Down
1 change: 1 addition & 0 deletions samples/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@types/react": "~18.3.12",
"expo": "^52.0.0",
"expo-constants": "~17.0.3",
"expo-image-picker": "~16.0.5",
"expo-linking": "~7.0.2",
"expo-router": "~4.0.5",
"expo-status-bar": "~2.0.0",
Expand Down
1 change: 0 additions & 1 deletion samples/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"react-native": "0.77.0",
"react-native-gesture-handler": "^2.22.1",
"react-native-image-picker": "^7.2.2",
"react-native-quick-base64": "^2.1.2",
"react-native-reanimated": "3.16.7",
"react-native-safe-area-context": "5.2.0",
"react-native-screens": "4.6.0",
Expand Down
22 changes: 2 additions & 20 deletions samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ import { ErrorEvent } from '@sentry/core';
import HeavyNavigationScreen from './Screens/HeavyNavigationScreen';
import WebviewScreen from './Screens/WebviewScreen';
import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment';
import { toByteArray } from 'react-native-quick-base64';
import { launchImageLibrary } from 'react-native-image-picker';
import * as ImagePicker from 'react-native-image-picker';

if (typeof setImmediate === 'undefined') {
require('setimmediate');
Expand Down Expand Up @@ -107,6 +106,7 @@ Sentry.init({
: true,
}),
Sentry.feedbackIntegration({
imagePicker: ImagePicker,
styles:{
submitButton: {
backgroundColor: '#6a1b9a',
Expand Down Expand Up @@ -153,23 +153,6 @@ const Stack = isMobileOs
: createStackNavigator();
const Tab = createBottomTabNavigator();

const handleChooseImage = (attachFile: (filename: string, data: Uint8Array) => void): void => {
launchImageLibrary({ mediaType: 'photo', includeBase64: true }, (response) => {
if (response.didCancel) {
console.log('User cancelled image picker');
} else if (response.errorCode) {
console.log('ImagePicker Error: ', response.errorMessage);
} else if (response.assets && response.assets.length > 0) {
const filename = response.assets[0].fileName;
const base64String = response.assets[0].base64;
const screenShotUint8Array = toByteArray(base64String);
if (filename && screenShotUint8Array) {
attachFile(filename, screenShotUint8Array);
}
}
});
};

const ErrorsTabNavigator = Sentry.withProfiler(
() => {
return (
Expand All @@ -189,7 +172,6 @@ const ErrorsTabNavigator = Sentry.withProfiler(
<FeedbackForm
{...props}
enableScreenshot={true}
onAddScreenshot={handleChooseImage}
onFormClose={props.navigation.goBack}
onFormSubmitted={props.navigation.goBack}
styles={{
Expand Down
34 changes: 21 additions & 13 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -14686,6 +14686,26 @@ __metadata:
languageName: node
linkType: hard

"expo-image-loader@npm:~5.0.0":
version: 5.0.0
resolution: "expo-image-loader@npm:5.0.0"
peerDependencies:
expo: "*"
checksum: 7741b4b926124a1f85e51b0b8c8c9adc37fccf0654eaa0e715cb55cffc716bdc149c0421120f9bec69a69643d3328ec8562899b4aa37a0fdcecfb6fe3cf6f985
languageName: node
linkType: hard

"expo-image-picker@npm:~16.0.5":
version: 16.0.6
resolution: "expo-image-picker@npm:16.0.6"
dependencies:
expo-image-loader: ~5.0.0
peerDependencies:
expo: "*"
checksum: 448097075ad08c99a9c2857547e36f07e5e491fe5264fe03e77418271f077478a89f1ad462febedb9070013bf90f826b75ad4b2855d98a93593f76ae20c121de
languageName: node
linkType: hard

"expo-keep-awake@npm:~14.0.1":
version: 14.0.1
resolution: "expo-keep-awake@npm:14.0.1"
Expand Down Expand Up @@ -22988,18 +23008,6 @@ __metadata:
languageName: node
linkType: hard

"react-native-quick-base64@npm:^2.1.2":
version: 2.1.2
resolution: "react-native-quick-base64@npm:2.1.2"
dependencies:
base64-js: ^1.5.1
peerDependencies:
react: "*"
react-native: "*"
checksum: 46f3b26f48b26978686b0c043336220d681e6a02af5abcf3eb4ab7b9216251d1eb2fac5c559e984d963e93f54bd9f323651daac09762196815558abbd551729b
languageName: node
linkType: hard

"react-native-reanimated@npm:3.16.7":
version: 3.16.7
resolution: "react-native-reanimated@npm:3.16.7"
Expand Down Expand Up @@ -24599,6 +24607,7 @@ __metadata:
"@types/react": ~18.3.12
expo: ^52.0.0
expo-constants: ~17.0.3
expo-image-picker: ~16.0.5
expo-linking: ~7.0.2
expo-router: ~4.0.5
expo-status-bar: ~2.0.0
Expand Down Expand Up @@ -24695,7 +24704,6 @@ __metadata:
react-native: 0.77.0
react-native-gesture-handler: ^2.22.1
react-native-image-picker: ^7.2.2
react-native-quick-base64: ^2.1.2
react-native-reanimated: 3.16.7
react-native-safe-area-context: 5.2.0
react-native-screens: 4.6.0
Expand Down
Loading