@tapthrough/expo 0.0.0-stage → 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 +105 -2
- package/app.plugin.d.ts +37 -0
- package/app.plugin.js +79 -0
- package/dist/Annotator.d.ts +14 -0
- package/dist/Annotator.d.ts.map +1 -0
- package/dist/Annotator.js +160 -0
- package/dist/Annotator.js.map +1 -0
- package/dist/Bubble.d.ts +27 -0
- package/dist/Bubble.d.ts.map +1 -0
- package/dist/Bubble.js +234 -0
- package/dist/Bubble.js.map +1 -0
- package/dist/Feedback.d.ts +14 -0
- package/dist/Feedback.d.ts.map +1 -0
- package/dist/Feedback.js +977 -0
- package/dist/Feedback.js.map +1 -0
- package/dist/Overlay.d.ts +13 -0
- package/dist/Overlay.d.ts.map +1 -0
- package/dist/Overlay.js +44 -0
- package/dist/Overlay.js.map +1 -0
- package/dist/Popover.d.ts +98 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +531 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Tapthrough.d.ts +27 -0
- package/dist/Tapthrough.d.ts.map +1 -0
- package/dist/Tapthrough.js +111 -0
- package/dist/Tapthrough.js.map +1 -0
- package/dist/core/annotations.d.ts +63 -0
- package/dist/core/annotations.d.ts.map +1 -0
- package/dist/core/annotations.js +76 -0
- package/dist/core/annotations.js.map +1 -0
- package/dist/core/bubble.d.ts +21 -0
- package/dist/core/bubble.d.ts.map +1 -0
- package/dist/core/bubble.js +25 -0
- package/dist/core/bubble.js.map +1 -0
- package/dist/core/controller.d.ts +46 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +103 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/diagnostics.d.ts +47 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +101 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/feedback.d.ts +302 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +345 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/flags.d.ts +13 -0
- package/dist/core/flags.d.ts.map +1 -0
- package/dist/core/flags.js +25 -0
- package/dist/core/flags.js.map +1 -0
- package/dist/core/ideas.d.ts +166 -0
- package/dist/core/ideas.d.ts.map +1 -0
- package/dist/core/ideas.js +159 -0
- package/dist/core/ideas.js.map +1 -0
- package/dist/core/launch.d.ts +43 -0
- package/dist/core/launch.d.ts.map +1 -0
- package/dist/core/launch.js +81 -0
- package/dist/core/launch.js.map +1 -0
- package/dist/core/reset.d.ts +17 -0
- package/dist/core/reset.d.ts.map +1 -0
- package/dist/core/reset.js +19 -0
- package/dist/core/reset.js.map +1 -0
- package/dist/core/review.d.ts +49 -0
- package/dist/core/review.d.ts.map +1 -0
- package/dist/core/review.js +92 -0
- package/dist/core/review.js.map +1 -0
- package/dist/core/url.d.ts +9 -0
- package/dist/core/url.d.ts.map +1 -0
- package/dist/core/url.js +23 -0
- package/dist/core/url.js.map +1 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +13 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/native-intent.d.ts +18 -0
- package/dist/native-intent.d.ts.map +1 -0
- package/dist/native-intent.js +32 -0
- package/dist/native-intent.js.map +1 -0
- package/dist/runtime.d.ts +24 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.js.map +1 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +48 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/supabase.d.ts +20 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +18 -0
- package/dist/supabase.js.map +1 -0
- package/dist/ui/native.d.ts +34 -0
- package/dist/ui/native.d.ts.map +1 -0
- package/dist/ui/native.js +76 -0
- package/dist/ui/native.js.map +1 -0
- package/dist/ui/parts.d.ts +62 -0
- package/dist/ui/parts.d.ts.map +1 -0
- package/dist/ui/parts.js +110 -0
- package/dist/ui/parts.js.map +1 -0
- package/dist/ui/theme.d.ts +90 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +61 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +82 -3
- package/src/Annotator.tsx +279 -0
- package/src/Bubble.tsx +331 -0
- package/src/Feedback.tsx +1739 -0
- package/src/Overlay.tsx +61 -0
- package/src/Popover.tsx +1393 -0
- package/src/Tapthrough.tsx +171 -0
- package/src/core/annotations.ts +116 -0
- package/src/core/bubble.ts +45 -0
- package/src/core/controller.ts +119 -0
- package/src/core/diagnostics.ts +128 -0
- package/src/core/feedback.ts +589 -0
- package/src/core/flags.ts +31 -0
- package/src/core/ideas.ts +337 -0
- package/src/core/launch.ts +119 -0
- package/src/core/reset.ts +28 -0
- package/src/core/review.ts +148 -0
- package/src/core/url.ts +21 -0
- package/src/hooks.ts +15 -0
- package/src/index.ts +8 -0
- package/src/native-intent.ts +36 -0
- package/src/runtime.ts +30 -0
- package/src/screenshot.ts +64 -0
- package/src/supabase.ts +27 -0
- package/src/ui/native.ts +116 -0
- package/src/ui/parts.tsx +249 -0
- package/src/ui/theme.ts +75 -0
package/src/Feedback.tsx
ADDED
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
import Constants from 'expo-constants'
|
|
2
|
+
import { usePathname } from 'expo-router'
|
|
3
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ActivityIndicator,
|
|
6
|
+
Animated,
|
|
7
|
+
AppState,
|
|
8
|
+
Image,
|
|
9
|
+
Keyboard,
|
|
10
|
+
KeyboardAvoidingView,
|
|
11
|
+
Linking,
|
|
12
|
+
Platform,
|
|
13
|
+
Pressable,
|
|
14
|
+
ScrollView,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
Text,
|
|
17
|
+
TextInput,
|
|
18
|
+
useWindowDimensions,
|
|
19
|
+
View,
|
|
20
|
+
} from 'react-native'
|
|
21
|
+
import { Annotator } from './Annotator.js'
|
|
22
|
+
import { Bubble, COLORS, screenBounds } from './Bubble.js'
|
|
23
|
+
import type { Annotation } from './core/annotations.js'
|
|
24
|
+
import { defaultPosition, type Point } from './core/bubble.js'
|
|
25
|
+
import { createDiagnostics, installDiagnostics } from './core/diagnostics.js'
|
|
26
|
+
import {
|
|
27
|
+
type Checklist,
|
|
28
|
+
type ChecklistStep,
|
|
29
|
+
failedStepBody,
|
|
30
|
+
fetchChecklist,
|
|
31
|
+
fileIssue,
|
|
32
|
+
type IssueFix,
|
|
33
|
+
type NewerBuild,
|
|
34
|
+
previewContextFromManifest,
|
|
35
|
+
type RecheckItem,
|
|
36
|
+
requestFix,
|
|
37
|
+
requestLaunch,
|
|
38
|
+
reviewBadge,
|
|
39
|
+
sendCause,
|
|
40
|
+
sendFeedback,
|
|
41
|
+
sendRecheck,
|
|
42
|
+
sendStepResult,
|
|
43
|
+
type Triage,
|
|
44
|
+
} from './core/feedback.js'
|
|
45
|
+
import {
|
|
46
|
+
answerIdea,
|
|
47
|
+
approveMessage,
|
|
48
|
+
type Caller,
|
|
49
|
+
fetchIdeas,
|
|
50
|
+
fetchOpenPrs,
|
|
51
|
+
type Idea,
|
|
52
|
+
type IdeaDraft,
|
|
53
|
+
ideaAction,
|
|
54
|
+
ideasNeedingYou,
|
|
55
|
+
isMoving,
|
|
56
|
+
type OpenPr,
|
|
57
|
+
sendStaffReport,
|
|
58
|
+
submitIdea,
|
|
59
|
+
} from './core/ideas.js'
|
|
60
|
+
import { nextOpenStep, popoverPlacement, type Results, resultsFrom, stepId } from './core/review.js'
|
|
61
|
+
import { useLaunchState } from './hooks.js'
|
|
62
|
+
import { Overlay, useBackPress } from './Overlay.js'
|
|
63
|
+
import {
|
|
64
|
+
type Direction,
|
|
65
|
+
type Nav,
|
|
66
|
+
ReviewerName,
|
|
67
|
+
ReviewPopover,
|
|
68
|
+
type SetupStatus,
|
|
69
|
+
} from './Popover.js'
|
|
70
|
+
import { controller } from './runtime.js'
|
|
71
|
+
import { captureScreenshot, type Screenshot } from './screenshot.js'
|
|
72
|
+
import { haptics, useReduceMotion } from './ui/native.js'
|
|
73
|
+
import { Capsule, CircleButton, Icon, Surface } from './ui/parts.js'
|
|
74
|
+
import { SPRING, text, useColors } from './ui/theme.js'
|
|
75
|
+
|
|
76
|
+
/** Remembered for the session, so reviewers type their name once. */
|
|
77
|
+
let rememberedName = ''
|
|
78
|
+
/** Where idea updates go, if the person gave an email; also kept for the session. */
|
|
79
|
+
let rememberedEmail = ''
|
|
80
|
+
|
|
81
|
+
const diagnostics = createDiagnostics()
|
|
82
|
+
let diagnosticsInstalled = false
|
|
83
|
+
|
|
84
|
+
declare const ErrorUtils:
|
|
85
|
+
| {
|
|
86
|
+
getGlobalHandler(): (error: unknown, isFatal?: boolean) => void
|
|
87
|
+
setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void
|
|
88
|
+
}
|
|
89
|
+
| undefined
|
|
90
|
+
|
|
91
|
+
function deviceInfo() {
|
|
92
|
+
const constants = Platform.constants as unknown as Record<string, unknown>
|
|
93
|
+
if (Platform.OS === 'ios') {
|
|
94
|
+
return {
|
|
95
|
+
os: 'iOS',
|
|
96
|
+
osVersion: String(Platform.Version),
|
|
97
|
+
model: constants.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const model = [constants.Manufacturer, constants.Model].filter(Boolean).join(' ')
|
|
101
|
+
return {
|
|
102
|
+
os: 'Android',
|
|
103
|
+
osVersion: String(constants.Release ?? Platform.Version),
|
|
104
|
+
...(model ? { model } : {}),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** e.g. "iPhone · iOS 26.1", for the composer's list of what's attached. */
|
|
109
|
+
function deviceLabel() {
|
|
110
|
+
const { os, osVersion, model } = deviceInfo()
|
|
111
|
+
return `${model ?? os} · ${os} ${osVersion}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The app's version and build, from its config, for reports from staff builds. */
|
|
115
|
+
function appVersion(): { version?: string; build?: string } {
|
|
116
|
+
const config = Constants.expoConfig
|
|
117
|
+
const build = Platform.OS === 'ios' ? config?.ios?.buildNumber : config?.android?.versionCode
|
|
118
|
+
return {
|
|
119
|
+
...(config?.version ? { version: config.version } : {}),
|
|
120
|
+
...(build !== undefined ? { build: String(build) } : {}),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** e.g. "Version 2.4.0 (311)", for staff builds' panel and composer. */
|
|
125
|
+
function appLabel(): string {
|
|
126
|
+
const { version, build } = appVersion()
|
|
127
|
+
if (!version) return 'This build'
|
|
128
|
+
return build ? `Version ${version} (${build})` : `Version ${version}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a staff build's panel shows instead of a PR's checklist: nothing to check. */
|
|
132
|
+
const STAFF_CHECKLIST: Checklist = {
|
|
133
|
+
pr: 0,
|
|
134
|
+
sha: '',
|
|
135
|
+
open: true,
|
|
136
|
+
scenarios: [],
|
|
137
|
+
recheck: [],
|
|
138
|
+
library: [],
|
|
139
|
+
newerBuild: null,
|
|
140
|
+
reset: null,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function nextFrame() {
|
|
144
|
+
return new Promise<void>((resolve) =>
|
|
145
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Puts the app's keyboard away. A sheet that opens while it's up can't tell
|
|
151
|
+
* it's there, so the bottom of the sheet would sit under it. Resolves once it's
|
|
152
|
+
* gone, so a screenshot taken next doesn't catch it halfway down.
|
|
153
|
+
*/
|
|
154
|
+
function dismissKeyboard() {
|
|
155
|
+
if (!Keyboard.isVisible()) return Promise.resolve()
|
|
156
|
+
return new Promise<void>((resolve) => {
|
|
157
|
+
const done = () => {
|
|
158
|
+
subscription.remove()
|
|
159
|
+
clearTimeout(fallback)
|
|
160
|
+
resolve()
|
|
161
|
+
}
|
|
162
|
+
const subscription = Keyboard.addListener('keyboardDidHide', done)
|
|
163
|
+
// In case the event never comes, e.g. a hardware keyboard.
|
|
164
|
+
const fallback = setTimeout(done, 500)
|
|
165
|
+
Keyboard.dismiss()
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface FailingStep {
|
|
170
|
+
scenarioKey: string
|
|
171
|
+
step: ChecklistStep
|
|
172
|
+
/** The step's position in the scenario, for moving on once the report is sent. */
|
|
173
|
+
position: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** How often a PR preview checks for new results and newer builds while in the foreground. */
|
|
177
|
+
const REFRESH_MS = 30_000
|
|
178
|
+
/** How often an open idea that's moving checks for its next step. */
|
|
179
|
+
const IDEA_REFRESH_MS = 8_000
|
|
180
|
+
|
|
181
|
+
/** Update groups the reviewer has already been told about, so the toast shows once. */
|
|
182
|
+
const announcedBuilds = new Set<string>()
|
|
183
|
+
|
|
184
|
+
type Phase = 'idle' | 'loading' | 'open' | 'capturing' | 'composing'
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A small draggable Review button over a PR preview. Tapping it grows a
|
|
188
|
+
* popover listing what the PR needs tested; picking a scenario can set the app
|
|
189
|
+
* up for it and then walks through its steps. Without a plan it goes straight
|
|
190
|
+
* to reporting an issue with a screenshot of the screen behind it. It keeps
|
|
191
|
+
* the checklist fresh in the background, so it can say when a newer build is out.
|
|
192
|
+
*/
|
|
193
|
+
export function ReportButton({
|
|
194
|
+
apiUrl,
|
|
195
|
+
canSetUp,
|
|
196
|
+
appKey,
|
|
197
|
+
}: {
|
|
198
|
+
apiUrl: string
|
|
199
|
+
canSetUp: boolean
|
|
200
|
+
/** Set on staging and TestFlight builds: feedback works outside PR previews. */
|
|
201
|
+
appKey?: string | undefined
|
|
202
|
+
}) {
|
|
203
|
+
const preview = useMemo(() => previewContextFromManifest(Constants.manifest2), [])
|
|
204
|
+
// A PR build proves itself with its update; any other build needs the app key.
|
|
205
|
+
const caller = useMemo<Caller | null>(
|
|
206
|
+
() => (preview ? { kind: 'build', preview } : appKey ? { kind: 'key', appKey } : null),
|
|
207
|
+
[preview, appKey],
|
|
208
|
+
)
|
|
209
|
+
const staffMode = caller?.kind === 'key'
|
|
210
|
+
const pathname = usePathname()
|
|
211
|
+
const { width, height } = useWindowDimensions()
|
|
212
|
+
const bounds = screenBounds(width, height)
|
|
213
|
+
const [phase, setPhase] = useState<Phase>('idle')
|
|
214
|
+
const [checklist, setChecklist] = useState<Checklist | null>(staffMode ? STAFF_CHECKLIST : null)
|
|
215
|
+
const [nav, setNav] = useState<Nav>({ view: 'list' })
|
|
216
|
+
const [direction, setDirection] = useState<Direction>('forward')
|
|
217
|
+
const [full, setFull] = useState(false)
|
|
218
|
+
const [overrides, setOverrides] = useState<Results>({})
|
|
219
|
+
const [rechecked, setRechecked] = useState<Record<string, 'fixed' | 'still_broken'>>({})
|
|
220
|
+
const [setup, setSetup] = useState<SetupStatus | null>(null)
|
|
221
|
+
const [anchor, setAnchor] = useState<Point | null>(null)
|
|
222
|
+
const [error, setError] = useState<string | null>(null)
|
|
223
|
+
const [name, setName] = useState(rememberedName)
|
|
224
|
+
const [failing, setFailing] = useState<FailingStep | null>(null)
|
|
225
|
+
const [screenshot, setScreenshot] = useState<Screenshot | null>(null)
|
|
226
|
+
const [toast, setToast] = useState<NewerBuild | null>(null)
|
|
227
|
+
const [ideas, setIdeas] = useState<Idea[]>([])
|
|
228
|
+
const [ideaBusy, setIdeaBusy] = useState<string | null>(null)
|
|
229
|
+
const [pulls, setPulls] = useState<OpenPr[] | null>(null)
|
|
230
|
+
const [composerKind, setComposerKind] = useState<'issue' | 'idea'>('issue')
|
|
231
|
+
const launch = useLaunchState()
|
|
232
|
+
const platform = Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : null
|
|
233
|
+
const results = useMemo(
|
|
234
|
+
() => ({ ...resultsFrom(checklist), ...overrides }),
|
|
235
|
+
[checklist, overrides],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// PR previews and staff builds record diagnostics; store builds are never hooked.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!caller || diagnosticsInstalled) return
|
|
241
|
+
diagnosticsInstalled = true
|
|
242
|
+
installDiagnostics(
|
|
243
|
+
diagnostics,
|
|
244
|
+
{
|
|
245
|
+
console,
|
|
246
|
+
errorUtils: typeof ErrorUtils === 'undefined' ? undefined : ErrorUtils,
|
|
247
|
+
fetchHost: globalThis,
|
|
248
|
+
},
|
|
249
|
+
apiUrl,
|
|
250
|
+
)
|
|
251
|
+
}, [caller, apiUrl])
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (caller) diagnostics.recordScreen(pathname)
|
|
255
|
+
}, [caller, pathname])
|
|
256
|
+
|
|
257
|
+
async function refresh() {
|
|
258
|
+
if (!caller || !platform) return null
|
|
259
|
+
const [list, recent] = await Promise.all([
|
|
260
|
+
preview ? fetchChecklist(preview, platform, { apiUrl }) : Promise.resolve(STAFF_CHECKLIST),
|
|
261
|
+
fetchIdeas(caller, platform, { apiUrl }),
|
|
262
|
+
])
|
|
263
|
+
if (list) setChecklist(list)
|
|
264
|
+
setIdeas(recent)
|
|
265
|
+
const newer = list?.newerBuild
|
|
266
|
+
if (newer && !announcedBuilds.has(newer.updateGroupId)) {
|
|
267
|
+
announcedBuilds.add(newer.updateGroupId)
|
|
268
|
+
setToast(newer)
|
|
269
|
+
}
|
|
270
|
+
return list
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Fetch up front so the popover opens instantly, then poll while the app is in front.
|
|
274
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh reads current props
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (!caller || !platform) return
|
|
277
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
278
|
+
const start = () => {
|
|
279
|
+
clearInterval(timer)
|
|
280
|
+
void refresh()
|
|
281
|
+
timer = setInterval(() => void refresh(), REFRESH_MS)
|
|
282
|
+
}
|
|
283
|
+
const stop = () => clearInterval(timer)
|
|
284
|
+
if (AppState.currentState === 'active') start()
|
|
285
|
+
const subscription = AppState.addEventListener('change', (next) =>
|
|
286
|
+
next === 'active' ? start() : stop(),
|
|
287
|
+
)
|
|
288
|
+
return () => {
|
|
289
|
+
stop()
|
|
290
|
+
subscription.remove()
|
|
291
|
+
}
|
|
292
|
+
}, [caller, platform, apiUrl])
|
|
293
|
+
|
|
294
|
+
// Watching an idea that's being written up or built: check every few seconds, not every 30.
|
|
295
|
+
const watched = nav.view === 'idea' ? ideas.find((i) => i.id === nav.id) : undefined
|
|
296
|
+
const watching = phase === 'open' && watched !== undefined && isMoving(watched)
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!watching || !caller || !platform) return
|
|
299
|
+
const timer = setInterval(async () => {
|
|
300
|
+
setIdeas(await fetchIdeas(caller, platform, { apiUrl }))
|
|
301
|
+
}, IDEA_REFRESH_MS)
|
|
302
|
+
return () => clearInterval(timer)
|
|
303
|
+
}, [watching, caller, platform, apiUrl])
|
|
304
|
+
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (!toast) return
|
|
307
|
+
const timer = setTimeout(() => setToast(null), 6000)
|
|
308
|
+
return () => clearTimeout(timer)
|
|
309
|
+
}, [toast])
|
|
310
|
+
|
|
311
|
+
// A setup this popover started: follow the launch through its stages.
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (!setup || setup.error || setup.progress === 'done') return
|
|
314
|
+
if (launch.status === 'idle' || launch.source !== 'app') return
|
|
315
|
+
if (launch.status === 'launching') {
|
|
316
|
+
if (launch.stage && launch.stage !== setup.progress) {
|
|
317
|
+
setSetup({ ...setup, progress: launch.stage })
|
|
318
|
+
}
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
controller.dismiss()
|
|
322
|
+
if (launch.status === 'error') {
|
|
323
|
+
haptics.warning()
|
|
324
|
+
setSetup({ ...setup, error: launch.error.message })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
haptics.success()
|
|
328
|
+
setSetup({ ...setup, progress: 'done' })
|
|
329
|
+
}, [launch, setup])
|
|
330
|
+
|
|
331
|
+
// Once every line is ticked, let it show for a moment, then start the steps.
|
|
332
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: begin reads the current checklist
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (setup?.progress !== 'done' || setup.error) return
|
|
335
|
+
const key = setup.key
|
|
336
|
+
const timer = setTimeout(() => {
|
|
337
|
+
setSetup(null)
|
|
338
|
+
begin(key)
|
|
339
|
+
void refresh()
|
|
340
|
+
}, 700)
|
|
341
|
+
return () => clearTimeout(timer)
|
|
342
|
+
}, [setup])
|
|
343
|
+
|
|
344
|
+
if (!caller || !platform) return null
|
|
345
|
+
|
|
346
|
+
function go(next: Nav, dir: Direction = 'forward') {
|
|
347
|
+
setDirection(dir)
|
|
348
|
+
setNav(next)
|
|
349
|
+
setError(null)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function rememberName() {
|
|
353
|
+
rememberedName = name.trim()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Starts a scenario's steps where they're left off, or explores another state. */
|
|
357
|
+
function begin(key: string) {
|
|
358
|
+
const scenario = checklist?.scenarios.find((s) => s.key === key)
|
|
359
|
+
if (!scenario?.steps.length) {
|
|
360
|
+
go({ view: 'explore', key })
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
const index = nextOpenStep(scenario, results)
|
|
364
|
+
go(index >= 0 ? { view: 'step', key, index } : { view: 'done', key })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function setUp(key: string) {
|
|
368
|
+
if (!preview || !platform) return
|
|
369
|
+
haptics.selection()
|
|
370
|
+
controller.dismiss()
|
|
371
|
+
go({ view: 'setup', key })
|
|
372
|
+
setSetup({ key, progress: 'requesting' })
|
|
373
|
+
const result = await requestLaunch({ preview, platform, scenarioKey: key }, { apiUrl })
|
|
374
|
+
if (!result.ok) {
|
|
375
|
+
haptics.warning()
|
|
376
|
+
setSetup({ key, progress: 'requesting', error: result.message })
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (!controller.offer(result.launchUrl, 'app')) {
|
|
380
|
+
setSetup({
|
|
381
|
+
key,
|
|
382
|
+
progress: 'requesting',
|
|
383
|
+
error: 'That setup link was already used. Try again.',
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function pass(key: string, index: number) {
|
|
389
|
+
const scenario = checklist?.scenarios.find((s) => s.key === key)
|
|
390
|
+
const step = scenario?.steps[index]
|
|
391
|
+
if (!preview || !platform || !scenario || !step) return
|
|
392
|
+
rememberName()
|
|
393
|
+
haptics.selection()
|
|
394
|
+
const id = stepId(key, step.index)
|
|
395
|
+
const previous = results[id]
|
|
396
|
+
const after: Results = { ...results, [id]: 'pass' }
|
|
397
|
+
setOverrides((current) => ({ ...current, [id]: 'pass' }))
|
|
398
|
+
// Let the green show, then slide in the next step.
|
|
399
|
+
setTimeout(() => {
|
|
400
|
+
const next = nextOpenStep(scenario, after, index + 1)
|
|
401
|
+
go(next >= 0 ? { view: 'step', key, index: next } : { view: 'done', key })
|
|
402
|
+
if (next < 0) haptics.success()
|
|
403
|
+
}, 280)
|
|
404
|
+
const sent = await sendStepResult(
|
|
405
|
+
{
|
|
406
|
+
preview,
|
|
407
|
+
platform,
|
|
408
|
+
scenarioKey: key,
|
|
409
|
+
stepIndex: step.index,
|
|
410
|
+
result: 'pass',
|
|
411
|
+
...(rememberedName ? { reporterName: rememberedName } : {}),
|
|
412
|
+
},
|
|
413
|
+
{ apiUrl },
|
|
414
|
+
)
|
|
415
|
+
if (!sent.ok) {
|
|
416
|
+
setOverrides((current) => ({ ...current, [id]: previous }))
|
|
417
|
+
setError(sent.message)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function fail(key: string, index: number) {
|
|
422
|
+
const step = checklist?.scenarios.find((s) => s.key === key)?.steps[index]
|
|
423
|
+
if (!step) return
|
|
424
|
+
rememberName()
|
|
425
|
+
haptics.warning()
|
|
426
|
+
void startReport({ scenarioKey: key, step, position: index })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function recheck(item: RecheckItem, result: 'fixed' | 'still_broken') {
|
|
430
|
+
if (!preview || !platform) return
|
|
431
|
+
rememberName()
|
|
432
|
+
haptics.selection()
|
|
433
|
+
setRechecked((current) => ({ ...current, [item.id]: result }))
|
|
434
|
+
setTimeout(() => go({ view: 'list' }, 'back'), 350)
|
|
435
|
+
const sent = await sendRecheck(
|
|
436
|
+
{
|
|
437
|
+
preview,
|
|
438
|
+
platform,
|
|
439
|
+
findingId: item.id,
|
|
440
|
+
result,
|
|
441
|
+
...(rememberedName ? { reporterName: rememberedName } : {}),
|
|
442
|
+
},
|
|
443
|
+
{ apiUrl },
|
|
444
|
+
)
|
|
445
|
+
if (!sent.ok) {
|
|
446
|
+
setRechecked(({ [item.id]: _, ...rest }) => rest)
|
|
447
|
+
setError(sent.message)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function toggle() {
|
|
452
|
+
if (phase === 'open') {
|
|
453
|
+
close()
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
setToast(null)
|
|
457
|
+
const keyboardGone = dismissKeyboard()
|
|
458
|
+
// A setup that finished or failed while closed shouldn't leave a spinner behind.
|
|
459
|
+
if (nav.view === 'setup' && !setup) setNav({ view: 'list' })
|
|
460
|
+
// With a checklist the popover always opens, so ideas and other PRs are a tap away.
|
|
461
|
+
if (checklist) {
|
|
462
|
+
setPhase('open')
|
|
463
|
+
void refresh()
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
setPhase('loading')
|
|
467
|
+
const list = await refresh()
|
|
468
|
+
if (list) {
|
|
469
|
+
setPhase('open')
|
|
470
|
+
} else {
|
|
471
|
+
await keyboardGone
|
|
472
|
+
await startReport(null)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function close() {
|
|
477
|
+
setPhase('idle')
|
|
478
|
+
setFull(false)
|
|
479
|
+
setError(null)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Hides everything, screenshots the app, then opens the composer. */
|
|
483
|
+
async function startReport(step: FailingStep | null, kind: 'issue' | 'idea' = 'issue') {
|
|
484
|
+
setFailing(step)
|
|
485
|
+
setComposerKind(step ? 'issue' : kind)
|
|
486
|
+
setToast(null)
|
|
487
|
+
setPhase('capturing')
|
|
488
|
+
try {
|
|
489
|
+
await nextFrame()
|
|
490
|
+
setScreenshot(await captureScreenshot())
|
|
491
|
+
} finally {
|
|
492
|
+
// Whatever happens to the screenshot, the reviewer gets the composer.
|
|
493
|
+
setPhase('composing')
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Back from the composer without sending: to the popover if that's where it started. */
|
|
498
|
+
function cancelReport() {
|
|
499
|
+
setScreenshot(null)
|
|
500
|
+
setFailing(null)
|
|
501
|
+
setPhase(checklist ? 'open' : 'idle')
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** After an idea is sent: straight to it in the popover, where its progress shows. */
|
|
505
|
+
function viewIdea(idea: Idea) {
|
|
506
|
+
setIdeas((current) => [idea, ...current.filter((i) => i.id !== idea.id)])
|
|
507
|
+
setScreenshot(null)
|
|
508
|
+
setFailing(null)
|
|
509
|
+
go({ view: 'idea', id: idea.id })
|
|
510
|
+
setPhase(checklist ? 'open' : 'idle')
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function replaceIdea(idea: Idea) {
|
|
514
|
+
setIdeas((current) => current.map((i) => (i.id === idea.id ? idea : i)))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function answer(id: string, answers: string[]) {
|
|
518
|
+
if (!caller || !platform) return
|
|
519
|
+
rememberName()
|
|
520
|
+
setIdeaBusy(id)
|
|
521
|
+
const result = await answerIdea(
|
|
522
|
+
{ caller, platform, id, answers, ...(rememberedName ? { by: rememberedName } : {}) },
|
|
523
|
+
{ apiUrl },
|
|
524
|
+
)
|
|
525
|
+
setIdeaBusy(null)
|
|
526
|
+
if (!result.ok) {
|
|
527
|
+
haptics.warning()
|
|
528
|
+
setError(result.message)
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
haptics.success()
|
|
532
|
+
replaceIdea(result.idea)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function act(id: string, action: 'build' | 'retry' | 'approve') {
|
|
536
|
+
if (!caller || !platform) return
|
|
537
|
+
rememberName()
|
|
538
|
+
haptics.selection()
|
|
539
|
+
setIdeaBusy(id)
|
|
540
|
+
const result = await ideaAction(
|
|
541
|
+
{ caller, platform, id, action, ...(rememberedName ? { by: rememberedName } : {}) },
|
|
542
|
+
{ apiUrl },
|
|
543
|
+
)
|
|
544
|
+
setIdeaBusy(null)
|
|
545
|
+
if (!result.ok) {
|
|
546
|
+
haptics.warning()
|
|
547
|
+
setError(result.message)
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
haptics.success()
|
|
551
|
+
replaceIdea(
|
|
552
|
+
action === 'approve' ? { ...result.idea, note: approveMessage(result.approve) } : result.idea,
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function showPulls() {
|
|
557
|
+
if (!preview || !platform) return
|
|
558
|
+
setPulls(null)
|
|
559
|
+
const list = await fetchOpenPrs(preview, platform, { apiUrl })
|
|
560
|
+
if (list) setPulls(list)
|
|
561
|
+
else {
|
|
562
|
+
setPulls([])
|
|
563
|
+
setError('Couldn’t load the open pull requests. Try again in a moment.')
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Opens another PR's latest build in this app, the same way a newer build opens. */
|
|
568
|
+
function switchPr(pr: OpenPr) {
|
|
569
|
+
if (!pr.openUrl) return
|
|
570
|
+
haptics.selection()
|
|
571
|
+
close()
|
|
572
|
+
Linking.openURL(pr.openUrl)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** After a report is sent: a failed step moves the plan on; the next tap carries on there. */
|
|
576
|
+
function finishReport() {
|
|
577
|
+
if (failing) {
|
|
578
|
+
const scenario = checklist?.scenarios.find((s) => s.key === failing.scenarioKey)
|
|
579
|
+
const id = stepId(failing.scenarioKey, failing.step.index)
|
|
580
|
+
const after: Results = { ...results, [id]: 'fail' }
|
|
581
|
+
setOverrides((current) => ({ ...current, [id]: 'fail' }))
|
|
582
|
+
if (scenario) {
|
|
583
|
+
const next = nextOpenStep(scenario, after, failing.position + 1)
|
|
584
|
+
go(
|
|
585
|
+
next >= 0
|
|
586
|
+
? { view: 'step', key: scenario.key, index: next }
|
|
587
|
+
: { view: 'done', key: scenario.key },
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
setScreenshot(null)
|
|
592
|
+
setFailing(null)
|
|
593
|
+
setPhase('idle')
|
|
594
|
+
void refresh()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const placement = popoverPlacement(anchor ?? defaultPosition(bounds), bounds)
|
|
598
|
+
const showButton = phase === 'idle' || phase === 'loading' || (phase === 'open' && !full)
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
<Overlay>
|
|
602
|
+
{toast && phase !== 'open' && phase !== 'composing' && phase !== 'capturing' ? (
|
|
603
|
+
<NewBuildToast build={toast} onPress={toggle} />
|
|
604
|
+
) : null}
|
|
605
|
+
{phase === 'open' && checklist ? (
|
|
606
|
+
<ReviewPopover
|
|
607
|
+
staff={staffMode ? appLabel() : null}
|
|
608
|
+
checklist={checklist}
|
|
609
|
+
results={results}
|
|
610
|
+
rechecked={rechecked}
|
|
611
|
+
nav={nav}
|
|
612
|
+
direction={direction}
|
|
613
|
+
onNav={(next, dir) => {
|
|
614
|
+
haptics.selection()
|
|
615
|
+
go(next, dir)
|
|
616
|
+
}}
|
|
617
|
+
placement={placement}
|
|
618
|
+
bounds={bounds}
|
|
619
|
+
full={full}
|
|
620
|
+
onToggleFull={() => {
|
|
621
|
+
haptics.light()
|
|
622
|
+
setFull((current) => !current)
|
|
623
|
+
}}
|
|
624
|
+
onClose={close}
|
|
625
|
+
canSetUp={canSetUp}
|
|
626
|
+
setup={setup}
|
|
627
|
+
onSetUp={setUp}
|
|
628
|
+
onStart={(key) => {
|
|
629
|
+
haptics.selection()
|
|
630
|
+
setSetup(null)
|
|
631
|
+
begin(key)
|
|
632
|
+
}}
|
|
633
|
+
onPass={pass}
|
|
634
|
+
onFail={fail}
|
|
635
|
+
onRecheck={recheck}
|
|
636
|
+
onReportOther={() => void startReport(null)}
|
|
637
|
+
ideas={ideas}
|
|
638
|
+
onShareIdea={() => void startReport(null, 'idea')}
|
|
639
|
+
onAnswerIdea={(id, answers) => void answer(id, answers)}
|
|
640
|
+
onIdeaAction={(id, action) => void act(id, action)}
|
|
641
|
+
ideaBusy={ideaBusy}
|
|
642
|
+
pulls={pulls}
|
|
643
|
+
onShowPulls={() => void showPulls()}
|
|
644
|
+
onSwitchPr={switchPr}
|
|
645
|
+
name={name}
|
|
646
|
+
onNameChange={(next) => {
|
|
647
|
+
setName(next)
|
|
648
|
+
rememberedName = next.trim()
|
|
649
|
+
}}
|
|
650
|
+
error={error}
|
|
651
|
+
/>
|
|
652
|
+
) : null}
|
|
653
|
+
{showButton ? (
|
|
654
|
+
<Bubble
|
|
655
|
+
onPress={toggle}
|
|
656
|
+
badge={reviewBadge(checklist) ?? (ideasNeedingYou(ideas) ? { kind: 'idea' } : null)}
|
|
657
|
+
busy={phase === 'loading'}
|
|
658
|
+
active={phase === 'open'}
|
|
659
|
+
onRest={setAnchor}
|
|
660
|
+
/>
|
|
661
|
+
) : null}
|
|
662
|
+
{phase === 'composing' && screenshot ? <CaptureFlash /> : null}
|
|
663
|
+
{phase === 'composing' ? (
|
|
664
|
+
<Composer
|
|
665
|
+
screenshot={screenshot}
|
|
666
|
+
route={pathname}
|
|
667
|
+
branch={preview?.branch}
|
|
668
|
+
staff={staffMode ? appLabel() : null}
|
|
669
|
+
onSendStaff={(input) =>
|
|
670
|
+
sendStaffReport(
|
|
671
|
+
{
|
|
672
|
+
appKey: appKey ?? '',
|
|
673
|
+
platform,
|
|
674
|
+
report: {
|
|
675
|
+
...input,
|
|
676
|
+
route: pathname,
|
|
677
|
+
device: deviceInfo(),
|
|
678
|
+
app: appVersion(),
|
|
679
|
+
diagnostics: diagnostics.snapshot(),
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
{ apiUrl },
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
device={deviceLabel()}
|
|
686
|
+
hasScenario={Boolean(controller.lastScenarioId())}
|
|
687
|
+
failedStep={failing?.step ?? null}
|
|
688
|
+
kind={composerKind}
|
|
689
|
+
onKindChange={setComposerKind}
|
|
690
|
+
onSendIdea={(draft) =>
|
|
691
|
+
submitIdea(
|
|
692
|
+
{
|
|
693
|
+
caller,
|
|
694
|
+
platform,
|
|
695
|
+
draft: { ...draft, route: pathname, device: deviceInfo() },
|
|
696
|
+
},
|
|
697
|
+
{ apiUrl },
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
onViewIdea={viewIdea}
|
|
701
|
+
onSend={async (input) => {
|
|
702
|
+
if (!preview) return { ok: false as const, message: 'This build isn’t a PR preview.' }
|
|
703
|
+
const result = await sendFeedback(
|
|
704
|
+
{
|
|
705
|
+
...input,
|
|
706
|
+
body: failing ? failedStepBody(failing.step, input.body) : input.body,
|
|
707
|
+
// A failed step in the PR's own plan is the PR's to fix.
|
|
708
|
+
...(failing ? { cause: 'pr' as const } : {}),
|
|
709
|
+
preview,
|
|
710
|
+
platform,
|
|
711
|
+
route: pathname,
|
|
712
|
+
device: deviceInfo(),
|
|
713
|
+
diagnostics: diagnostics.snapshot(),
|
|
714
|
+
...(controller.lastScenarioId() ? { scenarioId: controller.lastScenarioId() } : {}),
|
|
715
|
+
},
|
|
716
|
+
{ apiUrl },
|
|
717
|
+
)
|
|
718
|
+
if (result.ok && failing) {
|
|
719
|
+
await sendStepResult(
|
|
720
|
+
{
|
|
721
|
+
preview,
|
|
722
|
+
platform,
|
|
723
|
+
scenarioKey: failing.scenarioKey,
|
|
724
|
+
stepIndex: failing.step.index,
|
|
725
|
+
result: 'fail',
|
|
726
|
+
...(input.reporterName ? { reporterName: input.reporterName } : {}),
|
|
727
|
+
...(result.findingId ? { findingId: result.findingId } : {}),
|
|
728
|
+
},
|
|
729
|
+
{ apiUrl },
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
return result
|
|
733
|
+
}}
|
|
734
|
+
onSetCause={(findingId, cause) =>
|
|
735
|
+
preview
|
|
736
|
+
? sendCause({ preview, platform, findingId, cause }, { apiUrl })
|
|
737
|
+
: Promise.resolve({ ok: false as const, message: 'Not a PR preview.' })
|
|
738
|
+
}
|
|
739
|
+
onFileIssue={(findingId) =>
|
|
740
|
+
preview
|
|
741
|
+
? fileIssue({ preview, platform, findingId }, { apiUrl })
|
|
742
|
+
: Promise.resolve({
|
|
743
|
+
ok: false as const,
|
|
744
|
+
message: 'Not a PR preview.',
|
|
745
|
+
openInBrowser: false,
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
onRequestFix={(findingId) =>
|
|
749
|
+
!preview
|
|
750
|
+
? Promise.resolve({ ok: false as const, message: 'Not a PR preview.' })
|
|
751
|
+
: requestFix(
|
|
752
|
+
{
|
|
753
|
+
preview,
|
|
754
|
+
platform,
|
|
755
|
+
findingId,
|
|
756
|
+
...(rememberedName ? { reporterName: rememberedName } : {}),
|
|
757
|
+
},
|
|
758
|
+
{ apiUrl },
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
onCancel={cancelReport}
|
|
762
|
+
onClose={finishReport}
|
|
763
|
+
/>
|
|
764
|
+
) : null}
|
|
765
|
+
</Overlay>
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* A floating glass sheet that rises from the bottom edge and sits above the
|
|
771
|
+
* keyboard. It's a plain view in the overlay rather than a Modal, because on
|
|
772
|
+
* iOS a Modal can't present while the app is already showing a native modal
|
|
773
|
+
* screen. With `scrim`, the app behind dims and a tap on it closes the sheet.
|
|
774
|
+
*/
|
|
775
|
+
function Sheet({
|
|
776
|
+
onClose,
|
|
777
|
+
scrim = false,
|
|
778
|
+
children,
|
|
779
|
+
}: {
|
|
780
|
+
onClose: () => void
|
|
781
|
+
/** Dims the app behind, and a tap on it closes the sheet. */
|
|
782
|
+
scrim?: boolean
|
|
783
|
+
children: ReactNode
|
|
784
|
+
}) {
|
|
785
|
+
const { height } = useWindowDimensions()
|
|
786
|
+
const reduceMotion = useReduceMotion()
|
|
787
|
+
const shown = useRef(new Animated.Value(0)).current
|
|
788
|
+
useBackPress(onClose)
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
if (reduceMotion) {
|
|
791
|
+
shown.setValue(1)
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
Animated.spring(shown, { toValue: 1, ...SPRING, useNativeDriver: true }).start()
|
|
795
|
+
}, [shown, reduceMotion])
|
|
796
|
+
|
|
797
|
+
return (
|
|
798
|
+
<KeyboardAvoidingView
|
|
799
|
+
style={[
|
|
800
|
+
StyleSheet.absoluteFill,
|
|
801
|
+
styles.backdrop,
|
|
802
|
+
{ paddingTop: screenBounds(0, 0).insetTop },
|
|
803
|
+
]}
|
|
804
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
805
|
+
>
|
|
806
|
+
{/* Always there, so taps outside the sheet never reach the app behind it. */}
|
|
807
|
+
<Animated.View style={[StyleSheet.absoluteFill, scrim && styles.scrim, { opacity: shown }]}>
|
|
808
|
+
<Pressable
|
|
809
|
+
style={StyleSheet.absoluteFill}
|
|
810
|
+
onPress={scrim ? onClose : undefined}
|
|
811
|
+
accessible={scrim}
|
|
812
|
+
accessibilityRole="button"
|
|
813
|
+
accessibilityLabel="Close"
|
|
814
|
+
/>
|
|
815
|
+
</Animated.View>
|
|
816
|
+
<Animated.View
|
|
817
|
+
style={[
|
|
818
|
+
styles.sheet,
|
|
819
|
+
// Slides only: fading a view that holds glass stops the glass rendering.
|
|
820
|
+
{
|
|
821
|
+
transform: [
|
|
822
|
+
{
|
|
823
|
+
translateY: shown.interpolate({
|
|
824
|
+
inputRange: [0, 1],
|
|
825
|
+
outputRange: [height * 0.6, 0],
|
|
826
|
+
}),
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
},
|
|
830
|
+
]}
|
|
831
|
+
accessibilityViewIsModal
|
|
832
|
+
>
|
|
833
|
+
<Surface radius={38} style={styles.sheetSurface}>
|
|
834
|
+
{children}
|
|
835
|
+
</Surface>
|
|
836
|
+
</Animated.View>
|
|
837
|
+
</KeyboardAvoidingView>
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Close or Done on the left, a centered title, and the main action on the right. */
|
|
842
|
+
function SheetHeader({
|
|
843
|
+
title,
|
|
844
|
+
subtitle,
|
|
845
|
+
left,
|
|
846
|
+
right,
|
|
847
|
+
}: {
|
|
848
|
+
title?: string
|
|
849
|
+
subtitle?: string
|
|
850
|
+
left?: ReactNode
|
|
851
|
+
right?: ReactNode
|
|
852
|
+
}) {
|
|
853
|
+
const colors = useColors()
|
|
854
|
+
return (
|
|
855
|
+
<View style={styles.sheetHeader}>
|
|
856
|
+
<View style={styles.sheetSide}>{left}</View>
|
|
857
|
+
<View style={styles.sheetTitle}>
|
|
858
|
+
{title ? (
|
|
859
|
+
<Text style={[text.headline, styles.center, { color: colors.label }]} numberOfLines={1}>
|
|
860
|
+
{title}
|
|
861
|
+
</Text>
|
|
862
|
+
) : null}
|
|
863
|
+
{subtitle ? (
|
|
864
|
+
<Text
|
|
865
|
+
style={[text.footnote, styles.center, { color: colors.secondary }]}
|
|
866
|
+
numberOfLines={1}
|
|
867
|
+
>
|
|
868
|
+
{subtitle}
|
|
869
|
+
</Text>
|
|
870
|
+
) : null}
|
|
871
|
+
</View>
|
|
872
|
+
<View style={[styles.sheetSide, styles.sheetSideRight]}>{right}</View>
|
|
873
|
+
</View>
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** A short white flash as the screen is captured, like the system screenshot. */
|
|
878
|
+
function CaptureFlash() {
|
|
879
|
+
const opacity = useRef(new Animated.Value(0.85)).current
|
|
880
|
+
const [done, setDone] = useState(false)
|
|
881
|
+
useEffect(() => {
|
|
882
|
+
Animated.timing(opacity, { toValue: 0, duration: 260, useNativeDriver: true }).start(() =>
|
|
883
|
+
setDone(true),
|
|
884
|
+
)
|
|
885
|
+
}, [opacity])
|
|
886
|
+
if (done) return null
|
|
887
|
+
return (
|
|
888
|
+
<Animated.View
|
|
889
|
+
style={[StyleSheet.absoluteFill, styles.flash, { opacity }]}
|
|
890
|
+
pointerEvents="none"
|
|
891
|
+
/>
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** "A new build is ready" for a moment at the top, then it's just the bubble's dot. */
|
|
896
|
+
function NewBuildToast({ build, onPress }: { build: NewerBuild; onPress: () => void }) {
|
|
897
|
+
const colors = useColors()
|
|
898
|
+
return (
|
|
899
|
+
<View style={styles.toastWrap} pointerEvents="box-none">
|
|
900
|
+
<Pressable
|
|
901
|
+
onPress={onPress}
|
|
902
|
+
style={({ pressed }) => [pressed && styles.pressed]}
|
|
903
|
+
accessibilityRole="button"
|
|
904
|
+
accessibilityLiveRegion="polite"
|
|
905
|
+
>
|
|
906
|
+
<Surface radius={24} style={styles.toast}>
|
|
907
|
+
<View style={[styles.toastDot, { backgroundColor: COLORS.tape }]} />
|
|
908
|
+
<Text style={[text.subhead, styles.strong, { color: colors.label }]}>
|
|
909
|
+
{build.hasFix ? 'Claude’s fix is ready to test' : 'A new build is ready'}
|
|
910
|
+
</Text>
|
|
911
|
+
<Text style={[text.subhead, styles.strong, { color: colors.tint }]}>View</Text>
|
|
912
|
+
</Surface>
|
|
913
|
+
</Pressable>
|
|
914
|
+
</View>
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
type Status =
|
|
919
|
+
| { kind: 'editing' }
|
|
920
|
+
| { kind: 'sending' }
|
|
921
|
+
| {
|
|
922
|
+
kind: 'sent'
|
|
923
|
+
commentUrl: string
|
|
924
|
+
findingId?: string
|
|
925
|
+
triage: Triage
|
|
926
|
+
withScreenshot: boolean
|
|
927
|
+
}
|
|
928
|
+
| { kind: 'error'; message: string }
|
|
929
|
+
| { kind: 'idea_sent'; idea: Idea; emailed: boolean }
|
|
930
|
+
/** A staff build's report, filed straight as a GitHub issue. */
|
|
931
|
+
| { kind: 'filed'; issueNumber: number; issueUrl: string }
|
|
932
|
+
|
|
933
|
+
function Composer({
|
|
934
|
+
screenshot,
|
|
935
|
+
route,
|
|
936
|
+
branch,
|
|
937
|
+
device,
|
|
938
|
+
hasScenario,
|
|
939
|
+
failedStep,
|
|
940
|
+
kind,
|
|
941
|
+
staff,
|
|
942
|
+
onSendStaff,
|
|
943
|
+
onKindChange,
|
|
944
|
+
onSendIdea,
|
|
945
|
+
onViewIdea,
|
|
946
|
+
onSend,
|
|
947
|
+
onSetCause,
|
|
948
|
+
onRequestFix,
|
|
949
|
+
onFileIssue,
|
|
950
|
+
onCancel,
|
|
951
|
+
onClose,
|
|
952
|
+
}: {
|
|
953
|
+
screenshot: Screenshot | null
|
|
954
|
+
route: string
|
|
955
|
+
branch: string | undefined
|
|
956
|
+
/** e.g. "iPhone · iOS 26.1", for the list of what's attached. */
|
|
957
|
+
device: string
|
|
958
|
+
/** The report carries the scenario's setup. */
|
|
959
|
+
hasScenario: boolean
|
|
960
|
+
/** Set when the reviewer failed a step; the note becomes optional. */
|
|
961
|
+
failedStep: ChecklistStep | null
|
|
962
|
+
/** A bug report goes to the PR; an idea goes to Claude to write up and build. */
|
|
963
|
+
kind: 'issue' | 'idea'
|
|
964
|
+
/** A staff build's version label; reports go straight to a GitHub issue. Null on PR builds. */
|
|
965
|
+
staff: string | null
|
|
966
|
+
onSendStaff: (input: {
|
|
967
|
+
body: string
|
|
968
|
+
reporterName?: string
|
|
969
|
+
screenshot?: Screenshot
|
|
970
|
+
annotations?: Annotation[]
|
|
971
|
+
}) => ReturnType<typeof sendStaffReport>
|
|
972
|
+
onKindChange: (kind: 'issue' | 'idea') => void
|
|
973
|
+
onSendIdea: (draft: IdeaDraft) => ReturnType<typeof submitIdea>
|
|
974
|
+
onViewIdea: (idea: Idea) => void
|
|
975
|
+
onSend: (input: {
|
|
976
|
+
body: string
|
|
977
|
+
reporterName?: string
|
|
978
|
+
screenshot?: Screenshot
|
|
979
|
+
annotations?: Annotation[]
|
|
980
|
+
}) => ReturnType<typeof sendFeedback>
|
|
981
|
+
onSetCause: (findingId: string, cause: 'pr' | 'unrelated') => ReturnType<typeof sendCause>
|
|
982
|
+
onRequestFix: (findingId: string) => ReturnType<typeof requestFix>
|
|
983
|
+
onFileIssue: (findingId: string) => ReturnType<typeof fileIssue>
|
|
984
|
+
/** Closed without sending. */
|
|
985
|
+
onCancel: () => void
|
|
986
|
+
/** Closed after sending. */
|
|
987
|
+
onClose: () => void
|
|
988
|
+
}) {
|
|
989
|
+
const colors = useColors()
|
|
990
|
+
const { width, height } = useWindowDimensions()
|
|
991
|
+
const [body, setBody] = useState('')
|
|
992
|
+
const [name, setName] = useState(rememberedName)
|
|
993
|
+
const [email, setEmail] = useState(rememberedEmail)
|
|
994
|
+
const [includeScreenshot, setIncludeScreenshot] = useState(screenshot !== null)
|
|
995
|
+
const [annotations, setAnnotations] = useState<Annotation[]>([])
|
|
996
|
+
const [annotating, setAnnotating] = useState(false)
|
|
997
|
+
const [status, setStatus] = useState<Status>({ kind: 'editing' })
|
|
998
|
+
const pr = branch?.match(/^pr-(\d+)$/)?.[1]
|
|
999
|
+
const sending = status.kind === 'sending'
|
|
1000
|
+
const canSend = (failedStep !== null || body.trim() !== '') && !sending
|
|
1001
|
+
const withShot = includeScreenshot && screenshot !== null
|
|
1002
|
+
|
|
1003
|
+
const idea = kind === 'idea' && failedStep === null
|
|
1004
|
+
|
|
1005
|
+
async function sendIdea() {
|
|
1006
|
+
rememberedName = name.trim()
|
|
1007
|
+
rememberedEmail = email.trim()
|
|
1008
|
+
setStatus({ kind: 'sending' })
|
|
1009
|
+
const result = await onSendIdea({
|
|
1010
|
+
body: body.trim(),
|
|
1011
|
+
...(name.trim() ? { authorName: name.trim() } : {}),
|
|
1012
|
+
...(email.trim() ? { authorEmail: email.trim() } : {}),
|
|
1013
|
+
...(withShot && screenshot ? { screenshot } : {}),
|
|
1014
|
+
})
|
|
1015
|
+
if (!result.ok) {
|
|
1016
|
+
haptics.warning()
|
|
1017
|
+
setStatus({ kind: 'error', message: result.message })
|
|
1018
|
+
return
|
|
1019
|
+
}
|
|
1020
|
+
haptics.success()
|
|
1021
|
+
Keyboard.dismiss()
|
|
1022
|
+
setStatus({ kind: 'idea_sent', idea: result.idea, emailed: email.trim() !== '' })
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async function sendStaff() {
|
|
1026
|
+
rememberedName = name.trim()
|
|
1027
|
+
setStatus({ kind: 'sending' })
|
|
1028
|
+
const result = await onSendStaff({
|
|
1029
|
+
body: body.trim(),
|
|
1030
|
+
...(name.trim() ? { reporterName: name.trim() } : {}),
|
|
1031
|
+
...(withShot && screenshot ? { screenshot, annotations } : {}),
|
|
1032
|
+
})
|
|
1033
|
+
if (!result.ok) {
|
|
1034
|
+
haptics.warning()
|
|
1035
|
+
setStatus({ kind: 'error', message: result.message })
|
|
1036
|
+
return
|
|
1037
|
+
}
|
|
1038
|
+
haptics.success()
|
|
1039
|
+
Keyboard.dismiss()
|
|
1040
|
+
setStatus({ kind: 'filed', issueNumber: result.issueNumber, issueUrl: result.issueUrl })
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async function send() {
|
|
1044
|
+
if (idea) return sendIdea()
|
|
1045
|
+
if (staff) return sendStaff()
|
|
1046
|
+
rememberedName = name.trim()
|
|
1047
|
+
setStatus({ kind: 'sending' })
|
|
1048
|
+
const result = await onSend({
|
|
1049
|
+
body: body.trim(),
|
|
1050
|
+
...(name.trim() ? { reporterName: name.trim() } : {}),
|
|
1051
|
+
...(withShot && screenshot ? { screenshot, annotations } : {}),
|
|
1052
|
+
})
|
|
1053
|
+
if (!result.ok) {
|
|
1054
|
+
haptics.warning()
|
|
1055
|
+
setStatus({ kind: 'error', message: result.message })
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
haptics.success()
|
|
1059
|
+
Keyboard.dismiss()
|
|
1060
|
+
setStatus({
|
|
1061
|
+
kind: 'sent',
|
|
1062
|
+
commentUrl: result.commentUrl,
|
|
1063
|
+
withScreenshot: withShot,
|
|
1064
|
+
...(result.findingId ? { findingId: result.findingId } : {}),
|
|
1065
|
+
triage: {
|
|
1066
|
+
cause: result.cause ?? 'unknown',
|
|
1067
|
+
causeSource: result.causeSource ?? 'guess',
|
|
1068
|
+
reasons: result.reasons ?? [],
|
|
1069
|
+
...(result.issueUrl ? { issueUrl: result.issueUrl } : {}),
|
|
1070
|
+
},
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const attached = [
|
|
1075
|
+
withShot ? 'this screenshot' : null,
|
|
1076
|
+
route === '/' ? 'the home screen' : `the screen (${route})`,
|
|
1077
|
+
hasScenario ? 'the scenario’s setup' : null,
|
|
1078
|
+
device,
|
|
1079
|
+
'recent logs',
|
|
1080
|
+
].filter(Boolean)
|
|
1081
|
+
const attachedText = `Attached: ${attached.slice(0, -1).join(', ')} and ${attached.at(-1)}.`
|
|
1082
|
+
|
|
1083
|
+
return (
|
|
1084
|
+
<>
|
|
1085
|
+
<Sheet
|
|
1086
|
+
onClose={
|
|
1087
|
+
status.kind === 'sent' || status.kind === 'idea_sent' || status.kind === 'filed'
|
|
1088
|
+
? onClose
|
|
1089
|
+
: onCancel
|
|
1090
|
+
}
|
|
1091
|
+
scrim={status.kind === 'sent' || status.kind === 'idea_sent' || status.kind === 'filed'}
|
|
1092
|
+
>
|
|
1093
|
+
{status.kind === 'filed' ? (
|
|
1094
|
+
<Filed issueNumber={status.issueNumber} issueUrl={status.issueUrl} onClose={onClose} />
|
|
1095
|
+
) : status.kind === 'idea_sent' ? (
|
|
1096
|
+
<IdeaSent
|
|
1097
|
+
emailed={status.emailed}
|
|
1098
|
+
onView={() => onViewIdea(status.idea)}
|
|
1099
|
+
onClose={onClose}
|
|
1100
|
+
/>
|
|
1101
|
+
) : status.kind === 'sent' ? (
|
|
1102
|
+
<Sent
|
|
1103
|
+
pr={pr}
|
|
1104
|
+
commentUrl={status.commentUrl}
|
|
1105
|
+
triage={status.triage}
|
|
1106
|
+
withScreenshot={status.withScreenshot}
|
|
1107
|
+
onSetCause={
|
|
1108
|
+
status.findingId
|
|
1109
|
+
? (cause) => onSetCause(status.findingId as string, cause)
|
|
1110
|
+
: undefined
|
|
1111
|
+
}
|
|
1112
|
+
onRequestFix={
|
|
1113
|
+
status.findingId ? () => onRequestFix(status.findingId as string) : undefined
|
|
1114
|
+
}
|
|
1115
|
+
onFileIssue={
|
|
1116
|
+
status.findingId ? () => onFileIssue(status.findingId as string) : undefined
|
|
1117
|
+
}
|
|
1118
|
+
onClose={onClose}
|
|
1119
|
+
/>
|
|
1120
|
+
) : (
|
|
1121
|
+
<>
|
|
1122
|
+
<SheetHeader
|
|
1123
|
+
title={failedStep ? 'Step Failed' : idea ? 'Share an Idea' : 'Report an Issue'}
|
|
1124
|
+
subtitle={staff ?? (pr ? `PR #${pr}` : undefined)}
|
|
1125
|
+
left={
|
|
1126
|
+
<CircleButton icon="xmark" label="Cancel" onPress={onCancel} disabled={sending} />
|
|
1127
|
+
}
|
|
1128
|
+
right={
|
|
1129
|
+
<CircleButton
|
|
1130
|
+
icon="arrow.up"
|
|
1131
|
+
label={
|
|
1132
|
+
idea ? 'Send idea to Claude' : staff ? 'File as a GitHub issue' : 'Send to PR'
|
|
1133
|
+
}
|
|
1134
|
+
tint
|
|
1135
|
+
onPress={send}
|
|
1136
|
+
disabled={!canSend}
|
|
1137
|
+
busy={sending}
|
|
1138
|
+
/>
|
|
1139
|
+
}
|
|
1140
|
+
/>
|
|
1141
|
+
<ScrollView
|
|
1142
|
+
keyboardShouldPersistTaps="handled"
|
|
1143
|
+
style={styles.sheetScroll}
|
|
1144
|
+
contentContainerStyle={styles.form}
|
|
1145
|
+
>
|
|
1146
|
+
{failedStep ? null : (
|
|
1147
|
+
<Segmented
|
|
1148
|
+
label="What you're sending"
|
|
1149
|
+
value={kind}
|
|
1150
|
+
onChange={onKindChange}
|
|
1151
|
+
options={[
|
|
1152
|
+
{ value: 'issue', title: 'Something’s Wrong' },
|
|
1153
|
+
{ value: 'idea', title: 'Idea' },
|
|
1154
|
+
]}
|
|
1155
|
+
/>
|
|
1156
|
+
)}
|
|
1157
|
+
{failedStep ? (
|
|
1158
|
+
<View style={styles.quote}>
|
|
1159
|
+
<View style={[styles.quoteMark, { backgroundColor: colors.fail }]}>
|
|
1160
|
+
<Icon name="xmark" size={10} color="#FFFFFF" weight="bold" />
|
|
1161
|
+
</View>
|
|
1162
|
+
<View style={styles.quoteText}>
|
|
1163
|
+
<Text style={[text.subhead, styles.strong, { color: colors.label }]}>
|
|
1164
|
+
{failedStep.action}
|
|
1165
|
+
</Text>
|
|
1166
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>
|
|
1167
|
+
Expected: {failedStep.expect}
|
|
1168
|
+
</Text>
|
|
1169
|
+
</View>
|
|
1170
|
+
</View>
|
|
1171
|
+
) : null}
|
|
1172
|
+
<TextInput
|
|
1173
|
+
value={body}
|
|
1174
|
+
onChangeText={setBody}
|
|
1175
|
+
placeholder={
|
|
1176
|
+
failedStep
|
|
1177
|
+
? 'What happened instead? (optional)'
|
|
1178
|
+
: idea
|
|
1179
|
+
? 'What should the app do? Say what you’d change, and where.'
|
|
1180
|
+
: 'What’s wrong? What did you expect?'
|
|
1181
|
+
}
|
|
1182
|
+
placeholderTextColor={colors.secondary}
|
|
1183
|
+
multiline
|
|
1184
|
+
autoFocus
|
|
1185
|
+
style={[
|
|
1186
|
+
text.body,
|
|
1187
|
+
styles.note,
|
|
1188
|
+
{ color: colors.label, backgroundColor: colors.fill },
|
|
1189
|
+
]}
|
|
1190
|
+
accessibilityLabel={idea ? 'Your idea' : "What's wrong"}
|
|
1191
|
+
/>
|
|
1192
|
+
{status.kind === 'error' ? (
|
|
1193
|
+
<Text
|
|
1194
|
+
style={[text.footnote, styles.inset, { color: colors.fail }]}
|
|
1195
|
+
accessibilityRole="alert"
|
|
1196
|
+
>
|
|
1197
|
+
{status.message}
|
|
1198
|
+
</Text>
|
|
1199
|
+
) : null}
|
|
1200
|
+
<View style={styles.attach}>
|
|
1201
|
+
{screenshot ? (
|
|
1202
|
+
<Pressable
|
|
1203
|
+
onPress={() => setIncludeScreenshot((on) => !on)}
|
|
1204
|
+
accessibilityRole="switch"
|
|
1205
|
+
accessibilityLabel="Include screenshot"
|
|
1206
|
+
accessibilityState={{ checked: includeScreenshot }}
|
|
1207
|
+
style={[
|
|
1208
|
+
styles.thumb,
|
|
1209
|
+
{
|
|
1210
|
+
height: Math.round((THUMB_WIDTH * height) / Math.max(width, 1)),
|
|
1211
|
+
borderColor: colors.separator,
|
|
1212
|
+
},
|
|
1213
|
+
!includeScreenshot && styles.thumbOff,
|
|
1214
|
+
]}
|
|
1215
|
+
>
|
|
1216
|
+
<Image
|
|
1217
|
+
source={{ uri: `data:${screenshot.contentType};base64,${screenshot.base64}` }}
|
|
1218
|
+
style={StyleSheet.absoluteFill}
|
|
1219
|
+
resizeMode="cover"
|
|
1220
|
+
accessibilityLabel="Screenshot of the current screen"
|
|
1221
|
+
/>
|
|
1222
|
+
<View style={styles.thumbX}>
|
|
1223
|
+
<Icon
|
|
1224
|
+
name={includeScreenshot ? 'xmark' : 'arrow.clockwise'}
|
|
1225
|
+
size={8}
|
|
1226
|
+
color="#FFFFFF"
|
|
1227
|
+
weight="bold"
|
|
1228
|
+
/>
|
|
1229
|
+
</View>
|
|
1230
|
+
</Pressable>
|
|
1231
|
+
) : null}
|
|
1232
|
+
<View style={styles.attachMeta}>
|
|
1233
|
+
{withShot && !idea ? (
|
|
1234
|
+
<Pressable
|
|
1235
|
+
onPress={() => setAnnotating(true)}
|
|
1236
|
+
accessibilityRole="button"
|
|
1237
|
+
style={({ pressed }) => [
|
|
1238
|
+
styles.pill,
|
|
1239
|
+
{ backgroundColor: colors.tintFill },
|
|
1240
|
+
pressed && styles.pressed,
|
|
1241
|
+
]}
|
|
1242
|
+
>
|
|
1243
|
+
<Text style={[text.subhead, styles.strong, { color: colors.tint }]}>
|
|
1244
|
+
{annotations.length ? `Mark Up (${annotations.length})` : 'Mark Up'}
|
|
1245
|
+
</Text>
|
|
1246
|
+
</Pressable>
|
|
1247
|
+
) : null}
|
|
1248
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>{attachedText}</Text>
|
|
1249
|
+
</View>
|
|
1250
|
+
</View>
|
|
1251
|
+
<View style={styles.inset}>
|
|
1252
|
+
<ReviewerName name={name} onChange={setName} colors={colors} />
|
|
1253
|
+
</View>
|
|
1254
|
+
{idea ? (
|
|
1255
|
+
<>
|
|
1256
|
+
<TextInput
|
|
1257
|
+
value={email}
|
|
1258
|
+
onChangeText={setEmail}
|
|
1259
|
+
placeholder="Email for updates (optional)"
|
|
1260
|
+
placeholderTextColor={colors.secondary}
|
|
1261
|
+
keyboardType="email-address"
|
|
1262
|
+
autoCapitalize="none"
|
|
1263
|
+
autoCorrect={false}
|
|
1264
|
+
style={[
|
|
1265
|
+
text.subhead,
|
|
1266
|
+
styles.email,
|
|
1267
|
+
{ color: colors.label, backgroundColor: colors.fill },
|
|
1268
|
+
]}
|
|
1269
|
+
accessibilityLabel="Email for updates"
|
|
1270
|
+
/>
|
|
1271
|
+
<Text style={[text.footnote, styles.inset, { color: colors.secondary }]}>
|
|
1272
|
+
Claude reads the code and writes this up as a GitHub issue, asking you first if
|
|
1273
|
+
it needs to. Then anyone on the team can tap Build It, and you get a build to
|
|
1274
|
+
test.
|
|
1275
|
+
</Text>
|
|
1276
|
+
</>
|
|
1277
|
+
) : staff ? (
|
|
1278
|
+
<Text style={[text.footnote, styles.inset, { color: colors.secondary }]}>
|
|
1279
|
+
Sending opens a GitHub issue with what you wrote, the screenshot, this screen, the
|
|
1280
|
+
app’s version and recent errors.
|
|
1281
|
+
</Text>
|
|
1282
|
+
) : (
|
|
1283
|
+
<Text style={[text.footnote, styles.inset, { color: colors.secondary }]}>
|
|
1284
|
+
Sending posts this as a comment on {pr ? `PR #${pr}` : 'the PR'}. If it was
|
|
1285
|
+
already broken before this PR, you can file it as a GitHub issue next.
|
|
1286
|
+
</Text>
|
|
1287
|
+
)}
|
|
1288
|
+
</ScrollView>
|
|
1289
|
+
</>
|
|
1290
|
+
)}
|
|
1291
|
+
</Sheet>
|
|
1292
|
+
{/* A sibling of the sheet, so it can cover the whole screen. */}
|
|
1293
|
+
{annotating && screenshot ? (
|
|
1294
|
+
<Annotator
|
|
1295
|
+
screenshot={screenshot}
|
|
1296
|
+
initial={annotations}
|
|
1297
|
+
onDone={(boxes) => {
|
|
1298
|
+
setAnnotations(boxes)
|
|
1299
|
+
setAnnotating(false)
|
|
1300
|
+
}}
|
|
1301
|
+
onCancel={() => setAnnotating(false)}
|
|
1302
|
+
/>
|
|
1303
|
+
) : null}
|
|
1304
|
+
</>
|
|
1305
|
+
)
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const THUMB_WIDTH = 55
|
|
1309
|
+
|
|
1310
|
+
/** Two choices in one capsule; neither is picked until the cause is known. */
|
|
1311
|
+
function Segmented<T extends string>({
|
|
1312
|
+
options,
|
|
1313
|
+
value,
|
|
1314
|
+
onChange,
|
|
1315
|
+
label,
|
|
1316
|
+
}: {
|
|
1317
|
+
options: { value: T; title: string }[]
|
|
1318
|
+
value: T | null
|
|
1319
|
+
onChange: (value: T) => void
|
|
1320
|
+
label: string
|
|
1321
|
+
}) {
|
|
1322
|
+
const colors = useColors()
|
|
1323
|
+
return (
|
|
1324
|
+
<View
|
|
1325
|
+
style={[styles.segments, { backgroundColor: colors.fill }]}
|
|
1326
|
+
accessibilityRole="radiogroup"
|
|
1327
|
+
accessibilityLabel={label}
|
|
1328
|
+
>
|
|
1329
|
+
{options.map((option) => {
|
|
1330
|
+
const on = option.value === value
|
|
1331
|
+
return (
|
|
1332
|
+
<Pressable
|
|
1333
|
+
key={option.value}
|
|
1334
|
+
onPress={() => {
|
|
1335
|
+
if (on) return
|
|
1336
|
+
haptics.selection()
|
|
1337
|
+
onChange(option.value)
|
|
1338
|
+
}}
|
|
1339
|
+
accessibilityRole="radio"
|
|
1340
|
+
accessibilityState={{ checked: on }}
|
|
1341
|
+
style={[styles.segment, on && [styles.segmentOn, { backgroundColor: colors.segment }]]}
|
|
1342
|
+
>
|
|
1343
|
+
<Text
|
|
1344
|
+
style={[
|
|
1345
|
+
text.footnote,
|
|
1346
|
+
styles.segmentText,
|
|
1347
|
+
on && styles.strong,
|
|
1348
|
+
{ color: colors.label },
|
|
1349
|
+
]}
|
|
1350
|
+
numberOfLines={1}
|
|
1351
|
+
>
|
|
1352
|
+
{option.title}
|
|
1353
|
+
</Text>
|
|
1354
|
+
</Pressable>
|
|
1355
|
+
)
|
|
1356
|
+
})}
|
|
1357
|
+
</View>
|
|
1358
|
+
)
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** What filing an issue did, said plainly: where it went and whether Claude is on it. */
|
|
1362
|
+
function issueStatus(number: number, fix: IssueFix): string {
|
|
1363
|
+
const filed = `Created issue #${number}, linked from the PR.`
|
|
1364
|
+
if (fix === 'started')
|
|
1365
|
+
return `${filed} It has the tapthrough:fix label, so Claude is starting a fix in its own PR.`
|
|
1366
|
+
if (fix === 'not_set_up')
|
|
1367
|
+
return `${filed} Claude can’t fix it yet: Fix with Claude isn’t set up on this repo. The issue says what a developer needs to do.`
|
|
1368
|
+
if (fix === 'refused') return `${filed} Claude didn’t start. The issue says why.`
|
|
1369
|
+
if (fix === 'off')
|
|
1370
|
+
return `${filed} A developer can add the tapthrough:fix label to have Claude fix it.`
|
|
1371
|
+
return `${filed}`
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** After a staff build's report: the issue it became. */
|
|
1375
|
+
function Filed({
|
|
1376
|
+
issueNumber,
|
|
1377
|
+
issueUrl,
|
|
1378
|
+
onClose,
|
|
1379
|
+
}: {
|
|
1380
|
+
issueNumber: number
|
|
1381
|
+
issueUrl: string
|
|
1382
|
+
onClose: () => void
|
|
1383
|
+
}) {
|
|
1384
|
+
const colors = useColors()
|
|
1385
|
+
return (
|
|
1386
|
+
<>
|
|
1387
|
+
<SheetHeader
|
|
1388
|
+
title="Filed"
|
|
1389
|
+
left={<CircleButton icon="xmark" label="Done" onPress={onClose} />}
|
|
1390
|
+
/>
|
|
1391
|
+
<View style={[styles.form, styles.ideaSent]}>
|
|
1392
|
+
<View style={[styles.ideaMark, { backgroundColor: colors.tintFill }]}>
|
|
1393
|
+
<Icon name="checkmark" size={22} color={colors.tint} />
|
|
1394
|
+
</View>
|
|
1395
|
+
<Text style={[text.subhead, styles.center, { color: colors.label }]}>
|
|
1396
|
+
Created issue #{issueNumber} with your screenshot and details. The team sees it on GitHub.
|
|
1397
|
+
</Text>
|
|
1398
|
+
<Capsule title="View on GitHub" kind="plain" onPress={() => Linking.openURL(issueUrl)} />
|
|
1399
|
+
<Capsule title="Done" onPress={onClose} />
|
|
1400
|
+
</View>
|
|
1401
|
+
</>
|
|
1402
|
+
)
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** After an idea is sent: what happens next, and where to follow it. */
|
|
1406
|
+
function IdeaSent({
|
|
1407
|
+
emailed,
|
|
1408
|
+
onView,
|
|
1409
|
+
onClose,
|
|
1410
|
+
}: {
|
|
1411
|
+
emailed: boolean
|
|
1412
|
+
onView: () => void
|
|
1413
|
+
onClose: () => void
|
|
1414
|
+
}) {
|
|
1415
|
+
const colors = useColors()
|
|
1416
|
+
return (
|
|
1417
|
+
<>
|
|
1418
|
+
<SheetHeader
|
|
1419
|
+
title="Sent to Claude"
|
|
1420
|
+
left={<CircleButton icon="xmark" label="Done" onPress={onClose} />}
|
|
1421
|
+
/>
|
|
1422
|
+
<View style={[styles.form, styles.ideaSent]}>
|
|
1423
|
+
<View style={[styles.ideaMark, { backgroundColor: colors.tintFill }]}>
|
|
1424
|
+
<Icon name="lightbulb" size={22} color={colors.tint} />
|
|
1425
|
+
</View>
|
|
1426
|
+
<Text style={[text.subhead, styles.center, { color: colors.label }]}>
|
|
1427
|
+
Claude is reading the code and writing your idea up. If it has questions, they show up
|
|
1428
|
+
under Ideas in the Review panel{emailed ? ', and in your email' : ''}.
|
|
1429
|
+
</Text>
|
|
1430
|
+
<Capsule title="View Idea" onPress={onView} />
|
|
1431
|
+
<Capsule title="Done" kind="plain" onPress={onClose} />
|
|
1432
|
+
</View>
|
|
1433
|
+
</>
|
|
1434
|
+
)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* After a report is sent: whether it looks like the PR's bug, and the one tap
|
|
1439
|
+
* that follows. A bug the PR didn't cause can be filed as a GitHub issue from here.
|
|
1440
|
+
*/
|
|
1441
|
+
function Sent({
|
|
1442
|
+
pr,
|
|
1443
|
+
commentUrl,
|
|
1444
|
+
triage,
|
|
1445
|
+
withScreenshot,
|
|
1446
|
+
onSetCause,
|
|
1447
|
+
onRequestFix,
|
|
1448
|
+
onFileIssue,
|
|
1449
|
+
onClose,
|
|
1450
|
+
}: {
|
|
1451
|
+
pr: string | undefined
|
|
1452
|
+
commentUrl: string
|
|
1453
|
+
triage: Triage
|
|
1454
|
+
withScreenshot: boolean
|
|
1455
|
+
onSetCause: ((cause: 'pr' | 'unrelated') => ReturnType<typeof sendCause>) | undefined
|
|
1456
|
+
onRequestFix: (() => ReturnType<typeof requestFix>) | undefined
|
|
1457
|
+
onFileIssue: (() => ReturnType<typeof fileIssue>) | undefined
|
|
1458
|
+
onClose: () => void
|
|
1459
|
+
}) {
|
|
1460
|
+
const colors = useColors()
|
|
1461
|
+
const [cause, setCause] = useState(triage.cause)
|
|
1462
|
+
const [fix, setFix] = useState<'idle' | 'asking' | 'requested' | 'not_set_up'>('idle')
|
|
1463
|
+
const [byReporter, setByReporter] = useState(triage.causeSource === 'reporter')
|
|
1464
|
+
const [error, setError] = useState<string | null>(null)
|
|
1465
|
+
const [issue, setIssue] = useState<
|
|
1466
|
+
{ state: 'idle' | 'filing' } | { state: 'filed'; number: number; url: string; fix: IssueFix }
|
|
1467
|
+
>({ state: 'idle' })
|
|
1468
|
+
|
|
1469
|
+
async function createIssue() {
|
|
1470
|
+
if (!onFileIssue) {
|
|
1471
|
+
if (triage.issueUrl) Linking.openURL(triage.issueUrl)
|
|
1472
|
+
return
|
|
1473
|
+
}
|
|
1474
|
+
setIssue({ state: 'filing' })
|
|
1475
|
+
setError(null)
|
|
1476
|
+
const result = await onFileIssue()
|
|
1477
|
+
if (result.ok) {
|
|
1478
|
+
haptics.success()
|
|
1479
|
+
setIssue({
|
|
1480
|
+
state: 'filed',
|
|
1481
|
+
number: result.issueNumber,
|
|
1482
|
+
url: result.issueUrl,
|
|
1483
|
+
fix: result.fix,
|
|
1484
|
+
})
|
|
1485
|
+
return
|
|
1486
|
+
}
|
|
1487
|
+
setIssue({ state: 'idle' })
|
|
1488
|
+
// Tapthrough can't file it here (old server, or no Issues permission yet): GitHub's own form still works.
|
|
1489
|
+
if (result.openInBrowser && triage.issueUrl) {
|
|
1490
|
+
Linking.openURL(triage.issueUrl)
|
|
1491
|
+
return
|
|
1492
|
+
}
|
|
1493
|
+
setError(result.message)
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async function askFix() {
|
|
1497
|
+
if (!onRequestFix) return
|
|
1498
|
+
setFix('asking')
|
|
1499
|
+
setError(null)
|
|
1500
|
+
const result = await onRequestFix()
|
|
1501
|
+
if (result.ok) {
|
|
1502
|
+
setFix(result.status)
|
|
1503
|
+
} else {
|
|
1504
|
+
setFix('idle')
|
|
1505
|
+
setError(result.message)
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
async function choose(next: 'pr' | 'unrelated') {
|
|
1510
|
+
if (!onSetCause) return
|
|
1511
|
+
const previous = cause
|
|
1512
|
+
setCause(next)
|
|
1513
|
+
setByReporter(true)
|
|
1514
|
+
setError(null)
|
|
1515
|
+
const result = await onSetCause(next)
|
|
1516
|
+
if (!result.ok) {
|
|
1517
|
+
setCause(previous)
|
|
1518
|
+
setError(result.message)
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const where = pr ? `PR #${pr}` : 'the PR'
|
|
1523
|
+
const guess =
|
|
1524
|
+
cause !== 'unknown' && !byReporter && triage.reasons.length > 0
|
|
1525
|
+
? `Tapthrough’s guess: ${triage.reasons.join(' ')}`
|
|
1526
|
+
: null
|
|
1527
|
+
const autoFix = triage.issueFix === 'auto'
|
|
1528
|
+
const why =
|
|
1529
|
+
cause === 'unknown'
|
|
1530
|
+
? 'Did this PR cause it, or was it already broken?'
|
|
1531
|
+
: cause === 'pr'
|
|
1532
|
+
? `It stays on ${where} as a comment for the author to fix. Claude can fix it on this PR’s branch.`
|
|
1533
|
+
: autoFix
|
|
1534
|
+
? `It stays on ${where} as a note and won’t block the merge. A GitHub issue tracks it on its own, and Claude starts a fix for it in a separate PR.`
|
|
1535
|
+
: `It stays on ${where} as a note and won’t block the merge. A GitHub issue tracks it on its own. A developer can add the tapthrough:fix label to have Claude fix it in a separate PR.`
|
|
1536
|
+
|
|
1537
|
+
return (
|
|
1538
|
+
<>
|
|
1539
|
+
<SheetHeader right={<CircleButton icon="checkmark" label="Done" onPress={onClose} />} />
|
|
1540
|
+
<View style={styles.sent}>
|
|
1541
|
+
<View style={[styles.bigMark, { backgroundColor: colors.pass }]}>
|
|
1542
|
+
<Icon name="checkmark" size={28} color="#FFFFFF" weight="bold" />
|
|
1543
|
+
</View>
|
|
1544
|
+
<Text style={[text.title2, styles.center, { color: colors.label }]}>
|
|
1545
|
+
Sent to {pr ? `PR #${pr}` : 'the PR'}
|
|
1546
|
+
</Text>
|
|
1547
|
+
<Text style={[text.subhead, styles.center, { color: colors.secondary }]}>
|
|
1548
|
+
{withScreenshot ? 'Posted to the PR with your screenshot.' : 'Posted to the PR.'}
|
|
1549
|
+
</Text>
|
|
1550
|
+
</View>
|
|
1551
|
+
{onSetCause ? (
|
|
1552
|
+
<Segmented
|
|
1553
|
+
label="Cause"
|
|
1554
|
+
value={cause === 'unknown' ? null : cause}
|
|
1555
|
+
onChange={choose}
|
|
1556
|
+
options={[
|
|
1557
|
+
{ value: 'pr', title: 'Caused by this PR' },
|
|
1558
|
+
{ value: 'unrelated', title: 'Already broken' },
|
|
1559
|
+
]}
|
|
1560
|
+
/>
|
|
1561
|
+
) : null}
|
|
1562
|
+
<Text style={[text.footnote, styles.center, styles.why, { color: colors.secondary }]}>
|
|
1563
|
+
{why}
|
|
1564
|
+
</Text>
|
|
1565
|
+
{guess ? (
|
|
1566
|
+
<Text style={[text.caption, styles.center, styles.why, { color: colors.tertiary }]}>
|
|
1567
|
+
{guess}
|
|
1568
|
+
</Text>
|
|
1569
|
+
) : null}
|
|
1570
|
+
<View style={styles.next}>
|
|
1571
|
+
{cause === 'pr' && onRequestFix ? (
|
|
1572
|
+
fix === 'idle' ? (
|
|
1573
|
+
<Capsule title="Ask Claude to Fix" onPress={askFix} />
|
|
1574
|
+
) : (
|
|
1575
|
+
<View style={[styles.status, { backgroundColor: colors.fill }]}>
|
|
1576
|
+
{fix === 'asking' ? <ActivityIndicator size="small" /> : null}
|
|
1577
|
+
<Text style={[text.footnote, styles.statusText, { color: colors.label }]}>
|
|
1578
|
+
{fix === 'asking'
|
|
1579
|
+
? 'Asking Claude…'
|
|
1580
|
+
: fix === 'requested'
|
|
1581
|
+
? 'Claude is on it. When the fix builds, the Review button shows a dot.'
|
|
1582
|
+
: 'Fix with Claude isn’t set up on this repo yet. The PR comment has the two setup steps for a developer.'}
|
|
1583
|
+
</Text>
|
|
1584
|
+
</View>
|
|
1585
|
+
)
|
|
1586
|
+
) : null}
|
|
1587
|
+
{cause === 'unrelated' && (onFileIssue || triage.issueUrl) ? (
|
|
1588
|
+
issue.state === 'filed' ? (
|
|
1589
|
+
<>
|
|
1590
|
+
<View style={[styles.status, { backgroundColor: colors.fill }]}>
|
|
1591
|
+
<Text style={[text.footnote, styles.statusText, { color: colors.label }]}>
|
|
1592
|
+
{issueStatus(issue.number, issue.fix)}
|
|
1593
|
+
</Text>
|
|
1594
|
+
</View>
|
|
1595
|
+
<Capsule
|
|
1596
|
+
kind="plain"
|
|
1597
|
+
title={`View Issue #${issue.number}`}
|
|
1598
|
+
onPress={() => Linking.openURL(issue.url)}
|
|
1599
|
+
/>
|
|
1600
|
+
</>
|
|
1601
|
+
) : (
|
|
1602
|
+
<Capsule
|
|
1603
|
+
title={autoFix ? 'Create Issue for Claude' : 'Create GitHub Issue'}
|
|
1604
|
+
onPress={createIssue}
|
|
1605
|
+
busy={issue.state === 'filing'}
|
|
1606
|
+
/>
|
|
1607
|
+
)
|
|
1608
|
+
) : null}
|
|
1609
|
+
{error ? (
|
|
1610
|
+
<Text
|
|
1611
|
+
style={[text.footnote, styles.center, { color: colors.fail }]}
|
|
1612
|
+
accessibilityRole="alert"
|
|
1613
|
+
>
|
|
1614
|
+
{error}
|
|
1615
|
+
</Text>
|
|
1616
|
+
) : null}
|
|
1617
|
+
<Capsule kind="plain" title="View on GitHub" onPress={() => Linking.openURL(commentUrl)} />
|
|
1618
|
+
</View>
|
|
1619
|
+
</>
|
|
1620
|
+
)
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const styles = StyleSheet.create({
|
|
1624
|
+
pressed: { opacity: 0.7 },
|
|
1625
|
+
strong: { fontWeight: '600' },
|
|
1626
|
+
center: { textAlign: 'center' },
|
|
1627
|
+
inset: { paddingHorizontal: 8 },
|
|
1628
|
+
backdrop: { justifyContent: 'flex-end' },
|
|
1629
|
+
scrim: { backgroundColor: 'rgba(0, 0, 0, 0.14)' },
|
|
1630
|
+
flash: { backgroundColor: '#FFFFFF' },
|
|
1631
|
+
sheet: { marginHorizontal: 8, marginBottom: 8, flexShrink: 1 },
|
|
1632
|
+
sheetSurface: { flexShrink: 1, padding: 8, overflow: 'hidden' },
|
|
1633
|
+
sheetHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingBottom: 4 },
|
|
1634
|
+
sheetSide: { width: 44 },
|
|
1635
|
+
sheetSideRight: { alignItems: 'flex-end' },
|
|
1636
|
+
sheetTitle: { flex: 1, minWidth: 0 },
|
|
1637
|
+
sheetScroll: { flexGrow: 0, flexShrink: 1 },
|
|
1638
|
+
form: { paddingHorizontal: 8, paddingTop: 6, paddingBottom: 12, gap: 14 },
|
|
1639
|
+
quote: { flexDirection: 'row', gap: 12, alignItems: 'flex-start', paddingHorizontal: 8 },
|
|
1640
|
+
quoteMark: {
|
|
1641
|
+
width: 22,
|
|
1642
|
+
height: 22,
|
|
1643
|
+
borderRadius: 11,
|
|
1644
|
+
marginTop: 1,
|
|
1645
|
+
alignItems: 'center',
|
|
1646
|
+
justifyContent: 'center',
|
|
1647
|
+
},
|
|
1648
|
+
quoteText: { flex: 1, gap: 2 },
|
|
1649
|
+
note: {
|
|
1650
|
+
minHeight: 104,
|
|
1651
|
+
borderRadius: 20,
|
|
1652
|
+
paddingHorizontal: 14,
|
|
1653
|
+
paddingTop: 12,
|
|
1654
|
+
paddingBottom: 12,
|
|
1655
|
+
textAlignVertical: 'top',
|
|
1656
|
+
},
|
|
1657
|
+
email: { borderRadius: 14, paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 8 },
|
|
1658
|
+
ideaSent: { alignItems: 'stretch', gap: 12, paddingTop: 8 },
|
|
1659
|
+
ideaMark: {
|
|
1660
|
+
width: 48,
|
|
1661
|
+
height: 48,
|
|
1662
|
+
borderRadius: 24,
|
|
1663
|
+
alignItems: 'center',
|
|
1664
|
+
justifyContent: 'center',
|
|
1665
|
+
alignSelf: 'center',
|
|
1666
|
+
},
|
|
1667
|
+
attach: { flexDirection: 'row', gap: 14, alignItems: 'flex-start', paddingHorizontal: 8 },
|
|
1668
|
+
thumb: {
|
|
1669
|
+
width: THUMB_WIDTH,
|
|
1670
|
+
borderRadius: 9,
|
|
1671
|
+
overflow: 'hidden',
|
|
1672
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
1673
|
+
},
|
|
1674
|
+
thumbOff: { opacity: 0.3 },
|
|
1675
|
+
thumbX: {
|
|
1676
|
+
position: 'absolute',
|
|
1677
|
+
top: 4,
|
|
1678
|
+
right: 4,
|
|
1679
|
+
width: 18,
|
|
1680
|
+
height: 18,
|
|
1681
|
+
borderRadius: 9,
|
|
1682
|
+
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
1683
|
+
alignItems: 'center',
|
|
1684
|
+
justifyContent: 'center',
|
|
1685
|
+
},
|
|
1686
|
+
attachMeta: { flex: 1, gap: 8, alignItems: 'flex-start' },
|
|
1687
|
+
pill: { paddingVertical: 7, paddingHorizontal: 16, borderRadius: 999 },
|
|
1688
|
+
sent: { alignItems: 'center', gap: 6, paddingHorizontal: 20 },
|
|
1689
|
+
bigMark: {
|
|
1690
|
+
width: 64,
|
|
1691
|
+
height: 64,
|
|
1692
|
+
borderRadius: 32,
|
|
1693
|
+
alignItems: 'center',
|
|
1694
|
+
justifyContent: 'center',
|
|
1695
|
+
marginBottom: 8,
|
|
1696
|
+
},
|
|
1697
|
+
segments: {
|
|
1698
|
+
flexDirection: 'row',
|
|
1699
|
+
marginTop: 20,
|
|
1700
|
+
marginHorizontal: 12,
|
|
1701
|
+
padding: 3,
|
|
1702
|
+
borderRadius: 999,
|
|
1703
|
+
},
|
|
1704
|
+
segment: { flex: 1, paddingVertical: 8, borderRadius: 999, alignItems: 'center' },
|
|
1705
|
+
segmentOn: {
|
|
1706
|
+
shadowColor: '#000000',
|
|
1707
|
+
shadowOpacity: 0.12,
|
|
1708
|
+
shadowRadius: 8,
|
|
1709
|
+
shadowOffset: { width: 0, height: 3 },
|
|
1710
|
+
elevation: 2,
|
|
1711
|
+
},
|
|
1712
|
+
segmentText: { fontSize: 14 },
|
|
1713
|
+
why: { paddingHorizontal: 24, paddingTop: 10 },
|
|
1714
|
+
next: { paddingHorizontal: 12, paddingTop: 16, paddingBottom: 10, gap: 6 },
|
|
1715
|
+
status: {
|
|
1716
|
+
flexDirection: 'row',
|
|
1717
|
+
alignItems: 'center',
|
|
1718
|
+
gap: 10,
|
|
1719
|
+
paddingVertical: 14,
|
|
1720
|
+
paddingHorizontal: 16,
|
|
1721
|
+
borderRadius: 22,
|
|
1722
|
+
},
|
|
1723
|
+
statusText: { flex: 1 },
|
|
1724
|
+
toastWrap: {
|
|
1725
|
+
position: 'absolute',
|
|
1726
|
+
top: Platform.OS === 'ios' ? 58 : 28,
|
|
1727
|
+
left: 0,
|
|
1728
|
+
right: 0,
|
|
1729
|
+
alignItems: 'center',
|
|
1730
|
+
},
|
|
1731
|
+
toast: {
|
|
1732
|
+
flexDirection: 'row',
|
|
1733
|
+
alignItems: 'center',
|
|
1734
|
+
gap: 8,
|
|
1735
|
+
paddingVertical: 12,
|
|
1736
|
+
paddingHorizontal: 18,
|
|
1737
|
+
},
|
|
1738
|
+
toastDot: { width: 8, height: 8, borderRadius: 4 },
|
|
1739
|
+
})
|