@graineai/inapp-react-native 0.33.3 → 0.33.5
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.js +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/protocol.d.ts +115 -0
- package/dist/protocol.js +220 -0
- package/dist/react-native/index.d.ts +12 -0
- package/dist/react-native/index.js +96 -4
- package/dist/react-native/loader.d.ts +14 -0
- package/dist/react-native/loader.js +73 -0
- package/dist/react-native/voice-launcher.d.ts +15 -1
- package/dist/react-native/voice-launcher.js +125 -4
- package/dist/react-native/widget.js +164 -42
- package/package.json +3 -3
package/dist/client.js
CHANGED
|
@@ -238,6 +238,9 @@ export class GraineInAppClient {
|
|
|
238
238
|
case "widget":
|
|
239
239
|
this.emit("widget", msg.widget);
|
|
240
240
|
break;
|
|
241
|
+
case "widget_pending":
|
|
242
|
+
this.emit("widget_pending", msg.widget || {});
|
|
243
|
+
break;
|
|
241
244
|
case "app_action":
|
|
242
245
|
void this.runAction(msg.action);
|
|
243
246
|
break;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
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";
|
|
5
|
+
export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOADER_KIND, DEFAULT_LOADER_TEXT, LOADER_TEXT_MAX, normaliseLoaderStyle, resolveLoader, } from "./protocol.js";
|
|
6
|
+
export type { LoaderKind, LoaderSize, LoaderSpeed, LoaderMoment, LoaderText, LoaderStyle, LoaderLook } from "./protocol.js";
|
|
7
|
+
export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
|
|
8
|
+
export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
|
|
9
|
+
export type { WidgetSelect } from "./protocol.js";
|
|
10
|
+
export type { WidgetSelectAction } from "./protocol.js";
|
|
3
11
|
export { GraineWidget } from "./react-native/widget.js";
|
|
12
|
+
export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
|
|
13
|
+
export type { GraineLoaderProps } from "./react-native/loader.js";
|
|
14
|
+
export { PENDING_WIDGET_MS } from "./react-native/index.js";
|
|
4
15
|
export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
|
|
5
16
|
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
|
|
6
17
|
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type FrictionConfig, type FrictionScreenRules, type FrictionNudge, type FrictionSignal, type FrictionKind, } from "./friction.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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";
|
|
3
|
+
export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOADER_KIND, DEFAULT_LOADER_TEXT, LOADER_TEXT_MAX, normaliseLoaderStyle, resolveLoader, } from "./protocol.js";
|
|
4
|
+
export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
|
|
5
|
+
export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
|
|
2
6
|
export { GraineWidget } from "./react-native/widget.js";
|
|
7
|
+
export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
|
|
8
|
+
export { PENDING_WIDGET_MS } from "./react-native/index.js";
|
|
3
9
|
export { GraineInAppClient, } from "./client.js";
|
|
4
10
|
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
|
|
5
11
|
export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface AppEvent {
|
|
|
33
33
|
detail?: string;
|
|
34
34
|
cooldown_seconds?: number;
|
|
35
35
|
max_per_session?: number;
|
|
36
|
+
quiet?: boolean;
|
|
37
|
+
data?: Record<string, unknown>;
|
|
36
38
|
}
|
|
37
39
|
export interface AppEventFrame {
|
|
38
40
|
type: "app_event";
|
|
@@ -64,3 +66,116 @@ export declare const UNNAMED_TAP = "tap:screen";
|
|
|
64
66
|
export declare function resolveWidgetImages(data: Record<string, unknown>, baseUrl: string): Record<string, unknown>;
|
|
65
67
|
export type WidgetSurface = "screen" | "sheet" | "card";
|
|
66
68
|
export declare function widgetSurface(kind: string | null | undefined): WidgetSurface;
|
|
69
|
+
export type WidgetPreset = "gallery" | "cards" | "list" | "spotlight" | "compact";
|
|
70
|
+
export type WidgetImageSize = "small" | "medium" | "large";
|
|
71
|
+
export type WidgetDensity = "comfortable" | "compact";
|
|
72
|
+
export type WidgetButton = "filled" | "outline" | "row";
|
|
73
|
+
export type WidgetSelect = "one" | "many";
|
|
74
|
+
export type WidgetPlatform = "auto" | "ios" | "material" | "oneui";
|
|
75
|
+
export interface WidgetStyle {
|
|
76
|
+
preset?: WidgetPreset;
|
|
77
|
+
imageSize?: WidgetImageSize;
|
|
78
|
+
density?: WidgetDensity;
|
|
79
|
+
button?: WidgetButton;
|
|
80
|
+
facts?: boolean;
|
|
81
|
+
columns?: 1 | 2;
|
|
82
|
+
surface?: "auto" | WidgetSurface;
|
|
83
|
+
radius?: number;
|
|
84
|
+
accent?: string;
|
|
85
|
+
platform?: WidgetPlatform;
|
|
86
|
+
select?: WidgetSelect;
|
|
87
|
+
maxSelect?: number;
|
|
88
|
+
}
|
|
89
|
+
export declare const WIDGET_PRESETS: ReadonlyArray<WidgetPreset>;
|
|
90
|
+
export declare const WIDGET_IMAGE_SIZES: ReadonlyArray<WidgetImageSize>;
|
|
91
|
+
export declare const WIDGET_DENSITIES: ReadonlyArray<WidgetDensity>;
|
|
92
|
+
export declare const WIDGET_BUTTONS: ReadonlyArray<WidgetButton>;
|
|
93
|
+
export declare const WIDGET_SURFACE_CHOICES: ReadonlyArray<"auto" | WidgetSurface>;
|
|
94
|
+
export declare const WIDGET_PLATFORMS: ReadonlyArray<WidgetPlatform>;
|
|
95
|
+
export declare const WIDGET_SELECTS: ReadonlyArray<WidgetSelect>;
|
|
96
|
+
export declare const MAX_SELECT_DEFAULT = 3;
|
|
97
|
+
export declare const DEFAULT_WIDGET_PRESET: WidgetPreset;
|
|
98
|
+
export declare function normaliseWidgetStyle(raw: unknown): WidgetStyle;
|
|
99
|
+
export interface WidgetLook {
|
|
100
|
+
preset: WidgetPreset;
|
|
101
|
+
imageSize: WidgetImageSize;
|
|
102
|
+
density: WidgetDensity;
|
|
103
|
+
button: WidgetButton;
|
|
104
|
+
facts: boolean;
|
|
105
|
+
columns: 1 | 2;
|
|
106
|
+
surface: WidgetSurface;
|
|
107
|
+
radius?: number;
|
|
108
|
+
accent?: string;
|
|
109
|
+
platform: WidgetPlatform;
|
|
110
|
+
select: WidgetSelect;
|
|
111
|
+
maxSelect: number;
|
|
112
|
+
}
|
|
113
|
+
export declare function resolveWidgetLook(style: WidgetStyle | null | undefined, kind: string | null | undefined): WidgetLook;
|
|
114
|
+
export declare function manySummary(titles: string[], verb?: string): string;
|
|
115
|
+
export interface PlatformGrammar {
|
|
116
|
+
radius: number;
|
|
117
|
+
chipRadius: number;
|
|
118
|
+
buttonRadius: number;
|
|
119
|
+
pillButtons: boolean;
|
|
120
|
+
separatorInset: number;
|
|
121
|
+
grabber: {
|
|
122
|
+
width: number;
|
|
123
|
+
height: number;
|
|
124
|
+
};
|
|
125
|
+
titleWeight: "600" | "700" | "500";
|
|
126
|
+
padBoost: number;
|
|
127
|
+
chipOutlined: boolean;
|
|
128
|
+
}
|
|
129
|
+
export declare const PLATFORM_GRAMMAR: Record<Exclude<WidgetPlatform, "auto">, PlatformGrammar>;
|
|
130
|
+
export declare function resolvePlatform(platform: WidgetPlatform | undefined, os: string | undefined, manufacturer?: string | null): Exclude<WidgetPlatform, "auto">;
|
|
131
|
+
export type LoaderKind = "dots" | "pulse" | "ring" | "bars" | "mark";
|
|
132
|
+
export type LoaderSize = "small" | "medium" | "large";
|
|
133
|
+
export type LoaderSpeed = "calm" | "normal" | "quick";
|
|
134
|
+
export type LoaderMoment = "connecting" | "reconnecting" | "thinking" | "component";
|
|
135
|
+
export interface LoaderText {
|
|
136
|
+
connecting?: string;
|
|
137
|
+
reconnecting?: string;
|
|
138
|
+
thinking?: string;
|
|
139
|
+
component?: string;
|
|
140
|
+
}
|
|
141
|
+
export interface LoaderStyle {
|
|
142
|
+
kind?: LoaderKind;
|
|
143
|
+
color?: string;
|
|
144
|
+
size?: LoaderSize;
|
|
145
|
+
speed?: LoaderSpeed;
|
|
146
|
+
text?: LoaderText;
|
|
147
|
+
skeleton?: boolean;
|
|
148
|
+
}
|
|
149
|
+
export declare const LOADER_KINDS: ReadonlyArray<LoaderKind>;
|
|
150
|
+
export declare const LOADER_SIZES: ReadonlyArray<LoaderSize>;
|
|
151
|
+
export declare const LOADER_SPEEDS: ReadonlyArray<LoaderSpeed>;
|
|
152
|
+
export declare const LOADER_MOMENTS: ReadonlyArray<LoaderMoment>;
|
|
153
|
+
export declare const DEFAULT_LOADER_KIND: LoaderKind;
|
|
154
|
+
export declare const DEFAULT_LOADER_TEXT: Required<LoaderText>;
|
|
155
|
+
export declare const LOADER_TEXT_MAX = 40;
|
|
156
|
+
export declare function normaliseLoaderStyle(raw: unknown): LoaderStyle;
|
|
157
|
+
export interface LoaderLook {
|
|
158
|
+
kind: LoaderKind;
|
|
159
|
+
color: string | null;
|
|
160
|
+
px: number;
|
|
161
|
+
periodMs: number;
|
|
162
|
+
text: Required<LoaderText>;
|
|
163
|
+
skeleton: boolean;
|
|
164
|
+
}
|
|
165
|
+
export declare function resolveLoader(style: LoaderStyle | null | undefined): LoaderLook;
|
|
166
|
+
export interface WidgetSelectAction {
|
|
167
|
+
path?: string;
|
|
168
|
+
action?: string;
|
|
169
|
+
args?: Record<string, string>;
|
|
170
|
+
say?: string;
|
|
171
|
+
}
|
|
172
|
+
export declare const NAVIGATE_ACTION = "open_screen";
|
|
173
|
+
export declare const SELECT_ARG_MAX = 10;
|
|
174
|
+
export declare const SELECT_VALUE_MAX = 200;
|
|
175
|
+
export declare function normaliseSelectAction(raw: unknown): WidgetSelectAction;
|
|
176
|
+
export declare function fillSelectTemplate(template: string, item: Record<string, unknown>): string;
|
|
177
|
+
export declare function selectCall(onSelect: WidgetSelectAction | undefined | null, item: Record<string, unknown>): {
|
|
178
|
+
name: string;
|
|
179
|
+
args: Record<string, string>;
|
|
180
|
+
} | null;
|
|
181
|
+
export declare function fillSelectArgs(args: Record<string, string> | undefined, item: Record<string, unknown>): Record<string, string>;
|
package/dist/protocol.js
CHANGED
|
@@ -41,3 +41,223 @@ 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 WIDGET_SELECTS = ["one", "many"];
|
|
51
|
+
export const MAX_SELECT_DEFAULT = 3;
|
|
52
|
+
export const DEFAULT_WIDGET_PRESET = "cards";
|
|
53
|
+
export function normaliseWidgetStyle(raw) {
|
|
54
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
55
|
+
return {};
|
|
56
|
+
const r = raw;
|
|
57
|
+
const out = {};
|
|
58
|
+
const pick = (v, allowed) => {
|
|
59
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
60
|
+
return allowed.includes(s) ? s : undefined;
|
|
61
|
+
};
|
|
62
|
+
const preset = pick(r.preset, WIDGET_PRESETS);
|
|
63
|
+
if (preset)
|
|
64
|
+
out.preset = preset;
|
|
65
|
+
const imageSize = pick(r.imageSize, WIDGET_IMAGE_SIZES);
|
|
66
|
+
if (imageSize)
|
|
67
|
+
out.imageSize = imageSize;
|
|
68
|
+
const density = pick(r.density, WIDGET_DENSITIES);
|
|
69
|
+
if (density)
|
|
70
|
+
out.density = density;
|
|
71
|
+
const button = pick(r.button, WIDGET_BUTTONS);
|
|
72
|
+
if (button)
|
|
73
|
+
out.button = button;
|
|
74
|
+
if (typeof r.facts === "boolean")
|
|
75
|
+
out.facts = r.facts;
|
|
76
|
+
if (r.columns === 2 || r.columns === "2")
|
|
77
|
+
out.columns = 2;
|
|
78
|
+
else if (r.columns === 1 || r.columns === "1")
|
|
79
|
+
out.columns = 1;
|
|
80
|
+
const surface = pick(r.surface, WIDGET_SURFACE_CHOICES);
|
|
81
|
+
if (surface)
|
|
82
|
+
out.surface = surface;
|
|
83
|
+
if (typeof r.radius === "number" && Number.isFinite(r.radius))
|
|
84
|
+
out.radius = Math.max(0, Math.min(24, Math.round(r.radius)));
|
|
85
|
+
if (typeof r.accent === "string" && /^#[0-9a-f]{6}$/i.test(r.accent.trim()))
|
|
86
|
+
out.accent = r.accent.trim().toUpperCase();
|
|
87
|
+
const platform = pick(r.platform, WIDGET_PLATFORMS);
|
|
88
|
+
if (platform)
|
|
89
|
+
out.platform = platform;
|
|
90
|
+
const select = pick(r.select, WIDGET_SELECTS);
|
|
91
|
+
if (select)
|
|
92
|
+
out.select = select;
|
|
93
|
+
if (typeof r.maxSelect === "number" && Number.isFinite(r.maxSelect)) {
|
|
94
|
+
out.maxSelect = Math.max(2, Math.min(8, Math.round(r.maxSelect)));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const PRESET_DEFAULTS = {
|
|
99
|
+
gallery: { imageSize: "large", button: "filled" },
|
|
100
|
+
cards: { imageSize: "medium", button: "filled" },
|
|
101
|
+
list: { imageSize: "small", button: "row" },
|
|
102
|
+
spotlight: { imageSize: "large", button: "filled" },
|
|
103
|
+
compact: { imageSize: "small", button: "outline" },
|
|
104
|
+
};
|
|
105
|
+
export function resolveWidgetLook(style, kind) {
|
|
106
|
+
const s = normaliseWidgetStyle(style);
|
|
107
|
+
const preset = s.preset ?? DEFAULT_WIDGET_PRESET;
|
|
108
|
+
const d = PRESET_DEFAULTS[preset];
|
|
109
|
+
const surface = s.surface && s.surface !== "auto" ? s.surface : widgetSurface(kind);
|
|
110
|
+
return {
|
|
111
|
+
preset,
|
|
112
|
+
imageSize: s.imageSize ?? d.imageSize,
|
|
113
|
+
density: s.density ?? "comfortable",
|
|
114
|
+
button: s.button ?? d.button,
|
|
115
|
+
facts: s.facts ?? true,
|
|
116
|
+
columns: (preset === "gallery" || preset === "cards") && s.columns === 2 ? 2 : 1,
|
|
117
|
+
surface,
|
|
118
|
+
...(s.radius !== undefined ? { radius: s.radius } : {}),
|
|
119
|
+
...(s.accent ? { accent: s.accent } : {}),
|
|
120
|
+
platform: s.platform ?? "auto",
|
|
121
|
+
select: s.select ?? "one",
|
|
122
|
+
maxSelect: s.maxSelect ?? MAX_SELECT_DEFAULT,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function manySummary(titles, verb = "compare") {
|
|
126
|
+
const names = titles.map((t) => String(t).trim()).filter(Boolean);
|
|
127
|
+
if (!names.length)
|
|
128
|
+
return "";
|
|
129
|
+
const list = names.length === 1 ? names[0]
|
|
130
|
+
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
131
|
+
return `Let's ${verb} ${list}.`;
|
|
132
|
+
}
|
|
133
|
+
export const PLATFORM_GRAMMAR = {
|
|
134
|
+
ios: { radius: 12, chipRadius: 8, buttonRadius: 12, pillButtons: false, separatorInset: 16, grabber: { width: 36, height: 5 }, titleWeight: "600", padBoost: 0, chipOutlined: false },
|
|
135
|
+
material: { radius: 16, chipRadius: 8, buttonRadius: 20, pillButtons: true, separatorInset: 0, grabber: { width: 32, height: 4 }, titleWeight: "500", padBoost: 0, chipOutlined: true },
|
|
136
|
+
oneui: { radius: 26, chipRadius: 999, buttonRadius: 999, pillButtons: true, separatorInset: 0, grabber: { width: 40, height: 4 }, titleWeight: "700", padBoost: 2, chipOutlined: false },
|
|
137
|
+
};
|
|
138
|
+
export function resolvePlatform(platform, os, manufacturer) {
|
|
139
|
+
if (platform && platform !== "auto")
|
|
140
|
+
return platform;
|
|
141
|
+
if (os === "android")
|
|
142
|
+
return /samsung/i.test(String(manufacturer ?? "")) ? "oneui" : "material";
|
|
143
|
+
return "ios";
|
|
144
|
+
}
|
|
145
|
+
export const LOADER_KINDS = ["dots", "pulse", "ring", "bars", "mark"];
|
|
146
|
+
export const LOADER_SIZES = ["small", "medium", "large"];
|
|
147
|
+
export const LOADER_SPEEDS = ["calm", "normal", "quick"];
|
|
148
|
+
export const LOADER_MOMENTS = ["connecting", "reconnecting", "thinking", "component"];
|
|
149
|
+
export const DEFAULT_LOADER_KIND = "dots";
|
|
150
|
+
export const DEFAULT_LOADER_TEXT = {
|
|
151
|
+
connecting: "Connecting…",
|
|
152
|
+
reconnecting: "Reconnecting…",
|
|
153
|
+
thinking: "Thinking…",
|
|
154
|
+
component: "Loading…",
|
|
155
|
+
};
|
|
156
|
+
export const LOADER_TEXT_MAX = 40;
|
|
157
|
+
export function normaliseLoaderStyle(raw) {
|
|
158
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
159
|
+
return {};
|
|
160
|
+
const r = raw;
|
|
161
|
+
const out = {};
|
|
162
|
+
const pick = (v, allowed) => {
|
|
163
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
164
|
+
return allowed.includes(s) ? s : undefined;
|
|
165
|
+
};
|
|
166
|
+
const kind = pick(r.kind, LOADER_KINDS);
|
|
167
|
+
if (kind)
|
|
168
|
+
out.kind = kind;
|
|
169
|
+
const size = pick(r.size, LOADER_SIZES);
|
|
170
|
+
if (size)
|
|
171
|
+
out.size = size;
|
|
172
|
+
const speed = pick(r.speed, LOADER_SPEEDS);
|
|
173
|
+
if (speed)
|
|
174
|
+
out.speed = speed;
|
|
175
|
+
if (typeof r.color === "string") {
|
|
176
|
+
const c = r.color.trim();
|
|
177
|
+
if (c.toLowerCase() === "accent")
|
|
178
|
+
out.color = "accent";
|
|
179
|
+
else if (/^#[0-9a-f]{6}$/i.test(c))
|
|
180
|
+
out.color = c.toUpperCase();
|
|
181
|
+
}
|
|
182
|
+
if (typeof r.skeleton === "boolean")
|
|
183
|
+
out.skeleton = r.skeleton;
|
|
184
|
+
if (r.text && typeof r.text === "object" && !Array.isArray(r.text)) {
|
|
185
|
+
const t = r.text;
|
|
186
|
+
const text = {};
|
|
187
|
+
for (const m of LOADER_MOMENTS) {
|
|
188
|
+
const v = typeof t[m] === "string" ? t[m].trim().slice(0, LOADER_TEXT_MAX) : "";
|
|
189
|
+
if (v)
|
|
190
|
+
text[m] = v;
|
|
191
|
+
}
|
|
192
|
+
if (Object.keys(text).length)
|
|
193
|
+
out.text = text;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
const LOADER_PX = { small: 14, medium: 20, large: 28 };
|
|
198
|
+
const LOADER_PERIOD = { calm: 1600, normal: 1100, quick: 700 };
|
|
199
|
+
export function resolveLoader(style) {
|
|
200
|
+
const s = normaliseLoaderStyle(style);
|
|
201
|
+
return {
|
|
202
|
+
kind: s.kind ?? DEFAULT_LOADER_KIND,
|
|
203
|
+
color: s.color && s.color !== "accent" ? s.color : null,
|
|
204
|
+
px: LOADER_PX[s.size ?? "medium"],
|
|
205
|
+
periodMs: LOADER_PERIOD[s.speed ?? "normal"],
|
|
206
|
+
text: { ...DEFAULT_LOADER_TEXT, ...(s.text ?? {}) },
|
|
207
|
+
skeleton: s.skeleton ?? true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const ACTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
211
|
+
export const NAVIGATE_ACTION = "open_screen";
|
|
212
|
+
export const SELECT_ARG_MAX = 10;
|
|
213
|
+
export const SELECT_VALUE_MAX = 200;
|
|
214
|
+
export function normaliseSelectAction(raw) {
|
|
215
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
216
|
+
return {};
|
|
217
|
+
const r = raw;
|
|
218
|
+
const out = {};
|
|
219
|
+
const name = String(r.action ?? "").trim().toLowerCase();
|
|
220
|
+
if (ACTION_NAME.test(name))
|
|
221
|
+
out.action = name;
|
|
222
|
+
const path = typeof r.path === "string" ? r.path.trim().slice(0, SELECT_VALUE_MAX) : "";
|
|
223
|
+
if (path)
|
|
224
|
+
out.path = path;
|
|
225
|
+
if (r.args && typeof r.args === "object" && !Array.isArray(r.args)) {
|
|
226
|
+
const args = {};
|
|
227
|
+
for (const [k, v] of Object.entries(r.args)) {
|
|
228
|
+
const key = String(k).trim();
|
|
229
|
+
if (!key || Object.keys(args).length >= SELECT_ARG_MAX)
|
|
230
|
+
continue;
|
|
231
|
+
if (v === null || v === undefined || typeof v === "object")
|
|
232
|
+
continue;
|
|
233
|
+
args[key] = String(v).slice(0, SELECT_VALUE_MAX);
|
|
234
|
+
}
|
|
235
|
+
if (Object.keys(args).length)
|
|
236
|
+
out.args = args;
|
|
237
|
+
}
|
|
238
|
+
const say = typeof r.say === "string" ? r.say.trim().slice(0, SELECT_VALUE_MAX) : "";
|
|
239
|
+
if (say)
|
|
240
|
+
out.say = say;
|
|
241
|
+
return out.action || out.path ? out : {};
|
|
242
|
+
}
|
|
243
|
+
export function fillSelectTemplate(template, item) {
|
|
244
|
+
return String(template).replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_, key) => {
|
|
245
|
+
const v = item[key];
|
|
246
|
+
return v === null || v === undefined ? "" : String(v);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
export function selectCall(onSelect, item) {
|
|
250
|
+
const s = onSelect ?? {};
|
|
251
|
+
if (!s.action && !s.path)
|
|
252
|
+
return null;
|
|
253
|
+
const args = fillSelectArgs(s.args, item);
|
|
254
|
+
if (s.path)
|
|
255
|
+
args.path = fillSelectTemplate(s.path, item);
|
|
256
|
+
return { name: s.action || NAVIGATE_ACTION, args };
|
|
257
|
+
}
|
|
258
|
+
export function fillSelectArgs(args, item) {
|
|
259
|
+
const out = {};
|
|
260
|
+
for (const [k, v] of Object.entries(args ?? {}))
|
|
261
|
+
out[k] = fillSelectTemplate(v, item);
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
@@ -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, type WidgetSelectAction } 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";
|
|
@@ -68,11 +69,15 @@ export type GraineEvent = {
|
|
|
68
69
|
screen: string;
|
|
69
70
|
detail: string;
|
|
70
71
|
};
|
|
72
|
+
export declare const PENDING_WIDGET_MS = 12000;
|
|
73
|
+
export declare const AWAITING_REPLY_MS = 12000;
|
|
71
74
|
export interface GraineWidget {
|
|
72
75
|
id: string;
|
|
73
76
|
kind: string;
|
|
74
77
|
data: Record<string, unknown>;
|
|
75
78
|
fallbackText: string;
|
|
79
|
+
style?: WidgetStyle;
|
|
80
|
+
onSelect?: WidgetSelectAction;
|
|
76
81
|
}
|
|
77
82
|
export interface Caption {
|
|
78
83
|
role: "agent" | "customer";
|
|
@@ -130,6 +135,13 @@ export declare function useGraineWidget(): {
|
|
|
130
135
|
data: Record<string, unknown>;
|
|
131
136
|
summary: string;
|
|
132
137
|
} | null;
|
|
138
|
+
loading: {
|
|
139
|
+
id: string;
|
|
140
|
+
kind: string;
|
|
141
|
+
} | null;
|
|
142
|
+
awaitingReply: boolean;
|
|
143
|
+
closed: GraineWidget | null;
|
|
144
|
+
reopen: () => void;
|
|
133
145
|
submit: (data: Record<string, unknown>, summary: string) => void;
|
|
134
146
|
dismiss: () => void;
|
|
135
147
|
};
|
|
@@ -3,11 +3,13 @@ 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, normaliseSelectAction, selectCall, fillSelectTemplate } 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";
|
|
10
10
|
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
|
|
11
|
+
export const PENDING_WIDGET_MS = 12000;
|
|
12
|
+
export const AWAITING_REPLY_MS = 12000;
|
|
11
13
|
export const Ctx = createContext(null);
|
|
12
14
|
let defaultClient = null;
|
|
13
15
|
export function setDefaultClient(client) {
|
|
@@ -396,36 +398,126 @@ export function useGraineWidget() {
|
|
|
396
398
|
kind: String(w.kind || ""),
|
|
397
399
|
data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
|
|
398
400
|
fallbackText: String(w.fallback_text || ""),
|
|
401
|
+
style: normaliseWidgetStyle(w.style),
|
|
402
|
+
onSelect: normaliseSelectAction(w.on_select ?? w.onSelect),
|
|
399
403
|
}
|
|
400
404
|
: null, [client]);
|
|
401
405
|
const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
|
|
402
406
|
const [answered, setAnswered] = useState(null);
|
|
407
|
+
const [loading, setLoading] = useState(null);
|
|
408
|
+
const loadingTimer = useRef(null);
|
|
409
|
+
const [awaitingReply, setAwaitingReply] = useState(false);
|
|
410
|
+
const [closed, setClosed] = useState(null);
|
|
411
|
+
const awaitingTimer = useRef(null);
|
|
412
|
+
useEffect(() => () => {
|
|
413
|
+
if (loadingTimer.current)
|
|
414
|
+
clearTimeout(loadingTimer.current);
|
|
415
|
+
if (awaitingTimer.current)
|
|
416
|
+
clearTimeout(awaitingTimer.current);
|
|
417
|
+
}, []);
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const done = () => {
|
|
420
|
+
if (awaitingTimer.current) {
|
|
421
|
+
clearTimeout(awaitingTimer.current);
|
|
422
|
+
awaitingTimer.current = null;
|
|
423
|
+
}
|
|
424
|
+
setAwaitingReply(false);
|
|
425
|
+
};
|
|
426
|
+
const offs = [
|
|
427
|
+
client.on("agent_speaking", (speaking) => { if (speaking !== false)
|
|
428
|
+
done(); }),
|
|
429
|
+
client.on("chunk", done),
|
|
430
|
+
client.on("caption", (c) => { if (c?.role === "agent")
|
|
431
|
+
done(); }),
|
|
432
|
+
client.on("widget", done),
|
|
433
|
+
];
|
|
434
|
+
return () => { for (const off of offs)
|
|
435
|
+
off(); };
|
|
436
|
+
}, [client]);
|
|
403
437
|
useEffect(() => {
|
|
404
438
|
const pending = adopt(client.pendingWidget);
|
|
405
439
|
if (pending)
|
|
406
440
|
setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
|
|
407
|
-
|
|
441
|
+
const offPending = client.on("widget_pending", (hint) => {
|
|
442
|
+
setLoading({ id: String(hint?.widget_id ?? ""), kind: String(hint?.kind ?? "") });
|
|
443
|
+
if (loadingTimer.current)
|
|
444
|
+
clearTimeout(loadingTimer.current);
|
|
445
|
+
loadingTimer.current = setTimeout(() => setLoading(null), PENDING_WIDGET_MS);
|
|
446
|
+
});
|
|
447
|
+
const offWidget = client.on("widget", (w) => {
|
|
408
448
|
const next = adopt(w);
|
|
409
449
|
if (!next)
|
|
410
450
|
return;
|
|
451
|
+
if (loadingTimer.current) {
|
|
452
|
+
clearTimeout(loadingTimer.current);
|
|
453
|
+
loadingTimer.current = null;
|
|
454
|
+
}
|
|
455
|
+
setLoading(null);
|
|
456
|
+
setClosed(null);
|
|
411
457
|
setWidget(next);
|
|
412
458
|
setAnswered(null);
|
|
413
459
|
});
|
|
460
|
+
return () => { offPending(); offWidget(); };
|
|
414
461
|
}, [client, adopt]);
|
|
415
462
|
const submit = useCallback((data, summary) => {
|
|
416
463
|
if (!widget)
|
|
417
464
|
return;
|
|
465
|
+
const onSelect = widget.onSelect;
|
|
466
|
+
const item = { ...data, summary };
|
|
467
|
+
const call = selectCall(onSelect, item);
|
|
468
|
+
if (call) {
|
|
469
|
+
void client
|
|
470
|
+
.executeAction(call.name, call.args)
|
|
471
|
+
.then((res) => {
|
|
472
|
+
if (res?.status && res.status !== "ok") {
|
|
473
|
+
client.reportEvent({
|
|
474
|
+
name: "in_app_action_failed",
|
|
475
|
+
detail: `"${call.name}" did not run: ${res?.message || res.status}. Do not describe a screen the customer cannot see.`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
.catch(() => { });
|
|
480
|
+
if (onSelect?.say)
|
|
481
|
+
summary = fillSelectTemplate(onSelect.say, item);
|
|
482
|
+
}
|
|
418
483
|
client.submitWidget(widget.id, data, summary);
|
|
419
484
|
client.clearPendingWidget();
|
|
420
485
|
setWidget(null);
|
|
421
486
|
setAnswered({ widget, data, summary });
|
|
487
|
+
setAwaitingReply(true);
|
|
488
|
+
if (awaitingTimer.current)
|
|
489
|
+
clearTimeout(awaitingTimer.current);
|
|
490
|
+
awaitingTimer.current = setTimeout(() => setAwaitingReply(false), AWAITING_REPLY_MS);
|
|
422
491
|
}, [client, widget]);
|
|
423
492
|
const dismiss = useCallback(() => {
|
|
493
|
+
if (widget) {
|
|
494
|
+
client.reportEvent({
|
|
495
|
+
name: "component_dismissed",
|
|
496
|
+
detail: "The customer closed the component without choosing. Do not refer to options they cannot see; offer to show it again if they want it.",
|
|
497
|
+
quiet: true,
|
|
498
|
+
data: {
|
|
499
|
+
tool_name: widget.id.split(":")[0] || "",
|
|
500
|
+
kind: widget.kind,
|
|
501
|
+
title: String(widget.data?.title ?? ""),
|
|
502
|
+
answered: false,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
setClosed(widget);
|
|
424
507
|
client.clearPendingWidget();
|
|
425
508
|
setWidget(null);
|
|
426
509
|
setAnswered(null);
|
|
427
|
-
|
|
428
|
-
|
|
510
|
+
setLoading(null);
|
|
511
|
+
setAwaitingReply(false);
|
|
512
|
+
}, [client, widget]);
|
|
513
|
+
const reopen = useCallback(() => {
|
|
514
|
+
if (!closed)
|
|
515
|
+
return;
|
|
516
|
+
setWidget(closed);
|
|
517
|
+
setClosed(null);
|
|
518
|
+
setAnswered(null);
|
|
519
|
+
}, [closed]);
|
|
520
|
+
return { widget, answered, loading, awaitingReply, closed, reopen, submit, dismiss };
|
|
429
521
|
}
|
|
430
522
|
export function useGraineAction(name, handler) {
|
|
431
523
|
const client = useGraineClient();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type LoaderKind, type LoaderLook, type LoaderMoment } from "../protocol.js";
|
|
3
|
+
export interface GraineLoaderProps {
|
|
4
|
+
moment?: LoaderMoment;
|
|
5
|
+
label?: string;
|
|
6
|
+
kind?: LoaderKind;
|
|
7
|
+
color?: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
stacked?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function useGraineLoader(): LoaderLook & {
|
|
12
|
+
accent: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function GraineLoader(props: GraineLoaderProps): React.JSX.Element;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useContext, useEffect, useRef } from "react";
|
|
3
|
+
import { Animated, Easing, Text, View, useColorScheme } from "react-native";
|
|
4
|
+
import { Ctx } from "./index.js";
|
|
5
|
+
import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
|
|
6
|
+
import { resolveLoader } from "../protocol.js";
|
|
7
|
+
export function useGraineLoader() {
|
|
8
|
+
const appearance = useContext(Ctx)?.appearance ?? null;
|
|
9
|
+
const look = resolveLoader(appearance?.loader);
|
|
10
|
+
const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
|
|
11
|
+
return { ...look, accent: look.color ?? dash.accent };
|
|
12
|
+
}
|
|
13
|
+
function useLoop(periodMs) {
|
|
14
|
+
const v = useRef(new Animated.Value(0)).current;
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: periodMs, easing: Easing.linear, useNativeDriver: true }));
|
|
17
|
+
loop.start();
|
|
18
|
+
return () => loop.stop();
|
|
19
|
+
}, [v, periodMs]);
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
function Dots({ px, color, t }) {
|
|
23
|
+
const d = Math.max(4, Math.round(px * 0.32));
|
|
24
|
+
return (_jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: Math.round(d * 0.7), height: px }, children: [0, 1, 2].map((i) => (_jsx(Animated.View, { style: {
|
|
25
|
+
width: d, height: d, borderRadius: 999, backgroundColor: color,
|
|
26
|
+
opacity: t.interpolate({ inputRange: [0, 0.33, 0.66, 1], outputRange: [i === 0 ? 1 : 0.3, i === 1 ? 1 : 0.3, i === 2 ? 1 : 0.3, i === 0 ? 1 : 0.3] }),
|
|
27
|
+
} }, i))) }));
|
|
28
|
+
}
|
|
29
|
+
function Pulse({ px, color, t }) {
|
|
30
|
+
return (_jsxs(View, { style: { width: px, height: px, alignItems: "center", justifyContent: "center" }, children: [_jsx(Animated.View, { style: {
|
|
31
|
+
position: "absolute", width: px, height: px, borderRadius: 999, backgroundColor: color,
|
|
32
|
+
opacity: t.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
|
|
33
|
+
transform: [{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1.4] }) }],
|
|
34
|
+
} }), _jsx(View, { style: { width: Math.round(px * 0.5), height: Math.round(px * 0.5), borderRadius: 999, backgroundColor: color } })] }));
|
|
35
|
+
}
|
|
36
|
+
function Ring({ px, color, t, track }) {
|
|
37
|
+
const w = Math.max(2, Math.round(px * 0.14));
|
|
38
|
+
return (_jsx(Animated.View, { style: {
|
|
39
|
+
width: px, height: px, borderRadius: 999, borderWidth: w,
|
|
40
|
+
borderColor: track, borderTopColor: color,
|
|
41
|
+
transform: [{ rotate: t.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }) }],
|
|
42
|
+
} }));
|
|
43
|
+
}
|
|
44
|
+
function Bars({ px, color, t }) {
|
|
45
|
+
const w = Math.max(3, Math.round(px * 0.18));
|
|
46
|
+
return (_jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: Math.round(w * 0.8), height: px }, children: [0, 1, 2].map((i) => (_jsx(Animated.View, { style: {
|
|
47
|
+
width: w, borderRadius: 999, backgroundColor: color, height: px,
|
|
48
|
+
transform: [{ scaleY: t.interpolate({ inputRange: [0, 0.5, 1], outputRange: [i === 1 ? 0.4 : 1, i === 1 ? 1 : 0.4, i === 1 ? 0.4 : 1] }) }],
|
|
49
|
+
} }, i))) }));
|
|
50
|
+
}
|
|
51
|
+
function Mark({ px, color, t, logoUrl }) {
|
|
52
|
+
if (!logoUrl)
|
|
53
|
+
return _jsx(Pulse, { px: px, color: color, t: t });
|
|
54
|
+
return (_jsx(Animated.Image, { source: { uri: logoUrl }, accessibilityIgnoresInvertColors: true, resizeMode: "contain", style: { width: px, height: px, opacity: t.interpolate({ inputRange: [0, 0.5, 1], outputRange: [1, 0.45, 1] }) } }));
|
|
55
|
+
}
|
|
56
|
+
export function GraineLoader(props) {
|
|
57
|
+
const appearance = useContext(Ctx)?.appearance ?? null;
|
|
58
|
+
const scheme = useColorScheme();
|
|
59
|
+
const look = resolveLoader(appearance?.loader);
|
|
60
|
+
const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
|
|
61
|
+
const color = props.color ?? look.color ?? dash.accent;
|
|
62
|
+
const px = props.size ?? look.px;
|
|
63
|
+
const kind = props.kind ?? look.kind;
|
|
64
|
+
const palette = resolveWidgetPalette(appearance, { scheme, accent: color });
|
|
65
|
+
const t = useLoop(look.periodMs);
|
|
66
|
+
const label = props.label ?? (props.moment ? look.text[props.moment] : "");
|
|
67
|
+
const mark = kind === "pulse" ? _jsx(Pulse, { px: px, color: color, t: t }) :
|
|
68
|
+
kind === "ring" ? _jsx(Ring, { px: px, color: color, t: t, track: palette.border }) :
|
|
69
|
+
kind === "bars" ? _jsx(Bars, { px: px, color: color, t: t }) :
|
|
70
|
+
kind === "mark" ? _jsx(Mark, { px: px, color: color, t: t, logoUrl: dash.logoUrl ?? "" }) :
|
|
71
|
+
_jsx(Dots, { px: px, color: color, t: t });
|
|
72
|
+
return (_jsxs(View, { accessibilityRole: "progressbar", accessibilityLabel: label || "Loading", style: { flexDirection: props.stacked ? "column" : "row", alignItems: "center", gap: props.stacked ? 8 : 10 }, children: [mark, label ? (_jsx(Text, { style: { color: palette.muted, fontSize: Math.max(12, Math.round(px * 0.65)), fontWeight: "600" }, children: label })) : null] }));
|
|
73
|
+
}
|
|
@@ -23,9 +23,23 @@ 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
|
+
}
|
|
38
|
+
export type GrainePhase = "idle" | "connecting" | "reconnecting" | "thinking" | "speaking" | "listening";
|
|
28
39
|
export interface GraineVoiceApi {
|
|
40
|
+
phase: GrainePhase;
|
|
41
|
+
connectivity: GraineConnectivity;
|
|
42
|
+
retry: () => void;
|
|
29
43
|
connected: boolean;
|
|
30
44
|
connecting: boolean;
|
|
31
45
|
muted: boolean;
|
|
@@ -40,4 +54,4 @@ export interface GraineVoiceApi {
|
|
|
40
54
|
clearCaptions: () => void;
|
|
41
55
|
}
|
|
42
56
|
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;
|
|
57
|
+
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -4,7 +4,7 @@ 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);
|
|
@@ -17,6 +17,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
17
17
|
const [muted, setMutedState] = useState(false);
|
|
18
18
|
const [ready, setReady] = useState(false);
|
|
19
19
|
const [captions, setCaptions] = useState([]);
|
|
20
|
+
const [awaitingAgent, setAwaitingAgent] = useState(false);
|
|
20
21
|
const [captionsOn, setCaptionsOn] = useState(true);
|
|
21
22
|
const foldCaption = useCallback((next) => {
|
|
22
23
|
setCaptions((prev) => {
|
|
@@ -38,6 +39,88 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
38
39
|
const [voiceAgentId, setVoiceAgentId] = useState(client.config ? client.config.webcallAgentId || client.config.agentId : null);
|
|
39
40
|
const onErrorRef = useRef(onError);
|
|
40
41
|
onErrorRef.current = onError;
|
|
42
|
+
const READY_TIMEOUT_MS = timing?.readyTimeoutMs ?? 15000;
|
|
43
|
+
const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
|
|
44
|
+
const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
|
|
45
|
+
const [connectivity, setConnectivity] = useState({ state: "loading", attempt: 0 });
|
|
46
|
+
const attemptRef = useRef(0);
|
|
47
|
+
const retryTimerRef = useRef(null);
|
|
48
|
+
const readyTimerRef = useRef(null);
|
|
49
|
+
const endWatchdogRef = useRef(null);
|
|
50
|
+
const connectedRef = useRef(false);
|
|
51
|
+
useEffect(() => { connectedRef.current = connected; }, [connected]);
|
|
52
|
+
const endedNotifiedRef = useRef(true);
|
|
53
|
+
const onEndedRef = useRef(onEnded);
|
|
54
|
+
onEndedRef.current = onEnded;
|
|
55
|
+
const clearTimer = (r) => {
|
|
56
|
+
if (r.current) {
|
|
57
|
+
clearTimeout(r.current);
|
|
58
|
+
r.current = null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const scheduleRetry = useCallback((reason) => {
|
|
62
|
+
clearTimer(readyTimerRef);
|
|
63
|
+
if (connectedRef.current)
|
|
64
|
+
return;
|
|
65
|
+
const attempt = attemptRef.current;
|
|
66
|
+
if (attempt >= RETRY_DELAYS_MS.length) {
|
|
67
|
+
setConnectivity({ state: "failed", attempt, reason });
|
|
68
|
+
setConnecting(false);
|
|
69
|
+
pendingStartRef.current = false;
|
|
70
|
+
onErrorRef.current?.("The assistant could not load. Check your connection and try again.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const base = RETRY_DELAYS_MS[attempt];
|
|
74
|
+
const delay = Math.round(base * (0.75 + Math.random() * 0.5));
|
|
75
|
+
attemptRef.current = attempt + 1;
|
|
76
|
+
setConnectivity({ state: "retrying", attempt: attempt + 1, reason });
|
|
77
|
+
setReady(false);
|
|
78
|
+
clearTimer(retryTimerRef);
|
|
79
|
+
retryTimerRef.current = setTimeout(() => {
|
|
80
|
+
retryTimerRef.current = null;
|
|
81
|
+
try {
|
|
82
|
+
ref.current?.reload();
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
}, delay);
|
|
86
|
+
}, []);
|
|
87
|
+
const armReadyTimer = useCallback(() => {
|
|
88
|
+
clearTimer(readyTimerRef);
|
|
89
|
+
readyTimerRef.current = setTimeout(() => {
|
|
90
|
+
readyTimerRef.current = null;
|
|
91
|
+
scheduleRetry("ready-timeout");
|
|
92
|
+
}, READY_TIMEOUT_MS);
|
|
93
|
+
}, [scheduleRetry]);
|
|
94
|
+
const restartLoad = useCallback(() => {
|
|
95
|
+
clearTimer(retryTimerRef);
|
|
96
|
+
attemptRef.current = 0;
|
|
97
|
+
setConnectivity({ state: "loading", attempt: 0 });
|
|
98
|
+
setReady(false);
|
|
99
|
+
try {
|
|
100
|
+
ref.current?.reload();
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
armReadyTimer();
|
|
104
|
+
}, [armReadyTimer]);
|
|
105
|
+
const forceEnd = useCallback((why) => {
|
|
106
|
+
clearTimer(endWatchdogRef);
|
|
107
|
+
setConnected(false);
|
|
108
|
+
setConnecting(false);
|
|
109
|
+
connectedRef.current = false;
|
|
110
|
+
if (!endedNotifiedRef.current) {
|
|
111
|
+
endedNotifiedRef.current = true;
|
|
112
|
+
console.warn(`[graine] the page did not confirm the call ended (${why}); ending it locally`);
|
|
113
|
+
onEndedRef.current?.();
|
|
114
|
+
}
|
|
115
|
+
restartLoad();
|
|
116
|
+
}, [restartLoad]);
|
|
117
|
+
const armEndWatchdog = useCallback(() => {
|
|
118
|
+
clearTimer(endWatchdogRef);
|
|
119
|
+
if (!connectedRef.current)
|
|
120
|
+
return;
|
|
121
|
+
endWatchdogRef.current = setTimeout(() => forceEnd("watchdog"), END_WATCHDOG_MS);
|
|
122
|
+
}, [forceEnd]);
|
|
123
|
+
useEffect(() => () => { clearTimer(retryTimerRef); clearTimer(readyTimerRef); clearTimer(endWatchdogRef); }, []);
|
|
41
124
|
useEffect(() => {
|
|
42
125
|
if (voiceAgentId)
|
|
43
126
|
return;
|
|
@@ -75,6 +158,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
75
158
|
return client.on("screen", () => pushContext());
|
|
76
159
|
}, [client, ready, pushContext]);
|
|
77
160
|
const beginCall = useCallback(async () => {
|
|
161
|
+
endedNotifiedRef.current = false;
|
|
78
162
|
if (client.isConnected) {
|
|
79
163
|
console.warn("[graine] The provider has its own connection open while GraineVoiceLauncher " +
|
|
80
164
|
"is starting a call. Pass autoConnect={false} to <GraineProvider> — otherwise " +
|
|
@@ -132,6 +216,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
132
216
|
}
|
|
133
217
|
return;
|
|
134
218
|
case "graine:ready":
|
|
219
|
+
clearTimer(readyTimerRef);
|
|
220
|
+
clearTimer(retryTimerRef);
|
|
221
|
+
attemptRef.current = 0;
|
|
222
|
+
setConnectivity({ state: "ready", attempt: 0 });
|
|
135
223
|
setReady(true);
|
|
136
224
|
pushContext();
|
|
137
225
|
if ((autoStart || pendingStartRef.current) && !startedRef.current && activeRef.current) {
|
|
@@ -144,6 +232,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
144
232
|
}
|
|
145
233
|
return;
|
|
146
234
|
case "graine:call":
|
|
235
|
+
if (msg.connected)
|
|
236
|
+
endedNotifiedRef.current = false;
|
|
147
237
|
setConnected(!!msg.connected);
|
|
148
238
|
setConnecting(!!msg.connecting);
|
|
149
239
|
if (!msg.connected && !msg.connecting)
|
|
@@ -159,14 +249,30 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
159
249
|
text: String(msg.text ?? ""),
|
|
160
250
|
live: !!msg.live,
|
|
161
251
|
};
|
|
252
|
+
if (line.role === "customer" && !line.live)
|
|
253
|
+
setAwaitingAgent(true);
|
|
254
|
+
if (line.role === "agent")
|
|
255
|
+
setAwaitingAgent(false);
|
|
162
256
|
foldCaption(line);
|
|
163
257
|
onCaption?.(line);
|
|
258
|
+
client.emit?.("caption", line);
|
|
259
|
+
if (line.role === "agent")
|
|
260
|
+
client.emit?.("agent_speaking", true);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case "graine:widget-pending": {
|
|
264
|
+
client.emit?.("widget_pending", msg.widget || {});
|
|
164
265
|
return;
|
|
165
266
|
}
|
|
166
267
|
case "graine:ended":
|
|
268
|
+
clearTimer(endWatchdogRef);
|
|
167
269
|
setConnected(false);
|
|
168
270
|
setConnecting(false);
|
|
169
|
-
|
|
271
|
+
connectedRef.current = false;
|
|
272
|
+
if (!endedNotifiedRef.current) {
|
|
273
|
+
endedNotifiedRef.current = true;
|
|
274
|
+
onEnded?.();
|
|
275
|
+
}
|
|
170
276
|
return;
|
|
171
277
|
case "graine:mic-denied":
|
|
172
278
|
client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
|
|
@@ -191,7 +297,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
191
297
|
}
|
|
192
298
|
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
193
299
|
const pendingStartRef = useRef(false);
|
|
300
|
+
const lastCaption = captions.length ? captions[captions.length - 1] : null;
|
|
301
|
+
const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
302
|
+
: connecting || (!ready && !!voiceAgentId) ? "connecting"
|
|
303
|
+
: !connected ? "idle"
|
|
304
|
+
: lastCaption?.role === "agent" && lastCaption.live ? "speaking"
|
|
305
|
+
: awaitingAgent ? "thinking"
|
|
306
|
+
: "listening";
|
|
194
307
|
const api = {
|
|
308
|
+
phase,
|
|
195
309
|
connected,
|
|
196
310
|
connecting,
|
|
197
311
|
muted,
|
|
@@ -204,6 +318,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
204
318
|
start: useCallback(() => {
|
|
205
319
|
setConnecting(true);
|
|
206
320
|
setCaptions([]);
|
|
321
|
+
setAwaitingAgent(false);
|
|
207
322
|
client.clearPendingWidget();
|
|
208
323
|
if (!ready) {
|
|
209
324
|
pendingStartRef.current = true;
|
|
@@ -211,7 +326,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
211
326
|
}
|
|
212
327
|
void beginCall();
|
|
213
328
|
}, [beginCall, ready]),
|
|
214
|
-
end: useCallback(() => post({ type: "graine:end-call" }), [post]),
|
|
329
|
+
end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
|
|
330
|
+
connectivity,
|
|
331
|
+
retry: restartLoad,
|
|
215
332
|
setMuted: useCallback((m) => {
|
|
216
333
|
setMutedState(m);
|
|
217
334
|
post({ type: "graine:set-muted", muted: m });
|
|
@@ -223,6 +340,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
223
340
|
const first = activeSeenRef.current === null;
|
|
224
341
|
activeSeenRef.current = active;
|
|
225
342
|
if (active) {
|
|
343
|
+
if (attemptRef.current >= RETRY_DELAYS_MS.length) {
|
|
344
|
+
restartLoad();
|
|
345
|
+
}
|
|
226
346
|
if (autoStart) {
|
|
227
347
|
if (startedRef.current)
|
|
228
348
|
return;
|
|
@@ -243,6 +363,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
243
363
|
if (first)
|
|
244
364
|
return;
|
|
245
365
|
post({ type: "graine:end-call" });
|
|
366
|
+
armEndWatchdog();
|
|
246
367
|
startedRef.current = false;
|
|
247
368
|
pendingStartRef.current = false;
|
|
248
369
|
setConnected(false);
|
|
@@ -286,5 +407,5 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
286
407
|
(mountedIdleRef.current ? "&idle=1" : "") +
|
|
287
408
|
`&pk=${encodeURIComponent(client.opts.publishableKey)}` +
|
|
288
409
|
(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)] }));
|
|
410
|
+
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
411
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
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
|
+
import { GraineLoader } from "./loader.js";
|
|
5
6
|
import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
|
|
6
|
-
import { widgetSurface } from "../protocol.js";
|
|
7
|
+
import { PLATFORM_GRAMMAR, manySummary, resolveLoader, resolvePlatform, resolveWidgetLook, widgetSurface } from "../protocol.js";
|
|
7
8
|
function tint(hex, alpha) {
|
|
8
9
|
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
|
|
9
10
|
if (!m)
|
|
@@ -11,38 +12,85 @@ function tint(hex, alpha) {
|
|
|
11
12
|
const n = parseInt(m[1], 16);
|
|
12
13
|
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
const HERO_HEIGHT = { small: 100, medium: 140, large: 180 };
|
|
16
|
+
const THUMB = { small: 56, medium: 72, large: 96 };
|
|
17
|
+
const LIST_THUMB = { small: 36, medium: 44, large: 56 };
|
|
18
|
+
function WidgetRow({ row, variant, look, grammar, theme, accent, radius, picked, onPick, }) {
|
|
19
|
+
const many = picked !== null;
|
|
15
20
|
const r = Math.max(10, radius - 6);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
21
|
+
const tight = look.density === "compact";
|
|
22
|
+
const pad = (variant === "list" ? (tight ? 6 : 9) : (tight ? 7 : 10)) + grammar.padBoost;
|
|
23
|
+
const gap = tight ? 6 : 10;
|
|
24
|
+
const rowIsButton = many || look.button === "row";
|
|
25
|
+
const outline = look.button === "outline";
|
|
26
|
+
const label = row.unavailable ? "Unavailable" : row.selectLabel;
|
|
27
|
+
const a11y = `${row.selectLabel}: ${row.title}`;
|
|
28
|
+
const badge = row.subtitle ? (_jsx(View, { style: { flexDirection: "row" }, children: _jsx(Text, { style: {
|
|
29
|
+
color: accent, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4,
|
|
30
|
+
textTransform: "uppercase", backgroundColor: tint(accent, 0.15),
|
|
31
|
+
borderRadius: 999, paddingHorizontal: 8, paddingVertical: 3, overflow: "hidden",
|
|
32
|
+
}, children: row.subtitle }) })) : null;
|
|
33
|
+
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: {
|
|
34
|
+
color: theme.text, fontSize: big ? 17 : (variant === "list" ? 13.5 : 14.5), fontWeight: "800",
|
|
35
|
+
fontVariant: ["tabular-nums"],
|
|
36
|
+
}, children: row.price })) : null] }));
|
|
37
|
+
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: {
|
|
38
|
+
color: theme.muted, fontSize: variant === "list" ? 10.5 : 11.5, fontWeight: "600",
|
|
39
|
+
backgroundColor: grammar.chipOutlined ? "transparent" : tint(theme.border, 0.35),
|
|
40
|
+
borderWidth: grammar.chipOutlined ? 1 : 0, borderColor: theme.border,
|
|
41
|
+
borderRadius: grammar.chipRadius, paddingHorizontal: variant === "list" ? 6 : 8, paddingVertical: variant === "list" ? 2 : 4, overflow: "hidden",
|
|
42
|
+
}, children: f }, `${f}-${i}`))) })) : null;
|
|
43
|
+
const tick = (_jsx(View, { style: {
|
|
44
|
+
width: 22, height: 22, borderRadius: 999, alignItems: "center", justifyContent: "center",
|
|
45
|
+
backgroundColor: picked ? accent : "transparent",
|
|
46
|
+
borderWidth: picked ? 0 : 1.5, borderColor: theme.border,
|
|
47
|
+
}, children: picked ? _jsx(Text, { style: { color: theme.onAccent, fontSize: 12, fontWeight: "800" }, children: "\u2713" }) : null }));
|
|
48
|
+
const button = (wide) => (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, hitSlop: wide ? undefined : 8, style: {
|
|
49
|
+
minHeight: 44, justifyContent: "center", alignItems: "center",
|
|
50
|
+
borderRadius: wide ? grammar.buttonRadius : (grammar.pillButtons ? 999 : grammar.buttonRadius), paddingHorizontal: wide ? 12 : 14,
|
|
51
|
+
marginTop: wide ? 8 : 0,
|
|
52
|
+
backgroundColor: row.unavailable || outline ? "transparent" : accent,
|
|
53
|
+
borderWidth: row.unavailable || outline ? 1.5 : 0,
|
|
54
|
+
borderColor: row.unavailable ? theme.border : accent,
|
|
55
|
+
}, children: _jsx(Text, { style: {
|
|
56
|
+
color: row.unavailable ? theme.muted : (outline ? accent : theme.onAccent),
|
|
57
|
+
fontSize: wide ? 14 : 12.5, fontWeight: "700",
|
|
58
|
+
}, children: label }) }));
|
|
59
|
+
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;
|
|
60
|
+
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 }));
|
|
61
|
+
if (variant === "hero") {
|
|
62
|
+
return wrap(_jsxs(_Fragment, { children: [image({ width: "100%", height: HERO_HEIGHT[look.imageSize] }), _jsxs(View, { style: { gap: 3 }, children: [badge, titleLine(true), facts, many ? null : rowIsButton ? null : button(true)] }), many ? _jsx(View, { style: { position: "absolute", top: pad + 6, right: pad + 6 }, children: tick }) : null] }), {
|
|
63
|
+
borderRadius: r, borderWidth: picked ? 2 : 1, borderColor: picked ? accent : theme.border, padding: pad,
|
|
64
|
+
opacity: row.unavailable ? 0.55 : 1, gap, flexDirection: "column",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (variant === "list") {
|
|
68
|
+
const t = LIST_THUMB[look.imageSize];
|
|
69
|
+
return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [titleLine(false), facts] }), many ? tick : rowIsButton ? (_jsx(Text, { style: { color: theme.muted, fontSize: 22, fontWeight: "600", paddingLeft: 4 }, children: "›" })) : button(false)] }), {
|
|
70
|
+
flexDirection: "row", alignItems: "center", gap,
|
|
71
|
+
paddingVertical: pad, paddingHorizontal: 2, minHeight: 44,
|
|
72
|
+
borderBottomWidth: 1, borderBottomColor: theme.border, marginLeft: grammar.separatorInset,
|
|
73
|
+
opacity: row.unavailable ? 0.55 : 1,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (variant === "compact") {
|
|
77
|
+
return wrap(_jsxs(_Fragment, { children: [_jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [badge, titleLine(false), facts] }), many ? tick : rowIsButton ? null : button(false)] }), {
|
|
78
|
+
borderRadius: r, borderWidth: picked ? 2 : 1, borderColor: picked ? accent : theme.border,
|
|
79
|
+
paddingVertical: pad, paddingHorizontal: pad + 2,
|
|
80
|
+
flexDirection: "row", alignItems: "center", gap,
|
|
81
|
+
opacity: row.unavailable ? 0.55 : 1, minHeight: 44,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const t = THUMB[look.imageSize];
|
|
85
|
+
return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 3 }, children: [badge, titleLine(false), facts] }), many ? tick : rowIsButton ? null : button(false)] }), {
|
|
86
|
+
borderRadius: r, borderWidth: picked ? 2 : 1, borderColor: picked ? accent : theme.border, padding: pad,
|
|
87
|
+
opacity: row.unavailable ? 0.55 : 1, flexDirection: "row", gap, alignItems: "center",
|
|
88
|
+
});
|
|
43
89
|
}
|
|
44
|
-
function AnsweredLine({ answered, theme, accent, radius, onClear }) {
|
|
90
|
+
function AnsweredLine({ answered, theme, accent, radius, awaiting, onClear }) {
|
|
45
91
|
const d = (answered.data || {});
|
|
92
|
+
const appearance = useContext(Ctx)?.appearance ?? null;
|
|
93
|
+
const loaderText = resolveLoader(appearance?.loader).text.thinking;
|
|
46
94
|
const title = String(d.name || d.title || d.label || d.slot || "").trim();
|
|
47
95
|
const detail = [String(d.price || ""), String(d.day || "")].filter(Boolean).join(" · ");
|
|
48
96
|
if (!title && !detail)
|
|
@@ -54,20 +102,38 @@ function AnsweredLine({ answered, theme, accent, radius, onClear }) {
|
|
|
54
102
|
}, children: [_jsx(View, { style: {
|
|
55
103
|
width: 26, height: 26, borderRadius: 999, backgroundColor: tint(accent, 0.18),
|
|
56
104
|
alignItems: "center", justifyContent: "center",
|
|
57
|
-
}, children: _jsx(Text, { style: { color: accent, fontSize: 14, fontWeight: "800" }, children: "✓" }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontSize: 13.5, fontWeight: "700" }, children: title || detail }), title && detail ? (_jsx(Text, { numberOfLines: 1, style: { color: theme.muted, fontSize: 11.5, fontWeight: "600" }, children: detail })) : null] }), _jsx(Pressable, { onPress: onClear, accessibilityRole: "button", accessibilityLabel: "Clear what I chose", hitSlop: 10, style: { minHeight: 44, justifyContent: "center", paddingHorizontal: 4 }, children: _jsx(Text, { style: { color: theme.muted, fontSize: 12, fontWeight: "700" }, children: "Clear" }) })] }));
|
|
105
|
+
}, children: awaiting ? _jsx(GraineLoader, { size: 14 }) : (_jsx(Text, { style: { color: accent, fontSize: 14, fontWeight: "800" }, children: "✓" })) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontSize: 13.5, fontWeight: "700" }, children: title || detail }), awaiting ? (_jsx(Text, { numberOfLines: 1, style: { color: theme.muted, fontSize: 11.5, fontWeight: "600" }, children: loaderText })) : title && detail ? (_jsx(Text, { numberOfLines: 1, style: { color: theme.muted, fontSize: 11.5, fontWeight: "600" }, children: detail })) : null] }), awaiting ? null : (_jsx(Pressable, { onPress: onClear, accessibilityRole: "button", accessibilityLabel: "Clear what I chose", hitSlop: 10, style: { minHeight: 44, justifyContent: "center", paddingHorizontal: 4 }, children: _jsx(Text, { style: { color: theme.muted, fontSize: 12, fontWeight: "700" }, children: "Clear" }) }))] }));
|
|
58
106
|
}
|
|
59
107
|
export function GraineWidget(props) {
|
|
60
|
-
const { widget, answered, submit, dismiss } = useGraineWidget();
|
|
108
|
+
const { widget, answered, loading, awaitingReply, submit, dismiss } = useGraineWidget();
|
|
61
109
|
const appearance = useContext(Ctx)?.appearance ?? null;
|
|
62
110
|
const scheme = useColorScheme();
|
|
111
|
+
const [pagerWidth, setPagerWidth] = useState(0);
|
|
112
|
+
const [page, setPage] = useState(0);
|
|
113
|
+
const [ticked, setTicked] = useState([]);
|
|
114
|
+
const look = resolveWidgetLook(widget?.style, widget?.kind);
|
|
115
|
+
const grammar = PLATFORM_GRAMMAR[resolvePlatform(look.platform, Platform.OS, Platform.constants?.Manufacturer ?? Platform.constants?.Brand)];
|
|
63
116
|
const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
|
|
64
|
-
const accent = props.accent ?? dash.accent;
|
|
65
|
-
const radius = props.radius ?? dash.radius;
|
|
117
|
+
const accent = props.accent ?? look.accent ?? dash.accent;
|
|
118
|
+
const radius = props.radius ?? look.radius ?? (look.platform !== "auto" ? grammar.radius : dash.radius);
|
|
66
119
|
const theme = {
|
|
67
120
|
...resolveWidgetPalette(appearance, { scheme, accent }),
|
|
68
121
|
...(props.theme ?? {}),
|
|
69
122
|
};
|
|
70
|
-
const chosen = answered ? (_jsx(AnsweredLine, { answered: answered, theme: theme, accent: accent, radius: radius, onClear: dismiss })) : null;
|
|
123
|
+
const chosen = answered ? (_jsx(AnsweredLine, { answered: answered, theme: theme, accent: accent, radius: radius, awaiting: awaitingReply, onClear: dismiss })) : null;
|
|
124
|
+
if (!widget && loading) {
|
|
125
|
+
const loader = resolveLoader(appearance?.loader);
|
|
126
|
+
const pendingSurface = loader.skeleton ? widgetSurface(loading.kind) : "card";
|
|
127
|
+
const bone = (w, h, extra = {}) => (_jsx(View, { style: { width: w, height: h, borderRadius: 8, backgroundColor: tint(theme.border, 0.6), ...extra } }));
|
|
128
|
+
const bones = (_jsxs(View, { style: { gap: 10 }, accessibilityLabel: loader.text.component, children: [[0, 1, 2].map((i) => (_jsxs(View, { style: { borderRadius: Math.max(10, radius - 6), borderWidth: 1, borderColor: theme.border, padding: 10, flexDirection: "row", gap: 10, alignItems: "center" }, children: [bone(72, 72), _jsxs(View, { style: { flex: 1, gap: 8 }, children: [bone("70%", 12), bone("40%", 12), bone("55%", 10)] })] }, i))), _jsx(View, { style: { alignItems: "center", paddingTop: 6 }, children: _jsx(GraineLoader, { moment: "component" }) })] }));
|
|
129
|
+
if (pendingSurface === "screen") {
|
|
130
|
+
return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", onRequestClose: dismiss, transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, children: _jsx(View, { style: { flex: 1, backgroundColor: theme.surface, paddingTop: 64, paddingHorizontal: 18 }, children: bones }) })] }));
|
|
131
|
+
}
|
|
132
|
+
if (pendingSurface === "sheet") {
|
|
133
|
+
return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, onRequestClose: dismiss, children: _jsx(View, { style: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "flex-end" }, children: _jsx(View, { style: { backgroundColor: theme.surface, borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius), padding: 18, paddingBottom: 28 }, children: bones }) }) })] }));
|
|
134
|
+
}
|
|
135
|
+
return (_jsxs(_Fragment, { children: [chosen, _jsx(View, { style: { backgroundColor: theme.surface, borderRadius: radius, borderWidth: 1, borderColor: theme.border, paddingHorizontal: 14, paddingVertical: 12, marginBottom: 8 }, children: _jsx(GraineLoader, { moment: "component" }) })] }));
|
|
136
|
+
}
|
|
71
137
|
if (!widget)
|
|
72
138
|
return chosen;
|
|
73
139
|
const data = (widget.data ?? {});
|
|
@@ -132,8 +198,19 @@ export function GraineWidget(props) {
|
|
|
132
198
|
const isChips = (widget.kind === "choices" || isQuickActions) && options.length > 0;
|
|
133
199
|
const isCalendar = widget.kind === "calendar_slots" && Array.isArray(data?.days) && data.days.length > 0;
|
|
134
200
|
const drawable = isProducts || isChips || isCalendar;
|
|
135
|
-
const surface = drawable ?
|
|
201
|
+
const surface = drawable ? look.surface : "card";
|
|
136
202
|
const pick = (it) => submit(normalised.toSubmit(it), normalised.toSay(it));
|
|
203
|
+
const many = look.select === "many" && drawable;
|
|
204
|
+
const submitMany = (items) => {
|
|
205
|
+
const titles = items.map((it) => String(it.title ?? it.name ?? "")).filter(Boolean);
|
|
206
|
+
submit({
|
|
207
|
+
items: items.map((it) => normalised.toSubmit(it)),
|
|
208
|
+
ids: items.map((it) => String(it.id ?? "")).join(","),
|
|
209
|
+
titles: titles.join(", "),
|
|
210
|
+
count: items.length,
|
|
211
|
+
}, manySummary(titles, normalised.selectLabel.toLowerCase()));
|
|
212
|
+
setTicked([]);
|
|
213
|
+
};
|
|
137
214
|
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
215
|
const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
|
|
139
216
|
const slots = Array.isArray(d?.slots) ? d.slots.map(String) : [];
|
|
@@ -144,7 +221,52 @@ export function GraineWidget(props) {
|
|
|
144
221
|
borderWidth: 1, borderColor: theme.border, borderRadius: 11,
|
|
145
222
|
paddingHorizontal: 14,
|
|
146
223
|
}, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
|
|
147
|
-
})] })) : isProducts ? (
|
|
224
|
+
})] })) : isProducts ? ((() => {
|
|
225
|
+
const toggle = (id) => setTicked((cur) => cur.includes(id) ? cur.filter((x) => x !== id)
|
|
226
|
+
: cur.length >= look.maxSelect ? [...cur.slice(1), id]
|
|
227
|
+
: [...cur, id]);
|
|
228
|
+
const draw = (row, i, variant) => (_jsx(WidgetRow, { row: row, variant: variant, look: look, grammar: grammar, theme: theme, accent: accent, radius: radius, picked: many ? ticked.includes(row.id) : null, onPick: () => many
|
|
229
|
+
? toggle(row.id)
|
|
230
|
+
: pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`));
|
|
231
|
+
const gap = look.density === "compact" ? 6 : 10;
|
|
232
|
+
if (look.preset === "spotlight") {
|
|
233
|
+
const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
|
|
234
|
+
const step = cardW + gap;
|
|
235
|
+
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: {
|
|
236
|
+
height: 5, borderRadius: 999, width: i === page ? 14 : 5,
|
|
237
|
+
backgroundColor: i === page ? accent : theme.border,
|
|
238
|
+
} }, `dot-${row.id}-${i}`))) })) : null] }));
|
|
239
|
+
}
|
|
240
|
+
if (look.columns === 2) {
|
|
241
|
+
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}`))) }));
|
|
242
|
+
}
|
|
243
|
+
const body = look.preset === "gallery" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "hero")) })
|
|
244
|
+
: look.preset === "list" ? _jsx(View, { children: rows.map((row, i) => draw(row, i, "list")) })
|
|
245
|
+
: look.preset === "compact" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "compact")) })
|
|
246
|
+
: _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "row")) });
|
|
247
|
+
if (!many)
|
|
248
|
+
return body;
|
|
249
|
+
const chosenRows = ticked
|
|
250
|
+
.map((id) => ({ row: rows.find((r) => r.id === id), i: rows.findIndex((r) => r.id === id) }))
|
|
251
|
+
.filter((x) => !!x.row);
|
|
252
|
+
return (_jsxs(_Fragment, { children: [body, _jsx(Pressable, { disabled: chosenRows.length < 2, onPress: () => {
|
|
253
|
+
const items = chosenRows.map(({ row, i }) => ({
|
|
254
|
+
...normalised.items[i], id: row.id, title: row.title, price: row.price,
|
|
255
|
+
}));
|
|
256
|
+
submitMany(items);
|
|
257
|
+
}, accessibilityRole: "button", accessibilityLabel: chosenRows.length < 2
|
|
258
|
+
? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
|
|
259
|
+
: `${normalised.selectLabel} ${chosenRows.length}`, style: {
|
|
260
|
+
marginTop: 12, minHeight: 48, borderRadius: grammar.pillButtons ? 999 : grammar.buttonRadius,
|
|
261
|
+
alignItems: "center", justifyContent: "center",
|
|
262
|
+
backgroundColor: chosenRows.length < 2 ? tint(theme.border, 0.5) : accent,
|
|
263
|
+
}, children: _jsx(Text, { style: {
|
|
264
|
+
color: chosenRows.length < 2 ? theme.muted : theme.onAccent,
|
|
265
|
+
fontSize: 14.5, fontWeight: "700",
|
|
266
|
+
}, children: chosenRows.length < 2
|
|
267
|
+
? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
|
|
268
|
+
: `${normalised.selectLabel} ${chosenRows.length}` }) })] }));
|
|
269
|
+
})()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
|
|
148
270
|
const label = String(o?.label ?? o?.title ?? o ?? "");
|
|
149
271
|
const id = String(o?.id ?? label);
|
|
150
272
|
return (_jsx(Pressable, { onPress: () => submit(isQuickActions ? { id, label } : { chosen: id, label }, `I'll go with ${label}.`), accessibilityRole: "button", accessibilityLabel: label, style: {
|
|
@@ -155,7 +277,7 @@ export function GraineWidget(props) {
|
|
|
155
277
|
const title = data?.title ? String(data.title) : "";
|
|
156
278
|
const prompt = data?.prompt ? String(data.prompt) : "";
|
|
157
279
|
if (surface === "screen") {
|
|
158
|
-
return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: dismiss, transparent:
|
|
280
|
+
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
281
|
paddingTop: 52, paddingHorizontal: 18, paddingBottom: 12,
|
|
160
282
|
flexDirection: "row", alignItems: "center", gap: 12,
|
|
161
283
|
}, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close and keep talking", hitSlop: 10, style: {
|
|
@@ -167,11 +289,11 @@ export function GraineWidget(props) {
|
|
|
167
289
|
}, 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
290
|
}
|
|
169
291
|
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: {
|
|
292
|
+
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
293
|
backgroundColor: theme.surface,
|
|
172
294
|
borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius),
|
|
173
295
|
paddingHorizontal: 18, paddingTop: 10, paddingBottom: 28, gap: 14,
|
|
174
|
-
}, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width:
|
|
296
|
+
}, 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
297
|
alignSelf: "flex-start", minHeight: 44, justifyContent: "center",
|
|
176
298
|
paddingHorizontal: 16, borderRadius: 999, backgroundColor: tint(theme.border, 0.4),
|
|
177
299
|
}, 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.
|
|
3
|
+
"version": "0.33.5",
|
|
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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
35
35
|
"test": "npm run build && node --test test/*.test.mjs && npm run test:render",
|
|
36
36
|
"prepublishOnly": "npm test && node scripts/verify-package-clean.mjs",
|
|
37
|
-
"test:render": "node --import ./test/harness/register.mjs --test test/render
|
|
37
|
+
"test:render": "node --import ./test/harness/register.mjs --test test/render/*.test.mjs"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@jambonz/client-sdk-react-native": "^0.1.4",
|