@simon-says-build/ssb-overlay 0.10.0 → 0.11.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/package.json +1 -1
- package/src/SsbHost.js +53 -7
- package/src/core.js +20 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/ssb-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "SSB inside a native app SimonSays controls: the SSB button for the people who change the app (screenshot, mark, ask for a change), and Help for everyone who uses it.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/src/SsbHost.js
CHANGED
|
@@ -40,6 +40,7 @@ import { playDemo, PLAYER_VERSION } from '@simon-says-build/demo-player';
|
|
|
40
40
|
import BottomSheet, { BottomSheetBackdrop, BottomSheetFooter, BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
|
41
41
|
import { GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler';
|
|
42
42
|
import {
|
|
43
|
+
codeShown,
|
|
43
44
|
FAB, addPoint, clampFab, demoBanner, openSessionsOf, sessionRow, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
|
|
44
45
|
STATE_WORD, workSummary, boxItem, bundleOriginOf, clientInfo, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
|
|
45
46
|
} from './core.js';
|
|
@@ -149,7 +150,10 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
149
150
|
const chromeRef = useRef(null);
|
|
150
151
|
const storeKey = `ssb-overlay:chat:${config.appId}`;
|
|
151
152
|
|
|
152
|
-
const [gate, setGate] = useState(null); // {show, person, app, canJob} once SSB has answered
|
|
153
|
+
const [gate, setGate] = useState(null); // {show, person, app, canJob, link?} once SSB has answered
|
|
154
|
+
const [linking, setLinking] = useState(null); // the code to link this login with: { code, url, expiresAt }
|
|
155
|
+
const gateRef = useRef(null);
|
|
156
|
+
gateRef.current = gate;
|
|
153
157
|
const [mode, setMode] = useState('idle'); // idle | sheet | drawing | capturing
|
|
154
158
|
const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
|
|
155
159
|
const [frozen, setFrozen] = useState(null); // while drawing: { pic, hosts } — the screen held still under the pencil
|
|
@@ -191,7 +195,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
191
195
|
if (!live) return;
|
|
192
196
|
setGate(g?.show ? g : null);
|
|
193
197
|
// This device, for "it's done, tap to see it" — only for someone SSB let in.
|
|
194
|
-
if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS, buildInfo(client)) : null)).catch(() => {});
|
|
198
|
+
if (g?.show && !g.link && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS, buildInfo(client)) : null)).catch(() => {});
|
|
195
199
|
}).catch(() => { if (live) setGate(null); });
|
|
196
200
|
return () => { live = false; };
|
|
197
201
|
}, [ssb, sessionKey]);
|
|
@@ -251,14 +255,28 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
251
255
|
Keyboard.dismiss(); setMode('idle');
|
|
252
256
|
} catch (e) { setError(e.message); } finally { setBusy(false); }
|
|
253
257
|
}, [ssb, here, config.api, chatId]);
|
|
258
|
+
// NOT LINKED (2026-10-28): an app admin who is not yet anyone in SSB sees
|
|
259
|
+
// only "Link my account" (LinkPanel); while it is open SSB is asked every
|
|
260
|
+
// few seconds, and the moment the link lands the panel is the chat.
|
|
254
261
|
useEffect(() => {
|
|
255
|
-
if (mode !== 'sheet' || !gate) return undefined;
|
|
262
|
+
if (mode !== 'sheet' || !gate?.link) return undefined;
|
|
263
|
+
const t = setInterval(() => ssb.me().then((g) => {
|
|
264
|
+
if (g?.show && !g.link) { setGate(g); setLinking(null); if (pushToken) Promise.resolve(pushToken()).then((tk) => (tk ? ssb.pushToken(tk, Platform.OS, buildInfo(client)) : null)).catch(() => {}); }
|
|
265
|
+
}).catch(() => {}), 3000);
|
|
266
|
+
return () => clearInterval(t);
|
|
267
|
+
}, [mode, gate, ssb]);
|
|
268
|
+
const startLinking = useCallback(async () => {
|
|
269
|
+
setBusy(true); setError(null);
|
|
270
|
+
try { setLinking(await ssb.link()); } catch (e) { setError(e.message); } finally { setBusy(false); }
|
|
271
|
+
}, [ssb]);
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (mode !== 'sheet' || !gate || gate.link) return undefined;
|
|
256
274
|
loadWork();
|
|
257
275
|
const t = setInterval(loadWork, 10000);
|
|
258
276
|
return () => clearInterval(t);
|
|
259
277
|
}, [mode, gate, loadWork]);
|
|
260
278
|
useEffect(() => {
|
|
261
|
-
if (!gate || !chatId) return undefined;
|
|
279
|
+
if (!gate || gate.link || !chatId) return undefined;
|
|
262
280
|
const every = talk ? 2000 : pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
|
|
263
281
|
if (!every) return undefined;
|
|
264
282
|
const t = setInterval(load, every);
|
|
@@ -272,7 +290,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
272
290
|
catch { return null; }
|
|
273
291
|
}, [ssb]);
|
|
274
292
|
useEffect(() => {
|
|
275
|
-
if (mode !== 'sheet' || !index || !gate) return undefined;
|
|
293
|
+
if (mode !== 'sheet' || !index || !gate || gate.link) return undefined;
|
|
276
294
|
const t = setInterval(loadSessions, 15000);
|
|
277
295
|
return () => clearInterval(t);
|
|
278
296
|
}, [mode, index, gate, loadSessions]);
|
|
@@ -296,6 +314,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
296
314
|
|
|
297
315
|
const openSheet = useCallback(async () => {
|
|
298
316
|
setError(null);
|
|
317
|
+
if (gateRef.current?.link) { setMode('sheet'); return; }
|
|
299
318
|
setMode('capturing');
|
|
300
319
|
// The first open asks for the sessions while the screen is taken, at most
|
|
301
320
|
// a few seconds: an answer that never comes is no index.
|
|
@@ -565,6 +584,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
565
584
|
}),
|
|
566
585
|
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);
|
|
567
586
|
}
|
|
587
|
+
if (mode === 'sheet' && gate.link) return h(LinkPanel, { gate, linking, busy, error, insets, onLink: startLinking, onClose: close });
|
|
568
588
|
if (mode === 'sheet') return h(Panel, {
|
|
569
589
|
gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
|
|
570
590
|
onDemo: typeof playDemo === 'function' ? startDemo : null,
|
|
@@ -580,6 +600,26 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
580
600
|
h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
|
|
581
601
|
}
|
|
582
602
|
|
|
603
|
+
// NOT LINKED: the only thing an app admin SSB does not know yet can do. The
|
|
604
|
+
// code is for simonsays.build/link (the button opens it on this phone; on a
|
|
605
|
+
// computer, it is typed there); SSB is asked meanwhile, and the chat replaces
|
|
606
|
+
// this once the link lands. docs/IN-APP-SSB.md "Linking".
|
|
607
|
+
function LinkPanel({ gate, linking, busy, error, insets, onLink, onClose }) {
|
|
608
|
+
const app = gate.app?.name || 'this app';
|
|
609
|
+
return h(View, { style: [s.linkSheet, { paddingTop: insets.top + 16, paddingBottom: insets.bottom + 24 }], testID: 'ssb-link' },
|
|
610
|
+
h(View, { style: s.linkHead },
|
|
611
|
+
h(Text, { style: s.title }, 'Link your SSB account'),
|
|
612
|
+
h(Pressable, { testID: 'ssb-link-close', onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close', style: s.iconBtn }, h(Text, { style: s.quietText }, '✕'))),
|
|
613
|
+
h(Text, { style: s.linkBody }, `SSB doesn't know who ${gate.person?.label || 'you'} is yet. Link this login to your SSB account once, and SSB works as you in ${app}.`),
|
|
614
|
+
linking ? h(View, { style: s.linkCodeBox },
|
|
615
|
+
h(Text, { style: s.linkCode, testID: 'ssb-link-code', selectable: true }, codeShown(linking.code)),
|
|
616
|
+
h(Pressable, { testID: 'ssb-link-open', onPress: () => Linking.openURL(linking.url).catch(() => {}), accessibilityRole: 'link', style: [s.primary, s.linkBtn] }, h(Text, { style: s.primaryText }, 'Open simonsays.build')),
|
|
617
|
+
h(Text, { style: s.linkBody }, 'Or, on a computer signed in to SSB, go to simonsays.build/link and type the code. It works for 10 minutes.'),
|
|
618
|
+
h(View, { style: s.row }, h(ActivityIndicator, { color: MUTED }), h(Text, { style: s.sub }, ' Waiting for you to confirm…')))
|
|
619
|
+
: h(Pressable, { testID: 'ssb-link-start', onPress: onLink, disabled: busy, accessibilityRole: 'button', style: [s.primary, s.linkBtn, busy && s.disabled] }, h(Text, { style: s.primaryText }, busy ? 'Getting a code…' : 'Link my account')),
|
|
620
|
+
error ? h(Text, { style: s.error }, error) : null);
|
|
621
|
+
}
|
|
622
|
+
|
|
583
623
|
// THE SHEET (2026-10-22, Michael: "a compact chat as a sheet, where you can
|
|
584
624
|
// expand fully, which shows the persistent panel at the top"). It opens HALF
|
|
585
625
|
// way: the conversation and the composer, with the app still showing above
|
|
@@ -692,8 +732,8 @@ function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, set
|
|
|
692
732
|
r.any ? h(View, { style: s.sessDot }) : null,
|
|
693
733
|
h(Text, { style: s.workTitle, numberOfLines: 2 }, r.title),
|
|
694
734
|
h(Text, { style: s.sessAgo }, r.ago)),
|
|
695
|
-
r.
|
|
696
|
-
r.
|
|
735
|
+
r.pills.length || r.news.length ? h(View, { style: s.sessChips },
|
|
736
|
+
...r.pills.map((p, i) => h(Text, { key: `p${i}`, style: [s.state, s[`sess_${p.state}`]], testID: i ? `ssb-session-state-${r.id}-${i}` : `ssb-session-state-${r.id}` }, p.word)),
|
|
697
737
|
...r.news.map((n) => h(Text, { key: n, style: s.sessNews }, n))) : null);
|
|
698
738
|
})) : null;
|
|
699
739
|
|
|
@@ -824,6 +864,12 @@ const s = StyleSheet.create({
|
|
|
824
864
|
chipText: { fontSize: 13, color: TEXT, fontWeight: '600' },
|
|
825
865
|
primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 12, backgroundColor: BLUE, minWidth: 64, alignItems: 'center', ...glow(BLUE, 10) },
|
|
826
866
|
primaryText: { color: '#fff', fontSize: 14, fontWeight: '800', letterSpacing: 0.2 },
|
|
867
|
+
linkSheet: { ...StyleSheet.absoluteFillObject, backgroundColor: BG, paddingHorizontal: 20 },
|
|
868
|
+
linkHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 },
|
|
869
|
+
linkBody: { color: MUTED, fontSize: 14, lineHeight: 20, marginTop: 12 },
|
|
870
|
+
linkCodeBox: { marginTop: 20, alignItems: 'center' },
|
|
871
|
+
linkCode: { color: TEXT, fontSize: 34, fontWeight: '800', letterSpacing: 4, fontVariant: ['tabular-nums'] },
|
|
872
|
+
linkBtn: { marginTop: 20, paddingVertical: 12, alignSelf: 'stretch' },
|
|
827
873
|
small: { paddingVertical: 6, paddingHorizontal: 12, minWidth: 0 },
|
|
828
874
|
quiet: { paddingVertical: 6, paddingHorizontal: 10 },
|
|
829
875
|
quietText: { color: MUTED, fontSize: 14, fontWeight: '600' },
|
package/src/core.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export const DEFAULT_API = 'https://simonsays.build';
|
|
6
6
|
// What this overlay is, said when it registers for pushes (clientInfo below).
|
|
7
7
|
// Kept equal to package.json by test/core.test.mjs.
|
|
8
|
-
export const OVERLAY_VERSION = '0.
|
|
8
|
+
export const OVERLAY_VERSION = '0.11.0';
|
|
9
9
|
export const HOST = 5;
|
|
10
10
|
export const TEXT = 6;
|
|
11
11
|
const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box
|
|
@@ -31,6 +31,8 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
|
|
|
31
31
|
authorization: `Bearer ${token}`,
|
|
32
32
|
'x-ssb-app': config.appId,
|
|
33
33
|
'x-ssb-backend': config.backend,
|
|
34
|
+
// This overlay can link an unlinked login (functions/index.mjs holds it to that).
|
|
35
|
+
'x-ssb-overlay': OVERLAY_VERSION,
|
|
34
36
|
...(init.headers || {}),
|
|
35
37
|
},
|
|
36
38
|
});
|
|
@@ -41,6 +43,8 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
|
|
|
41
43
|
const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
|
|
42
44
|
return {
|
|
43
45
|
me: () => call('/me'),
|
|
46
|
+
// A code to link this app login to an SSB account (functions/in-app.mjs startLink).
|
|
47
|
+
link: () => post('/link'),
|
|
44
48
|
send: (body) => post('/annotations', body),
|
|
45
49
|
chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
|
|
46
50
|
// The sessions index: the OPEN sessions of this app the person is in.
|
|
@@ -60,6 +64,9 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
|
|
|
60
64
|
};
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
// A link code as the person reads it: two groups of four.
|
|
68
|
+
export const codeShown = (code) => String(code || '').replace(/^(.{4})(.+)$/, '$1-$2');
|
|
69
|
+
|
|
63
70
|
// ---- the pencil: scribbles on the live screen --------------------------------
|
|
64
71
|
const MIN_STEP = 3; // a point closer than this to the last one adds nothing
|
|
65
72
|
|
|
@@ -321,18 +328,27 @@ export const SESSION_STATE_WORD = Object.freeze({
|
|
|
321
328
|
/** Sessions worth an index: yours, not released. */
|
|
322
329
|
export const openSessionsOf = (list) => (Array.isArray(list) ? list : []).filter((x) => x && x.mine !== false && !x.done);
|
|
323
330
|
|
|
324
|
-
/**
|
|
331
|
+
/**
|
|
332
|
+
* One row of the index: its name, where it is, and what is new in it for you.
|
|
333
|
+
* `pills`: where it is — one per app when it spans several (2026-10-28, per
|
|
334
|
+
* platform: web's Guided Review can be ready while the phone's work builds).
|
|
335
|
+
*/
|
|
325
336
|
export function sessionRow(x, now = Date.now()) {
|
|
326
337
|
const u = x?.updates || {};
|
|
327
338
|
const st = x?.status?.state || null;
|
|
328
339
|
const word = SESSION_STATE_WORD[st] || null;
|
|
329
|
-
const
|
|
340
|
+
const inOf = (s, a) => (s === 'building' && a?.total ? ` · ${a.landed} of ${a.total} in` : '');
|
|
341
|
+
const count = inOf(st, x?.status);
|
|
342
|
+
const apps = Array.isArray(x?.status?.apps) ? x.status.apps.filter((a) => SESSION_STATE_WORD[a.state]) : [];
|
|
343
|
+
const pills = apps.length > 1
|
|
344
|
+
? apps.map((a) => ({ state: a.state, word: `${a.name || 'An app'}: ${SESSION_STATE_WORD[a.state]}${inOf(a.state, a)}` }))
|
|
345
|
+
: word ? [{ state: st, word: `${word}${count}` }] : [];
|
|
330
346
|
const news = [
|
|
331
347
|
u.replied ? 'SSB replied' : null,
|
|
332
348
|
u.waiting ? `${u.waiting} draft${u.waiting === 1 ? '' : 's'} waiting` : null,
|
|
333
349
|
u.live ? `${u.live} live` : null,
|
|
334
350
|
].filter(Boolean);
|
|
335
|
-
return { id: x.id, title: x.title || x.preview || 'A session', state: st, stateWord: word ? `${word}${count}` : null, news, any: !!u.any, ago: agoOf(x.lastMessageAt, now) };
|
|
351
|
+
return { id: x.id, title: x.title || x.preview || 'A session', state: st, stateWord: word ? `${word}${count}` : null, pills, news, any: !!u.any, ago: agoOf(x.lastMessageAt, now) };
|
|
336
352
|
}
|
|
337
353
|
export function agoOf(iso, now = Date.now()) {
|
|
338
354
|
const s = (now - Date.parse(iso || '')) / 1000;
|