@graineai/inapp-react-native 0.33.4 → 0.33.6
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 +12 -1
- package/dist/react-native/index.js +125 -6
- package/dist/react-native/loader.d.ts +14 -0
- package/dist/react-native/loader.js +73 -0
- package/dist/react-native/ui.js +6 -3
- package/dist/react-native/voice-launcher.d.ts +2 -0
- package/dist/react-native/voice-launcher.js +22 -0
- package/dist/react-native/widget.js +83 -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, AWAITING_REPLY_MS, MIN_LOADER_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, AWAITING_REPLY_MS, MIN_LOADER_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 ?? false,
|
|
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,16 @@ 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;
|
|
74
|
+
export declare const MIN_LOADER_MS = 450;
|
|
72
75
|
export interface GraineWidget {
|
|
73
76
|
id: string;
|
|
74
77
|
kind: string;
|
|
75
78
|
data: Record<string, unknown>;
|
|
76
79
|
fallbackText: string;
|
|
77
80
|
style?: WidgetStyle;
|
|
81
|
+
onSelect?: WidgetSelectAction;
|
|
78
82
|
}
|
|
79
83
|
export interface Caption {
|
|
80
84
|
role: "agent" | "customer";
|
|
@@ -132,6 +136,13 @@ export declare function useGraineWidget(): {
|
|
|
132
136
|
data: Record<string, unknown>;
|
|
133
137
|
summary: string;
|
|
134
138
|
} | null;
|
|
139
|
+
loading: {
|
|
140
|
+
id: string;
|
|
141
|
+
kind: string;
|
|
142
|
+
} | null;
|
|
143
|
+
awaitingReply: boolean;
|
|
144
|
+
closed: GraineWidget | null;
|
|
145
|
+
reopen: () => void;
|
|
135
146
|
submit: (data: Record<string, unknown>, summary: string) => void;
|
|
136
147
|
dismiss: () => void;
|
|
137
148
|
};
|
|
@@ -3,11 +3,14 @@ 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;
|
|
13
|
+
export const MIN_LOADER_MS = 450;
|
|
11
14
|
export const Ctx = createContext(null);
|
|
12
15
|
let defaultClient = null;
|
|
13
16
|
export function setDefaultClient(client) {
|
|
@@ -126,6 +129,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
|
|
|
126
129
|
openRef.current = open;
|
|
127
130
|
useEffect(() => {
|
|
128
131
|
const offs = [
|
|
132
|
+
client.on("call_state", (s) => {
|
|
133
|
+
if (client.isConnected)
|
|
134
|
+
return;
|
|
135
|
+
setConnected(!!s?.connected);
|
|
136
|
+
setConnecting(!!s?.connecting);
|
|
137
|
+
}),
|
|
129
138
|
client.on("open", () => {
|
|
130
139
|
setConnected(true);
|
|
131
140
|
setConnecting(false);
|
|
@@ -397,36 +406,146 @@ export function useGraineWidget() {
|
|
|
397
406
|
data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
|
|
398
407
|
fallbackText: String(w.fallback_text || ""),
|
|
399
408
|
style: normaliseWidgetStyle(w.style),
|
|
409
|
+
onSelect: normaliseSelectAction(w.on_select ?? w.onSelect),
|
|
400
410
|
}
|
|
401
411
|
: null, [client]);
|
|
402
412
|
const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
|
|
403
413
|
const [answered, setAnswered] = useState(null);
|
|
414
|
+
const [loading, setLoading] = useState(null);
|
|
415
|
+
const loadingTimer = useRef(null);
|
|
416
|
+
const shownAt = useRef(null);
|
|
417
|
+
const minTimer = useRef(null);
|
|
418
|
+
const [awaitingReply, setAwaitingReply] = useState(false);
|
|
419
|
+
const [closed, setClosed] = useState(null);
|
|
420
|
+
const awaitingTimer = useRef(null);
|
|
421
|
+
useEffect(() => () => {
|
|
422
|
+
if (loadingTimer.current)
|
|
423
|
+
clearTimeout(loadingTimer.current);
|
|
424
|
+
if (awaitingTimer.current)
|
|
425
|
+
clearTimeout(awaitingTimer.current);
|
|
426
|
+
if (minTimer.current)
|
|
427
|
+
clearTimeout(minTimer.current);
|
|
428
|
+
}, []);
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
const done = () => {
|
|
431
|
+
if (awaitingTimer.current) {
|
|
432
|
+
clearTimeout(awaitingTimer.current);
|
|
433
|
+
awaitingTimer.current = null;
|
|
434
|
+
}
|
|
435
|
+
setAwaitingReply(false);
|
|
436
|
+
};
|
|
437
|
+
const offs = [
|
|
438
|
+
client.on("agent_speaking", (speaking) => { if (speaking !== false)
|
|
439
|
+
done(); }),
|
|
440
|
+
client.on("chunk", done),
|
|
441
|
+
client.on("caption", (c) => { if (c?.role === "agent")
|
|
442
|
+
done(); }),
|
|
443
|
+
client.on("widget", done),
|
|
444
|
+
];
|
|
445
|
+
return () => { for (const off of offs)
|
|
446
|
+
off(); };
|
|
447
|
+
}, [client]);
|
|
404
448
|
useEffect(() => {
|
|
405
449
|
const pending = adopt(client.pendingWidget);
|
|
406
450
|
if (pending)
|
|
407
451
|
setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
|
|
408
|
-
|
|
452
|
+
const offPending = client.on("widget_pending", (hint) => {
|
|
453
|
+
shownAt.current = Date.now();
|
|
454
|
+
setLoading({ id: String(hint?.widget_id ?? ""), kind: String(hint?.kind ?? "") });
|
|
455
|
+
if (loadingTimer.current)
|
|
456
|
+
clearTimeout(loadingTimer.current);
|
|
457
|
+
loadingTimer.current = setTimeout(() => setLoading(null), PENDING_WIDGET_MS);
|
|
458
|
+
});
|
|
459
|
+
const offWidget = client.on("widget", (w) => {
|
|
409
460
|
const next = adopt(w);
|
|
410
461
|
if (!next)
|
|
411
462
|
return;
|
|
412
|
-
|
|
413
|
-
|
|
463
|
+
if (loadingTimer.current) {
|
|
464
|
+
clearTimeout(loadingTimer.current);
|
|
465
|
+
loadingTimer.current = null;
|
|
466
|
+
}
|
|
467
|
+
const show = () => {
|
|
468
|
+
shownAt.current = null;
|
|
469
|
+
setLoading(null);
|
|
470
|
+
setClosed(null);
|
|
471
|
+
setWidget(next);
|
|
472
|
+
setAnswered(null);
|
|
473
|
+
};
|
|
474
|
+
const seen = shownAt.current === null ? MIN_LOADER_MS : Date.now() - shownAt.current;
|
|
475
|
+
if (seen >= MIN_LOADER_MS)
|
|
476
|
+
show();
|
|
477
|
+
else {
|
|
478
|
+
if (minTimer.current)
|
|
479
|
+
clearTimeout(minTimer.current);
|
|
480
|
+
minTimer.current = setTimeout(show, MIN_LOADER_MS - seen);
|
|
481
|
+
}
|
|
414
482
|
});
|
|
483
|
+
return () => { offPending(); offWidget(); };
|
|
415
484
|
}, [client, adopt]);
|
|
416
485
|
const submit = useCallback((data, summary) => {
|
|
417
486
|
if (!widget)
|
|
418
487
|
return;
|
|
488
|
+
const onSelect = widget.onSelect;
|
|
489
|
+
const item = { ...data, summary };
|
|
490
|
+
const call = selectCall(onSelect, item);
|
|
491
|
+
if (call) {
|
|
492
|
+
void client
|
|
493
|
+
.executeAction(call.name, call.args)
|
|
494
|
+
.then((res) => {
|
|
495
|
+
if (res?.status && res.status !== "ok") {
|
|
496
|
+
client.reportEvent({
|
|
497
|
+
name: "in_app_action_failed",
|
|
498
|
+
detail: `"${call.name}" did not run: ${res?.message || res.status}. Do not describe a screen the customer cannot see.`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
.catch(() => { });
|
|
503
|
+
if (onSelect?.say)
|
|
504
|
+
summary = fillSelectTemplate(onSelect.say, item);
|
|
505
|
+
}
|
|
419
506
|
client.submitWidget(widget.id, data, summary);
|
|
420
507
|
client.clearPendingWidget();
|
|
421
508
|
setWidget(null);
|
|
422
509
|
setAnswered({ widget, data, summary });
|
|
510
|
+
setAwaitingReply(true);
|
|
511
|
+
if (awaitingTimer.current)
|
|
512
|
+
clearTimeout(awaitingTimer.current);
|
|
513
|
+
awaitingTimer.current = setTimeout(() => setAwaitingReply(false), AWAITING_REPLY_MS);
|
|
423
514
|
}, [client, widget]);
|
|
424
515
|
const dismiss = useCallback(() => {
|
|
516
|
+
if (minTimer.current) {
|
|
517
|
+
clearTimeout(minTimer.current);
|
|
518
|
+
minTimer.current = null;
|
|
519
|
+
}
|
|
520
|
+
shownAt.current = null;
|
|
521
|
+
if (widget) {
|
|
522
|
+
client.reportEvent({
|
|
523
|
+
name: "component_dismissed",
|
|
524
|
+
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.",
|
|
525
|
+
quiet: true,
|
|
526
|
+
data: {
|
|
527
|
+
tool_name: widget.id.split(":")[0] || "",
|
|
528
|
+
kind: widget.kind,
|
|
529
|
+
title: String(widget.data?.title ?? ""),
|
|
530
|
+
answered: false,
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
setClosed(widget);
|
|
425
535
|
client.clearPendingWidget();
|
|
426
536
|
setWidget(null);
|
|
427
537
|
setAnswered(null);
|
|
428
|
-
|
|
429
|
-
|
|
538
|
+
setLoading(null);
|
|
539
|
+
setAwaitingReply(false);
|
|
540
|
+
}, [client, widget]);
|
|
541
|
+
const reopen = useCallback(() => {
|
|
542
|
+
if (!closed)
|
|
543
|
+
return;
|
|
544
|
+
setWidget(closed);
|
|
545
|
+
setClosed(null);
|
|
546
|
+
setAnswered(null);
|
|
547
|
+
}, [closed]);
|
|
548
|
+
return { widget, answered, loading, awaitingReply, closed, reopen, submit, dismiss };
|
|
430
549
|
}
|
|
431
550
|
export function useGraineAction(name, handler) {
|
|
432
551
|
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
|
+
}
|
package/dist/react-native/ui.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index.js";
|
|
5
|
+
import { GraineLoader } from "./loader.js";
|
|
5
6
|
import { resolveNativeAppearance } from "./appearance.js";
|
|
6
7
|
const DARK = {
|
|
7
8
|
surface: "#17161A",
|
|
@@ -40,6 +41,8 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
40
41
|
const [draft, setDraft] = useState("");
|
|
41
42
|
const scrollRef = useRef(null);
|
|
42
43
|
const last = messages[messages.length - 1];
|
|
44
|
+
const waiting = connecting;
|
|
45
|
+
const waitingMoment = "connecting";
|
|
43
46
|
const status = error
|
|
44
47
|
? "unavailable"
|
|
45
48
|
: connecting ? "connecting…"
|
|
@@ -74,7 +77,7 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
|
|
|
74
77
|
], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
|
|
75
78
|
styles.bubble,
|
|
76
79
|
m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
|
|
77
|
-
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }),
|
|
80
|
+
], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), waiting ? null : (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] }))] })] }));
|
|
78
81
|
}
|
|
79
82
|
export const GraineLauncher = GraineAgentBar;
|
|
80
83
|
const makeStyles = (t) => StyleSheet.create({
|
|
@@ -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) => {
|
|
@@ -235,6 +236,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
235
236
|
endedNotifiedRef.current = false;
|
|
236
237
|
setConnected(!!msg.connected);
|
|
237
238
|
setConnecting(!!msg.connecting);
|
|
239
|
+
client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
|
|
238
240
|
if (!msg.connected && !msg.connecting)
|
|
239
241
|
startedRef.current = false;
|
|
240
242
|
onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
|
|
@@ -248,8 +250,19 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
248
250
|
text: String(msg.text ?? ""),
|
|
249
251
|
live: !!msg.live,
|
|
250
252
|
};
|
|
253
|
+
if (line.role === "customer" && !line.live)
|
|
254
|
+
setAwaitingAgent(true);
|
|
255
|
+
if (line.role === "agent")
|
|
256
|
+
setAwaitingAgent(false);
|
|
251
257
|
foldCaption(line);
|
|
252
258
|
onCaption?.(line);
|
|
259
|
+
client.emit?.("caption", line);
|
|
260
|
+
if (line.role === "agent")
|
|
261
|
+
client.emit?.("agent_speaking", true);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
case "graine:widget-pending": {
|
|
265
|
+
client.emit?.("widget_pending", msg.widget || {});
|
|
253
266
|
return;
|
|
254
267
|
}
|
|
255
268
|
case "graine:ended":
|
|
@@ -285,7 +298,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
285
298
|
}
|
|
286
299
|
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
287
300
|
const pendingStartRef = useRef(false);
|
|
301
|
+
const lastCaption = captions.length ? captions[captions.length - 1] : null;
|
|
302
|
+
const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
|
|
303
|
+
: connecting || (!ready && !!voiceAgentId) ? "connecting"
|
|
304
|
+
: !connected ? "idle"
|
|
305
|
+
: lastCaption?.role === "agent" && lastCaption.live ? "speaking"
|
|
306
|
+
: awaitingAgent ? "thinking"
|
|
307
|
+
: "listening";
|
|
288
308
|
const api = {
|
|
309
|
+
phase,
|
|
289
310
|
connected,
|
|
290
311
|
connecting,
|
|
291
312
|
muted,
|
|
@@ -298,6 +319,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
298
319
|
start: useCallback(() => {
|
|
299
320
|
setConnecting(true);
|
|
300
321
|
setCaptions([]);
|
|
322
|
+
setAwaitingAgent(false);
|
|
301
323
|
client.clearPendingWidget();
|
|
302
324
|
if (!ready) {
|
|
303
325
|
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,23 @@ 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 kindSurface = widgetSurface(loading.kind);
|
|
127
|
+
const pendingSurface = kindSurface;
|
|
128
|
+
const big = pendingSurface === "screen" ? 34 : pendingSurface === "sheet" ? 26 : undefined;
|
|
129
|
+
const mark = (_jsx(View, { style: { alignItems: "center", gap: 10 }, children: _jsx(GraineLoader, { moment: "component", size: big, stacked: true }) }));
|
|
130
|
+
const bone = (w, h) => (_jsx(View, { style: { width: w, height: h, borderRadius: 8, backgroundColor: tint(theme.border, 0.6) } }));
|
|
131
|
+
const bones = (rows) => (_jsx(View, { style: { gap: 10, opacity: 0.55 }, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: Array.from({ length: rows }, (_, 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))) }));
|
|
132
|
+
if (pendingSurface === "screen") {
|
|
133
|
+
return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", onRequestClose: dismiss, transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, children: _jsxs(View, { style: { flex: 1, backgroundColor: theme.surface, paddingTop: 56, paddingHorizontal: 18 }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close and keep talking", hitSlop: 10, style: { width: 44, height: 44, borderRadius: 999, alignItems: "center", justifyContent: "center", backgroundColor: tint(theme.border, 0.4) }, children: _jsx(Text, { style: { color: theme.text, fontSize: 20, fontWeight: "600" }, children: "\u00D7" }) }), _jsx(View, { style: { flex: 1, justifyContent: "center", paddingBottom: loader.skeleton ? 24 : 72 }, children: mark }), loader.skeleton ? bones(3) : null] }) })] }));
|
|
134
|
+
}
|
|
135
|
+
if (pendingSurface === "sheet") {
|
|
136
|
+
return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", 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: { backgroundColor: theme.surface, borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius), paddingHorizontal: 18, paddingTop: 10, paddingBottom: 28, gap: 18 }, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width: grammar.grabber.width, height: grammar.grabber.height, borderRadius: 999, backgroundColor: theme.border } }) }), _jsx(View, { style: { paddingVertical: loader.skeleton ? 10 : 34 }, children: mark }), loader.skeleton ? bones(2) : null] })] }) })] }));
|
|
137
|
+
}
|
|
138
|
+
return (_jsxs(_Fragment, { children: [chosen, _jsx(View, { style: { backgroundColor: theme.surface, borderRadius: radius, borderWidth: 1, borderColor: theme.border, paddingHorizontal: 14, paddingVertical: 14, marginBottom: 8, alignItems: "center" }, children: _jsx(GraineLoader, { moment: "component" }) })] }));
|
|
139
|
+
}
|
|
114
140
|
if (!widget)
|
|
115
141
|
return chosen;
|
|
116
142
|
const data = (widget.data ?? {});
|
|
@@ -177,6 +203,17 @@ export function GraineWidget(props) {
|
|
|
177
203
|
const drawable = isProducts || isChips || isCalendar;
|
|
178
204
|
const surface = drawable ? look.surface : "card";
|
|
179
205
|
const pick = (it) => submit(normalised.toSubmit(it), normalised.toSay(it));
|
|
206
|
+
const many = look.select === "many" && drawable;
|
|
207
|
+
const submitMany = (items) => {
|
|
208
|
+
const titles = items.map((it) => String(it.title ?? it.name ?? "")).filter(Boolean);
|
|
209
|
+
submit({
|
|
210
|
+
items: items.map((it) => normalised.toSubmit(it)),
|
|
211
|
+
ids: items.map((it) => String(it.id ?? "")).join(","),
|
|
212
|
+
titles: titles.join(", "),
|
|
213
|
+
count: items.length,
|
|
214
|
+
}, manySummary(titles, normalised.selectLabel.toLowerCase()));
|
|
215
|
+
setTicked([]);
|
|
216
|
+
};
|
|
180
217
|
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
218
|
const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
|
|
182
219
|
const slots = Array.isArray(d?.slots) ? d.slots.map(String) : [];
|
|
@@ -188,7 +225,12 @@ export function GraineWidget(props) {
|
|
|
188
225
|
paddingHorizontal: 14,
|
|
189
226
|
}, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
|
|
190
227
|
})] })) : isProducts ? ((() => {
|
|
191
|
-
const
|
|
228
|
+
const toggle = (id) => setTicked((cur) => cur.includes(id) ? cur.filter((x) => x !== id)
|
|
229
|
+
: cur.length >= look.maxSelect ? [...cur.slice(1), id]
|
|
230
|
+
: [...cur, id]);
|
|
231
|
+
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
|
|
232
|
+
? toggle(row.id)
|
|
233
|
+
: pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`));
|
|
192
234
|
const gap = look.density === "compact" ? 6 : 10;
|
|
193
235
|
if (look.preset === "spotlight") {
|
|
194
236
|
const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
|
|
@@ -201,16 +243,32 @@ export function GraineWidget(props) {
|
|
|
201
243
|
if (look.columns === 2) {
|
|
202
244
|
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
245
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
246
|
+
const body = look.preset === "gallery" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "hero")) })
|
|
247
|
+
: look.preset === "list" ? _jsx(View, { children: rows.map((row, i) => draw(row, i, "list")) })
|
|
248
|
+
: look.preset === "compact" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "compact")) })
|
|
249
|
+
: _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "row")) });
|
|
250
|
+
if (!many)
|
|
251
|
+
return body;
|
|
252
|
+
const chosenRows = ticked
|
|
253
|
+
.map((id) => ({ row: rows.find((r) => r.id === id), i: rows.findIndex((r) => r.id === id) }))
|
|
254
|
+
.filter((x) => !!x.row);
|
|
255
|
+
return (_jsxs(_Fragment, { children: [body, _jsx(Pressable, { disabled: chosenRows.length < 2, onPress: () => {
|
|
256
|
+
const items = chosenRows.map(({ row, i }) => ({
|
|
257
|
+
...normalised.items[i], id: row.id, title: row.title, price: row.price,
|
|
258
|
+
}));
|
|
259
|
+
submitMany(items);
|
|
260
|
+
}, accessibilityRole: "button", accessibilityLabel: chosenRows.length < 2
|
|
261
|
+
? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
|
|
262
|
+
: `${normalised.selectLabel} ${chosenRows.length}`, style: {
|
|
263
|
+
marginTop: 12, minHeight: 48, borderRadius: grammar.pillButtons ? 999 : grammar.buttonRadius,
|
|
264
|
+
alignItems: "center", justifyContent: "center",
|
|
265
|
+
backgroundColor: chosenRows.length < 2 ? tint(theme.border, 0.5) : accent,
|
|
266
|
+
}, children: _jsx(Text, { style: {
|
|
267
|
+
color: chosenRows.length < 2 ? theme.muted : theme.onAccent,
|
|
268
|
+
fontSize: 14.5, fontWeight: "700",
|
|
269
|
+
}, children: chosenRows.length < 2
|
|
270
|
+
? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
|
|
271
|
+
: `${normalised.selectLabel} ${chosenRows.length}` }) })] }));
|
|
214
272
|
})()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
|
|
215
273
|
const label = String(o?.label ?? o?.title ?? o ?? "");
|
|
216
274
|
const id = String(o?.id ?? label);
|
package/package.json
CHANGED