@graineai/inapp-react-native 0.33.4 → 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 +9 -0
- package/dist/index.js +5 -0
- package/dist/protocol.d.ts +61 -0
- package/dist/protocol.js +137 -0
- package/dist/react-native/index.d.ts +11 -1
- package/dist/react-native/index.js +95 -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 +2 -0
- package/dist/react-native/voice-launcher.js +21 -0
- package/dist/react-native/widget.js +80 -25
- package/package.json +1 -1
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
|
@@ -2,7 +2,16 @@ export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, Screen
|
|
|
2
2
|
export { widgetSurface } from "./protocol.js";
|
|
3
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
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";
|
|
5
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";
|
|
6
15
|
export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
|
|
7
16
|
export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
|
|
8
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,6 +1,11 @@
|
|
|
1
1
|
export { widgetSurface } from "./protocol.js";
|
|
2
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";
|
|
3
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";
|
|
4
9
|
export { GraineInAppClient, } from "./client.js";
|
|
5
10
|
export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
|
|
6
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";
|
|
@@ -68,6 +70,7 @@ export type WidgetPreset = "gallery" | "cards" | "list" | "spotlight" | "compact
|
|
|
68
70
|
export type WidgetImageSize = "small" | "medium" | "large";
|
|
69
71
|
export type WidgetDensity = "comfortable" | "compact";
|
|
70
72
|
export type WidgetButton = "filled" | "outline" | "row";
|
|
73
|
+
export type WidgetSelect = "one" | "many";
|
|
71
74
|
export type WidgetPlatform = "auto" | "ios" | "material" | "oneui";
|
|
72
75
|
export interface WidgetStyle {
|
|
73
76
|
preset?: WidgetPreset;
|
|
@@ -80,6 +83,8 @@ export interface WidgetStyle {
|
|
|
80
83
|
radius?: number;
|
|
81
84
|
accent?: string;
|
|
82
85
|
platform?: WidgetPlatform;
|
|
86
|
+
select?: WidgetSelect;
|
|
87
|
+
maxSelect?: number;
|
|
83
88
|
}
|
|
84
89
|
export declare const WIDGET_PRESETS: ReadonlyArray<WidgetPreset>;
|
|
85
90
|
export declare const WIDGET_IMAGE_SIZES: ReadonlyArray<WidgetImageSize>;
|
|
@@ -87,6 +92,8 @@ export declare const WIDGET_DENSITIES: ReadonlyArray<WidgetDensity>;
|
|
|
87
92
|
export declare const WIDGET_BUTTONS: ReadonlyArray<WidgetButton>;
|
|
88
93
|
export declare const WIDGET_SURFACE_CHOICES: ReadonlyArray<"auto" | WidgetSurface>;
|
|
89
94
|
export declare const WIDGET_PLATFORMS: ReadonlyArray<WidgetPlatform>;
|
|
95
|
+
export declare const WIDGET_SELECTS: ReadonlyArray<WidgetSelect>;
|
|
96
|
+
export declare const MAX_SELECT_DEFAULT = 3;
|
|
90
97
|
export declare const DEFAULT_WIDGET_PRESET: WidgetPreset;
|
|
91
98
|
export declare function normaliseWidgetStyle(raw: unknown): WidgetStyle;
|
|
92
99
|
export interface WidgetLook {
|
|
@@ -100,8 +107,11 @@ export interface WidgetLook {
|
|
|
100
107
|
radius?: number;
|
|
101
108
|
accent?: string;
|
|
102
109
|
platform: WidgetPlatform;
|
|
110
|
+
select: WidgetSelect;
|
|
111
|
+
maxSelect: number;
|
|
103
112
|
}
|
|
104
113
|
export declare function resolveWidgetLook(style: WidgetStyle | null | undefined, kind: string | null | undefined): WidgetLook;
|
|
114
|
+
export declare function manySummary(titles: string[], verb?: string): string;
|
|
105
115
|
export interface PlatformGrammar {
|
|
106
116
|
radius: number;
|
|
107
117
|
chipRadius: number;
|
|
@@ -118,3 +128,54 @@ export interface PlatformGrammar {
|
|
|
118
128
|
}
|
|
119
129
|
export declare const PLATFORM_GRAMMAR: Record<Exclude<WidgetPlatform, "auto">, PlatformGrammar>;
|
|
120
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
|
@@ -47,6 +47,8 @@ export const WIDGET_DENSITIES = ["comfortable", "compact"];
|
|
|
47
47
|
export const WIDGET_BUTTONS = ["filled", "outline", "row"];
|
|
48
48
|
export const WIDGET_SURFACE_CHOICES = ["auto", "screen", "sheet", "card"];
|
|
49
49
|
export const WIDGET_PLATFORMS = ["auto", "ios", "material", "oneui"];
|
|
50
|
+
export const WIDGET_SELECTS = ["one", "many"];
|
|
51
|
+
export const MAX_SELECT_DEFAULT = 3;
|
|
50
52
|
export const DEFAULT_WIDGET_PRESET = "cards";
|
|
51
53
|
export function normaliseWidgetStyle(raw) {
|
|
52
54
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
@@ -85,6 +87,12 @@ export function normaliseWidgetStyle(raw) {
|
|
|
85
87
|
const platform = pick(r.platform, WIDGET_PLATFORMS);
|
|
86
88
|
if (platform)
|
|
87
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
|
+
}
|
|
88
96
|
return out;
|
|
89
97
|
}
|
|
90
98
|
const PRESET_DEFAULTS = {
|
|
@@ -110,8 +118,18 @@ export function resolveWidgetLook(style, kind) {
|
|
|
110
118
|
...(s.radius !== undefined ? { radius: s.radius } : {}),
|
|
111
119
|
...(s.accent ? { accent: s.accent } : {}),
|
|
112
120
|
platform: s.platform ?? "auto",
|
|
121
|
+
select: s.select ?? "one",
|
|
122
|
+
maxSelect: s.maxSelect ?? MAX_SELECT_DEFAULT,
|
|
113
123
|
};
|
|
114
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
|
+
}
|
|
115
133
|
export const PLATFORM_GRAMMAR = {
|
|
116
134
|
ios: { radius: 12, chipRadius: 8, buttonRadius: 12, pillButtons: false, separatorInset: 16, grabber: { width: 36, height: 5 }, titleWeight: "600", padBoost: 0, chipOutlined: false },
|
|
117
135
|
material: { radius: 16, chipRadius: 8, buttonRadius: 20, pillButtons: true, separatorInset: 0, grabber: { width: 32, height: 4 }, titleWeight: "500", padBoost: 0, chipOutlined: true },
|
|
@@ -124,3 +142,122 @@ export function resolvePlatform(platform, os, manufacturer) {
|
|
|
124
142
|
return /samsung/i.test(String(manufacturer ?? "")) ? "oneui" : "material";
|
|
125
143
|
return "ios";
|
|
126
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,7 +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
|
+
import { type WidgetStyle, type WidgetSelectAction } from "../protocol.js";
|
|
6
6
|
export { GraineFab, GraineTouchCapture } from "./fab.js";
|
|
7
7
|
export type { GraineFabProps, GraineTouchCaptureProps } from "./fab.js";
|
|
8
8
|
export { widgetSurface } from "../protocol.js";
|
|
@@ -69,12 +69,15 @@ export type GraineEvent = {
|
|
|
69
69
|
screen: string;
|
|
70
70
|
detail: string;
|
|
71
71
|
};
|
|
72
|
+
export declare const PENDING_WIDGET_MS = 12000;
|
|
73
|
+
export declare const AWAITING_REPLY_MS = 12000;
|
|
72
74
|
export interface GraineWidget {
|
|
73
75
|
id: string;
|
|
74
76
|
kind: string;
|
|
75
77
|
data: Record<string, unknown>;
|
|
76
78
|
fallbackText: string;
|
|
77
79
|
style?: WidgetStyle;
|
|
80
|
+
onSelect?: WidgetSelectAction;
|
|
78
81
|
}
|
|
79
82
|
export interface Caption {
|
|
80
83
|
role: "agent" | "customer";
|
|
@@ -132,6 +135,13 @@ export declare function useGraineWidget(): {
|
|
|
132
135
|
data: Record<string, unknown>;
|
|
133
136
|
summary: string;
|
|
134
137
|
} | null;
|
|
138
|
+
loading: {
|
|
139
|
+
id: string;
|
|
140
|
+
kind: string;
|
|
141
|
+
} | null;
|
|
142
|
+
awaitingReply: boolean;
|
|
143
|
+
closed: GraineWidget | null;
|
|
144
|
+
reopen: () => void;
|
|
135
145
|
submit: (data: Record<string, unknown>, summary: string) => void;
|
|
136
146
|
dismiss: () => void;
|
|
137
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, normaliseWidgetStyle } 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) {
|
|
@@ -397,36 +399,125 @@ export function useGraineWidget() {
|
|
|
397
399
|
data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
|
|
398
400
|
fallbackText: String(w.fallback_text || ""),
|
|
399
401
|
style: normaliseWidgetStyle(w.style),
|
|
402
|
+
onSelect: normaliseSelectAction(w.on_select ?? w.onSelect),
|
|
400
403
|
}
|
|
401
404
|
: null, [client]);
|
|
402
405
|
const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
|
|
403
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]);
|
|
404
437
|
useEffect(() => {
|
|
405
438
|
const pending = adopt(client.pendingWidget);
|
|
406
439
|
if (pending)
|
|
407
440
|
setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
|
|
408
|
-
|
|
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) => {
|
|
409
448
|
const next = adopt(w);
|
|
410
449
|
if (!next)
|
|
411
450
|
return;
|
|
451
|
+
if (loadingTimer.current) {
|
|
452
|
+
clearTimeout(loadingTimer.current);
|
|
453
|
+
loadingTimer.current = null;
|
|
454
|
+
}
|
|
455
|
+
setLoading(null);
|
|
456
|
+
setClosed(null);
|
|
412
457
|
setWidget(next);
|
|
413
458
|
setAnswered(null);
|
|
414
459
|
});
|
|
460
|
+
return () => { offPending(); offWidget(); };
|
|
415
461
|
}, [client, adopt]);
|
|
416
462
|
const submit = useCallback((data, summary) => {
|
|
417
463
|
if (!widget)
|
|
418
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
|
+
}
|
|
419
483
|
client.submitWidget(widget.id, data, summary);
|
|
420
484
|
client.clearPendingWidget();
|
|
421
485
|
setWidget(null);
|
|
422
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);
|
|
423
491
|
}, [client, widget]);
|
|
424
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);
|
|
425
507
|
client.clearPendingWidget();
|
|
426
508
|
setWidget(null);
|
|
427
509
|
setAnswered(null);
|
|
428
|
-
|
|
429
|
-
|
|
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 };
|
|
430
521
|
}
|
|
431
522
|
export function useGraineAction(name, handler) {
|
|
432
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
|
+
}
|
|
@@ -35,7 +35,9 @@ export interface GraineConnectivity {
|
|
|
35
35
|
attempt: number;
|
|
36
36
|
reason?: string;
|
|
37
37
|
}
|
|
38
|
+
export type GrainePhase = "idle" | "connecting" | "reconnecting" | "thinking" | "speaking" | "listening";
|
|
38
39
|
export interface GraineVoiceApi {
|
|
40
|
+
phase: GrainePhase;
|
|
39
41
|
connectivity: GraineConnectivity;
|
|
40
42
|
retry: () => void;
|
|
41
43
|
connected: boolean;
|
|
@@ -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) => {
|
|
@@ -248,8 +249,19 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
248
249
|
text: String(msg.text ?? ""),
|
|
249
250
|
live: !!msg.live,
|
|
250
251
|
};
|
|
252
|
+
if (line.role === "customer" && !line.live)
|
|
253
|
+
setAwaitingAgent(true);
|
|
254
|
+
if (line.role === "agent")
|
|
255
|
+
setAwaitingAgent(false);
|
|
251
256
|
foldCaption(line);
|
|
252
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 || {});
|
|
253
265
|
return;
|
|
254
266
|
}
|
|
255
267
|
case "graine:ended":
|
|
@@ -285,7 +297,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
285
297
|
}
|
|
286
298
|
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
287
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";
|
|
288
307
|
const api = {
|
|
308
|
+
phase,
|
|
289
309
|
connected,
|
|
290
310
|
connecting,
|
|
291
311
|
muted,
|
|
@@ -298,6 +318,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
298
318
|
start: useCallback(() => {
|
|
299
319
|
setConnecting(true);
|
|
300
320
|
setCaptions([]);
|
|
321
|
+
setAwaitingAgent(false);
|
|
301
322
|
client.clearPendingWidget();
|
|
302
323
|
if (!ready) {
|
|
303
324
|
pendingStartRef.current = true;
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useContext, useState } from "react";
|
|
3
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 { PLATFORM_GRAMMAR, resolvePlatform, resolveWidgetLook } 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)
|
|
@@ -14,12 +15,13 @@ function tint(hex, alpha) {
|
|
|
14
15
|
const HERO_HEIGHT = { small: 100, medium: 140, large: 180 };
|
|
15
16
|
const THUMB = { small: 56, medium: 72, large: 96 };
|
|
16
17
|
const LIST_THUMB = { small: 36, medium: 44, large: 56 };
|
|
17
|
-
function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick, }) {
|
|
18
|
+
function WidgetRow({ row, variant, look, grammar, theme, accent, radius, picked, onPick, }) {
|
|
19
|
+
const many = picked !== null;
|
|
18
20
|
const r = Math.max(10, radius - 6);
|
|
19
21
|
const tight = look.density === "compact";
|
|
20
22
|
const pad = (variant === "list" ? (tight ? 6 : 9) : (tight ? 7 : 10)) + grammar.padBoost;
|
|
21
23
|
const gap = tight ? 6 : 10;
|
|
22
|
-
const rowIsButton = look.button === "row";
|
|
24
|
+
const rowIsButton = many || look.button === "row";
|
|
23
25
|
const outline = look.button === "outline";
|
|
24
26
|
const label = row.unavailable ? "Unavailable" : row.selectLabel;
|
|
25
27
|
const a11y = `${row.selectLabel}: ${row.title}`;
|
|
@@ -38,6 +40,11 @@ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick,
|
|
|
38
40
|
borderWidth: grammar.chipOutlined ? 1 : 0, borderColor: theme.border,
|
|
39
41
|
borderRadius: grammar.chipRadius, paddingHorizontal: variant === "list" ? 6 : 8, paddingVertical: variant === "list" ? 2 : 4, overflow: "hidden",
|
|
40
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 }));
|
|
41
48
|
const button = (wide) => (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, hitSlop: wide ? undefined : 8, style: {
|
|
42
49
|
minHeight: 44, justifyContent: "center", alignItems: "center",
|
|
43
50
|
borderRadius: wide ? grammar.buttonRadius : (grammar.pillButtons ? 999 : grammar.buttonRadius), paddingHorizontal: wide ? 12 : 14,
|
|
@@ -52,14 +59,14 @@ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick,
|
|
|
52
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;
|
|
53
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 }));
|
|
54
61
|
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,
|
|
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,
|
|
57
64
|
opacity: row.unavailable ? 0.55 : 1, gap, flexDirection: "column",
|
|
58
65
|
});
|
|
59
66
|
}
|
|
60
67
|
if (variant === "list") {
|
|
61
68
|
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)] }), {
|
|
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)] }), {
|
|
63
70
|
flexDirection: "row", alignItems: "center", gap,
|
|
64
71
|
paddingVertical: pad, paddingHorizontal: 2, minHeight: 44,
|
|
65
72
|
borderBottomWidth: 1, borderBottomColor: theme.border, marginLeft: grammar.separatorInset,
|
|
@@ -67,21 +74,23 @@ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick,
|
|
|
67
74
|
});
|
|
68
75
|
}
|
|
69
76
|
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)] }), {
|
|
71
|
-
borderRadius: r, borderWidth: 1, borderColor: theme.border,
|
|
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,
|
|
72
79
|
paddingVertical: pad, paddingHorizontal: pad + 2,
|
|
73
80
|
flexDirection: "row", alignItems: "center", gap,
|
|
74
81
|
opacity: row.unavailable ? 0.55 : 1, minHeight: 44,
|
|
75
82
|
});
|
|
76
83
|
}
|
|
77
84
|
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,
|
|
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,
|
|
80
87
|
opacity: row.unavailable ? 0.55 : 1, flexDirection: "row", gap, alignItems: "center",
|
|
81
88
|
});
|
|
82
89
|
}
|
|
83
|
-
function AnsweredLine({ answered, theme, accent, radius, onClear }) {
|
|
90
|
+
function AnsweredLine({ answered, theme, accent, radius, awaiting, onClear }) {
|
|
84
91
|
const d = (answered.data || {});
|
|
92
|
+
const appearance = useContext(Ctx)?.appearance ?? null;
|
|
93
|
+
const loaderText = resolveLoader(appearance?.loader).text.thinking;
|
|
85
94
|
const title = String(d.name || d.title || d.label || d.slot || "").trim();
|
|
86
95
|
const detail = [String(d.price || ""), String(d.day || "")].filter(Boolean).join(" · ");
|
|
87
96
|
if (!title && !detail)
|
|
@@ -93,14 +102,15 @@ function AnsweredLine({ answered, theme, accent, radius, onClear }) {
|
|
|
93
102
|
}, children: [_jsx(View, { style: {
|
|
94
103
|
width: 26, height: 26, borderRadius: 999, backgroundColor: tint(accent, 0.18),
|
|
95
104
|
alignItems: "center", justifyContent: "center",
|
|
96
|
-
}, 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" }) }))] }));
|
|
97
106
|
}
|
|
98
107
|
export function GraineWidget(props) {
|
|
99
|
-
const { widget, answered, submit, dismiss } = useGraineWidget();
|
|
108
|
+
const { widget, answered, loading, awaitingReply, submit, dismiss } = useGraineWidget();
|
|
100
109
|
const appearance = useContext(Ctx)?.appearance ?? null;
|
|
101
110
|
const scheme = useColorScheme();
|
|
102
111
|
const [pagerWidth, setPagerWidth] = useState(0);
|
|
103
112
|
const [page, setPage] = useState(0);
|
|
113
|
+
const [ticked, setTicked] = useState([]);
|
|
104
114
|
const look = resolveWidgetLook(widget?.style, widget?.kind);
|
|
105
115
|
const grammar = PLATFORM_GRAMMAR[resolvePlatform(look.platform, Platform.OS, Platform.constants?.Manufacturer ?? Platform.constants?.Brand)];
|
|
106
116
|
const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
|
|
@@ -110,7 +120,20 @@ export function GraineWidget(props) {
|
|
|
110
120
|
...resolveWidgetPalette(appearance, { scheme, accent }),
|
|
111
121
|
...(props.theme ?? {}),
|
|
112
122
|
};
|
|
113
|
-
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
|
+
}
|
|
114
137
|
if (!widget)
|
|
115
138
|
return chosen;
|
|
116
139
|
const data = (widget.data ?? {});
|
|
@@ -177,6 +200,17 @@ export function GraineWidget(props) {
|
|
|
177
200
|
const drawable = isProducts || isChips || isCalendar;
|
|
178
201
|
const surface = drawable ? look.surface : "card";
|
|
179
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
|
+
};
|
|
180
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) => {
|
|
181
215
|
const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
|
|
182
216
|
const slots = Array.isArray(d?.slots) ? d.slots.map(String) : [];
|
|
@@ -188,7 +222,12 @@ export function GraineWidget(props) {
|
|
|
188
222
|
paddingHorizontal: 14,
|
|
189
223
|
}, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
|
|
190
224
|
})] })) : isProducts ? ((() => {
|
|
191
|
-
const
|
|
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}`));
|
|
192
231
|
const gap = look.density === "compact" ? 6 : 10;
|
|
193
232
|
if (look.preset === "spotlight") {
|
|
194
233
|
const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
|
|
@@ -201,16 +240,32 @@ export function GraineWidget(props) {
|
|
|
201
240
|
if (look.columns === 2) {
|
|
202
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}`))) }));
|
|
203
242
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
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}` }) })] }));
|
|
214
269
|
})()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
|
|
215
270
|
const label = String(o?.label ?? o?.title ?? o ?? "");
|
|
216
271
|
const id = String(o?.id ?? label);
|
package/package.json
CHANGED