@tapthrough/expo 0.0.0-stage → 0.1.1

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,337 @@
1
+ import type { Platform } from '@tapthrough/protocol'
2
+ import type { PreviewContext } from './feedback.js'
3
+
4
+ export type IdeaStatus =
5
+ | 'grooming'
6
+ | 'needs_answers'
7
+ | 'ready'
8
+ | 'building'
9
+ | 'built'
10
+ | 'ready_to_test'
11
+ | 'approved'
12
+ | 'merged'
13
+ | 'closed'
14
+ | 'declined'
15
+ | 'failed'
16
+ | 'not_set_up'
17
+
18
+ export type IdeaAction = 'answer' | 'build' | 'approve' | 'retry'
19
+
20
+ /** One step from sent to merged: done, in progress, not reached yet, or where it stopped. */
21
+ export interface IdeaStage {
22
+ key: 'sent' | 'written' | 'built' | 'preview' | 'merged'
23
+ title: string
24
+ state: 'done' | 'active' | 'waiting' | 'failed'
25
+ detail: string | null
26
+ at: string | null
27
+ }
28
+
29
+ /** An idea someone sent from the app, as Tapthrough reports it. */
30
+ export interface Idea {
31
+ id: string
32
+ status: IdeaStatus
33
+ body: string
34
+ title: string | null
35
+ summary: string | null
36
+ questions: string[]
37
+ steps: { action: string; expect: string }[]
38
+ authorName: string | null
39
+ issueNumber: number | null
40
+ issueUrl: string | null
41
+ prNumber: number | null
42
+ prUrl: string | null
43
+ previewUrl: string | null
44
+ note: string | null
45
+ /** e.g. "Claude has a question", from the server so wording can change without an app update. */
46
+ statusLine: string
47
+ /** Older servers send none; the app then shows the status line only. */
48
+ stages?: IdeaStage[]
49
+ /** Something looks stuck or slow; null when all is well. */
50
+ warning?: string | null
51
+ runUrl?: string | null
52
+ actionsUrl?: string
53
+ createdAt: string
54
+ actions: IdeaAction[]
55
+ }
56
+
57
+ /** An open PR with a preview, for switching builds without going back to GitHub. */
58
+ export interface OpenPr {
59
+ number: number
60
+ title: string
61
+ author: string | null
62
+ draft: boolean
63
+ current: boolean
64
+ openUrl: string | null
65
+ needsNewBuild: boolean
66
+ fromIdea: boolean
67
+ updatedAt: string
68
+ }
69
+
70
+ export type ApproveOutcome = 'review' | 'auto_merge' | 'merged'
71
+
72
+ type Result<T> = ({ ok: true } & T) | { ok: false; message: string }
73
+
74
+ interface Options {
75
+ apiUrl: string
76
+ fetch?: typeof fetch
77
+ }
78
+
79
+ const OFFLINE = "Couldn't reach Tapthrough. Check your connection and try again."
80
+
81
+ /**
82
+ * Who's asking: a PR build, proved by its running update, or a staging or
83
+ * TestFlight build, proved by the repo's app key.
84
+ */
85
+ export type Caller = { kind: 'build'; preview: PreviewContext } | { kind: 'key'; appKey: string }
86
+
87
+ function credential(caller: Caller, platform: Platform) {
88
+ return caller.kind === 'build'
89
+ ? {
90
+ apiVersion: 1,
91
+ updateGroupId: caller.preview.updateGroupId,
92
+ projectId: caller.preview.projectId,
93
+ platform,
94
+ }
95
+ : { apiVersion: 1, appKey: caller.appKey, platform }
96
+ }
97
+
98
+ function query(caller: Caller, platform: Platform) {
99
+ return new URLSearchParams(
100
+ caller.kind === 'build'
101
+ ? {
102
+ updateGroupId: caller.preview.updateGroupId,
103
+ projectId: caller.preview.projectId,
104
+ platform,
105
+ }
106
+ : { appKey: caller.appKey, platform },
107
+ )
108
+ }
109
+
110
+ async function call<T>(
111
+ options: Options,
112
+ path: string,
113
+ init: { method: 'GET' } | { method: 'POST'; body: unknown },
114
+ pick: (body: Record<string, unknown>) => T | undefined,
115
+ ): Promise<Result<T>> {
116
+ const doFetch = options.fetch ?? fetch
117
+ let response: Response
118
+ try {
119
+ response = await doFetch(`${options.apiUrl.replace(/\/$/, '')}${path}`, {
120
+ method: init.method,
121
+ ...(init.method === 'POST'
122
+ ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(init.body) }
123
+ : {}),
124
+ })
125
+ } catch {
126
+ return { ok: false, message: OFFLINE }
127
+ }
128
+ const body = (await response.json().catch(() => undefined)) as
129
+ | (Record<string, unknown> & { error?: { message?: string } })
130
+ | undefined
131
+ const value = response.ok && body ? pick(body) : undefined
132
+ if (value !== undefined) return { ok: true, ...value }
133
+ // A server from before ideas answers 404 without an error code.
134
+ if (response.status === 404 && !body?.error) {
135
+ return { ok: false, message: 'This version of Tapthrough doesn’t take ideas yet.' }
136
+ }
137
+ return { ok: false, message: body?.error?.message ?? `Tapthrough returned ${response.status}.` }
138
+ }
139
+
140
+ const pickIdea = (body: Record<string, unknown>) =>
141
+ body.idea && typeof body.idea === 'object' ? { idea: body.idea as Idea } : undefined
142
+
143
+ export interface IdeaDraft {
144
+ body: string
145
+ authorName?: string
146
+ authorEmail?: string
147
+ route?: string
148
+ device?: { os?: string; osVersion?: string; model?: string }
149
+ screenshot?: { contentType: 'image/jpeg' | 'image/png'; base64: string }
150
+ }
151
+
152
+ /** Sends an idea; Claude starts writing it up in the repo's fix workflow. Never throws. */
153
+ export function submitIdea(
154
+ input: { caller: Caller; platform: Platform; draft: IdeaDraft },
155
+ options: Options,
156
+ ) {
157
+ const { draft } = input
158
+ return call(
159
+ options,
160
+ '/api/v1/ideas',
161
+ {
162
+ method: 'POST',
163
+ body: {
164
+ ...credential(input.caller, input.platform),
165
+ body: draft.body,
166
+ ...(draft.authorName ? { authorName: draft.authorName } : {}),
167
+ ...(draft.authorEmail ? { authorEmail: draft.authorEmail } : {}),
168
+ ...(draft.route ? { route: draft.route } : {}),
169
+ ...(draft.device ? { device: draft.device } : {}),
170
+ ...(draft.screenshot ? { screenshot: draft.screenshot } : {}),
171
+ },
172
+ },
173
+ pickIdea,
174
+ )
175
+ }
176
+
177
+ /** The app's recent ideas, newest first; an empty list when Tapthrough can't say. */
178
+ export async function fetchIdeas(
179
+ caller: Caller,
180
+ platform: Platform,
181
+ options: Options,
182
+ ): Promise<Idea[]> {
183
+ const path = `/api/v1/ideas?${query(caller, platform)}`
184
+ const result = await call(options, path, { method: 'GET' }, (body) =>
185
+ Array.isArray(body.ideas) ? { ideas: body.ideas as Idea[] } : undefined,
186
+ )
187
+ return result.ok ? result.ideas : []
188
+ }
189
+
190
+ /** The repo's open PRs with previews; null when Tapthrough can't say. */
191
+ export async function fetchOpenPrs(
192
+ preview: PreviewContext,
193
+ platform: Platform,
194
+ options: Options,
195
+ ): Promise<OpenPr[] | null> {
196
+ const path = `/api/v1/pulls?${query({ kind: 'build', preview }, platform)}`
197
+ const result = await call(options, path, { method: 'GET' }, (body) =>
198
+ Array.isArray(body.pulls) ? { pulls: body.pulls as OpenPr[] } : undefined,
199
+ )
200
+ return result.ok ? result.pulls : null
201
+ }
202
+
203
+ /** Answers Claude's questions; it writes the idea up again with them. Never throws. */
204
+ export function answerIdea(
205
+ input: {
206
+ caller: Caller
207
+ platform: Platform
208
+ id: string
209
+ answers: string[]
210
+ by?: string
211
+ },
212
+ options: Options,
213
+ ) {
214
+ return call(
215
+ options,
216
+ `/api/v1/ideas/${encodeURIComponent(input.id)}/answers`,
217
+ {
218
+ method: 'POST',
219
+ body: {
220
+ ...credential(input.caller, input.platform),
221
+ answers: input.answers,
222
+ ...(input.by ? { by: input.by } : {}),
223
+ },
224
+ },
225
+ pickIdea,
226
+ )
227
+ }
228
+
229
+ /** Build It, Retry or Approve on one idea. Never throws. */
230
+ export function ideaAction(
231
+ input: {
232
+ caller: Caller
233
+ platform: Platform
234
+ id: string
235
+ action: 'build' | 'retry' | 'approve'
236
+ by?: string
237
+ },
238
+ options: Options,
239
+ ) {
240
+ return call(
241
+ options,
242
+ `/api/v1/ideas/${encodeURIComponent(input.id)}/${input.action}`,
243
+ {
244
+ method: 'POST',
245
+ body: { ...credential(input.caller, input.platform), ...(input.by ? { by: input.by } : {}) },
246
+ },
247
+ (body) => {
248
+ const picked = pickIdea(body)
249
+ if (!picked) return undefined
250
+ const result = body.result
251
+ return {
252
+ ...picked,
253
+ approve:
254
+ result === 'review' || result === 'auto_merge' || result === 'merged'
255
+ ? (result as ApproveOutcome)
256
+ : null,
257
+ }
258
+ },
259
+ )
260
+ }
261
+
262
+ /** Ideas waiting on a person: questions to answer, or a build to test. */
263
+ export function ideasNeedingYou(ideas: Idea[]): number {
264
+ return ideas.filter((idea) => idea.status === 'needs_answers' || idea.status === 'ready_to_test')
265
+ .length
266
+ }
267
+
268
+ /** What Approve did, in plain words. */
269
+ export function approveMessage(outcome: ApproveOutcome | null): string {
270
+ if (outcome === 'merged') return 'Approved and merged.'
271
+ if (outcome === 'auto_merge') return 'Approved. It merges once its checks pass.'
272
+ return 'Approved. An engineer reviews and merges it.'
273
+ }
274
+
275
+ /** Grooming, building or waiting on a preview: worth refreshing often while someone watches. */
276
+ export function isMoving(idea: Idea): boolean {
277
+ return idea.status === 'grooming' || idea.status === 'building' || idea.status === 'built'
278
+ }
279
+
280
+ /** "2:41 PM" today, "Nov 10" before, for a timeline step. */
281
+ export function stepTime(iso: string | null, now = new Date()): string | null {
282
+ if (!iso) return null
283
+ const date = new Date(iso)
284
+ if (Number.isNaN(date.getTime())) return null
285
+ const sameDay = date.toDateString() === now.toDateString()
286
+ return sameDay
287
+ ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
288
+ : date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
289
+ }
290
+
291
+ export interface StaffReport {
292
+ body: string
293
+ reporterName?: string
294
+ route?: string
295
+ device?: { os?: string; osVersion?: string; model?: string }
296
+ app?: { version?: string; build?: string }
297
+ screenshot?: { contentType: 'image/jpeg' | 'image/png'; base64: string }
298
+ annotations?: unknown[]
299
+ diagnostics?: unknown
300
+ }
301
+
302
+ /**
303
+ * Files a report from a staging or TestFlight build. There's no PR to comment
304
+ * on, so Tapthrough opens a GitHub issue. Never throws.
305
+ */
306
+ export function sendStaffReport(
307
+ input: { appKey: string; platform: Platform; report: StaffReport },
308
+ options: Options,
309
+ ) {
310
+ const { report } = input
311
+ return call(
312
+ options,
313
+ '/api/v1/reports',
314
+ {
315
+ method: 'POST',
316
+ body: {
317
+ apiVersion: 1,
318
+ appKey: input.appKey,
319
+ platform: input.platform,
320
+ body: report.body,
321
+ ...(report.reporterName ? { reporterName: report.reporterName } : {}),
322
+ ...(report.route ? { route: report.route } : {}),
323
+ ...(report.device ? { device: report.device } : {}),
324
+ ...(report.app ? { app: report.app } : {}),
325
+ ...(report.screenshot ? { screenshot: report.screenshot } : {}),
326
+ ...(report.screenshot && report.annotations?.length
327
+ ? { annotations: report.annotations }
328
+ : {}),
329
+ ...(report.diagnostics ? { diagnostics: report.diagnostics } : {}),
330
+ },
331
+ },
332
+ (body) =>
333
+ typeof body.issueNumber === 'number' && typeof body.issueUrl === 'string'
334
+ ? { issueNumber: body.issueNumber, issueUrl: body.issueUrl }
335
+ : undefined,
336
+ )
337
+ }
@@ -0,0 +1,119 @@
1
+ import { ErrorResponse, type Flags, resolveRoute, SeedHookResponse } from '@tapthrough/protocol'
2
+ import { decodeTokenClaims } from './feedback.js'
3
+
4
+ /** Logs the app in with the session the seed hook created. */
5
+ export interface AuthAdapter {
6
+ signIn(session: Record<string, unknown>): Promise<void>
7
+ }
8
+
9
+ /** Clears one piece of local state, e.g. a query cache or a persisted store. */
10
+ export type ResetTarget = () => void | Promise<void>
11
+
12
+ export interface LaunchDeps {
13
+ seedHookUrl: string
14
+ /**
15
+ * The app's own IDs (iOS bundle ID, Android package). A token for any other
16
+ * app is refused before anything changes, so a stray link can't swap accounts.
17
+ */
18
+ expectedAudiences?: string[]
19
+ auth: AuthAdapter
20
+ reset?: ResetTarget[]
21
+ applyFlags?: (flags: Flags) => void
22
+ navigate: (path: string) => void | Promise<void>
23
+ fetch?: typeof fetch
24
+ /** Called as each part of the launch starts, so a UI can show progress. */
25
+ onStage?: (stage: LaunchStage) => void
26
+ }
27
+
28
+ /** The parts of a launch, in order. */
29
+ export type LaunchStage = 'seeding' | 'resetting' | 'signing_in' | 'opening'
30
+
31
+ export interface LaunchError {
32
+ code: string
33
+ /** Shown to the reviewer as is. */
34
+ message: string
35
+ }
36
+
37
+ export type LaunchResult = { ok: true; path: string } | { ok: false; error: LaunchError }
38
+
39
+ const fail = (code: string, message: string): LaunchResult => ({
40
+ ok: false,
41
+ error: { code, message },
42
+ })
43
+
44
+ /**
45
+ * The launch sequence for one link token: ask the seed hook for a session,
46
+ * clear local state, log in, apply flags, then open the scenario's screen.
47
+ * Never throws; every failure comes back as a reviewer-facing message.
48
+ */
49
+ export async function runLaunch(token: string, deps: LaunchDeps): Promise<LaunchResult> {
50
+ const doFetch = deps.fetch ?? fetch
51
+
52
+ if (deps.expectedAudiences?.length) {
53
+ const aud = decodeTokenClaims(token)?.aud
54
+ const audiences = Array.isArray(aud) ? aud : [aud]
55
+ if (!audiences.some((a) => typeof a === 'string' && deps.expectedAudiences?.includes(a))) {
56
+ return fail('wrong_app', 'This link is for a different app.')
57
+ }
58
+ }
59
+
60
+ deps.onStage?.('seeding')
61
+ let response: Response
62
+ try {
63
+ response = await doFetch(deps.seedHookUrl, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ token }),
67
+ })
68
+ } catch {
69
+ return fail('network', "Couldn't reach the seed hook. Check your connection and try again.")
70
+ }
71
+
72
+ const body: unknown = await response.json().catch(() => undefined)
73
+ if (!response.ok) {
74
+ const error = ErrorResponse.safeParse(body)
75
+ if (error.success) return { ok: false, error: error.data.error }
76
+ return fail('seed_hook_failed', `The seed hook returned ${response.status}.`)
77
+ }
78
+ const seeded = SeedHookResponse.safeParse(body)
79
+ if (!seeded.success) {
80
+ return fail('bad_response', 'The seed hook returned a response this app version cannot read.')
81
+ }
82
+
83
+ // Resolve before changing anything, so a bad route leaves the app untouched.
84
+ let path: string
85
+ try {
86
+ path = resolveRoute(seeded.data.route, seeded.data.refs)
87
+ } catch (error) {
88
+ return fail('bad_route', error instanceof Error ? error.message : 'Could not resolve the route')
89
+ }
90
+
91
+ // Reset before signing in, so clearing persisted keys can't remove the new session.
92
+ deps.onStage?.('resetting')
93
+ try {
94
+ for (const target of deps.reset ?? []) await target()
95
+ } catch (error) {
96
+ return fail('reset_failed', `Couldn't clear local data: ${messageOf(error)}`)
97
+ }
98
+
99
+ deps.onStage?.('signing_in')
100
+ try {
101
+ await deps.auth.signIn(seeded.data.session)
102
+ } catch (error) {
103
+ return fail('sign_in_failed', `Couldn't sign in as the test user: ${messageOf(error)}`)
104
+ }
105
+
106
+ deps.onStage?.('opening')
107
+ deps.applyFlags?.(seeded.data.flags)
108
+
109
+ try {
110
+ await deps.navigate(path)
111
+ } catch (error) {
112
+ return fail('navigation_failed', `Couldn't open ${path}: ${messageOf(error)}`)
113
+ }
114
+ return { ok: true, path }
115
+ }
116
+
117
+ function messageOf(error: unknown): string {
118
+ return error instanceof Error ? error.message : String(error)
119
+ }
@@ -0,0 +1,28 @@
1
+ import type { ResetTarget } from './launch.js'
2
+
3
+ /** Clears a TanStack Query cache so the test user never sees the previous user's data. */
4
+ export function resetQueryClient(queryClient: { clear(): void }): ResetTarget {
5
+ return () => queryClient.clear()
6
+ }
7
+
8
+ /** Removes persisted keys from AsyncStorage, MMKV or anything with `removeItem`. */
9
+ export function resetStorage(
10
+ storage: { removeItem(key: string): void | Promise<void> },
11
+ keys: string[],
12
+ ): ResetTarget {
13
+ return async () => {
14
+ for (const key of keys) await storage.removeItem(key)
15
+ }
16
+ }
17
+
18
+ interface ResettableStore {
19
+ getInitialState(): unknown
20
+ setState(state: never, replace: true): void
21
+ }
22
+
23
+ /** Puts Zustand stores back to their initial state. */
24
+ export function resetStores(stores: ResettableStore[]): ResetTarget {
25
+ return () => {
26
+ for (const store of stores) store.setState(store.getInitialState() as never, true)
27
+ }
28
+ }
@@ -0,0 +1,148 @@
1
+ import { type Bounds, BUBBLE_SIZE, EDGE_MARGIN, type Point } from './bubble.js'
2
+ import type {
3
+ Checklist,
4
+ ChecklistScenario,
5
+ LibraryEntry,
6
+ SetupLine,
7
+ StepResultValue,
8
+ } from './feedback.js'
9
+ import type { LaunchStage } from './launch.js'
10
+
11
+ /** Step results keyed `scenario:index`, with the reviewer's own taps applied on top. */
12
+ export type Results = Record<string, StepResultValue | undefined>
13
+
14
+ export const stepId = (scenarioKey: string, index: number) => `${scenarioKey}:${index}`
15
+
16
+ /** The results the server already has for each step. */
17
+ export function resultsFrom(checklist: Checklist | null): Results {
18
+ if (!checklist) return {}
19
+ return Object.fromEntries(
20
+ checklist.scenarios.flatMap((scenario) =>
21
+ scenario.steps.map((step) => [stepId(scenario.key, step.index), step.result?.result]),
22
+ ),
23
+ )
24
+ }
25
+
26
+ export interface ScenarioProgress {
27
+ total: number
28
+ /** Steps with any result, skips included. */
29
+ checked: number
30
+ passed: number
31
+ failed: number
32
+ state: 'new' | 'partial' | 'passed' | 'failed'
33
+ }
34
+
35
+ export function scenarioProgress(scenario: ChecklistScenario, results: Results): ScenarioProgress {
36
+ const values = scenario.steps.map((step) => results[stepId(scenario.key, step.index)])
37
+ const checked = values.filter(Boolean).length
38
+ const passed = values.filter((v) => v === 'pass').length
39
+ const failed = values.filter((v) => v === 'fail').length
40
+ const total = values.length
41
+ const state =
42
+ checked === 0 ? 'new' : checked < total ? 'partial' : failed > 0 ? 'failed' : 'passed'
43
+ return { total, checked, passed, failed, state }
44
+ }
45
+
46
+ /**
47
+ * Position in `scenario.steps` of the first step without a result, starting at
48
+ * `from` and wrapping round. -1 when every step has one.
49
+ */
50
+ export function nextOpenStep(scenario: ChecklistScenario, results: Results, from = 0): number {
51
+ const n = scenario.steps.length
52
+ for (let k = 0; k < n; k++) {
53
+ const i = (((from + k) % n) + n) % n
54
+ const step = scenario.steps[i]
55
+ if (step && !results[stepId(scenario.key, step.index)]) return i
56
+ }
57
+ return -1
58
+ }
59
+
60
+ /** The next scenario with a step left, after `afterKey` and wrapping round. */
61
+ export function nextOpenScenario(
62
+ checklist: Checklist,
63
+ results: Results,
64
+ afterKey?: string,
65
+ ): ChecklistScenario | undefined {
66
+ const list = checklist.scenarios
67
+ const start = afterKey ? list.findIndex((s) => s.key === afterKey) + 1 : 0
68
+ for (let k = 0; k < list.length; k++) {
69
+ const scenario = list[(start + k) % list.length]
70
+ if (scenario && nextOpenStep(scenario, results) >= 0) return scenario
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /** Scenarios in the run that aren't in the PR's plan, for "Other states". */
76
+ export function otherStates(checklist: Checklist): LibraryEntry[] {
77
+ const planned = new Set(checklist.scenarios.map((s) => s.key))
78
+ return (checklist.library ?? []).filter((entry) => !entry.picked && !planned.has(entry.key))
79
+ }
80
+
81
+ export interface Placement {
82
+ side: 'left' | 'right'
83
+ /** Distance from the screen's `side` edge. */
84
+ inset: number
85
+ width: number
86
+ vertical: 'above' | 'below'
87
+ /** Distance from the screen's bottom (above the button) or top (below it). */
88
+ offset: number
89
+ maxHeight: number
90
+ }
91
+
92
+ /**
93
+ * Where the popover goes for a button resting at `anchor`: on the button's
94
+ * side of the screen, and above or below it, whichever has more room.
95
+ */
96
+ export function popoverPlacement(
97
+ anchor: Point,
98
+ bounds: Bounds,
99
+ preferred: { width: number; maxHeight: number } = { width: 320, maxHeight: 560 },
100
+ size = BUBBLE_SIZE,
101
+ gap = 10,
102
+ ): Placement {
103
+ const side = anchor.x + size / 2 < bounds.width / 2 ? 'left' : 'right'
104
+ const width = Math.min(preferred.width, bounds.width - EDGE_MARGIN * 2)
105
+ const above = anchor.y - gap - bounds.insetTop
106
+ const below = bounds.height - bounds.insetBottom - (anchor.y + size + gap)
107
+ const vertical = above >= below ? 'above' : 'below'
108
+ const room = Math.max(vertical === 'above' ? above : below, 0)
109
+ return {
110
+ side,
111
+ inset: EDGE_MARGIN,
112
+ width,
113
+ vertical,
114
+ offset: vertical === 'above' ? bounds.height - anchor.y + gap : anchor.y + size + gap,
115
+ maxHeight: Math.min(preferred.maxHeight, room),
116
+ }
117
+ }
118
+
119
+ export type SetupProgress = 'requesting' | LaunchStage | 'done'
120
+ export type LineState = 'waiting' | 'active' | 'done'
121
+
122
+ const RANK: Record<SetupProgress, number> = {
123
+ requesting: 0,
124
+ seeding: 1,
125
+ resetting: 2,
126
+ signing_in: 3,
127
+ opening: 4,
128
+ done: 5,
129
+ }
130
+ // The seed hook makes the account and data; sign-in uses the account; flags and
131
+ // navigation come last. Each line is done once the launch is past its part.
132
+ const ACTIVE_AT: Record<SetupLine['kind'], number> = { data: 1, account: 3, flag: 4, screen: 4 }
133
+ const DONE_AT: Record<SetupLine['kind'], number> = { data: 2, account: 4, flag: 4, screen: 5 }
134
+
135
+ /** How far along each setup line is, for ticking them off while a launch runs. */
136
+ export function setupLineStates(lines: SetupLine[], progress: SetupProgress): LineState[] {
137
+ const rank = RANK[progress]
138
+ const states = lines.map(
139
+ (line): LineState =>
140
+ rank >= DONE_AT[line.kind] ? 'done' : rank >= ACTIVE_AT[line.kind] ? 'active' : 'waiting',
141
+ )
142
+ // Something is always visibly happening until the end.
143
+ if (!states.includes('active')) {
144
+ const first = states.indexOf('waiting')
145
+ if (first >= 0) states[first] = 'active'
146
+ }
147
+ return states
148
+ }
@@ -0,0 +1,21 @@
1
+ /** Path the preview link opens: `<scheme>://tapthrough/launch?t=<token>`, or the same path on a universal link. */
2
+ export const LAUNCH_PATH = 'tapthrough/launch'
3
+
4
+ // Parsed by hand: React Native's URL polyfill doesn't handle custom schemes everywhere.
5
+ const LAUNCH_URL = /(?:^|\/)tapthrough\/launch\/?\?(?:[^#]*&)?t=([^&#]+)/
6
+
7
+ /**
8
+ * Returns the link token from a launch URL or path, or null for any other URL.
9
+ * Accepts `myapp://tapthrough/launch?t=…`, `https://host/tapthrough/launch?t=…`
10
+ * and the bare path Expo Router passes to `+native-intent`.
11
+ */
12
+ export function parseLaunchUrl(url: string | null | undefined): string | null {
13
+ if (!url) return null
14
+ const match = LAUNCH_URL.exec(url)
15
+ if (!match?.[1]) return null
16
+ try {
17
+ return decodeURIComponent(match[1])
18
+ } catch {
19
+ return null
20
+ }
21
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+ import type { LaunchState } from './core/controller.js'
3
+ import { getFlagOverride, subscribeToFlagOverrides } from './core/flags.js'
4
+ import { controller } from './runtime.js'
5
+
6
+ /** The scenario's value for a flag, or undefined when the scenario doesn't set it. */
7
+ export function useFlagOverride(name: string): boolean | string | undefined {
8
+ const get = useCallback(() => getFlagOverride(name), [name])
9
+ return useSyncExternalStore(subscribeToFlagOverrides, get, get)
10
+ }
11
+
12
+ /** Where the current launch is: idle, launching, done or error. */
13
+ export function useLaunchState(): LaunchState {
14
+ return useSyncExternalStore(controller.subscribe, controller.getState, controller.getState)
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type { LaunchState } from './core/controller.js'
2
+ export { getFlagOverride, getFlagOverrides } from './core/flags.js'
3
+ export type { AuthAdapter, LaunchError, ResetTarget } from './core/launch.js'
4
+ export { resetQueryClient, resetStorage, resetStores } from './core/reset.js'
5
+ export { LAUNCH_PATH, parseLaunchUrl } from './core/url.js'
6
+ export { useFlagOverride, useLaunchState } from './hooks.js'
7
+ export { handleLaunchPath, redirectSystemPath } from './native-intent.js'
8
+ export { Tapthrough, type TapthroughProps } from './Tapthrough.js'