@graineai/inapp-react-native 0.26.1 → 0.27.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
@@ -819,9 +819,12 @@ fourth. The guarantees, so you do not have to think about them:
819
819
  applies only to the SDK's own text socket, never to a host's channel.
820
820
  - **Ordered.** Every context frame carries a monotonic `seq`; the runtime keeps
821
821
  the newest and drops anything that arrives late. The last screen always wins.
822
- - **Cleanup-safe.** A screen's unmount clears the screen only if it is still
823
- the one being described (`client.clearScreen(id)`), so a navigation library
824
- unmounting the old screen after the new one mounted cannot erase it.
822
+ - **Cleanup-safe.** The description is re-sent whenever its state changes and
823
+ withdrawn only on unmount — never on a change (0.26.2; earlier versions sent
824
+ a screen-less frame before every navigation). The withdrawal clears the
825
+ screen only if it is still the one being described (`client.clearScreen(id)`),
826
+ so a navigation library unmounting the old screen after the new one mounted
827
+ cannot erase it.
825
828
  - **Reconnect-safe.** The newest context is replayed whenever the media or the
826
829
  screen channel (re)connects.
827
830
  - **Self-healing.** The newest screen is re-asserted every few seconds during
@@ -882,7 +885,9 @@ whole translation:
882
885
  | `continuous` / `perScreen` | same |
883
886
  | `perScreen` / `oncePerGroupEntry` / `oncePerAppSession` | same |
884
887
  | `EmbedButtonInset` | `LauncherInset` — same shape |
885
- | `EmbedButton` (mount it yourself) | `GraineLauncher` |
888
+ | `EmbedButton` / the provider mounts the FAB | `fab={{ webView }}` on `GraineProvider`, or `GraineFab` yourself |
889
+ | Best-effort click tracking on touchables | `captureTaps` (on by default with `fab`) — `tap:screen` events |
890
+ | `useInitialize` → `{ isInitialized, error }` | `useGraineInit()` |
886
891
  | `Embed.Event(USER_DATA, { app_user_id, data })` | `useGraineIdentify()` / `client.identify({ id, name, … })` |
887
892
  | `Embed.Event(SCREEN_STATE, { screen, data })` | `useGraineScreen({ screen, fields })` |
888
893
  | `Embed.Event(CUSTOM_EVENT, data)` / `ANALYTICS_DATA` | `useGraineTrack()` / `client.track(name, data)` |
package/README.md CHANGED
@@ -225,6 +225,9 @@ requests a permission on your behalf.
225
225
  | `useGraineIdentify()` | Who the customer is — it reaches the greeting and the call record. |
226
226
  | `useGraineHighlight()` / `useGraineWidget()` | Let the agent point at things and draw components. |
227
227
  | `GraineVoiceLauncher` | The voice engine: WebView, microphone, WebRTC or WebSocket. |
228
+ | `fab={{ webView }}` on the provider / `GraineFab` | The floating button, mounted for you; opens the conversation, folds back on end. |
229
+ | `captureTaps` / `GraineTouchCapture` | Every tap tracked automatically — no per-button wrapping. |
230
+ | `useGraineInit()` | `{ isInitialized, error }`. |
228
231
  | `GraineAgentBar` | Optional default UI. Mount at the root. Replace it with your own. |
229
232
  | `addMaskRule(pattern, token)` | Mask an app-specific identifier format. |
230
233
 
package/dist/client.d.ts CHANGED
@@ -97,7 +97,10 @@ export declare class GraineInAppClient {
97
97
  setScreen(context: ScreenContext | null): void;
98
98
  private dispatchFriction;
99
99
  reportEvent(event: AppEvent): void;
100
- track(name: string, data?: Record<string, unknown>): void;
100
+ track(name: string, data?: Record<string, unknown>, opts?: {
101
+ coalesce?: boolean;
102
+ }): void;
103
+ private scheduleScreenTrailing;
101
104
  private scheduleScreen;
102
105
  setAppVersion(version: string | null | undefined): void;
103
106
  buildContextFrame(): {
package/dist/client.js CHANGED
@@ -415,16 +415,34 @@ export class GraineInAppClient {
415
415
  this.emit("app_event", event);
416
416
  this.send({ type: "app_event", event });
417
417
  }
418
- track(name, data) {
418
+ track(name, data, opts) {
419
419
  if (!name)
420
420
  return;
421
421
  this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
422
422
  if (this.recentEvents.length > 10)
423
423
  this.recentEvents.shift();
424
+ if (opts?.coalesce) {
425
+ this.dispatchFriction(this.detector.onTrack(name, Date.now()));
426
+ if (this.screen)
427
+ this.scheduleScreenTrailing();
428
+ return;
429
+ }
424
430
  this.dispatchFriction(this.detector.onTrack(name, Date.now()));
425
431
  if (this.screen)
426
432
  this.scheduleScreen();
427
433
  }
434
+ scheduleScreenTrailing() {
435
+ this.pendingScreen = true;
436
+ if (this.contextTimer)
437
+ return;
438
+ this.contextTimer = setTimeout(() => {
439
+ this.contextTimer = null;
440
+ if (this.pendingScreen) {
441
+ this.pendingScreen = false;
442
+ this.scheduleScreen();
443
+ }
444
+ }, CONTEXT_THROTTLE_MS);
445
+ }
428
446
  scheduleScreen() {
429
447
  this.emit("screen", { screen: this.screen, actions: this.getAvailableActions() });
430
448
  if (this.contextTimer) {
package/dist/friction.js CHANGED
@@ -225,7 +225,10 @@ export class FrictionDetector {
225
225
  if (times.length < taps)
226
226
  return [];
227
227
  this.taps.set(key, []);
228
- const s = this.signal("rage_taps", { tap: name.slice(4), count: times.length, seconds: (windowMs / 1000).toFixed(1) }, `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);
228
+ const label = name.slice(4);
229
+ const s = this.signal("rage_taps", { tap: label, count: times.length, seconds: (windowMs / 1000).toFixed(1) }, label === "screen"
230
+ ? `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
+ : `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);
229
232
  return s ? [s] : [];
230
233
  }
231
234
  idleDueIn(now) {
package/dist/index.d.ts CHANGED
@@ -4,8 +4,10 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type Fri
4
4
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
5
5
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
6
6
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
7
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
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
+ 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";
9
11
  export { resolveNativeAppearance, type NativeAppearance, type NativeAppearanceFallback, } from "./react-native/appearance.js";
10
12
  export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
11
13
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
package/dist/index.js CHANGED
@@ -3,8 +3,10 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "
3
3
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
4
4
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
5
5
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
6
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, } from "./react-native/index.js";
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
+ export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
9
+ export { UNNAMED_TAP } from "./protocol.js";
8
10
  export { resolveNativeAppearance, } from "./react-native/appearance.js";
9
11
  export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
10
12
  export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
@@ -58,3 +58,4 @@ export interface AppActionResultFrame extends AppActionResult {
58
58
  export type ActionHandler = (args: Record<string, any>) => AppActionResult | void | Promise<AppActionResult | void>;
59
59
  export declare const ACTION_DEADLINE_MS = 6000;
60
60
  export declare const CONTEXT_THROTTLE_MS = 400;
61
+ export declare const UNNAMED_TAP = "tap:screen";
package/dist/protocol.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export const ACTION_DEADLINE_MS = 6000;
2
2
  export const CONTEXT_THROTTLE_MS = 400;
3
+ export const UNNAMED_TAP = "tap:screen";
@@ -0,0 +1,20 @@
1
+ import React, { type ComponentType } from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ export declare const TAP_MAX_MOVE_PX = 12;
4
+ export declare const TAP_MAX_MS = 400;
5
+ export interface GraineTouchCaptureProps {
6
+ children: React.ReactNode;
7
+ enabled?: boolean;
8
+ style?: StyleProp<ViewStyle>;
9
+ }
10
+ export declare function GraineTouchCapture({ children, enabled, style }: GraineTouchCaptureProps): React.JSX.Element;
11
+ export interface GraineFabProps {
12
+ webView: ComponentType<any>;
13
+ position?: "bottom-right" | "bottom-left";
14
+ size?: number;
15
+ accent?: string;
16
+ label?: string;
17
+ onOpen?: () => void;
18
+ onClose?: () => void;
19
+ }
20
+ export declare function GraineFab({ webView, position, size, accent: accentProp, label, onOpen, onClose }: GraineFabProps): React.JSX.Element | null;
@@ -0,0 +1,85 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useContext, useRef, useState } from "react";
3
+ import { Image, Platform, Pressable, Text, View } from "react-native";
4
+ let FullWindowOverlay = null;
5
+ try {
6
+ if (Platform.OS === "ios" && typeof require === "function") {
7
+ FullWindowOverlay = require("react-native-screens")?.FullWindowOverlay ?? null;
8
+ }
9
+ }
10
+ catch {
11
+ FullWindowOverlay = null;
12
+ }
13
+ function AboveNativeScreens({ children }) {
14
+ if (!FullWindowOverlay)
15
+ return _jsx(_Fragment, { children: children });
16
+ const Overlay = FullWindowOverlay;
17
+ return _jsx(Overlay, { children: _jsx(View, { style: { flex: 1 }, pointerEvents: "box-none", children: children }) });
18
+ }
19
+ import { Ctx, useGraineAgent, useGraineClient } from "./index.js";
20
+ import { GraineVoiceLauncher } from "./voice-launcher.js";
21
+ import { GraineAgentBar } from "./ui.js";
22
+ import { resolveNativeAppearance } from "./appearance.js";
23
+ import { UNNAMED_TAP } from "../protocol.js";
24
+ export const TAP_MAX_MOVE_PX = 12;
25
+ export const TAP_MAX_MS = 400;
26
+ export function GraineTouchCapture({ children, enabled = true, style }) {
27
+ const client = useGraineClient();
28
+ const ctx = useContext(Ctx);
29
+ const start = useRef(null);
30
+ const onTouchStart = useCallback((e) => {
31
+ try {
32
+ const t = e?.nativeEvent;
33
+ if (!t || (t.touches?.length ?? 1) > 1) {
34
+ start.current = null;
35
+ return;
36
+ }
37
+ start.current = { x: t.pageX, y: t.pageY, at: Date.now() };
38
+ }
39
+ catch {
40
+ start.current = null;
41
+ }
42
+ }, []);
43
+ const onTouchEnd = useCallback((e) => {
44
+ try {
45
+ const s = start.current;
46
+ start.current = null;
47
+ if (!enabled || !s)
48
+ return;
49
+ const t = e?.nativeEvent;
50
+ if (!t)
51
+ return;
52
+ if (Math.hypot(t.pageX - s.x, t.pageY - s.y) > TAP_MAX_MOVE_PX)
53
+ return;
54
+ if (Date.now() - s.at > TAP_MAX_MS)
55
+ return;
56
+ if (!client.getScreen())
57
+ return;
58
+ if (ctx && !ctx.launcherVisible)
59
+ return;
60
+ client.track(UNNAMED_TAP, { x: Math.round(t.pageX), y: Math.round(t.pageY) }, { coalesce: true });
61
+ }
62
+ catch { }
63
+ }, [client, ctx, enabled]);
64
+ return (_jsx(View, { style: [{ flex: 1 }, style], onTouchStart: onTouchStart, onTouchEnd: onTouchEnd, children: children }));
65
+ }
66
+ export function GraineFab({ webView, position = "bottom-right", size = 56, accent: accentProp, label, onOpen, onClose }) {
67
+ const { launcherVisible, launcherInset, appearance, error } = useGraineAgent();
68
+ const [open, setOpen] = useState(false);
69
+ const dashboard = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "Assistant", radius: 18 });
70
+ const accent = accentProp ?? dashboard.accent;
71
+ const name = dashboard.heading;
72
+ if (error || !launcherVisible)
73
+ return null;
74
+ if (open) {
75
+ return (_jsx(AboveNativeScreens, { children: _jsx(GraineVoiceLauncher, { webView: webView, autoStart: true, onEnded: () => { setOpen(false); onClose?.(); }, children: () => _jsx(GraineAgentBar, { accent: accent, name: name }) }) }));
76
+ }
77
+ const side = position === "bottom-left" ? { left: launcherInset.right } : { right: launcherInset.right };
78
+ return (_jsx(AboveNativeScreens, { children: _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label ?? `Talk to ${name}`, onPress: () => { setOpen(true); onOpen?.(); }, style: ({ pressed }) => ({
79
+ position: "absolute", bottom: launcherInset.bottom, ...side,
80
+ width: size, height: size, borderRadius: size / 2, backgroundColor: accent,
81
+ alignItems: "center", justifyContent: "center",
82
+ opacity: pressed ? 0.85 : 1,
83
+ elevation: 6, shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 8, shadowOffset: { width: 0, height: 4 },
84
+ }), children: dashboard.logoUrl ? (_jsx(Image, { source: { uri: dashboard.logoUrl }, style: { width: size * 0.6, height: size * 0.6, borderRadius: size * 0.3 } })) : (_jsx(Text, { style: { color: "#fff", fontSize: size * 0.4, fontWeight: "700" }, children: name.trim().charAt(0).toUpperCase() || "•" })) }) }));
85
+ }
@@ -80,6 +80,7 @@ export interface Turn {
80
80
  text: string;
81
81
  live?: boolean;
82
82
  }
83
+ export declare const Ctx: React.Context<GraineContextValue | null>;
83
84
  export declare function setDefaultClient(client: GraineInAppClient | null): void;
84
85
  export declare function useGraineClient(): GraineInAppClient;
85
86
  export interface GraineProviderProps extends Partial<GraineInAppOptions> {
@@ -93,11 +94,15 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
93
94
  } | null;
94
95
  appVersion?: string;
95
96
  friction?: FrictionConfig;
97
+ fab?: Omit<import("./fab.js").GraineFabProps, "webView"> & {
98
+ webView: React.ComponentType<any>;
99
+ };
100
+ captureTaps?: boolean;
96
101
  includeScreens?: string[];
97
102
  launcherDelayMs?: number;
98
103
  visibility?: LauncherVisibility;
99
104
  }
100
- export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
105
+ export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
101
106
  export declare function useGraineAgent(): GraineContextValue;
102
107
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
103
108
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
@@ -120,6 +125,10 @@ export declare function useGraineWidget(): {
120
125
  dismiss: () => void;
121
126
  };
122
127
  export declare function useGraineAction(name: string, handler: ActionHandler): void;
128
+ export declare function useGraineInit(): {
129
+ isInitialized: boolean;
130
+ error: string | null;
131
+ };
123
132
  export declare function useGraineReady(): {
124
133
  ready: boolean;
125
134
  error: string | null;
@@ -1,10 +1,11 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { AppState } from "react-native";
4
4
  import { GraineInAppClient } from "../client.js";
5
5
  import { VoiceSession } from "../voice.js";
6
+ import { GraineFab, GraineTouchCapture } from "./fab.js";
6
7
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
7
- const Ctx = createContext(null);
8
+ export const Ctx = createContext(null);
8
9
  let defaultClient = null;
9
10
  export function setDefaultClient(client) {
10
11
  defaultClient = client;
@@ -17,7 +18,7 @@ export function useGraineClient() {
17
18
  }
18
19
  return client;
19
20
  }
20
- export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
21
+ export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
21
22
  const clientRef = useRef(null);
22
23
  if (!clientRef.current) {
23
24
  clientRef.current =
@@ -268,7 +269,9 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
268
269
  identify: (user) => client.identify(user),
269
270
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
270
271
  launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady]);
271
- return _jsx(Ctx.Provider, { value: value, children: children });
272
+ const wantsCapture = captureTaps ?? !!fab;
273
+ const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
274
+ return _jsx(Ctx.Provider, { value: value, children: body });
272
275
  }
273
276
  function useGraine() {
274
277
  const ctx = useContext(Ctx);
@@ -346,8 +349,12 @@ export function useGraineScreen(context) {
346
349
  const serialised = JSON.stringify(context ?? null);
347
350
  useEffect(() => {
348
351
  client.setScreen(context);
349
- return () => client.clearScreen(context?.screen);
350
352
  }, [client, serialised]);
353
+ const reported = useRef(context?.screen);
354
+ reported.current = context?.screen;
355
+ useEffect(() => () => {
356
+ client.clearScreen(reported.current);
357
+ }, [client]);
351
358
  }
352
359
  export function useGraineField(name, field) {
353
360
  const client = useGraineClient();
@@ -406,6 +413,10 @@ export function useGraineAction(name, handler) {
406
413
  handlerRef.current = handler;
407
414
  useEffect(() => client.registerAction(name, (args) => handlerRef.current(args)), [client, name]);
408
415
  }
416
+ export function useGraineInit() {
417
+ const { sessionReady, error } = useGraineAgent();
418
+ return { isInitialized: sessionReady, error };
419
+ }
409
420
  export function useGraineReady() {
410
421
  const { sessionReady, error } = useGraineAgent();
411
422
  return { ready: sessionReady, error };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",