@simon-says-build/ssb-overlay 0.5.1 → 0.6.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 CHANGED
@@ -41,5 +41,11 @@ export function SsbHost(props: {
41
41
  voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
42
42
  /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
43
43
  insets?: { top?: number; bottom?: number };
44
+ /**
45
+ * Which build this is: the origin the app's JS bundle was loaded from (a
46
+ * SimonSays preview's tunnel). Read off the bundle when omitted; Ready lists
47
+ * only what this build holds, and ▶ Demo plays it here.
48
+ */
49
+ bundle?: string;
44
50
  children?: ReactNode;
45
51
  }): ReactNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "The SSB button inside a native app SimonSays controls: screenshot the screen, mark it, and ask SSB for a change. Renders nothing for anyone SSB does not let in.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,6 +22,7 @@
22
22
  "README.md"
23
23
  ],
24
24
  "peerDependencies": {
25
+ "@simon-says-build/demo-player": ">=0.1.0",
25
26
  "react": ">=18",
26
27
  "react-native": ">=0.76"
27
28
  },
package/src/SsbHost.js CHANGED
@@ -21,11 +21,14 @@
21
21
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
22
  import {
23
23
  ActivityIndicator, Image, Keyboard, KeyboardAvoidingView, Linking, PanResponder, Platform, Pressable,
24
- ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
24
+ NativeModules, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
25
25
  } from 'react-native';
26
+ // The app's demo player (a peer): ▶ Demo hands it the demo to play in this
27
+ // build. An older player has no playDemo, and the button is not offered.
28
+ import { playDemo } from '@simon-says-build/demo-player';
26
29
  import {
27
30
  FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
28
- STATE_WORD, boxItem, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
31
+ STATE_WORD, boxItem, bundleOriginOf, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
29
32
  } from './core.js';
30
33
 
31
34
  const h = React.createElement;
@@ -65,11 +68,16 @@ const frames = (n = 2) => new Promise((resolve) => {
65
68
  step(n);
66
69
  });
67
70
 
68
- export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, children }) {
71
+ // Where this app's JS was loaded from: a preview's tunnel, or a file.
72
+ const scriptURL = () => {
73
+ try { const sc = NativeModules.SourceCode; return sc?.getConstants?.()?.scriptURL ?? sc?.scriptURL ?? ''; } catch { return ''; }
74
+ };
75
+
76
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, bundle, children }) {
69
77
  const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
70
78
  useSsbLinks(notifications);
71
79
  if (!config || typeof getToken !== 'function') return children ?? null;
72
- return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
80
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, bundle, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
73
81
  }
74
82
 
75
83
  // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
@@ -85,7 +93,12 @@ function useSsbLinks(notifications) {
85
93
  }, [notifications]);
86
94
  }
87
95
 
88
- function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
96
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, bundle, children }) {
97
+ // WHICH BUILD THIS IS (docs/IN-APP-SSB.md "Ready is this build"): Ready is
98
+ // only what this bundle holds. `bundle` overrides (the harness); else the
99
+ // origin the JS came from.
100
+ const here = useMemo(() => (bundle !== undefined ? String(bundle || '') : bundleOriginOf(scriptURL())), [bundle]);
101
+ const [elsewhere, setElsewhere] = useState([]);
89
102
  const screen = useWindowDimensions();
90
103
  const tokenRef = useRef(getToken);
91
104
  tokenRef.current = getToken;
@@ -181,7 +194,18 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
181
194
  const open = mode !== 'idle';
182
195
  useEffect(() => { if (open) load(); }, [open, chatId]);
183
196
  // The top of the panel: the app's work, asked when it opens and while it stays open.
184
- const loadWork = useCallback(async () => { try { setWork((await ssb.work())?.inFlight || []); } catch { /* the last list stands */ } }, [ssb]);
197
+ const loadWork = useCallback(async () => {
198
+ try { const r = await ssb.work(here); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
199
+ }, [ssb, here]);
200
+ // ▶ Demo: one demo of everything Ready in this build, played by this build.
201
+ const startDemo = useCallback(async () => {
202
+ setBusy(true); setError(null);
203
+ try {
204
+ const r = await ssb.demo(here);
205
+ if (!playDemo({ id: r.demoId, token: r.token, api: config.api, here: true })) throw new Error('the demo could not start');
206
+ Keyboard.dismiss(); setMode('idle');
207
+ } catch (e) { setError(e.message); } finally { setBusy(false); }
208
+ }, [ssb, here, config.api]);
185
209
  useEffect(() => {
186
210
  if (mode !== 'sheet' || !gate) return undefined;
187
211
  loadWork();
@@ -445,7 +469,8 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
445
469
  h(Pressable, { testID: 'ssb-draw-send', onPress: sendDrawing, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, '→'))) : null);
446
470
  }
447
471
  if (mode === 'sheet') return h(Panel, {
448
- gate, chat, work, screen, insets, draft, setDraft, busy, error,
472
+ gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
473
+ onDemo: typeof playDemo === 'function' ? startDemo : null,
449
474
  onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
450
475
  canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
451
476
  onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
@@ -458,14 +483,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
458
483
  h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
459
484
  }
460
485
 
461
- function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
486
+ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
462
487
  const scroll = useRef(null);
463
488
  const [open, setOpen] = useState(null); // the draft whose detail is shown
464
489
  const { flying, waiting, sent } = workView({ inFlight: work, chat });
465
490
  const offer = offerOf(chat);
466
491
  const banner = demoBanner(chat);
467
492
  const msgs = chat?.messages || [];
468
- const hasWork = flying.length || waiting.length || sent.length || banner;
493
+ const ready = flying.filter((x) => x.state === 'ready');
494
+ const away = elsewhereLine(elsewhere);
495
+ const hasWork = flying.length || waiting.length || sent.length || banner || away;
469
496
  const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
470
497
  return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
471
498
  h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
@@ -482,7 +509,10 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
482
509
  testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
483
510
  accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
484
511
  }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
485
- flying.length ? h(Text, { style: s.section, testID: 'ssb-section-flight' }, 'In flight') : null,
512
+ flying.length ? h(View, { style: s.sectionRow },
513
+ h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
514
+ ready.length && onDemo ? h(Pressable, { testID: 'ssb-demo-ready', onPress: onDemo, disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Demo what is ready in this build (${ready.length})`, style: [s.primary, s.small, busy && s.disabled] },
515
+ h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
486
516
  // A row opens its job in the SimonSays app, the console when that app
487
517
  // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
488
518
  // who can open it there; anyone else's rows stay plain.
@@ -494,6 +524,7 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
494
524
  showMe(x)
495
525
  ? h(Pressable, { onPress: () => onOpenLink(showMe(x)), accessibilityRole: 'button', accessibilityLabel: `Show me: ${x.title}`, style: [s.primary, s.small] }, h(Text, { style: s.primaryText }, '▶ Show me'))
496
526
  : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
527
+ away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
497
528
  waiting.length ? h(View, { style: s.sectionRow },
498
529
  h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
499
530
  waiting.length > 1 ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, accessibilityRole: 'button', accessibilityLabel: 'Start all', style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, `Start all (${waiting.length})`)) : null) : null,
package/src/core.js CHANGED
@@ -43,7 +43,10 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
43
43
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
44
  pushToken: (token, platform) => post('/push-token', { token, platform }),
45
45
  voice: (body) => post('/voice', body),
46
- work: () => call('/work'),
46
+ // `bundle`: where this app's JS came from (a preview's tunnel), so SSB
47
+ // scopes Ready to this build; undefined = the whole list, as before.
48
+ work: (bundle) => call(bundle === undefined ? '/work' : `/work?bundle=${encodeURIComponent(bundle)}`),
49
+ demo: (bundle) => post('/demo', { bundle }),
47
50
  };
48
51
  }
49
52
 
@@ -308,3 +311,17 @@ export function workView({ inFlight = [], chat = null } = {}) {
308
311
  const { waiting, sent } = draftsOf(chat);
309
312
  return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
310
313
  }
314
+
315
+ // READY IS THIS BUILD (2026-10-20, docs/IN-APP-SSB.md): the origin the app's
316
+ // JS bundle was loaded from — a preview's tunnel — or "" for a build that
317
+ // loads from a file (a store or local build, which holds no SSB preview).
318
+ export function bundleOriginOf(scriptURL) {
319
+ const m = /^(https?:\/\/[^/?#]+)/i.exec(String(scriptURL || ''));
320
+ return m ? m[1] : '';
321
+ }
322
+ // What the overlay says about Ready work this build does not hold.
323
+ export function elsewhereLine(readyElsewhere = []) {
324
+ const n = (readyElsewhere || []).reduce((t, x) => t + (Number(x?.count) || 0), 0);
325
+ if (!n) return '';
326
+ return `${n === 1 ? '1 more change is' : `${n} more changes are`} ready in another build — open its preview to see ${n === 1 ? 'it' : 'them'}.`;
327
+ }