@simon-says-build/demo-player 0.7.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 CHANGED
@@ -22,6 +22,8 @@ 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
+
25
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.
26
28
 
27
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/demo-player",
3
- "version": "0.7.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
@@ -17,6 +17,7 @@ import { Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, View, useW
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
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).
@@ -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.7.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;