@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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -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
+ })