@bugsession/react-native 0.1.0-beta.2 → 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 +8 -2
- 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 -10
- package/dist/index.js +156 -65
- 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 +162 -74
- package/src/trigger.tsx +364 -0
package/dist/trigger.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createTrigger = createTrigger;
|
|
7
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
|
+
const react_1 = __importDefault(require("react"));
|
|
9
|
+
const react_native_1 = require("react-native");
|
|
10
|
+
// Native modals (e.g. Expo Router `presentation: 'modal'`) are presented above the app's
|
|
11
|
+
// root view, which would hide the trigger. react-native-screens' FullWindowOverlay renders
|
|
12
|
+
// in a window above them and passes touches through empty areas. Optional dependency.
|
|
13
|
+
let FullWindowOverlay = null;
|
|
14
|
+
if (react_native_1.Platform.OS === 'ios') {
|
|
15
|
+
try {
|
|
16
|
+
FullWindowOverlay = require('react-native-screens').FullWindowOverlay ?? null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
FullWindowOverlay = null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const tab = { width: 34, height: 76 };
|
|
23
|
+
const panelWidth = 212;
|
|
24
|
+
const autoHideMs = 4000;
|
|
25
|
+
const barShape = [0.55, 0.8, 1, 0.8, 0.55];
|
|
26
|
+
const compactBarShape = [0.8, 1, 0.8];
|
|
27
|
+
const ink = '#FF3B30';
|
|
28
|
+
const strokeWidth = 5;
|
|
29
|
+
// Survives remounts (e.g. the trigger rendered by an error boundary) while the app is open.
|
|
30
|
+
let tabPlacement = null;
|
|
31
|
+
function elapsed(ms) {
|
|
32
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
33
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
34
|
+
}
|
|
35
|
+
function createTrigger(controller) {
|
|
36
|
+
function usePhase() {
|
|
37
|
+
return react_1.default.useSyncExternalStore(controller.subscribe, controller.getPhase, controller.getPhase);
|
|
38
|
+
}
|
|
39
|
+
function useInsets() {
|
|
40
|
+
const [insets, setInsets] = react_1.default.useState(null);
|
|
41
|
+
react_1.default.useEffect(() => { void controller.safeAreaInsets().then(setInsets).catch(() => setInsets(null)); }, []);
|
|
42
|
+
// Without native insets (older native builds, Android), assume a notch and home indicator on iOS.
|
|
43
|
+
return insets ?? { top: react_native_1.Platform.OS === 'ios' ? 54 : 24, bottom: react_native_1.Platform.OS === 'ios' ? 34 : 16 };
|
|
44
|
+
}
|
|
45
|
+
function useElapsed(phase, maxDurationSeconds) {
|
|
46
|
+
const [now, setNow] = react_1.default.useState(Date.now());
|
|
47
|
+
react_1.default.useEffect(() => {
|
|
48
|
+
if (phase.kind !== 'recording')
|
|
49
|
+
return;
|
|
50
|
+
const timer = setInterval(() => setNow(Date.now()), 500);
|
|
51
|
+
return () => clearInterval(timer);
|
|
52
|
+
}, [phase]);
|
|
53
|
+
const ms = phase.kind === 'recording' ? Math.max(0, now - phase.startedAt) : 0;
|
|
54
|
+
return { ms, nearLimit: ms > (maxDurationSeconds - 30) * 1000 };
|
|
55
|
+
}
|
|
56
|
+
function Waveform({ compact }) {
|
|
57
|
+
const shape = compact ? compactBarShape : barShape;
|
|
58
|
+
const bars = react_1.default.useRef(shape.map(() => new react_native_1.Animated.Value(0.25))).current;
|
|
59
|
+
const [live, setLive] = react_1.default.useState(false);
|
|
60
|
+
react_1.default.useEffect(() => {
|
|
61
|
+
const unsubscribe = controller.onAudioLevel(level => {
|
|
62
|
+
setLive(true);
|
|
63
|
+
react_native_1.Animated.parallel(bars.map((bar, i) => react_native_1.Animated.timing(bar, {
|
|
64
|
+
toValue: Math.max(0.18, Math.min(1, level * shape[i] * (0.85 + Math.random() * 0.3))),
|
|
65
|
+
duration: 90, easing: react_native_1.Easing.out(react_native_1.Easing.quad), useNativeDriver: true,
|
|
66
|
+
}))).start();
|
|
67
|
+
});
|
|
68
|
+
return () => unsubscribe?.();
|
|
69
|
+
}, [bars, shape]);
|
|
70
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.wave, compact && styles.waveCompact], accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: bars.map((bar, i) => (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { style: [styles.waveBar, compact && styles.waveBarCompact, !live && styles.waveBarIdle, { transform: [{ scaleY: bar }] }] }, i)) });
|
|
71
|
+
}
|
|
72
|
+
function RecordingDot({ size = 9 }) {
|
|
73
|
+
const pulse = react_1.default.useRef(new react_native_1.Animated.Value(1)).current;
|
|
74
|
+
react_1.default.useEffect(() => {
|
|
75
|
+
const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
|
|
76
|
+
react_native_1.Animated.timing(pulse, { toValue: 0.35, duration: 700, useNativeDriver: true }),
|
|
77
|
+
react_native_1.Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
|
|
78
|
+
]));
|
|
79
|
+
loop.start();
|
|
80
|
+
return () => loop.stop();
|
|
81
|
+
}, [pulse]);
|
|
82
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { style: { width: size, height: size, borderRadius: size / 2, backgroundColor: ink, opacity: pulse } });
|
|
83
|
+
}
|
|
84
|
+
/** One rotated, rounded rectangle per segment: smooth strokes without an SVG dependency. */
|
|
85
|
+
function Stroke({ points }) {
|
|
86
|
+
if (points.length === 1) {
|
|
87
|
+
const p = points[0];
|
|
88
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.segment, { left: p.x - strokeWidth / 2, top: p.y - strokeWidth / 2, width: strokeWidth }] });
|
|
89
|
+
}
|
|
90
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: points.slice(1).map((b, i) => {
|
|
91
|
+
const a = points[i];
|
|
92
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y) + strokeWidth;
|
|
93
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.segment, {
|
|
94
|
+
left: (a.x + b.x) / 2 - length / 2, top: (a.y + b.y) / 2 - strokeWidth / 2, width: length,
|
|
95
|
+
transform: [{ rotate: `${Math.atan2(b.y - a.y, b.x - a.x)}rad` }],
|
|
96
|
+
}] }, i);
|
|
97
|
+
}) });
|
|
98
|
+
}
|
|
99
|
+
/** Full-screen pen layer, active only while the tester is drawing. Strokes are part of the screen recording. */
|
|
100
|
+
function DrawingCanvas({ strokes, setStrokes }) {
|
|
101
|
+
const { width, height } = (0, react_native_1.useWindowDimensions)();
|
|
102
|
+
const current = react_1.default.useRef(null);
|
|
103
|
+
const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
|
|
104
|
+
onStartShouldSetPanResponder: () => true,
|
|
105
|
+
onMoveShouldSetPanResponder: () => true,
|
|
106
|
+
onPanResponderGrant: event => {
|
|
107
|
+
current.current = [{ x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }];
|
|
108
|
+
setStrokes(all => [...all, current.current]);
|
|
109
|
+
},
|
|
110
|
+
onPanResponderMove: event => {
|
|
111
|
+
const stroke = current.current;
|
|
112
|
+
if (!stroke)
|
|
113
|
+
return;
|
|
114
|
+
const point = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
|
|
115
|
+
const last = stroke[stroke.length - 1];
|
|
116
|
+
if (Math.hypot(point.x - last.x, point.y - last.y) < 3 || stroke.length >= 400)
|
|
117
|
+
return;
|
|
118
|
+
const next = [...stroke, point];
|
|
119
|
+
current.current = next;
|
|
120
|
+
setStrokes(all => [...all.slice(0, -1), next]);
|
|
121
|
+
},
|
|
122
|
+
onPanResponderRelease: () => {
|
|
123
|
+
const stroke = current.current;
|
|
124
|
+
current.current = null;
|
|
125
|
+
if (!stroke)
|
|
126
|
+
return;
|
|
127
|
+
const xs = stroke.map(p => p.x), ys = stroke.map(p => p.y);
|
|
128
|
+
const x = Math.min(...xs), y = Math.min(...ys);
|
|
129
|
+
controller.annotate({
|
|
130
|
+
x: Math.round(x), y: Math.round(y),
|
|
131
|
+
width: Math.round(Math.max(...xs) - x), height: Math.round(Math.max(...ys) - y),
|
|
132
|
+
screenWidth: Math.round(width), screenHeight: Math.round(height), points: stroke.length,
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
}), [width, height, setStrokes]);
|
|
136
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { ...responder.panHandlers, style: styles.canvas, accessibilityLabel: "Drawing area. Draw to highlight a problem.", children: strokes.map((points, i) => (0, jsx_runtime_1.jsx)(Stroke, { points: points }, i)) });
|
|
137
|
+
}
|
|
138
|
+
function PanelButton({ label, onPress, disabled, tone, accessibilityLabel }) {
|
|
139
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? label, accessibilityState: { disabled: !!disabled }, disabled: disabled, onPress: onPress, hitSlop: 4, style: [styles.panelButton, tone === 'primary' && styles.panelButtonPrimary, tone === 'danger' && styles.panelButtonDanger, disabled && styles.panelButtonDisabled], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.panelButtonText, tone === 'primary' && styles.panelButtonTextPrimary], children: label }) });
|
|
140
|
+
}
|
|
141
|
+
/** After stopping, a short toast at the top; the recording has already ended, so it is not captured. */
|
|
142
|
+
function StatusToast({ phase }) {
|
|
143
|
+
const insets = useInsets();
|
|
144
|
+
const appear = react_1.default.useRef(new react_native_1.Animated.Value(0)).current;
|
|
145
|
+
const visible = phase.kind === 'uploading' || phase.kind === 'sent' || phase.kind === 'failed';
|
|
146
|
+
const [shown, setShown] = react_1.default.useState(phase);
|
|
147
|
+
react_1.default.useEffect(() => { if (visible)
|
|
148
|
+
setShown(phase); }, [phase, visible]);
|
|
149
|
+
react_1.default.useEffect(() => {
|
|
150
|
+
react_native_1.Animated.spring(appear, { toValue: visible ? 1 : 0, useNativeDriver: true, bounciness: 5, speed: 14 }).start();
|
|
151
|
+
}, [visible, appear]);
|
|
152
|
+
let content = null;
|
|
153
|
+
if (shown.kind === 'uploading')
|
|
154
|
+
content = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { size: "small", color: "#FFFFFF" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.toastText, children: "Sending report\u2026" })] });
|
|
155
|
+
else if (shown.kind === 'sent')
|
|
156
|
+
content = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.badge, styles.badgeSuccess], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: "\u2713" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.toastText, children: "Report sent" })] });
|
|
157
|
+
else if (shown.kind === 'failed')
|
|
158
|
+
content = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.badge, styles.badgeError], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: "!" }) }), (0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.toastText, numberOfLines: 2, children: ["Not sent: ", shown.message] })] });
|
|
159
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { pointerEvents: "none", accessibilityLiveRegion: "polite", style: [styles.toastLayer, {
|
|
160
|
+
top: insets.top + 8, opacity: appear,
|
|
161
|
+
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [-16, 0] }) }],
|
|
162
|
+
}], children: content && (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.toast, children: content }) });
|
|
163
|
+
}
|
|
164
|
+
return function Trigger({ position = 'bottom-right', offset = 96, draggable = true, style, accessibilityLabel, children, onError, maxDurationSeconds = 150 }) {
|
|
165
|
+
const phase = usePhase();
|
|
166
|
+
const insets = useInsets();
|
|
167
|
+
const { width, height } = (0, react_native_1.useWindowDimensions)();
|
|
168
|
+
const { ms, nearLimit } = useElapsed(phase, maxDurationSeconds);
|
|
169
|
+
const [expanded, setExpanded] = react_1.default.useState(false);
|
|
170
|
+
const [drawing, setDrawing] = react_1.default.useState(false);
|
|
171
|
+
const [strokes, setStrokes] = react_1.default.useState([]);
|
|
172
|
+
const minTop = insets.top + 8;
|
|
173
|
+
const maxTop = Math.max(minTop, height - insets.bottom - tab.height - 8);
|
|
174
|
+
const clampTop = react_1.default.useCallback((top) => Math.max(minTop, Math.min(top, maxTop)), [minTop, maxTop]);
|
|
175
|
+
const [placement, setPlacement] = react_1.default.useState(() => tabPlacement ?? {
|
|
176
|
+
side: position.includes('left') ? 'left' : 'right',
|
|
177
|
+
top: position.includes('top') ? minTop + offset : height - insets.bottom - tab.height - offset,
|
|
178
|
+
});
|
|
179
|
+
const top = clampTop(placement.top);
|
|
180
|
+
const drag = react_1.default.useRef(new react_native_1.Animated.ValueXY()).current;
|
|
181
|
+
const dragStart = react_1.default.useRef({ side: placement.side, top });
|
|
182
|
+
dragStart.current = { side: placement.side, top };
|
|
183
|
+
const recording = phase.kind === 'recording';
|
|
184
|
+
const starting = phase.kind === 'starting';
|
|
185
|
+
// Auto-stop at the limit, and reset the controls when a recording ends.
|
|
186
|
+
react_1.default.useEffect(() => {
|
|
187
|
+
if (recording && ms >= maxDurationSeconds * 1000)
|
|
188
|
+
void controller.toggle();
|
|
189
|
+
}, [recording, ms, maxDurationSeconds]);
|
|
190
|
+
react_1.default.useEffect(() => {
|
|
191
|
+
if (!recording) {
|
|
192
|
+
setExpanded(false);
|
|
193
|
+
setDrawing(false);
|
|
194
|
+
setStrokes([]);
|
|
195
|
+
}
|
|
196
|
+
}, [recording]);
|
|
197
|
+
// The options panel hides itself so it stays out of the video as much as possible.
|
|
198
|
+
react_1.default.useEffect(() => {
|
|
199
|
+
if (!expanded || drawing)
|
|
200
|
+
return;
|
|
201
|
+
const timer = setTimeout(() => setExpanded(false), autoHideMs);
|
|
202
|
+
return () => clearTimeout(timer);
|
|
203
|
+
}, [expanded, drawing]);
|
|
204
|
+
const responder = react_1.default.useMemo(() => react_native_1.PanResponder.create({
|
|
205
|
+
onStartShouldSetPanResponder: () => false,
|
|
206
|
+
onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
|
|
207
|
+
onPanResponderMove: (_, gesture) => drag.setValue({ x: gesture.dx, y: clampTop(dragStart.current.top + gesture.dy) - dragStart.current.top }),
|
|
208
|
+
onPanResponderRelease: (_, gesture) => {
|
|
209
|
+
const startX = dragStart.current.side === 'right' ? width - tab.width : 0;
|
|
210
|
+
const next = { side: (startX + gesture.dx + tab.width / 2 > width / 2 ? 'right' : 'left'), top: clampTop(dragStart.current.top + gesture.dy) };
|
|
211
|
+
tabPlacement = next;
|
|
212
|
+
setPlacement(next);
|
|
213
|
+
drag.setValue({ x: 0, y: 0 });
|
|
214
|
+
},
|
|
215
|
+
onPanResponderTerminate: () => drag.setValue({ x: 0, y: 0 }),
|
|
216
|
+
}), [draggable, drag, width, clampTop]);
|
|
217
|
+
const onTab = async () => {
|
|
218
|
+
if (recording) {
|
|
219
|
+
setExpanded(open => !open || drawing);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await controller.toggle();
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
controller.fail(error);
|
|
227
|
+
onError?.(error);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const stop = () => { setExpanded(false); setDrawing(false); setStrokes([]); void controller.toggle(); };
|
|
231
|
+
const side = placement.side;
|
|
232
|
+
const showTab = phase.kind === 'idle' || starting || recording;
|
|
233
|
+
const panelTop = Math.max(minTop, Math.min(top - 12, height - insets.bottom - 8 - (drawing ? 150 : 170)));
|
|
234
|
+
const panelEdge = { [side]: tab.width + 6 };
|
|
235
|
+
let tabContent;
|
|
236
|
+
if (starting)
|
|
237
|
+
tabContent = (0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { size: "small", color: "#FFFFFF" });
|
|
238
|
+
else if (recording)
|
|
239
|
+
tabContent = (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(RecordingDot, { size: 8 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.tabTime, nearLimit && styles.warning], children: elapsed(ms) }), (0, jsx_runtime_1.jsx)(Waveform, { compact: true })] });
|
|
240
|
+
else
|
|
241
|
+
tabContent = children ?? (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.recordRing, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.recordDot }) });
|
|
242
|
+
const layer = (0, jsx_runtime_1.jsxs)(react_native_1.View, { pointerEvents: "box-none", style: styles.layer, children: [drawing && recording && (0, jsx_runtime_1.jsx)(DrawingCanvas, { strokes: strokes, setStrokes: setStrokes }), showTab && (0, jsx_runtime_1.jsx)(react_native_1.Animated.View, { ...(draggable ? responder.panHandlers : {}), style: [styles.tabPosition, {
|
|
243
|
+
top, [side]: 0, transform: drag.getTranslateTransform(),
|
|
244
|
+
}], children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel ?? (recording ? `Recording, ${Math.floor(ms / 1000)} seconds. Show recording options.` : 'Record a bug report'), accessibilityHint: recording ? undefined : 'Records the screen and your voice.', accessibilityState: { busy: starting, expanded: recording ? expanded : undefined }, disabled: starting, onPress: () => { void onTab(); }, style: [styles.tab, side === 'right' ? styles.tabRight : styles.tabLeft, recording && styles.tabRecording, style], children: tabContent }) }), recording && expanded && (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.panel, panelEdge, { top: panelTop }], children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.panelHeader, children: [(0, jsx_runtime_1.jsx)(RecordingDot, {}), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.panelTime, nearLimit && styles.warning], children: elapsed(ms) }), (0, jsx_runtime_1.jsx)(Waveform, {})] }), drawing ? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.panelHint, children: "Draw on the screen to highlight the problem." }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.panelRow, children: [(0, jsx_runtime_1.jsx)(PanelButton, { label: "Undo", disabled: !strokes.length, onPress: () => setStrokes(all => all.slice(0, -1)) }), (0, jsx_runtime_1.jsx)(PanelButton, { label: "Clear", disabled: !strokes.length, onPress: () => setStrokes([]) })] }), (0, jsx_runtime_1.jsx)(PanelButton, { label: "Done", tone: "primary", onPress: () => { setDrawing(false); setStrokes([]); setExpanded(false); } })] }) : (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(PanelButton, { label: "\u270E Draw on screen", onPress: () => setDrawing(true) }), (0, jsx_runtime_1.jsx)(PanelButton, { label: "\u25A0 Stop & send", tone: "danger", accessibilityLabel: "Stop recording and send the report", onPress: stop })] })] }), (0, jsx_runtime_1.jsx)(StatusToast, { phase: phase })] });
|
|
245
|
+
return FullWindowOverlay
|
|
246
|
+
? (0, jsx_runtime_1.jsx)(FullWindowOverlay, { unstable_accessibilityContainerViewIsModal: false, children: layer })
|
|
247
|
+
: layer;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const styles = react_native_1.StyleSheet.create({
|
|
251
|
+
layer: { ...react_native_1.StyleSheet.absoluteFill, zIndex: 9999, elevation: 9999 },
|
|
252
|
+
tabPosition: { position: 'absolute' },
|
|
253
|
+
tab: {
|
|
254
|
+
width: tab.width, height: tab.height, alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
255
|
+
backgroundColor: 'rgba(16,17,20,0.92)', borderWidth: react_native_1.StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.16)',
|
|
256
|
+
shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 8, shadowOffset: { width: 0, height: 3 }, elevation: 10,
|
|
257
|
+
},
|
|
258
|
+
tabRight: { borderTopLeftRadius: 14, borderBottomLeftRadius: 14, borderRightWidth: 0 },
|
|
259
|
+
tabLeft: { borderTopRightRadius: 14, borderBottomRightRadius: 14, borderLeftWidth: 0 },
|
|
260
|
+
tabRecording: { borderColor: 'rgba(255,59,48,0.5)' },
|
|
261
|
+
tabTime: { color: '#FFFFFF', fontSize: 10, fontWeight: '700', fontVariant: ['tabular-nums'] },
|
|
262
|
+
recordRing: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: 'rgba(255,255,255,0.9)', alignItems: 'center', justifyContent: 'center' },
|
|
263
|
+
recordDot: { width: 11, height: 11, borderRadius: 5.5, backgroundColor: ink },
|
|
264
|
+
warning: { color: '#FFB340' },
|
|
265
|
+
panel: {
|
|
266
|
+
position: 'absolute', width: panelWidth, gap: 8, padding: 12, borderRadius: 18,
|
|
267
|
+
backgroundColor: 'rgba(16,17,20,0.96)', borderWidth: react_native_1.StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.14)',
|
|
268
|
+
shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 14, shadowOffset: { width: 0, height: 6 }, elevation: 14,
|
|
269
|
+
},
|
|
270
|
+
panelHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 2 },
|
|
271
|
+
panelTime: { color: '#FFFFFF', fontSize: 16, fontWeight: '700', fontVariant: ['tabular-nums'], flex: 1 },
|
|
272
|
+
panelHint: { color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 16 },
|
|
273
|
+
panelRow: { flexDirection: 'row', gap: 8 },
|
|
274
|
+
panelButton: { flex: 1, alignItems: 'center', paddingVertical: 10, paddingHorizontal: 12, borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.12)' },
|
|
275
|
+
panelButtonPrimary: { backgroundColor: '#FFFFFF' },
|
|
276
|
+
panelButtonDanger: { backgroundColor: ink },
|
|
277
|
+
panelButtonDisabled: { opacity: 0.4 },
|
|
278
|
+
panelButtonText: { color: '#FFFFFF', fontSize: 14, fontWeight: '700' },
|
|
279
|
+
panelButtonTextPrimary: { color: '#000000' },
|
|
280
|
+
canvas: { ...react_native_1.StyleSheet.absoluteFill, borderWidth: 3, borderColor: 'rgba(255,59,48,0.55)' },
|
|
281
|
+
segment: { position: 'absolute', height: strokeWidth, borderRadius: strokeWidth / 2, backgroundColor: ink },
|
|
282
|
+
toastLayer: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
|
|
283
|
+
toast: {
|
|
284
|
+
flexDirection: 'row', alignItems: 'center', gap: 10, maxWidth: 340, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 22,
|
|
285
|
+
backgroundColor: 'rgba(16,17,20,0.96)', shadowColor: '#000', shadowOpacity: 0.3, shadowRadius: 12, shadowOffset: { width: 0, height: 5 }, elevation: 14,
|
|
286
|
+
},
|
|
287
|
+
toastText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600', flexShrink: 1 },
|
|
288
|
+
wave: { flexDirection: 'row', alignItems: 'center', gap: 3, height: 18 },
|
|
289
|
+
waveCompact: { gap: 2, height: 12 },
|
|
290
|
+
waveBar: { width: 3, height: 18, borderRadius: 1.5, backgroundColor: '#FF6B61' },
|
|
291
|
+
waveBarCompact: { width: 2, height: 12, borderRadius: 1 },
|
|
292
|
+
waveBarIdle: { backgroundColor: 'rgba(255,255,255,0.45)' },
|
|
293
|
+
badge: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' },
|
|
294
|
+
badgeSuccess: { backgroundColor: '#30D158' },
|
|
295
|
+
badgeError: { backgroundColor: '#FF453A' },
|
|
296
|
+
badgeText: { color: '#000000', fontSize: 13, fontWeight: '800' },
|
|
297
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bugsession/react-native",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"peerDependencies": {
|
|
8
8
|
"react": ">=18.3.1 <20",
|
|
9
|
-
"react-native": ">=0.76.0 <0.89"
|
|
9
|
+
"react-native": ">=0.76.0 <0.89",
|
|
10
|
+
"react-native-screens": ">=4.0.0"
|
|
10
11
|
},
|
|
11
12
|
"description": "Opt-in React Native runtime evidence SDK",
|
|
12
13
|
"license": "UNLICENSED",
|
|
@@ -42,5 +43,10 @@
|
|
|
42
43
|
"homepage": "https://github.com/Anis-Ghliss/bugsession/tree/main/packages/react-native#readme",
|
|
43
44
|
"bugs": {
|
|
44
45
|
"url": "https://github.com/Anis-Ghliss/bugsession/issues"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react-native-screens": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
}
|
package/src/capture.ts
CHANGED
|
@@ -12,6 +12,12 @@ export type NativeCaptureAdapter = {
|
|
|
12
12
|
stop(): Promise<NativeRecording>;
|
|
13
13
|
cancel(): Promise<void>;
|
|
14
14
|
upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
|
|
15
|
+
/** Optional: live microphone level (0-1) for the recording indicator. Returns an unsubscribe function. */
|
|
16
|
+
onAudioLevel?(listener: (level: number) => void): () => void;
|
|
17
|
+
/** Optional: safe-area insets in points, used to place the recording controls above the home indicator. */
|
|
18
|
+
safeAreaInsets?(): Promise<{ top: number; bottom: number } | null>;
|
|
19
|
+
/** Optional: native app and device facts merged into each recording's metadata. */
|
|
20
|
+
appContext?(): Promise<{ application: Record<string, unknown>; device: Record<string, unknown> } | null>;
|
|
15
21
|
};
|
|
16
22
|
export function alignNativeRecording(recording: NativeRecording, offsetMs: number, uncertaintyMs: number) {
|
|
17
23
|
const videoStartMs = recording.firstVideoNativeMs + offsetMs;
|
package/src/core.ts
CHANGED
|
@@ -20,17 +20,81 @@ export class SessionClock {
|
|
|
20
20
|
private epoch: number;
|
|
21
21
|
constructor(private source = () => performance.now()) { this.epoch = source(); }
|
|
22
22
|
now() { return Math.max(0, this.source() - this.epoch); }
|
|
23
|
+
/** Monotonic source reading at which this clock reads zero. */
|
|
24
|
+
get origin() { return this.epoch; }
|
|
23
25
|
}
|
|
24
26
|
export class Collector {
|
|
25
27
|
events: Evidence[] = [];
|
|
26
28
|
dropped = 0;
|
|
27
29
|
constructor(public clock = new SessionClock(), private limit = 5000) {}
|
|
28
30
|
add(type: string, data: Record<string, unknown>) {
|
|
31
|
+
this.addAt(type, data, this.clock.now());
|
|
32
|
+
}
|
|
33
|
+
addAt(type: string, data: Record<string, unknown>, timestampMs: number) {
|
|
29
34
|
if (this.events.length >= this.limit) { this.dropped++; return; }
|
|
30
|
-
this.events.push({ ...data, type, timestampMs
|
|
35
|
+
this.events.push({ ...data, type, timestampMs });
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
|
-
|
|
38
|
+
|
|
39
|
+
export type TelemetrySink = { add(type: string, data: Record<string, unknown>): void; clock: { now(): number } };
|
|
40
|
+
export const MAX_PRE_RECORD_MS = 300_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Routes app events to the active session, or, between sessions, into a bounded
|
|
44
|
+
* in-memory buffer of the last `preRecordMs`. The buffer never leaves the device
|
|
45
|
+
* unless a recording starts. Timestamps are kept on one app-lifetime clock and
|
|
46
|
+
* converted to the session clock, so requests spanning the start stay consistent.
|
|
47
|
+
*/
|
|
48
|
+
export class Telemetry implements TelemetrySink {
|
|
49
|
+
readonly clock: SessionClock;
|
|
50
|
+
private buffer: Evidence[] = [];
|
|
51
|
+
private session: { collector: Collector; offsetMs: number } | null = null;
|
|
52
|
+
private preRecordMs: number;
|
|
53
|
+
constructor(preRecordMs = 60_000, private maxBuffered = 500, source = () => performance.now()) {
|
|
54
|
+
this.clock = new SessionClock(source);
|
|
55
|
+
this.preRecordMs = Math.max(0, Math.min(preRecordMs, MAX_PRE_RECORD_MS));
|
|
56
|
+
}
|
|
57
|
+
/** False when nothing would be kept, so callers can skip formatting work. */
|
|
58
|
+
get active() { return !!this.session || this.preRecordMs > 0; }
|
|
59
|
+
configure(preRecordMs: number, maxBuffered = this.maxBuffered) {
|
|
60
|
+
this.preRecordMs = Math.max(0, Math.min(preRecordMs, MAX_PRE_RECORD_MS));
|
|
61
|
+
this.maxBuffered = maxBuffered;
|
|
62
|
+
this.prune(this.clock.now());
|
|
63
|
+
}
|
|
64
|
+
add(type: string, data: Record<string, unknown>) {
|
|
65
|
+
const now = this.clock.now();
|
|
66
|
+
if (this.session) {
|
|
67
|
+
const { collector, offsetMs } = this.session;
|
|
68
|
+
collector.addAt(type, shiftRequestTime(data, offsetMs), now - offsetMs);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (this.preRecordMs <= 0) return;
|
|
72
|
+
this.buffer.push({ ...data, type, timestampMs: now });
|
|
73
|
+
this.prune(now);
|
|
74
|
+
}
|
|
75
|
+
/** Routes events to `collector` and returns the buffered events on its clock (negative times). */
|
|
76
|
+
begin(collector: Collector): Evidence[] {
|
|
77
|
+
const offsetMs = collector.clock.origin - this.clock.origin;
|
|
78
|
+
this.prune(this.clock.now());
|
|
79
|
+
const before = this.buffer.map(e => ({ ...shiftRequestTime(e, offsetMs), timestampMs: e.timestampMs - offsetMs, preRecording: true }));
|
|
80
|
+
this.buffer = [];
|
|
81
|
+
this.session = { collector, offsetMs };
|
|
82
|
+
return before;
|
|
83
|
+
}
|
|
84
|
+
end() { this.session = null; }
|
|
85
|
+
private prune(now: number) {
|
|
86
|
+
const cutoff = now - this.preRecordMs;
|
|
87
|
+
let drop = 0;
|
|
88
|
+
while (drop < this.buffer.length && this.buffer[drop].timestampMs < cutoff) drop++;
|
|
89
|
+
drop = Math.max(drop, this.buffer.length - this.maxBuffered);
|
|
90
|
+
if (drop > 0) this.buffer.splice(0, drop);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function shiftRequestTime<T extends Record<string, unknown>>(data: T, offsetMs: number): T {
|
|
94
|
+
return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch {
|
|
34
98
|
return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
35
99
|
const url = safeUrl(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
36
100
|
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|