@graineai/inapp-react-native 0.33.2 → 0.33.4

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/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, WidgetSurface, } from "./protocol.js";
2
2
  export { widgetSurface } from "./protocol.js";
3
+ export { WIDGET_PRESETS, WIDGET_IMAGE_SIZES, WIDGET_DENSITIES, WIDGET_BUTTONS, WIDGET_SURFACE_CHOICES, DEFAULT_WIDGET_PRESET, WIDGET_PLATFORMS, PLATFORM_GRAMMAR, normaliseWidgetStyle, resolveWidgetLook, resolvePlatform, } from "./protocol.js";
4
+ export type { WidgetStyle, WidgetLook, WidgetPreset, WidgetImageSize, WidgetDensity, WidgetButton, WidgetPlatform, PlatformGrammar } from "./protocol.js";
3
5
  export { GraineWidget } from "./react-native/widget.js";
4
6
  export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
5
7
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { widgetSurface } from "./protocol.js";
2
+ export { WIDGET_PRESETS, WIDGET_IMAGE_SIZES, WIDGET_DENSITIES, WIDGET_BUTTONS, WIDGET_SURFACE_CHOICES, DEFAULT_WIDGET_PRESET, WIDGET_PLATFORMS, PLATFORM_GRAMMAR, normaliseWidgetStyle, resolveWidgetLook, resolvePlatform, } from "./protocol.js";
2
3
  export { GraineWidget } from "./react-native/widget.js";
3
4
  export { GraineInAppClient, } from "./client.js";
4
5
  export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
@@ -64,3 +64,57 @@ export declare const UNNAMED_TAP = "tap:screen";
64
64
  export declare function resolveWidgetImages(data: Record<string, unknown>, baseUrl: string): Record<string, unknown>;
65
65
  export type WidgetSurface = "screen" | "sheet" | "card";
66
66
  export declare function widgetSurface(kind: string | null | undefined): WidgetSurface;
67
+ export type WidgetPreset = "gallery" | "cards" | "list" | "spotlight" | "compact";
68
+ export type WidgetImageSize = "small" | "medium" | "large";
69
+ export type WidgetDensity = "comfortable" | "compact";
70
+ export type WidgetButton = "filled" | "outline" | "row";
71
+ export type WidgetPlatform = "auto" | "ios" | "material" | "oneui";
72
+ export interface WidgetStyle {
73
+ preset?: WidgetPreset;
74
+ imageSize?: WidgetImageSize;
75
+ density?: WidgetDensity;
76
+ button?: WidgetButton;
77
+ facts?: boolean;
78
+ columns?: 1 | 2;
79
+ surface?: "auto" | WidgetSurface;
80
+ radius?: number;
81
+ accent?: string;
82
+ platform?: WidgetPlatform;
83
+ }
84
+ export declare const WIDGET_PRESETS: ReadonlyArray<WidgetPreset>;
85
+ export declare const WIDGET_IMAGE_SIZES: ReadonlyArray<WidgetImageSize>;
86
+ export declare const WIDGET_DENSITIES: ReadonlyArray<WidgetDensity>;
87
+ export declare const WIDGET_BUTTONS: ReadonlyArray<WidgetButton>;
88
+ export declare const WIDGET_SURFACE_CHOICES: ReadonlyArray<"auto" | WidgetSurface>;
89
+ export declare const WIDGET_PLATFORMS: ReadonlyArray<WidgetPlatform>;
90
+ export declare const DEFAULT_WIDGET_PRESET: WidgetPreset;
91
+ export declare function normaliseWidgetStyle(raw: unknown): WidgetStyle;
92
+ export interface WidgetLook {
93
+ preset: WidgetPreset;
94
+ imageSize: WidgetImageSize;
95
+ density: WidgetDensity;
96
+ button: WidgetButton;
97
+ facts: boolean;
98
+ columns: 1 | 2;
99
+ surface: WidgetSurface;
100
+ radius?: number;
101
+ accent?: string;
102
+ platform: WidgetPlatform;
103
+ }
104
+ export declare function resolveWidgetLook(style: WidgetStyle | null | undefined, kind: string | null | undefined): WidgetLook;
105
+ export interface PlatformGrammar {
106
+ radius: number;
107
+ chipRadius: number;
108
+ buttonRadius: number;
109
+ pillButtons: boolean;
110
+ separatorInset: number;
111
+ grabber: {
112
+ width: number;
113
+ height: number;
114
+ };
115
+ titleWeight: "600" | "700" | "500";
116
+ padBoost: number;
117
+ chipOutlined: boolean;
118
+ }
119
+ export declare const PLATFORM_GRAMMAR: Record<Exclude<WidgetPlatform, "auto">, PlatformGrammar>;
120
+ export declare function resolvePlatform(platform: WidgetPlatform | undefined, os: string | undefined, manufacturer?: string | null): Exclude<WidgetPlatform, "auto">;
package/dist/protocol.js CHANGED
@@ -41,3 +41,86 @@ const SURFACE = {
41
41
  export function widgetSurface(kind) {
42
42
  return SURFACE[String(kind ?? "").trim().toLowerCase()] ?? "card";
43
43
  }
44
+ export const WIDGET_PRESETS = ["gallery", "cards", "list", "spotlight", "compact"];
45
+ export const WIDGET_IMAGE_SIZES = ["small", "medium", "large"];
46
+ export const WIDGET_DENSITIES = ["comfortable", "compact"];
47
+ export const WIDGET_BUTTONS = ["filled", "outline", "row"];
48
+ export const WIDGET_SURFACE_CHOICES = ["auto", "screen", "sheet", "card"];
49
+ export const WIDGET_PLATFORMS = ["auto", "ios", "material", "oneui"];
50
+ export const DEFAULT_WIDGET_PRESET = "cards";
51
+ export function normaliseWidgetStyle(raw) {
52
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
53
+ return {};
54
+ const r = raw;
55
+ const out = {};
56
+ const pick = (v, allowed) => {
57
+ const s = String(v ?? "").trim().toLowerCase();
58
+ return allowed.includes(s) ? s : undefined;
59
+ };
60
+ const preset = pick(r.preset, WIDGET_PRESETS);
61
+ if (preset)
62
+ out.preset = preset;
63
+ const imageSize = pick(r.imageSize, WIDGET_IMAGE_SIZES);
64
+ if (imageSize)
65
+ out.imageSize = imageSize;
66
+ const density = pick(r.density, WIDGET_DENSITIES);
67
+ if (density)
68
+ out.density = density;
69
+ const button = pick(r.button, WIDGET_BUTTONS);
70
+ if (button)
71
+ out.button = button;
72
+ if (typeof r.facts === "boolean")
73
+ out.facts = r.facts;
74
+ if (r.columns === 2 || r.columns === "2")
75
+ out.columns = 2;
76
+ else if (r.columns === 1 || r.columns === "1")
77
+ out.columns = 1;
78
+ const surface = pick(r.surface, WIDGET_SURFACE_CHOICES);
79
+ if (surface)
80
+ out.surface = surface;
81
+ if (typeof r.radius === "number" && Number.isFinite(r.radius))
82
+ out.radius = Math.max(0, Math.min(24, Math.round(r.radius)));
83
+ if (typeof r.accent === "string" && /^#[0-9a-f]{6}$/i.test(r.accent.trim()))
84
+ out.accent = r.accent.trim().toUpperCase();
85
+ const platform = pick(r.platform, WIDGET_PLATFORMS);
86
+ if (platform)
87
+ out.platform = platform;
88
+ return out;
89
+ }
90
+ const PRESET_DEFAULTS = {
91
+ gallery: { imageSize: "large", button: "filled" },
92
+ cards: { imageSize: "medium", button: "filled" },
93
+ list: { imageSize: "small", button: "row" },
94
+ spotlight: { imageSize: "large", button: "filled" },
95
+ compact: { imageSize: "small", button: "outline" },
96
+ };
97
+ export function resolveWidgetLook(style, kind) {
98
+ const s = normaliseWidgetStyle(style);
99
+ const preset = s.preset ?? DEFAULT_WIDGET_PRESET;
100
+ const d = PRESET_DEFAULTS[preset];
101
+ const surface = s.surface && s.surface !== "auto" ? s.surface : widgetSurface(kind);
102
+ return {
103
+ preset,
104
+ imageSize: s.imageSize ?? d.imageSize,
105
+ density: s.density ?? "comfortable",
106
+ button: s.button ?? d.button,
107
+ facts: s.facts ?? true,
108
+ columns: (preset === "gallery" || preset === "cards") && s.columns === 2 ? 2 : 1,
109
+ surface,
110
+ ...(s.radius !== undefined ? { radius: s.radius } : {}),
111
+ ...(s.accent ? { accent: s.accent } : {}),
112
+ platform: s.platform ?? "auto",
113
+ };
114
+ }
115
+ export const PLATFORM_GRAMMAR = {
116
+ ios: { radius: 12, chipRadius: 8, buttonRadius: 12, pillButtons: false, separatorInset: 16, grabber: { width: 36, height: 5 }, titleWeight: "600", padBoost: 0, chipOutlined: false },
117
+ material: { radius: 16, chipRadius: 8, buttonRadius: 20, pillButtons: true, separatorInset: 0, grabber: { width: 32, height: 4 }, titleWeight: "500", padBoost: 0, chipOutlined: true },
118
+ oneui: { radius: 26, chipRadius: 999, buttonRadius: 999, pillButtons: true, separatorInset: 0, grabber: { width: 40, height: 4 }, titleWeight: "700", padBoost: 2, chipOutlined: false },
119
+ };
120
+ export function resolvePlatform(platform, os, manufacturer) {
121
+ if (platform && platform !== "auto")
122
+ return platform;
123
+ if (os === "android")
124
+ return /samsung/i.test(String(manufacturer ?? "")) ? "oneui" : "material";
125
+ return "ios";
126
+ }
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
3
3
  import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
4
4
  import type { ActionHandler, ScreenContext, ScreenField } from "../protocol.js";
5
+ import { type WidgetStyle } from "../protocol.js";
5
6
  export { GraineFab, GraineTouchCapture } from "./fab.js";
6
7
  export type { GraineFabProps, GraineTouchCaptureProps } from "./fab.js";
7
8
  export { widgetSurface } from "../protocol.js";
@@ -73,6 +74,7 @@ export interface GraineWidget {
73
74
  kind: string;
74
75
  data: Record<string, unknown>;
75
76
  fallbackText: string;
77
+ style?: WidgetStyle;
76
78
  }
77
79
  export interface Caption {
78
80
  role: "agent" | "customer";
@@ -3,7 +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
+ import { resolveWidgetImages, normaliseWidgetStyle } from "../protocol.js";
7
7
  import { GraineFab, GraineTouchCapture } from "./fab.js";
8
8
  export { GraineFab, GraineTouchCapture } from "./fab.js";
9
9
  export { widgetSurface } from "../protocol.js";
@@ -396,6 +396,7 @@ export function useGraineWidget() {
396
396
  kind: String(w.kind || ""),
397
397
  data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
398
398
  fallbackText: String(w.fallback_text || ""),
399
+ style: normaliseWidgetStyle(w.style),
399
400
  }
400
401
  : null, [client]);
401
402
  const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
@@ -23,9 +23,21 @@ export interface GraineVoiceLauncherProps {
23
23
  onEnded?: () => void;
24
24
  onMicDenied?: (reason: string) => void;
25
25
  onError?: (message: string) => void;
26
+ timing?: {
27
+ readyTimeoutMs?: number;
28
+ retryDelaysMs?: number[];
29
+ endWatchdogMs?: number;
30
+ };
26
31
  children?: (api: GraineVoiceApi) => React.ReactNode;
27
32
  }
33
+ export interface GraineConnectivity {
34
+ state: "loading" | "ready" | "retrying" | "failed";
35
+ attempt: number;
36
+ reason?: string;
37
+ }
28
38
  export interface GraineVoiceApi {
39
+ connectivity: GraineConnectivity;
40
+ retry: () => void;
29
41
  connected: boolean;
30
42
  connecting: boolean;
31
43
  muted: boolean;
@@ -40,4 +52,4 @@ export interface GraineVoiceApi {
40
52
  clearCaptions: () => void;
41
53
  }
42
54
  export declare const CAPTION_HISTORY = 12;
43
- export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
55
+ export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -4,13 +4,14 @@ import { AppState, View } from "react-native";
4
4
  import { useGraineAgent } from "./index.js";
5
5
  import { requestMicrophonePermission } from "./permissions.js";
6
6
  export const CAPTION_HISTORY = 12;
7
- export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
7
+ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
8
8
  const { client, appearance } = useGraineAgent();
9
9
  const ref = useRef(null);
10
10
  const startedRef = useRef(false);
11
11
  const activeRef = useRef(active);
12
12
  activeRef.current = active;
13
13
  const mountedIdleRef = useRef(!active);
14
+ const mountStampRef = useRef(Date.now());
14
15
  const [connected, setConnected] = useState(false);
15
16
  const [connecting, setConnecting] = useState(false);
16
17
  const [muted, setMutedState] = useState(false);
@@ -37,6 +38,88 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
37
38
  const [voiceAgentId, setVoiceAgentId] = useState(client.config ? client.config.webcallAgentId || client.config.agentId : null);
38
39
  const onErrorRef = useRef(onError);
39
40
  onErrorRef.current = onError;
41
+ const READY_TIMEOUT_MS = timing?.readyTimeoutMs ?? 15000;
42
+ const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
43
+ const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
44
+ const [connectivity, setConnectivity] = useState({ state: "loading", attempt: 0 });
45
+ const attemptRef = useRef(0);
46
+ const retryTimerRef = useRef(null);
47
+ const readyTimerRef = useRef(null);
48
+ const endWatchdogRef = useRef(null);
49
+ const connectedRef = useRef(false);
50
+ useEffect(() => { connectedRef.current = connected; }, [connected]);
51
+ const endedNotifiedRef = useRef(true);
52
+ const onEndedRef = useRef(onEnded);
53
+ onEndedRef.current = onEnded;
54
+ const clearTimer = (r) => {
55
+ if (r.current) {
56
+ clearTimeout(r.current);
57
+ r.current = null;
58
+ }
59
+ };
60
+ const scheduleRetry = useCallback((reason) => {
61
+ clearTimer(readyTimerRef);
62
+ if (connectedRef.current)
63
+ return;
64
+ const attempt = attemptRef.current;
65
+ if (attempt >= RETRY_DELAYS_MS.length) {
66
+ setConnectivity({ state: "failed", attempt, reason });
67
+ setConnecting(false);
68
+ pendingStartRef.current = false;
69
+ onErrorRef.current?.("The assistant could not load. Check your connection and try again.");
70
+ return;
71
+ }
72
+ const base = RETRY_DELAYS_MS[attempt];
73
+ const delay = Math.round(base * (0.75 + Math.random() * 0.5));
74
+ attemptRef.current = attempt + 1;
75
+ setConnectivity({ state: "retrying", attempt: attempt + 1, reason });
76
+ setReady(false);
77
+ clearTimer(retryTimerRef);
78
+ retryTimerRef.current = setTimeout(() => {
79
+ retryTimerRef.current = null;
80
+ try {
81
+ ref.current?.reload();
82
+ }
83
+ catch { }
84
+ }, delay);
85
+ }, []);
86
+ const armReadyTimer = useCallback(() => {
87
+ clearTimer(readyTimerRef);
88
+ readyTimerRef.current = setTimeout(() => {
89
+ readyTimerRef.current = null;
90
+ scheduleRetry("ready-timeout");
91
+ }, READY_TIMEOUT_MS);
92
+ }, [scheduleRetry]);
93
+ const restartLoad = useCallback(() => {
94
+ clearTimer(retryTimerRef);
95
+ attemptRef.current = 0;
96
+ setConnectivity({ state: "loading", attempt: 0 });
97
+ setReady(false);
98
+ try {
99
+ ref.current?.reload();
100
+ }
101
+ catch { }
102
+ armReadyTimer();
103
+ }, [armReadyTimer]);
104
+ const forceEnd = useCallback((why) => {
105
+ clearTimer(endWatchdogRef);
106
+ setConnected(false);
107
+ setConnecting(false);
108
+ connectedRef.current = false;
109
+ if (!endedNotifiedRef.current) {
110
+ endedNotifiedRef.current = true;
111
+ console.warn(`[graine] the page did not confirm the call ended (${why}); ending it locally`);
112
+ onEndedRef.current?.();
113
+ }
114
+ restartLoad();
115
+ }, [restartLoad]);
116
+ const armEndWatchdog = useCallback(() => {
117
+ clearTimer(endWatchdogRef);
118
+ if (!connectedRef.current)
119
+ return;
120
+ endWatchdogRef.current = setTimeout(() => forceEnd("watchdog"), END_WATCHDOG_MS);
121
+ }, [forceEnd]);
122
+ useEffect(() => () => { clearTimer(retryTimerRef); clearTimer(readyTimerRef); clearTimer(endWatchdogRef); }, []);
40
123
  useEffect(() => {
41
124
  if (voiceAgentId)
42
125
  return;
@@ -74,6 +157,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
74
157
  return client.on("screen", () => pushContext());
75
158
  }, [client, ready, pushContext]);
76
159
  const beginCall = useCallback(async () => {
160
+ endedNotifiedRef.current = false;
77
161
  if (client.isConnected) {
78
162
  console.warn("[graine] The provider has its own connection open while GraineVoiceLauncher " +
79
163
  "is starting a call. Pass autoConnect={false} to <GraineProvider> — otherwise " +
@@ -131,6 +215,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
131
215
  }
132
216
  return;
133
217
  case "graine:ready":
218
+ clearTimer(readyTimerRef);
219
+ clearTimer(retryTimerRef);
220
+ attemptRef.current = 0;
221
+ setConnectivity({ state: "ready", attempt: 0 });
134
222
  setReady(true);
135
223
  pushContext();
136
224
  if ((autoStart || pendingStartRef.current) && !startedRef.current && activeRef.current) {
@@ -143,6 +231,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
143
231
  }
144
232
  return;
145
233
  case "graine:call":
234
+ if (msg.connected)
235
+ endedNotifiedRef.current = false;
146
236
  setConnected(!!msg.connected);
147
237
  setConnecting(!!msg.connecting);
148
238
  if (!msg.connected && !msg.connecting)
@@ -163,9 +253,14 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
163
253
  return;
164
254
  }
165
255
  case "graine:ended":
256
+ clearTimer(endWatchdogRef);
166
257
  setConnected(false);
167
258
  setConnecting(false);
168
- onEnded?.();
259
+ connectedRef.current = false;
260
+ if (!endedNotifiedRef.current) {
261
+ endedNotifiedRef.current = true;
262
+ onEnded?.();
263
+ }
169
264
  return;
170
265
  case "graine:mic-denied":
171
266
  client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
@@ -210,7 +305,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
210
305
  }
211
306
  void beginCall();
212
307
  }, [beginCall, ready]),
213
- end: useCallback(() => post({ type: "graine:end-call" }), [post]),
308
+ end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
309
+ connectivity,
310
+ retry: restartLoad,
214
311
  setMuted: useCallback((m) => {
215
312
  setMutedState(m);
216
313
  post({ type: "graine:set-muted", muted: m });
@@ -222,6 +319,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
222
319
  const first = activeSeenRef.current === null;
223
320
  activeSeenRef.current = active;
224
321
  if (active) {
322
+ if (attemptRef.current >= RETRY_DELAYS_MS.length) {
323
+ restartLoad();
324
+ }
225
325
  if (autoStart) {
226
326
  if (startedRef.current)
227
327
  return;
@@ -242,6 +342,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
242
342
  if (first)
243
343
  return;
244
344
  post({ type: "graine:end-call" });
345
+ armEndWatchdog();
245
346
  startedRef.current = false;
246
347
  pendingStartRef.current = false;
247
348
  setConnected(false);
@@ -278,7 +379,6 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
278
379
  }, [post]);
279
380
  if (!voiceAgentId)
280
381
  return _jsx(_Fragment, { children: children?.(api) });
281
- const mountStampRef = useRef(Date.now());
282
382
  const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
283
383
  const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
284
384
  `?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
@@ -286,5 +386,5 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
286
386
  (mountedIdleRef.current ? "&idle=1" : "") +
287
387
  `&pk=${encodeURIComponent(client.opts.publishableKey)}` +
288
388
  (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
289
- return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
389
+ return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, onLoadStart: armReadyTimer, onError: (e) => scheduleRetry(`load-error:${e?.nativeEvent?.code ?? ""}`), onHttpError: (e) => scheduleRetry(`http-${e?.nativeEvent?.statusCode ?? ""}`), onRenderProcessGone: () => scheduleRetry("render-process-gone"), style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
290
390
  }
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useContext } from "react";
3
- import { Image, Modal, Pressable, ScrollView, Text, View, useColorScheme } from "react-native";
2
+ import { useContext, useState } from "react";
3
+ import { Image, Modal, Platform, Pressable, ScrollView, Text, View, useColorScheme } from "react-native";
4
4
  import { Ctx, useGraineWidget } from "./index.js";
5
5
  import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
6
- import { widgetSurface } from "../protocol.js";
6
+ import { PLATFORM_GRAMMAR, resolvePlatform, resolveWidgetLook } from "../protocol.js";
7
7
  function tint(hex, alpha) {
8
8
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
9
9
  if (!m)
@@ -11,35 +11,74 @@ function tint(hex, alpha) {
11
11
  const n = parseInt(m[1], 16);
12
12
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
13
13
  }
14
- function WidgetRow({ row, hero, theme, accent, radius, onPick, }) {
14
+ const HERO_HEIGHT = { small: 100, medium: 140, large: 180 };
15
+ const THUMB = { small: 56, medium: 72, large: 96 };
16
+ const LIST_THUMB = { small: 36, medium: 44, large: 56 };
17
+ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick, }) {
15
18
  const r = Math.max(10, radius - 6);
16
- return (_jsxs(View, { style: {
19
+ const tight = look.density === "compact";
20
+ const pad = (variant === "list" ? (tight ? 6 : 9) : (tight ? 7 : 10)) + grammar.padBoost;
21
+ const gap = tight ? 6 : 10;
22
+ const rowIsButton = look.button === "row";
23
+ const outline = look.button === "outline";
24
+ const label = row.unavailable ? "Unavailable" : row.selectLabel;
25
+ const a11y = `${row.selectLabel}: ${row.title}`;
26
+ const badge = row.subtitle ? (_jsx(View, { style: { flexDirection: "row" }, children: _jsx(Text, { style: {
27
+ color: accent, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4,
28
+ textTransform: "uppercase", backgroundColor: tint(accent, 0.15),
29
+ borderRadius: 999, paddingHorizontal: 8, paddingVertical: 3, overflow: "hidden",
30
+ }, children: row.subtitle }) })) : null;
31
+ const titleLine = (big) => (_jsxs(View, { style: { flexDirection: "row", alignItems: "baseline", gap: 8 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: theme.text, fontSize: big ? 16.5 : (variant === "list" ? 13.5 : 14.5), fontWeight: grammar.titleWeight, flex: 1 }, children: row.title }), row.price ? (_jsx(Text, { style: {
32
+ color: theme.text, fontSize: big ? 17 : (variant === "list" ? 13.5 : 14.5), fontWeight: "800",
33
+ fontVariant: ["tabular-nums"],
34
+ }, children: row.price })) : null] }));
35
+ const facts = look.facts && row.facts.length ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 2 }, children: (variant === "list" ? row.facts.slice(0, 2) : row.facts).map((f, i) => (_jsx(Text, { style: {
36
+ color: theme.muted, fontSize: variant === "list" ? 10.5 : 11.5, fontWeight: "600",
37
+ backgroundColor: grammar.chipOutlined ? "transparent" : tint(theme.border, 0.35),
38
+ borderWidth: grammar.chipOutlined ? 1 : 0, borderColor: theme.border,
39
+ borderRadius: grammar.chipRadius, paddingHorizontal: variant === "list" ? 6 : 8, paddingVertical: variant === "list" ? 2 : 4, overflow: "hidden",
40
+ }, children: f }, `${f}-${i}`))) })) : null;
41
+ const button = (wide) => (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, hitSlop: wide ? undefined : 8, style: {
42
+ minHeight: 44, justifyContent: "center", alignItems: "center",
43
+ borderRadius: wide ? grammar.buttonRadius : (grammar.pillButtons ? 999 : grammar.buttonRadius), paddingHorizontal: wide ? 12 : 14,
44
+ marginTop: wide ? 8 : 0,
45
+ backgroundColor: row.unavailable || outline ? "transparent" : accent,
46
+ borderWidth: row.unavailable || outline ? 1.5 : 0,
47
+ borderColor: row.unavailable ? theme.border : accent,
48
+ }, children: _jsx(Text, { style: {
49
+ color: row.unavailable ? theme.muted : (outline ? accent : theme.onAccent),
50
+ fontSize: wide ? 14 : 12.5, fontWeight: "700",
51
+ }, children: label }) }));
52
+ const image = (style) => row.image ? (_jsx(Image, { source: { uri: row.image }, accessibilityIgnoresInvertColors: true, resizeMode: "cover", style: { borderRadius: Math.max(8, radius - 8), backgroundColor: theme.border, ...style } })) : null;
53
+ const wrap = (children, style) => rowIsButton ? (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, style: style, children: children })) : (_jsx(View, { style: style, children: children }));
54
+ if (variant === "hero") {
55
+ return wrap(_jsxs(_Fragment, { children: [image({ width: "100%", height: HERO_HEIGHT[look.imageSize] }), _jsxs(View, { style: { gap: 3 }, children: [badge, titleLine(true), facts, rowIsButton ? null : button(true)] })] }), {
56
+ borderRadius: r, borderWidth: 1, borderColor: theme.border, padding: pad,
57
+ opacity: row.unavailable ? 0.55 : 1, gap, flexDirection: "column",
58
+ });
59
+ }
60
+ if (variant === "list") {
61
+ const t = LIST_THUMB[look.imageSize];
62
+ return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [titleLine(false), facts] }), rowIsButton ? (_jsx(Text, { style: { color: theme.muted, fontSize: 22, fontWeight: "600", paddingLeft: 4 }, children: "›" })) : button(false)] }), {
63
+ flexDirection: "row", alignItems: "center", gap,
64
+ paddingVertical: pad, paddingHorizontal: 2, minHeight: 44,
65
+ borderBottomWidth: 1, borderBottomColor: theme.border, marginLeft: grammar.separatorInset,
66
+ opacity: row.unavailable ? 0.55 : 1,
67
+ });
68
+ }
69
+ if (variant === "compact") {
70
+ return wrap(_jsxs(_Fragment, { children: [_jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [badge, titleLine(false), facts] }), rowIsButton ? null : button(false)] }), {
17
71
  borderRadius: r, borderWidth: 1, borderColor: theme.border,
18
- padding: 10, opacity: row.unavailable ? 0.55 : 1,
19
- flexDirection: hero ? "column" : "row", gap: 10, alignItems: hero ? "stretch" : "center",
20
- }, children: [row.image ? (_jsx(Image, { source: { uri: row.image }, accessibilityIgnoresInvertColors: true, style: hero
21
- ? { width: "100%", height: 140, borderRadius: Math.max(8, radius - 8), backgroundColor: theme.border }
22
- : { width: 72, height: 72, borderRadius: Math.max(8, radius - 8), backgroundColor: theme.border }, resizeMode: "cover" })) : null, _jsxs(View, { style: { flex: hero ? undefined : 1, minWidth: 0, gap: 3 }, children: [row.subtitle ? (_jsx(View, { style: { flexDirection: "row" }, children: _jsx(Text, { style: {
23
- color: accent, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4,
24
- textTransform: "uppercase", backgroundColor: tint(accent, 0.15),
25
- borderRadius: 999, paddingHorizontal: 8, paddingVertical: 3, overflow: "hidden",
26
- }, children: row.subtitle }) })) : null, _jsxs(View, { style: { flexDirection: "row", alignItems: "baseline", gap: 8 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: theme.text, fontSize: hero ? 16.5 : 14.5, fontWeight: "700", flex: 1 }, children: row.title }), row.price ? (_jsx(Text, { style: {
27
- color: theme.text, fontSize: hero ? 17 : 14.5, fontWeight: "800",
28
- fontVariant: ["tabular-nums"],
29
- }, children: row.price })) : null] }), row.facts.length ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 2 }, children: row.facts.map((f, i) => (_jsx(Text, { style: {
30
- color: theme.muted, fontSize: 11.5, fontWeight: "600",
31
- backgroundColor: tint(theme.border, 0.35),
32
- borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4, overflow: "hidden",
33
- }, children: f }, `${f}-${i}`))) })) : null, hero ? (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: `${row.selectLabel}: ${row.title}`, style: {
34
- marginTop: 8, height: 44, borderRadius: 11, alignItems: "center", justifyContent: "center",
35
- backgroundColor: row.unavailable ? "transparent" : accent,
36
- borderWidth: row.unavailable ? 1 : 0, borderColor: theme.border,
37
- }, children: _jsx(Text, { style: { color: row.unavailable ? theme.muted : theme.onAccent, fontSize: 14, fontWeight: "700" }, children: row.unavailable ? "Unavailable" : row.selectLabel }) })) : null] }), hero ? null : (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: `${row.selectLabel}: ${row.title}`, hitSlop: 8, style: {
38
- minHeight: 44, justifyContent: "center",
39
- backgroundColor: row.unavailable ? "transparent" : accent,
40
- borderWidth: row.unavailable ? 1 : 0, borderColor: theme.border,
41
- borderRadius: 999, paddingHorizontal: 14,
42
- }, children: _jsx(Text, { style: { color: row.unavailable ? theme.muted : theme.onAccent, fontSize: 12.5, fontWeight: "700" }, children: row.unavailable ? "Unavailable" : row.selectLabel }) }))] }));
72
+ paddingVertical: pad, paddingHorizontal: pad + 2,
73
+ flexDirection: "row", alignItems: "center", gap,
74
+ opacity: row.unavailable ? 0.55 : 1, minHeight: 44,
75
+ });
76
+ }
77
+ const t = THUMB[look.imageSize];
78
+ return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 3 }, children: [badge, titleLine(false), facts] }), rowIsButton ? null : button(false)] }), {
79
+ borderRadius: r, borderWidth: 1, borderColor: theme.border, padding: pad,
80
+ opacity: row.unavailable ? 0.55 : 1, flexDirection: "row", gap, alignItems: "center",
81
+ });
43
82
  }
44
83
  function AnsweredLine({ answered, theme, accent, radius, onClear }) {
45
84
  const d = (answered.data || {});
@@ -60,9 +99,13 @@ export function GraineWidget(props) {
60
99
  const { widget, answered, submit, dismiss } = useGraineWidget();
61
100
  const appearance = useContext(Ctx)?.appearance ?? null;
62
101
  const scheme = useColorScheme();
102
+ const [pagerWidth, setPagerWidth] = useState(0);
103
+ const [page, setPage] = useState(0);
104
+ const look = resolveWidgetLook(widget?.style, widget?.kind);
105
+ const grammar = PLATFORM_GRAMMAR[resolvePlatform(look.platform, Platform.OS, Platform.constants?.Manufacturer ?? Platform.constants?.Brand)];
63
106
  const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
64
- const accent = props.accent ?? dash.accent;
65
- const radius = props.radius ?? dash.radius;
107
+ const accent = props.accent ?? look.accent ?? dash.accent;
108
+ const radius = props.radius ?? look.radius ?? (look.platform !== "auto" ? grammar.radius : dash.radius);
66
109
  const theme = {
67
110
  ...resolveWidgetPalette(appearance, { scheme, accent }),
68
111
  ...(props.theme ?? {}),
@@ -132,7 +175,7 @@ export function GraineWidget(props) {
132
175
  const isChips = (widget.kind === "choices" || isQuickActions) && options.length > 0;
133
176
  const isCalendar = widget.kind === "calendar_slots" && Array.isArray(data?.days) && data.days.length > 0;
134
177
  const drawable = isProducts || isChips || isCalendar;
135
- const surface = drawable ? widgetSurface(widget.kind) : "card";
178
+ const surface = drawable ? look.surface : "card";
136
179
  const pick = (it) => submit(normalised.toSubmit(it), normalised.toSay(it));
137
180
  const body = (_jsx(_Fragment, { children: isCalendar ? (_jsxs(View, { style: { gap: 12 }, children: [data.note ? (_jsx(Text, { style: { color: theme.muted, fontSize: 12.5, lineHeight: 18 }, children: String(data.note) })) : null, data.days.slice(0, 4).map((d, di) => {
138
181
  const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
@@ -144,7 +187,31 @@ export function GraineWidget(props) {
144
187
  borderWidth: 1, borderColor: theme.border, borderRadius: 11,
145
188
  paddingHorizontal: 14,
146
189
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
147
- })] })) : isProducts ? (_jsx(View, { style: { gap: 10 }, children: rows.map((row, i) => (_jsx(WidgetRow, { row: row, hero: surface === "screen" && i === 0 && !!row.image, theme: theme, accent: accent, radius: radius, onPick: () => pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`))) })) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
190
+ })] })) : isProducts ? ((() => {
191
+ const draw = (row, i, variant) => (_jsx(WidgetRow, { row: row, variant: variant, look: look, grammar: grammar, theme: theme, accent: accent, radius: radius, onPick: () => pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`));
192
+ const gap = look.density === "compact" ? 6 : 10;
193
+ if (look.preset === "spotlight") {
194
+ const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
195
+ const step = cardW + gap;
196
+ return (_jsxs(View, { onLayout: (e) => setPagerWidth(e?.nativeEvent?.layout?.width ?? 0), children: [_jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, snapToInterval: step, decelerationRate: "fast", onMomentumScrollEnd: (e) => setPage(Math.round((e?.nativeEvent?.contentOffset?.x ?? 0) / step)), contentContainerStyle: { gap, paddingRight: 24 }, children: rows.map((row, i) => (_jsx(View, { style: { width: cardW }, children: draw(row, i, "hero") }, `${row.id}-${i}`))) }), rows.length > 1 ? (_jsx(View, { style: { flexDirection: "row", justifyContent: "center", gap: 5, paddingTop: 10 }, accessibilityLabel: `${Math.min(page + 1, rows.length)} of ${rows.length}`, children: rows.map((row, i) => (_jsx(View, { style: {
197
+ height: 5, borderRadius: 999, width: i === page ? 14 : 5,
198
+ backgroundColor: i === page ? accent : theme.border,
199
+ } }, `dot-${row.id}-${i}`))) })) : null] }));
200
+ }
201
+ if (look.columns === 2) {
202
+ return (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap, justifyContent: "space-between" }, children: rows.map((row, i) => (_jsx(View, { style: { width: "48%" }, children: draw(row, i, "hero") }, `${row.id}-${i}`))) }));
203
+ }
204
+ if (look.preset === "gallery") {
205
+ return _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "hero")) });
206
+ }
207
+ if (look.preset === "list") {
208
+ return _jsx(View, { children: rows.map((row, i) => draw(row, i, "list")) });
209
+ }
210
+ if (look.preset === "compact") {
211
+ return _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "compact")) });
212
+ }
213
+ return _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "row")) });
214
+ })()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
148
215
  const label = String(o?.label ?? o?.title ?? o ?? "");
149
216
  const id = String(o?.id ?? label);
150
217
  return (_jsx(Pressable, { onPress: () => submit(isQuickActions ? { id, label } : { chosen: id, label }, `I'll go with ${label}.`), accessibilityRole: "button", accessibilityLabel: label, style: {
@@ -155,7 +222,7 @@ export function GraineWidget(props) {
155
222
  const title = data?.title ? String(data.title) : "";
156
223
  const prompt = data?.prompt ? String(data.prompt) : "";
157
224
  if (surface === "screen") {
158
- return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: dismiss, transparent: false, children: _jsxs(View, { style: { flex: 1, backgroundColor: theme.surface }, children: [_jsxs(View, { style: {
225
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: dismiss, transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, children: _jsxs(View, { style: { flex: 1, backgroundColor: theme.surface }, children: [_jsxs(View, { style: {
159
226
  paddingTop: 52, paddingHorizontal: 18, paddingBottom: 12,
160
227
  flexDirection: "row", alignItems: "center", gap: 12,
161
228
  }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close and keep talking", hitSlop: 10, style: {
@@ -167,11 +234,11 @@ export function GraineWidget(props) {
167
234
  }, children: [_jsx(Text, { style: { color: theme.muted, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4, textTransform: "uppercase" }, children: "Still listening" }), _jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontSize: 13.5, marginTop: 2 }, children: prompt || "Tap one, or just say it." })] })] }) })] }));
168
235
  }
169
236
  if (surface === "sheet") {
170
- return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", transparent: true, onRequestClose: dismiss, children: _jsxs(View, { style: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "flex-end" }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close", style: { flex: 1 } }), _jsxs(View, { style: {
237
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, onRequestClose: dismiss, children: _jsxs(View, { style: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "flex-end" }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close", style: { flex: 1 } }), _jsxs(View, { style: {
171
238
  backgroundColor: theme.surface,
172
239
  borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius),
173
240
  paddingHorizontal: 18, paddingTop: 10, paddingBottom: 28, gap: 14,
174
- }, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width: 38, height: 4, borderRadius: 999, backgroundColor: theme.border } }) }), title ? (_jsx(Text, { style: { color: theme.text, fontSize: 18, fontWeight: "800" }, children: title })) : null, prompt ? (_jsx(Text, { style: { color: theme.muted, fontSize: 13, lineHeight: 18, marginTop: -8 }, children: prompt })) : null, _jsx(ScrollView, { style: { maxHeight: 420 }, contentContainerStyle: { paddingBottom: 4 }, keyboardShouldPersistTaps: "handled", children: body }), _jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "None of these", style: {
241
+ }, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width: grammar.grabber.width, height: grammar.grabber.height, borderRadius: 999, backgroundColor: theme.border } }) }), title ? (_jsx(Text, { style: { color: theme.text, fontSize: 18, fontWeight: "800" }, children: title })) : null, prompt ? (_jsx(Text, { style: { color: theme.muted, fontSize: 13, lineHeight: 18, marginTop: -8 }, children: prompt })) : null, _jsx(ScrollView, { style: { maxHeight: 420 }, contentContainerStyle: { paddingBottom: 4 }, keyboardShouldPersistTaps: "handled", children: body }), _jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "None of these", style: {
175
242
  alignSelf: "flex-start", minHeight: 44, justifyContent: "center",
176
243
  paddingHorizontal: 16, borderRadius: 999, backgroundColor: tint(theme.border, 0.4),
177
244
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "700" }, children: "None of these" }) })] })] }) })] }));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.33.2",
3
+ "version": "0.33.4",
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",
@@ -32,18 +32,20 @@
32
32
  "scripts": {
33
33
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
34
34
  "typecheck": "tsc -p tsconfig.build.json --noEmit",
35
- "test": "npm run build && node --test test/*.test.mjs",
36
- "prepublishOnly": "npm test && node scripts/verify-package-clean.mjs"
35
+ "test": "npm run build && node --test test/*.test.mjs && npm run test:render",
36
+ "prepublishOnly": "npm test && node scripts/verify-package-clean.mjs",
37
+ "test:render": "node --import ./test/harness/register.mjs --test test/render/*.test.mjs"
37
38
  },
38
39
  "peerDependencies": {
40
+ "@jambonz/client-sdk-react-native": "^0.1.4",
39
41
  "react": ">=17",
40
- "react-native": ">=0.68",
41
- "@jambonz/client-sdk-react-native": "^0.1.4"
42
+ "react-native": ">=0.68"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/react": "^18.2.0",
45
46
  "react": "^18.2.0",
46
47
  "react-native": "^0.74.0",
48
+ "react-test-renderer": "^18.2.0",
47
49
  "typescript": "^5.4.0"
48
50
  },
49
51
  "keywords": [