@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.10.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.stateWord || r.news.length ? h(View, { style: s.sessChips },
696
- r.stateWord ? h(Text, { style: [s.state, s[`sess_${r.state}`]], testID: `ssb-session-state-${r.id}` }, r.stateWord) : null,
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.10.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
- /** One row of the index: its name, where it is, and what is new in it for you. */
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 count = st === 'building' && x.status.total ? ` · ${x.status.landed} of ${x.status.total} in` : '';
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;