@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.
- package/LICENSE +21 -0
- package/README.md +105 -2
- package/app.plugin.d.ts +37 -0
- package/app.plugin.js +79 -0
- package/dist/Annotator.d.ts +14 -0
- package/dist/Annotator.d.ts.map +1 -0
- package/dist/Annotator.js +160 -0
- package/dist/Annotator.js.map +1 -0
- package/dist/Bubble.d.ts +27 -0
- package/dist/Bubble.d.ts.map +1 -0
- package/dist/Bubble.js +234 -0
- package/dist/Bubble.js.map +1 -0
- package/dist/Feedback.d.ts +14 -0
- package/dist/Feedback.d.ts.map +1 -0
- package/dist/Feedback.js +977 -0
- package/dist/Feedback.js.map +1 -0
- package/dist/Overlay.d.ts +13 -0
- package/dist/Overlay.d.ts.map +1 -0
- package/dist/Overlay.js +44 -0
- package/dist/Overlay.js.map +1 -0
- package/dist/Popover.d.ts +98 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +531 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Tapthrough.d.ts +27 -0
- package/dist/Tapthrough.d.ts.map +1 -0
- package/dist/Tapthrough.js +111 -0
- package/dist/Tapthrough.js.map +1 -0
- package/dist/core/annotations.d.ts +63 -0
- package/dist/core/annotations.d.ts.map +1 -0
- package/dist/core/annotations.js +76 -0
- package/dist/core/annotations.js.map +1 -0
- package/dist/core/bubble.d.ts +21 -0
- package/dist/core/bubble.d.ts.map +1 -0
- package/dist/core/bubble.js +25 -0
- package/dist/core/bubble.js.map +1 -0
- package/dist/core/controller.d.ts +46 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +103 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/diagnostics.d.ts +47 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +101 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/feedback.d.ts +302 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +345 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/flags.d.ts +13 -0
- package/dist/core/flags.d.ts.map +1 -0
- package/dist/core/flags.js +25 -0
- package/dist/core/flags.js.map +1 -0
- package/dist/core/ideas.d.ts +166 -0
- package/dist/core/ideas.d.ts.map +1 -0
- package/dist/core/ideas.js +159 -0
- package/dist/core/ideas.js.map +1 -0
- package/dist/core/launch.d.ts +43 -0
- package/dist/core/launch.d.ts.map +1 -0
- package/dist/core/launch.js +81 -0
- package/dist/core/launch.js.map +1 -0
- package/dist/core/reset.d.ts +17 -0
- package/dist/core/reset.d.ts.map +1 -0
- package/dist/core/reset.js +19 -0
- package/dist/core/reset.js.map +1 -0
- package/dist/core/review.d.ts +49 -0
- package/dist/core/review.d.ts.map +1 -0
- package/dist/core/review.js +92 -0
- package/dist/core/review.js.map +1 -0
- package/dist/core/url.d.ts +9 -0
- package/dist/core/url.d.ts.map +1 -0
- package/dist/core/url.js +23 -0
- package/dist/core/url.js.map +1 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +13 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/native-intent.d.ts +18 -0
- package/dist/native-intent.d.ts.map +1 -0
- package/dist/native-intent.js +32 -0
- package/dist/native-intent.js.map +1 -0
- package/dist/runtime.d.ts +24 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.js.map +1 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +48 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/supabase.d.ts +20 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +18 -0
- package/dist/supabase.js.map +1 -0
- package/dist/ui/native.d.ts +34 -0
- package/dist/ui/native.d.ts.map +1 -0
- package/dist/ui/native.js +76 -0
- package/dist/ui/native.js.map +1 -0
- package/dist/ui/parts.d.ts +62 -0
- package/dist/ui/parts.d.ts.map +1 -0
- package/dist/ui/parts.js +110 -0
- package/dist/ui/parts.js.map +1 -0
- package/dist/ui/theme.d.ts +90 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +61 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +82 -3
- package/src/Annotator.tsx +279 -0
- package/src/Bubble.tsx +331 -0
- package/src/Feedback.tsx +1739 -0
- package/src/Overlay.tsx +61 -0
- package/src/Popover.tsx +1393 -0
- package/src/Tapthrough.tsx +171 -0
- package/src/core/annotations.ts +116 -0
- package/src/core/bubble.ts +45 -0
- package/src/core/controller.ts +119 -0
- package/src/core/diagnostics.ts +128 -0
- package/src/core/feedback.ts +589 -0
- package/src/core/flags.ts +31 -0
- package/src/core/ideas.ts +337 -0
- package/src/core/launch.ts +119 -0
- package/src/core/reset.ts +28 -0
- package/src/core/review.ts +148 -0
- package/src/core/url.ts +21 -0
- package/src/hooks.ts +15 -0
- package/src/index.ts +8 -0
- package/src/native-intent.ts +36 -0
- package/src/runtime.ts +30 -0
- package/src/screenshot.ts +64 -0
- package/src/supabase.ts +27 -0
- package/src/ui/native.ts +116 -0
- package/src/ui/parts.tsx +249 -0
- package/src/ui/theme.ts +75 -0
|
@@ -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
|
+
}
|
package/src/core/url.ts
ADDED
|
@@ -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'
|