@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.
@@ -0,0 +1,364 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Animated, Easing, PanResponder, Platform, Pressable, StyleSheet, Text, View, useWindowDimensions, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ export type IndicatorPhase =
5
+ | { kind: 'idle' }
6
+ | { kind: 'starting' }
7
+ | { kind: 'recording'; startedAt: number }
8
+ | { kind: 'uploading' }
9
+ | { kind: 'sent' }
10
+ | { kind: 'failed'; message: string };
11
+
12
+ export type Annotation = { x: number; y: number; width: number; height: number; screenWidth: number; screenHeight: number; points: number };
13
+
14
+ /** What the trigger needs from the SDK; injected to avoid a circular import. */
15
+ export type TriggerController = {
16
+ getPhase(): IndicatorPhase;
17
+ subscribe(listener: () => void): () => void;
18
+ toggle(): Promise<void>;
19
+ fail(error: unknown): void;
20
+ annotate(annotation: Annotation): void;
21
+ onAudioLevel(listener: (level: number) => void): (() => void) | null;
22
+ safeAreaInsets(): Promise<{ top: number; bottom: number } | null>;
23
+ };
24
+
25
+ export type BugSessionTriggerProps = {
26
+ /** Initial screen edge (left/right) and half (top/bottom) of the side tab. */
27
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
28
+ /** Initial distance in points between the tab and the top or bottom safe area. */
29
+ offset?: number;
30
+ /** Drag the tab along either edge; it snaps to the nearer side. */
31
+ draggable?: boolean;
32
+ style?: StyleProp<ViewStyle>;
33
+ accessibilityLabel?: string;
34
+ /** Replaces the idle tab's record mark. */
35
+ children?: React.ReactNode;
36
+ onError?: (error: unknown) => void;
37
+ /** Stops automatically so the upload stays under the recorder's limit. Defaults to 150. */
38
+ maxDurationSeconds?: number;
39
+ };
40
+
41
+ // Native modals (e.g. Expo Router `presentation: 'modal'`) are presented above the app's
42
+ // root view, which would hide the trigger. react-native-screens' FullWindowOverlay renders
43
+ // in a window above them and passes touches through empty areas. Optional dependency.
44
+ let FullWindowOverlay: React.ComponentType<{ children: React.ReactNode; unstable_accessibilityContainerViewIsModal?: boolean }> | null = null;
45
+ if (Platform.OS === 'ios') {
46
+ try { FullWindowOverlay = require('react-native-screens').FullWindowOverlay ?? null; } catch { FullWindowOverlay = null; }
47
+ }
48
+
49
+ const tab = { width: 34, height: 76 };
50
+ const panelWidth = 212;
51
+ const autoHideMs = 4000;
52
+ const barShape = [0.55, 0.8, 1, 0.8, 0.55];
53
+ const compactBarShape = [0.8, 1, 0.8];
54
+ const ink = '#FF3B30';
55
+ const strokeWidth = 5;
56
+ // Survives remounts (e.g. the trigger rendered by an error boundary) while the app is open.
57
+ let tabPlacement: { side: 'left' | 'right'; top: number } | null = null;
58
+
59
+ type Point = { x: number; y: number };
60
+ type Side = 'left' | 'right';
61
+
62
+ function elapsed(ms: number) {
63
+ const total = Math.max(0, Math.floor(ms / 1000));
64
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
65
+ }
66
+
67
+ export function createTrigger(controller: TriggerController) {
68
+ function usePhase() {
69
+ return React.useSyncExternalStore(controller.subscribe, controller.getPhase, controller.getPhase);
70
+ }
71
+ function useInsets() {
72
+ const [insets, setInsets] = React.useState<{ top: number; bottom: number } | null>(null);
73
+ React.useEffect(() => { void controller.safeAreaInsets().then(setInsets).catch(() => setInsets(null)); }, []);
74
+ // Without native insets (older native builds, Android), assume a notch and home indicator on iOS.
75
+ return insets ?? { top: Platform.OS === 'ios' ? 54 : 24, bottom: Platform.OS === 'ios' ? 34 : 16 };
76
+ }
77
+ function useElapsed(phase: IndicatorPhase, maxDurationSeconds: number) {
78
+ const [now, setNow] = React.useState(Date.now());
79
+ React.useEffect(() => {
80
+ if (phase.kind !== 'recording') return;
81
+ const timer = setInterval(() => setNow(Date.now()), 500);
82
+ return () => clearInterval(timer);
83
+ }, [phase]);
84
+ const ms = phase.kind === 'recording' ? Math.max(0, now - phase.startedAt) : 0;
85
+ return { ms, nearLimit: ms > (maxDurationSeconds - 30) * 1000 };
86
+ }
87
+
88
+ function Waveform({ compact }: { compact?: boolean }) {
89
+ const shape = compact ? compactBarShape : barShape;
90
+ const bars = React.useRef(shape.map(() => new Animated.Value(0.25))).current;
91
+ const [live, setLive] = React.useState(false);
92
+ React.useEffect(() => {
93
+ const unsubscribe = controller.onAudioLevel(level => {
94
+ setLive(true);
95
+ Animated.parallel(bars.map((bar, i) => Animated.timing(bar, {
96
+ toValue: Math.max(0.18, Math.min(1, level * shape[i] * (0.85 + Math.random() * 0.3))),
97
+ duration: 90, easing: Easing.out(Easing.quad), useNativeDriver: true,
98
+ }))).start();
99
+ });
100
+ return () => unsubscribe?.();
101
+ }, [bars, shape]);
102
+ return <View style={[styles.wave, compact && styles.waveCompact]} accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
103
+ {bars.map((bar, i) => <Animated.View key={i} style={[styles.waveBar, compact && styles.waveBarCompact, !live && styles.waveBarIdle, { transform: [{ scaleY: bar }] }]} />)}
104
+ </View>;
105
+ }
106
+
107
+ function RecordingDot({ size = 9 }: { size?: number }) {
108
+ const pulse = React.useRef(new Animated.Value(1)).current;
109
+ React.useEffect(() => {
110
+ const loop = Animated.loop(Animated.sequence([
111
+ Animated.timing(pulse, { toValue: 0.35, duration: 700, useNativeDriver: true }),
112
+ Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
113
+ ]));
114
+ loop.start();
115
+ return () => loop.stop();
116
+ }, [pulse]);
117
+ return <Animated.View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: ink, opacity: pulse }} />;
118
+ }
119
+
120
+ /** One rotated, rounded rectangle per segment: smooth strokes without an SVG dependency. */
121
+ function Stroke({ points }: { points: Point[] }) {
122
+ if (points.length === 1) {
123
+ const p = points[0];
124
+ return <View style={[styles.segment, { left: p.x - strokeWidth / 2, top: p.y - strokeWidth / 2, width: strokeWidth }]} />;
125
+ }
126
+ return <>{points.slice(1).map((b, i) => {
127
+ const a = points[i];
128
+ const length = Math.hypot(b.x - a.x, b.y - a.y) + strokeWidth;
129
+ return <View key={i} style={[styles.segment, {
130
+ left: (a.x + b.x) / 2 - length / 2, top: (a.y + b.y) / 2 - strokeWidth / 2, width: length,
131
+ transform: [{ rotate: `${Math.atan2(b.y - a.y, b.x - a.x)}rad` }],
132
+ }]} />;
133
+ })}</>;
134
+ }
135
+
136
+ /** Full-screen pen layer, active only while the tester is drawing. Strokes are part of the screen recording. */
137
+ function DrawingCanvas({ strokes, setStrokes }: { strokes: Point[][]; setStrokes: React.Dispatch<React.SetStateAction<Point[][]>> }) {
138
+ const { width, height } = useWindowDimensions();
139
+ const current = React.useRef<Point[] | null>(null);
140
+ const responder = React.useMemo(() => PanResponder.create({
141
+ onStartShouldSetPanResponder: () => true,
142
+ onMoveShouldSetPanResponder: () => true,
143
+ onPanResponderGrant: event => {
144
+ current.current = [{ x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }];
145
+ setStrokes(all => [...all, current.current!]);
146
+ },
147
+ onPanResponderMove: event => {
148
+ const stroke = current.current;
149
+ if (!stroke) return;
150
+ const point = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
151
+ const last = stroke[stroke.length - 1];
152
+ if (Math.hypot(point.x - last.x, point.y - last.y) < 3 || stroke.length >= 400) return;
153
+ const next = [...stroke, point];
154
+ current.current = next;
155
+ setStrokes(all => [...all.slice(0, -1), next]);
156
+ },
157
+ onPanResponderRelease: () => {
158
+ const stroke = current.current;
159
+ current.current = null;
160
+ if (!stroke) return;
161
+ const xs = stroke.map(p => p.x), ys = stroke.map(p => p.y);
162
+ const x = Math.min(...xs), y = Math.min(...ys);
163
+ controller.annotate({
164
+ x: Math.round(x), y: Math.round(y),
165
+ width: Math.round(Math.max(...xs) - x), height: Math.round(Math.max(...ys) - y),
166
+ screenWidth: Math.round(width), screenHeight: Math.round(height), points: stroke.length,
167
+ });
168
+ },
169
+ }), [width, height, setStrokes]);
170
+ return <View {...responder.panHandlers} style={styles.canvas} accessibilityLabel="Drawing area. Draw to highlight a problem.">
171
+ {strokes.map((points, i) => <Stroke key={i} points={points} />)}
172
+ </View>;
173
+ }
174
+
175
+ function PanelButton({ label, onPress, disabled, tone, accessibilityLabel }: { label: string; onPress(): void; disabled?: boolean; tone?: 'primary' | 'danger'; accessibilityLabel?: string }) {
176
+ return <Pressable accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} accessibilityState={{ disabled: !!disabled }}
177
+ disabled={disabled} onPress={onPress} hitSlop={4}
178
+ style={[styles.panelButton, tone === 'primary' && styles.panelButtonPrimary, tone === 'danger' && styles.panelButtonDanger, disabled && styles.panelButtonDisabled]}>
179
+ <Text style={[styles.panelButtonText, tone === 'primary' && styles.panelButtonTextPrimary]}>{label}</Text>
180
+ </Pressable>;
181
+ }
182
+
183
+ /** After stopping, a short toast at the top; the recording has already ended, so it is not captured. */
184
+ function StatusToast({ phase }: { phase: IndicatorPhase }) {
185
+ const insets = useInsets();
186
+ const appear = React.useRef(new Animated.Value(0)).current;
187
+ const visible = phase.kind === 'uploading' || phase.kind === 'sent' || phase.kind === 'failed';
188
+ const [shown, setShown] = React.useState<IndicatorPhase>(phase);
189
+ React.useEffect(() => { if (visible) setShown(phase); }, [phase, visible]);
190
+ React.useEffect(() => {
191
+ Animated.spring(appear, { toValue: visible ? 1 : 0, useNativeDriver: true, bounciness: 5, speed: 14 }).start();
192
+ }, [visible, appear]);
193
+ let content: React.ReactNode = null;
194
+ if (shown.kind === 'uploading') content = <><ActivityIndicator size="small" color="#FFFFFF" /><Text style={styles.toastText}>Sending report…</Text></>;
195
+ else if (shown.kind === 'sent') content = <><View style={[styles.badge, styles.badgeSuccess]}><Text style={styles.badgeText}>✓</Text></View><Text style={styles.toastText}>Report sent</Text></>;
196
+ else if (shown.kind === 'failed') content = <><View style={[styles.badge, styles.badgeError]}><Text style={styles.badgeText}>!</Text></View><Text style={styles.toastText} numberOfLines={2}>Not sent: {shown.message}</Text></>;
197
+ return <Animated.View pointerEvents="none" accessibilityLiveRegion="polite" style={[styles.toastLayer, {
198
+ top: insets.top + 8, opacity: appear,
199
+ transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [-16, 0] }) }],
200
+ }]}>
201
+ {content && <View style={styles.toast}>{content}</View>}
202
+ </Animated.View>;
203
+ }
204
+
205
+ return function Trigger({ position = 'bottom-right', offset = 96, draggable = true, style, accessibilityLabel, children, onError, maxDurationSeconds = 150 }: BugSessionTriggerProps) {
206
+ const phase = usePhase();
207
+ const insets = useInsets();
208
+ const { width, height } = useWindowDimensions();
209
+ const { ms, nearLimit } = useElapsed(phase, maxDurationSeconds);
210
+ const [expanded, setExpanded] = React.useState(false);
211
+ const [drawing, setDrawing] = React.useState(false);
212
+ const [strokes, setStrokes] = React.useState<Point[][]>([]);
213
+ const minTop = insets.top + 8;
214
+ const maxTop = Math.max(minTop, height - insets.bottom - tab.height - 8);
215
+ const clampTop = React.useCallback((top: number) => Math.max(minTop, Math.min(top, maxTop)), [minTop, maxTop]);
216
+ const [placement, setPlacement] = React.useState<{ side: Side; top: number }>(() => tabPlacement ?? {
217
+ side: position.includes('left') ? 'left' : 'right',
218
+ top: position.includes('top') ? minTop + offset : height - insets.bottom - tab.height - offset,
219
+ });
220
+ const top = clampTop(placement.top);
221
+ const drag = React.useRef(new Animated.ValueXY()).current;
222
+ const dragStart = React.useRef({ side: placement.side, top });
223
+ dragStart.current = { side: placement.side, top };
224
+ const recording = phase.kind === 'recording';
225
+ const starting = phase.kind === 'starting';
226
+
227
+ // Auto-stop at the limit, and reset the controls when a recording ends.
228
+ React.useEffect(() => {
229
+ if (recording && ms >= maxDurationSeconds * 1000) void controller.toggle();
230
+ }, [recording, ms, maxDurationSeconds]);
231
+ React.useEffect(() => {
232
+ if (!recording) { setExpanded(false); setDrawing(false); setStrokes([]); }
233
+ }, [recording]);
234
+ // The options panel hides itself so it stays out of the video as much as possible.
235
+ React.useEffect(() => {
236
+ if (!expanded || drawing) return;
237
+ const timer = setTimeout(() => setExpanded(false), autoHideMs);
238
+ return () => clearTimeout(timer);
239
+ }, [expanded, drawing]);
240
+
241
+ const responder = React.useMemo(() => PanResponder.create({
242
+ onStartShouldSetPanResponder: () => false,
243
+ onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
244
+ onPanResponderMove: (_, gesture) => drag.setValue({ x: gesture.dx, y: clampTop(dragStart.current.top + gesture.dy) - dragStart.current.top }),
245
+ onPanResponderRelease: (_, gesture) => {
246
+ const startX = dragStart.current.side === 'right' ? width - tab.width : 0;
247
+ const next = { side: (startX + gesture.dx + tab.width / 2 > width / 2 ? 'right' : 'left') as Side, top: clampTop(dragStart.current.top + gesture.dy) };
248
+ tabPlacement = next;
249
+ setPlacement(next);
250
+ drag.setValue({ x: 0, y: 0 });
251
+ },
252
+ onPanResponderTerminate: () => drag.setValue({ x: 0, y: 0 }),
253
+ }), [draggable, drag, width, clampTop]);
254
+
255
+ const onTab = async () => {
256
+ if (recording) { setExpanded(open => !open || drawing); return; }
257
+ try { await controller.toggle(); }
258
+ catch (error) { controller.fail(error); onError?.(error); }
259
+ };
260
+ const stop = () => { setExpanded(false); setDrawing(false); setStrokes([]); void controller.toggle(); };
261
+
262
+ const side = placement.side;
263
+ const showTab = phase.kind === 'idle' || starting || recording;
264
+ const panelTop = Math.max(minTop, Math.min(top - 12, height - insets.bottom - 8 - (drawing ? 150 : 170)));
265
+ const panelEdge = { [side]: tab.width + 6 } as { left?: number; right?: number };
266
+
267
+ let tabContent: React.ReactNode;
268
+ if (starting) tabContent = <ActivityIndicator size="small" color="#FFFFFF" />;
269
+ else if (recording) tabContent = <>
270
+ <RecordingDot size={8} />
271
+ <Text style={[styles.tabTime, nearLimit && styles.warning]}>{elapsed(ms)}</Text>
272
+ <Waveform compact />
273
+ </>;
274
+ else tabContent = children ?? <View style={styles.recordRing}><View style={styles.recordDot} /></View>;
275
+
276
+ const layer = <View pointerEvents="box-none" style={styles.layer}>
277
+ {drawing && recording && <DrawingCanvas strokes={strokes} setStrokes={setStrokes} />}
278
+ {showTab && <Animated.View {...(draggable ? responder.panHandlers : {})} style={[styles.tabPosition, {
279
+ top, [side]: 0, transform: drag.getTranslateTransform(),
280
+ }]}>
281
+ <Pressable
282
+ accessibilityRole="button"
283
+ accessibilityLabel={accessibilityLabel ?? (recording ? `Recording, ${Math.floor(ms / 1000)} seconds. Show recording options.` : 'Record a bug report')}
284
+ accessibilityHint={recording ? undefined : 'Records the screen and your voice.'}
285
+ accessibilityState={{ busy: starting, expanded: recording ? expanded : undefined }}
286
+ disabled={starting}
287
+ onPress={() => { void onTab(); }}
288
+ style={[styles.tab, side === 'right' ? styles.tabRight : styles.tabLeft, recording && styles.tabRecording, style]}
289
+ >{tabContent}</Pressable>
290
+ </Animated.View>}
291
+ {recording && expanded && <View style={[styles.panel, panelEdge, { top: panelTop }]}>
292
+ <View style={styles.panelHeader}>
293
+ <RecordingDot />
294
+ <Text style={[styles.panelTime, nearLimit && styles.warning]}>{elapsed(ms)}</Text>
295
+ <Waveform />
296
+ </View>
297
+ {drawing ? <>
298
+ <Text style={styles.panelHint}>Draw on the screen to highlight the problem.</Text>
299
+ <View style={styles.panelRow}>
300
+ <PanelButton label="Undo" disabled={!strokes.length} onPress={() => setStrokes(all => all.slice(0, -1))} />
301
+ <PanelButton label="Clear" disabled={!strokes.length} onPress={() => setStrokes([])} />
302
+ </View>
303
+ <PanelButton label="Done" tone="primary" onPress={() => { setDrawing(false); setStrokes([]); setExpanded(false); }} />
304
+ </> : <>
305
+ <PanelButton label="✎ Draw on screen" onPress={() => setDrawing(true)} />
306
+ <PanelButton label="■ Stop & send" tone="danger" accessibilityLabel="Stop recording and send the report" onPress={stop} />
307
+ </>}
308
+ </View>}
309
+ <StatusToast phase={phase} />
310
+ </View>;
311
+ return FullWindowOverlay
312
+ ? <FullWindowOverlay unstable_accessibilityContainerViewIsModal={false}>{layer}</FullWindowOverlay>
313
+ : layer;
314
+ };
315
+ }
316
+
317
+ const styles = StyleSheet.create({
318
+ layer: { ...StyleSheet.absoluteFill, zIndex: 9999, elevation: 9999 },
319
+ tabPosition: { position: 'absolute' },
320
+ tab: {
321
+ width: tab.width, height: tab.height, alignItems: 'center', justifyContent: 'center', gap: 5,
322
+ backgroundColor: 'rgba(16,17,20,0.92)', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.16)',
323
+ shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 8, shadowOffset: { width: 0, height: 3 }, elevation: 10,
324
+ },
325
+ tabRight: { borderTopLeftRadius: 14, borderBottomLeftRadius: 14, borderRightWidth: 0 },
326
+ tabLeft: { borderTopRightRadius: 14, borderBottomRightRadius: 14, borderLeftWidth: 0 },
327
+ tabRecording: { borderColor: 'rgba(255,59,48,0.5)' },
328
+ tabTime: { color: '#FFFFFF', fontSize: 10, fontWeight: '700', fontVariant: ['tabular-nums'] },
329
+ recordRing: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: 'rgba(255,255,255,0.9)', alignItems: 'center', justifyContent: 'center' },
330
+ recordDot: { width: 11, height: 11, borderRadius: 5.5, backgroundColor: ink },
331
+ warning: { color: '#FFB340' },
332
+ panel: {
333
+ position: 'absolute', width: panelWidth, gap: 8, padding: 12, borderRadius: 18,
334
+ backgroundColor: 'rgba(16,17,20,0.96)', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.14)',
335
+ shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 14, shadowOffset: { width: 0, height: 6 }, elevation: 14,
336
+ },
337
+ panelHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 2 },
338
+ panelTime: { color: '#FFFFFF', fontSize: 16, fontWeight: '700', fontVariant: ['tabular-nums'], flex: 1 },
339
+ panelHint: { color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 16 },
340
+ panelRow: { flexDirection: 'row', gap: 8 },
341
+ panelButton: { flex: 1, alignItems: 'center', paddingVertical: 10, paddingHorizontal: 12, borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.12)' },
342
+ panelButtonPrimary: { backgroundColor: '#FFFFFF' },
343
+ panelButtonDanger: { backgroundColor: ink },
344
+ panelButtonDisabled: { opacity: 0.4 },
345
+ panelButtonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '700' },
346
+ panelButtonTextPrimary: { color: '#000000' },
347
+ canvas: { ...StyleSheet.absoluteFill, borderWidth: 3, borderColor: 'rgba(255,59,48,0.55)' },
348
+ segment: { position: 'absolute', height: strokeWidth, borderRadius: strokeWidth / 2, backgroundColor: ink },
349
+ toastLayer: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
350
+ toast: {
351
+ flexDirection: 'row', alignItems: 'center', gap: 10, maxWidth: 340, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 22,
352
+ backgroundColor: 'rgba(16,17,20,0.96)', shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 12, shadowOffset: { width: 0, height: 5 }, elevation: 14,
353
+ },
354
+ toastText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600', flexShrink: 1 },
355
+ wave: { flexDirection: 'row', alignItems: 'center', gap: 3, height: 18 },
356
+ waveCompact: { gap: 2, height: 12 },
357
+ waveBar: { width: 3, height: 18, borderRadius: 1.5, backgroundColor: '#FF6B61' },
358
+ waveBarCompact: { width: 2, height: 12, borderRadius: 1 },
359
+ waveBarIdle: { backgroundColor: 'rgba(255,255,255,0.45)' },
360
+ badge: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' },
361
+ badgeSuccess: { backgroundColor: '#30D158' },
362
+ badgeError: { backgroundColor: '#FF453A' },
363
+ badgeText: { color: '#000000', fontSize: 13, fontWeight: '800' },
364
+ });