@graineai/inapp-react-native 0.28.0 → 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.
@@ -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";
@@ -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]);
@@ -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;
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.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",