@simon-says-build/ssb-overlay 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +6 -0
- package/install/cli.mjs +26 -0
- package/install/install.mjs +343 -0
- package/package.json +19 -3
- package/src/SsbHost.js +67 -25
- package/src/core.js +18 -1
package/index.d.ts
CHANGED
|
@@ -41,5 +41,11 @@ export function SsbHost(props: {
|
|
|
41
41
|
voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
|
|
42
42
|
/** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
|
|
43
43
|
insets?: { top?: number; bottom?: number };
|
|
44
|
+
/**
|
|
45
|
+
* Which build this is: the origin the app's JS bundle was loaded from (a
|
|
46
|
+
* SimonSays preview's tunnel). Read off the bundle when omitted; Ready lists
|
|
47
|
+
* only what this build holds, and ▶ Demo plays it here.
|
|
48
|
+
*/
|
|
49
|
+
bundle?: string;
|
|
44
50
|
children?: ReactNode;
|
|
45
51
|
}): ReactNode;
|
package/install/cli.mjs
ADDED
|
@@ -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,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simon-says-build/ssb-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The SSB button inside a native app SimonSays controls: screenshot the screen, mark it, and ask SSB for a change. Renders nothing for anyone SSB does not let in.",
|
|
5
|
+
"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
|
-
"
|
|
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": {
|
|
25
|
+
"@simon-says-build/demo-player": ">=0.1.0",
|
|
10
26
|
"react": ">=18",
|
|
11
27
|
"react-native": ">=0.76"
|
|
12
28
|
},
|
|
13
29
|
"scripts": {
|
|
14
|
-
"test": "node --test test
|
|
30
|
+
"test": "node --test test/*.test.mjs"
|
|
15
31
|
},
|
|
16
32
|
"license": "UNLICENSED"
|
|
17
33
|
}
|
package/src/SsbHost.js
CHANGED
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
22
22
|
import {
|
|
23
23
|
ActivityIndicator, Image, Keyboard, KeyboardAvoidingView, Linking, PanResponder, Platform, Pressable,
|
|
24
|
-
ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
|
|
24
|
+
NativeModules, ScrollView, StyleSheet, Text, TextInput, View, useWindowDimensions,
|
|
25
25
|
} from 'react-native';
|
|
26
|
+
// The app's demo player (a peer): ▶ Demo hands it the demo to play in this
|
|
27
|
+
// build. An older player has no playDemo, and the button is not offered.
|
|
28
|
+
import { playDemo } from '@simon-says-build/demo-player';
|
|
26
29
|
import {
|
|
27
30
|
FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
|
|
28
|
-
STATE_WORD, boxItem, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
|
|
31
|
+
STATE_WORD, boxItem, bundleOriginOf, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
|
|
29
32
|
} from './core.js';
|
|
30
33
|
|
|
31
34
|
const h = React.createElement;
|
|
@@ -65,11 +68,16 @@ const frames = (n = 2) => new Promise((resolve) => {
|
|
|
65
68
|
step(n);
|
|
66
69
|
});
|
|
67
70
|
|
|
68
|
-
|
|
71
|
+
// Where this app's JS was loaded from: a preview's tunnel, or a file.
|
|
72
|
+
const scriptURL = () => {
|
|
73
|
+
try { const sc = NativeModules.SourceCode; return sc?.getConstants?.()?.scriptURL ?? sc?.scriptURL ?? ''; } catch { return ''; }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, bundle, children }) {
|
|
69
77
|
const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
|
|
70
78
|
useSsbLinks(notifications);
|
|
71
79
|
if (!config || typeof getToken !== 'function') return children ?? null;
|
|
72
|
-
return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
|
|
80
|
+
return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, bundle, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
// A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
|
|
@@ -85,7 +93,12 @@ function useSsbLinks(notifications) {
|
|
|
85
93
|
}, [notifications]);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, children }) {
|
|
96
|
+
function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, bundle, children }) {
|
|
97
|
+
// WHICH BUILD THIS IS (docs/IN-APP-SSB.md "Ready is this build"): Ready is
|
|
98
|
+
// only what this bundle holds. `bundle` overrides (the harness); else the
|
|
99
|
+
// origin the JS came from.
|
|
100
|
+
const here = useMemo(() => (bundle !== undefined ? String(bundle || '') : bundleOriginOf(scriptURL())), [bundle]);
|
|
101
|
+
const [elsewhere, setElsewhere] = useState([]);
|
|
89
102
|
const screen = useWindowDimensions();
|
|
90
103
|
const tokenRef = useRef(getToken);
|
|
91
104
|
tokenRef.current = getToken;
|
|
@@ -181,7 +194,18 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
181
194
|
const open = mode !== 'idle';
|
|
182
195
|
useEffect(() => { if (open) load(); }, [open, chatId]);
|
|
183
196
|
// The top of the panel: the app's work, asked when it opens and while it stays open.
|
|
184
|
-
const loadWork = useCallback(async () => {
|
|
197
|
+
const loadWork = useCallback(async () => {
|
|
198
|
+
try { const r = await ssb.work(here); setWork(r?.inFlight || []); setElsewhere(r?.readyElsewhere || []); } catch { /* the last list stands */ }
|
|
199
|
+
}, [ssb, here]);
|
|
200
|
+
// ▶ Demo: one demo of everything Ready in this build, played by this build.
|
|
201
|
+
const startDemo = useCallback(async () => {
|
|
202
|
+
setBusy(true); setError(null);
|
|
203
|
+
try {
|
|
204
|
+
const r = await ssb.demo(here);
|
|
205
|
+
if (!playDemo({ id: r.demoId, token: r.token, api: config.api, here: true })) throw new Error('the demo could not start');
|
|
206
|
+
Keyboard.dismiss(); setMode('idle');
|
|
207
|
+
} catch (e) { setError(e.message); } finally { setBusy(false); }
|
|
208
|
+
}, [ssb, here, config.api]);
|
|
185
209
|
useEffect(() => {
|
|
186
210
|
if (mode !== 'sheet' || !gate) return undefined;
|
|
187
211
|
loadWork();
|
|
@@ -217,7 +241,10 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
217
241
|
|
|
218
242
|
// A demo opens IN the app (or the dev client): SSB steps aside first, or the
|
|
219
243
|
// full-screen panel would sit on top of what it opened.
|
|
220
|
-
const openLink = useCallback((url
|
|
244
|
+
const openLink = useCallback((url, fallback) => {
|
|
245
|
+
if (!url) return; Keyboard.dismiss(); setMode('idle');
|
|
246
|
+
Linking.openURL(url).catch(() => (fallback ? Linking.openURL(fallback).catch(() => {}) : null));
|
|
247
|
+
}, []);
|
|
221
248
|
const openRef = useRef(openSheet);
|
|
222
249
|
openRef.current = openSheet;
|
|
223
250
|
const close = useCallback(() => {
|
|
@@ -442,7 +469,8 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
442
469
|
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);
|
|
443
470
|
}
|
|
444
471
|
if (mode === 'sheet') return h(Panel, {
|
|
445
|
-
gate, chat, work, screen, insets, draft, setDraft, busy, error,
|
|
472
|
+
gate, chat, work, screen, insets, draft, setDraft, busy, error, elsewhere,
|
|
473
|
+
onDemo: typeof playDemo === 'function' ? startDemo : null,
|
|
446
474
|
onSend: sendDraft, onDraw: () => { Keyboard.dismiss(); setMode('drawing'); }, onClose: close,
|
|
447
475
|
canTalk: !!voice?.Room, talk, onTalk: startTalk, onEndTalk: endTalk, onMute: toggleMute,
|
|
448
476
|
onNew: () => { rememberChat(null); setChat(null); }, onDecide: decide, onStartAll: startAll, onOpenLink: openLink,
|
|
@@ -455,14 +483,16 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
|
|
|
455
483
|
h(View, { ref: chromeRef, collapsable: false, pointerEvents: 'box-none', style: StyleSheet.absoluteFill }, chrome));
|
|
456
484
|
}
|
|
457
485
|
|
|
458
|
-
function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
|
|
486
|
+
function Panel({ gate, chat, work, elsewhere, onDemo, screen, insets, draft, setDraft, busy, error, onSend, onDraw, onClose, onNew, onDecide, onStartAll, onOpenLink, canTalk, talk, onTalk, onEndTalk, onMute }) {
|
|
459
487
|
const scroll = useRef(null);
|
|
460
488
|
const [open, setOpen] = useState(null); // the draft whose detail is shown
|
|
461
489
|
const { flying, waiting, sent } = workView({ inFlight: work, chat });
|
|
462
490
|
const offer = offerOf(chat);
|
|
463
491
|
const banner = demoBanner(chat);
|
|
464
492
|
const msgs = chat?.messages || [];
|
|
465
|
-
const
|
|
493
|
+
const ready = flying.filter((x) => x.state === 'ready');
|
|
494
|
+
const away = elsewhereLine(elsewhere);
|
|
495
|
+
const hasWork = flying.length || waiting.length || sent.length || banner || away;
|
|
466
496
|
const showMe = (x) => (x.state === 'ready' && banner?.url ? banner.url : null);
|
|
467
497
|
return h(View, { style: [s.panel, { paddingTop: insets.top }], testID: 'ssb-panel' },
|
|
468
498
|
h(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1 } },
|
|
@@ -473,23 +503,28 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
|
|
|
473
503
|
h(Text, { style: s.sub, numberOfLines: 1 }, `${gate.app?.name || 'this app'} · as ${gate.person?.label || 'you'}`)),
|
|
474
504
|
chat ? h(Pressable, { onPress: onNew, accessibilityRole: 'button', accessibilityLabel: 'New conversation', style: s.quiet }, h(Text, { style: s.quietText }, 'New')) : null,
|
|
475
505
|
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
506
|
// THE WORK, at the top: in flight, then drafts to start, then what this chat started.
|
|
482
507
|
hasWork ? h(ScrollView, { style: [s.work, { maxHeight: Math.round(screen.height * 0.42) }], testID: 'ssb-work', keyboardShouldPersistTaps: 'handled' },
|
|
483
508
|
banner ? h(Pressable, {
|
|
484
509
|
testID: 'ssb-demo', disabled: !banner.url, onPress: () => onOpenLink(banner.url),
|
|
485
510
|
accessibilityRole: banner.url ? 'button' : 'text', style: [s.demo, banner.url && s.demoReady],
|
|
486
511
|
}, h(Text, { style: [s.demoText, banner.url && { color: '#fff' }] }, banner.text), banner.url ? h(Text, { style: s.demoGo }, '▶ See it') : null) : null,
|
|
487
|
-
flying.length ? h(
|
|
488
|
-
|
|
489
|
-
h(
|
|
512
|
+
flying.length ? h(View, { style: s.sectionRow },
|
|
513
|
+
h(Text, { style: [s.section, { flex: 1 }], testID: 'ssb-section-flight' }, 'In flight'),
|
|
514
|
+
ready.length && onDemo ? h(Pressable, { testID: 'ssb-demo-ready', onPress: onDemo, disabled: busy, accessibilityRole: 'button', accessibilityLabel: `Demo what is ready in this build (${ready.length})`, style: [s.primary, s.small, busy && s.disabled] },
|
|
515
|
+
h(Text, { style: s.primaryText }, `▶ Demo (${ready.length})`)) : null) : null,
|
|
516
|
+
// A row opens its job in the SimonSays app, the console when that app
|
|
517
|
+
// is not on the phone (functions/in-app.mjs jobLinksFor) — for those
|
|
518
|
+
// who can open it there; anyone else's rows stay plain.
|
|
519
|
+
...flying.map((x) => h(x.links ? Pressable : View, {
|
|
520
|
+
key: `f${x.id}`, style: s.workRow, testID: `ssb-flight-${x.id}`,
|
|
521
|
+
...(x.links ? { onPress: () => onOpenLink(x.links.app, x.links.web), accessibilityRole: 'link', accessibilityLabel: `Open in SimonSays: ${x.title}` } : {}),
|
|
522
|
+
},
|
|
523
|
+
h(Text, { style: s.workTitle, numberOfLines: 2 }, x.links ? `${x.title} ›` : x.title),
|
|
490
524
|
showMe(x)
|
|
491
525
|
? 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
526
|
: h(Text, { style: [s.state, s[`state_${x.state}`]] }, STATE_WORD[x.state] || x.state))),
|
|
527
|
+
away ? h(Text, { style: [s.draftBody, { paddingHorizontal: 16, paddingVertical: 8 }], testID: 'ssb-ready-elsewhere' }, away) : null,
|
|
493
528
|
waiting.length ? h(View, { style: s.sectionRow },
|
|
494
529
|
h(Text, { style: [s.section, { flex: 1 }] }, 'Drafts'),
|
|
495
530
|
waiting.length > 1 ? h(Pressable, { testID: 'ssb-start-all', onPress: onStartAll, disabled: busy, accessibilityRole: 'button', accessibilityLabel: 'Start all', style: [s.primary, s.small, busy && s.disabled] }, h(Text, { style: s.primaryText }, `Start all (${waiting.length})`)) : null) : null,
|
|
@@ -513,17 +548,24 @@ function Panel({ gate, chat, work, screen, insets, draft, setDraft, busy, error,
|
|
|
513
548
|
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
549
|
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
550
|
error ? h(Text, { style: s.error }, error) : null,
|
|
551
|
+
// THE COMPOSER, or while talking the voice controls in its place: voice is
|
|
552
|
+
// the other way to say it, so Hang up sits where Send does.
|
|
516
553
|
h(View, { style: [s.composer, { paddingBottom: Math.max(12, insets.bottom > 60 ? 16 : insets.bottom) }] },
|
|
517
|
-
h(
|
|
554
|
+
talk ? h(View, { style: s.talkBar, testID: 'ssb-talk-bar' },
|
|
555
|
+
h(View, { style: [s.talkDot, talk.status !== 'live' && { backgroundColor: MUTED }] }),
|
|
556
|
+
h(Text, { style: s.talkText }, talkLine(talk))) : h(TextInput, {
|
|
518
557
|
testID: 'ssb-composer', value: draft, onChangeText: setDraft, multiline: true, placeholder: 'Ask SSB, or say what should change…',
|
|
519
558
|
placeholderTextColor: MUTED, keyboardAppearance: 'dark', style: s.input,
|
|
520
559
|
}),
|
|
521
560
|
h(View, { style: s.row },
|
|
522
561
|
h(Pressable, { testID: 'ssb-draw', onPress: onDraw, accessibilityRole: 'button', accessibilityLabel: 'Draw on the screen', style: s.chip }, h(Text, { style: s.chipText }, '✎ Draw')),
|
|
523
562
|
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,
|
|
563
|
+
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
564
|
h(View, { style: { flex: 1 } }),
|
|
525
|
-
|
|
526
|
-
|
|
565
|
+
talk
|
|
566
|
+
? 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'))
|
|
567
|
+
: 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] },
|
|
568
|
+
busy ? h(ActivityIndicator, { size: 'small', color: '#fff' }) : h(Text, { style: s.primaryText }, 'Send'))))));
|
|
527
569
|
}
|
|
528
570
|
|
|
529
571
|
const shadow = { shadowColor: '#000', shadowOpacity: 0.45, shadowRadius: 18, shadowOffset: { width: 0, height: 8 }, elevation: 12 };
|
|
@@ -560,12 +602,12 @@ const s = StyleSheet.create({
|
|
|
560
602
|
headLogo: { width: 40, height: 40, borderRadius: 20 },
|
|
561
603
|
title: { fontSize: 17, fontWeight: '800', color: TEXT, letterSpacing: 0.3 },
|
|
562
604
|
sub: { fontSize: 12, color: MUTED, marginTop: 2 },
|
|
563
|
-
|
|
605
|
+
// While talking, in the composer's place.
|
|
606
|
+
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
607
|
talkDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: GREEN, marginRight: 8, ...glow(GREEN, 6) },
|
|
565
|
-
talkText: { flex: 1, fontSize:
|
|
566
|
-
talkBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: LINE, backgroundColor: RAISED, marginLeft: 6 },
|
|
608
|
+
talkText: { flex: 1, fontSize: 15, fontWeight: '600', color: TEXT },
|
|
567
609
|
talkEnd: { backgroundColor: RED, borderColor: RED },
|
|
568
|
-
|
|
610
|
+
hangup: { backgroundColor: RED, ...glow(RED, 10) },
|
|
569
611
|
// The work, at the top.
|
|
570
612
|
work: { flexGrow: 0, borderBottomWidth: 1, borderBottomColor: LINE, backgroundColor: SURFACE },
|
|
571
613
|
section: { fontSize: 11, fontWeight: '800', letterSpacing: 1.4, textTransform: 'uppercase', color: MUTED, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 6 },
|
package/src/core.js
CHANGED
|
@@ -43,7 +43,10 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
|
|
|
43
43
|
decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
|
|
44
44
|
pushToken: (token, platform) => post('/push-token', { token, platform }),
|
|
45
45
|
voice: (body) => post('/voice', body),
|
|
46
|
-
|
|
46
|
+
// `bundle`: where this app's JS came from (a preview's tunnel), so SSB
|
|
47
|
+
// scopes Ready to this build; undefined = the whole list, as before.
|
|
48
|
+
work: (bundle) => call(bundle === undefined ? '/work' : `/work?bundle=${encodeURIComponent(bundle)}`),
|
|
49
|
+
demo: (bundle) => post('/demo', { bundle }),
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -308,3 +311,17 @@ export function workView({ inFlight = [], chat = null } = {}) {
|
|
|
308
311
|
const { waiting, sent } = draftsOf(chat);
|
|
309
312
|
return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
|
|
310
313
|
}
|
|
314
|
+
|
|
315
|
+
// READY IS THIS BUILD (2026-10-20, docs/IN-APP-SSB.md): the origin the app's
|
|
316
|
+
// JS bundle was loaded from — a preview's tunnel — or "" for a build that
|
|
317
|
+
// loads from a file (a store or local build, which holds no SSB preview).
|
|
318
|
+
export function bundleOriginOf(scriptURL) {
|
|
319
|
+
const m = /^(https?:\/\/[^/?#]+)/i.exec(String(scriptURL || ''));
|
|
320
|
+
return m ? m[1] : '';
|
|
321
|
+
}
|
|
322
|
+
// What the overlay says about Ready work this build does not hold.
|
|
323
|
+
export function elsewhereLine(readyElsewhere = []) {
|
|
324
|
+
const n = (readyElsewhere || []).reduce((t, x) => t + (Number(x?.count) || 0), 0);
|
|
325
|
+
if (!n) return '';
|
|
326
|
+
return `${n === 1 ? '1 more change is' : `${n} more changes are`} ready in another build — open its preview to see ${n === 1 ? 'it' : 'them'}.`;
|
|
327
|
+
}
|