Skip to content

feat(feedback): Feedback Widget Drop 2 #4726

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

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- Expo Updates Context is passed to native after native init to be available for crashes ([#4808](https://github.com/getsentry/sentry-react-native/pull/4808))
- Expo Updates Context values should all be lowercase ([#4809](https://github.com/getsentry/sentry-react-native/pull/4809))
- Fixes Feedback Widget accessibility issue on iOS ([#4739](https://github.com/getsentry/sentry-react-native/pull/4739))

### Dependencies

Expand Down
19 changes: 19 additions & 0 deletions dev-packages/e2e-tests/maestro/feedback.yml
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml
Original file line number Diff line number Diff line change
@@ -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: '[email protected]'
- inputText: '[email protected]'

# 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'
45 changes: 45 additions & 0 deletions dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml
Original file line number Diff line number Diff line change
@@ -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: '[email protected]'

# 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'
4 changes: 3 additions & 1 deletion dev-packages/e2e-tests/patch-scripts/rn.patch.app.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const e2eComponentPatch = '<EndToEndTestsScreen />';
const lastImportRex = /^([^]*)(import\s+[^;]*?;$)/m;
const patchRex = '@sentry/react-native';
const headerComponentRex = /<ScrollView/gm;
const exportDefaultRex = /export\s+default\s+App;/m;

const jsPath = path.join(args.app, 'App.js');
const tsxPath = path.join(args.app, 'App.tsx');
Expand All @@ -50,7 +51,8 @@ const isPatched = app.match(patchRex);
if (!isPatched) {
const patched = app
.replace(lastImportRex, m => 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!');
Expand Down
5 changes: 5 additions & 0 deletions dev-packages/e2e-tests/src/EndToEndTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface Spec extends TurboModule {
getDataFromUri(uri: string): Promise<number[]>;
popTimeToDisplayFor(key: string): Promise<number | undefined | null>;
setActiveSpanId(spanId: string): boolean;
encodeToBase64(data: number[]): Promise<string | undefined | null>;
}

export type NativeStackFrame = {
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/js/feedback/FeedbackButton.tsx
Original file line number Diff line number Diff line change
@@ -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<FeedbackButtonProps> {
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 (
<TouchableOpacity
style={styles.triggerButton}
onPress={showFeedbackWidget}
accessibilityLabel={text.triggerAriaLabel}
>
<Image source={{ uri: feedbackIcon }} style={styles.triggerIcon}/>
<Text style={styles.triggerText} testID='sentry-feedback-button'>{text.triggerLabel}</Text>
</TouchableOpacity>
);
}
}
Loading
Loading