@bugsession/react-native 0.1.0-beta.3 → 0.1.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/capture.d.ts +12 -0
- package/dist/core.d.ts +33 -1
- package/dist/core.js +65 -2
- package/dist/index.d.ts +18 -11
- package/dist/index.js +153 -113
- package/dist/trigger.d.ts +55 -0
- package/dist/trigger.js +297 -0
- package/package.json +8 -2
- package/src/capture.ts +6 -0
- package/src/core.ts +66 -2
- package/src/index.tsx +159 -124
- package/src/trigger.tsx +364 -0
package/src/index.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Dimensions, Pressable, StyleSheet, Text, View, type PressableProps, Platform } from 'react-native';
|
|
3
|
+
import { createTrigger, type IndicatorPhase } from './trigger';
|
|
4
|
+
export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
|
|
5
|
+
import { Collector, instrumentFetch, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
|
|
4
6
|
import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
|
|
5
7
|
export type { NativeCaptureAdapter } from './capture';
|
|
6
|
-
export { Collector, SessionClock, safeText, safeUrl } from './core';
|
|
8
|
+
export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
|
|
7
9
|
|
|
8
10
|
export type AudioAdapter = {
|
|
9
11
|
prepare(): Promise<void>;
|
|
@@ -11,31 +13,75 @@ export type AudioAdapter = {
|
|
|
11
13
|
stop(): Promise<string | null>;
|
|
12
14
|
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
13
15
|
};
|
|
14
|
-
export type BugSessionOptions = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
export type BugSessionOptions = {
|
|
17
|
+
recorderUrl: string;
|
|
18
|
+
recorderToken?: string;
|
|
19
|
+
audio?: AudioAdapter;
|
|
20
|
+
capture?: NativeCaptureAdapter;
|
|
21
|
+
device: Record<string, unknown>;
|
|
22
|
+
application: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Seconds of app events (logs, errors, network metadata, actions, navigation; never video or audio)
|
|
25
|
+
* kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
|
|
26
|
+
*/
|
|
27
|
+
preRecordSeconds?: number;
|
|
23
28
|
};
|
|
29
|
+
export type BugSessionErrorBoundaryProps = { error: Error; retry: () => void | Promise<void> };
|
|
24
30
|
type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
|
|
25
|
-
|
|
31
|
+
type RejectionTracker = { allRejections: boolean; onUnhandled(id: number, rejection: unknown): void; onHandled(id: number): void };
|
|
32
|
+
const globals = globalThis as typeof globalThis & {
|
|
33
|
+
ErrorUtils?: ErrorUtilsType;
|
|
34
|
+
HermesInternal?: { enablePromiseRejectionTracker?(options: RejectionTracker): void };
|
|
35
|
+
};
|
|
26
36
|
let options: BugSessionOptions;
|
|
27
37
|
let collector: Collector | null = null;
|
|
38
|
+
const telemetry = new Telemetry();
|
|
39
|
+
let installed = false;
|
|
40
|
+
let preRecordEvents: Evidence[] = [];
|
|
28
41
|
let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
|
|
29
42
|
let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
|
|
30
|
-
let restore: (() => void) | null = null;
|
|
31
43
|
let currentScreen = 'Home';
|
|
32
44
|
let rawFetch: typeof fetch;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
// Status shown by the trigger's indicator; also reflects start()/stop() called directly by the app.
|
|
46
|
+
let phase: IndicatorPhase = { kind: 'idle' };
|
|
47
|
+
let phaseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
const phaseListeners = new Set<() => void>();
|
|
49
|
+
function setPhase(next: IndicatorPhase) {
|
|
50
|
+
phase = next;
|
|
51
|
+
clearTimeout(phaseTimer);
|
|
52
|
+
if (next.kind === 'sent' || next.kind === 'failed') phaseTimer = setTimeout(() => setPhase({ kind: 'idle' }), next.kind === 'sent' ? 3000 : 6000);
|
|
53
|
+
phaseListeners.forEach(listener => listener());
|
|
54
|
+
}
|
|
55
|
+
function compact(values: Record<string, unknown>) {
|
|
56
|
+
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* App and device facts for the recording's metadata. Native values (bundle id, hardware
|
|
60
|
+
* model) come from the capture module when available; values passed to configure() win.
|
|
61
|
+
*/
|
|
62
|
+
async function recordingContext() {
|
|
63
|
+
const constants = (Platform.constants ?? {}) as Record<string, unknown>;
|
|
64
|
+
const rn = constants.reactNativeVersion as { major: number; minor: number; patch: number } | undefined;
|
|
65
|
+
const screen = Dimensions.get('screen');
|
|
66
|
+
let locale: string | undefined;
|
|
67
|
+
let timeZone: string | undefined;
|
|
68
|
+
try { ({ locale, timeZone } = Intl.DateTimeFormat().resolvedOptions()); } catch { /* Intl unavailable */ }
|
|
69
|
+
const device: Record<string, unknown> = {
|
|
70
|
+
platform: Platform.OS,
|
|
71
|
+
osVersion: Platform.OS === 'android' ? constants.Release : String(Platform.Version),
|
|
72
|
+
screen: { width: Math.round(screen.width), height: Math.round(screen.height), scale: screen.scale },
|
|
73
|
+
locale, timeZone,
|
|
74
|
+
...(Platform.OS === 'android' ? { model: constants.Model, manufacturer: constants.Manufacturer } : { idiom: constants.interfaceIdiom }),
|
|
75
|
+
};
|
|
76
|
+
const application: Record<string, unknown> = {
|
|
77
|
+
reactNative: rn ? `${rn.major}.${rn.minor}.${rn.patch}` : undefined,
|
|
78
|
+
jsEngine: globals.HermesInternal ? 'hermes' : 'jsc',
|
|
79
|
+
buildType: __DEV__ ? 'development' : 'release',
|
|
80
|
+
};
|
|
81
|
+
const native = await options.capture?.appContext?.().catch(() => null) ?? null;
|
|
36
82
|
return {
|
|
37
|
-
|
|
38
|
-
|
|
83
|
+
device: compact({ ...device, ...native?.device, ...options.device }),
|
|
84
|
+
application: compact({ ...application, ...native?.application, ...options.application }),
|
|
39
85
|
};
|
|
40
86
|
}
|
|
41
87
|
function authHeaders(): Record<string, string> {
|
|
@@ -58,38 +104,60 @@ async function request(path: string, data?: unknown) {
|
|
|
58
104
|
if (!response.ok) throw new Error(`Recorder ${response.status}: ${await response.text()}`);
|
|
59
105
|
return response.json();
|
|
60
106
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const level of ['log', 'warn', 'error'] as const) {
|
|
107
|
+
// Installed once at configure() for the app's lifetime, so events from before a
|
|
108
|
+
// recording starts can be attached to it (see Telemetry).
|
|
109
|
+
function install() {
|
|
110
|
+
installed = true;
|
|
111
|
+
globalThis.fetch = instrumentFetch(rawFetch, telemetry);
|
|
112
|
+
for (const level of ['log', 'info', 'debug', 'warn', 'error'] as const) {
|
|
113
|
+
const original = console[level];
|
|
67
114
|
console[level] = (...args: unknown[]) => {
|
|
68
|
-
|
|
69
|
-
|
|
115
|
+
if (telemetry.active) {
|
|
116
|
+
const error = args.find((a): a is Error => a instanceof Error);
|
|
117
|
+
telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: error?.stack ? safeText(error.stack) : undefined, screen: currentScreen });
|
|
118
|
+
}
|
|
119
|
+
original(...args);
|
|
70
120
|
};
|
|
71
121
|
}
|
|
72
122
|
const utils = globals.ErrorUtils;
|
|
73
123
|
const handler = utils?.getGlobalHandler();
|
|
74
|
-
|
|
124
|
+
if (utils && handler) utils.setGlobalHandler((error, fatal) => {
|
|
75
125
|
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
76
|
-
handler
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
126
|
+
handler(error, fatal);
|
|
127
|
+
});
|
|
128
|
+
// React Native only tracks unhandled rejections in development, and reports them
|
|
129
|
+
// through ExceptionsManager rather than the global handler. Track them in every
|
|
130
|
+
// build, and keep React Native's development behavior (LogBox) by forwarding.
|
|
131
|
+
globals.HermesInternal?.enablePromiseRejectionTracker?.({
|
|
132
|
+
allRejections: true,
|
|
133
|
+
onUnhandled: (id, rejection) => {
|
|
134
|
+
captureError(rejection instanceof Error ? rejection : new Error(rejectionMessage(rejection)), 'unhandled-rejection');
|
|
135
|
+
if (__DEV__) handler?.(new Error(`Uncaught (in promise, id: ${id}): "${rejectionMessage(rejection)}"`), false);
|
|
136
|
+
},
|
|
137
|
+
onHandled: id => {
|
|
138
|
+
if (__DEV__) console.warn(`Promise rejection handled (id: ${id})`);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function rejectionMessage(rejection: unknown) {
|
|
143
|
+
if (rejection instanceof Error) return rejection.toString();
|
|
144
|
+
if (typeof rejection === 'string') return rejection;
|
|
145
|
+
try { return JSON.stringify(rejection) ?? String(rejection); } catch { return String(rejection); }
|
|
84
146
|
}
|
|
85
147
|
function captureError(error: unknown, source = 'boundary') {
|
|
86
|
-
|
|
148
|
+
if (!telemetry.active) return;
|
|
149
|
+
telemetry.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
|
|
87
150
|
}
|
|
88
151
|
|
|
89
152
|
export const BugSession = {
|
|
90
153
|
configure(config: BugSessionOptions) {
|
|
91
154
|
if (state !== 'idle') throw new Error('Cannot configure an active BugSession');
|
|
92
155
|
options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
|
|
156
|
+
telemetry.configure((config.preRecordSeconds ?? 60) * 1000);
|
|
157
|
+
if (!installed) {
|
|
158
|
+
rawFetch = globalThis.fetch.bind(globalThis);
|
|
159
|
+
install();
|
|
160
|
+
}
|
|
93
161
|
},
|
|
94
162
|
get status() { return state; },
|
|
95
163
|
async start() {
|
|
@@ -99,7 +167,7 @@ export const BugSession = {
|
|
|
99
167
|
if (Platform.OS === 'android' && !options.audio) throw new Error('Android needs a microphone adapter');
|
|
100
168
|
if (state !== 'idle') throw new Error(`BugSession is ${state}`);
|
|
101
169
|
state = 'starting';
|
|
102
|
-
|
|
170
|
+
setPhase({ kind: 'starting' });
|
|
103
171
|
let createdId: string | null = null;
|
|
104
172
|
let audioStarted = false;
|
|
105
173
|
const isIOS = Platform.OS === 'ios';
|
|
@@ -112,7 +180,8 @@ export const BugSession = {
|
|
|
112
180
|
const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
|
|
113
181
|
collector = new Collector(clock);
|
|
114
182
|
const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
|
|
115
|
-
const
|
|
183
|
+
const context = await recordingContext();
|
|
184
|
+
const result = await request('/sessions/start', { device: context.device, application: context.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
|
|
116
185
|
createdId = result.id;
|
|
117
186
|
const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
|
|
118
187
|
const audioStartMs = clock.now();
|
|
@@ -126,24 +195,32 @@ export const BugSession = {
|
|
|
126
195
|
uncertaintyMs: best.rtt / 2, captureBackend,
|
|
127
196
|
...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
|
|
128
197
|
};
|
|
129
|
-
|
|
198
|
+
preRecordEvents = telemetry.begin(collector);
|
|
130
199
|
state = 'recording';
|
|
200
|
+
setPhase({ kind: 'recording', startedAt: Date.now() });
|
|
131
201
|
collector.add('navigation', { from: null, to: currentScreen });
|
|
132
202
|
return session.id;
|
|
133
203
|
} catch (error) {
|
|
134
204
|
if (useNativeCapture) await options.capture?.cancel().catch(() => {});
|
|
135
205
|
if (audioStarted) await options.audio!.stop().catch(() => null);
|
|
136
206
|
if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
|
|
137
|
-
|
|
207
|
+
telemetry.end(); preRecordEvents = [];
|
|
208
|
+
collector = null; session = null; state = 'idle';
|
|
209
|
+
setPhase({ kind: 'failed', message: safeText(error) });
|
|
210
|
+
throw error;
|
|
138
211
|
}
|
|
139
212
|
},
|
|
140
213
|
async stop() {
|
|
141
214
|
if (state !== 'recording' || !session || !collector) throw new Error('No recording is active');
|
|
142
215
|
state = 'stopping';
|
|
216
|
+
setPhase({ kind: 'uploading' });
|
|
143
217
|
const c = collector;
|
|
144
218
|
const active = session;
|
|
145
219
|
c.add('session-stop', {});
|
|
146
|
-
|
|
220
|
+
telemetry.end();
|
|
221
|
+
// Sent separately from `events` so recorders that predate pre-record support ignore them.
|
|
222
|
+
const before = preRecordEvents;
|
|
223
|
+
preRecordEvents = [];
|
|
147
224
|
try {
|
|
148
225
|
let media: Record<string, unknown>;
|
|
149
226
|
if (active.captureBackend === 'replaykit') {
|
|
@@ -169,25 +246,55 @@ export const BugSession = {
|
|
|
169
246
|
await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
170
247
|
media = { ...active, audioEndMs };
|
|
171
248
|
}
|
|
172
|
-
|
|
173
|
-
durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
|
|
249
|
+
const result = await request(`/sessions/${active.id}/stop`, {
|
|
250
|
+
durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped, preRecordEvents: before,
|
|
174
251
|
media,
|
|
175
252
|
});
|
|
253
|
+
setPhase({ kind: 'sent' });
|
|
254
|
+
return result;
|
|
176
255
|
} catch (error) {
|
|
177
256
|
if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
|
|
178
257
|
// Release the device recorder, but preserve partial files for recovery.
|
|
179
|
-
await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
|
|
258
|
+
await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, preRecordEvents: before, media: active, durationMs: c.clock.now() }).catch(() => {});
|
|
259
|
+
setPhase({ kind: 'failed', message: safeText(error) });
|
|
180
260
|
throw error;
|
|
181
261
|
} finally { collector = null; session = null; state = 'idle'; }
|
|
182
262
|
},
|
|
183
263
|
trackAction(action: { name: string; screen?: string }) {
|
|
184
|
-
|
|
264
|
+
if (telemetry.active) telemetry.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
|
|
185
265
|
},
|
|
186
266
|
trackNavigation(screen: string) {
|
|
187
|
-
if (currentScreen !== screen)
|
|
267
|
+
if (currentScreen !== screen && telemetry.active) telemetry.add('navigation', { from: currentScreen, to: screen });
|
|
188
268
|
currentScreen = screen;
|
|
189
269
|
},
|
|
190
270
|
captureError,
|
|
271
|
+
Trigger: createTrigger({
|
|
272
|
+
getPhase: () => phase,
|
|
273
|
+
subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
|
|
274
|
+
async toggle() {
|
|
275
|
+
if (state === 'recording') await BugSession.stop();
|
|
276
|
+
else if (state === 'idle') await BugSession.start();
|
|
277
|
+
},
|
|
278
|
+
fail(error) { if (phase.kind !== 'failed') setPhase({ kind: 'failed', message: safeText(error) }); },
|
|
279
|
+
onAudioLevel: listener => options?.capture?.onAudioLevel?.(listener) ?? null,
|
|
280
|
+
safeAreaInsets: async () => options?.capture?.safeAreaInsets ? options.capture.safeAreaInsets() : null,
|
|
281
|
+
annotate(annotation) { if (state === 'recording') telemetry.add('annotation', { ...annotation, screen: currentScreen }); },
|
|
282
|
+
}),
|
|
283
|
+
/**
|
|
284
|
+
* Expo Router error boundary that reports render errors (which never reach the
|
|
285
|
+
* global error handler). In a route or layout file:
|
|
286
|
+
* `export const ErrorBoundary = BugSession.ErrorBoundary;`
|
|
287
|
+
*/
|
|
288
|
+
ErrorBoundary({ error, retry }: BugSessionErrorBoundaryProps) {
|
|
289
|
+
React.useEffect(() => { captureError(error, 'render'); }, [error]);
|
|
290
|
+
return <View style={styles.boundary}>
|
|
291
|
+
<Text style={styles.boundaryTitle}>Something went wrong</Text>
|
|
292
|
+
<Text style={styles.boundaryMessage}>{error.message}</Text>
|
|
293
|
+
<Pressable accessibilityRole="button" onPress={() => { void retry(); }} style={styles.boundaryRetry}>
|
|
294
|
+
<Text style={styles.boundaryRetryText}>Try again</Text>
|
|
295
|
+
</Pressable>
|
|
296
|
+
</View>;
|
|
297
|
+
},
|
|
191
298
|
/** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
|
|
192
299
|
navigationListener(ref: { getCurrentRoute(): { name: string } | undefined }) {
|
|
193
300
|
return () => { const route = ref.getCurrentRoute(); if (route) BugSession.trackNavigation(route.name); };
|
|
@@ -195,84 +302,12 @@ export const BugSession = {
|
|
|
195
302
|
Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
|
|
196
303
|
return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
|
|
197
304
|
},
|
|
198
|
-
Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
|
|
199
|
-
const { width, height } = useWindowDimensions();
|
|
200
|
-
const [point, setPoint] = React.useState(() => triggerPosition
|
|
201
|
-
? clampTriggerPosition(triggerPosition, width, height)
|
|
202
|
-
: clampTriggerPosition({
|
|
203
|
-
left: position.includes('right') ? width - triggerSize - offset : offset,
|
|
204
|
-
top: position.includes('bottom') ? height - triggerSize - offset : offset,
|
|
205
|
-
}, width, height));
|
|
206
|
-
const pointRef = React.useRef(point);
|
|
207
|
-
pointRef.current = point;
|
|
208
|
-
const drag = React.useRef<Animated.ValueXY | null>(null);
|
|
209
|
-
if (!drag.current) drag.current = new Animated.ValueXY();
|
|
210
|
-
const dragStart = React.useRef(point);
|
|
211
|
-
React.useEffect(() => {
|
|
212
|
-
const next = clampTriggerPosition(pointRef.current, width, height);
|
|
213
|
-
pointRef.current = next;
|
|
214
|
-
setPoint(next);
|
|
215
|
-
if (triggerPosition) triggerPosition = next;
|
|
216
|
-
drag.current?.setValue({ x: 0, y: 0 });
|
|
217
|
-
}, [width, height]);
|
|
218
|
-
const responder = React.useMemo(() => PanResponder.create({
|
|
219
|
-
onStartShouldSetPanResponder: () => false,
|
|
220
|
-
onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
|
|
221
|
-
onPanResponderGrant: () => { dragStart.current = pointRef.current; },
|
|
222
|
-
onPanResponderMove: (_, gesture) => {
|
|
223
|
-
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
224
|
-
drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
|
|
225
|
-
},
|
|
226
|
-
onPanResponderRelease: (_, gesture) => {
|
|
227
|
-
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
228
|
-
triggerPosition = next;
|
|
229
|
-
pointRef.current = next;
|
|
230
|
-
setPoint(next);
|
|
231
|
-
drag.current?.setValue({ x: 0, y: 0 });
|
|
232
|
-
},
|
|
233
|
-
onPanResponderTerminate: (_, gesture) => {
|
|
234
|
-
const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
|
|
235
|
-
triggerPosition = next;
|
|
236
|
-
pointRef.current = next;
|
|
237
|
-
setPoint(next);
|
|
238
|
-
drag.current?.setValue({ x: 0, y: 0 });
|
|
239
|
-
},
|
|
240
|
-
}), [draggable, width, height]);
|
|
241
|
-
const [busy, setBusy] = React.useState(false);
|
|
242
|
-
const recording = state === 'recording';
|
|
243
|
-
const toggle = async () => {
|
|
244
|
-
if (busy) return;
|
|
245
|
-
setBusy(true);
|
|
246
|
-
try {
|
|
247
|
-
if (state === 'recording') await BugSession.stop();
|
|
248
|
-
else await BugSession.start();
|
|
249
|
-
} catch (error) { onError?.(error); }
|
|
250
|
-
finally { setBusy(false); }
|
|
251
|
-
};
|
|
252
|
-
return <View pointerEvents="box-none" style={styles.triggerLayer}>
|
|
253
|
-
<Animated.View {...(draggable ? responder.panHandlers : {})} style={[styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }]}>
|
|
254
|
-
<Pressable
|
|
255
|
-
accessibilityRole="button"
|
|
256
|
-
accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
|
|
257
|
-
accessibilityHint={draggable ? 'Drag to move the BugSession button' : undefined}
|
|
258
|
-
accessibilityState={{ busy, selected: recording }}
|
|
259
|
-
disabled={busy}
|
|
260
|
-
onPress={() => { void toggle(); }}
|
|
261
|
-
style={[styles.trigger, recording && styles.triggerRecording, style]}
|
|
262
|
-
>
|
|
263
|
-
{children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
|
|
264
|
-
</Pressable>
|
|
265
|
-
</Animated.View>
|
|
266
|
-
</View>;
|
|
267
|
-
},
|
|
268
305
|
};
|
|
269
306
|
|
|
270
307
|
const styles = StyleSheet.create({
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
|
|
277
|
-
triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
|
|
308
|
+
boundary: { flex: 1, justifyContent: 'center', padding: 24, gap: 12, backgroundColor: '#FFFFFF' },
|
|
309
|
+
boundaryTitle: { fontSize: 22, fontWeight: '700', color: '#111111' },
|
|
310
|
+
boundaryMessage: { fontSize: 15, color: '#555555' },
|
|
311
|
+
boundaryRetry: { alignSelf: 'flex-start', paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#173B34' },
|
|
312
|
+
boundaryRetryText: { color: '#F4F4EC', fontWeight: '700' },
|
|
278
313
|
});
|