@simon-says-build/ssb-overlay 0.4.0 → 0.5.1

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.
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // npx @simon-says-build/ssb-overlay install <appDir> --app-id <id> [--no-install] [--json]
3
+ // (install/install.mjs; docs/IN-APP-SSB.md "Installing it in an app")
4
+ import path from 'node:path';
5
+ import { install } from './install.mjs';
6
+
7
+ const args = process.argv.slice(2);
8
+ const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : undefined; };
9
+ if (args[0] !== 'install') {
10
+ console.error('usage: ssb-overlay install <appDir> --app-id <ssb app id> [--no-install] [--json]');
11
+ process.exit(64);
12
+ }
13
+ const dir = path.resolve(args[1] && !args[1].startsWith('--') ? args[1] : '.');
14
+ try {
15
+ const r = install(dir, { appId: opt('--app-id'), npmInstall: !args.includes('--no-install') });
16
+ if (args.includes('--json')) console.log(JSON.stringify(r, null, 2));
17
+ else {
18
+ for (const d of r.done) console.log(`✓ ${d}`);
19
+ for (const t of r.todo) console.log(`! ${t}`);
20
+ console.log(`SSB settings for this app (inAppSsb): ${JSON.stringify(r.settings)}`);
21
+ }
22
+ process.exit(0);
23
+ } catch (e) {
24
+ console.error(`ssb-overlay install: ${e.message}`);
25
+ process.exit(1);
26
+ }
@@ -0,0 +1,343 @@
1
+ // INSTALL SSB IN AN APP (docs/IN-APP-SSB.md "Installing it in an app",
2
+ // 2026-10-20, the product owner: "we just need SSB to reliably be able to
3
+ // install the package").
4
+ //
5
+ // npx @simon-says-build/ssb-overlay install <appDir> --app-id <ssb app id> [--no-install] [--json]
6
+ //
7
+ // Reads the app's facts off its repo, adds the packages, writes ONE component
8
+ // (`SsbOverlay`) wired to what the app already has, wraps the root layout in
9
+ // it, and says what SSB should record about the app (`inAppSsb`). Anything it
10
+ // cannot do safely it does NOT guess: it says so by name (`todo[]`), so the
11
+ // job that ran it — or a person — finishes that part. Never throws on a fact
12
+ // it cannot find; exits non-zero only when it changed nothing it was asked to.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execFileSync } from 'node:child_process';
16
+
17
+ const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
18
+ const json = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
19
+ const exists = (p) => fs.existsSync(p);
20
+
21
+ /** The packages an install adds (react-native-view-shot through `expo install`, so its version fits the SDK). */
22
+ export const PACKAGES = ['@simon-says-build/ssb-overlay', '@simon-says-build/demo-player', 'react-native-view-shot'];
23
+
24
+ // ---- what the app is ----------------------------------------------------------
25
+ /** Every fact the install decides by, read off the repo. Pure apart from reading files. */
26
+ export function detect(dir) {
27
+ const pkg = json(path.join(dir, 'package.json')) || {};
28
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
29
+ const has = (n) => Object.prototype.hasOwnProperty.call(deps, n);
30
+ const ts = exists(path.join(dir, 'tsconfig.json'));
31
+ const layout = ['app/_layout.tsx', 'app/_layout.jsx', 'app/_layout.js', 'src/app/_layout.tsx', 'src/app/_layout.js']
32
+ .map((p) => path.join(dir, p)).find(exists) || null;
33
+
34
+ // How people sign in: the app's own backend, which SSB checks their token against.
35
+ let auth = null, backend = null, firebaseProject = null, supabaseClient = null;
36
+ if (has('@react-native-firebase/auth')) auth = 'rn-firebase';
37
+ else if (has('firebase')) auth = 'firebase-js';
38
+ else if (has('@supabase/supabase-js')) auth = 'supabase';
39
+ if (auth === 'rn-firebase' || auth === 'firebase-js') {
40
+ firebaseProject = firebaseProjectOf(dir);
41
+ backend = firebaseProject ? `firebase:${firebaseProject}` : null;
42
+ }
43
+ if (auth === 'supabase') {
44
+ supabaseClient = supabaseClientOf(dir);
45
+ backend = 'process.env.EXPO_PUBLIC_SUPABASE_URL';
46
+ }
47
+ // The claim the app's own code marks its admins with (`claims.admin`, `admin?: boolean`).
48
+ const adminClaim = auth && auth !== 'supabase' && grepSource(dir, /\bclaims\??\.admin\b|\badmin\?\s*:\s*boolean/) ? 'admin' : null;
49
+
50
+ return {
51
+ dir, ts, layout,
52
+ expo: deps.expo || null,
53
+ router: has('expo-router'),
54
+ auth, backend, firebaseProject, supabaseClient, adminClaim,
55
+ voice: has('livekit-client') && has('@livekit/react-native'),
56
+ notifications: has('expo-notifications'),
57
+ asyncStorage: has('@react-native-async-storage/async-storage'),
58
+ constants: has('expo-constants'),
59
+ // The app already plays SSB's demos itself (Impulse wraps its root in <DemoHost>): keep its own.
60
+ demoHost: !!(layout && /<DemoHost[\s>]/.test(read(layout) || '')),
61
+ installed: PACKAGES.filter(has),
62
+ componentsDir: ['components', 'src/components'].map((p) => path.join(dir, p)).find(exists) || path.join(dir, 'components'),
63
+ };
64
+ }
65
+
66
+ export function firebaseProjectOf(dir) {
67
+ for (const p of ['GoogleService-Info.plist', 'ios/GoogleService-Info.plist']) {
68
+ const t = read(path.join(dir, p));
69
+ const m = t && /<key>PROJECT_ID<\/key>\s*<string>([^<]+)<\/string>/.exec(t);
70
+ if (m) return m[1].trim();
71
+ }
72
+ for (const p of ['google-services.json', 'android/app/google-services.json']) {
73
+ const j = json(path.join(dir, p));
74
+ if (j?.project_info?.project_id) return j.project_info.project_id;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** The module that makes the app's Supabase client (`createClient(` … `export const supabase`), as an import path from `dir`. */
80
+ export function supabaseClientOf(dir) {
81
+ for (const f of sourceFiles(dir)) {
82
+ const t = read(f) || '';
83
+ if (/createClient\s*\(/.test(t) && /export\s+(const|let)\s+supabase\b/.test(t)) return f;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function sourceFiles(dir) {
89
+ const out = [];
90
+ const walk = (d, depth) => {
91
+ if (depth > 6) return;
92
+ let es = [];
93
+ try { es = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
94
+ for (const e of es) {
95
+ if (['node_modules', '.git', 'ios', 'android', '.expo', 'dist', 'build'].includes(e.name)) continue;
96
+ const p = path.join(d, e.name);
97
+ if (e.isDirectory()) walk(p, depth + 1);
98
+ else if (/\.(tsx?|jsx?|mjs)$/.test(e.name)) out.push(p);
99
+ }
100
+ };
101
+ walk(dir, 0);
102
+ return out;
103
+ }
104
+ const grepSource = (dir, re) => sourceFiles(dir).some((f) => re.test(read(f) || ''));
105
+
106
+ // ---- the component ------------------------------------------------------------
107
+ /** The relative import of `to` from the folder of `from`, without extension. */
108
+ export function importPath(from, to) {
109
+ let r = path.relative(path.dirname(from), to).replace(/\\/g, '/').replace(/\.(tsx?|jsx?|mjs)$/, '');
110
+ if (!r.startsWith('.')) r = `./${r}`;
111
+ return r;
112
+ }
113
+
114
+ /**
115
+ * `SsbOverlay`, written for THIS app: its backend and how its session is read,
116
+ * its LiveKit for 🎙 Talk, its expo-notifications for "ready to see", its
117
+ * storage. Returns { file, source }.
118
+ */
119
+ export function component(facts, { appId }) {
120
+ const ext = facts.ts ? 'tsx' : 'js';
121
+ const file = path.join(facts.componentsDir, `SsbOverlay.${ext}`);
122
+ const T = facts.ts;
123
+ const lines = [];
124
+ const imp = [];
125
+ imp.push(`import { SsbHost } from "@simon-says-build/ssb-overlay";`);
126
+ if (!facts.demoHost) imp.push(`import { DemoHost } from "@simon-says-build/demo-player";`);
127
+ imp.push(facts.demoHost ? `import { usePathname } from "expo-router";` : `import { router, usePathname } from "expo-router";`);
128
+ imp.push(`import ${T ? 'React, { type ReactNode, useEffect, useState }' : 'React, { useEffect, useState }'} from "react";`);
129
+ imp.push(`import { captureScreen } from "react-native-view-shot";`);
130
+ if (facts.asyncStorage) imp.push(`import AsyncStorage from "@react-native-async-storage/async-storage";`);
131
+ if (facts.notifications) imp.push(`import * as Notifications from "expo-notifications";`);
132
+ if (facts.notifications && facts.constants) imp.push(`import Constants from "expo-constants";`);
133
+ if (facts.voice) { imp.push(`import { AudioSession } from "@livekit/react-native";`); imp.push(`import { Room } from "livekit-client";`); }
134
+
135
+ // How the overlay reads the session — the app's own sign-in, never a new one.
136
+ let session;
137
+ if (facts.auth === 'rn-firebase') {
138
+ imp.push(`import { getAuth, onAuthStateChanged } from "@react-native-firebase/auth";`);
139
+ session = [
140
+ `// The signed-in user, from the app's own Firebase sign-in.`,
141
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
142
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(getAuth().currentUser?.uid ?? null);`,
143
+ ` useEffect(() => onAuthStateChanged(getAuth(), (u) => setUid(u?.uid ?? null)), []);`,
144
+ ` return { uid, token: async () => (getAuth().currentUser ? getAuth().currentUser${T ? '!' : ''}.getIdToken() : null) };`,
145
+ `}`,
146
+ ];
147
+ } else if (facts.auth === 'firebase-js') {
148
+ imp.push(`import { getAuth, onAuthStateChanged } from "firebase/auth";`);
149
+ session = [
150
+ `// The signed-in user, from the app's own Firebase sign-in.`,
151
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
152
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(getAuth().currentUser?.uid ?? null);`,
153
+ ` useEffect(() => onAuthStateChanged(getAuth(), (u) => setUid(u?.uid ?? null)), []);`,
154
+ ` return { uid, token: async () => (getAuth().currentUser ? getAuth().currentUser${T ? '!' : ''}.getIdToken() : null) };`,
155
+ `}`,
156
+ ];
157
+ } else if (facts.auth === 'supabase' && facts.supabaseClient) {
158
+ imp.push(`import { supabase } from "${importPath(file, facts.supabaseClient)}";`);
159
+ session = [
160
+ `// The signed-in user, from the app's own Supabase sign-in.`,
161
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
162
+ ` const [uid, setUid] = useState${T ? '<string | null>' : ''}(null);`,
163
+ ` useEffect(() => {`,
164
+ ` if (!supabase) return undefined;`,
165
+ ` supabase.auth.getSession().then(({ data }) => setUid(data.session?.user.id ?? null));`,
166
+ ` const { data } = supabase.auth.onAuthStateChange((_e, s) => setUid(s?.user.id ?? null));`,
167
+ ` return () => data.subscription.unsubscribe();`,
168
+ ` }, []);`,
169
+ ` return { uid, token: async () => (supabase ? (await supabase.auth.getSession()).data.session?.access_token ?? null : null) };`,
170
+ `}`,
171
+ ];
172
+ } else {
173
+ session = [
174
+ `// TODO(ssb): the app's sign-in could not be read off the repo. Return the`,
175
+ `// signed-in user's id and their session token from the app's own auth.`,
176
+ `function useSession()${T ? ': { uid: string | null; token: () => Promise<string | null> }' : ''} {`,
177
+ ` return { uid: null, token: async () => null };`,
178
+ `}`,
179
+ ];
180
+ }
181
+
182
+ const backend = facts.backend?.startsWith('process.env.') ? facts.backend : JSON.stringify(facts.backend || '');
183
+ lines.push(
184
+ `// SimonSays inside the app (written by \`npx @simon-says-build/ssb-overlay install\`;`,
185
+ `// docs: https://github.com/simon-says-studio/simon-says-build/blob/main/docs/IN-APP-SSB.md).`,
186
+ `//`,
187
+ `// <SsbHost> draws SSB's button ONLY for a login SimonSays lets in — checked by`,
188
+ `// SimonSays against this app's own backend with the user's own session token;`,
189
+ ...(facts.demoHost ? [`// everyone else sees nothing. The app's own demo player plays SSB's demos.`] : [
190
+ `// everyone else sees nothing. <DemoHost> plays a change's demo, and renders the`,
191
+ `// app unchanged in any build that does not carry one.`,
192
+ ]),
193
+ ...imp, ``,
194
+ `// This app in SimonSays, and where its people sign in.`,
195
+ `const SSB_APP_ID = ${JSON.stringify(appId)};`,
196
+ `const SSB_BACKEND = ${backend};`, ``,
197
+ );
198
+ if (facts.voice) {
199
+ lines.push(
200
+ `// 🎙 Talk, on the app's own LiveKit. If the app manages its audio session itself`,
201
+ `// (calls, recording), start and stop it here through that code instead.`,
202
+ `const SSB_VOICE = {`,
203
+ ` Room,`,
204
+ ` startAudio: () => AudioSession.startAudioSession(),`,
205
+ ` stopAudio: () => AudioSession.stopAudioSession(),`,
206
+ `};`, ``,
207
+ );
208
+ }
209
+ if (facts.notifications) {
210
+ lines.push(
211
+ `// The device, for "it's done, tap to see it" — asked only once SimonSays lets the`,
212
+ `// login in, and only when the app already has notification permission.`,
213
+ `async function pushToken() {`,
214
+ ` ${facts.constants ? 'const projectId = Constants.expoConfig?.extra?.eas?.projectId;' : 'const projectId = undefined;'}`,
215
+ ` if (!projectId) return null;`,
216
+ ` const { status } = await Notifications.getPermissionsAsync();`,
217
+ ` if (status !== "granted") return null;`,
218
+ ` return (await Notifications.getExpoPushTokenAsync({ projectId })).data;`,
219
+ `}`, ``,
220
+ );
221
+ }
222
+ if (!facts.demoHost) lines.push(
223
+ `// The demo player opens a step's in-app path by plain string.`,
224
+ `const DEMO_ROUTER = { push: (path${T ? ': string' : ''}) => router.push(path${T ? ' as never' : ''}) };`, ``,
225
+ );
226
+ lines.push(
227
+ ...session, ``,
228
+ `export function SsbOverlay({ children }${T ? ': { children: ReactNode }' : ''}) {`,
229
+ ` const session = useSession();`,
230
+ ` const route = usePathname();`,
231
+ ` return (`,
232
+ ` <SsbHost`,
233
+ ` appId={SSB_APP_ID}`,
234
+ ` backend={SSB_BACKEND}`,
235
+ ` getToken={session.token}`,
236
+ ` sessionKey={session.uid}`,
237
+ ` route={route}`,
238
+ ` capture={() => captureScreen({ format: "jpg", quality: 0.6, result: "data-uri" })}`,
239
+ ...(facts.asyncStorage ? [` storage={AsyncStorage}`] : []),
240
+ ...(facts.notifications ? [` pushToken={pushToken}`, ` notifications={Notifications}`] : []),
241
+ ...(facts.voice ? [` voice={SSB_VOICE}`] : []),
242
+ ` >`,
243
+ ...(facts.demoHost ? [` {children}`] : [
244
+ ` <DemoHost`,
245
+ ` router={DEMO_ROUTER}`,
246
+ ` demo={{`,
247
+ ` id: process.env.EXPO_PUBLIC_SSB_DEMO_ID,`,
248
+ ` token: process.env.EXPO_PUBLIC_SSB_DEMO_TOKEN,`,
249
+ ` api: process.env.EXPO_PUBLIC_SSB_API,`,
250
+ ` }}`,
251
+ ` >`,
252
+ ` {children}`,
253
+ ` </DemoHost>`,
254
+ ]),
255
+ ` </SsbHost>`,
256
+ ` );`,
257
+ `}`, ``,
258
+ );
259
+ return { file, source: lines.join('\n') };
260
+ }
261
+
262
+ // ---- the root layout ------------------------------------------------------------
263
+ /**
264
+ * The layout with its default export's returned JSX wrapped in <SsbOverlay>,
265
+ * or { error } when its shape is not the one this can wrap safely: an
266
+ * `export default function` whose body returns `( … )`.
267
+ */
268
+ export function wrapLayout(source, importFrom) {
269
+ if (/<SsbOverlay[\s>]/.test(source)) return { source, already: true };
270
+ const fn = /export\s+default\s+function\s+[A-Za-z0-9_]*\s*\([^)]*\)\s*(:\s*[^{]+)?\{/.exec(source);
271
+ if (!fn) return { error: 'the root layout does not `export default function` — wrap its returned JSX in <SsbOverlay> by hand' };
272
+ // The function body, by brace depth (strings and comments are rare enough in a layout's braces to accept).
273
+ const bodyStart = fn.index + fn[0].length;
274
+ let depth = 1, i = bodyStart;
275
+ for (; i < source.length && depth > 0; i++) {
276
+ if (source[i] === '{') depth++;
277
+ else if (source[i] === '}') depth--;
278
+ }
279
+ const bodyEnd = i - 1;
280
+ // The LAST `return (` at the body's own depth: the layout's main render.
281
+ let d = 0, ret = -1;
282
+ for (let j = bodyStart; j < bodyEnd; j++) {
283
+ const c = source[j];
284
+ if (c === '{') d++;
285
+ else if (c === '}') d--;
286
+ else if (d === 0 && source.startsWith('return', j) && /^return\s*\(/.test(source.slice(j, j + 20))) ret = j;
287
+ }
288
+ if (ret < 0) return { error: 'the root layout does not `return ( … )` at its top level — wrap its returned JSX in <SsbOverlay> by hand' };
289
+ const open = source.indexOf('(', ret);
290
+ let p = 1, k = open + 1;
291
+ for (; k < bodyEnd && p > 0; k++) {
292
+ if (source[k] === '(') p++;
293
+ else if (source[k] === ')') p--;
294
+ }
295
+ const close = k - 1;
296
+ const inner = source.slice(open + 1, close);
297
+ if (!inner.trim().startsWith('<')) return { error: 'the root layout returns something other than JSX — wrap it in <SsbOverlay> by hand' };
298
+ const wrapped = `${source.slice(0, open + 1)}\n <SsbOverlay>${inner.replace(/\s+$/, '')}\n </SsbOverlay>\n ${source.slice(close)}`;
299
+ // The import, after the last import line.
300
+ const imports = [...wrapped.matchAll(/^import[\s\S]*?from\s+["'][^"']+["'];?[ \t]*$/gm)];
301
+ const at = imports.length ? imports.at(-1).index + imports.at(-1)[0].length : 0;
302
+ const line = `\nimport { SsbOverlay } from "${importFrom}";`;
303
+ return { source: wrapped.slice(0, at) + line + wrapped.slice(at) };
304
+ }
305
+
306
+ // ---- what SSB records about the app ------------------------------------------------
307
+ export function settingsFor(facts) {
308
+ const s = {};
309
+ if (facts.firebaseProject) s.firebaseProjects = [facts.firebaseProject];
310
+ if (facts.adminClaim) s.adminClaims = [facts.adminClaim];
311
+ return s;
312
+ }
313
+
314
+ // ---- the install ------------------------------------------------------------------
315
+ export function install(dir, { appId, npmInstall = true, run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: 'inherit' }) } = {}) {
316
+ if (!appId) throw new Error('--app-id is required (the app\'s id in SimonSays)');
317
+ const facts = detect(dir);
318
+ const done = [], todo = [];
319
+ if (!facts.expo) todo.push('this is not an Expo app (no `expo` dependency) — the overlay is for Expo apps');
320
+ if (!facts.router) todo.push('no expo-router: pass the current route to <SsbHost route> from the app\'s own navigation');
321
+ if (!facts.backend) todo.push(facts.auth ? `could not find the ${facts.auth === 'supabase' ? 'Supabase URL' : 'Firebase project id (GoogleService-Info.plist / google-services.json)'} — set SSB_BACKEND by hand` : 'could not tell how the app signs in — fill in useSession() and SSB_BACKEND by hand');
322
+ if (facts.auth === 'supabase' && !facts.supabaseClient) todo.push('could not find the module exporting the Supabase client — fill in useSession() by hand');
323
+
324
+ if (npmInstall) {
325
+ run('npx', ['--yes', 'expo', 'install', ...PACKAGES], dir);
326
+ done.push(`added ${PACKAGES.join(', ')}`);
327
+ }
328
+
329
+ const { file, source } = component(facts, { appId });
330
+ fs.mkdirSync(path.dirname(file), { recursive: true });
331
+ fs.writeFileSync(file, source);
332
+ done.push(`wrote ${path.relative(dir, file)}`);
333
+
334
+ if (facts.layout) {
335
+ const r = wrapLayout(fs.readFileSync(facts.layout, 'utf8'), importPath(facts.layout, file));
336
+ if (r.error) todo.push(`${path.relative(dir, facts.layout)}: ${r.error}`);
337
+ else if (r.already) done.push(`${path.relative(dir, facts.layout)} already wraps <SsbOverlay>`);
338
+ else { fs.writeFileSync(facts.layout, r.source); done.push(`wrapped ${path.relative(dir, facts.layout)} in <SsbOverlay>`); }
339
+ } else todo.push('no root layout (app/_layout) — wrap the app\'s root component in <SsbOverlay> by hand');
340
+
341
+ if (facts.voice) todo.push('🎙 Talk uses @livekit/react-native\'s AudioSession; if the app manages its own audio session (calls, recording), route SSB_VOICE.startAudio/stopAudio through that code');
342
+ return { facts: { ...facts, dir: undefined }, settings: settingsFor(facts), done, todo };
343
+ }
package/package.json CHANGED
@@ -1,17 +1,32 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/simon-says-studio/simon-says-build.git",
8
+ "directory": "packages/ssb-overlay"
9
+ },
5
10
  "type": "module",
6
11
  "main": "index.js",
7
12
  "types": "index.d.ts",
8
- "files": ["index.js", "index.d.ts", "src/", "assets/", "README.md"],
13
+ "bin": {
14
+ "ssb-overlay": "install/cli.mjs"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "index.d.ts",
19
+ "src/",
20
+ "assets/",
21
+ "install/",
22
+ "README.md"
23
+ ],
9
24
  "peerDependencies": {
10
25
  "react": ">=18",
11
26
  "react-native": ">=0.76"
12
27
  },
13
28
  "scripts": {
14
- "test": "node --test test/"
29
+ "test": "node --test test/*.test.mjs"
15
30
  },
16
31
  "license": "UNLICENSED"
17
32
  }
package/src/SsbHost.js CHANGED
@@ -217,7 +217,10 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
217
217
 
218
218
  // A demo opens IN the app (or the dev client): SSB steps aside first, or the
219
219
  // full-screen panel would sit on top of what it opened.
220
- const openLink = useCallback((url) => { if (!url) return; Keyboard.dismiss(); setMode('idle'); Linking.openURL(url).catch(() => {}); }, []);
220
+ const openLink = useCallback((url, fallback) => {
221
+ if (!url) return; Keyboard.dismiss(); setMode('idle');
222
+ Linking.openURL(url).catch(() => (fallback ? Linking.openURL(fallback).catch(() => {}) : null));
223
+ }, []);
221
224
  const openRef = useRef(openSheet);
222
225
  openRef.current = openSheet;
223
226
  const close = useCallback(() => {
@@ -473,11 +476,6 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
473
476
  h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
474
477
  chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
475
478
  h(Pressable, { onPress: onClose, accessibilityRole: 'button', accessibilityLabel: 'Close SSB', style: s.iconBtn }, h(Text, { style: s.iconText }, '✕'))),
476
- talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
477
- h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
478
- h(Text, { style: s.talkText }, talkLine(talk)),
479
- h(Pressable, { onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.talkBtn }, h(Text, { style: s.talkBtnText }, talk.muted ? 'Unmute' : 'Mute')),
480
- h(Pressable, { onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.talkBtn, s.talkEnd] }, h(Text, { style: [s.talkBtnText, { color: '#fff' }] }, 'End'))) : null,
481
479
  // THE WORK, at the top: in flight, then drafts to start, then what this chat started.
482
480
  hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
483
481
  banner ? h(Pressable, {
@@ -485,8 +483,14 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
485
483
  accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
486
484
  }, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
487
485
  flying.length ? h(Text, { style: s.section, testID: 'ssb-section-flight' }, 'In flight') : null,
488
- ...flying.map((x) => h(View, { key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}` },
489
- h(Text, { style: s.workTitle, numberOfLines: 2 }, x.title),
486
+ // A row opens its job in the SimonSays app, the console when that app
487
+ // is not on the phone (functions/in-app.mjs jobLinksFor) — for those
488
+ // who can open it there; anyone else's rows stay plain.
489
+ ...flying.map((x) => h(x.links ? Pressable : View, {
490
+ key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
491
+ ...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
492
+ },
493
+ h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
490
494
  showMe(x)
491
495
  ? 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'))
492
496
  : h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
@@ -513,17 +517,24 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
513
517
  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)),
514
518
  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),
515
519
  error ? h(Text, { style: s.error }, error) : null,
520
+ // THE COMPOSER, or while talking the voice controls in its place: voice is
521
+ // the other way to say it, so Hang up sits where Send does.
516
522
  h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
517
- h(TextInput, {
523
+ talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
524
+ h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
525
+ h(Text, { style: s.talkText }, talkLine(talk))) : h(TextInput, {
518
526
  testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
519
527
  placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
520
528
  }),
521
529
  h(View, { style: s.row },
522
530
  h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
523
531
  canTalk && !talk ? h(Pressable, { testID: 'ssb-talk', onPress: onTalk, accessibilityRole: 'button', accessibilityLabel: 'Talk to SSB', style: s.chip }, h(Text, { style: s.chipText }, '🎙 Talk')) : null,
532
+ talk ? h(Pressable, { testID: 'ssb-mute', onPress: onMute, accessibilityRole: 'button', accessibilityLabel: talk.muted ? 'Unmute' : 'Mute', style: s.chip }, h(Text, { style: s.chipText }, talk.muted ? 'Unmute' : 'Mute')) : null,
524
533
  h(View, { style: { flex: 1 } }),
525
- 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] },
526
- busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
534
+ talk
535
+ ? h(Pressable, { testID: 'ssb-hangup', onPress: onEndTalk, accessibilityRole: 'button', accessibilityLabel: 'End the talk', style: [s.primary, s.hangup] }, h(Text, { style: s.primaryText }, 'Hang up'))
536
+ : 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] },
537
+ busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
527
538
  }
528
539
 
529
540
  const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
@@ -560,12 +571,12 @@ const s = StyleSheet.create({
560
571
  headLogo: { width: 40, height: 40, borderRadius: 20 },
561
572
  title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
562
573
  sub: { fontSize: 12, color: MUTED, marginTop: 2 },
563
- talkBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: 'rgba(47,210,122,0.08)', borderBottomWidth: 1, borderBottomColor: LINE },
574
+ // While talking, in the composer's place.
575
+ talkBar: { minHeight: 44, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 13, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(47,210,122,0.45)', backgroundColor: 'rgba(47,210,122,0.08)' },
564
576
  talkDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: GREEN, marginRight: 8, ...glow(GREEN, 6) },
565
- talkText: { flex: 1, fontSize: 14, fontWeight: '600', color: TEXT },
566
- talkBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED, marginLeft: 6 },
577
+ talkText: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
567
578
  talkEnd: { backgroundColor: RED, borderColor: RED },
568
- talkBtnText: { fontSize: 13, fontWeight: '700', color: TEXT },
579
+ hangup: { backgroundColor: RED, ...glow(RED, 10) },
569
580
  // The work, at the top.
570
581
  work: { flexGrow: 0, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
571
582
  section: { fontSize: 11, fontWeight: '800', letterSpacing: 1.4, textTransform: 'uppercase', color: MUTED, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 6 },