@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.
- package/dist/friction.d.ts +5 -1
- package/dist/friction.js +39 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol.d.ts +3 -0
- package/dist/protocol.js +28 -0
- package/dist/react-native/index.js +2 -1
- package/dist/react-native/voice-launcher.js +6 -0
- package/package.json +2 -2
package/dist/friction.d.ts
CHANGED
|
@@ -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
|
|
29
|
-
|
|
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 || !
|
|
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 =
|
|
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";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Graine in-app agent for React Native
|
|
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",
|