@graineai/inapp-react-native 0.28.0 → 0.31.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/dist/client.d.ts CHANGED
@@ -63,6 +63,9 @@ export declare class GraineInAppClient {
63
63
  configureFriction(override: FrictionConfig | null | undefined): void;
64
64
  get friction(): Required<FrictionConfig>;
65
65
  on(event: string, fn: Listener): () => void;
66
+ private lastWidget;
67
+ get pendingWidget(): any;
68
+ clearPendingWidget(): void;
66
69
  emit(event: string, payload?: any): void;
67
70
  private get fetch();
68
71
  session(): Promise<SessionConfig>;
package/dist/client.js CHANGED
@@ -28,6 +28,7 @@ export class GraineInAppClient {
28
28
  this.identity = {};
29
29
  this.appVersion = null;
30
30
  this.recentEvents = [];
31
+ this.lastWidget = null;
31
32
  if (!opts?.publishableKey)
32
33
  throw new Error("[Graine] publishableKey is required.");
33
34
  if (!opts?.baseUrl)
@@ -57,7 +58,15 @@ export class GraineInAppClient {
57
58
  this.listeners.get(event).add(fn);
58
59
  return () => this.listeners.get(event)?.delete(fn);
59
60
  }
61
+ get pendingWidget() {
62
+ return this.lastWidget;
63
+ }
64
+ clearPendingWidget() {
65
+ this.lastWidget = null;
66
+ }
60
67
  emit(event, payload) {
68
+ if (event === "widget")
69
+ this.lastWidget = payload ?? null;
61
70
  for (const fn of this.listeners.get(event) ?? []) {
62
71
  try {
63
72
  fn(payload);
@@ -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,7 +7,7 @@ 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";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ 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";
@@ -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";
@@ -388,24 +389,36 @@ export function useGraineHighlight(name) {
388
389
  }
389
390
  export function useGraineWidget() {
390
391
  const client = useGraineClient();
391
- const [widget, setWidget] = useState(null);
392
- useEffect(() => client.on("widget", (w) => {
393
- if (!w?.widget_id)
394
- return;
395
- setWidget({
392
+ const adopt = useCallback((w) => w?.widget_id
393
+ ? {
396
394
  id: String(w.widget_id),
397
395
  kind: String(w.kind || ""),
398
- data: (w.data || {}),
396
+ data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
399
397
  fallbackText: String(w.fallback_text || ""),
398
+ }
399
+ : null, [client]);
400
+ const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
401
+ useEffect(() => {
402
+ const pending = adopt(client.pendingWidget);
403
+ if (pending)
404
+ setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
405
+ return client.on("widget", (w) => {
406
+ const next = adopt(w);
407
+ if (next)
408
+ setWidget(next);
400
409
  });
401
- }), [client]);
410
+ }, [client, adopt]);
402
411
  const submit = useCallback((data, summary) => {
403
412
  if (!widget)
404
413
  return;
405
414
  client.submitWidget(widget.id, data, summary);
415
+ client.clearPendingWidget();
406
416
  setWidget(null);
407
417
  }, [client, widget]);
408
- const dismiss = useCallback(() => setWidget(null), []);
418
+ const dismiss = useCallback(() => {
419
+ client.clearPendingWidget();
420
+ setWidget(null);
421
+ }, [client]);
409
422
  return { widget, submit, dismiss };
410
423
  }
411
424
  export function useGraineAction(name, handler) {
@@ -20,6 +20,12 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
20
20
  const foldCaption = useCallback((next) => {
21
21
  setCaptions((prev) => {
22
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
+ }
23
29
  const continues = tail && tail.live && tail.role === next.role;
24
30
  const merged = continues ? [...prev.slice(0, -1), next] : [...prev, next];
25
31
  return merged.length > CAPTION_HISTORY ? merged.slice(-CAPTION_HISTORY) : merged;
@@ -132,6 +138,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
132
138
  startedRef.current = true;
133
139
  void beginCall();
134
140
  }
141
+ else if (!activeRef.current && !startedRef.current) {
142
+ post({ type: "graine:warm" });
143
+ }
135
144
  return;
136
145
  case "graine:call":
137
146
  setConnected(!!msg.connected);
@@ -194,6 +203,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
194
203
  start: useCallback(() => {
195
204
  setConnecting(true);
196
205
  setCaptions([]);
206
+ client.clearPendingWidget();
197
207
  if (!ready) {
198
208
  pendingStartRef.current = true;
199
209
  return;
@@ -237,6 +247,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
237
247
  setConnected(false);
238
248
  setConnecting(false);
239
249
  setCaptions([]);
250
+ client.clearPendingWidget();
240
251
  onCallState?.({ connected: false, connecting: false });
241
252
  }, [active]);
242
253
  useEffect(() => {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.28.0",
3
+ "version": "0.31.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",