@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 +12 -0
- package/package.json +2 -1
- package/src/SsbHost.js +57 -11
- package/src/core.js +36 -2
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.
|
|
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
|
-
|
|
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 () => {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|