@tapthrough/expo 0.1.1 → 0.1.4

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/src/Feedback.tsx CHANGED
@@ -24,6 +24,7 @@ import type { Annotation } from './core/annotations.js'
24
24
  import { defaultPosition, type Point } from './core/bubble.js'
25
25
  import { createDiagnostics, installDiagnostics } from './core/diagnostics.js'
26
26
  import {
27
+ buttonBadge,
27
28
  type Checklist,
28
29
  type ChecklistStep,
29
30
  failedStepBody,
@@ -35,7 +36,6 @@ import {
35
36
  type RecheckItem,
36
37
  requestFix,
37
38
  requestLaunch,
38
- reviewBadge,
39
39
  sendCause,
40
40
  sendFeedback,
41
41
  sendRecheck,
@@ -53,7 +53,9 @@ import {
53
53
  ideaAction,
54
54
  ideasNeedingYou,
55
55
  isMoving,
56
+ newlyWaiting,
56
57
  type OpenPr,
58
+ replyToIdea,
57
59
  sendStaffReport,
58
60
  submitIdea,
59
61
  } from './core/ideas.js'
@@ -180,6 +182,10 @@ const IDEA_REFRESH_MS = 8_000
180
182
 
181
183
  /** Update groups the reviewer has already been told about, so the toast shows once. */
182
184
  const announcedBuilds = new Set<string>()
185
+ /** Ideas (and which round of questions) the reviewer has already been told about. */
186
+ const announcedIdeas = new Set<string>()
187
+
188
+ type Toast = { kind: 'build'; build: NewerBuild } | { kind: 'idea'; idea: Idea }
183
189
 
184
190
  type Phase = 'idle' | 'loading' | 'open' | 'capturing' | 'composing'
185
191
 
@@ -223,7 +229,7 @@ export function ReportButton({
223
229
  const [name, setName] = useState(rememberedName)
224
230
  const [failing, setFailing] = useState<FailingStep | null>(null)
225
231
  const [screenshot, setScreenshot] = useState<Screenshot | null>(null)
226
- const [toast, setToast] = useState<NewerBuild | null>(null)
232
+ const [toast, setToast] = useState<Toast | null>(null)
227
233
  const [ideas, setIdeas] = useState<Idea[]>([])
228
234
  const [ideaBusy, setIdeaBusy] = useState<string | null>(null)
229
235
  const [pulls, setPulls] = useState<OpenPr[] | null>(null)
@@ -262,10 +268,14 @@ export function ReportButton({
262
268
  ])
263
269
  if (list) setChecklist(list)
264
270
  setIdeas(recent)
271
+ const waiting = newlyWaiting(recent, announcedIdeas)
265
272
  const newer = list?.newerBuild
266
273
  if (newer && !announcedBuilds.has(newer.updateGroupId)) {
267
274
  announcedBuilds.add(newer.updateGroupId)
268
- setToast(newer)
275
+ setToast({ kind: 'build', build: newer })
276
+ } else if (waiting) {
277
+ haptics.light()
278
+ setToast({ kind: 'idea', idea: waiting })
269
279
  }
270
280
  return list
271
281
  }
@@ -473,6 +483,15 @@ export function ReportButton({
473
483
  }
474
484
  }
475
485
 
486
+ /** From the idea banner: open the popover on that idea. */
487
+ function openIdea(id: string) {
488
+ setToast(null)
489
+ dismissKeyboard()
490
+ go({ view: 'idea', id })
491
+ setPhase('open')
492
+ void refresh()
493
+ }
494
+
476
495
  function close() {
477
496
  setPhase('idle')
478
497
  setFull(false)
@@ -532,6 +551,24 @@ export function ReportButton({
532
551
  replaceIdea(result.idea)
533
552
  }
534
553
 
554
+ async function reply(id: string, text: string) {
555
+ if (!caller || !platform) return
556
+ rememberName()
557
+ setIdeaBusy(id)
558
+ const result = await replyToIdea(
559
+ { caller, platform, id, reply: text, ...(rememberedName ? { by: rememberedName } : {}) },
560
+ { apiUrl },
561
+ )
562
+ setIdeaBusy(null)
563
+ if (!result.ok) {
564
+ haptics.warning()
565
+ setError(result.message)
566
+ return
567
+ }
568
+ haptics.success()
569
+ replaceIdea(result.idea)
570
+ }
571
+
535
572
  async function act(id: string, action: 'build' | 'retry' | 'approve') {
536
573
  if (!caller || !platform) return
537
574
  rememberName()
@@ -600,7 +637,11 @@ export function ReportButton({
600
637
  return (
601
638
  <Overlay>
602
639
  {toast && phase !== 'open' && phase !== 'composing' && phase !== 'capturing' ? (
603
- <NewBuildToast build={toast} onPress={toggle} />
640
+ toast.kind === 'build' ? (
641
+ <NewBuildToast build={toast.build} onPress={toggle} />
642
+ ) : (
643
+ <IdeaToast idea={toast.idea} onPress={() => openIdea(toast.idea.id)} />
644
+ )
604
645
  ) : null}
605
646
  {phase === 'open' && checklist ? (
606
647
  <ReviewPopover
@@ -637,6 +678,7 @@ export function ReportButton({
637
678
  ideas={ideas}
638
679
  onShareIdea={() => void startReport(null, 'idea')}
639
680
  onAnswerIdea={(id, answers) => void answer(id, answers)}
681
+ onReplyIdea={(id, text) => void reply(id, text)}
640
682
  onIdeaAction={(id, action) => void act(id, action)}
641
683
  ideaBusy={ideaBusy}
642
684
  pulls={pulls}
@@ -653,7 +695,7 @@ export function ReportButton({
653
695
  {showButton ? (
654
696
  <Bubble
655
697
  onPress={toggle}
656
- badge={reviewBadge(checklist) ?? (ideasNeedingYou(ideas) ? { kind: 'idea' } : null)}
698
+ badge={buttonBadge(checklist, ideasNeedingYou(ideas))}
657
699
  busy={phase === 'loading'}
658
700
  active={phase === 'open'}
659
701
  onRest={setAnchor}
@@ -915,6 +957,38 @@ function NewBuildToast({ build, onPress }: { build: NewerBuild; onPress: () => v
915
957
  )
916
958
  }
917
959
 
960
+ /** "Claude has 3 questions" for a moment at the top; the bubble's count stays after. */
961
+ function IdeaToast({ idea, onPress }: { idea: Idea; onPress: () => void }) {
962
+ const colors = useColors()
963
+ const name = idea.title ?? idea.body.split(/\r?\n/)[0] ?? ''
964
+ return (
965
+ <View style={styles.toastWrap} pointerEvents="box-none">
966
+ <Pressable
967
+ onPress={onPress}
968
+ style={({ pressed }) => [pressed && styles.pressed]}
969
+ accessibilityRole="button"
970
+ accessibilityLiveRegion="polite"
971
+ accessibilityLabel={`${idea.statusLine}: ${name}. View`}
972
+ >
973
+ <Surface radius={24} style={[styles.toast, styles.ideaToast]}>
974
+ <View style={[styles.toastDot, { backgroundColor: COLORS.idea }]} />
975
+ <View style={styles.toastText}>
976
+ <Text style={[text.subhead, styles.strong, { color: colors.label }]} numberOfLines={1}>
977
+ {idea.statusLine}
978
+ </Text>
979
+ {name ? (
980
+ <Text style={[text.footnote, { color: colors.secondary }]} numberOfLines={1}>
981
+ {name}
982
+ </Text>
983
+ ) : null}
984
+ </View>
985
+ <Text style={[text.subhead, styles.strong, { color: colors.tint }]}>View</Text>
986
+ </Surface>
987
+ </Pressable>
988
+ </View>
989
+ )
990
+ }
991
+
918
992
  type Status =
919
993
  | { kind: 'editing' }
920
994
  | { kind: 'sending' }
@@ -1736,4 +1810,6 @@ const styles = StyleSheet.create({
1736
1810
  paddingHorizontal: 18,
1737
1811
  },
1738
1812
  toastDot: { width: 8, height: 8, borderRadius: 4 },
1813
+ ideaToast: { maxWidth: 360, marginHorizontal: 16 },
1814
+ toastText: { flexShrink: 1 },
1739
1815
  })
package/src/Popover.tsx CHANGED
@@ -91,6 +91,7 @@ export interface ReviewPopoverProps {
91
91
  ideas: Idea[]
92
92
  onShareIdea: () => void
93
93
  onAnswerIdea: (id: string, answers: string[]) => void
94
+ onReplyIdea: (id: string, reply: string) => void
94
95
  onIdeaAction: (id: string, action: 'build' | 'retry' | 'approve') => void
95
96
  /** The idea whose request is in flight, to show a spinner on its button. */
96
97
  ideaBusy: string | null
@@ -320,8 +321,13 @@ function listView(p: ReviewPopoverProps, colors: Colors): Screen {
320
321
  recheck.length ? `${recheck.length} to re-check` : null,
321
322
  ].filter(Boolean)
322
323
  return {
323
- title: p.staff ? 'Feedback' : `PR #${checklist.pr}`,
324
- subtitle: p.staff ?? (counts.join(' · ') || 'Nothing planned for this PR'),
324
+ // Say plainly which build this is: a PR's code, or the installed staging/TestFlight build.
325
+ title: p.staff ?? `PR #${checklist.pr}`,
326
+ subtitle: p.staff
327
+ ? 'Not a PR build · send feedback and ideas'
328
+ : [checklist.sha ? checklist.sha.slice(0, 7) : null, counts.join(' · ') || 'Nothing planned']
329
+ .filter(Boolean)
330
+ .join(' · '),
325
331
  body: (
326
332
  <>
327
333
  {checklist.newerBuild ? <NewerRow build={checklist.newerBuild} colors={colors} /> : null}
@@ -460,6 +466,15 @@ function ideaView(p: ReviewPopoverProps, idea: Idea, colors: Colors): Screen {
460
466
  footer = (
461
467
  <Capsule title="Build It" busy={busy} onPress={() => p.onIdeaAction(idea.id, 'build')} />
462
468
  )
469
+ } else if (idea.actions.includes('approve') && p.staff) {
470
+ // A staging or TestFlight build can't load a PR's code; that takes the development build.
471
+ footer = idea.previewUrl ? (
472
+ <Capsule
473
+ title={`Open PR #${idea.prNumber} Page`}
474
+ kind="plain"
475
+ onPress={() => Linking.openURL(idea.previewUrl as string)}
476
+ />
477
+ ) : null
463
478
  } else if (idea.actions.includes('approve')) {
464
479
  footer = here ? (
465
480
  <Capsule title="Approve" busy={busy} onPress={() => p.onIdeaAction(idea.id, 'approve')} />
@@ -533,7 +548,15 @@ function ideaView(p: ReviewPopoverProps, idea: Idea, colors: Colors): Screen {
533
548
  colors={colors}
534
549
  />
535
550
  ) : null}
536
- {idea.steps.length && idea.status !== 'needs_answers' ? (
551
+ {idea.actions.includes('reply') ? (
552
+ <IdeaReply
553
+ key={idea.id}
554
+ busy={busy}
555
+ onSend={(reply) => p.onReplyIdea(idea.id, reply)}
556
+ colors={colors}
557
+ />
558
+ ) : null}
559
+ {idea.steps.length && idea.status !== 'needs_answers' && idea.status !== 'declined' ? (
537
560
  <Section title="How to test it" colors={colors}>
538
561
  <Numbered
539
562
  items={idea.steps.map((s) => ({ title: s.action, detail: s.expect }))}
@@ -543,9 +566,11 @@ function ideaView(p: ReviewPopoverProps, idea: Idea, colors: Colors): Screen {
543
566
  ) : null}
544
567
  {idea.actions.includes('approve') ? (
545
568
  <Text style={[text.footnote, { color: colors.secondary }]}>
546
- {here
547
- ? 'You’re on this idea’s build. Try the steps; if it’s right, approve it.'
548
- : 'Open its build, try the steps, then approve it from there.'}
569
+ {p.staff
570
+ ? `You’re on ${p.staff}, not a PR build, so PR #${idea.prNumber} can’t open here. Open it on a phone with the development build, or in a simulator from the PR’s page. Approve it from there.`
571
+ : here
572
+ ? 'You’re on this idea’s build. Try the steps; if it’s right, approve it.'
573
+ : 'Open its build, try the steps, then approve it from there.'}
549
574
  </Text>
550
575
  ) : null}
551
576
  <LinkRow
@@ -715,6 +740,51 @@ function IdeaAnswers({
715
740
  )
716
741
  }
717
742
 
743
+ /** After Claude stopped: one field to say how to go on, e.g. a smaller first version. */
744
+ function IdeaReply({
745
+ busy,
746
+ onSend,
747
+ colors,
748
+ }: {
749
+ busy: boolean
750
+ onSend: (reply: string) => void
751
+ colors: Colors
752
+ }) {
753
+ const [reply, setReply] = useState('')
754
+ return (
755
+ <View style={styles.sectionBlock}>
756
+ <View style={styles.question}>
757
+ <Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>
758
+ Tell Claude how to go on
759
+ </Text>
760
+ <Text style={[text.footnote, { color: colors.secondary }]}>
761
+ For example, a smaller first version it can build now. Claude writes it up again for you
762
+ to check before building.
763
+ </Text>
764
+ <TextInput
765
+ value={reply}
766
+ onChangeText={setReply}
767
+ placeholder="Your reply"
768
+ placeholderTextColor={colors.secondary}
769
+ multiline
770
+ style={[
771
+ text.subhead,
772
+ styles.nameInput,
773
+ { color: colors.label, backgroundColor: colors.fill },
774
+ ]}
775
+ accessibilityLabel="Tell Claude how to go on"
776
+ />
777
+ </View>
778
+ <Capsule
779
+ title="Send to Claude"
780
+ busy={busy}
781
+ disabled={reply.trim() === ''}
782
+ onPress={() => onSend(reply.trim())}
783
+ />
784
+ </View>
785
+ )
786
+ }
787
+
718
788
  function prsView(p: ReviewPopoverProps, colors: Colors): Screen {
719
789
  const { pulls } = p
720
790
  return {
@@ -292,8 +292,8 @@ export type Badge =
292
292
  | { kind: 'failed' }
293
293
  | { kind: 'remaining'; count: number }
294
294
  | { kind: 'done' }
295
- /** Nothing to check here, but an idea waits on someone: questions, or a build to test. */
296
- | { kind: 'idea' }
295
+ /** Ideas waiting on someone: questions to answer, or a build to test. */
296
+ | { kind: 'idea'; count: number }
297
297
 
298
298
  /** What the floating Review button shows: a new build wins, then failures, then steps left. */
299
299
  export function reviewBadge(checklist: Checklist | null): Badge | null {
@@ -306,6 +306,17 @@ export function reviewBadge(checklist: Checklist | null): Badge | null {
306
306
  return count === 0 ? { kind: 'done' } : { kind: 'remaining', count }
307
307
  }
308
308
 
309
+ /**
310
+ * The button's badge with ideas counted in. An idea waiting on someone blocks
311
+ * Claude, so it outranks steps left; only a new build or a failed step wins.
312
+ */
313
+ export function buttonBadge(checklist: Checklist | null, ideasWaiting: number): Badge | null {
314
+ const review = reviewBadge(checklist)
315
+ if (review?.kind === 'new-build' || review?.kind === 'failed') return review
316
+ if (ideasWaiting > 0) return { kind: 'idea', count: ideasWaiting }
317
+ return review
318
+ }
319
+
309
320
  /** Marks an earlier report fixed or still broken on this build. Never throws. */
310
321
  export async function sendRecheck(
311
322
  input: {
package/src/core/ideas.ts CHANGED
@@ -15,7 +15,7 @@ export type IdeaStatus =
15
15
  | 'failed'
16
16
  | 'not_set_up'
17
17
 
18
- export type IdeaAction = 'answer' | 'build' | 'approve' | 'retry'
18
+ export type IdeaAction = 'answer' | 'build' | 'approve' | 'retry' | 'reply'
19
19
 
20
20
  /** One step from sent to merged: done, in progress, not reached yet, or where it stopped. */
21
21
  export interface IdeaStage {
@@ -226,6 +226,26 @@ export function answerIdea(
226
226
  )
227
227
  }
228
228
 
229
+ /** After Claude stopped: tell it how to go on, and it writes the idea up again. Never throws. */
230
+ export function replyToIdea(
231
+ input: { caller: Caller; platform: Platform; id: string; reply: string; by?: string },
232
+ options: Options,
233
+ ) {
234
+ return call(
235
+ options,
236
+ `/api/v1/ideas/${encodeURIComponent(input.id)}/reply`,
237
+ {
238
+ method: 'POST',
239
+ body: {
240
+ ...credential(input.caller, input.platform),
241
+ reply: input.reply,
242
+ ...(input.by ? { by: input.by } : {}),
243
+ },
244
+ },
245
+ pickIdea,
246
+ )
247
+ }
248
+
229
249
  /** Build It, Retry or Approve on one idea. Never throws. */
230
250
  export function ideaAction(
231
251
  input: {
@@ -259,10 +279,28 @@ export function ideaAction(
259
279
  )
260
280
  }
261
281
 
282
+ const WAITING: IdeaStatus[] = ['needs_answers', 'ready_to_test']
283
+
262
284
  /** Ideas waiting on a person: questions to answer, or a build to test. */
263
285
  export function ideasNeedingYou(ideas: Idea[]): number {
264
- return ideas.filter((idea) => idea.status === 'needs_answers' || idea.status === 'ready_to_test')
265
- .length
286
+ return ideas.filter((idea) => WAITING.includes(idea.status)).length
287
+ }
288
+
289
+ /**
290
+ * The first idea that started waiting on someone since we last looked, to
291
+ * announce once. `seen` holds `id:status` pairs already announced; it's
292
+ * updated in place, so a new round of questions announces again.
293
+ */
294
+ export function newlyWaiting(ideas: Idea[], seen: Set<string>): Idea | null {
295
+ let first: Idea | null = null
296
+ for (const idea of ideas) {
297
+ if (!WAITING.includes(idea.status)) continue
298
+ const key = `${idea.id}:${idea.status}:${idea.questions.join('\n')}`
299
+ if (seen.has(key)) continue
300
+ seen.add(key)
301
+ first ??= idea
302
+ }
303
+ return first
266
304
  }
267
305
 
268
306
  /** What Approve did, in plain words. */