@simon-says-build/ssb-overlay 0.6.0 → 0.8.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
@@ -26,4 +26,25 @@ import { captureScreen } from "react-native-view-shot";
26
26
  Nothing secret goes in the bundle: the app's own session token is checked by
27
27
  SSB against the app's backend (functions/in-app.mjs).
28
28
 
29
+ ## Help, for everyone who uses the app (0.8.0)
30
+
31
+ `<SsbHelp>` is the web Help widget's native twin (docs/HELPDESK.md): ANY
32
+ signed-in user of the app asks how it works; SSB answers, and hands it to a
33
+ person on the organization's help desk when it cannot help. It shows only
34
+ where the organization switched its help desk on for the app.
35
+
36
+ ```tsx
37
+ import { SsbHelp, SsbHelpChat, useSsbHelp } from "@simon-says-build/ssb-overlay";
38
+
39
+ <SsbHelp appId="impulse-native" backend="firebase:impulse-mode"
40
+ getToken={() => auth.currentUser?.getIdToken() ?? null} sessionKey={user?.uid}
41
+ route={usePathname()} bubble={showHelpEverywhere}>
42
+ {/* the app */}
43
+ </SsbHelp>
44
+
45
+ // Where Help belongs in the app: `useSsbHelp().show` / `.unread`, a screen
46
+ // rendering <SsbHelpChat onClose={router.back} />, and `bubble` for a Help
47
+ // button on every screen (the person's own switch, kept by the app).
48
+ ```
49
+
29
50
  `npm test` runs the pure half.
package/index.d.ts CHANGED
@@ -39,6 +39,12 @@ export function SsbHost(props: {
39
39
  * Without it there is no Talk button.
40
40
  */
41
41
  voice?: { Room: new (opts?: object) => any; startAudio?: () => Promise<void> | void; stopAudio?: () => Promise<void> | void };
42
+ /**
43
+ * What this build is, over what the overlay reads itself (0.7.0): sent with the
44
+ * push token so SSB knows what is installed. `devClient: false` makes "Ready to
45
+ * see" pushes say the news without a preview link this build can't open.
46
+ */
47
+ client?: { app?: string; build?: string; ota?: string; os?: string; devClient?: boolean };
42
48
  /** Keep the button off the home indicator / tab bar. Default {top: 60, bottom: 40}. */
43
49
  insets?: { top?: number; bottom?: number };
44
50
  /**
@@ -49,3 +55,34 @@ export function SsbHost(props: {
49
55
  bundle?: string;
50
56
  children?: ReactNode;
51
57
  }): ReactNode;
58
+
59
+ /**
60
+ * Help for the app's OWN users (0.8.0, docs/HELPDESK.md): the web Help
61
+ * widget's native twin, on the same API and desk. Wrap the app's root; it
62
+ * renders `children` unchanged unless the help API lets this login ask (any
63
+ * login the app's backend verifies, where the organization switched its help
64
+ * desk on). Put Help where it belongs with `useSsbHelp()` and `<SsbHelpChat>`;
65
+ * `bubble` floats it on every screen.
66
+ */
67
+ export function SsbHelp(props: {
68
+ appId?: string;
69
+ /** Where the app signs in: its Supabase URL, or `firebase:<projectId>`. */
70
+ backend?: string;
71
+ api?: string;
72
+ getToken: () => Promise<string | null | undefined>;
73
+ sessionKey?: string | null;
74
+ /** The current route: sent with each question, so the answer knows the screen. */
75
+ route?: string;
76
+ /** Float a Help button on every screen (the person's own switch). Default false. */
77
+ bubble?: boolean;
78
+ /** Keep the bubble above the tab bar. Default {bottom: 100}. */
79
+ insets?: { bottom?: number };
80
+ colors?: { accent?: string; background?: string; text?: string; muted?: string; bubble?: string; team?: string; line?: string };
81
+ children?: ReactNode;
82
+ }): ReactNode;
83
+
84
+ /** The Help conversation as a screen of the app's own. `onClose` shows a close button. */
85
+ export function SsbHelpChat(props: { onClose?: () => void; title?: string }): ReactNode;
86
+
87
+ /** Whether this login may ask Help, whether a reply waits unread, and open it full screen. */
88
+ export function useSsbHelp(): { show: boolean; unread: boolean; open: () => void };
package/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { SsbHost } from './src/SsbHost.js';
2
+ export { SsbHelp, SsbHelpChat, useSsbHelp } from './src/SsbHelp.js';
3
+ export { helpConfig, helpApi } from './src/help.js';
2
4
  export { overlayConfig, inAppApi, drawingMark, sendBody, ssbLinkOf } from './src/core.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simon-says-build/ssb-overlay",
3
- "version": "0.6.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.",
3
+ "version": "0.8.0",
4
+ "description": "SSB inside a native app SimonSays controls: the SSB button for the people who change the app (screenshot, mark, ask for a change), and Help for everyone who uses it.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/simon-says-studio/simon-says-build.git",
package/src/SsbHelp.js ADDED
@@ -0,0 +1,165 @@
1
+ // <SsbHelp>: Help for an app's OWN users, inside a native app — the web Help
2
+ // widget's twin (web/public/help/widget.js, docs/HELPDESK.md), on the same API
3
+ // and the same desk. Not <SsbHost>: that is SSB for the people who change the
4
+ // app; this is for everyone who uses it (docs/TWO-ELEMENTS.md).
5
+ //
6
+ // Wrap the app's root in <SsbHelp>. It renders its children and nothing else
7
+ // until the help API says this login may ask (`show`). Then:
8
+ // - `useSsbHelp()` gives the app `{ show, unread, open }`, to put Help where
9
+ // it belongs in the app (Impulse: the top of the chats list);
10
+ // - <SsbHelpChat /> is the conversation as a screen of the app's own;
11
+ // - `bubble` floats a Help button on every screen, opening the same
12
+ // conversation full screen — the app decides when (a switch the person sets).
13
+ //
14
+ // One conversation, held here, so the bubble and a screen show the same thread.
15
+ // No JSX, so it needs nothing from the app's Babel setup beyond React Native's.
16
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
17
+ import {
18
+ ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View,
19
+ } from 'react-native';
20
+ import { canAskPerson, helpApi, helpConfig, helpPollEvery, helpSendBody, helpUnread, helpWho, ticketLine } from './help.js';
21
+
22
+ const h = React.createElement;
23
+ const DEFAULT_COLORS = { accent: '#2f5bea', background: '#ffffff', text: '#1f2430', muted: '#6b7280', bubble: '#f3f4f6', team: '#fef3c7', line: '#e5e7eb' };
24
+ const DEFAULT_INSETS = { bottom: 100 };
25
+ const Ctx = createContext({ show: false, unread: false, open: () => {} });
26
+
27
+ /** `{ show, unread, open }`: whether this login may ask, a reply waiting, and open the conversation full screen. */
28
+ export const useSsbHelp = () => useContext(Ctx);
29
+
30
+ export function SsbHelp({ appId, backend, api: apiOrigin, getToken, sessionKey, route, bubble = false, insets, colors, children }) {
31
+ const config = useMemo(() => helpConfig({ appId, backend, api: apiOrigin }), [appId, backend, apiOrigin]);
32
+ const tokenRef = useRef(getToken);
33
+ tokenRef.current = getToken;
34
+ const api = useMemo(() => (config ? helpApi(config, () => tokenRef.current?.()) : null), [config]);
35
+ const [me, setMe] = useState(null);
36
+ const [thread, setThread] = useState({ messages: [], thinking: false, ticket: null });
37
+ const [screens, setScreens] = useState(0); // <SsbHelpChat>s mounted: the conversation is open
38
+ const [modal, setModal] = useState(false);
39
+ const [seen, setSeen] = useState(0);
40
+ const open = screens > 0 || modal;
41
+ const C = { ...DEFAULT_COLORS, ...(colors || {}) };
42
+
43
+ // May this login ask? Again whenever the login changes.
44
+ useEffect(() => {
45
+ let live = true;
46
+ setMe(null);
47
+ if (!api) return undefined;
48
+ api.me().then((r) => { if (live) setMe(r?.show ? r : null); }).catch(() => { if (live) setMe(null); });
49
+ return () => { live = false; };
50
+ }, [api, sessionKey]);
51
+
52
+ const read = useCallback(async () => {
53
+ if (!api || !me?.exists || !me.chatId) return;
54
+ try {
55
+ const r = await api.chat(me.chatId);
56
+ setThread({ messages: r.messages || [], thinking: !!r.thinking, ticket: r.ticket || null });
57
+ } catch { /* the next poll */ }
58
+ }, [api, me]);
59
+ useEffect(() => {
60
+ if (!me) return undefined;
61
+ read();
62
+ const t = setInterval(read, helpPollEvery({ open, thinking: thread.thinking }));
63
+ return () => clearInterval(t);
64
+ }, [me, open, thread.thinking, read]);
65
+ useEffect(() => { if (open) setSeen(thread.messages.length); }, [open, thread.messages.length]);
66
+
67
+ const send = useCallback(async (text) => {
68
+ const r = await api.send(helpSendBody({ text, route, me }));
69
+ setMe((m) => ({ ...m, chatId: r.chatId, exists: true }));
70
+ setThread((t) => ({ messages: [...t.messages, { id: `local-${Date.now()}`, role: 'user', text: String(text).trim() }], thinking: !r.ticket, ticket: r.ticket || t.ticket }));
71
+ }, [api, route, me]);
72
+ const askPerson = useCallback(async () => {
73
+ const r = await api.escalate(me.chatId);
74
+ setThread((t) => ({ ...t, ticket: r.ticket || t.ticket }));
75
+ read();
76
+ }, [api, me, read]);
77
+
78
+ const value = useMemo(() => ({
79
+ show: !!me, unread: !open && helpUnread(thread.messages, seen), open: () => setModal(true),
80
+ me, thread, send, askPerson, colors: C, mount: () => { setScreens((n) => n + 1); return () => setScreens((n) => n - 1); },
81
+ }), [me, open, thread, seen, send, askPerson, C.accent]); // eslint-disable-line react-hooks/exhaustive-deps
82
+
83
+ const bottom = (insets || DEFAULT_INSETS).bottom ?? DEFAULT_INSETS.bottom;
84
+ return h(Ctx.Provider, { value },
85
+ children,
86
+ me && bubble && !open ? h(Pressable, {
87
+ testID: 'ssb-help-bubble', accessibilityRole: 'button', accessibilityLabel: 'Help',
88
+ onPress: () => setModal(true), style: [s.bubble, { bottom, backgroundColor: C.accent }],
89
+ }, h(Text, { style: s.bubbleText }, '?'), value.unread ? h(View, { style: s.dot, testID: 'ssb-help-unread' }) : null) : null,
90
+ me ? h(Modal, { visible: modal, animationType: 'slide', presentationStyle: 'pageSheet', onRequestClose: () => setModal(false) },
91
+ h(SsbHelpChat, { onClose: () => setModal(false) })) : null,
92
+ );
93
+ }
94
+
95
+ /** The conversation, as a screen of the app's own (or the bubble's sheet). `onClose` shows a close button. */
96
+ export function SsbHelpChat({ onClose, title }) {
97
+ const ctx = useContext(Ctx);
98
+ const { me, thread, send, askPerson, colors: C = DEFAULT_COLORS, mount } = ctx;
99
+ const [draft, setDraft] = useState('');
100
+ const [sending, setSending] = useState(false);
101
+ const [error, setError] = useState('');
102
+ const scroll = useRef(null);
103
+ useEffect(() => (mount ? mount() : undefined), []); // eslint-disable-line react-hooks/exhaustive-deps
104
+ if (!me) return h(View, { style: [s.empty, { backgroundColor: C.background }] }, h(ActivityIndicator, null));
105
+ const msgs = thread.messages || [];
106
+ const line = ticketLine(thread.ticket || me.ticket);
107
+ const go = async () => {
108
+ const text = draft.trim();
109
+ if (!text || sending) return;
110
+ setSending(true); setError('');
111
+ try { await send(text); setDraft(''); } catch (e) { setError(e.message); }
112
+ setSending(false);
113
+ };
114
+ const person = async () => { setError(''); try { await askPerson(); } catch (e) { setError(e.message); } };
115
+ const first = (me.requester?.label || '').split(/[ @]/)[0];
116
+ return h(KeyboardAvoidingView, { style: [s.root, { backgroundColor: C.background }], behavior: Platform.OS === 'ios' ? 'padding' : undefined, testID: 'ssb-help-chat' },
117
+ h(View, { style: [s.header, { borderBottomColor: C.line }] },
118
+ h(Text, { style: [s.title, { color: C.text }] }, title || `Help${me.app?.name ? ` · ${me.app.name}` : ''}`),
119
+ onClose ? h(Pressable, { onPress: onClose, accessibilityLabel: 'Close', testID: 'ssb-help-close', hitSlop: 12 }, h(Text, { style: [s.close, { color: C.muted }] }, '✕')) : null),
120
+ line ? h(Text, { style: [s.ticket, { color: C.accent, borderBottomColor: C.line }], testID: 'ssb-help-ticket' }, line) : null,
121
+ h(ScrollView, { ref: scroll, style: s.body, contentContainerStyle: s.bodyIn, onContentSizeChange: () => scroll.current?.scrollToEnd?.({ animated: true }) },
122
+ msgs.length ? null : h(Text, { style: [s.msg, s.left, { backgroundColor: C.bubble, color: C.text }] },
123
+ `Hi${first ? ` ${first}` : ''} — ask anything about ${me.app?.name || 'the app'}. If I can't help, I'll get you a person.`),
124
+ ...msgs.map((m) => (m.role === 'event'
125
+ ? h(Text, { key: m.id, style: [s.event, { color: C.muted }] }, m.text)
126
+ : h(View, { key: m.id, style: m.role === 'user' ? s.rightWrap : s.leftWrap },
127
+ h(Text, { style: [s.who, { color: C.muted }] }, helpWho(m)),
128
+ h(Text, { style: [s.msg, m.role === 'user' ? { backgroundColor: C.accent, color: '#fff' } : { backgroundColor: m.role === 'agent' ? C.team : C.bubble, color: C.text }] }, m.text)))),
129
+ thread.thinking ? h(Text, { style: [s.event, { color: C.muted, fontStyle: 'italic' }], testID: 'ssb-help-thinking' }, 'Thinking…') : null),
130
+ h(View, { style: [s.footer, { borderTopColor: C.line }] },
131
+ error ? h(Text, { style: s.error }, error) : null,
132
+ h(View, { style: s.row },
133
+ h(TextInput, { value: draft, onChangeText: setDraft, placeholder: 'How do I…', placeholderTextColor: C.muted, multiline: true, testID: 'ssb-help-input', style: [s.input, { color: C.text, borderColor: C.line }] }),
134
+ h(Pressable, { onPress: go, disabled: sending || !draft.trim(), testID: 'ssb-help-send', style: [s.send, { backgroundColor: C.accent }, (sending || !draft.trim()) && s.disabled] },
135
+ h(Text, { style: s.sendText }, sending ? '…' : 'Send'))),
136
+ canAskPerson(msgs, thread.ticket || me.ticket) ? h(Pressable, { onPress: person, testID: 'ssb-help-person' }, h(Text, { style: [s.person, { color: C.accent }] }, 'Talk to a person')) : null));
137
+ }
138
+
139
+ const s = StyleSheet.create({
140
+ root: { flex: 1 },
141
+ empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
142
+ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1 },
143
+ title: { flex: 1, fontSize: 17, fontWeight: '700' },
144
+ close: { fontSize: 20 },
145
+ ticket: { paddingHorizontal: 16, paddingVertical: 8, fontSize: 13, borderBottomWidth: 1 },
146
+ body: { flex: 1 },
147
+ bodyIn: { padding: 16, gap: 8 },
148
+ leftWrap: { alignItems: 'flex-start' },
149
+ rightWrap: { alignItems: 'flex-end' },
150
+ left: { alignSelf: 'flex-start' },
151
+ who: { fontSize: 11, marginBottom: 2 },
152
+ msg: { maxWidth: '85%', paddingVertical: 8, paddingHorizontal: 12, borderRadius: 14, fontSize: 15, lineHeight: 21, overflow: 'hidden' },
153
+ event: { fontSize: 12, textAlign: 'center', marginVertical: 4 },
154
+ footer: { borderTopWidth: 1, padding: 10, gap: 6 },
155
+ row: { flexDirection: 'row', alignItems: 'flex-end', gap: 8 },
156
+ input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15 },
157
+ send: { paddingHorizontal: 16, height: 44, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
158
+ sendText: { color: '#fff', fontWeight: '700', fontSize: 15 },
159
+ disabled: { opacity: 0.5 },
160
+ person: { fontSize: 13, fontWeight: '600' },
161
+ error: { color: '#b91c1c', fontSize: 13 },
162
+ bubble: { position: 'absolute', right: 16, width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 10, shadowOffset: { width: 0, height: 4 }, elevation: 6 },
163
+ bubbleText: { color: '#fff', fontSize: 24, fontWeight: '800' },
164
+ dot: { position: 'absolute', top: 2, right: 2, width: 12, height: 12, borderRadius: 6, backgroundColor: '#ef4444', borderWidth: 2, borderColor: '#fff' },
165
+ });
package/src/SsbHost.js CHANGED
@@ -25,10 +25,10 @@ import {
25
25
  } from 'react-native';
26
26
  // The app's demo player (a peer): ▶ Demo hands it the demo to play in this
27
27
  // build. An older player has no playDemo, and the button is not offered.
28
- import { playDemo } from '@simon-says-build/demo-player';
28
+ import { playDemo, PLAYER_VERSION } from '@simon-says-build/demo-player';
29
29
  import {
30
30
  FAB, addPoint, clampFab, demoBanner, fabHome, isDrag, describeHost, draftsOf, fieldTop, inAppApi, namedHosts, newReply, offerOf,
31
- STATE_WORD, boxItem, bundleOriginOf, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
31
+ STATE_WORD, boxItem, bundleOriginOf, clientInfo, elsewhereLine, workView, drawingBox, drawingMark, drawingPoint, itemRect, overlayConfig, pollEvery, SNAPSHOT_TOPIC, snapshotOf, talkLine, segmentsOf, sendBody, smallestAt, ssbLinkOf,
32
32
  } from './core.js';
33
33
 
34
34
  const h = React.createElement;
@@ -73,11 +73,26 @@ const scriptURL = () => {
73
73
  try { const sc = NativeModules.SourceCode; return sc?.getConstants?.()?.scriptURL ?? sc?.scriptURL ?? ''; } catch { return ''; }
74
74
  };
75
75
 
76
- export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, bundle, children }) {
76
+ // What this build is, for the push registration (core.js clientInfo). Each
77
+ // read is optional: an app without expo-application or a dev launcher still
78
+ // registers, it just says less.
79
+ function buildInfo(override) {
80
+ const mods = globalThis.expo?.modules || {};
81
+ const appMod = mods.ExpoApplication || NativeModules.ExpoApplication || {};
82
+ const updates = mods.ExpoUpdates || NativeModules.ExpoUpdates || {};
83
+ const hasLauncher = !!(mods.ExpoDevLauncher || mods.ExpoDevLauncherInternal || NativeModules.EXDevLauncher || NativeModules.EXDevLauncherInternal);
84
+ return clientInfo({
85
+ player: PLAYER_VERSION, app: appMod.nativeApplicationVersion, build: appMod.nativeBuildVersion,
86
+ os: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
87
+ runtime: typeof updates.runtimeVersion === 'string' ? updates.runtimeVersion : undefined, hasLauncher, bundleOrigin: bundleOriginOf(scriptURL()), override,
88
+ });
89
+ }
90
+
91
+ export function SsbHost({ appId, backend, api, getToken, sessionKey, route, capture, storage, pushToken, notifications, voice, insets, bundle, client, children }) {
77
92
  const config = useMemo(() => overlayConfig({ appId, backend, api }), [appId, backend, api]);
78
93
  useSsbLinks(notifications);
79
94
  if (!config || typeof getToken !== 'function') return children ?? null;
80
- return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, bundle, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
95
+ return h(Host, { config, getToken, sessionKey, route, capture, storage, pushToken, voice, bundle, client, insets: { ...DEFAULT_INSETS, ...(insets || {}) } }, children);
81
96
  }
82
97
 
83
98
  // A tapped "Ready to see" notification (functions/in-app-notify.mjs) opens
@@ -93,7 +108,7 @@ function useSsbLinks(notifications) {
93
108
  }, [notifications]);
94
109
  }
95
110
 
96
- function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, bundle, children }) {
111
+ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken, voice, insets, bundle, client, children }) {
97
112
  // WHICH BUILD THIS IS (docs/IN-APP-SSB.md "Ready is this build"): Ready is
98
113
  // only what this bundle holds. `bundle` overrides (the harness); else the
99
114
  // origin the JS came from.
@@ -147,7 +162,7 @@ function Host({ config, getToken, sessionKey, route, capture, storage, pushToken
147
162
  if (!live) return;
148
163
  setGate(g?.show ? g : null);
149
164
  // This device, for "it's done, tap to see it" — only for someone SSB let in.
150
- if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS) : null)).catch(() => {});
165
+ if (g?.show && pushToken) Promise.resolve(pushToken()).then((t) => (t ? ssb.pushToken(t, Platform.OS, buildInfo(client)) : null)).catch(() => {});
151
166
  }).catch(() => { if (live) setGate(null); });
152
167
  return () => { live = false; };
153
168
  }, [ssb, sessionKey]);
package/src/core.js CHANGED
@@ -3,6 +3,9 @@
3
3
  // (functions/annotate.mjs), so the shapes below are the extension's.
4
4
 
5
5
  export const DEFAULT_API = 'https://simonsays.build';
6
+ // What this overlay is, said when it registers for pushes (clientInfo below).
7
+ // Kept equal to package.json by test/core.test.mjs.
8
+ export const OVERLAY_VERSION = '0.8.0';
6
9
  export const HOST = 5;
7
10
  export const TEXT = 6;
8
11
  const PIN_SLOP = 12; // a drag shorter than this is a tap: a pin, not a box
@@ -41,7 +44,8 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
41
44
  send: (body) => post('/annotations', body),
42
45
  chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
43
46
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
- pushToken: (token, platform) => post('/push-token', { token, platform }),
47
+ // `client`: what this build is (clientInfo), so SSB knows what is installed.
48
+ pushToken: (token, platform, client) => post('/push-token', { token, platform, ...(client ? { client } : {}) }),
45
49
  voice: (body) => post('/voice', body),
46
50
  // `bundle`: where this app's JS came from (a preview's tunnel), so SSB
47
51
  // scopes Ready to this build; undefined = the whole list, as before.
@@ -325,3 +329,16 @@ export function elsewhereLine(readyElsewhere = []) {
325
329
  if (!n) return '';
326
330
  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
331
  }
332
+
333
+ // WHAT IS INSTALLED (0.7.0, docs/IN-APP-SSB.md "What is installed"): said with
334
+ // the push token, so SSB knows which builds can open a "Ready to see" preview.
335
+ // `devClient`: a dev launcher is in the binary → true; none, and the JS came
336
+ // from a file (a store or release build) → false; otherwise unknown (absent),
337
+ // and SSB sends the link as before. The host's `client` prop outranks all of it.
338
+ export function clientInfo({ player, app, build, os, runtime, hasLauncher, bundleOrigin, override } = {}) {
339
+ const devClient = hasLauncher ? true : bundleOrigin === '' ? false : undefined;
340
+ // `runtime`: expo-updates' runtimeVersion, which under the fingerprint policy
341
+ // IS the native fingerprint a preview is gated on (docs/DEV-CLIENTS.md).
342
+ const out = { overlay: OVERLAY_VERSION, player, app, build, os, runtime, devClient, ...(override || {}) };
343
+ return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== undefined && v !== null && v !== ''));
344
+ }
package/src/help.js ADDED
@@ -0,0 +1,76 @@
1
+ // HELP for an app's own users, the pure half (no React Native here, so
2
+ // node --test runs it). The web Help widget's native twin
3
+ // (web/public/help/widget.js, docs/HELPDESK.md): the same API, the same
4
+ // conversation, the same desk. The server is functions/help-widget.mjs: ANY
5
+ // login the app's backend verifies is let in (this is a customer asking for
6
+ // help, not someone changing the app), only where the app's organization
7
+ // switched its help desk on. A native app sends no Origin, so no CORS applies.
8
+
9
+ import { DEFAULT_API } from './core.js';
10
+
11
+ /** The Help settings, or null when they are not set (Help renders nothing). */
12
+ export function helpConfig({ appId, backend, api } = {}) {
13
+ if (!appId || !backend) return null;
14
+ return { appId: String(appId), backend: String(backend).replace(/\/+$/, ''), api: String(api || DEFAULT_API).replace(/\/+$/, '') };
15
+ }
16
+
17
+ /** The help API (/api/help/:appId/*). Every call carries the app's CURRENT session token. */
18
+ export function helpApi(config, getToken, fetchImpl = globalThis.fetch) {
19
+ const base = `${config.api}/api/help/${encodeURIComponent(config.appId)}`;
20
+ const call = async (path, init = {}) => {
21
+ const token = await getToken();
22
+ if (!token) { const e = new Error('signed out'); e.status = 401; throw e; }
23
+ const res = await fetchImpl(`${base}${path}`, {
24
+ ...init,
25
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ssb-backend': config.backend, ...(init.headers || {}) },
26
+ });
27
+ const json = await res.json().catch(() => ({}));
28
+ if (!res.ok) { const e = new Error(json.error || `Help answered ${res.status}`); e.status = res.status; throw e; }
29
+ return json;
30
+ };
31
+ const post = (path, body) => call(path, { method: 'POST', body: JSON.stringify(body || {}) });
32
+ return {
33
+ me: () => call('/me'),
34
+ send: (body) => post('/messages', body),
35
+ chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
36
+ escalate: (chatId) => post('/escalate', { chatId }),
37
+ };
38
+ }
39
+
40
+ /** What a message to Help carries: the words, and where in the app they were. */
41
+ export function helpSendBody({ text, route, me }) {
42
+ return {
43
+ text: String(text || '').trim().slice(0, 6000),
44
+ ...(route ? { url: `app:${String(route).slice(0, 400)}` } : {}),
45
+ ...(me?.exists && me.chatId ? { chatId: me.chatId } : {}),
46
+ };
47
+ }
48
+
49
+ /** How often to read the thread: often while SSB thinks, less while open, rarely closed. */
50
+ export function helpPollEvery({ open, thinking }) {
51
+ if (open) return thinking ? 2500 : 6000;
52
+ return 30000;
53
+ }
54
+
55
+ /** Who says a message, as the panel labels it. */
56
+ export function helpWho(m) {
57
+ if (m.role === 'user') return 'You';
58
+ if (m.role === 'agent') return `${m.byLabel || 'Support'} · team`;
59
+ return 'Assistant';
60
+ }
61
+
62
+ /** The line under the header while a person has the conversation, or ''. */
63
+ export function ticketLine(ticket) {
64
+ if (!ticket) return '';
65
+ if (ticket.status === 'solved') return 'Solved — ask anything new.';
66
+ return `A person${ticket.orgName ? ` from ${ticket.orgName}` : ''} has this${ticket.assignee ? ` · ${ticket.assignee}` : ''}`;
67
+ }
68
+
69
+ /** "Talk to a person" is offered once there is a conversation and nobody has it. */
70
+ export const canAskPerson = (messages, ticket) => (messages || []).length > 0 && (!ticket || ticket.status === 'solved');
71
+
72
+ /** A reply landed while the panel was closed (the bubble's dot). */
73
+ export function helpUnread(messages, seen) {
74
+ const list = messages || [];
75
+ return list.length > seen && list[list.length - 1]?.role !== 'user';
76
+ }