@simon-says-build/ssb-overlay 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @simon-says-build/ssb-overlay
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The SSB button inside a native app SimonSays controls (docs/IN-APP-SSB.md). A
4
+ member of the app's organization, an SSB operator, or an allowed cast login
5
+ (the super-admin by default) gets a small **SSB** button; it opens Tell SSB on
6
+ the phone: the screen's picture, a mark on it, the conversation, SSB's drafts
7
+ and **Start working**. Everyone else sees nothing.
8
+
9
+ ```tsx
10
+ import { SsbHost } from "@simon-says-build/ssb-overlay";
11
+ import { captureScreen } from "react-native-view-shot";
12
+
13
+ <SsbHost
14
+ appId={Constants.expoConfig?.extra?.ssb?.appId}
15
+ backend={process.env.EXPO_PUBLIC_SUPABASE_URL}
16
+ getToken={async () => (await supabase.auth.getSession()).data.session?.access_token}
17
+ sessionKey={session?.user.id}
18
+ route={usePathname()}
19
+ capture={() => captureScreen({ format: "jpg", quality: 0.6, result: "data-uri" })}
20
+ storage={AsyncStorage}
21
+ >
22
+ {/* the app */}
23
+ </SsbHost>
24
+ ```
25
+
26
+ Nothing secret goes in the bundle: the app's own session token is checked by
27
+ SSB against the app's backend (functions/in-app.mjs).
28
+
29
+ `npm test` runs the pure half.
package/index.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Wrap the app's root. Renders `children` unchanged unless SSB lets the
5
+ * signed-in login in (a member of the app's organization, an SSB operator, or
6
+ * an allowed cast login — the super-admin by default); then an SSB button
7
+ * floats bottom-right. docs/IN-APP-SSB.md.
8
+ */
9
+ /** The link an SSB notification opens, or null: for an app that handles its own notification taps. */
10
+ export function ssbLinkOf(response: unknown): string | null;
11
+
12
+ export function SsbHost(props: {
13
+ /** The app's SSB id (app.json `extra.ssb.appId`). Missing = no overlay. */
14
+ appId?: string;
15
+ /** Where the app signs in: its Supabase URL, or `firebase:<projectId>`. Missing = no overlay. */
16
+ backend?: string;
17
+ /** SSB's origin. Default https://simonsays.build. */
18
+ api?: string;
19
+ /** The app's CURRENT session token (Supabase access token / Firebase ID token), or null when signed out. */
20
+ getToken: () => Promise<string | null | undefined>;
21
+ /** Anything that changes when the login does (the user id): SSB is asked again. */
22
+ sessionKey?: string | null;
23
+ /** The current route (expo-router's usePathname()). */
24
+ route?: string;
25
+ /** A picture of the screen as a JPEG/PNG data URL, e.g. react-native-view-shot's captureScreen({ result: 'data-uri' }). Without it, sends carry words and marks only. */
26
+ capture?: () => Promise<string>;
27
+ /** Remembers the conversation across launches (AsyncStorage fits). */
28
+ storage?: { getItem(k: string): Promise<string | null> | string | null; setItem(k: string, v: string): unknown; removeItem?(k: string): unknown };
29
+ /** This device's Expo push token (Notifications.getExpoPushTokenAsync), so SSB can say "it's done, tap to see it" in this app. */
30
+ pushToken?: () => Promise<string | null | undefined>;
31
+ /** The app's expo-notifications module: a tapped SSB notification then opens the demo's preview in the dev client. */
32
+ notifications?: {
33
+ addNotificationResponseReceivedListener(fn: (r: unknown) => void): { remove(): void };
34
+ getLastNotificationResponseAsync?(): Promise<unknown>;
35
+ };
36
+ /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
37
+ insets?: { top?: number; bottom?: number };
38
+ children?: ReactNode;
39
+ }): ReactNode;
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { SsbHost } from './src/SsbHost.js';
2
+ export { overlayConfig, inAppApi, markOf, sendBody, ssbLinkOf } from './src/core.js';
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "files": ["index.js", "index.d.ts", "src/", "README.md"],
9
+ "peerDependencies": {
10
+ "react": ">=18",
11
+ "react-native": ">=0.76"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test test/"
15
+ },
16
+ "license": "UNLICENSED"
17
+ }
package/src/SsbHost.js ADDED
@@ -0,0 +1,417 @@
1
+ // <SsbHost>: the SSB button inside a native app (docs/IN-APP-SSB.md).
2
+ //
3
+ // Renders its children and nothing else until SSB says the signed-in login
4
+ // may use it (GET /api/in-app/me: a member of the app's organization, an SSB
5
+ // operator, or a cast login on the app's allowed list, by default the
6
+ // super-admin). Then a small SSB button floats bottom-right. Tapping it takes
7
+ // a picture of the screen as it is and opens a sheet: the conversation with
8
+ // SSB about this app (Tell SSB's, functions/annotate.mjs), the picture to
9
+ // attach, and **Mark** — tap or drag on the live screen, type beside the
10
+ // mark, send: the mark is captured ON the screen, with the field and our
11
+ // chrome hidden. SSB replies on the thread and proposes jobs; the drafts bar
12
+ // starts them. The same conversation is on the console.
13
+ //
14
+ // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
15
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import {
17
+ ActivityIndicator, Image, Keyboard, KeyboardAvoidingView, Linking, PanResponder, Platform, Pressable,
18
+ ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
19
+ } from 'react-native';
20
+ import {
21
+ demoBanner, describeHost, draftsLine, draftsOf, fieldTop, inAppApi, markOf, markPoint, markRect, namedHosts,
22
+ newReply, offerOf, overlayConfig, pollEvery, sendBody, smallestAt, ssbLinkOf,
23
+ } from './core.js';
24
+
25
+ const h = React.createElement;
26
+ const BLUE = '#2f5bea', INK = '#161a23', MUTED = '#6b7280', LINE = '#eef0f3', RED = '#e5484d';
27
+ const DEFAULT_INSETS = { top: 60, bottom: 40 };
28
+
29
+ // React keeps the fiber on the host instance (Fabric: __internalInstanceHandle).
30
+ const fiberOf = (instance) => instance?.__internalInstanceHandle ?? instance?._internalInstanceHandle ?? null;
31
+ let publicInstanceOf = (fiber) => fiber?.stateNode?.canonical?.publicInstance ?? null;
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const impl = require('react-native/Libraries/ReactNative/RendererImplementation');
35
+ if (typeof impl.getPublicInstanceFromInternalInstanceHandle === 'function') {
36
+ const fallback = publicInstanceOf;
37
+ publicInstanceOf = (fiber) => {
38
+ try { return impl.getPublicInstanceFromInternalInstanceHandle(fiber) ?? fallback(fiber); } catch { return fallback(fiber); }
39
+ };
40
+ }
41
+ } catch { /* an older renderer: the fallback above */ }
42
+ const measure = (fiber) => new Promise((resolve) => {
43
+ const inst = publicInstanceOf(fiber);
44
+ if (!inst?.measureInWindow) return resolve(null);
45
+ inst.measureInWindow((x, y, width, height) => resolve(width || height ? { left: x, top: y, width, height } : null));
46
+ });
47
+ const frames = (n = 2) => new Promise((resolve) => {
48
+ const step = (k) => (k <= 0 ? resolve() : requestAnimationFrame(() => step(k - 1)));
49
+ step(n);
50
+ });
51
+
52
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, insets, children }) {
53
+ const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
54
+ useSsbLinks(notifications);
55
+ if (!config || typeof getToken !== 'function') return children ?? null;
56
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
57
+ }
58
+
59
+ // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
60
+ // the demo's preview in the app's dev client. Heard whoever is signed in,
61
+ // and on a cold start too: the tap may be what launched the app.
62
+ function useSsbLinks(notifications) {
63
+ useEffect(() => {
64
+ if (!notifications?.addNotificationResponseReceivedListener) return undefined;
65
+ const open = (r) => { const url = ssbLinkOf(r); if (url) Linking.openURL(url).catch(() => {}); };
66
+ Promise.resolve(notifications.getLastNotificationResponseAsync?.()).then(open).catch(() => {});
67
+ const sub = notifications.addNotificationResponseReceivedListener(open);
68
+ return () => sub?.remove?.();
69
+ }, [notifications]);
70
+ }
71
+
72
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, insets, children }) {
73
+ const screen = useWindowDimensions();
74
+ const tokenRef = useRef(getToken);
75
+ tokenRef.current = getToken;
76
+ const ssb = useMemo(() => inAppApi(config, () => tokenRef.current()), [config]);
77
+ const rootRef = useRef(null);
78
+ const chromeRef = useRef(null);
79
+ const storeKey = `ssb-overlay:chat:${config.appId}`;
80
+
81
+ const [gate, setGate] = useState(null); // {show, person, app, canJob} once SSB has answered
82
+ const [mode, setMode] = useState('idle'); // idle | sheet | marking | noting | capturing
83
+ const [shot, setShot] = useState(null); // the screen as it was when the button was tapped
84
+ const [attach, setAttach] = useState(true);
85
+ const [mark, setMark] = useState(null);
86
+ const [element, setElement] = useState(null);
87
+ const [drag, setDrag] = useState(null);
88
+ const dragRef = useRef(null);
89
+ const [note, setNote] = useState('');
90
+ const [draft, setDraft] = useState('');
91
+ const [chatId, setChatId] = useState(null);
92
+ const [chat, setChat] = useState(null);
93
+ const [busy, setBusy] = useState(false);
94
+ const [error, setError] = useState(null);
95
+ const [showDrafts, setShowDrafts] = useState(false);
96
+ const [seen, setSeen] = useState(null);
97
+ const [kbTop, setKbTop] = useState(screen.height);
98
+
99
+ // ---- the gate: ask once per login --------------------------------------
100
+ useEffect(() => {
101
+ let live = true;
102
+ setGate(null);
103
+ setMode('idle');
104
+ ssb.me().then((g) => {
105
+ if (!live) return;
106
+ setGate(g?.show ? g : null);
107
+ // This device, for "it's done, tap to see it" — only for someone SSB let in.
108
+ if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS) : null)).catch(() => {});
109
+ }).catch(() => { if (live) setGate(null); });
110
+ return () => { live = false; };
111
+ }, [ssb, sessionKey]);
112
+
113
+ useEffect(() => {
114
+ if (!storage?.getItem) return;
115
+ Promise.resolve(storage.getItem(storeKey)).then((id) => { if (id) setChatId(id); }).catch(() => {});
116
+ }, [storeKey]);
117
+ const rememberChat = useCallback((id) => {
118
+ setChatId(id);
119
+ if (storage?.setItem) Promise.resolve(id ? storage.setItem(storeKey, id) : storage.removeItem?.(storeKey)).catch(() => {});
120
+ }, [storage, storeKey]);
121
+
122
+ useEffect(() => {
123
+ const show = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', (e) => setKbTop(e.endCoordinates.screenY));
124
+ const hide = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKbTop(screen.height));
125
+ return () => { show.remove(); hide.remove(); };
126
+ }, [screen.height]);
127
+
128
+ // A new screen makes a waiting mark and the picture stale (the extension's
129
+ // rule): the mark is dropped and its words move to the composer.
130
+ const lastRoute = useRef(route);
131
+ useEffect(() => {
132
+ if (lastRoute.current === route) return;
133
+ lastRoute.current = route;
134
+ setShot(null);
135
+ if (mode === 'marking' || mode === 'noting') {
136
+ if (note.trim()) setDraft((d) => (d ? `${d}\n${note}` : note));
137
+ setMark(null); setElement(null); setNote(''); setMode('sheet');
138
+ }
139
+ }, [route]);
140
+
141
+ // ---- the thread ----------------------------------------------------------
142
+ const load = useCallback(async () => {
143
+ if (!chatId) return;
144
+ try { setChat(await ssb.chat(chatId)); }
145
+ catch (e) { if (e.status === 404) { rememberChat(null); setChat(null); } }
146
+ }, [ssb, chatId, rememberChat]);
147
+
148
+ const open = mode !== 'idle';
149
+ useEffect(() => { if (open) load(); }, [open, chatId]);
150
+ useEffect(() => {
151
+ if (!gate || !chatId) return undefined;
152
+ const every = pollEvery({ open, thinking: !!chat?.thinking || chat?.demo?.status === 'preparing' });
153
+ if (!every) return undefined;
154
+ const t = setInterval(load, every);
155
+ return () => clearInterval(t);
156
+ }, [gate, chatId, open, chat?.thinking, load]);
157
+
158
+ const lastReplyId = (c) => [...(c?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming)?.id ?? null;
159
+ const [seenDemo, setSeenDemo] = useState(null);
160
+ const unread = !open && chat && (newReply(chat, seen) || (chat.demo?.status === 'ready' && chat.demo.url !== seenDemo));
161
+
162
+ // ---- open, mark, send ----------------------------------------------------
163
+ const grab = useCallback(async () => {
164
+ if (!capture) return null;
165
+ await frames(2);
166
+ try { return await capture(); } catch { return null; }
167
+ }, [capture]);
168
+
169
+ const openSheet = useCallback(async () => {
170
+ setError(null);
171
+ setMode('capturing');
172
+ const pic = await grab();
173
+ setShot(pic); setAttach(!!pic);
174
+ setMode('sheet');
175
+ }, [grab]);
176
+
177
+ const close = useCallback(() => {
178
+ Keyboard.dismiss();
179
+ setSeen(lastReplyId(chat));
180
+ setSeenDemo(chat?.demo?.url ?? null);
181
+ setMark(null); setElement(null); setNote(''); setShowDrafts(false);
182
+ setMode('idle');
183
+ }, [chat]);
184
+
185
+ const deliver = useCallback(async (body) => {
186
+ setBusy(true); setError(null);
187
+ try {
188
+ const r = await ssb.send(body);
189
+ rememberChat(r.chatId);
190
+ const c = await ssb.chat(r.chatId).catch(() => null);
191
+ if (c) setChat(c);
192
+ return true;
193
+ } catch (e) {
194
+ setError(e.message);
195
+ return false;
196
+ } finally { setBusy(false); }
197
+ }, [ssb, rememberChat]);
198
+
199
+ const sendDraft = useCallback(async () => {
200
+ const text = draft.trim();
201
+ if (!text || busy) return;
202
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, screenshot: attach ? shot : null }));
203
+ if (ok) { setDraft(''); if (attach) setAttach(false); }
204
+ }, [draft, busy, deliver, chatId, route, screen, attach, shot]);
205
+
206
+ const placed = useCallback(async (g) => {
207
+ const m = markOf(g, screen);
208
+ setMark(m); setDrag(null); setMode('noting');
209
+ // What the app has under the mark, named the way the app names it.
210
+ try {
211
+ const root = fiberOf(rootRef.current);
212
+ const skip = fiberOf(chromeRef.current);
213
+ const hosts = namedHosts(root, skip);
214
+ const cands = await Promise.all(hosts.map(async (fiber) => ({ fiber, rect: await measure(fiber) })));
215
+ const hit = smallestAt(cands, markPoint(m, screen));
216
+ setElement(hit ? describeHost(hit.fiber) : null);
217
+ } catch { setElement(null); }
218
+ }, [screen]);
219
+
220
+ const sendMark = useCallback(async () => {
221
+ const text = note.trim();
222
+ if (!text || busy || !mark) return;
223
+ Keyboard.dismiss();
224
+ setMode('capturing'); // only the mark stays on screen
225
+ const pic = await grab();
226
+ setMode('sheet');
227
+ const ok = await deliver(sendBody({ chatId, text, route, platform: Platform.OS, screen, mark, element, screenshot: pic }));
228
+ if (ok) { setMark(null); setElement(null); setNote(''); setAttach(false); }
229
+ else setDraft((d) => d || text);
230
+ }, [note, busy, mark, grab, deliver, chatId, route, screen, element]);
231
+
232
+ const decide = useCallback(async (p, action) => {
233
+ setBusy(true); setError(null);
234
+ try {
235
+ await ssb.decide(chatId, p.id, action, action === 'raise' ? { kind: p.kind, title: p.title, detail: p.detail, severity: p.severity } : {});
236
+ await load();
237
+ return true;
238
+ } catch (e) { setError(`${p.title}: ${e.message}`); return false; }
239
+ finally { setBusy(false); }
240
+ }, [ssb, chatId, load]);
241
+
242
+ const startAll = useCallback(async () => {
243
+ for (const p of draftsOf(chat).waiting) if (!(await decide(p, 'raise'))) break;
244
+ }, [chat, decide]);
245
+
246
+ const pan = useMemo(() => PanResponder.create({
247
+ onStartShouldSetPanResponder: () => true,
248
+ onMoveShouldSetPanResponder: () => true,
249
+ // The gesture lives in a ref (the release must not run inside a state updater); state only draws it.
250
+ onPanResponderGrant: (e) => { const { pageX, pageY } = e.nativeEvent; dragRef.current = { x0: pageX, y0: pageY, x1: pageX, y1: pageY }; setDrag(dragRef.current); },
251
+ onPanResponderMove: (e) => { const d = dragRef.current; if (!d) return; dragRef.current = { ...d, x1: e.nativeEvent.pageX, y1: e.nativeEvent.pageY }; setDrag(dragRef.current); },
252
+ onPanResponderRelease: (e) => { const d = dragRef.current; dragRef.current = null; if (d) placed({ ...d, x1: e.nativeEvent.pageX, y1: e.nativeEvent.pageY }); },
253
+ onPanResponderTerminate: () => { dragRef.current = null; setDrag(null); },
254
+ }), [placed]);
255
+
256
+ // ---- drawing ------------------------------------------------------------
257
+ const markView = (m, key = 'mark') => {
258
+ const r = markRect(m, screen);
259
+ return m.kind === 'box'
260
+ ? h(View, { key, pointerEvents: 'none', style: [s.box, r] })
261
+ : h(View, { key, pointerEvents: 'none', style: [s.pin, r] }, h(Text, { style: s.pinText }, '1'));
262
+ };
263
+
264
+ const chrome = (() => {
265
+ if (!gate) return null;
266
+ if (mode === 'capturing') return mark ? markView(mark) : null;
267
+ if (mode === 'idle') {
268
+ return h(Pressable, {
269
+ testID: 'ssb-button', accessibilityRole: 'button', accessibilityLabel: 'Ask SSB about this screen',
270
+ onPress: openSheet, style: ({ pressed }) => [s.fab, { bottom: insets.bottom + 16 }, pressed && s.pressed],
271
+ }, h(Text, { style: s.fabText }, 'SSB'), unread ? h(View, { style: s.dot }) : null);
272
+ }
273
+ if (mode === 'marking') {
274
+ const live = drag ? markOf(drag, screen) : null;
275
+ return h(View, { style: StyleSheet.absoluteFill, ...pan.panHandlers, testID: 'ssb-mark-surface' },
276
+ live ? markView(live, 'live') : null,
277
+ h(View, { style: [s.hint, { top: insets.top + 8 }] },
278
+ h(Text, { style: s.hintText }, 'Tap or drag on what this is about'),
279
+ h(Pressable, { onPress: () => setMode('sheet'), accessibilityRole: 'button', accessibilityLabel: 'Cancel marking' }, h(Text, { style: s.hintLink }, 'Cancel'))));
280
+ }
281
+ if (mode === 'noting' && mark) {
282
+ const top = fieldTop({ rect: markRect(mark, screen), screen, keyboardTop: kbTop, insets });
283
+ return h(View, { style: StyleSheet.absoluteFill, pointerEvents: 'box-none' },
284
+ markView(mark),
285
+ h(View, { style: [s.field, { top }] },
286
+ h(TextInput, {
287
+ testID: 'ssb-mark-note', autoFocus: true, value: note, onChangeText: setNote, placeholder: 'What should change here?',
288
+ placeholderTextColor: MUTED, style: s.fieldInput, returnKeyType: 'send', onSubmitEditing: sendMark, blurOnSubmit: false,
289
+ }),
290
+ h(Pressable, { onPress: () => { setMark(null); setNote(''); setMode('sheet'); }, accessibilityRole: 'button', accessibilityLabel: 'Drop the mark', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕')),
291
+ h(Pressable, { testID: 'ssb-mark-send', onPress: sendMark, disabled: !note.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.sendRound, (!note.trim() || busy) && s.disabled] }, h(Text, { style: s.sendRoundText }, '→'))));
292
+ }
293
+ if (mode === 'sheet') return h(Sheet, {
294
+ gate, chat, screen, insets, shot, attach, setAttach, draft, setDraft, busy, error, showDrafts, setShowDrafts,
295
+ onSend: sendDraft, onMark: () => { Keyboard.dismiss(); setMode('marking'); }, onClose: close,
296
+ onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll,
297
+ });
298
+ return null;
299
+ })();
300
+
301
+ return h(View, { ref: rootRef, style: s.root, collapsable: false },
302
+ children,
303
+ h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
304
+ }
305
+
306
+ function Sheet({ gate, chat, screen, insets, shot, attach, setAttach, draft, setDraft, busy, error, showDrafts, setShowDrafts, onSend, onMark, onClose, onNew, onDecide, onStartAll }) {
307
+ const scroll = useRef(null);
308
+ const drafts = draftsOf(chat);
309
+ const line = draftsLine(drafts);
310
+ const offer = offerOf(chat);
311
+ const banner = demoBanner(chat);
312
+ const msgs = chat?.messages || [];
313
+ return h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: s.sheetWrap, pointerEvents: 'box-none' },
314
+ h(View, { style: [s.sheet, { maxHeight: screen.height * 0.78, paddingBottom: Math.max(12, insets.bottom - 20) }], testID: 'ssb-sheet' },
315
+ h(View, { style: s.header },
316
+ h(View, { style: { flex: 1 } },
317
+ h(Text, { style: s.title }, 'Tell SSB'),
318
+ h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
319
+ chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
320
+ h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
321
+ banner ? h(Pressable, {
322
+ testID: 'ssb-demo', disabled: !banner.url, onPress: () => banner.url && Linking.openURL(banner.url).catch(() => {}),
323
+ accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
324
+ }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
325
+ line ? h(View, { style: s.bar },
326
+ h(Pressable, { onPress: () => setShowDrafts(!showDrafts), style: { flex: 1 }, accessibilityRole: 'button', accessibilityLabel: line }, h(Text, { style: s.barText }, `${line} ${showDrafts ? '▴' : '▾'}`)),
327
+ drafts.waiting.length ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, style: [s.primary, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start working')) : null) : null,
328
+ showDrafts ? h(ScrollView, { style: s.drafts },
329
+ ...drafts.waiting.map((p) => h(View, { key: p.id, style: s.draft },
330
+ h(Text, { style: s.draftTitle }, p.title),
331
+ p.detail ? h(Text, { style: s.draftBody, numberOfLines: 4 }, p.detail) : null,
332
+ h(View, { style: s.row },
333
+ h(Pressable, { onPress: () => onDecide(p, 'dismiss'), disabled: busy, style: s.quiet }, h(Text, { style: s.quietText }, 'Dismiss')),
334
+ h(Pressable, { onPress: () => onDecide(p, 'raise'), disabled: busy, style: [s.primary, busy && s.disabled] }, h(Text, { style: s.primaryText }, 'Start'))))),
335
+ ...drafts.sent.map((p) => h(View, { key: p.id, style: s.draft },
336
+ h(Text, { style: s.draftTitle }, `✓ ${p.title}`),
337
+ p.jobUrl ? h(Pressable, { onPress: () => Linking.openURL(p.jobUrl).catch(() => {}) }, h(Text, { style: s.link }, 'Watch the job →')) : null))) : null,
338
+ h(ScrollView, { ref: scroll, style: s.thread, contentContainerStyle: { paddingVertical: 8 }, onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }) },
339
+ msgs.length ? null : h(Text, { style: s.empty }, 'Say what should change on this screen, or tap Mark to point at it.'),
340
+ ...msgs.map((m) => h(View, { key: m.id, style: [s.bubble, m.role === 'user' ? s.mine : s.theirs] },
341
+ m.image ? h(Image, { source: { uri: m.image }, style: s.thumb, resizeMode: 'contain' }) : null,
342
+ h(Text, { style: m.role === 'user' ? s.mineText : s.theirsText }, m.text),
343
+ m.id === offer ? h(Pressable, { onPress: onStartAll, disabled: busy, style: [s.primary, { marginTop: 8, alignSelf: 'flex-start' }] }, h(Text, { style: s.primaryText }, 'Confirm and start work')) : null)),
344
+ chat?.thinking ? h(View, { style: [s.bubble, s.theirs, s.row] }, h(ActivityIndicator, { size: 'small', color: MUTED }), h(Text, { style: [s.theirsText, { marginLeft: 8 }] }, 'SSB is thinking…')) : null),
345
+ error ? h(Text, { style: s.error }, error) : null,
346
+ h(View, { style: s.composer },
347
+ h(TextInput, {
348
+ testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
349
+ placeholderTextColor: MUTED, style: s.input,
350
+ }),
351
+ h(View, { style: s.row },
352
+ shot ? h(Pressable, { onPress: () => setAttach(!attach), accessibilityRole: 'checkbox', accessibilityState: { checked: attach }, accessibilityLabel: 'Attach this screen', style: [s.chip, attach && s.chipOn] },
353
+ h(Image, { source: { uri: shot }, style: s.chipThumb }),
354
+ h(Text, { style: [s.chipText, attach && { color: BLUE }] }, attach ? 'Screen attached' : 'Attach screen')) : null,
355
+ h(Pressable, { testID: 'ssb-mark', onPress: onMark, accessibilityRole: 'button', accessibilityLabel: 'Mark the screen', style: s.chip }, h(Text, { style: s.chipText }, '◎ Mark')),
356
+ h(View, { style: { flex: 1 } }),
357
+ h(Pressable, { testID: 'ssb-send', onPress: onSend, disabled: !draft.trim() || busy, accessibilityRole: 'button', accessibilityLabel: 'Send to SSB', style: [s.primary, (!draft.trim() || busy) && s.disabled] },
358
+ busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
359
+ }
360
+
361
+ const shadow = { shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 18, shadowOffset: { width: 0, height: 6 }, elevation: 12 };
362
+ const s = StyleSheet.create({
363
+ root: { flex: 1 },
364
+ pressed: { opacity: 0.7 },
365
+ disabled: { opacity: 0.5 },
366
+ fab: { position: 'absolute', right: 16, width: 48, height: 48, borderRadius: 24, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', ...shadow },
367
+ fabText: { color: '#fff', fontSize: 13, fontWeight: '800', letterSpacing: 0.5 },
368
+ dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: RED, borderWidth: 2, borderColor: '#fff' },
369
+ pin: { position: 'absolute', borderRadius: 14, backgroundColor: RED, borderWidth: 2, borderColor: '#fff', alignItems: 'center', justifyContent: 'center', ...shadow },
370
+ pinText: { color: '#fff', fontSize: 13, fontWeight: '700' },
371
+ box: { position: 'absolute', borderWidth: 3, borderColor: BLUE, borderRadius: 6, backgroundColor: 'rgba(47,91,234,0.08)' },
372
+ hint: { position: 'absolute', alignSelf: 'center', flexDirection: 'row', alignItems: 'center', paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: INK, ...shadow },
373
+ hintText: { color: '#fff', fontSize: 14, fontWeight: '600', marginRight: 12 },
374
+ hintLink: { color: '#9db1ff', fontSize: 14, fontWeight: '600' },
375
+ field: { position: 'absolute', left: 12, right: 12, height: 56, flexDirection: 'row', alignItems: 'center', paddingLeft: 14, paddingRight: 6, borderRadius: 14, backgroundColor: '#fff', ...shadow },
376
+ fieldInput: { flex: 1, fontSize: 16, color: INK },
377
+ sendRound: { width: 40, height: 40, borderRadius: 20, backgroundColor: BLUE, alignItems: 'center', justifyContent: 'center', marginLeft: 4 },
378
+ sendRoundText: { color: '#fff', fontSize: 20, fontWeight: '700' },
379
+ sheetWrap: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end' },
380
+ sheet: { backgroundColor: '#fff', borderTopLeftRadius: 18, borderTopRightRadius: 18, ...shadow },
381
+ header: { flexDirection: 'row', alignItems: 'center', paddingTop: 14, paddingHorizontal: 16, paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: LINE },
382
+ title: { fontSize: 16, fontWeight: '700', color: INK },
383
+ sub: { fontSize: 12, color: MUTED, marginTop: 2 },
384
+ bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: '#fffaf0', borderBottomWidth: 1, borderBottomColor: '#f5d38a' },
385
+ barText: { fontSize: 13, fontWeight: '600', color: INK },
386
+ demo: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#f0fdf4', borderBottomWidth: 1, borderBottomColor: LINE },
387
+ demoReady: { backgroundColor: BLUE },
388
+ demoText: { flex: 1, fontSize: 13, fontWeight: '600', color: INK },
389
+ demoGo: { color: '#fff', fontSize: 14, fontWeight: '800', marginLeft: 8 },
390
+ drafts: { maxHeight: 260, borderBottomWidth: 1, borderBottomColor: LINE },
391
+ draft: { paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: LINE },
392
+ draftTitle: { fontSize: 14, fontWeight: '600', color: INK },
393
+ draftBody: { fontSize: 13, color: MUTED, marginTop: 4, lineHeight: 18 },
394
+ link: { color: BLUE, fontSize: 13, marginTop: 6 },
395
+ thread: { paddingHorizontal: 12, flexGrow: 0, minHeight: 80 },
396
+ empty: { color: MUTED, fontSize: 14, textAlign: 'center', paddingVertical: 18, paddingHorizontal: 12 },
397
+ bubble: { maxWidth: '86%', borderRadius: 14, paddingVertical: 8, paddingHorizontal: 12, marginVertical: 4 },
398
+ mine: { alignSelf: 'flex-end', backgroundColor: INK },
399
+ theirs: { alignSelf: 'flex-start', backgroundColor: '#f1f3f6' },
400
+ mineText: { color: '#fff', fontSize: 15, lineHeight: 20 },
401
+ theirsText: { color: INK, fontSize: 15, lineHeight: 20 },
402
+ thumb: { width: 140, height: 220, borderRadius: 8, marginBottom: 6, backgroundColor: '#0002' },
403
+ error: { color: RED, fontSize: 13, paddingHorizontal: 16, paddingTop: 6 },
404
+ composer: { paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: LINE },
405
+ input: { minHeight: 44, maxHeight: 120, fontSize: 16, color: INK, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 10 },
406
+ row: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8 },
407
+ chip: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 10, borderRadius: 10, borderWidth: 1, borderColor: '#d1d5db' },
408
+ chipOn: { borderColor: BLUE, backgroundColor: '#e8eeff' },
409
+ chipThumb: { width: 16, height: 28, borderRadius: 3, marginRight: 6, backgroundColor: '#0002' },
410
+ chipText: { fontSize: 13, color: INK, fontWeight: '600' },
411
+ primary: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 10, backgroundColor: BLUE, minWidth: 64, alignItems: 'center' },
412
+ primaryText: { color: '#fff', fontSize: 14, fontWeight: '700' },
413
+ quiet: { paddingVertical: 6, paddingHorizontal: 10 },
414
+ quietText: { color: MUTED, fontSize: 14, fontWeight: '600' },
415
+ iconBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
416
+ iconText: { color: MUTED, fontSize: 18 },
417
+ });
package/src/core.js ADDED
@@ -0,0 +1,204 @@
1
+ // The overlay's pure half: no React Native here, so node --test runs it.
2
+ // The server side is functions/in-app.mjs; the conversation is Tell SSB's
3
+ // (functions/annotate.mjs), so the shapes below are the extension's.
4
+
5
+ export const DEFAULT_API = 'https://simonsays.build';
6
+ export const HOST = 5;
7
+ export const TEXT = 6;
8
+ const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box
9
+
10
+ /** The app's SSB settings, or null when it is not set up (the overlay renders nothing). */
11
+ export function overlayConfig({ appId, backend, api } = {}) {
12
+ if (!appId || !backend) return null;
13
+ return { appId: String(appId), backend: String(backend).replace(/\/+$/, ''), api: String(api || DEFAULT_API).replace(/\/+$/, '') };
14
+ }
15
+
16
+ /**
17
+ * The in-app API (functions/index.mjs /api/in-app/*). Every call carries the
18
+ * app's CURRENT session token, asked for each time: the app refreshes it.
19
+ */
20
+ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
21
+ const call = async (path, init = {}) => {
22
+ const token = await getToken();
23
+ if (!token) { const e = new Error('signed out'); e.status = 401; throw e; }
24
+ const res = await fetchImpl(`${config.api}/api/in-app${path}`, {
25
+ ...init,
26
+ headers: {
27
+ 'content-type': 'application/json',
28
+ authorization: `Bearer ${token}`,
29
+ 'x-ssb-app': config.appId,
30
+ 'x-ssb-backend': config.backend,
31
+ ...(init.headers || {}),
32
+ },
33
+ });
34
+ const json = await res.json().catch(() => ({}));
35
+ if (!res.ok) { const e = new Error(json.error || `SSB answered ${res.status}`); e.status = res.status; throw e; }
36
+ return json;
37
+ };
38
+ const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
39
+ return {
40
+ me: () => call('/me'),
41
+ send: (body) => post('/annotations', body),
42
+ chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
43
+ decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
+ pushToken: (token, platform) => post('/push-token', { token, platform }),
45
+ };
46
+ }
47
+
48
+ /** A finished gesture on the screen, as a mark in fractions of the screen: a tap is a pin, a drag a box. */
49
+ export function markOf({ x0, y0, x1, y1 }, screen) {
50
+ const fx = (v) => Math.min(1, Math.max(0, v / screen.width));
51
+ const fy = (v) => Math.min(1, Math.max(0, v / screen.height));
52
+ if (Math.hypot(x1 - x0, y1 - y0) < PIN_SLOP) return { kind: 'pin', x: fx(x1), y: fy(y1) };
53
+ const left = Math.min(x0, x1), top = Math.min(y0, y1);
54
+ return { kind: 'box', x: fx(left), y: fy(top), w: fx(Math.abs(x1 - x0)), h: fy(Math.abs(y1 - y0)) };
55
+ }
56
+
57
+ /** The mark's box on the screen, in points (a pin is a small square round its point). */
58
+ export function markRect(mark, screen) {
59
+ const x = mark.x * screen.width, y = mark.y * screen.height;
60
+ if (mark.kind === 'box') return { left: x, top: y, width: mark.w * screen.width, height: mark.h * screen.height };
61
+ return { left: x - 14, top: y - 14, width: 28, height: 28 };
62
+ }
63
+
64
+ /** The point a mark is about: a pin's point, a box's middle. */
65
+ export const markPoint = (mark, screen) => mark.kind === 'box'
66
+ ? { x: (mark.x + mark.w / 2) * screen.width, y: (mark.y + mark.h / 2) * screen.height }
67
+ : { x: mark.x * screen.width, y: mark.y * screen.height };
68
+
69
+ /**
70
+ * Where the field beside the mark goes: just above the keyboard when that is
71
+ * clear of the mark, else under the status bar. `keyboardTop` is the
72
+ * keyboard's top edge in screen points (the screen height when it is down).
73
+ */
74
+ export function fieldTop({ rect, screen, keyboardTop, insets, fieldH = 56 }) {
75
+ const above = Math.min(screen.height - insets.bottom, keyboardTop) - fieldH - 12;
76
+ if (above >= rect.top + rect.height + 8) return above;
77
+ return insets.top + 8;
78
+ }
79
+
80
+ // ---- the element under a mark: React's own tree ---------------------------
81
+ const lower = (s) => String(s ?? '').trim();
82
+
83
+ function* walk(root) {
84
+ const stack = root ? [root] : [];
85
+ while (stack.length) {
86
+ const f = stack.pop();
87
+ yield f;
88
+ const kids = [];
89
+ for (let c = f.child; c; c = c.sibling) kids.push(c);
90
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
91
+ }
92
+ }
93
+
94
+ const textUnder = (f, max = 120) => {
95
+ let out = '';
96
+ for (const n of walk(f.child)) {
97
+ if (n.tag === TEXT) out += (typeof n.memoizedProps === 'string' ? n.memoizedProps : String(n.memoizedProps?.text ?? '')) + ' ';
98
+ if (out.length > max) break;
99
+ }
100
+ return out.replace(/\s+/g, ' ').trim().slice(0, max);
101
+ };
102
+
103
+ /** The host fibers worth naming: ones with a testID, a label, a role, or a press. Our own `skip` subtree is left out. */
104
+ export function namedHosts(root, skip = null, max = 400) {
105
+ const out = [];
106
+ const stack = root ? [root] : [];
107
+ while (stack.length && out.length < max) {
108
+ const f = stack.pop();
109
+ if (skip && f === skip) continue;
110
+ const p = f.memoizedProps || {};
111
+ if (f.tag === HOST && (p.testID || p.accessibilityLabel || p['aria-label'] || p.accessibilityRole || p.role || typeof p.onPress === 'function' || typeof p.onChangeText === 'function')) out.push(f);
112
+ for (let c = f.child; c; c = c.sibling) stack.push(c);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** What the page says about an element, the shape the extension sends (annotate.mjs cleanMarks `element`). */
118
+ export function describeHost(f) {
119
+ const p = f?.memoizedProps || {};
120
+ const role = p.accessibilityRole || p.role || (typeof p.onChangeText === 'function' ? 'textbox' : typeof p.onPress === 'function' ? 'button' : '');
121
+ const el = {
122
+ tag: typeof f?.type === 'string' ? f.type : '',
123
+ role: lower(role),
124
+ name: lower(p.accessibilityLabel || p['aria-label']),
125
+ text: textUnder(f),
126
+ css: p.testID ? `testID=${p.testID}` : '',
127
+ };
128
+ return Object.fromEntries(Object.entries(el).filter(([, v]) => v));
129
+ }
130
+
131
+ /** Of measured candidates [{rect, fiber}], the smallest that holds the point. */
132
+ export function smallestAt(cands, pt) {
133
+ let best = null;
134
+ for (const c of cands) {
135
+ const r = c.rect;
136
+ if (!r || pt.x < r.left || pt.x > r.left + r.width || pt.y < r.top || pt.y > r.top + r.height) continue;
137
+ if (!best || r.width * r.height < best.rect.width * best.rect.height) best = c;
138
+ }
139
+ return best;
140
+ }
141
+
142
+ // ---- the conversation -------------------------------------------------------
143
+ /** The drafts SSB has proposed: waiting, and the ones already started. */
144
+ export function draftsOf(chat) {
145
+ const ps = chat?.proposals || [];
146
+ return { waiting: ps.filter((p) => p.status === 'proposed'), sent: ps.filter((p) => p.status === 'raised') };
147
+ }
148
+
149
+ /** The drafts bar's words: "2 drafts ready · 1 started", or '' when there is nothing. */
150
+ export function draftsLine({ waiting, sent }) {
151
+ const parts = [];
152
+ if (waiting.length) parts.push(`${waiting.length} draft${waiting.length === 1 ? '' : 's'} ready`);
153
+ if (sent.length) parts.push(`${sent.length} started`);
154
+ return parts.join(' · ');
155
+ }
156
+
157
+ /** The latest SSB reply carrying "Confirm and start work", while a draft waits. */
158
+ export function offerOf(chat) {
159
+ if (!draftsOf(chat).waiting.length) return null;
160
+ const last = [...(chat?.messages || [])].reverse().find((m) => m.role !== 'user');
161
+ return last?.offerStart ? last.id : null;
162
+ }
163
+
164
+ /** How often to ask for the thread: quick while it is open or SSB is writing, never otherwise. */
165
+ export function pollEvery({ open, thinking }) {
166
+ if (open) return thinking ? 1500 : 4000;
167
+ return thinking ? 5000 : 0;
168
+ }
169
+
170
+ /** Did SSB say something new since `seenId`? (the button's dot while the sheet is closed). */
171
+ export function newReply(chat, seenId) {
172
+ const last = [...(chat?.messages || [])].reverse().find((m) => m.role !== 'user' && !m.streaming);
173
+ return !!last && last.id !== seenId;
174
+ }
175
+
176
+ /** The body of a send (POST /api/in-app/annotations). */
177
+ export function sendBody({ chatId, text, route, platform, screen, mark, element, screenshot }) {
178
+ return {
179
+ ...(chatId ? { chatId } : {}),
180
+ text: String(text || '').trim(),
181
+ url: String(route || '/'),
182
+ page: { title: `In the app (${platform || 'phone'}): ${route || '/'}`, viewport: { w: Math.round(screen.width), h: Math.round(screen.height), scrollY: 0 } },
183
+ marks: mark ? [{ ...mark, note: String(text || '').trim(), ...(element && Object.keys(element).length ? { element } : {}) }] : [],
184
+ ...(screenshot ? { screenshot } : {}),
185
+ };
186
+ }
187
+
188
+ // ---- "it's done, tap to see it" (functions/in-app-notify.mjs) ----------------
189
+ /** The link an SSB notification opens (the dev-client preview with the demo), or null for any other notification. */
190
+ export function ssbLinkOf(response) {
191
+ const data = response?.notification?.request?.content?.data;
192
+ return data?.kind === 'ssb-demo' && typeof data.url === 'string' && data.url ? data.url : null;
193
+ }
194
+
195
+ /** What the sheet says about the conversation's phone demo, or null. */
196
+ export function demoBanner(chat) {
197
+ const d = chat?.demo;
198
+ if (!d) return null;
199
+ const what = d.title || 'your change';
200
+ if (d.status === 'ready' && d.url) return { text: `Ready to see: ${what}`, url: d.url };
201
+ if (d.status === 'preparing') return { text: `Done: ${what}. Preparing a demo you can open in the app…` };
202
+ if (d.status === 'failed') return { text: `Done: ${what}. The demo couldn't be prepared${d.error ? `: ${d.error}` : '.'}` };
203
+ return null;
204
+ }