@synostudios/app-reporter 0.0.0-stage → 0.4.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 +9 -0
- package/README.md +64 -2
- package/expo-module.config.json +4 -0
- package/ios/AppOsReporter.podspec +17 -0
- package/ios/ReporterModule.swift +35 -0
- package/package.json +42 -4
- package/src/drag.ts +24 -0
- package/src/index.tsx +274 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Synostudios LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synostudios/app-reporter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A floating bug-report button for Expo / React Native apps. Tap it, type what's wrong, and it sends a screenshot, your note and some context (route, app version, device, build commit) to your report endpoint.
|
|
4
|
+
|
|
5
|
+
It shows only in development builds and TestFlight installs, never in App Store installs.
|
|
6
|
+
|
|
7
|
+
- **Drag** the button anywhere; it snaps to the nearest side.
|
|
8
|
+
- **Tap** to report. The button hides itself before the screenshot, so it isn't in it.
|
|
9
|
+
- **Long-press** to hide it until the next launch.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx expo install @synostudios/app-reporter react-native-view-shot react-native-screens expo-application expo-device
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The package includes a few lines of native Swift (to tell a TestFlight install from an App Store one), so rebuild the app afterwards: `npx expo run:ios`, or `expo prebuild` and a new build.
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
At the app's root:
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
// src/app/_layout.tsx (Expo Router)
|
|
25
|
+
import { IssueReporter } from "@synostudios/app-reporter";
|
|
26
|
+
import { usePathname } from "expo-router";
|
|
27
|
+
|
|
28
|
+
export default function RootLayout() {
|
|
29
|
+
const pathname = usePathname();
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<Stack />
|
|
33
|
+
<IssueReporter
|
|
34
|
+
harnessUrl={process.env.EXPO_PUBLIC_HARNESS_URL}
|
|
35
|
+
getToken={async () => (await supabase.auth.getSession()).data.session?.access_token}
|
|
36
|
+
buildSha={process.env.EXPO_PUBLIC_KITCHEN_SHA}
|
|
37
|
+
getContext={() => ({ route: pathname })}
|
|
38
|
+
/>
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Props
|
|
45
|
+
|
|
46
|
+
| Prop | |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `harnessUrl` | Base URL of the service that receives reports (`POST {harnessUrl}/api/reports`) |
|
|
49
|
+
| `getToken` | The signed-in user's access token, sent as `Authorization: Bearer`. Your endpoint decides who may report |
|
|
50
|
+
| `enabled` | Defaults to `isTestBuild` (below) |
|
|
51
|
+
| `buildSha` | The git commit the build came from |
|
|
52
|
+
| `getContext` | Extra context sent with the report, e.g. `{ route }` |
|
|
53
|
+
| `insets` | Space to keep clear at the top and bottom; defaults suit a notched iPhone |
|
|
54
|
+
|
|
55
|
+
## Where it shows
|
|
56
|
+
|
|
57
|
+
A TestFlight build is the same binary that later goes to the App Store, so the switch happens at runtime. `isTestBuild` (exported) is true in dev builds, and in release builds on a real device whose App Store receipt is a sandbox one, which is what TestFlight installs get. App Store installs get a production receipt, so the button never renders there.
|
|
58
|
+
|
|
59
|
+
The code still ships in App Store builds, switched off, so don't put anything secret in its props: authenticate reports with the user's own session, as above.
|
|
60
|
+
|
|
61
|
+
The button and sheet render in a `FullWindowOverlay` (react-native-screens), a window above the app's, so they show over native modals and form sheets too.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
3
|
+
|
|
4
|
+
Pod::Spec.new do |s|
|
|
5
|
+
s.name = 'AppOsReporter'
|
|
6
|
+
s.version = package['version']
|
|
7
|
+
s.summary = package['description']
|
|
8
|
+
s.license = package['license']
|
|
9
|
+
s.author = 'Synostudios'
|
|
10
|
+
s.homepage = 'https://www.npmjs.com/package/@synostudios/app-reporter'
|
|
11
|
+
s.platforms = { :ios => '15.1' }
|
|
12
|
+
s.source = { git: '' }
|
|
13
|
+
s.static_framework = true
|
|
14
|
+
s.swift_version = '5.9'
|
|
15
|
+
s.dependency 'ExpoModulesCore'
|
|
16
|
+
s.source_files = '**/*.swift'
|
|
17
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
public class ReporterModule: Module {
|
|
5
|
+
private var timer: Timer?
|
|
6
|
+
|
|
7
|
+
public func definition() -> ModuleDefinition {
|
|
8
|
+
Name("AppOsReporter")
|
|
9
|
+
|
|
10
|
+
// TestFlight, Xcode and simulator installs carry a sandbox receipt; App Store installs a production one.
|
|
11
|
+
// ponytail: appStoreReceiptURL is deprecated from iOS 18 but still answers; switch to StoreKit's
|
|
12
|
+
// AppTransaction.shared.environment (async, iOS 16+) if Apple removes it.
|
|
13
|
+
Constant("isSandbox") {
|
|
14
|
+
Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// react-native-screens' FullWindowOverlay adds its view to the window once, when it mounts, so a modal or sheet
|
|
18
|
+
// presented afterwards lands on top of it. This keeps the overlay in front.
|
|
19
|
+
// ponytail: a 0.5s poll, since UIKit has no "a view controller was presented" hook; only runs while the reporter shows.
|
|
20
|
+
Function("keepOnTop") {
|
|
21
|
+
DispatchQueue.main.async {
|
|
22
|
+
guard self.timer == nil else { return }
|
|
23
|
+
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
|
24
|
+
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
|
|
25
|
+
for window in scene.windows {
|
|
26
|
+
for view in window.subviews where String(describing: type(of: view)).contains("FullWindowOverlayContainer") {
|
|
27
|
+
if view !== window.subviews.last { window.bringSubviewToFront(view) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synostudios/app-reporter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "A draggable bug-report button for Expo apps: screenshot, note and context, sent to your own endpoint. Shows only in dev and TestFlight builds.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"types": "src/index.tsx",
|
|
8
|
+
"react-native": "src/index.tsx",
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"!src/**/*.test.ts",
|
|
12
|
+
"ios",
|
|
13
|
+
"expo-module.config.json",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"release": "npm run typecheck && npm version minor --no-git-tag-version && npm publish"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"expo": ">=52",
|
|
23
|
+
"expo-application": ">=6",
|
|
24
|
+
"expo-device": ">=7",
|
|
25
|
+
"react": ">=19",
|
|
26
|
+
"react-native": ">=0.79",
|
|
27
|
+
"react-native-screens": ">=4",
|
|
28
|
+
"react-native-view-shot": ">=4"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "~19.3.0",
|
|
32
|
+
"expo": "~58.0.23",
|
|
33
|
+
"expo-application": "~58.1.2",
|
|
34
|
+
"expo-device": "~58.0.5",
|
|
35
|
+
"react": "19.3.0",
|
|
36
|
+
"react-native": "0.88.0",
|
|
37
|
+
"react-native-screens": "~4.30.0",
|
|
38
|
+
"react-native-view-shot": "^6.2.1",
|
|
39
|
+
"typescript": "~6.0.3"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/drag.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Where the button settles after a drag, and whether a touch was a tap. Pure, so it's unit-tested.
|
|
2
|
+
|
|
3
|
+
export const SIZE = 52;
|
|
4
|
+
const MARGIN = 12;
|
|
5
|
+
|
|
6
|
+
// Snaps to the nearer side edge and keeps the button inside the safe area, so it never covers the notch or home bar.
|
|
7
|
+
export function snap(
|
|
8
|
+
x: number,
|
|
9
|
+
y: number,
|
|
10
|
+
screen: { width: number; height: number },
|
|
11
|
+
insets: { top: number; bottom: number } = { top: 0, bottom: 0 },
|
|
12
|
+
) {
|
|
13
|
+
const left = MARGIN;
|
|
14
|
+
const right = screen.width - SIZE - MARGIN;
|
|
15
|
+
const top = insets.top + MARGIN;
|
|
16
|
+
const bottom = screen.height - insets.bottom - SIZE - MARGIN;
|
|
17
|
+
return { x: x + SIZE / 2 < screen.width / 2 ? left : right, y: Math.min(Math.max(y, top), Math.max(top, bottom)) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A touch that barely moved counts as a tap, not a drag.
|
|
21
|
+
export const isTap = (dx: number, dy: number) => Math.hypot(dx, dy) < 6;
|
|
22
|
+
|
|
23
|
+
// Starting spot: the right edge, two thirds of the way down.
|
|
24
|
+
export const start = (screen: { width: number; height: number }) => ({ x: screen.width - SIZE - MARGIN, y: Math.round(screen.height * 0.66) });
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// A draggable button that files a bug, with a screenshot, on the app's Fixes tab in the App Harness.
|
|
2
|
+
// Shows in dev builds and TestFlight installs, never in App Store installs: see README.md.
|
|
3
|
+
import * as Application from "expo-application";
|
|
4
|
+
import * as Device from "expo-device";
|
|
5
|
+
import { requireOptionalNativeModule } from "expo"; // expo re-exports it; a direct expo-modules-core peer trips expo-doctor (2026-11-09)
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import {
|
|
8
|
+
ActivityIndicator,
|
|
9
|
+
Animated,
|
|
10
|
+
Image,
|
|
11
|
+
KeyboardAvoidingView,
|
|
12
|
+
Linking,
|
|
13
|
+
PanResponder,
|
|
14
|
+
Platform,
|
|
15
|
+
Pressable,
|
|
16
|
+
StyleSheet,
|
|
17
|
+
Text,
|
|
18
|
+
TextInput,
|
|
19
|
+
useWindowDimensions,
|
|
20
|
+
View,
|
|
21
|
+
} from "react-native";
|
|
22
|
+
import { FullWindowOverlay } from "react-native-screens";
|
|
23
|
+
import { captureScreen } from "react-native-view-shot";
|
|
24
|
+
import { isTap, SIZE, snap, start } from "./drag";
|
|
25
|
+
|
|
26
|
+
// ios/ReporterModule.swift: true in a TestFlight install (and a simulator or Xcode install), false from the App Store.
|
|
27
|
+
// Missing until the app's native code is rebuilt, which reads as false.
|
|
28
|
+
const native = requireOptionalNativeModule<{ isSandbox: boolean; keepOnTop(): void }>("AppOsReporter");
|
|
29
|
+
const sandbox = native?.isSandbox ?? false;
|
|
30
|
+
/** Dev builds, and release builds installed from TestFlight. Never an App Store install. */
|
|
31
|
+
export const isTestBuild = __DEV__ || (sandbox && Device.isDevice);
|
|
32
|
+
|
|
33
|
+
export type IssueReporterProps = {
|
|
34
|
+
/** The harness, e.g. http://localhost:3000 (the iOS simulator reaches your Mac's localhost). */
|
|
35
|
+
harnessUrl: string | undefined;
|
|
36
|
+
/** The signed-in user's access token (e.g. the Supabase session's). The harness only takes reports from the app's testers. */
|
|
37
|
+
getToken: () => Promise<string | null | undefined> | string | null | undefined;
|
|
38
|
+
/** Defaults to isTestBuild: dev builds and TestFlight installs. */
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
/** The git commit this build came from, e.g. process.env.EXPO_PUBLIC_KITCHEN_SHA. The fix starts from it. */
|
|
41
|
+
buildSha?: string;
|
|
42
|
+
/** Extra context for the report, e.g. () => ({ route: pathname }). */
|
|
43
|
+
getContext?: () => Record<string, unknown>;
|
|
44
|
+
/** Space to keep clear at the top and bottom (safe area insets). */
|
|
45
|
+
insets?: { top: number; bottom: number };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type Sent = { url: string } | { error: string };
|
|
49
|
+
|
|
50
|
+
export function IssueReporter({ enabled = isTestBuild, ...props }: IssueReporterProps) {
|
|
51
|
+
if (!enabled) return null;
|
|
52
|
+
return <Reporter {...props} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Reporter({ harnessUrl, getToken, buildSha, getContext, insets = { top: 50, bottom: 34 } }: Omit<IssueReporterProps, "enabled">) {
|
|
56
|
+
const screen = useWindowDimensions();
|
|
57
|
+
const [pos] = useState(() => new Animated.ValueXY(start(screen)));
|
|
58
|
+
const [hidden, setHidden] = useState(false); // long-press hides it until the next launch
|
|
59
|
+
const [capturing, setCapturing] = useState(false);
|
|
60
|
+
const [shot, setShot] = useState<string | null>(null);
|
|
61
|
+
const [context, setContext] = useState<Record<string, unknown>>({});
|
|
62
|
+
|
|
63
|
+
const open = async () => {
|
|
64
|
+
const ctx = getContext?.() ?? {};
|
|
65
|
+
setCapturing(true);
|
|
66
|
+
// Let the button disappear from the screen before capturing it.
|
|
67
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
68
|
+
try {
|
|
69
|
+
setShot(await captureScreen({ format: "png", result: "base64" }));
|
|
70
|
+
} catch {
|
|
71
|
+
setShot(""); // report without a screenshot rather than not at all
|
|
72
|
+
} finally {
|
|
73
|
+
setCapturing(false);
|
|
74
|
+
setContext(ctx);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Keep the overlay above sheets presented after it (ios/ReporterModule.swift).
|
|
79
|
+
useEffect(() => native?.keepOnTop(), []);
|
|
80
|
+
|
|
81
|
+
// The gesture handlers outlive renders, so they read the latest screen size and open() through a ref.
|
|
82
|
+
const latest = useRef({ screen, insets, open });
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
latest.current = { screen, insets, open };
|
|
85
|
+
});
|
|
86
|
+
// eslint-disable-next-line react-hooks/refs -- the callbacks read the ref when a gesture fires, never during render
|
|
87
|
+
const [pan] = useState(() => {
|
|
88
|
+
const g = { at: start(screen), long: false, timer: undefined as ReturnType<typeof setTimeout> | undefined };
|
|
89
|
+
return PanResponder.create({
|
|
90
|
+
onStartShouldSetPanResponder: () => true,
|
|
91
|
+
onPanResponderGrant: () => {
|
|
92
|
+
g.long = false;
|
|
93
|
+
g.timer = setTimeout(() => {
|
|
94
|
+
g.long = true;
|
|
95
|
+
setHidden(true);
|
|
96
|
+
}, 700);
|
|
97
|
+
},
|
|
98
|
+
onPanResponderMove: (_, d) => {
|
|
99
|
+
if (!isTap(d.dx, d.dy)) clearTimeout(g.timer);
|
|
100
|
+
pos.setValue({ x: g.at.x + d.dx, y: g.at.y + d.dy });
|
|
101
|
+
},
|
|
102
|
+
onPanResponderRelease: (_, d) => {
|
|
103
|
+
clearTimeout(g.timer);
|
|
104
|
+
if (g.long) return;
|
|
105
|
+
if (isTap(d.dx, d.dy)) {
|
|
106
|
+
pos.setValue(g.at);
|
|
107
|
+
latest.current.open();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
g.at = snap(g.at.x + d.dx, g.at.y + d.dy, latest.current.screen, latest.current.insets);
|
|
111
|
+
Animated.spring(pos, { toValue: g.at, useNativeDriver: false, friction: 7 }).start();
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (hidden || capturing) return null;
|
|
117
|
+
// FullWindowOverlay is a window above the app's, so the button and sheet show over native modals and sheets too.
|
|
118
|
+
// Its children must be the button or the full-screen sheet themselves: a full-size wrapper would swallow every touch.
|
|
119
|
+
return (
|
|
120
|
+
<FullWindowOverlay>
|
|
121
|
+
{shot === null ? (
|
|
122
|
+
<Animated.View testID="app-os-reporter" {...pan.panHandlers} accessibilityRole="button" accessibilityLabel="Report a bug" style={[styles.button, pos.getLayout()]}>
|
|
123
|
+
<Text style={styles.glyph}>!</Text>
|
|
124
|
+
</Animated.View>
|
|
125
|
+
) : (
|
|
126
|
+
<Sheet
|
|
127
|
+
shot={shot}
|
|
128
|
+
top={insets.top}
|
|
129
|
+
onClose={() => setShot(null)}
|
|
130
|
+
send={async (note, expected) => send({ harnessUrl, token: (await getToken()) ?? undefined, shot, note, expected, buildSha, context, screen })}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</FullWindowOverlay>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Sheet({ shot, top, onClose, send }: { shot: string; top: number; onClose: () => void; send: (note: string, expected: string) => Promise<Sent> }) {
|
|
138
|
+
const [note, setNote] = useState("");
|
|
139
|
+
const [expected, setExpected] = useState("");
|
|
140
|
+
const [sending, setSending] = useState(false);
|
|
141
|
+
const [sent, setSent] = useState<Sent | null>(null);
|
|
142
|
+
const submit = async () => {
|
|
143
|
+
setSending(true);
|
|
144
|
+
setSent(await send(note, expected));
|
|
145
|
+
setSending(false);
|
|
146
|
+
};
|
|
147
|
+
const done = sent && "url" in sent;
|
|
148
|
+
return (
|
|
149
|
+
<View style={[styles.sheet, { paddingTop: top }]} accessibilityViewIsModal>
|
|
150
|
+
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={{ flex: 1 }}>
|
|
151
|
+
<View style={styles.header}>
|
|
152
|
+
<Pressable onPress={onClose} hitSlop={12}>
|
|
153
|
+
<Text style={styles.link}>{done ? "Done" : "Cancel"}</Text>
|
|
154
|
+
</Pressable>
|
|
155
|
+
<Text style={styles.title}>Report a bug</Text>
|
|
156
|
+
{/* Send sits in the header, where the keyboard can't cover it */}
|
|
157
|
+
{done ? (
|
|
158
|
+
<View style={{ width: 60 }} />
|
|
159
|
+
) : sending ? (
|
|
160
|
+
<ActivityIndicator style={{ width: 60 }} />
|
|
161
|
+
) : (
|
|
162
|
+
<Pressable onPress={submit} disabled={!note.trim()} hitSlop={12} accessibilityRole="button">
|
|
163
|
+
<Text style={[styles.link, styles.send, !note.trim() && { opacity: 0.35 }]}>{sent ? "Retry" : "Send"}</Text>
|
|
164
|
+
</Pressable>
|
|
165
|
+
)}
|
|
166
|
+
</View>
|
|
167
|
+
{done ? (
|
|
168
|
+
<View style={styles.body}>
|
|
169
|
+
<Text style={styles.ok}>Reported</Text>
|
|
170
|
+
<Text style={styles.muted}>It's on the app's Fixes tab in the harness, where Claude is restating it.</Text>
|
|
171
|
+
<Pressable onPress={() => Linking.openURL(sent.url)} style={styles.secondary}>
|
|
172
|
+
<Text style={styles.secondaryText}>Open in the harness</Text>
|
|
173
|
+
</Pressable>
|
|
174
|
+
</View>
|
|
175
|
+
) : (
|
|
176
|
+
<View style={styles.body}>
|
|
177
|
+
{/* eslint-disable-next-line jsx-a11y/alt-text -- a web rule; React Native's Image is labelled with accessibilityLabel */}
|
|
178
|
+
{shot ? <Image source={{ uri: `data:image/png;base64,${shot}` }} style={styles.thumb} resizeMode="contain" accessibilityLabel="Screenshot of the screen you were on" /> : <Text style={styles.muted}>No screenshot (capture failed).</Text>}
|
|
179
|
+
<TextInput
|
|
180
|
+
value={note}
|
|
181
|
+
onChangeText={setNote}
|
|
182
|
+
placeholder="What's wrong?"
|
|
183
|
+
multiline
|
|
184
|
+
autoFocus
|
|
185
|
+
style={[styles.input, { minHeight: 90 }]}
|
|
186
|
+
editable={!sending}
|
|
187
|
+
/>
|
|
188
|
+
<TextInput value={expected} onChangeText={setExpected} placeholder="What did you expect? (optional)" style={styles.input} editable={!sending} />
|
|
189
|
+
{sent && "error" in sent && <Text style={styles.error}>{sent.error}</Text>}
|
|
190
|
+
</View>
|
|
191
|
+
)}
|
|
192
|
+
</KeyboardAvoidingView>
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function send(r: {
|
|
198
|
+
harnessUrl?: string;
|
|
199
|
+
token?: string;
|
|
200
|
+
shot: string;
|
|
201
|
+
note: string;
|
|
202
|
+
expected: string;
|
|
203
|
+
buildSha?: string;
|
|
204
|
+
context: Record<string, unknown>;
|
|
205
|
+
screen: { width: number; height: number };
|
|
206
|
+
}): Promise<Sent> {
|
|
207
|
+
if (!r.harnessUrl) return { error: "Set harnessUrl on <IssueReporter /> (see its README)." };
|
|
208
|
+
if (!r.token) return { error: "Sign in to the app first: the harness takes reports from its testers only." };
|
|
209
|
+
// One JSON body with the screenshot as base64: multipart file parts differ between React Native's fetch and Expo's
|
|
210
|
+
// (Expo's rejects { uri, name, type } parts with "Unsupported FormDataPart implementation").
|
|
211
|
+
const payload = JSON.stringify({
|
|
212
|
+
screenshot: r.shot || undefined,
|
|
213
|
+
report: {
|
|
214
|
+
note: r.note.trim(),
|
|
215
|
+
expected: r.expected.trim() || undefined,
|
|
216
|
+
meta: {
|
|
217
|
+
bundleId: Application.applicationId,
|
|
218
|
+
appVersion: Application.nativeApplicationVersion,
|
|
219
|
+
buildNumber: Application.nativeBuildVersion,
|
|
220
|
+
buildSha: r.buildSha,
|
|
221
|
+
platform: Platform.OS,
|
|
222
|
+
osVersion: Device.osVersion ?? String(Platform.Version),
|
|
223
|
+
device: Device.modelName,
|
|
224
|
+
simulator: !Device.isDevice,
|
|
225
|
+
screen: { width: Math.round(r.screen.width), height: Math.round(r.screen.height) },
|
|
226
|
+
context: r.context,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
const res = await fetch(`${r.harnessUrl.replace(/\/+$/, "")}/api/reports`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { Authorization: `Bearer ${r.token}`, "Content-Type": "application/json" },
|
|
234
|
+
body: payload,
|
|
235
|
+
});
|
|
236
|
+
const body = await res.json().catch(() => ({}));
|
|
237
|
+
if (!res.ok) return { error: body.error ?? `The harness answered ${res.status}.` };
|
|
238
|
+
return { url: body.url };
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return { error: `Couldn't reach the harness at ${r.harnessUrl} (${e instanceof Error ? e.message : String(e)}).` };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const styles = StyleSheet.create({
|
|
245
|
+
button: {
|
|
246
|
+
position: "absolute",
|
|
247
|
+
width: SIZE,
|
|
248
|
+
height: SIZE,
|
|
249
|
+
borderRadius: SIZE / 2,
|
|
250
|
+
backgroundColor: "#18181b",
|
|
251
|
+
alignItems: "center",
|
|
252
|
+
justifyContent: "center",
|
|
253
|
+
shadowColor: "#000",
|
|
254
|
+
shadowOpacity: 0.25,
|
|
255
|
+
shadowRadius: 8,
|
|
256
|
+
shadowOffset: { width: 0, height: 3 },
|
|
257
|
+
elevation: 6,
|
|
258
|
+
zIndex: 9999,
|
|
259
|
+
},
|
|
260
|
+
glyph: { color: "#fff", fontSize: 24, fontWeight: "800" },
|
|
261
|
+
sheet: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "#fff" },
|
|
262
|
+
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#e4e4e7" },
|
|
263
|
+
title: { fontSize: 17, fontWeight: "600", color: "#18181b" },
|
|
264
|
+
link: { fontSize: 17, color: "#2563eb", width: 60 },
|
|
265
|
+
send: { fontWeight: "600", textAlign: "right" },
|
|
266
|
+
body: { padding: 16, gap: 12 },
|
|
267
|
+
thumb: { height: 220, borderRadius: 12, backgroundColor: "#f4f4f5" },
|
|
268
|
+
input: { borderWidth: 1, borderColor: "#d4d4d8", borderRadius: 10, padding: 12, fontSize: 16, color: "#18181b", textAlignVertical: "top" },
|
|
269
|
+
secondary: { borderWidth: 1, borderColor: "#d4d4d8", borderRadius: 10, height: 44, alignItems: "center", justifyContent: "center" },
|
|
270
|
+
secondaryText: { color: "#18181b", fontSize: 16, fontWeight: "500" },
|
|
271
|
+
ok: { fontSize: 22, fontWeight: "700", color: "#047857" },
|
|
272
|
+
muted: { color: "#71717a", fontSize: 15 },
|
|
273
|
+
error: { color: "#dc2626", fontSize: 15 },
|
|
274
|
+
});
|