@simon-says-build/demo-player 0.6.0 → 0.8.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/README.md +4 -0
- package/package.json +1 -1
- package/src/DemoHost.js +30 -3
- package/src/api.js +6 -0
- package/src/auth-steps.js +2 -2
- package/src/core.js +2 -2
- package/src/events.js +41 -0
- package/src/signin.js +21 -0
package/README.md
CHANGED
|
@@ -22,6 +22,10 @@ Pass the `process.env.EXPO_PUBLIC_*` reads in the app's own code. Expo inlines t
|
|
|
22
22
|
|
|
23
23
|
Without all three it renders its children and nothing else. Elements are found by `testID`, then `accessibilityLabel`, then visible text, by walking React's tree. No component needs wrapping.
|
|
24
24
|
|
|
25
|
+
**0.8.0: a planned review's events** (`event` steps, docs/PLANNED-REVIEW.md). The player asks SSB to run a helper on the review's private copy ("and now a walker arrives"), waits until it's done, and fills `{name}` in that person's later steps from its outputs. A person with a planned `state` has it set up (`@state`) before they're signed in, so their world is current every time the review opens. A failed setup stays on its card with the reason and a Next button.
|
|
26
|
+
|
|
27
|
+
**0.7.0: the sign-in link opens through the app's router** (`openInApp`), not `Linking.openURL`. The link names app.json's scheme, and a review plays in the dev build, whose scheme can differ (Walk2Gether's is `walk2gether-dev`), so the OS opened the production app or nothing. Without a `router`, the OS opens it as before.
|
|
28
|
+
|
|
25
29
|
**0.6.0: SSB signs the viewer in** on a phone's private, seeded copy (docs/MOBILE-DEMO.md "Sign-in (stage 2: SSB signs in)"). Before each person's part the player asks the demo API for their sign-in link (`POST /api/demo/:id/signin`), opens it with `Linking.openURL`, waits for the app to settle, and starts on the screen the link opened. The walk's own sign-in steps are skipped. The app must handle `<scheme>://auth?token=<custom token>&next=<path>`: sign in with the custom token, then route to `next`. Where there is no link (the app's real backend, no URL scheme, the copy not up), the card says who to sign in as, as before.
|
|
26
30
|
|
|
27
31
|
`npm test` runs the pure half.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/demo-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
@@ -16,7 +16,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
16
16
|
import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions } from 'react-native';
|
|
17
17
|
import { cardBox, currentDemo, demoConfig, findTarget, handlerFor, onDemo, phoneSteps, playDemo, releaseOf, reviewChangesOf, scalePoint } from './core.js';
|
|
18
18
|
import { demoApi, tourOf } from './api.js';
|
|
19
|
-
import { signInFirst } from './signin.js';
|
|
19
|
+
import { signInFirst, openInApp } from './signin.js';
|
|
20
|
+
import { runEvent, fillStep } from './events.js';
|
|
20
21
|
|
|
21
22
|
const h = React.createElement;
|
|
22
23
|
const FIND_FOR_MS = 8000; // a step's element may appear after a navigation settles
|
|
@@ -75,6 +76,8 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
75
76
|
const [release, setRelease] = useState(null); // a release's demo ends on releasing it (releaseOf)
|
|
76
77
|
const [reviewing, setReviewing] = useState(false);
|
|
77
78
|
const [signIn, setSignIn] = useState(null); // a `signin` step: null while it runs, else { ok, error }
|
|
79
|
+
const [event, setEvent] = useState(null); // an `event` step: null while it runs, else { ok, error }
|
|
80
|
+
const [vars, setVars] = useState({}); // per person: what their events set up ({walkId} in later steps)
|
|
78
81
|
|
|
79
82
|
useEffect(() => {
|
|
80
83
|
let live = true;
|
|
@@ -84,7 +87,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
84
87
|
return () => { live = false; };
|
|
85
88
|
}, [api]);
|
|
86
89
|
|
|
87
|
-
const step = steps?.[index] ?? null;
|
|
90
|
+
const step = useMemo(() => { const raw = steps?.[index] ?? null; return raw ? fillStep(raw, vars[raw.actorIndex]) : null; }, [steps, index, vars]);
|
|
88
91
|
|
|
89
92
|
|
|
90
93
|
// Find and follow the step's element.
|
|
@@ -139,6 +142,23 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
139
142
|
next();
|
|
140
143
|
}, [step, found, next]);
|
|
141
144
|
|
|
145
|
+
// AND NOW… (an `event` step, 0.8.0): SSB runs the helper on the copy; its
|
|
146
|
+
// outputs fill `{name}` in this person's later steps. A failure stays on the
|
|
147
|
+
// card, said, with Next; never a silent skip.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
setEvent(null);
|
|
150
|
+
if (step?.kind !== 'event' || done) return undefined;
|
|
151
|
+
let live = true;
|
|
152
|
+
runEvent({ api, actorIndex: step.actorIndex, helper: step.helper, args: step.args || {} }).then((r) => {
|
|
153
|
+
if (!live) return;
|
|
154
|
+
if (!r.ok) { setEvent(r); return; }
|
|
155
|
+
setVars((v) => ({ ...v, [step.actorIndex]: { ...(v[step.actorIndex] || {}), ...r.outputs } }));
|
|
156
|
+
if (index + 1 >= steps.length) { if (changes.length) setReviewing(true); else finish(); }
|
|
157
|
+
else setIndex(index + 1);
|
|
158
|
+
});
|
|
159
|
+
return () => { live = false; };
|
|
160
|
+
}, [step?.kind, step?.helper, index, done]);
|
|
161
|
+
|
|
142
162
|
// SIGN THIS PERSON IN FIRST (a `signin` step, tourOf): open the link, let
|
|
143
163
|
// the app sign in and route, then start their part on that screen. A link
|
|
144
164
|
// that can't be had or opened leaves the card saying who to be (stage 1).
|
|
@@ -148,7 +168,7 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
148
168
|
let live = true;
|
|
149
169
|
const first = steps[index + 1];
|
|
150
170
|
signInFirst({
|
|
151
|
-
api, actorIndex: step.actorIndex, openURL: (url) => Linking.openURL(
|
|
171
|
+
api, actorIndex: step.actorIndex, openURL: (url) => openInApp(url, { router, openURL: (u) => Linking.openURL(u) }),
|
|
152
172
|
settled: () => !live || !first?.target || first.target.point || !!findTarget(fiberOf(rootRef.current), first.target),
|
|
153
173
|
}).then((r) => {
|
|
154
174
|
if (!live) return;
|
|
@@ -173,6 +193,13 @@ function Player({ config, router, insets, onClose, children }) {
|
|
|
173
193
|
}
|
|
174
194
|
if (!step && changes.length) return h(Review, { api, changes, release, screen, insets, onBack: null, onDone: finish, onHide: () => setTucked(true) });
|
|
175
195
|
if (!step) return h(Card, { at: null, screen, insets, card, setCard, title: 'Demo', body: 'This demo has no steps a phone can play.' });
|
|
196
|
+
if (step.kind === 'event') {
|
|
197
|
+
return h(Card, {
|
|
198
|
+
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
|
199
|
+
body: event ? `The demo couldn't set this up: ${event.error}` : step.caption,
|
|
200
|
+
actions: event ? [{ label: 'Hide', onPress: () => setTucked(true) }, { label: 'Next', onPress: next, primary: true }] : [],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
176
203
|
if (step.kind === 'signin') {
|
|
177
204
|
return h(Card, {
|
|
178
205
|
at: null, screen, insets, card, setCard, title: `${index + 1} of ${steps.length}`,
|
package/src/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { skipAuthSteps } from './auth-steps.js';
|
|
2
2
|
import { signsIn } from './signin.js';
|
|
3
|
+
import { hasState } from './events.js';
|
|
3
4
|
|
|
4
5
|
// The demo API the web extension already calls (functions/index.mjs
|
|
5
6
|
// /api/demo/:id/*), authenticated by the demo's own token.
|
|
@@ -24,6 +25,9 @@ export function demoApi({ api, id, token }, fetchImpl = globalThis.fetch) {
|
|
|
24
25
|
release: () => call('/release', { method: 'POST', body: '{}' }),
|
|
25
26
|
// A phone's private copy (0.6.0): the link that signs this person in.
|
|
26
27
|
signin: (actorIndex) => call('/signin', { method: 'POST', body: JSON.stringify({ actorIndex }) }),
|
|
28
|
+
// A planned review (0.8.0): run a helper on the private copy, then read how it went.
|
|
29
|
+
event: (actorIndex, helper, args) => call('/event', { method: 'POST', body: JSON.stringify({ actorIndex, helper, args }) }),
|
|
30
|
+
eventStatus: (eventId) => call(`/event/${encodeURIComponent(eventId)}`),
|
|
27
31
|
discuss: (jobId, text) => call('/chat', { method: 'POST', body: JSON.stringify({ text, context: { about: jobId } }) }),
|
|
28
32
|
};
|
|
29
33
|
}
|
|
@@ -41,6 +45,8 @@ export function tourOf(demo, { here = false } = {}) {
|
|
|
41
45
|
const steps = a.tour?.steps || [];
|
|
42
46
|
if (!steps.length) continue;
|
|
43
47
|
const ask = `This part is as ${a.label}. Sign in as ${a.email} if you aren't already, then tap Next.`;
|
|
48
|
+
// A planned review sets this person's world up first (0.8.0): `@state`.
|
|
49
|
+
if (auto && hasState(a)) out.push({ kind: 'event', helper: '@state', caption: `Setting up ${a.label}'s part…`, actorIndex, intro: true });
|
|
44
50
|
if (auto) out.push({ kind: 'signin', caption: `Signing you in as ${a.label}…`, fallback: ask, actorIndex, intro: true });
|
|
45
51
|
else if (!here) out.push({ kind: 'say', caption: ask, actorIndex, intro: true });
|
|
46
52
|
for (const s of auto ? skipAuthSteps(steps) : steps) out.push({ ...s, actorIndex });
|
package/src/auth-steps.js
CHANGED
|
@@ -49,8 +49,8 @@ export function authStepsEnd(steps = []) {
|
|
|
49
49
|
return end;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** A person's steps without the walk's sign-in. A caption card (`say`) in it stays. */
|
|
52
|
+
/** A person's steps without the walk's sign-in. A caption card (`say`) or an `event` in it stays. */
|
|
53
53
|
export function skipAuthSteps(steps = []) {
|
|
54
54
|
const end = authStepsEnd(steps);
|
|
55
|
-
return steps.filter((s, i) => i >= end || s?.kind === 'say');
|
|
55
|
+
return steps.filter((s, i) => i >= end || s?.kind === 'say' || s?.kind === 'event');
|
|
56
56
|
}
|
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.8.0';
|
|
14
14
|
|
|
15
15
|
export const HOST = 5;
|
|
16
16
|
export const TEXT = 6;
|
|
@@ -21,7 +21,7 @@ export const RECORDED_SCREEN = { width: 393, height: 852 };
|
|
|
21
21
|
// What a phone can perform. `press` (a keyboard key), `select` (a web
|
|
22
22
|
// <select>) and `run` (a terminal demo) have no phone equivalent. `signin` is
|
|
23
23
|
// the player's own (tourOf, 0.6.0): sign this person in by link.
|
|
24
|
-
const PHONE_KINDS = new Set(['say', 'goto', 'show', 'click', 'fill', 'signin']);
|
|
24
|
+
const PHONE_KINDS = new Set(['say', 'goto', 'show', 'click', 'fill', 'signin', 'event']);
|
|
25
25
|
|
|
26
26
|
const lower = (s) => String(s ?? '').trim().toLowerCase();
|
|
27
27
|
const hiddenHost = (f) => {
|
package/src/events.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A PLANNED REVIEW'S EVENTS (0.8.0, docs/PLANNED-REVIEW.md). An `event` step is
|
|
2
|
+
// "and now…": the player asks SSB to run a helper on the review's private copy
|
|
3
|
+
// (POST /api/demo/:id/event), waits for the box serving the copy to say it's
|
|
4
|
+
// done, and carries its outputs into later steps (`{walkId}` in a path).
|
|
5
|
+
// `@state` is the person's own world, set up when their part opens, before
|
|
6
|
+
// sign-in, so a walk "starting in 30 minutes" is always 30 minutes away.
|
|
7
|
+
// Pure, so node --test runs it.
|
|
8
|
+
|
|
9
|
+
const POLL_MS = 1000;
|
|
10
|
+
const EVENT_MAX_MS = 60000;
|
|
11
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run one event and wait for it. → { ok: true, outputs } | { ok: false, error }.
|
|
15
|
+
*/
|
|
16
|
+
export async function runEvent({ api, actorIndex, helper, args = {}, sleep = pause, maxMs = EVENT_MAX_MS }) {
|
|
17
|
+
let eventId;
|
|
18
|
+
try { ({ eventId } = await api.event(actorIndex, helper, args)); } catch (e) { return { ok: false, error: e?.message || String(e) }; }
|
|
19
|
+
if (!eventId) return { ok: false, error: 'SSB did not take the event' };
|
|
20
|
+
for (let waited = 0; waited < maxMs; waited += POLL_MS) {
|
|
21
|
+
await sleep(POLL_MS);
|
|
22
|
+
let e;
|
|
23
|
+
try { e = await api.eventStatus(eventId); } catch { continue; } // a dropped poll is not a failure
|
|
24
|
+
if (e?.status === 'done') return { ok: true, outputs: e.outputs || {} };
|
|
25
|
+
if (e?.status === 'failed') return { ok: false, error: e.why || 'the setup failed' };
|
|
26
|
+
}
|
|
27
|
+
return { ok: false, error: 'the setup took too long' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `{name}` in a step's path, value, caption and args, from this person's outputs. Pure. */
|
|
31
|
+
export function fillStep(step, vars = {}) {
|
|
32
|
+
if (!step || !vars || !Object.keys(vars).length) return step;
|
|
33
|
+
const fill = (v) => typeof v === 'string' ? v.replace(/\{([A-Za-z0-9_]+)\}/g, (m, k) => (vars[k] != null ? String(vars[k]) : m))
|
|
34
|
+
: Array.isArray(v) ? v.map(fill) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fill(x)])) : v;
|
|
35
|
+
const out = { ...step };
|
|
36
|
+
for (const k of ['path', 'value', 'caption', 'args', 'target']) if (out[k] != null) out[k] = fill(out[k]);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether a person has a world to set up when their part opens. Pure. */
|
|
41
|
+
export const hasState = (actor) => Array.isArray(actor?.plan?.state) && actor.plan.state.length > 0;
|
package/src/signin.js
CHANGED
|
@@ -11,6 +11,27 @@ const POLL_MS = 250;
|
|
|
11
11
|
|
|
12
12
|
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The app path of a link in the app's own deep-link form: `x://auth?token=…`
|
|
16
|
+
* → `/auth?token=…`. The player opens its sign-in through the app's ROUTER
|
|
17
|
+
* (0.7.0), never the OS: the link names the app's scheme as app.json says it,
|
|
18
|
+
* and a review plays in the DEV build, whose scheme differs (walk2gether-dev),
|
|
19
|
+
* so Linking.openURL opened the production app, or nothing (2026-10-31). The
|
|
20
|
+
* router is always the app the player runs in. Pure; null for anything else.
|
|
21
|
+
*/
|
|
22
|
+
export function inAppPath(link) {
|
|
23
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/(.*)$/i.exec(String(link || ''));
|
|
24
|
+
if (!m || /^https?:/i.test(String(link))) return null;
|
|
25
|
+
return `/${m[1].replace(/^\/+/, '')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Open a sign-in link in THIS app: its router when it has one, else the OS. */
|
|
29
|
+
export async function openInApp(link, { router, openURL }) {
|
|
30
|
+
const p = inAppPath(link);
|
|
31
|
+
if (p && router?.push) { router.push(p); return; }
|
|
32
|
+
await openURL(link);
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
/** Whether a demo signs its people in itself: a phone's private copy, not a build the viewer is already in. */
|
|
15
36
|
export const signsIn = (demo, { here = false } = {}) => !here && demo?.platform === 'ios' && demo?.backend === 'sandbox';
|
|
16
37
|
|