@simon-says-build/demo-player 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +5 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/DemoHost.js +102 -16
- package/src/api.js +5 -0
- package/src/build-gate.js +62 -0
- package/src/core.js +27 -2
- package/src/signin.js +10 -4
package/index.d.ts
CHANGED
|
@@ -30,7 +30,11 @@ export const PLAYER_VERSION: string;
|
|
|
30
30
|
/** The changes a demo's last card offers to approve or discuss: one per `job:<id>` feature. */
|
|
31
31
|
export function reviewChangesOf(demo: unknown): { jobId: string; title: string }[];
|
|
32
32
|
/** A release's demo (0.4.0): the session merge its review card releases, or null. */
|
|
33
|
-
export function releaseOf(demo: unknown): { jobId: string; label: string; note: string } | null;
|
|
33
|
+
export function releaseOf(demo: unknown): { jobId: string; label: string; note: string; reviews: ReleaseReviews } | null;
|
|
34
|
+
/** A release's review requests (0.11.0), read for the demo's person. */
|
|
35
|
+
export type ReleaseReviewRequest = { to: string; by: string; status: 'pending' | 'approved' | 'discussed' | 'withdrawn'; at?: string; note?: string; decisionNote?: string; revisedAt?: string; release?: boolean };
|
|
36
|
+
export type ReleaseReviews = { mode: 'off' | 'optional' | 'required'; requests: ReleaseReviewRequest[]; mine: ReleaseReviewRequest | null; waiting: string[]; candidates: string[]; canAsk: boolean; mustAsk: boolean; last: boolean };
|
|
37
|
+
export function releaseReviewsOf(demo: unknown): ReleaseReviews;
|
|
34
38
|
|
|
35
39
|
/**
|
|
36
40
|
* React Navigation (an app without expo-router): a path is the chain of route
|
package/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { DemoHost } from './src/DemoHost.js';
|
|
2
|
-
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, PLAYER_VERSION } from './src/core.js';
|
|
2
|
+
export { demoConfig, findTarget, handlerFor, phoneSteps, scalePoint, playDemo, currentDemo, onDemo, reviewChangesOf, releaseOf, releaseReviewsOf, PLAYER_VERSION } from './src/core.js';
|
|
3
3
|
export { navigationRoute, navigationRouter, navigateArgs, pathOfState } from './src/navigation.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "SimonSays mobile demo player: walks a preview's viewer through a job's change, inside the app. Renders nothing unless the bundle carries a demo.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/DemoHost.js
CHANGED
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
//
|
|
14
14
|
// No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
|
|
15
15
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
-
import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
16
|
+
import { Linking, NativeModules, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
17
17
|
import { pageName } from './page-name.js';
|
|
18
18
|
import { OVERLAY_ID, cardBox, currentDemo, demoConfig, findTarget, handlerFor, introBody, introOf, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint, rescueNote, screenCandidates, stepTitle, targetOfKey } from './core.js';
|
|
19
19
|
import { demoApi, tourOf } from './api.js';
|
|
20
20
|
import { signInFirst, openInApp } from './signin.js';
|
|
21
21
|
import { runEvent, fillStep } from './events.js';
|
|
22
|
+
import { nativeRuntime, buildGap, needLinks, openNeed, staleBody } from './build-gate.js';
|
|
22
23
|
|
|
23
24
|
const h = React.createElement;
|
|
24
25
|
const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
|
|
@@ -83,11 +84,25 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
83
84
|
const [flash, setFlash] = useState(null); // "Now navigating to the walk page", said before a goto opens it
|
|
84
85
|
const [rescued, setRescued] = useState(null); // { for: index, status: asking|moved|gone, target?, why? }
|
|
85
86
|
const asked = useRef(new Set()); // steps already rescued once (by index)
|
|
87
|
+
const [stale, setStale] = useState(null); // { gap, links, appName }: this dev build is not the review's (build-gate.js)
|
|
86
88
|
|
|
87
89
|
useEffect(() => {
|
|
88
90
|
let live = true;
|
|
89
91
|
api.load()
|
|
90
|
-
.then((d) => {
|
|
92
|
+
.then((d) => {
|
|
93
|
+
if (!live) return;
|
|
94
|
+
// THE BUILD FIRST (0.12.0): a review made for another native build of
|
|
95
|
+
// this dev app never starts here — the person is sent to install the
|
|
96
|
+
// right one (build-gate.js). Nothing else runs: no sign-in, no steps.
|
|
97
|
+
const gap = config.here ? null : buildGap(d, nativeRuntime(globalThis.expo?.modules, NativeModules));
|
|
98
|
+
if (gap) {
|
|
99
|
+
const links = needLinks({ demo: d, gap, demoId: config.id, token: config.token });
|
|
100
|
+
setStale({ gap, links, appName: d.projectName || d.appName || 'the app' });
|
|
101
|
+
openNeed(links, (u) => Linking.openURL(u));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setSteps(phoneSteps(tourOf(d, { here: !!config.here })).concat()); setChanges(reviewChangesOf(d)); setRelease(releaseOf(d)); setIntro(introOf(d));
|
|
105
|
+
})
|
|
91
106
|
.catch((e) => { if (live) setError(e.message); });
|
|
92
107
|
return () => { live = false; };
|
|
93
108
|
}, [api]);
|
|
@@ -184,7 +199,8 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
184
199
|
|
|
185
200
|
// AND NOW… (an `event` step, 0.8.0): SSB runs the helper on the copy; its
|
|
186
201
|
// outputs fill `{name}` in this person's later steps. A failure stays on the
|
|
187
|
-
// card, said, with
|
|
202
|
+
// card, said, with Try again (0.12.0: never Next — what follows depends on it).
|
|
203
|
+
const [eventTry, setEventTry] = useState(0);
|
|
188
204
|
useEffect(() => {
|
|
189
205
|
setEvent(null);
|
|
190
206
|
if (step?.kind !== 'event' || done) return undefined;
|
|
@@ -197,11 +213,13 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
197
213
|
else setIndex(index + 1);
|
|
198
214
|
});
|
|
199
215
|
return () => { live = false; };
|
|
200
|
-
}, [step?.kind, step?.helper, index, done]);
|
|
216
|
+
}, [step?.kind, step?.helper, index, done, eventTry]);
|
|
201
217
|
|
|
202
218
|
// SIGN THIS PERSON IN FIRST (a `signin` step, tourOf): open the link, let
|
|
203
219
|
// the app sign in and route, then start their part on that screen. A link
|
|
204
|
-
// that can't be had or opened
|
|
220
|
+
// that can't be had or opened, or a sign-in whose first screen never comes
|
|
221
|
+
// up, stops the tour with Try again (0.12.0).
|
|
222
|
+
const [signInTry, setSignInTry] = useState(0); // "Try again" re-runs the sign-in
|
|
205
223
|
useEffect(() => {
|
|
206
224
|
setSignIn(null);
|
|
207
225
|
if (step?.kind !== 'signin' || done) return undefined;
|
|
@@ -217,10 +235,16 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
217
235
|
else setIndex(index + 1);
|
|
218
236
|
});
|
|
219
237
|
return () => { live = false; };
|
|
220
|
-
}, [step, done]);
|
|
238
|
+
}, [step, done, signInTry]);
|
|
221
239
|
|
|
222
240
|
const overlay = (() => {
|
|
223
241
|
if (error) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: `The demo didn't load: ${error}` });
|
|
242
|
+
if (stale) {
|
|
243
|
+
return h(Card, {
|
|
244
|
+
at: null, screen, insets, card, setCard, title: 'Guided Review', body: staleBody(stale.gap, stale.appName),
|
|
245
|
+
actions: [{ label: 'Open SimonSays', onPress: () => openNeed(stale.links, (u) => Linking.openURL(u)), primary: true }],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
224
248
|
if (!steps) return null;
|
|
225
249
|
if (intro && !tucked && !done) {
|
|
226
250
|
return h(Card, {
|
|
@@ -241,18 +265,22 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
241
265
|
}
|
|
242
266
|
if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
|
|
243
267
|
if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
|
|
268
|
+
// A FAILED SETUP OR SIGN-IN STOPS THE TOUR (0.12.0, Michael 2026-11-05:
|
|
269
|
+
// "the tour is theoretically continuable… we need a way to make the tour
|
|
270
|
+
// aware that an important step failed"). What follows depends on it, so
|
|
271
|
+
// there is no Next: Try again, or Hide.
|
|
244
272
|
if (step.kind === 'event') {
|
|
245
273
|
return h(Card, {
|
|
246
274
|
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
247
|
-
body: event ? `The
|
|
248
|
-
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: '
|
|
275
|
+
body: event ? `The review couldn't set this up, and the rest of it depends on it: ${event.error}` : step.caption,
|
|
276
|
+
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setEventTry((n) => n + 1), primary: true }] : [],
|
|
249
277
|
});
|
|
250
278
|
}
|
|
251
279
|
if (step.kind === 'signin') {
|
|
252
280
|
return h(Card, {
|
|
253
281
|
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
254
|
-
body: signIn ?
|
|
255
|
-
actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: '
|
|
282
|
+
body: signIn ? `SSB couldn't sign you in, and the review needs it: ${signIn.error}.` : step.caption,
|
|
283
|
+
actions: signIn ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Try again', onPress: () => setSignInTry((n) => n + 1), primary: true }] : [],
|
|
256
284
|
});
|
|
257
285
|
}
|
|
258
286
|
const canDo = (step.kind === 'click' || step.kind === 'fill') && found && (step.kind === 'click' ? handlerFor(found, 'onPress') : handlerFor(found, 'onChangeText'));
|
|
@@ -301,11 +329,66 @@ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide
|
|
|
301
329
|
api.discuss(id, text).then(() => set(id, { state: 'sent' })).catch((e) => set(id, { state: 'discussing', error: e.message }));
|
|
302
330
|
};
|
|
303
331
|
const left = release ? 0 : changes.filter((c) => !['approved', 'sent'].includes(rows[c.jobId]?.state)).length;
|
|
332
|
+
// REVIEW REQUESTS (0.11.0, docs/REVIEW-REQUESTS.md): asked to review it, the
|
|
333
|
+
// person answers here — Approve, or Discuss with a note, which holds the
|
|
334
|
+
// release until it is revised; otherwise they may approve and ask someone
|
|
335
|
+
// else first. `rq.state`: idle | picking | asking | discussing | sending | done.
|
|
336
|
+
const rv = release?.reviews || null;
|
|
337
|
+
const [rq, setRq] = useState({ state: 'idle', picked: [], note: '', error: null, said: '' });
|
|
338
|
+
const rqSet = (patch) => setRq((r) => ({ ...r, ...patch }));
|
|
339
|
+
const answer = (decision) => {
|
|
340
|
+
rqSet({ state: 'sending', error: null });
|
|
341
|
+
api.answerReview(decision, decision === 'discuss' ? rq.note.trim() : '')
|
|
342
|
+
.then((r) => rqSet({ state: 'done', said: decision === 'discuss' ? 'Sent — it waits for the revision and your approval.' : r?.released ? 'Approved — it is releasing now.' : r?.waiting ? `Approved. ${r.waiting}` : 'Approved.' }))
|
|
343
|
+
.catch((e) => rqSet({ state: decision === 'discuss' ? 'discussing' : 'idle', error: e.message }));
|
|
344
|
+
};
|
|
345
|
+
const askFor = () => {
|
|
346
|
+
if (!rq.picked.length) return;
|
|
347
|
+
rqSet({ state: 'sending', error: null });
|
|
348
|
+
api.approveAndAsk(rq.picked, rq.note.trim())
|
|
349
|
+
.then(() => rqSet({ state: 'done', said: `Approved — it goes live once ${rq.picked.join(', ')} ${rq.picked.length === 1 ? 'approves' : 'approve'}.` }))
|
|
350
|
+
.catch((e) => rqSet({ state: 'picking', error: e.message }));
|
|
351
|
+
};
|
|
352
|
+
const toggle = (e) => rqSet({ picked: rq.picked.includes(e) ? rq.picked.filter((x) => x !== e) : [...rq.picked, e] });
|
|
353
|
+
const reviewsBlock = !rv ? null : rq.state === 'done' ? h(Text, { style: s.changeState, testID: 'ssb-review-request-done' }, rq.said)
|
|
354
|
+
: rv.mine ? h(View, { style: s.change, testID: 'ssb-review-mine' },
|
|
355
|
+
h(Text, { style: s.changeTitle }, `${rv.mine.by} asked you to review this release.`),
|
|
356
|
+
rv.mine.note ? h(Text, { style: s.body }, `“${rv.mine.note}”`) : null,
|
|
357
|
+
rv.mine.status === 'discussed' && rv.mine.revisedAt ? h(Text, { style: s.body }, 'It was revised since you asked for changes.') : null,
|
|
358
|
+
rq.state === 'discussing' || (rq.state === 'sending' && rq.note) ? h(View, null,
|
|
359
|
+
h(TextInput, { style: s.input, value: rq.note, onChangeText: (note) => rqSet({ note }), placeholder: 'What should change before it goes live?', placeholderTextColor: '#8E8E93', multiline: true, autoFocus: true, accessibilityLabel: 'What should change before it goes live', testID: 'ssb-review-discuss-note' }),
|
|
360
|
+
h(View, { style: s.actions }, button('Cancel', () => rqSet({ state: 'idle', note: '' })), button(rq.state === 'sending' ? 'Sending…' : 'Send', rq.note.trim() && rq.state !== 'sending' ? () => answer('discuss') : () => {}, true))) : null)
|
|
361
|
+
: rv.waiting.length ? h(Text, { style: s.body, testID: 'ssb-review-waiting' }, `Waiting on ${rv.waiting.join(', ')}'s review before it goes live.`)
|
|
362
|
+
: rq.state === 'picking' || (rq.state === 'sending' && rq.picked.length) ? h(View, { style: s.change, testID: 'ssb-review-ask' },
|
|
363
|
+
h(Text, { style: s.changeTitle }, 'Request a review from…'),
|
|
364
|
+
...rv.candidates.map((e) => h(Pressable, { key: e, onPress: () => toggle(e), accessibilityRole: 'checkbox', accessibilityState: { checked: rq.picked.includes(e) }, accessibilityLabel: e, style: s.pick },
|
|
365
|
+
h(Text, { style: s.body }, `${rq.picked.includes(e) ? '☑' : '☐'} ${e}`))),
|
|
366
|
+
h(TextInput, { style: s.input, value: rq.note, onChangeText: (note) => rqSet({ note }), placeholder: 'A note for them (optional)', placeholderTextColor: '#8E8E93', multiline: true, accessibilityLabel: 'A note for the reviewer' }),
|
|
367
|
+
h(View, { style: s.actions }, button('Cancel', () => rqSet({ state: 'idle', picked: [] })), button(rq.state === 'sending' ? 'Asking…' : 'Approve & ask', rq.picked.length && rq.state !== 'sending' ? askFor : () => {}, true)))
|
|
368
|
+
: rv.mustAsk ? h(Text, { style: s.body }, 'This project needs a review before every release — request one.') : null;
|
|
369
|
+
// What the foot's main button does on a release.
|
|
370
|
+
// What the foot's buttons are on a release, beside Hide (flat: one row).
|
|
371
|
+
const releaseMain = !release || rel.state === 'released' ? []
|
|
372
|
+
: rq.state === 'done' ? [button(left ? `Done (${left} undecided)` : 'Done', onDone, true)]
|
|
373
|
+
: rv?.mine ? (rq.state === 'discussing' ? [] : [
|
|
374
|
+
button('💬 Discuss', () => rqSet({ state: 'discussing' })),
|
|
375
|
+
button(rq.state === 'sending' ? 'Approving…' : rv.last ? release.label : 'Approve', rq.state === 'sending' ? () => {} : () => answer('approve'), true)])
|
|
376
|
+
: rv?.waiting.length ? [button('Done', onDone, true)]
|
|
377
|
+
: rq.state === 'picking' ? []
|
|
378
|
+
: [
|
|
379
|
+
rv?.canAsk ? button('Request a review', () => rqSet({ state: 'picking' }), !!rv?.mustAsk) : null,
|
|
380
|
+
rv?.mustAsk ? null : button(rel.state === 'releasing' ? 'Approving…' : release.label, rel.state === 'releasing' ? () => {} : doRelease, true),
|
|
381
|
+
].filter(Boolean);
|
|
382
|
+
// The card's lead line, for where the release stands.
|
|
383
|
+
const lead = !release ? 'Approve what looks right. Anything that should be different, say so — SSB revises it.'
|
|
384
|
+
: rv?.mine ? 'Approve it, or say what should change — the release waits for your answer.'
|
|
385
|
+
: rv?.waiting.length ? 'It goes out once the review is in. Anything that should be different, say so — SSB revises it.'
|
|
386
|
+
: rv?.mustAsk ? 'This project needs a review before it goes out. Anything that should be different, say so — SSB revises it.'
|
|
387
|
+
: `Happy with it? ${release.label} sends them out together. Anything that should be different, say so — SSB revises it.`;
|
|
304
388
|
return h(View, { style: [s.card, s.review, { top: insets.top + 12, maxHeight: screen.height - insets.top - insets.bottom - 24, maxWidth: screen.width - 32, left: Math.max(16, (screen.width - Math.min(360, screen.width - 32)) / 2) }], testID: 'ssb-demo-review' },
|
|
305
389
|
h(Text, { style: s.title }, release ? (changes.length === 1 ? 'The next release: 1 change' : `The next release: ${changes.length} changes`)
|
|
306
390
|
: changes.length === 1 ? 'Review the change' : `Review ${changes.length} changes`),
|
|
307
|
-
h(Text, { style: s.body },
|
|
308
|
-
: 'Approve what looks right. Anything that should be different, say so — SSB revises it.'),
|
|
391
|
+
h(Text, { style: s.body }, lead),
|
|
309
392
|
h(ScrollView, { style: s.changes, keyboardShouldPersistTaps: 'handled' }, ...changes.map((c) => {
|
|
310
393
|
const r = rows[c.jobId] || {};
|
|
311
394
|
const state = r.state || 'idle';
|
|
@@ -318,18 +401,20 @@ function Review({ api, changes, release, screen, insets, onBack, onDone, onHide
|
|
|
318
401
|
h(View, { style: s.actions },
|
|
319
402
|
button('Cancel', () => set(c.jobId, { state: 'idle', error: null })),
|
|
320
403
|
button('Send', () => send(c.jobId), true))) : null,
|
|
321
|
-
state === 'idle' ? h(View, { style: s.actions },
|
|
404
|
+
state === 'idle' && !rv?.mine ? h(View, { style: s.actions },
|
|
322
405
|
button('💬 Discuss', () => set(c.jobId, { state: 'discussing' })),
|
|
323
406
|
release ? null : button('✓ Approve', () => approve(c.jobId), true)) : null);
|
|
324
407
|
})),
|
|
408
|
+
reviewsBlock,
|
|
409
|
+
rq.error ? h(Text, { style: s.error }, rq.error) : null,
|
|
325
410
|
release && rel.state === 'released' ? h(Text, { style: s.changeState, testID: 'ssb-release-done' }, 'Approved — it goes out in 5 minutes. Undo it on the release page in SimonSays if you need to.') : null,
|
|
326
411
|
release && rel.error ? h(Text, { style: s.error }, rel.error) : null,
|
|
327
412
|
h(View, { style: [s.actions, s.reviewFoot] },
|
|
328
413
|
button('Hide', onHide),
|
|
329
414
|
onBack ? button('Back', onBack) : null,
|
|
330
|
-
release && rel.state !== 'released'
|
|
331
|
-
?
|
|
332
|
-
: button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)));
|
|
415
|
+
...(release && rel.state !== 'released'
|
|
416
|
+
? releaseMain
|
|
417
|
+
: [button(left ? `Done (${left} undecided)` : 'Done', onDone, !left)])));
|
|
333
418
|
}
|
|
334
419
|
|
|
335
420
|
const button = (label, onPress, primary = false) => h(Pressable, {
|
|
@@ -373,6 +458,7 @@ const s = StyleSheet.create({
|
|
|
373
458
|
review: { width: 360 },
|
|
374
459
|
changes: { flexGrow: 0 },
|
|
375
460
|
reviewFoot: { marginTop: 16 },
|
|
461
|
+
pick: { flexDirection: 'row', paddingVertical: 6 },
|
|
376
462
|
change: { marginTop: 14, paddingTop: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#3A3A3C' },
|
|
377
463
|
changeTitle: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
|
|
378
464
|
changeState: { color: '#AEAEB2', fontSize: 13, marginTop: 4 },
|
package/src/api.js
CHANGED
|
@@ -23,6 +23,11 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
|
|
|
23
23
|
// A release's demo (0.4.0): approve and release the session, as the person
|
|
24
24
|
// the demo was made for, after the five-minute cool-off.
|
|
25
25
|
release: () => call('/release', { method: 'POST', body: '{}' }),
|
|
26
|
+
// REVIEW REQUESTS (0.11.0, docs/REVIEW-REQUESTS.md): approve and ask others
|
|
27
|
+
// to review it first (the last approval releases it); or, asked yourself,
|
|
28
|
+
// answer — approve, or discuss with a note, which holds it until revised.
|
|
29
|
+
approveAndAsk: (to, note) => call('/approve', { method: 'POST', body: JSON.stringify({ requestReview: { to, ...(note ? { note } : {}), release: true } }) }),
|
|
30
|
+
answerReview: (decision, note) => call('/review-requests/decide', { method: 'POST', body: JSON.stringify({ decision, ...(note ? { note } : {}) }) }),
|
|
26
31
|
// A phone's private copy (0.6.0): the link that signs this person in.
|
|
27
32
|
signin: (actorIndex) => call('/signin', { method: 'POST', body: JSON.stringify({ actorIndex }) }),
|
|
28
33
|
// A planned review (0.8.0): run a helper on the private copy, then read how it went.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// THE DEV BUILD MUST MATCH THE REVIEW (0.12.0, docs/TOUR-LINKS.md "6. The
|
|
2
|
+
// bundle checks itself"). Pure, so node --test runs it.
|
|
3
|
+
//
|
|
4
|
+
// A phone review's copy is Metro serving the session's JS, made for ONE native
|
|
5
|
+
// build of the app's dev client: `demo.preview.fingerprint`, the build SSB put
|
|
6
|
+
// in the library for it. A dev client loads whatever JS it is pointed at, so
|
|
7
|
+
// on an older build the review's code ran anyway and failed half-way: Michael's
|
|
8
|
+
// phone (2026-11-05) opened Connie Ho's Profile photos review on a Walk2Gether
|
|
9
|
+
// Dev build older than c316 and its sign-in died "auth/network-request-failed".
|
|
10
|
+
// Every check before that point (the notification's install step, the
|
|
11
|
+
// overlay's ssb-tour gate) was skipped — no install record, a dev-client link.
|
|
12
|
+
//
|
|
13
|
+
// The structural answer is the bundle itself: the player knows what native
|
|
14
|
+
// build the review needs (the demo record) and what native build it is running
|
|
15
|
+
// on (expo-updates' runtimeVersion — under the fingerprint policy, the native
|
|
16
|
+
// fingerprint; the dev builds read it from EXUpdates.bundle/fingerprint). A
|
|
17
|
+
// mismatch never starts the review: it hands the person to the SimonSays app's
|
|
18
|
+
// "need" screen (`simonsays://need?…`, which offers the build SSB made), and
|
|
19
|
+
// the website's /need page when the app is not there. A build that cannot say
|
|
20
|
+
// its runtime, or a review that names none, plays as before.
|
|
21
|
+
|
|
22
|
+
/** This build's native runtime (expo-updates' runtimeVersion), or null. */
|
|
23
|
+
export function nativeRuntime(mods = globalThis.expo?.modules, nativeModules = {}) {
|
|
24
|
+
const u = mods?.ExpoUpdates || nativeModules?.ExpoUpdates || {};
|
|
25
|
+
return typeof u.runtimeVersion === 'string' && u.runtimeVersion && !u.runtimeVersion.startsWith('file:') ? u.runtimeVersion : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the review needs that this build is not: null = play it, else { need, have }. */
|
|
29
|
+
export function buildGap(demo, have) {
|
|
30
|
+
const need = demo?.platform === 'ios' && typeof demo?.preview?.fingerprint === 'string' ? demo.preview.fingerprint : null;
|
|
31
|
+
if (!need || !have) return null;
|
|
32
|
+
return need === have ? null : { need, have };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The dev app's scheme, from the link the copy was opened with
|
|
36
|
+
// (`walk2gether-dev://expo-development-client/?url=…`).
|
|
37
|
+
const schemeOf = (url) => /^([a-z][a-z0-9+.-]*):\/\//i.exec(String(url || ''))?.[1] || undefined;
|
|
38
|
+
const qs = (o) => Object.entries(o).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where to send the person: the SimonSays app's need screen, else the site's.
|
|
42
|
+
* The query is schemas/tour-link.mjs needUrl's (test/build-gate.test.mjs holds
|
|
43
|
+
* them equal): app, scheme, tour, rt, haveRt.
|
|
44
|
+
*/
|
|
45
|
+
export function needLinks({ demo = {}, gap, demoId, token, site = 'https://simonsays.build' }) {
|
|
46
|
+
const q = qs({
|
|
47
|
+
app: demo.appId, scheme: schemeOf(demo.preview?.url), tour: demoId && token ? `${demoId}.${token}` : undefined,
|
|
48
|
+
rt: gap?.need, haveRt: gap?.have || undefined,
|
|
49
|
+
});
|
|
50
|
+
return { app: `simonsays://need?${q}`, web: `${site.replace(/\/$/, '')}/need?${q}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Open the app's need screen, falling through to the site. `openURL` is Linking.openURL. */
|
|
54
|
+
export async function openNeed(links, openURL) {
|
|
55
|
+
try { await openURL(links.app); return 'app'; } catch { /* no SimonSays app */ }
|
|
56
|
+
try { await openURL(links.web); return 'web'; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The card's words. */
|
|
60
|
+
export function staleBody(gap, appName = 'the app') {
|
|
61
|
+
return `This review needs a newer ${appName} Dev build (it needs ${gap.need.slice(0, 7)}; this phone has ${gap.have.slice(0, 7)}). Opening the SimonSays app to install it.`;
|
|
62
|
+
}
|
package/src/core.js
CHANGED
|
@@ -10,7 +10,7 @@ import { bubbleBox } from './step-place.js';
|
|
|
10
10
|
// What this player is, said when the app registers for "Ready to see"
|
|
11
11
|
// (packages/ssb-overlay sends it; functions/in-app-notify.mjs clientOf). Kept
|
|
12
12
|
// equal to package.json by test/core.test.mjs.
|
|
13
|
-
export const PLAYER_VERSION = '0.
|
|
13
|
+
export const PLAYER_VERSION = '0.12.0';
|
|
14
14
|
|
|
15
15
|
export const HOST = 5;
|
|
16
16
|
export const TEXT = 6;
|
|
@@ -217,7 +217,32 @@ export function reviewChangesOf(demo) {
|
|
|
217
217
|
export function releaseOf(demo) {
|
|
218
218
|
const sm = demo?.sessionMerge;
|
|
219
219
|
if (!sm || typeof sm.jobId !== 'string' || !sm.jobId) return null;
|
|
220
|
-
return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || '') };
|
|
220
|
+
return { jobId: sm.jobId, label: String(sm.label || 'Approve & release'), note: String(sm.note || ''), reviews: releaseReviewsOf(demo) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// REVIEW REQUESTS ON A RELEASE (0.11.0, docs/REVIEW-REQUESTS.md): the demo's
|
|
224
|
+
// `sessionMerge.reviews` (functions/review-requests.mjs releaseReviewsForDemo),
|
|
225
|
+
// read for the person the demo was made for (`createdBy`). Pure.
|
|
226
|
+
// mine the request addressed to them that is still theirs to answer, or null
|
|
227
|
+
// waiting others whose review it still waits on
|
|
228
|
+
// canAsk they may request a review (the setting is not off, someone to ask)
|
|
229
|
+
// mustAsk the project requires a review and none is approved: no release without one
|
|
230
|
+
// last their approval would be the last one, and a release was asked for
|
|
231
|
+
export function releaseReviewsOf(demo) {
|
|
232
|
+
const rv = demo?.sessionMerge?.reviews;
|
|
233
|
+
const me = String(demo?.createdBy || '').trim().toLowerCase();
|
|
234
|
+
const requests = Array.isArray(rv?.requests) ? rv.requests.filter((r) => r && typeof r.to === 'string') : [];
|
|
235
|
+
const open = requests.filter((r) => r.status === 'pending' || r.status === 'discussed');
|
|
236
|
+
const mine = open.find((r) => r.to === me) || null;
|
|
237
|
+
const waiting = open.filter((r) => r.to !== me).map((r) => r.to);
|
|
238
|
+
const candidates = (Array.isArray(rv?.candidates) ? rv.candidates : []).filter((e) => typeof e === 'string' && e !== me && !open.some((r) => r.to === e));
|
|
239
|
+
const mode = rv?.mode === 'off' || rv?.mode === 'required' ? rv.mode : 'optional';
|
|
240
|
+
return {
|
|
241
|
+
mode, requests, mine, waiting, candidates,
|
|
242
|
+
canAsk: mode !== 'off' && candidates.length > 0,
|
|
243
|
+
mustAsk: mode === 'required' && !requests.some((r) => r.status === 'approved') && !open.length,
|
|
244
|
+
last: !!mine && waiting.length === 0 && requests.some((r) => r.release),
|
|
245
|
+
};
|
|
221
246
|
}
|
|
222
247
|
|
|
223
248
|
// THE FIRST SCREEN OF A REVIEW (0.10.0, Michael 2026-11-03): a very brief
|
package/src/signin.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// only then starts the tour, on that screen. Pure, so node --test runs it.
|
|
7
7
|
|
|
8
8
|
const SETTLE_MIN_MS = 1500; // the app's handler signs in and routes before anything is looked for
|
|
9
|
-
const SETTLE_MAX_MS = 12000; // then the
|
|
9
|
+
const SETTLE_MAX_MS = 12000; // and then the sign-in has not taken: said, the tour stops (0.12.0)
|
|
10
10
|
const POLL_MS = 250;
|
|
11
11
|
|
|
12
12
|
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -38,8 +38,8 @@ export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform
|
|
|
38
38
|
/**
|
|
39
39
|
* Sign `actorIndex` in, then resolve once the app has settled. `openURL` is
|
|
40
40
|
* Linking.openURL; `settled()` says the first screen is up (its element is
|
|
41
|
-
* found). → { ok: true } | { ok: false, error } (the player
|
|
42
|
-
*
|
|
41
|
+
* found). → { ok: true } | { ok: false, error } (the player stops the tour
|
|
42
|
+
* with Try again).
|
|
43
43
|
*/
|
|
44
44
|
export async function signInFirst({ api, actorIndex, openURL, settled = () => true, sleep = pause, minMs = SETTLE_MIN_MS, maxMs = SETTLE_MAX_MS }) {
|
|
45
45
|
let link;
|
|
@@ -51,6 +51,12 @@ export async function signInFirst({ api, actorIndex, openURL, settled = () => tr
|
|
|
51
51
|
return { ok: false, error: e?.message || String(e) };
|
|
52
52
|
}
|
|
53
53
|
await sleep(minMs);
|
|
54
|
-
|
|
54
|
+
let ok = false;
|
|
55
|
+
for (let waited = minMs; waited < maxMs; waited += POLL_MS) { if ((ok = !!settled())) break; await sleep(POLL_MS); }
|
|
56
|
+
// CONFIRMED, NOT ASSUMED (0.12.0): the first screen past sign-in never came
|
|
57
|
+
// up, so the sign-in did not take — the app's own handler failed (2026-11-05:
|
|
58
|
+
// "Failed to verify token: [auth/network-request-failed]" while the tour
|
|
59
|
+
// carried on to Next and Take me there). Said, and the tour stops.
|
|
60
|
+
if (!ok) return { ok: false, error: "the app didn't sign in (its first screen never appeared)" };
|
|
55
61
|
return { ok: true };
|
|
56
62
|
}
|