Skip to content

Commit e751975

Browse files
antonisclaude
andauthored
feat(feedback): Show feedback widget on device shake (#5754)
* feat(feedback): Show feedback widget on device shake --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 449b1c8 commit e751975

File tree

15 files changed

+497
-10
lines changed

15 files changed

+497
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
- Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs
1616
- Enabled by default in Expo apps (requires `expo-updates` to be installed)
1717
- feat(android): Expose `enableAnrFingerprinting` option ([#5838](https://github.com/getsentry/sentry-react-native/issues/5838))
18+
- Show feedback widget on device shake ([#5754](https://github.com/getsentry/sentry-react-native/pull/5754))
19+
- Use `Sentry.enableFeedbackOnShake()` / `Sentry.disableFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`
1820

1921
### Fixes
2022

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import io.sentry.android.core.InternalSentrySdk;
4646
import io.sentry.android.core.SentryAndroidDateProvider;
4747
import io.sentry.android.core.SentryAndroidOptions;
48+
import io.sentry.android.core.SentryShakeDetector;
4849
import io.sentry.android.core.ViewHierarchyEventProcessor;
4950
import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader;
5051
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
@@ -122,6 +123,9 @@ public class RNSentryModuleImpl {
122123

123124
private final @NotNull Runnable emitNewFrameEvent;
124125

126+
private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
127+
private @Nullable SentryShakeDetector shakeDetector;
128+
125129
/** Max trace file size in bytes. */
126130
private long maxTraceFileSize = 5 * 1024 * 1024;
127131

@@ -208,10 +212,58 @@ public void addListener(String eventType) {
208212
}
209213

210214
public void removeListeners(double id) {
211-
// Is must be defined otherwise the generated interface from TS won't be
212-
// fulfilled
213-
logger.log(
214-
SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!");
215+
// removeListeners does not carry event-type information, so it cannot be used
216+
// to track shake listeners selectively. Shake detection is managed exclusively
217+
// via enableShakeDetection / disableShakeDetection.
218+
}
219+
220+
private void startShakeDetection() {
221+
if (shakeDetector != null) {
222+
return;
223+
}
224+
225+
try { // NOPMD - We don't want to crash in any case
226+
final ReactApplicationContext context = getReactApplicationContext();
227+
shakeDetector = new SentryShakeDetector(logger);
228+
shakeDetector.start(
229+
context,
230+
() -> {
231+
try { // NOPMD - We don't want to crash in any case
232+
final ReactApplicationContext ctx = getReactApplicationContext();
233+
if (ctx.hasActiveReactInstance()) {
234+
ctx.getJSModule(
235+
com.facebook.react.modules.core.DeviceEventManagerModule
236+
.RCTDeviceEventEmitter.class)
237+
.emit(ON_SHAKE_EVENT, null);
238+
}
239+
} catch (Throwable e) { // NOPMD - We don't want to crash in any case
240+
logger.log(SentryLevel.WARNING, "Failed to emit shake event.", e);
241+
}
242+
});
243+
} catch (Throwable e) { // NOPMD - We don't want to crash in any case
244+
logger.log(SentryLevel.WARNING, "Failed to start shake detection.", e);
245+
shakeDetector = null;
246+
}
247+
}
248+
249+
private void stopShakeDetection() {
250+
try { // NOPMD - We don't want to crash in any case
251+
if (shakeDetector != null) {
252+
shakeDetector.stop();
253+
shakeDetector = null;
254+
}
255+
} catch (Throwable e) { // NOPMD - We don't want to crash in any case
256+
logger.log(SentryLevel.WARNING, "Failed to stop shake detection.", e);
257+
shakeDetector = null;
258+
}
259+
}
260+
261+
public void enableShakeDetection() {
262+
startShakeDetection();
263+
}
264+
265+
public void disableShakeDetection() {
266+
stopShakeDetection();
215267
}
216268

217269
public void fetchModules(Promise promise) {

packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) {
212212
public boolean setActiveSpanId(String spanId) {
213213
return this.impl.setActiveSpanId(spanId);
214214
}
215+
216+
@Override
217+
public void enableShakeDetection() {
218+
this.impl.enableShakeDetection();
219+
}
220+
221+
@Override
222+
public void disableShakeDetection() {
223+
this.impl.disableShakeDetection();
224+
}
215225
}

packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) {
212212
public boolean setActiveSpanId(String spanId) {
213213
return this.impl.setActiveSpanId(spanId);
214214
}
215+
216+
@ReactMethod
217+
public void enableShakeDetection() {
218+
this.impl.enableShakeDetection();
219+
}
220+
221+
@ReactMethod
222+
public void disableShakeDetection() {
223+
this.impl.disableShakeDetection();
224+
}
215225
}

packages/core/ios/RNSentry.mm

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060

6161
@implementation RNSentry {
6262
bool hasListeners;
63+
bool _shakeDetectionEnabled;
6364
RNSentryTimeToDisplay *_timeToDisplay;
6465
NSArray<NSString *> *_ignoreErrorPatternsStr;
6566
NSArray<NSRegularExpression *> *_ignoreErrorPatternsRegex;
@@ -295,9 +296,54 @@ - (void)stopObserving
295296
[[RNSentryNativeLogsForwarder shared] stopForwarding];
296297
}
297298

299+
- (void)handleShakeDetected
300+
{
301+
if (_shakeDetectionEnabled) {
302+
[self sendEventWithName:RNSentryOnShakeEvent body:@{}];
303+
}
304+
}
305+
306+
// SentryShakeDetector is a Swift class; its notification name and methods are accessed
307+
// via the raw string / NSClassFromString to avoid requiring @import Sentry in this .mm file.
308+
static NSNotificationName const RNSentryShakeNotification = @"SentryShakeDetected";
309+
310+
RCT_EXPORT_METHOD(enableShakeDetection)
311+
{
312+
[[NSNotificationCenter defaultCenter] removeObserver:self
313+
name:RNSentryShakeNotification
314+
object:nil];
315+
[[NSNotificationCenter defaultCenter] addObserver:self
316+
selector:@selector(handleShakeDetected)
317+
name:RNSentryShakeNotification
318+
object:nil];
319+
Class shakeDetector = NSClassFromString(@"SentryShakeDetector");
320+
if (shakeDetector) {
321+
#pragma clang diagnostic push
322+
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
323+
[shakeDetector performSelector:@selector(enable)];
324+
#pragma clang diagnostic pop
325+
}
326+
_shakeDetectionEnabled = YES;
327+
}
328+
329+
RCT_EXPORT_METHOD(disableShakeDetection)
330+
{
331+
_shakeDetectionEnabled = NO;
332+
Class shakeDetector = NSClassFromString(@"SentryShakeDetector");
333+
if (shakeDetector) {
334+
#pragma clang diagnostic push
335+
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
336+
[shakeDetector performSelector:@selector(disable)];
337+
#pragma clang diagnostic pop
338+
}
339+
[[NSNotificationCenter defaultCenter] removeObserver:self
340+
name:RNSentryShakeNotification
341+
object:nil];
342+
}
343+
298344
- (NSArray<NSString *> *)supportedEvents
299345
{
300-
return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent ];
346+
return @[ RNSentryNewFrameEvent, RNSentryNativeLogEvent, RNSentryOnShakeEvent ];
301347
}
302348

303349
RCT_EXPORT_METHOD(

packages/core/ios/RNSentryEvents.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#import <Foundation/Foundation.h>
22

33
extern NSString *const RNSentryNewFrameEvent;
4+
extern NSString *const RNSentryOnShakeEvent;
45
extern NSString *const RNSentryNativeLogEvent;

packages/core/ios/RNSentryEvents.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#import "RNSentryEvents.h"
22

33
NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
4+
NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake";
45
NSString *const RNSentryNativeLogEvent = @"SentryNativeLog";

packages/core/src/js/NativeRNSentry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export interface Spec extends TurboModule {
5454
popTimeToDisplayFor(key: string): Promise<number | undefined | null>;
5555
setActiveSpanId(spanId: string): boolean;
5656
encodeToBase64(data: number[]): Promise<string | undefined | null>;
57+
enableShakeDetection(): void;
58+
disableShakeDetection(): void;
5759
}
5860

5961
export type NativeStackFrame = {

packages/core/src/js/feedback/FeedbackWidgetManager.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { debug } from '@sentry/core';
22
import { isWeb } from '../utils/environment';
33
import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
4+
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
45

56
export const PULL_DOWN_CLOSE_THRESHOLD = 200;
67
export const SLIDE_ANIMATION_DURATION = 200;
@@ -132,4 +133,20 @@ const resetScreenshotButtonManager = (): void => {
132133
ScreenshotButtonManager.reset();
133134
};
134135

135-
export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
136+
let _imperativeShakeListenerStarted = false;
137+
138+
const enableFeedbackOnShake = (): void => {
139+
lazyLoadAutoInjectFeedbackIntegration();
140+
if (!_imperativeShakeListenerStarted) {
141+
_imperativeShakeListenerStarted = startShakeListener(showFeedbackWidget);
142+
}
143+
};
144+
145+
const disableFeedbackOnShake = (): void => {
146+
if (_imperativeShakeListenerStarted) {
147+
stopShakeListener();
148+
_imperativeShakeListenerStarted = false;
149+
}
150+
};
151+
152+
export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, enableFeedbackOnShake, disableFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };

packages/core/src/js/feedback/FeedbackWidgetProvider.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import {
1313
FeedbackWidgetManager,
1414
PULL_DOWN_CLOSE_THRESHOLD,
1515
ScreenshotButtonManager,
16+
showFeedbackWidget,
1617
SLIDE_ANIMATION_DURATION,
1718
} from './FeedbackWidgetManager';
18-
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
19+
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, isShakeToReportEnabled } from './integration';
1920
import { ScreenshotButton } from './ScreenshotButton';
21+
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
2022
import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';
2123

2224
const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
@@ -51,6 +53,7 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
5153
};
5254

5355
private _themeListener: NativeEventSubscription | undefined;
56+
private _startedShakeListener: boolean = false;
5457

5558
private _panResponder = PanResponder.create({
5659
onStartShouldSetPanResponder: (_, gestureState) => {
@@ -92,21 +95,29 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
9295
}
9396

9497
/**
95-
* Add a listener to the theme change event.
98+
* Add a listener to the theme change event and start shake detection if configured.
9699
*/
97100
public componentDidMount(): void {
98101
this._themeListener = Appearance.addChangeListener(() => {
99102
this.forceUpdate();
100103
});
104+
105+
if (isShakeToReportEnabled()) {
106+
this._startedShakeListener = startShakeListener(showFeedbackWidget);
107+
}
101108
}
102109

103110
/**
104-
* Clean up the theme listener.
111+
* Clean up the theme listener and stop shake detection.
105112
*/
106113
public componentWillUnmount(): void {
107114
if (this._themeListener) {
108115
this._themeListener.remove();
109116
}
117+
118+
if (this._startedShakeListener) {
119+
stopShakeListener();
120+
}
110121
}
111122

112123
/**

0 commit comments

Comments
 (0)