@simon-says-build/ssb-overlay 0.5.1 → 0.7.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
@@ -39,7 +39,19 @@ export function SsbHost(props: {
39
39
  * Without it there is no Talk button.
40
40
  */
41
41
  voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
42
+ /**
43
+ * What this build is, over what the overlay reads itself (0.7.0): sent with the
44
+ * push token so SSB knows what is installed. `devClient: false` makes "Ready to
45
+ * see" pushes say the news without a preview link this build can't open.
46
+ */
47
+ client?: { app?: string; build?: string; ota?: string; os?: string; devClient?: boolean };
42
48
  /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
43
49
  insets?: { top?: number; bottom?: number };
50
+ /**
51
+ * Which build this is: the origin the app's JS bundle was loaded from (a
52
+ * SimonSays preview's tunnel). Read off the bundle when omitted; Ready lists
53
+ * only what this build holds, and ▶ Demo plays it here.
54
+ */
55
+ bundle?: string;
44
56
  children?: ReactNode;
45
57
  }): 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.7.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, PLAYER_VERSION } 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, clientInfo, 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,31 @@ 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
+ // What this build is, for the push registration (core.js clientInfo). Each
77
+ // read is optional: an app without expo-application or a dev launcher still
78
+ // registers, it just says less.
79
+ function buildInfo(override) {
80
+ const mods = globalThis.expo?.modules || {};
81
+ const appMod = mods.ExpoApplication || NativeModules.ExpoApplication || {};
82
+ const updates = mods.ExpoUpdates || NativeModules.ExpoUpdates || {};
83
+ const hasLauncher = !!(mods.ExpoDevLauncher || mods.ExpoDevLauncherInternal || NativeModules.EXDevLauncher || NativeModules.EXDevLauncherInternal);
84
+ return clientInfo({
85
+ player: PLAYER_VERSION, app: appMod.nativeApplicationVersion, build: appMod.nativeBuildVersion,
86
+ os: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
87
+ runtime: typeof updates.runtimeVersion === 'string' ? updates.runtimeVersion : undefined, hasLauncher, bundleOrigin: bundleOriginOf(scriptURL()), override,
88
+ });
89
+ }
90
+
91
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, bundle, client, children }) {
69
92
  const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
70
93
  useSsbLinks(notifications);
71
94
  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);
95
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, bundle, client, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
73
96
  }
74
97
 
75
98
  // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
@@ -85,7 +108,12 @@ function useSsbLinks(notifications) {
85
108
  }, [notifications]);
86
109
  }
87
110
 
88
- function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
111
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, bundle, client, children }) {
112
+ // WHICH BUILD THIS IS (docs/IN-APP-SSB.md "Ready is this build"): Ready is
113
+ // only what this bundle holds. `bundle` overrides (the harness); else the
114
+ // origin the JS came from.
115
+ const here = useMemo(() => (bundle !== undefined ? String(bundle || '') : bundleOriginOf(scriptURL())), [bundle]);
116
+ const [elsewhere, setElsewhere] = useState([]);
89
117
  const screen = useWindowDimensions();
90
118
  const tokenRef = useRef(getToken);
91
119
  tokenRef.current = getToken;
@@ -134,7 +162,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
134
162
  if (!live) return;
135
163
  setGate(g?.show ? g : null);
136
164
  // This device, for "it's done, tap to see it" — only for someone SSB let in.
137
- if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS) : null)).catch(() => {});
165
+ if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS, buildInfo(client)) : null)).catch(() => {});
138
166
  }).catch(() => { if (live) setGate(null); });
139
167
  return () => { live = false; };
140
168
  }, [ssb, sessionKey]);
@@ -181,7 +209,18 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
181
209
  const open = mode !== 'idle';
182
210
  useEffect(() => { if (open) load(); }, [open, chatId]);
183
211
  // 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]);
212
+ const loadWork = useCallback(async () => {
213
+ try { const r = await ssb.work(here); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
214
+ }, [ssb, here]);
215
+ // ▶ Demo: one demo of everything Ready in this build, played by this build.
216
+ const startDemo = useCallback(async () => {
217
+ setBusy(true); setError(null);
218
+ try {
219
+ const r = await ssb.demo(here);
220
+ if (!playDemo({ id: r.demoId, token: r.token, api: config.api, here: true })) throw new Error('the demo could not start');
221
+ Keyboard.dismiss(); setMode('idle');
222
+ } catch (e) { setError(e.message); } finally { setBusy(false); }
223
+ }, [ssb, here, config.api]);
185
224
  useEffect(() => {
186
225
  if (mode !== 'sheet' || !gate) return undefined;
187
226
  loadWork();
@@ -445,7 +484,8 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
445
484
  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
485
  }
447
486
  if (mode === 'sheet') return h(Panel, {
448
- gate, chat, work, screen, insets, draft, setDraft, busy, error,
487
+ gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
488
+ onDemo: typeof playDemo === 'function' ? startDemo : null,
449
489
  onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
450
490
  canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
451
491
  onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
@@ -458,14 +498,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
458
498
  h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
459
499
  }
460
500
 
461
- function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
501
+ 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
502
  const scroll = useRef(null);
463
503
  const [open, setOpen] = useState(null); // the draft whose detail is shown
464
504
  const { flying, waiting, sent } = workView({ inFlight: work, chat });
465
505
  const offer = offerOf(chat);
466
506
  const banner = demoBanner(chat);
467
507
  const msgs = chat?.messages || [];
468
- const hasWork = flying.length || waiting.length || sent.length || banner;
508
+ const ready = flying.filter((x) => x.state === 'ready');
509
+ const away = elsewhereLine(elsewhere);
510
+ const hasWork = flying.length || waiting.length || sent.length || banner || away;
469
511
  const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
470
512
  return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
471
513
  h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
@@ -482,7 +524,10 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
482
524
  testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
483
525
  accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
484
526
  }, 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,
527
+ flying.length ? h(View, { style: s.sectionRow },
528
+ h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
529
+ 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] },
530
+ h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
486
531
  // A row opens its job in the SimonSays app, the console when that app
487
532
  // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
488
533
  // who can open it there; anyone else's rows stay plain.
@@ -494,6 +539,7 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
494
539
  showMe(x)
495
540
  ? 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
541
  : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
542
+ away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
497
543
  waiting.length ? h(View, { style: s.sectionRow },
498
544
  h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
499
545
  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
@@ -3,6 +3,9 @@
3
3
  // (functions/annotate.mjs), so the shapes below are the extension's.
4
4
 
5
5
  export const DEFAULT_API = 'https://simonsays.build';
6
+ // What this overlay is, said when it registers for pushes (clientInfo below).
7
+ // Kept equal to package.json by test/core.test.mjs.
8
+ export const OVERLAY_VERSION = '0.7.0';
6
9
  export const HOST = 5;
7
10
  export const TEXT = 6;
8
11
  const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box
@@ -41,9 +44,13 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
41
44
  send: (body) => post('/annotations', body),
42
45
  chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
43
46
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
- pushToken: (token, platform) => post('/push-token', { token, platform }),
47
+ // `client`: what this build is (clientInfo), so SSB knows what is installed.
48
+ pushToken: (token, platform, client) => post('/push-token', { token, platform, ...(client ? { client } : {}) }),
45
49
  voice: (body) => post('/voice', body),
46
- work: () => call('/work'),
50
+ // `bundle`: where this app's JS came from (a preview's tunnel), so SSB
51
+ // scopes Ready to this build; undefined = the whole list, as before.
52
+ work: (bundle) => call(bundle === undefined ? '/work' : `/work?bundle=${encodeURIComponent(bundle)}`),
53
+ demo: (bundle) => post('/demo', { bundle }),
47
54
  };
48
55
  }
49
56
 
@@ -308,3 +315,30 @@ export function workView({ inFlight = [], chat = null } = {}) {
308
315
  const { waiting, sent } = draftsOf(chat);
309
316
  return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
310
317
  }
318
+
319
+ // READY IS THIS BUILD (2026-10-20, docs/IN-APP-SSB.md): the origin the app's
320
+ // JS bundle was loaded from — a preview's tunnel — or "" for a build that
321
+ // loads from a file (a store or local build, which holds no SSB preview).
322
+ export function bundleOriginOf(scriptURL) {
323
+ const m = /^(https?:\/\/[^/?#]+)/i.exec(String(scriptURL || ''));
324
+ return m ? m[1] : '';
325
+ }
326
+ // What the overlay says about Ready work this build does not hold.
327
+ export function elsewhereLine(readyElsewhere = []) {
328
+ const n = (readyElsewhere || []).reduce((t, x) => t + (Number(x?.count) || 0), 0);
329
+ if (!n) return '';
330
+ return `${n === 1 ? '1 more change is' : `${n} more changes are`} ready in another build — open its preview to see ${n === 1 ? 'it' : 'them'}.`;
331
+ }
332
+
333
+ // WHAT IS INSTALLED (0.7.0, docs/IN-APP-SSB.md "What is installed"): said with
334
+ // the push token, so SSB knows which builds can open a "Ready to see" preview.
335
+ // `devClient`: a dev launcher is in the binary → true; none, and the JS came
336
+ // from a file (a store or release build) → false; otherwise unknown (absent),
337
+ // and SSB sends the link as before. The host's `client` prop outranks all of it.
338
+ export function clientInfo({ player, app, build, os, runtime, hasLauncher, bundleOrigin, override } = {}) {
339
+ const devClient = hasLauncher ? true : bundleOrigin === '' ? false : undefined;
340
+ // `runtime`: expo-updates' runtimeVersion, which under the fingerprint policy
341
+ // IS the native fingerprint a preview is gated on (docs/DEV-CLIENTS.md).
342
+ const out = { overlay: OVERLAY_VERSION, player, app, build, os, runtime, devClient, ...(override || {}) };
343
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined && v !== null && v !== ''));
344
+ }