@bugsession/react-native 0.1.0-beta.3 → 0.1.0-beta.5

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/dist/index.js CHANGED
@@ -3,31 +3,75 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.BugSession = exports.safeUrl = exports.safeText = exports.SessionClock = exports.Collector = void 0;
6
+ exports.BugSession = exports.safeUrl = exports.safeText = exports.Telemetry = exports.SessionClock = exports.Collector = void 0;
7
7
  const jsx_runtime_1 = require("react/jsx-runtime");
8
8
  const react_1 = __importDefault(require("react"));
9
9
  const react_native_1 = require("react-native");
10
+ const trigger_1 = require("./trigger");
10
11
  const core_1 = require("./core");
12
+ const touches_1 = require("./touches");
11
13
  const capture_1 = require("./capture");
12
14
  var core_2 = require("./core");
13
15
  Object.defineProperty(exports, "Collector", { enumerable: true, get: function () { return core_2.Collector; } });
14
16
  Object.defineProperty(exports, "SessionClock", { enumerable: true, get: function () { return core_2.SessionClock; } });
17
+ Object.defineProperty(exports, "Telemetry", { enumerable: true, get: function () { return core_2.Telemetry; } });
15
18
  Object.defineProperty(exports, "safeText", { enumerable: true, get: function () { return core_2.safeText; } });
16
19
  Object.defineProperty(exports, "safeUrl", { enumerable: true, get: function () { return core_2.safeUrl; } });
17
20
  const globals = globalThis;
18
21
  let options;
19
22
  let collector = null;
23
+ const telemetry = new core_1.Telemetry();
24
+ let installed = false;
25
+ let preRecordEvents = [];
26
+ const MAX_POST_RECORD_SECONDS = 30;
20
27
  let session = null;
21
28
  let state = 'idle';
22
- let restore = null;
23
29
  let currentScreen = 'Home';
24
30
  let rawFetch;
25
- let triggerPosition = null;
26
- const triggerSize = 58;
27
- function clampTriggerPosition(point, width, height) {
31
+ // Status shown by the trigger's indicator; also reflects start()/stop() called directly by the app.
32
+ let phase = { kind: 'idle' };
33
+ let phaseTimer;
34
+ const phaseListeners = new Set();
35
+ function setPhase(next) {
36
+ phase = next;
37
+ clearTimeout(phaseTimer);
38
+ if (next.kind === 'sent' || next.kind === 'failed')
39
+ phaseTimer = setTimeout(() => setPhase({ kind: 'idle' }), next.kind === 'sent' ? 3000 : 6000);
40
+ phaseListeners.forEach(listener => listener());
41
+ }
42
+ function compact(values) {
43
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
44
+ }
45
+ /**
46
+ * App and device facts for the recording's metadata. Native values (bundle id, hardware
47
+ * model) come from the capture module when available; values passed to configure() win.
48
+ */
49
+ async function recordingContext() {
50
+ const constants = (react_native_1.Platform.constants ?? {});
51
+ const rn = constants.reactNativeVersion;
52
+ const screen = react_native_1.Dimensions.get('screen');
53
+ let locale;
54
+ let timeZone;
55
+ try {
56
+ ({ locale, timeZone } = Intl.DateTimeFormat().resolvedOptions());
57
+ }
58
+ catch { /* Intl unavailable */ }
59
+ const device = {
60
+ platform: react_native_1.Platform.OS,
61
+ osVersion: react_native_1.Platform.OS === 'android' ? constants.Release : String(react_native_1.Platform.Version),
62
+ screen: { width: Math.round(screen.width), height: Math.round(screen.height), scale: screen.scale },
63
+ locale, timeZone,
64
+ ...(react_native_1.Platform.OS === 'android' ? { model: constants.Model, manufacturer: constants.Manufacturer } : { idiom: constants.interfaceIdiom }),
65
+ };
66
+ const application = {
67
+ reactNative: rn ? `${rn.major}.${rn.minor}.${rn.patch}` : undefined,
68
+ jsEngine: globals.HermesInternal ? 'hermes' : 'jsc',
69
+ buildType: __DEV__ ? 'development' : 'release',
70
+ };
71
+ const native = await options.capture?.appContext?.().catch(() => null) ?? null;
28
72
  return {
29
- left: Math.max(0, Math.min(point.left, Math.max(0, width - triggerSize))),
30
- top: Math.max(0, Math.min(point.top, Math.max(0, height - triggerSize))),
73
+ device: compact({ ...device, ...native?.device, ...options.device }),
74
+ application: compact({ ...application, ...native?.application, ...options.application }),
31
75
  };
32
76
  }
33
77
  function authHeaders() {
@@ -54,42 +98,91 @@ async function request(path, data) {
54
98
  throw new Error(`Recorder ${response.status}: ${await response.text()}`);
55
99
  return response.json();
56
100
  }
57
- function install(c) {
58
- const originalFetch = globalThis.fetch;
59
- const wrappedFetch = (0, core_1.instrumentFetch)(rawFetch, c);
60
- globalThis.fetch = wrappedFetch;
61
- const originals = { log: console.log, warn: console.warn, error: console.error };
62
- for (const level of ['log', 'warn', 'error']) {
101
+ // Installed once at configure() for the app's lifetime, so events from before a
102
+ // recording starts can be attached to it (see Telemetry).
103
+ function install() {
104
+ installed = true;
105
+ // The recorder itself, and in development Metro's own symbolication and log requests.
106
+ (0, core_1.ignoreRequests)(url => (!!options && url.startsWith(options.recorderUrl)) || (__DEV__ && /\/(symbolicate|logs)(\?|$)/.test(url)));
107
+ globalThis.fetch = (0, core_1.instrumentFetch)(rawFetch, telemetry);
108
+ (0, core_1.instrumentXhr)(globalThis.XMLHttpRequest, telemetry);
109
+ for (const level of ['log', 'info', 'debug', 'warn', 'error']) {
110
+ const original = console[level];
63
111
  console[level] = (...args) => {
64
- c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(core_1.safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? (0, core_1.safeText)(args.find(a => a instanceof Error).stack) : undefined });
65
- originals[level](...args);
112
+ if (telemetry.active && !(level === 'error' && repeatsCapturedError(args))) {
113
+ const error = args.find((a) => a instanceof Error);
114
+ telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(core_1.safeText).join(' '), source: 'console', stack: error?.stack ? (0, core_1.safeText)(error.stack) : undefined, screen: currentScreen });
115
+ }
116
+ original(...args);
66
117
  };
67
118
  }
68
119
  const utils = globals.ErrorUtils;
69
120
  const handler = utils?.getGlobalHandler();
70
- const onError = (error, fatal) => {
71
- captureError(error, fatal ? 'fatal' : 'uncaught');
72
- handler?.(error, fatal);
73
- };
74
121
  if (utils && handler)
75
- utils.setGlobalHandler(onError);
76
- return () => {
77
- if (globalThis.fetch === wrappedFetch)
78
- globalThis.fetch = originalFetch;
79
- for (const level of ['log', 'warn', 'error'])
80
- console[level] = originals[level];
81
- if (utils?.getGlobalHandler() === onError && handler)
82
- utils.setGlobalHandler(handler);
83
- };
122
+ utils.setGlobalHandler((error, fatal) => {
123
+ captureError(error, fatal ? 'fatal' : 'uncaught');
124
+ handler(error, fatal);
125
+ });
126
+ // React Native only tracks unhandled rejections in development, and reports them
127
+ // through ExceptionsManager rather than the global handler. Track them in every
128
+ // build, and keep React Native's development behavior (LogBox) by forwarding.
129
+ globals.HermesInternal?.enablePromiseRejectionTracker?.({
130
+ allRejections: true,
131
+ onUnhandled: (id, rejection) => {
132
+ captureError(rejection instanceof Error ? rejection : new Error(rejectionMessage(rejection)), 'unhandled-rejection');
133
+ if (__DEV__)
134
+ handler?.(new Error(`Uncaught (in promise, id: ${id}): "${rejectionMessage(rejection)}"`), false);
135
+ },
136
+ onHandled: id => {
137
+ if (__DEV__)
138
+ console.warn(`Promise rejection handled (id: ${id})`);
139
+ },
140
+ });
141
+ }
142
+ function rejectionMessage(rejection) {
143
+ if (rejection instanceof Error)
144
+ return rejection.toString();
145
+ if (typeof rejection === 'string')
146
+ return rejection;
147
+ try {
148
+ return JSON.stringify(rejection) ?? String(rejection);
149
+ }
150
+ catch {
151
+ return String(rejection);
152
+ }
153
+ }
154
+ // React Native also prints captured errors (such as unhandled rejections in development)
155
+ // through console.error; those repeats are skipped for a moment after capture.
156
+ const recentErrors = [];
157
+ function repeatsCapturedError(args) {
158
+ const now = Date.now();
159
+ while (recentErrors.length && now - recentErrors[0].at > 2000)
160
+ recentErrors.shift();
161
+ if (!recentErrors.length)
162
+ return false;
163
+ const printed = args.map(a => a instanceof Error ? a.message : typeof a === 'string' ? a : '').join(' ');
164
+ return recentErrors.some(e => e.message.length > 8 && printed.includes(e.message));
84
165
  }
85
166
  function captureError(error, source = 'boundary') {
86
- collector?.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
167
+ if (!telemetry.active)
168
+ return;
169
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
170
+ if (message && repeatsCapturedError([message]))
171
+ return;
172
+ if (message)
173
+ recentErrors.push({ message: message.slice(0, 200), at: Date.now() });
174
+ telemetry.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
87
175
  }
88
176
  exports.BugSession = {
89
177
  configure(config) {
90
178
  if (state !== 'idle')
91
179
  throw new Error('Cannot configure an active BugSession');
92
180
  options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
181
+ telemetry.configure((config.preRecordSeconds ?? 60) * 1000);
182
+ if (!installed) {
183
+ rawFetch = globalThis.fetch.bind(globalThis);
184
+ install();
185
+ }
93
186
  },
94
187
  get status() { return state; },
95
188
  async start() {
@@ -104,7 +197,7 @@ exports.BugSession = {
104
197
  if (state !== 'idle')
105
198
  throw new Error(`BugSession is ${state}`);
106
199
  state = 'starting';
107
- rawFetch = globalThis.fetch.bind(globalThis);
200
+ setPhase({ kind: 'starting' });
108
201
  let createdId = null;
109
202
  let audioStarted = false;
110
203
  const isIOS = react_native_1.Platform.OS === 'ios';
@@ -119,7 +212,8 @@ exports.BugSession = {
119
212
  const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
120
213
  collector = new core_1.Collector(clock);
121
214
  const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
122
- const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
215
+ const context = await recordingContext();
216
+ const result = await request('/sessions/start', { device: context.device, application: context.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
123
217
  createdId = result.id;
124
218
  const nativeStart = useNativeCapture ? await options.capture.start() : undefined;
125
219
  const audioStartMs = clock.now();
@@ -136,8 +230,9 @@ exports.BugSession = {
136
230
  uncertaintyMs: best.rtt / 2, captureBackend,
137
231
  ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
138
232
  };
139
- restore = install(collector);
233
+ preRecordEvents = telemetry.begin(collector);
140
234
  state = 'recording';
235
+ setPhase({ kind: 'recording', startedAt: Date.now() });
141
236
  collector.add('navigation', { from: null, to: currentScreen });
142
237
  return session.id;
143
238
  }
@@ -148,9 +243,12 @@ exports.BugSession = {
148
243
  await options.audio.stop().catch(() => null);
149
244
  if (createdId)
150
245
  await request(`/sessions/${createdId}/abort`, {}).catch(() => { });
246
+ telemetry.end();
247
+ preRecordEvents = [];
151
248
  collector = null;
152
249
  session = null;
153
250
  state = 'idle';
251
+ setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
154
252
  throw error;
155
253
  }
156
254
  },
@@ -158,11 +256,24 @@ exports.BugSession = {
158
256
  if (state !== 'recording' || !session || !collector)
159
257
  throw new Error('No recording is active');
160
258
  state = 'stopping';
259
+ setPhase({ kind: 'uploading' });
161
260
  const c = collector;
162
261
  const active = session;
163
262
  c.add('session-stop', {});
164
- restore?.();
165
- restore = null;
263
+ const stoppedAtMs = c.clock.now();
264
+ const postRecordMs = Math.max(0, Math.min(options.postRecordSeconds ?? 5, MAX_POST_RECORD_SECONDS)) * 1000;
265
+ // Sent separately from `events` so recorders that predate pre/post-record support ignore them.
266
+ const before = preRecordEvents;
267
+ preRecordEvents = [];
268
+ // App events keep flowing to this session while media uploads; anything after Stop is
269
+ // reported separately as post-recording evidence.
270
+ const split = () => {
271
+ telemetry.end();
272
+ return {
273
+ events: c.events.filter(e => e.timestampMs <= stoppedAtMs),
274
+ after: c.events.filter(e => e.timestampMs > stoppedAtMs && e.timestampMs <= stoppedAtMs + postRecordMs).slice(0, 1000).map(e => ({ ...e, postRecording: true })),
275
+ };
276
+ };
166
277
  try {
167
278
  let media;
168
279
  if (active.captureBackend === 'replaykit') {
@@ -192,33 +303,76 @@ exports.BugSession = {
192
303
  await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
193
304
  media = { ...active, audioEndMs };
194
305
  }
195
- return await request(`/sessions/${active.id}/stop`, {
196
- durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
306
+ const remainingMs = stoppedAtMs + postRecordMs - c.clock.now();
307
+ if (remainingMs > 0)
308
+ await new Promise(resolve => setTimeout(resolve, remainingMs));
309
+ const { events, after } = split();
310
+ const result = await request(`/sessions/${active.id}/stop`, {
311
+ durationMs: stoppedAtMs, events, droppedEvents: c.dropped, preRecordEvents: before,
312
+ ...(after.length ? { postRecordEvents: after } : {}),
197
313
  media,
198
314
  });
315
+ setPhase({ kind: 'sent' });
316
+ return result;
199
317
  }
200
318
  catch (error) {
201
319
  if (active.captureBackend !== 'adb')
202
320
  await options.capture.cancel().catch(() => { });
203
321
  // Release the device recorder, but preserve partial files for recovery.
204
- await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => { });
322
+ const { events, after } = split();
323
+ await request(`/sessions/${active.id}/abort`, { reason: (0, core_1.safeText)(error), events, preRecordEvents: before, ...(after.length ? { postRecordEvents: after } : {}), media: active, durationMs: stoppedAtMs }).catch(() => { });
324
+ setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
205
325
  throw error;
206
326
  }
207
327
  finally {
328
+ telemetry.end();
208
329
  collector = null;
209
330
  session = null;
210
331
  state = 'idle';
211
332
  }
212
333
  },
213
334
  trackAction(action) {
214
- collector?.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
335
+ if (telemetry.active)
336
+ telemetry.add('action', { target: (0, core_1.safeText)(action.name), screen: (0, core_1.safeText)(action.screen ?? currentScreen) });
215
337
  },
216
338
  trackNavigation(screen) {
217
- if (currentScreen !== screen)
218
- collector?.add('navigation', { from: currentScreen, to: screen });
339
+ if (currentScreen !== screen && telemetry.active)
340
+ telemetry.add('navigation', { from: currentScreen, to: screen });
219
341
  currentScreen = screen;
220
342
  },
221
343
  captureError,
344
+ /**
345
+ * Wrap the app once (inside the navigator's container, around your screens) to record
346
+ * every tap automatically: position, current screen and the element's label when available.
347
+ */
348
+ Capture: (0, touches_1.createCapture)(() => telemetry.active ? tap => {
349
+ telemetry.add('action', { target: (0, core_1.safeText)(tap.label ?? `Tap at (${tap.x}, ${tap.y})`), screen: currentScreen, x: tap.x, y: tap.y, auto: true });
350
+ } : null),
351
+ Trigger: (0, trigger_1.createTrigger)({
352
+ getPhase: () => phase,
353
+ subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
354
+ async toggle() {
355
+ if (state === 'recording')
356
+ await exports.BugSession.stop();
357
+ else if (state === 'idle')
358
+ await exports.BugSession.start();
359
+ },
360
+ fail(error) { if (phase.kind !== 'failed')
361
+ setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) }); },
362
+ onAudioLevel: listener => options?.capture?.onAudioLevel?.(listener) ?? null,
363
+ safeAreaInsets: async () => options?.capture?.safeAreaInsets ? options.capture.safeAreaInsets() : null,
364
+ annotate(annotation) { if (state === 'recording')
365
+ telemetry.add('annotation', { ...annotation, screen: currentScreen }); },
366
+ }),
367
+ /**
368
+ * Expo Router error boundary that reports render errors (which never reach the
369
+ * global error handler). In a route or layout file:
370
+ * `export const ErrorBoundary = BugSession.ErrorBoundary;`
371
+ */
372
+ ErrorBoundary({ error, retry }) {
373
+ react_1.default.useEffect(() => { captureError(error, 'render'); }, [error]);
374
+ return (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.boundary, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.boundaryTitle, children: "Something went wrong" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.boundaryMessage, children: error.message }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => { void retry(); }, style: styles.boundaryRetry, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.boundaryRetryText, children: "Try again" }) })] });
375
+ },
222
376
  /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
223
377
  navigationListener(ref) {
224
378
  return () => { const route = ref.getCurrentRoute(); if (route)
@@ -227,79 +381,11 @@ exports.BugSession = {
227
381
  Touchable({ name, screen, onPress, ...props }) {
228
382
  return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { ...props, onPress: e => { exports.BugSession.trackAction({ name, screen }); onPress?.(e); } });
229
383
  },
230
- Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }) {
231
- const { width, height } = (0, react_native_1.useWindowDimensions)();
232
- const [point, setPoint] = react_1.default.useState(() => triggerPosition
233
- ? clampTriggerPosition(triggerPosition, width, height)
234
- : clampTriggerPosition({
235
- left: position.includes('right') ? width - triggerSize - offset : offset,
236
- top: position.includes('bottom') ? height - triggerSize - offset : offset,
237
- }, width, height));
238
- const pointRef = react_1.default.useRef(point);
239
- pointRef.current = point;
240
- const drag = react_1.default.useRef(null);
241
- if (!drag.current)
242
- drag.current = new react_native_1.Animated.ValueXY();
243
- const dragStart = react_1.default.useRef(point);
244
- react_1.default.useEffect(() => {
245
- const next = clampTriggerPosition(pointRef.current, width, height);
246
- pointRef.current = next;
247
- setPoint(next);
248
- if (triggerPosition)
249
- triggerPosition = next;
250
- drag.current?.setValue({ x: 0, y: 0 });
251
- }, [width, height]);
252
- const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
253
- onStartShouldSetPanResponder: () => false,
254
- onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
255
- onPanResponderGrant: () => { dragStart.current = pointRef.current; },
256
- onPanResponderMove: (_, gesture) => {
257
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
258
- drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
259
- },
260
- onPanResponderRelease: (_, gesture) => {
261
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
262
- triggerPosition = next;
263
- pointRef.current = next;
264
- setPoint(next);
265
- drag.current?.setValue({ x: 0, y: 0 });
266
- },
267
- onPanResponderTerminate: (_, gesture) => {
268
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
269
- triggerPosition = next;
270
- pointRef.current = next;
271
- setPoint(next);
272
- drag.current?.setValue({ x: 0, y: 0 });
273
- },
274
- }), [draggable, width, height]);
275
- const [busy, setBusy] = react_1.default.useState(false);
276
- const recording = state === 'recording';
277
- const toggle = async () => {
278
- if (busy)
279
- return;
280
- setBusy(true);
281
- try {
282
- if (state === 'recording')
283
- await exports.BugSession.stop();
284
- else
285
- await exports.BugSession.start();
286
- }
287
- catch (error) {
288
- onError?.(error);
289
- }
290
- finally {
291
- setBusy(false);
292
- }
293
- };
294
- return (0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "box-none", style: styles.triggerLayer, children: (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { ...(draggable ? responder.panHandlers : {}), style: [styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording'), accessibilityHint: draggable ? 'Drag to move the BugSession button' : undefined, accessibilityState: { busy, selected: recording }, disabled: busy, onPress: () => { void toggle(); }, style: [styles.trigger, recording && styles.triggerRecording, style], children: children ?? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.triggerMark, children: "b" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.triggerDot, recording && styles.triggerDotRecording] })] }) }) }) });
295
- },
296
384
  };
297
385
  const styles = react_native_1.StyleSheet.create({
298
- triggerLayer: { ...react_native_1.StyleSheet.absoluteFill, zIndex: 9999 },
299
- triggerPosition: { position: 'absolute' },
300
- trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
301
- triggerRecording: { backgroundColor: '#B9382F' },
302
- triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
303
- triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
304
- triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
386
+ boundary: { flex: 1, justifyContent: 'center', padding: 24, gap: 12, backgroundColor: '#FFFFFF' },
387
+ boundaryTitle: { fontSize: 22, fontWeight: '700', color: '#111111' },
388
+ boundaryMessage: { fontSize: 15, color: '#555555' },
389
+ boundaryRetry: { alignSelf: 'flex-start', paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#173B34' },
390
+ boundaryRetryText: { color: '#F4F4EC', fontWeight: '700' },
305
391
  });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Best-effort name for the touched element: walks up from the touched host component
3
+ * reading accessibility labels, test IDs, titles and visible text. Never reads input
4
+ * values. React internals differ between renderers, so every failure yields undefined.
5
+ */
6
+ export declare function tapLabel(event: unknown): string | undefined;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tapLabel = tapLabel;
4
+ const LABEL_DEPTH = 12;
5
+ const PRESSABLE_DEPTH = 4;
6
+ const DESCENDANT_BUDGET = 40;
7
+ function text(value) {
8
+ if (typeof value === 'string' || typeof value === 'number')
9
+ return String(value).trim() || undefined;
10
+ if (Array.isArray(value)) {
11
+ const joined = value.map(text).filter(Boolean).join(' ');
12
+ return joined || undefined;
13
+ }
14
+ return undefined;
15
+ }
16
+ /**
17
+ * Best-effort name for the touched element: walks up from the touched host component
18
+ * reading accessibility labels, test IDs, titles and visible text. Never reads input
19
+ * values. React internals differ between renderers, so every failure yields undefined.
20
+ */
21
+ function tapLabel(event) {
22
+ try {
23
+ const native = event;
24
+ const target = native?._targetInst ?? native?.target?.__internalInstanceHandle;
25
+ let fiber = target;
26
+ for (let depth = 0; fiber && depth < LABEL_DEPTH; depth++, fiber = fiber.return) {
27
+ const label = ownLabel(fiber.memoizedProps);
28
+ if (label)
29
+ return label.slice(0, 80);
30
+ // A tap on a button's padding targets the button itself; its text is inside it.
31
+ if (depth < PRESSABLE_DEPTH && pressable(fiber.memoizedProps)) {
32
+ const inner = descendantText(fiber.child);
33
+ if (inner)
34
+ return inner.slice(0, 80);
35
+ }
36
+ }
37
+ }
38
+ catch { /* renderer internals unavailable */ }
39
+ return undefined;
40
+ }
41
+ function ownLabel(props) {
42
+ if (!props || typeof props !== 'object')
43
+ return undefined;
44
+ return text(props.accessibilityLabel) ?? text(props['aria-label']) ?? text(props.title)
45
+ ?? (typeof props.placeholder === 'string' ? props.placeholder : undefined)
46
+ ?? (props.editable === undefined && props.value === undefined ? text(props.children) : undefined)
47
+ ?? text(props.testID);
48
+ }
49
+ function pressable(props) {
50
+ if (!props || typeof props !== 'object')
51
+ return false;
52
+ const role = props.accessibilityRole ?? props.role;
53
+ return typeof props.onPress === 'function' || typeof props.onClick === 'function' || role === 'button' || role === 'link'
54
+ || typeof props.onResponderRelease === 'function';
55
+ }
56
+ /** First readable text inside a pressable, breadth-first and bounded; never input values. */
57
+ function descendantText(first) {
58
+ const queue = [];
59
+ for (let f = first; f; f = f.sibling)
60
+ queue.push(f);
61
+ for (let visited = 0; queue.length && visited < DESCENDANT_BUDGET; visited++) {
62
+ const fiber = queue.shift();
63
+ const props = fiber.memoizedProps;
64
+ if (props && typeof props === 'object' && props.editable === undefined && props.value === undefined) {
65
+ const label = text(props.accessibilityLabel) ?? text(props.children);
66
+ if (label)
67
+ return label;
68
+ }
69
+ for (let f = fiber.child; f; f = f.sibling)
70
+ queue.push(f);
71
+ }
72
+ return undefined;
73
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { type ViewProps } from 'react-native';
3
+ export type TapSink = (tap: {
4
+ label?: string;
5
+ x: number;
6
+ y: number;
7
+ }) => void;
8
+ /**
9
+ * Wraps the app once and records every tap (position, screen and the element's label
10
+ * when one can be read), so testers' steps appear in the timeline without per-button code.
11
+ * It only observes touches; it never becomes the responder or blocks gestures.
12
+ */
13
+ export declare function createCapture(sink: () => TapSink | null): ({ children, style, ...props }: ViewProps & {
14
+ children?: React.ReactNode;
15
+ }) => React.JSX.Element;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCapture = createCapture;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const react_native_1 = require("react-native");
7
+ const tap_label_1 = require("./tap-label");
8
+ const TAP_MAX_MOVE = 10;
9
+ const TAP_MAX_MS = 800;
10
+ /**
11
+ * Wraps the app once and records every tap (position, screen and the element's label
12
+ * when one can be read), so testers' steps appear in the timeline without per-button code.
13
+ * It only observes touches; it never becomes the responder or blocks gestures.
14
+ */
15
+ function createCapture(sink) {
16
+ return function Capture({ children, style, ...props }) {
17
+ const start = (0, react_1.useRef)(null);
18
+ return (0, jsx_runtime_1.jsx)(react_native_1.View, { ...props, style: [styles.fill, style], onTouchStartCapture: e => {
19
+ props.onTouchStartCapture?.(e);
20
+ start.current = e.nativeEvent.touches.length > 1 ? null : { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY, at: Date.now() };
21
+ }, onTouchEndCapture: e => {
22
+ props.onTouchEndCapture?.(e);
23
+ const began = start.current;
24
+ start.current = null;
25
+ const add = sink();
26
+ if (!began || !add)
27
+ return;
28
+ const { pageX, pageY } = e.nativeEvent;
29
+ if (Math.hypot(pageX - began.x, pageY - began.y) > TAP_MAX_MOVE || Date.now() - began.at > TAP_MAX_MS)
30
+ return;
31
+ add({ label: (0, tap_label_1.tapLabel)(e), x: Math.round(pageX), y: Math.round(pageY) });
32
+ }, children: children });
33
+ };
34
+ }
35
+ const styles = react_native_1.StyleSheet.create({ fill: { flex: 1 } });
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ export type IndicatorPhase = {
4
+ kind: 'idle';
5
+ } | {
6
+ kind: 'starting';
7
+ } | {
8
+ kind: 'recording';
9
+ startedAt: number;
10
+ } | {
11
+ kind: 'uploading';
12
+ } | {
13
+ kind: 'sent';
14
+ } | {
15
+ kind: 'failed';
16
+ message: string;
17
+ };
18
+ export type Annotation = {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ screenWidth: number;
24
+ screenHeight: number;
25
+ points: number;
26
+ };
27
+ /** What the trigger needs from the SDK; injected to avoid a circular import. */
28
+ export type TriggerController = {
29
+ getPhase(): IndicatorPhase;
30
+ subscribe(listener: () => void): () => void;
31
+ toggle(): Promise<void>;
32
+ fail(error: unknown): void;
33
+ annotate(annotation: Annotation): void;
34
+ onAudioLevel(listener: (level: number) => void): (() => void) | null;
35
+ safeAreaInsets(): Promise<{
36
+ top: number;
37
+ bottom: number;
38
+ } | null>;
39
+ };
40
+ export type BugSessionTriggerProps = {
41
+ /** Initial screen edge (left/right) and half (top/bottom) of the side tab. */
42
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
43
+ /** Initial distance in points between the tab and the top or bottom safe area. */
44
+ offset?: number;
45
+ /** Drag the tab along either edge; it snaps to the nearer side. */
46
+ draggable?: boolean;
47
+ style?: StyleProp<ViewStyle>;
48
+ accessibilityLabel?: string;
49
+ /** Replaces the idle tab's record mark. */
50
+ children?: React.ReactNode;
51
+ onError?: (error: unknown) => void;
52
+ /** Stops automatically so the upload stays under the recorder's limit. Defaults to 150. */
53
+ maxDurationSeconds?: number;
54
+ };
55
+ export declare function createTrigger(controller: TriggerController): ({ position, offset, draggable, style, accessibilityLabel, children, onError, maxDurationSeconds }: BugSessionTriggerProps) => React.JSX.Element;