@fixback/expo 0.1.0
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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/FeedbackModal.d.ts +23 -0
- package/dist/FeedbackModal.js +201 -0
- package/dist/FixbackProvider.d.ts +35 -0
- package/dist/FixbackProvider.js +102 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +143 -0
- package/dist/auto-report-backoff.d.ts +42 -0
- package/dist/auto-report-backoff.js +67 -0
- package/dist/boot.d.ts +65 -0
- package/dist/boot.js +60 -0
- package/dist/breadcrumbs.d.ts +276 -0
- package/dist/breadcrumbs.js +731 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +351 -0
- package/dist/error-capture.d.ts +168 -0
- package/dist/error-capture.js +395 -0
- package/dist/http.d.ts +40 -0
- package/dist/http.js +20 -0
- package/dist/identity.d.ts +25 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +10 -0
- package/dist/report.d.ts +93 -0
- package/dist/report.js +67 -0
- package/dist/scrub.d.ts +55 -0
- package/dist/scrub.js +184 -0
- package/dist/shake.d.ts +51 -0
- package/dist/shake.js +79 -0
- package/dist/submit.d.ts +98 -0
- package/dist/submit.js +154 -0
- package/dist/tokens.d.ts +41 -0
- package/dist/tokens.js +46 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +13 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fixback
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @fixback/expo
|
|
2
|
+
|
|
3
|
+
The [Fixback](https://github.com/wemuda/fixback) capture SDK for **Expo / React
|
|
4
|
+
Native apps**: shake the device to open a feedback composer, and every report
|
|
5
|
+
ships with trace Evidence — console output, network metadata, screen changes —
|
|
6
|
+
plus an optional screenshot. Uncaught JS errors report themselves
|
|
7
|
+
(`source: auto`), exactly like the web SDK.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @fixback/expo
|
|
13
|
+
npx expo install expo-sensors react-native-view-shot @react-native-async-storage/async-storage
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The three Expo-ecosystem packages are peer dependencies (they carry the native
|
|
17
|
+
modules for the accelerometer, screenshots, and id persistence). All of them
|
|
18
|
+
work in Expo Go.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { FixbackProvider } from "@fixback/expo";
|
|
24
|
+
|
|
25
|
+
export default function App() {
|
|
26
|
+
return (
|
|
27
|
+
<FixbackProvider
|
|
28
|
+
options={{
|
|
29
|
+
key: "pk_…", // the Project's publishable key
|
|
30
|
+
origin: "https://com.acme.myapp", // an origin allowlisted on the Project
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<RootNavigator />
|
|
34
|
+
</FixbackProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Shake the device → the composer opens → write a comment, review (or remove)
|
|
40
|
+
the screenshot, send. There is no Kind to pick — Fixback's analysis classifies
|
|
41
|
+
the Issue (bug / improvement / idea) server-side.
|
|
42
|
+
|
|
43
|
+
### The `origin` option
|
|
44
|
+
|
|
45
|
+
Fixback's ingest matches every request's `Origin` header against the Project's
|
|
46
|
+
allowed origins. Browsers send that header automatically; a native app does
|
|
47
|
+
not, so the SDK sends the `origin` you configure — canonicalized the same way
|
|
48
|
+
the dashboard stores allowlist entries (lowercased host, default ports elided,
|
|
49
|
+
path dropped), so a pasted mixed-case bundle id still matches. Add the same
|
|
50
|
+
value to your Project's **allowed origins** in the Fixback dashboard.
|
|
51
|
+
Convention: your app's reverse-DNS id as an https host
|
|
52
|
+
(`https://com.acme.myapp`), or simply your product's existing web origin.
|
|
53
|
+
Until the origin is allowlisted the SDK stays dormant (a dev-build warning
|
|
54
|
+
explains why).
|
|
55
|
+
|
|
56
|
+
## Triggers
|
|
57
|
+
|
|
58
|
+
- **Shake** (default): tuned to a deliberate shake — threshold, peak count,
|
|
59
|
+
window, and cooldown are configurable via `options.shake`;
|
|
60
|
+
`shake: { enabled: false }` turns the gesture off.
|
|
61
|
+
- **Programmatic**: `useFixback().present()` in a component, or the
|
|
62
|
+
module-level `Fixback.present()` anywhere.
|
|
63
|
+
|
|
64
|
+
> **Development builds:** the Expo dev menu also opens on shake, so both
|
|
65
|
+
> appear. Use `present()` while developing; production builds have no dev
|
|
66
|
+
> menu.
|
|
67
|
+
|
|
68
|
+
## Screens in reports
|
|
69
|
+
|
|
70
|
+
React Native has no URL, so tell Fixback where the Reporter is:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
const { trackScreen } = useFixback();
|
|
74
|
+
// e.g. in your navigation container's state-change handler:
|
|
75
|
+
trackScreen(routeName);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Each change records a `navigation` trace crumb, and the active screen names
|
|
79
|
+
the report's URL (`<origin>/<screen>`).
|
|
80
|
+
|
|
81
|
+
## Options
|
|
82
|
+
|
|
83
|
+
| Option | Default | What it does |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `key` | — | The Project's publishable key (required). |
|
|
86
|
+
| `origin` | — | The origin sent on every request (required in practice — see above). |
|
|
87
|
+
| `apiUrl` | `https://api.fixback.dev` | Self-hosted Fixback API base. |
|
|
88
|
+
| `signedIdentity` | — | A server-minted identity JWT (`internal` tier). |
|
|
89
|
+
| `reporterName` / `reporterEmail` | — | Display-only reporter fields. |
|
|
90
|
+
| `capture` | server config | `{ console?, network? }` per-stream trace toggles. |
|
|
91
|
+
| `autoCapture` | `true` | Automatic uncaught-error reports. |
|
|
92
|
+
| `screenshots` | `true` | Capture a screenshot when the composer opens. |
|
|
93
|
+
| `beforeSend` | — | Reshape or drop (`null`) any report before transport. |
|
|
94
|
+
| `scrub` | `true` | The built-in URL/PII scrubbers. |
|
|
95
|
+
| `beforeBreadcrumb` | — | Filter/edit trace entries at the source. |
|
|
96
|
+
| `shake` | on | `{ enabled?, thresholdG?, minPeaks?, windowMs?, minGapMs?, cooldownMs?, sampleIntervalMs? }`. |
|
|
97
|
+
|
|
98
|
+
## Privacy
|
|
99
|
+
|
|
100
|
+
The same client-side scrubbing as the web SDK: URLs lose query strings and
|
|
101
|
+
path PII, console/error text loses emails, bearer tokens, and long digit runs,
|
|
102
|
+
network crumbs never carry bodies or headers. One difference is deliberate:
|
|
103
|
+
mobile screenshots are **not** auto-masked — so a screenshot only ever ships
|
|
104
|
+
from the composer, where the Reporter sees exactly what will be sent and can
|
|
105
|
+
remove it. **Automatic error reports never attach a screenshot.** A screenshot
|
|
106
|
+
the Reporter removes (or that has been delivered) is deleted from the app's
|
|
107
|
+
cache, and `screenshots: false` disables capture entirely. `beforeSend`
|
|
108
|
+
remains the programmatic choke point.
|
|
109
|
+
|
|
110
|
+
## What's not here (yet)
|
|
111
|
+
|
|
112
|
+
Invite redemption (deep links), element annotation/drawing, session replay,
|
|
113
|
+
unhandled-promise-rejection and native-crash capture. See the repo's
|
|
114
|
+
`docs/specs/0004-expo-reporter-sdk.md` and ADR-0021 for the full contract.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The feedback **composer** — the mobile counterpart of the web overlay's
|
|
3
|
+
* report panel (spec 0004 §C), styled in the Signal language from the vendored
|
|
4
|
+
* token slice. Opens on shake (or `present()`), collects Kind + comment, shows
|
|
5
|
+
* the exact screenshot that will ship (removable — the mobile privacy stance,
|
|
6
|
+
* ADR-0021), and submits through the client the provider hands in.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately thin: every decision beyond local UI state lives in
|
|
9
|
+
* `client.ts`, which is where the behaviour is unit-tested.
|
|
10
|
+
*/
|
|
11
|
+
import type { ComposerDraft } from "./client";
|
|
12
|
+
import type { ScreenshotFile, SubmitResult } from "./submit";
|
|
13
|
+
/** What the provider wires into the composer. */
|
|
14
|
+
export interface FeedbackModalProps {
|
|
15
|
+
readonly visible: boolean;
|
|
16
|
+
/** The screenshot captured as the composer opened, if any. */
|
|
17
|
+
readonly screenshot: ScreenshotFile | null;
|
|
18
|
+
readonly onDismiss: () => void;
|
|
19
|
+
readonly onSubmit: (draft: ComposerDraft) => Promise<SubmitResult>;
|
|
20
|
+
/** Release a screenshot the Reporter removed or abandoned (spec 0004 §C). */
|
|
21
|
+
readonly onDiscardScreenshot?: (screenshot: ScreenshotFile) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function FeedbackModal({ visible, screenshot, onDismiss, onSubmit, onDiscardScreenshot, }: FeedbackModalProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The feedback **composer** — the mobile counterpart of the web overlay's
|
|
4
|
+
* report panel (spec 0004 §C), styled in the Signal language from the vendored
|
|
5
|
+
* token slice. Opens on shake (or `present()`), collects Kind + comment, shows
|
|
6
|
+
* the exact screenshot that will ship (removable — the mobile privacy stance,
|
|
7
|
+
* ADR-0021), and submits through the client the provider hands in.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately thin: every decision beyond local UI state lives in
|
|
10
|
+
* `client.ts`, which is where the behaviour is unit-tested.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useRef, useState } from "react";
|
|
13
|
+
import { ActivityIndicator, Image, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View, } from "react-native";
|
|
14
|
+
import { signal } from "./tokens";
|
|
15
|
+
const MONO_FONT = Platform.select({ ios: "Menlo", default: "monospace" });
|
|
16
|
+
/** How long the "Sent" confirmation lingers before the composer closes. */
|
|
17
|
+
const SENT_DISMISS_MS = 900;
|
|
18
|
+
export function FeedbackModal({ visible, screenshot, onDismiss, onSubmit, onDiscardScreenshot, }) {
|
|
19
|
+
const [comment, setComment] = useState("");
|
|
20
|
+
const [includeScreenshot, setIncludeScreenshot] = useState(true);
|
|
21
|
+
const [phase, setPhase] = useState("compose");
|
|
22
|
+
const dismissTimer = useRef(null);
|
|
23
|
+
// A fresh open starts a fresh draft.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (visible) {
|
|
26
|
+
setComment("");
|
|
27
|
+
setIncludeScreenshot(true);
|
|
28
|
+
setPhase("compose");
|
|
29
|
+
}
|
|
30
|
+
return () => {
|
|
31
|
+
if (dismissTimer.current) {
|
|
32
|
+
clearTimeout(dismissTimer.current);
|
|
33
|
+
dismissTimer.current = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}, [visible]);
|
|
37
|
+
const busy = phase === "sending" || phase === "sent";
|
|
38
|
+
async function send() {
|
|
39
|
+
if (busy)
|
|
40
|
+
return;
|
|
41
|
+
setPhase("sending");
|
|
42
|
+
// No Kind on the draft — analysis classifies the Issue server-side (ADR-0023).
|
|
43
|
+
const result = await onSubmit({
|
|
44
|
+
comment,
|
|
45
|
+
screenshot: includeScreenshot ? screenshot : null,
|
|
46
|
+
});
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
setPhase("sent");
|
|
49
|
+
dismissTimer.current = setTimeout(onDismiss, SENT_DISMISS_MS);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
setPhase("failed");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Dismissal is never blocked — a stalled submission must not trap the
|
|
57
|
+
* Reporter in the composer (the in-flight request continues harmlessly).
|
|
58
|
+
* A draft's unsent screenshot is released on the way out; while sending or
|
|
59
|
+
* sent, the transport owns the file (released by the client on delivery).
|
|
60
|
+
*/
|
|
61
|
+
function dismiss() {
|
|
62
|
+
if (screenshot &&
|
|
63
|
+
includeScreenshot &&
|
|
64
|
+
(phase === "compose" || phase === "failed")) {
|
|
65
|
+
onDiscardScreenshot?.(screenshot);
|
|
66
|
+
}
|
|
67
|
+
onDismiss();
|
|
68
|
+
}
|
|
69
|
+
function removeScreenshot() {
|
|
70
|
+
if (screenshot)
|
|
71
|
+
onDiscardScreenshot?.(screenshot);
|
|
72
|
+
setIncludeScreenshot(false);
|
|
73
|
+
}
|
|
74
|
+
return (_jsx(Modal, { visible: visible, transparent: true, animationType: "slide", onRequestClose: dismiss, children: _jsxs(View, { style: styles.backdrop, children: [_jsx(Pressable, { style: styles.backdropTouch, accessibilityLabel: "Dismiss feedback", onPress: dismiss }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.sheet, children: [_jsx(Text, { style: styles.overline, children: "FIXBACK \u00B7 REPORT" }), _jsx(Text, { style: styles.title, children: "Send feedback" }), _jsx(TextInput, { style: styles.comment, multiline: true, editable: !busy, placeholder: "What happened? What did you expect?", placeholderTextColor: signal.textGhost, value: comment, onChangeText: setComment, accessibilityLabel: "Feedback comment" }), screenshot && includeScreenshot ? (_jsxs(View, { style: styles.screenshotRow, children: [_jsx(Image, { source: { uri: screenshot.uri }, style: styles.screenshotThumb, resizeMode: "cover", accessibilityLabel: "Screenshot preview" }), _jsxs(View, { style: styles.screenshotMeta, children: [_jsx(Text, { style: styles.screenshotLabel, children: "Screenshot attached" }), _jsx(Text, { style: styles.screenshotHint, children: "Exactly this image is sent \u2014 remove it if it shows something private." })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Remove screenshot", disabled: busy, onPress: removeScreenshot, children: _jsx(Text, { style: styles.removeLabel, children: "Remove" }) })] })) : null, phase === "failed" ? (_jsx(Text, { style: styles.failed, children: "Couldn't send \u2014 check your connection and try again." })) : null, phase === "sent" ? (_jsx(Text, { style: styles.sent, children: "Sent \u2014 thank you." })) : null, _jsxs(View, { style: styles.footer, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel", onPress: dismiss, style: styles.cancelButton, children: _jsx(Text, { style: styles.cancelLabel, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Send feedback", disabled: busy, onPress: () => void send(), style: [styles.sendButton, busy && styles.sendButtonBusy], children: phase === "sending" ? (_jsx(ActivityIndicator, { size: "small", color: signal.onEmphasis })) : (_jsx(Text, { style: styles.sendLabel, children: "Send" })) })] })] }) })] }) }));
|
|
75
|
+
}
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
backdrop: {
|
|
78
|
+
flex: 1,
|
|
79
|
+
justifyContent: "flex-end",
|
|
80
|
+
backgroundColor: signal.scrim,
|
|
81
|
+
},
|
|
82
|
+
backdropTouch: {
|
|
83
|
+
flex: 1,
|
|
84
|
+
},
|
|
85
|
+
sheet: {
|
|
86
|
+
backgroundColor: signal.surface,
|
|
87
|
+
borderTopLeftRadius: 14,
|
|
88
|
+
borderTopRightRadius: 14,
|
|
89
|
+
borderColor: signal.border,
|
|
90
|
+
borderWidth: 1,
|
|
91
|
+
paddingHorizontal: 16,
|
|
92
|
+
paddingTop: 14,
|
|
93
|
+
paddingBottom: 24,
|
|
94
|
+
},
|
|
95
|
+
overline: {
|
|
96
|
+
fontFamily: MONO_FONT,
|
|
97
|
+
fontSize: 10,
|
|
98
|
+
letterSpacing: 1.2,
|
|
99
|
+
color: signal.textFaint,
|
|
100
|
+
marginBottom: 2,
|
|
101
|
+
},
|
|
102
|
+
title: {
|
|
103
|
+
fontSize: 15,
|
|
104
|
+
fontWeight: "600",
|
|
105
|
+
color: signal.text,
|
|
106
|
+
marginBottom: 12,
|
|
107
|
+
},
|
|
108
|
+
comment: {
|
|
109
|
+
minHeight: 96,
|
|
110
|
+
borderWidth: 1,
|
|
111
|
+
borderColor: signal.borderControl,
|
|
112
|
+
borderRadius: 8,
|
|
113
|
+
backgroundColor: signal.canvas,
|
|
114
|
+
color: signal.text,
|
|
115
|
+
fontSize: 13,
|
|
116
|
+
padding: 10,
|
|
117
|
+
textAlignVertical: "top",
|
|
118
|
+
marginBottom: 12,
|
|
119
|
+
},
|
|
120
|
+
screenshotRow: {
|
|
121
|
+
flexDirection: "row",
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
gap: 10,
|
|
124
|
+
borderWidth: 1,
|
|
125
|
+
borderColor: signal.border,
|
|
126
|
+
borderRadius: 8,
|
|
127
|
+
backgroundColor: signal.sunken,
|
|
128
|
+
padding: 8,
|
|
129
|
+
marginBottom: 12,
|
|
130
|
+
},
|
|
131
|
+
screenshotThumb: {
|
|
132
|
+
width: 40,
|
|
133
|
+
height: 72,
|
|
134
|
+
borderRadius: 4,
|
|
135
|
+
borderWidth: 1,
|
|
136
|
+
borderColor: signal.borderStrong,
|
|
137
|
+
backgroundColor: signal.surface,
|
|
138
|
+
},
|
|
139
|
+
screenshotMeta: {
|
|
140
|
+
flex: 1,
|
|
141
|
+
},
|
|
142
|
+
screenshotLabel: {
|
|
143
|
+
fontSize: 12,
|
|
144
|
+
fontWeight: "600",
|
|
145
|
+
color: signal.textPanel,
|
|
146
|
+
},
|
|
147
|
+
screenshotHint: {
|
|
148
|
+
fontSize: 11,
|
|
149
|
+
color: signal.textFaint,
|
|
150
|
+
marginTop: 2,
|
|
151
|
+
},
|
|
152
|
+
removeLabel: {
|
|
153
|
+
fontSize: 12,
|
|
154
|
+
fontWeight: "600",
|
|
155
|
+
color: signal.danger,
|
|
156
|
+
padding: 4,
|
|
157
|
+
},
|
|
158
|
+
failed: {
|
|
159
|
+
fontSize: 12,
|
|
160
|
+
color: signal.danger,
|
|
161
|
+
marginBottom: 10,
|
|
162
|
+
},
|
|
163
|
+
sent: {
|
|
164
|
+
fontSize: 12,
|
|
165
|
+
color: signal.success,
|
|
166
|
+
marginBottom: 10,
|
|
167
|
+
},
|
|
168
|
+
footer: {
|
|
169
|
+
flexDirection: "row",
|
|
170
|
+
justifyContent: "flex-end",
|
|
171
|
+
gap: 8,
|
|
172
|
+
},
|
|
173
|
+
cancelButton: {
|
|
174
|
+
borderWidth: 1,
|
|
175
|
+
borderColor: signal.borderControl,
|
|
176
|
+
borderRadius: 8,
|
|
177
|
+
paddingHorizontal: 14,
|
|
178
|
+
paddingVertical: 9,
|
|
179
|
+
backgroundColor: signal.surface,
|
|
180
|
+
},
|
|
181
|
+
cancelLabel: {
|
|
182
|
+
fontSize: 13,
|
|
183
|
+
color: signal.textMuted,
|
|
184
|
+
},
|
|
185
|
+
sendButton: {
|
|
186
|
+
borderRadius: 8,
|
|
187
|
+
paddingHorizontal: 18,
|
|
188
|
+
paddingVertical: 9,
|
|
189
|
+
backgroundColor: signal.accent,
|
|
190
|
+
minWidth: 72,
|
|
191
|
+
alignItems: "center",
|
|
192
|
+
},
|
|
193
|
+
sendButtonBusy: {
|
|
194
|
+
opacity: 0.8,
|
|
195
|
+
},
|
|
196
|
+
sendLabel: {
|
|
197
|
+
fontSize: 13,
|
|
198
|
+
fontWeight: "600",
|
|
199
|
+
color: signal.onEmphasis,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FixbackProvider>` — the one component a host app mounts (spec 0004 §A).
|
|
3
|
+
*
|
|
4
|
+
* A thin React shell over the headless client: it starts/stops the client with
|
|
5
|
+
* the component lifecycle, arms the shake gesture while the client is ready,
|
|
6
|
+
* listens for composer-open requests, and renders the composer modal inside
|
|
7
|
+
* the app's own tree. `useFixback()` (and the module-level `Fixback` object,
|
|
8
|
+
* for non-React call sites) expose `present` / `dismiss` / `trackScreen`.
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactNode } from "react";
|
|
11
|
+
import { type FixbackOptions } from "./client";
|
|
12
|
+
/** What `useFixback()` returns. */
|
|
13
|
+
export interface FixbackHandle {
|
|
14
|
+
/** Open the feedback composer (a no-op while the SDK is dormant). */
|
|
15
|
+
present: () => void;
|
|
16
|
+
/** Close the composer if it is open. */
|
|
17
|
+
dismiss: () => void;
|
|
18
|
+
/** Record a screen change for the trace and the report `url` (spec 0004 §D). */
|
|
19
|
+
trackScreen: (name: string) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The provider's handle, or an inert one outside a provider — the SDK never
|
|
23
|
+
* throws into the host app, hooks included.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useFixback(): FixbackHandle;
|
|
26
|
+
/**
|
|
27
|
+
* The module-level escape hatch (spec 0004 §B): `Fixback.present()` from any
|
|
28
|
+
* plain function — a settings row, a dev menu item, an error boundary.
|
|
29
|
+
*/
|
|
30
|
+
export declare const Fixback: FixbackHandle;
|
|
31
|
+
export interface FixbackProviderProps {
|
|
32
|
+
readonly options: FixbackOptions;
|
|
33
|
+
readonly children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
export declare function FixbackProvider({ options, children }: FixbackProviderProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* `<FixbackProvider>` — the one component a host app mounts (spec 0004 §A).
|
|
4
|
+
*
|
|
5
|
+
* A thin React shell over the headless client: it starts/stops the client with
|
|
6
|
+
* the component lifecycle, arms the shake gesture while the client is ready,
|
|
7
|
+
* listens for composer-open requests, and renders the composer modal inside
|
|
8
|
+
* the app's own tree. `useFixback()` (and the module-level `Fixback` object,
|
|
9
|
+
* for non-React call sites) expose `present` / `dismiss` / `trackScreen`.
|
|
10
|
+
*/
|
|
11
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
12
|
+
import { createNativeAdapters, startShakeListener } from "./adapters";
|
|
13
|
+
import { createFixbackClient, } from "./client";
|
|
14
|
+
import { FeedbackModal } from "./FeedbackModal";
|
|
15
|
+
import { createShakeDetector } from "./shake";
|
|
16
|
+
const NOOP_HANDLE = {
|
|
17
|
+
present: () => { },
|
|
18
|
+
dismiss: () => { },
|
|
19
|
+
trackScreen: () => { },
|
|
20
|
+
};
|
|
21
|
+
const FixbackContext = createContext(null);
|
|
22
|
+
/**
|
|
23
|
+
* The provider's handle, or an inert one outside a provider — the SDK never
|
|
24
|
+
* throws into the host app, hooks included.
|
|
25
|
+
*/
|
|
26
|
+
export function useFixback() {
|
|
27
|
+
return useContext(FixbackContext) ?? NOOP_HANDLE;
|
|
28
|
+
}
|
|
29
|
+
/** The most recently mounted provider's handle, for non-React call sites. */
|
|
30
|
+
let activeHandle = null;
|
|
31
|
+
/**
|
|
32
|
+
* The module-level escape hatch (spec 0004 §B): `Fixback.present()` from any
|
|
33
|
+
* plain function — a settings row, a dev menu item, an error boundary.
|
|
34
|
+
*/
|
|
35
|
+
export const Fixback = {
|
|
36
|
+
present: () => activeHandle?.present(),
|
|
37
|
+
dismiss: () => activeHandle?.dismiss(),
|
|
38
|
+
trackScreen: (name) => activeHandle?.trackScreen(name),
|
|
39
|
+
};
|
|
40
|
+
export function FixbackProvider({ options, children }) {
|
|
41
|
+
// The client is created once per mounted provider; option changes after
|
|
42
|
+
// mount are deliberately ignored (remount to reconfigure), matching the web
|
|
43
|
+
// SDK's one-shot `init`.
|
|
44
|
+
const [client] = useState(() => createFixbackClient(options, createNativeAdapters()));
|
|
45
|
+
const [status, setStatus] = useState(client.getStatus());
|
|
46
|
+
const [composer, setComposer] = useState(null);
|
|
47
|
+
const composerOpenRef = useRef(false);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const unsubscribe = client.onStatus(setStatus);
|
|
50
|
+
void client.start();
|
|
51
|
+
return () => {
|
|
52
|
+
unsubscribe();
|
|
53
|
+
client.destroy();
|
|
54
|
+
};
|
|
55
|
+
}, [client]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
return client.onPresent((context) => {
|
|
58
|
+
if (composerOpenRef.current) {
|
|
59
|
+
// The composer is already open — this capture is never shown, so
|
|
60
|
+
// release its tmpfile instead of leaking it.
|
|
61
|
+
client.discardScreenshot(context.screenshot);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
composerOpenRef.current = true;
|
|
65
|
+
setComposer(context);
|
|
66
|
+
});
|
|
67
|
+
}, [client]);
|
|
68
|
+
// The shake gesture is armed only while the client can actually submit, so
|
|
69
|
+
// a dormant SDK never keeps the accelerometer running.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (status !== "ready")
|
|
72
|
+
return;
|
|
73
|
+
const config = client.shakeConfig();
|
|
74
|
+
if (!config.enabled)
|
|
75
|
+
return;
|
|
76
|
+
const detector = createShakeDetector({
|
|
77
|
+
...config,
|
|
78
|
+
onShake: () => {
|
|
79
|
+
if (!composerOpenRef.current)
|
|
80
|
+
void client.present();
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const subscription = startShakeListener((sample) => detector.sample(sample), config.sampleIntervalMs);
|
|
84
|
+
return () => subscription.remove();
|
|
85
|
+
}, [client, status]);
|
|
86
|
+
const handle = useMemo(() => ({
|
|
87
|
+
present: () => void client.present(),
|
|
88
|
+
dismiss: () => {
|
|
89
|
+
composerOpenRef.current = false;
|
|
90
|
+
setComposer(null);
|
|
91
|
+
},
|
|
92
|
+
trackScreen: (name) => client.trackScreen(name),
|
|
93
|
+
}), [client]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
activeHandle = handle;
|
|
96
|
+
return () => {
|
|
97
|
+
if (activeHandle === handle)
|
|
98
|
+
activeHandle = null;
|
|
99
|
+
};
|
|
100
|
+
}, [handle]);
|
|
101
|
+
return (_jsxs(FixbackContext.Provider, { value: handle, children: [children, _jsx(FeedbackModal, { visible: composer !== null, screenshot: composer?.screenshot ?? null, onDismiss: handle.dismiss, onSubmit: (draft) => client.submit(draft), onDiscardScreenshot: (shot) => client.discardScreenshot(shot) })] }));
|
|
102
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real React Native / Expo adapters behind the client's injectable seams
|
|
3
|
+
* (`FixbackAdapters`), plus the accelerometer wiring for the shake gesture.
|
|
4
|
+
*
|
|
5
|
+
* This is the only module (besides the UI) that imports platform packages, so
|
|
6
|
+
* everything else stays unit-testable in plain node. Every adapter is
|
|
7
|
+
* defensive: a missing native module or a capture failure degrades that one
|
|
8
|
+
* capability instead of throwing into the host app.
|
|
9
|
+
*/
|
|
10
|
+
import type { FixbackAdapters } from "./client";
|
|
11
|
+
import type { ShakeSample } from "./shake";
|
|
12
|
+
/** Build the real adapter set `FixbackProvider` hands to the client. */
|
|
13
|
+
export declare function createNativeAdapters(): FixbackAdapters;
|
|
14
|
+
/** How often the accelerometer reports while the gesture is armed. */
|
|
15
|
+
export declare const SHAKE_UPDATE_INTERVAL_MS = 80;
|
|
16
|
+
/** A removable shake-listener handle. */
|
|
17
|
+
export interface ShakeSubscription {
|
|
18
|
+
remove(): void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Feed accelerometer samples to a listener (the shake detector). Availability
|
|
22
|
+
* is checked first (a simulator has no accelerometer); an unavailable or
|
|
23
|
+
* missing sensor resolves to an inert subscription. `remove` also cancels a
|
|
24
|
+
* subscription still waiting on the availability check.
|
|
25
|
+
*/
|
|
26
|
+
export declare function startShakeListener(onSample: (sample: ShakeSample) => void, updateIntervalMs?: number): ShakeSubscription;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real React Native / Expo adapters behind the client's injectable seams
|
|
3
|
+
* (`FixbackAdapters`), plus the accelerometer wiring for the shake gesture.
|
|
4
|
+
*
|
|
5
|
+
* This is the only module (besides the UI) that imports platform packages, so
|
|
6
|
+
* everything else stays unit-testable in plain node. Every adapter is
|
|
7
|
+
* defensive: a missing native module or a capture failure degrades that one
|
|
8
|
+
* capability instead of throwing into the host app.
|
|
9
|
+
*/
|
|
10
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
11
|
+
import { Accelerometer } from "expo-sensors";
|
|
12
|
+
import { AppState, Dimensions, Platform } from "react-native";
|
|
13
|
+
import { captureScreen, releaseCapture } from "react-native-view-shot";
|
|
14
|
+
import { globalConsole, globalXhr } from "./breadcrumbs";
|
|
15
|
+
import { globalErrorUtils } from "./error-capture";
|
|
16
|
+
/** Read the device environment: window size + `<os> <version>`. */
|
|
17
|
+
function readEnvironment() {
|
|
18
|
+
try {
|
|
19
|
+
const { width, height } = Dimensions.get("window");
|
|
20
|
+
return {
|
|
21
|
+
windowWidth: width,
|
|
22
|
+
windowHeight: height,
|
|
23
|
+
os: `${Platform.OS} ${String(Platform.Version)}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Capture the current screen as a png tmpfile — best-effort: any failure (no
|
|
32
|
+
* native module, capture denied) resolves to `null` and the report simply
|
|
33
|
+
* ships without a screenshot (spec 0004 §C).
|
|
34
|
+
*/
|
|
35
|
+
async function captureScreenshot() {
|
|
36
|
+
try {
|
|
37
|
+
const uri = await captureScreen({
|
|
38
|
+
format: "png",
|
|
39
|
+
quality: 0.9,
|
|
40
|
+
result: "tmpfile",
|
|
41
|
+
});
|
|
42
|
+
if (typeof uri !== "string" || uri.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
return { uri, name: "screenshot.png", type: "image/png" };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Delete a captured screenshot's tmpfile. Best-effort and repeat-safe — an
|
|
52
|
+
* image the Reporter removed as private must not linger in the app's cache
|
|
53
|
+
* (spec 0004 §C).
|
|
54
|
+
*/
|
|
55
|
+
function releaseScreenshot(uri) {
|
|
56
|
+
try {
|
|
57
|
+
releaseCapture(uri);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* release is best-effort */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** A development-only warning; production builds stay silent. */
|
|
64
|
+
function devWarn(message) {
|
|
65
|
+
const g = globalThis;
|
|
66
|
+
if (g.__DEV__ === true) {
|
|
67
|
+
try {
|
|
68
|
+
g.console?.warn?.(message);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* never throw into the host app */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Build the real adapter set `FixbackProvider` hands to the client. */
|
|
76
|
+
export function createNativeAdapters() {
|
|
77
|
+
return {
|
|
78
|
+
storage: AsyncStorage,
|
|
79
|
+
environment: readEnvironment,
|
|
80
|
+
captureScreenshot,
|
|
81
|
+
releaseScreenshot,
|
|
82
|
+
consoleObj: globalConsole() ?? null,
|
|
83
|
+
xhr: globalXhr() ?? null,
|
|
84
|
+
errorUtils: globalErrorUtils() ?? null,
|
|
85
|
+
appState: AppState,
|
|
86
|
+
warn: devWarn,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** How often the accelerometer reports while the gesture is armed. */
|
|
90
|
+
export const SHAKE_UPDATE_INTERVAL_MS = 80;
|
|
91
|
+
/**
|
|
92
|
+
* Feed accelerometer samples to a listener (the shake detector). Availability
|
|
93
|
+
* is checked first (a simulator has no accelerometer); an unavailable or
|
|
94
|
+
* missing sensor resolves to an inert subscription. `remove` also cancels a
|
|
95
|
+
* subscription still waiting on the availability check.
|
|
96
|
+
*/
|
|
97
|
+
export function startShakeListener(onSample, updateIntervalMs = SHAKE_UPDATE_INTERVAL_MS) {
|
|
98
|
+
let removed = false;
|
|
99
|
+
let subscription = null;
|
|
100
|
+
void (async () => {
|
|
101
|
+
try {
|
|
102
|
+
const available = await Accelerometer.isAvailableAsync();
|
|
103
|
+
if (!available || removed)
|
|
104
|
+
return;
|
|
105
|
+
// The update interval is global to the sensor, shared with the host
|
|
106
|
+
// app's own listeners — only claim it when nobody else is listening, so
|
|
107
|
+
// arming the gesture never degrades a host feature's sample rate.
|
|
108
|
+
const listenerCount = typeof Accelerometer.getListenerCount === "function"
|
|
109
|
+
? Accelerometer.getListenerCount()
|
|
110
|
+
: 0;
|
|
111
|
+
if (listenerCount === 0) {
|
|
112
|
+
Accelerometer.setUpdateInterval(updateIntervalMs);
|
|
113
|
+
}
|
|
114
|
+
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
115
|
+
try {
|
|
116
|
+
onSample({ x, y, z, at: Date.now() });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* the detector must never throw into the sensor callback */
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
if (removed) {
|
|
123
|
+
subscription.remove();
|
|
124
|
+
subscription = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// No sensor module — the gesture simply stays unarmed.
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
return {
|
|
132
|
+
remove() {
|
|
133
|
+
removed = true;
|
|
134
|
+
try {
|
|
135
|
+
subscription?.remove();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* teardown is best-effort */
|
|
139
|
+
}
|
|
140
|
+
subscription = null;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|