@graineai/inapp-react-native 0.27.5 → 0.30.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/INTEGRATION.md CHANGED
@@ -558,6 +558,53 @@ your funnel.
558
558
 
559
559
  ---
560
560
 
561
+ ## Captions and hanging up
562
+
563
+ `GraineVoiceLauncher`'s render prop gives you the whole transcript and the CC
564
+ switch, so a caption box is a `map` and nothing else. **0.28.0.**
565
+
566
+ ```tsx
567
+ <GraineVoiceLauncher webView={WebView} active={open} autoStart>
568
+ {(api) => (
569
+ <>
570
+ {api.captionsOn && api.captions.map((c, i) => (
571
+ <Text key={i} style={{ opacity: c.live ? 0.7 : 1 }}>
572
+ {c.role === "agent" ? "Ring" : "You"}: {c.text}
573
+ </Text>
574
+ ))}
575
+ <Button title="CC" onPress={() => api.setCaptionsOn(!api.captionsOn)} />
576
+ <Button title="End" onPress={api.end} />
577
+ </>
578
+ )}
579
+ </GraineVoiceLauncher>
580
+ ```
581
+
582
+ | On `api` | |
583
+ |---|---|
584
+ | `captions` | Every turn so far, oldest first, capped at `CAPTION_HISTORY` (12) |
585
+ | `caption` | The turn being said now, or the last one. `null` before any |
586
+ | `captionsOn` / `setCaptionsOn` | The CC switch. Defaults **on** |
587
+ | `clearCaptions()` | Forget the transcript without ending the call |
588
+ | `end()` | Hang up |
589
+
590
+ A turn arrives many times as its words come in — each with fuller text and
591
+ `live: true` — then once more settled with `live: false`. The launcher folds
592
+ those into one entry, so `captions` holds turns, not keystrokes. Render `live`
593
+ ones as provisional if you want; they are the same line about to be finished.
594
+
595
+ The transcript is **cleared when a call starts and when you set `active={false}`**.
596
+ A call's words outliving its call is a privacy question, not a feature — if you
597
+ need them afterwards, copy them in `onEnded`.
598
+
599
+ `onCaption` still fires for every line and is unchanged; it is now a convenience
600
+ on top of `captions` rather than the only way to see them.
601
+
602
+ **Hanging up** happens on `api.end()` and again on `active={false}` — both post
603
+ the same idempotent stop frame, so a host that does one, the other, or both
604
+ ends exactly one call. Before 0.27.6 the deactivate path was conditional on the
605
+ launcher's own idea of whether a call was running, which could close the UI and
606
+ leave the call up.
607
+
561
608
  ## Tap capture when your screens sit above the provider
562
609
 
563
610
  `GraineProvider` wraps its children in `GraineTouchCapture`, so an app whose
@@ -1077,7 +1124,7 @@ told to ignore).
1077
1124
  | `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
1078
1125
  | `onError` | `(message) => void` | Voice could not be set up at all — key rejected, agent not enabled for apps, no network. Without this the launcher renders nothing and you cannot tell why. |
1079
1126
  | `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
1080
- | `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5) |
1127
+ | `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5; 0.27.6 makes that hangup unconditional) |
1081
1128
 
1082
1129
  **The launcher owns the connection.** It asks for the microphone, resolves the
1083
1130
  session, holds the socket and relays your screen, actions and events onto it. So
@@ -20,6 +20,7 @@ export interface FrictionScreenRules {
20
20
  repeatedErrors?: RepeatedErrorsRule | false;
21
21
  backAndForth?: BackAndForthRule | false;
22
22
  rageTaps?: RageTapsRule | false;
23
+ incompleteSubmit?: IncompleteSubmitRule | false;
23
24
  screenError?: boolean;
24
25
  message?: string;
25
26
  }
@@ -34,10 +35,13 @@ export interface FrictionConfig extends FrictionScreenRules {
34
35
  nudge?: FrictionNudge;
35
36
  screens?: Record<string, FrictionScreenRules>;
36
37
  }
38
+ export interface IncompleteSubmitRule {
39
+ taps: string[];
40
+ }
37
41
  export declare const DEFAULT_FRICTION: Readonly<Required<Omit<FrictionConfig, "message" | "ignore">>>;
38
42
  export declare function resolveFriction(...layers: Array<FrictionConfig | null | undefined>): Required<FrictionConfig>;
39
43
  export declare function rulesFor(config: Required<FrictionConfig>, screenId: string | null | undefined): Required<FrictionScreenRules>;
40
- export type FrictionKind = "stalled_on_step" | "repeated_field_error" | "looping_between_screens" | "rage_taps" | "screen_error";
44
+ export type FrictionKind = "stalled_on_step" | "repeated_field_error" | "looping_between_screens" | "rage_taps" | "incomplete_submit" | "screen_error";
41
45
  export interface FrictionSignal {
42
46
  name: FrictionKind;
43
47
  screen: string;
package/dist/friction.js CHANGED
@@ -3,6 +3,9 @@ const DEFAULT_RULES = {
3
3
  repeatedErrors: { count: 2, windowMs: 60000 },
4
4
  backAndForth: { visits: 3, windowMs: 45000 },
5
5
  rageTaps: { taps: 4, windowMs: 1500 },
6
+ incompleteSubmit: {
7
+ taps: ["submit", "next", "continue", "save", "proceed", "apply", "confirm", "done", "pay", "verify"],
8
+ },
6
9
  };
7
10
  export const DEFAULT_FRICTION = Object.freeze({
8
11
  enabled: true,
@@ -25,8 +28,19 @@ function rule(raw, shape) {
25
28
  return undefined;
26
29
  const out = {};
27
30
  for (const [k, fallback] of Object.entries(shape)) {
28
- const v = num(raw[k]);
29
- out[k] = v ?? fallback;
31
+ const given = raw[k];
32
+ if (typeof fallback === "number") {
33
+ out[k] = num(given) ?? fallback;
34
+ }
35
+ else if (Array.isArray(fallback)) {
36
+ out[k] = Array.isArray(given) && given.length ? given.map((v) => String(v)) : fallback;
37
+ }
38
+ else if (typeof fallback === "boolean") {
39
+ out[k] = typeof given === "boolean" ? given : fallback;
40
+ }
41
+ else {
42
+ out[k] = given ?? fallback;
43
+ }
30
44
  }
31
45
  return out;
32
46
  }
@@ -49,6 +63,9 @@ function screenRules(raw, base) {
49
63
  const rage = rule(r.rageTaps, base.rageTaps || DEFAULT_RULES.rageTaps);
50
64
  if (rage !== undefined)
51
65
  out.rageTaps = rage;
66
+ const inc = rule(r.incompleteSubmit, base.incompleteSubmit || DEFAULT_RULES.incompleteSubmit);
67
+ if (inc !== undefined)
68
+ out.incompleteSubmit = inc;
52
69
  if (typeof r.screenError === "boolean")
53
70
  out.screenError = r.screenError;
54
71
  if (typeof r.message === "string" && r.message.trim())
@@ -100,6 +117,7 @@ export function rulesFor(config, screenId) {
100
117
  ignore: over.ignore ?? false,
101
118
  idle: over.idle ?? config.idle,
102
119
  repeatedErrors: over.repeatedErrors ?? config.repeatedErrors,
120
+ incompleteSubmit: over.incompleteSubmit ?? config.incompleteSubmit,
103
121
  backAndForth: over.backAndForth ?? config.backAndForth,
104
122
  rageTaps: over.rageTaps ?? config.rageTaps,
105
123
  screenError: over.screenError ?? config.screenError,
@@ -109,6 +127,7 @@ export function rulesFor(config, screenId) {
109
127
  function fill(template, vars) {
110
128
  return template.replace(/\{(\w+)\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? "" : String(vars[k])));
111
129
  }
130
+ const outstanding = (fields) => (fields ?? []).filter((f) => f.error || (!f.optional && (f.status === "invalid" || f.status === "empty")));
112
131
  const stuckField = (fields) => (fields ?? []).find((f) => f.status === "invalid" || f.error) ?? (fields ?? []).find((f) => f.status === "empty");
113
132
  export class FrictionDetector {
114
133
  constructor(config) {
@@ -213,10 +232,26 @@ export class FrictionDetector {
213
232
  }
214
233
  onTrack(name, now) {
215
234
  const rules = this.active();
216
- if (!rules || !rules.rageTaps || !this.screen)
235
+ if (!rules || !this.screen)
217
236
  return [];
218
237
  if (!name.startsWith("tap:"))
219
238
  return [];
239
+ if (!rules.rageTaps && !rules.incompleteSubmit)
240
+ return [];
241
+ const label0 = name.slice(4);
242
+ if (rules.incompleteSubmit) {
243
+ const lower = label0.toLowerCase();
244
+ const isSubmit = rules.incompleteSubmit.taps.some((t) => lower.includes(t.toLowerCase()));
245
+ const left = outstanding(this.screen.fields);
246
+ if (isSubmit && left.length) {
247
+ const names = left.map((f) => f.label || f.name).filter(Boolean);
248
+ const s = this.signal("incomplete_submit", { tap: label0, count: left.length, fields: names.join(", "), first: names[0] ?? "" }, "The customer pressed \"{tap}\" on the \"{title}\" screen with {count} still to fill: {fields}. Tell them what is missing before they hit the error.", now);
249
+ if (s)
250
+ return [s];
251
+ }
252
+ }
253
+ if (!rules.rageTaps)
254
+ return [];
220
255
  const { taps, windowMs } = rules.rageTaps;
221
256
  const key = `${this.screen.screen}:${name}`;
222
257
  const times = (this.taps.get(key) ?? []).filter((t) => now - t <= windowMs);
@@ -225,7 +260,7 @@ export class FrictionDetector {
225
260
  if (times.length < taps)
226
261
  return [];
227
262
  this.taps.set(key, []);
228
- const label = name.slice(4);
263
+ const label = label0;
229
264
  const s = this.signal("rage_taps", { tap: label, count: times.length, seconds: (windowMs / 1000).toFixed(1) }, label === "screen"
230
265
  ? `The customer tapped the "{title}" screen {count} times in {seconds}s — something on it may not be responding or not doing what they expect.`
231
266
  : `The customer tapped "{tap}" {count} times in {seconds}s on the "{title}" screen — it may not be responding or not doing what they expect.`, now);
package/dist/index.d.ts CHANGED
@@ -7,9 +7,9 @@ export { addMaskRule, maskDeep, maskString } from "./mask.js";
7
7
  export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
8
8
  export { requestMicrophonePermission } from "./react-native/permissions.js";
9
9
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
10
- export { UNNAMED_TAP } from "./protocol.js";
10
+ export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
11
11
  export { resolveNativeAppearance, type NativeAppearance, type NativeAppearanceFallback, } from "./react-native/appearance.js";
12
12
  export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
13
13
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
14
- export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
14
+ export { GraineVoiceLauncher, CAPTION_HISTORY, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
15
15
  export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation.js";
package/dist/index.js CHANGED
@@ -6,9 +6,9 @@ export { addMaskRule, maskDeep, maskString } from "./mask.js";
6
6
  export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
7
7
  export { requestMicrophonePermission } from "./react-native/permissions.js";
8
8
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
9
- export { UNNAMED_TAP } from "./protocol.js";
9
+ export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
10
10
  export { resolveNativeAppearance, } from "./react-native/appearance.js";
11
11
  export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
12
12
  export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
13
- export { GraineVoiceLauncher, } from "./react-native/voice-launcher.js";
13
+ export { GraineVoiceLauncher, CAPTION_HISTORY, } from "./react-native/voice-launcher.js";
14
14
  export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
@@ -4,6 +4,7 @@ export interface ScreenField {
4
4
  value?: string | number | null;
5
5
  status?: "empty" | "filled" | "invalid" | "pending" | "locked";
6
6
  error?: string;
7
+ optional?: boolean;
7
8
  }
8
9
  export interface ScreenContext {
9
10
  screen: string;
@@ -12,6 +13,7 @@ export interface ScreenContext {
12
13
  name: string;
13
14
  step?: number;
14
15
  of?: number;
16
+ next?: string;
15
17
  };
16
18
  fields?: ScreenField[];
17
19
  error?: string;
@@ -59,3 +61,4 @@ export type ActionHandler = (args: Record<string, any>) => AppActionResult | voi
59
61
  export declare const ACTION_DEADLINE_MS = 6000;
60
62
  export declare const CONTEXT_THROTTLE_MS = 400;
61
63
  export declare const UNNAMED_TAP = "tap:screen";
64
+ export declare function resolveWidgetImages(data: Record<string, unknown>, baseUrl: string): Record<string, unknown>;
package/dist/protocol.js CHANGED
@@ -1,3 +1,31 @@
1
1
  export const ACTION_DEADLINE_MS = 6000;
2
2
  export const CONTEXT_THROTTLE_MS = 400;
3
3
  export const UNNAMED_TAP = "tap:screen";
4
+ const IMAGE_KEY = /^(image|imageurl|image_url|icon|iconurl|icon_url|logo|logourl|logo_url|photo|photourl|photo_url|thumbnail|thumbnailurl|thumbnail_url)$/i;
5
+ function resolveOne(value, base) {
6
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//"))
7
+ return value;
8
+ return `${base.replace(/\/+$/, "")}${value}`;
9
+ }
10
+ export function resolveWidgetImages(data, baseUrl) {
11
+ if (!data || typeof data !== "object" || !baseUrl)
12
+ return data;
13
+ const out = {};
14
+ for (const [k, v] of Object.entries(data)) {
15
+ if (IMAGE_KEY.test(k)) {
16
+ out[k] = resolveOne(v, baseUrl);
17
+ }
18
+ else if (Array.isArray(v)) {
19
+ out[k] = v.map((row) => row && typeof row === "object" && !Array.isArray(row)
20
+ ? Object.fromEntries(Object.entries(row).map(([rk, rv]) => [
21
+ rk,
22
+ IMAGE_KEY.test(rk) ? resolveOne(rv, baseUrl) : rv,
23
+ ]))
24
+ : row);
25
+ }
26
+ else {
27
+ out[k] = v;
28
+ }
29
+ }
30
+ return out;
31
+ }
@@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
3
3
  import { AppState } from "react-native";
4
4
  import { GraineInAppClient } from "../client.js";
5
5
  import { VoiceSession } from "../voice.js";
6
+ import { resolveWidgetImages } from "../protocol.js";
6
7
  import { GraineFab, GraineTouchCapture } from "./fab.js";
7
8
  export { GraineFab, GraineTouchCapture } from "./fab.js";
8
9
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
@@ -395,7 +396,7 @@ export function useGraineWidget() {
395
396
  setWidget({
396
397
  id: String(w.widget_id),
397
398
  kind: String(w.kind || ""),
398
- data: (w.data || {}),
399
+ data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
399
400
  fallbackText: String(w.fallback_text || ""),
400
401
  });
401
402
  }), [client]);
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { type Caption } from "./index.js";
2
3
  export interface GraineVoiceLauncherProps {
3
4
  transport?: "ws" | "rtc";
4
5
  onTransport?: (info: {
@@ -32,5 +33,11 @@ export interface GraineVoiceApi {
32
33
  start: () => void;
33
34
  end: () => void;
34
35
  setMuted: (m: boolean) => void;
36
+ captions: Caption[];
37
+ caption: Caption | null;
38
+ captionsOn: boolean;
39
+ setCaptionsOn: (on: boolean) => void;
40
+ clearCaptions: () => void;
35
41
  }
42
+ export declare const CAPTION_HISTORY = 12;
36
43
  export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { AppState, View } from "react-native";
4
4
  import { useGraineAgent } from "./index.js";
5
5
  import { requestMicrophonePermission } from "./permissions.js";
6
+ export const CAPTION_HISTORY = 12;
6
7
  export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
7
8
  const { client, appearance } = useGraineAgent();
8
9
  const ref = useRef(null);
@@ -14,6 +15,22 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
14
15
  const [connecting, setConnecting] = useState(false);
15
16
  const [muted, setMutedState] = useState(false);
16
17
  const [ready, setReady] = useState(false);
18
+ const [captions, setCaptions] = useState([]);
19
+ const [captionsOn, setCaptionsOn] = useState(true);
20
+ const foldCaption = useCallback((next) => {
21
+ setCaptions((prev) => {
22
+ const tail = prev[prev.length - 1];
23
+ if (!next.text) {
24
+ if (tail && tail.live && tail.role === next.role) {
25
+ return [...prev.slice(0, -1), { ...tail, live: false }];
26
+ }
27
+ return prev;
28
+ }
29
+ const continues = tail && tail.live && tail.role === next.role;
30
+ const merged = continues ? [...prev.slice(0, -1), next] : [...prev, next];
31
+ return merged.length > CAPTION_HISTORY ? merged.slice(-CAPTION_HISTORY) : merged;
32
+ });
33
+ }, []);
17
34
  const post = useCallback((msg) => {
18
35
  ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
19
36
  }, []);
@@ -132,9 +149,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
132
149
  case "graine:muted":
133
150
  setMutedState(!!msg.muted);
134
151
  return;
135
- case "graine:caption":
136
- onCaption?.({ role: msg.role, text: msg.text, live: !!msg.live });
152
+ case "graine:caption": {
153
+ const line = {
154
+ role: msg.role === "agent" ? "agent" : "customer",
155
+ text: String(msg.text ?? ""),
156
+ live: !!msg.live,
157
+ };
158
+ foldCaption(line);
159
+ onCaption?.(line);
137
160
  return;
161
+ }
138
162
  case "graine:ended":
139
163
  setConnected(false);
140
164
  setConnecting(false);
@@ -161,15 +185,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
161
185
  return;
162
186
  }
163
187
  }
164
- }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext]);
188
+ }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
165
189
  const pendingStartRef = useRef(false);
166
190
  const api = {
167
191
  connected,
168
192
  connecting,
169
193
  muted,
170
194
  ready: !!voiceAgentId && ready,
195
+ captions,
196
+ caption: captions.length ? captions[captions.length - 1] : null,
197
+ captionsOn,
198
+ setCaptionsOn,
199
+ clearCaptions: useCallback(() => setCaptions([]), []),
171
200
  start: useCallback(() => {
172
201
  setConnecting(true);
202
+ setCaptions([]);
173
203
  if (!ready) {
174
204
  pendingStartRef.current = true;
175
205
  return;
@@ -191,6 +221,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
191
221
  if (autoStart) {
192
222
  if (startedRef.current)
193
223
  return;
224
+ setConnecting(true);
225
+ setCaptions([]);
194
226
  if (ready) {
195
227
  startedRef.current = true;
196
228
  void beginCall();
@@ -205,10 +237,13 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
205
237
  }
206
238
  if (first)
207
239
  return;
208
- if (startedRef.current || pendingStartRef.current)
209
- post({ type: "graine:end-call" });
240
+ post({ type: "graine:end-call" });
210
241
  startedRef.current = false;
211
242
  pendingStartRef.current = false;
243
+ setConnected(false);
244
+ setConnecting(false);
245
+ setCaptions([]);
246
+ onCallState?.({ connected: false, connecting: false });
212
247
  }, [active]);
213
248
  useEffect(() => {
214
249
  let last = AppState.currentState;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.27.5",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
- "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
5
+ "description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",
7
7
  "private": false,
8
8
  "main": "dist/index.js",