@graineai/inapp-react-native 0.27.5 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INTEGRATION.md +48 -1
- package/dist/friction.d.ts +5 -1
- package/dist/friction.js +39 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/protocol.d.ts +3 -0
- package/dist/protocol.js +28 -0
- package/dist/react-native/index.js +2 -1
- package/dist/react-native/voice-launcher.d.ts +7 -0
- package/dist/react-native/voice-launcher.js +40 -5
- package/package.json +2 -2
package/INTEGRATION.md
CHANGED
|
@@ -558,6 +558,53 @@ your funnel.
|
|
|
558
558
|
|
|
559
559
|
---
|
|
560
560
|
|
|
561
|
+
## Captions and hanging up
|
|
562
|
+
|
|
563
|
+
`GraineVoiceLauncher`'s render prop gives you the whole transcript and the CC
|
|
564
|
+
switch, so a caption box is a `map` and nothing else. **0.28.0.**
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
<GraineVoiceLauncher webView={WebView} active={open} autoStart>
|
|
568
|
+
{(api) => (
|
|
569
|
+
<>
|
|
570
|
+
{api.captionsOn && api.captions.map((c, i) => (
|
|
571
|
+
<Text key={i} style={{ opacity: c.live ? 0.7 : 1 }}>
|
|
572
|
+
{c.role === "agent" ? "Ring" : "You"}: {c.text}
|
|
573
|
+
</Text>
|
|
574
|
+
))}
|
|
575
|
+
<Button title="CC" onPress={() => api.setCaptionsOn(!api.captionsOn)} />
|
|
576
|
+
<Button title="End" onPress={api.end} />
|
|
577
|
+
</>
|
|
578
|
+
)}
|
|
579
|
+
</GraineVoiceLauncher>
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
| On `api` | |
|
|
583
|
+
|---|---|
|
|
584
|
+
| `captions` | Every turn so far, oldest first, capped at `CAPTION_HISTORY` (12) |
|
|
585
|
+
| `caption` | The turn being said now, or the last one. `null` before any |
|
|
586
|
+
| `captionsOn` / `setCaptionsOn` | The CC switch. Defaults **on** |
|
|
587
|
+
| `clearCaptions()` | Forget the transcript without ending the call |
|
|
588
|
+
| `end()` | Hang up |
|
|
589
|
+
|
|
590
|
+
A turn arrives many times as its words come in — each with fuller text and
|
|
591
|
+
`live: true` — then once more settled with `live: false`. The launcher folds
|
|
592
|
+
those into one entry, so `captions` holds turns, not keystrokes. Render `live`
|
|
593
|
+
ones as provisional if you want; they are the same line about to be finished.
|
|
594
|
+
|
|
595
|
+
The transcript is **cleared when a call starts and when you set `active={false}`**.
|
|
596
|
+
A call's words outliving its call is a privacy question, not a feature — if you
|
|
597
|
+
need them afterwards, copy them in `onEnded`.
|
|
598
|
+
|
|
599
|
+
`onCaption` still fires for every line and is unchanged; it is now a convenience
|
|
600
|
+
on top of `captions` rather than the only way to see them.
|
|
601
|
+
|
|
602
|
+
**Hanging up** happens on `api.end()` and again on `active={false}` — both post
|
|
603
|
+
the same idempotent stop frame, so a host that does one, the other, or both
|
|
604
|
+
ends exactly one call. Before 0.27.6 the deactivate path was conditional on the
|
|
605
|
+
launcher's own idea of whether a call was running, which could close the UI and
|
|
606
|
+
leave the call up.
|
|
607
|
+
|
|
561
608
|
## Tap capture when your screens sit above the provider
|
|
562
609
|
|
|
563
610
|
`GraineProvider` wraps its children in `GraineTouchCapture`, so an app whose
|
|
@@ -1077,7 +1124,7 @@ told to ignore).
|
|
|
1077
1124
|
| `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
|
|
1078
1125
|
| `onError` | `(message) => void` | Voice could not be set up at all — key rejected, agent not enabled for apps, no network. Without this the launcher renders nothing and you cannot tell why. |
|
|
1079
1126
|
| `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
|
|
1080
|
-
| `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5) |
|
|
1127
|
+
| `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5; 0.27.6 makes that hangup unconditional) |
|
|
1081
1128
|
|
|
1082
1129
|
**The launcher owns the connection.** It asks for the microphone, resolves the
|
|
1083
1130
|
session, holds the socket and relays your screen, actions and events onto it. So
|
package/dist/friction.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface FrictionScreenRules {
|
|
|
20
20
|
repeatedErrors?: RepeatedErrorsRule | false;
|
|
21
21
|
backAndForth?: BackAndForthRule | false;
|
|
22
22
|
rageTaps?: RageTapsRule | false;
|
|
23
|
+
incompleteSubmit?: IncompleteSubmitRule | false;
|
|
23
24
|
screenError?: boolean;
|
|
24
25
|
message?: string;
|
|
25
26
|
}
|
|
@@ -34,10 +35,13 @@ export interface FrictionConfig extends FrictionScreenRules {
|
|
|
34
35
|
nudge?: FrictionNudge;
|
|
35
36
|
screens?: Record<string, FrictionScreenRules>;
|
|
36
37
|
}
|
|
38
|
+
export interface IncompleteSubmitRule {
|
|
39
|
+
taps: string[];
|
|
40
|
+
}
|
|
37
41
|
export declare const DEFAULT_FRICTION: Readonly<Required<Omit<FrictionConfig, "message" | "ignore">>>;
|
|
38
42
|
export declare function resolveFriction(...layers: Array<FrictionConfig | null | undefined>): Required<FrictionConfig>;
|
|
39
43
|
export declare function rulesFor(config: Required<FrictionConfig>, screenId: string | null | undefined): Required<FrictionScreenRules>;
|
|
40
|
-
export type FrictionKind = "stalled_on_step" | "repeated_field_error" | "looping_between_screens" | "rage_taps" | "screen_error";
|
|
44
|
+
export type FrictionKind = "stalled_on_step" | "repeated_field_error" | "looping_between_screens" | "rage_taps" | "incomplete_submit" | "screen_error";
|
|
41
45
|
export interface FrictionSignal {
|
|
42
46
|
name: FrictionKind;
|
|
43
47
|
screen: string;
|
package/dist/friction.js
CHANGED
|
@@ -3,6 +3,9 @@ const DEFAULT_RULES = {
|
|
|
3
3
|
repeatedErrors: { count: 2, windowMs: 60000 },
|
|
4
4
|
backAndForth: { visits: 3, windowMs: 45000 },
|
|
5
5
|
rageTaps: { taps: 4, windowMs: 1500 },
|
|
6
|
+
incompleteSubmit: {
|
|
7
|
+
taps: ["submit", "next", "continue", "save", "proceed", "apply", "confirm", "done", "pay", "verify"],
|
|
8
|
+
},
|
|
6
9
|
};
|
|
7
10
|
export const DEFAULT_FRICTION = Object.freeze({
|
|
8
11
|
enabled: true,
|
|
@@ -25,8 +28,19 @@ function rule(raw, shape) {
|
|
|
25
28
|
return undefined;
|
|
26
29
|
const out = {};
|
|
27
30
|
for (const [k, fallback] of Object.entries(shape)) {
|
|
28
|
-
const
|
|
29
|
-
|
|
31
|
+
const given = raw[k];
|
|
32
|
+
if (typeof fallback === "number") {
|
|
33
|
+
out[k] = num(given) ?? fallback;
|
|
34
|
+
}
|
|
35
|
+
else if (Array.isArray(fallback)) {
|
|
36
|
+
out[k] = Array.isArray(given) && given.length ? given.map((v) => String(v)) : fallback;
|
|
37
|
+
}
|
|
38
|
+
else if (typeof fallback === "boolean") {
|
|
39
|
+
out[k] = typeof given === "boolean" ? given : fallback;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
out[k] = given ?? fallback;
|
|
43
|
+
}
|
|
30
44
|
}
|
|
31
45
|
return out;
|
|
32
46
|
}
|
|
@@ -49,6 +63,9 @@ function screenRules(raw, base) {
|
|
|
49
63
|
const rage = rule(r.rageTaps, base.rageTaps || DEFAULT_RULES.rageTaps);
|
|
50
64
|
if (rage !== undefined)
|
|
51
65
|
out.rageTaps = rage;
|
|
66
|
+
const inc = rule(r.incompleteSubmit, base.incompleteSubmit || DEFAULT_RULES.incompleteSubmit);
|
|
67
|
+
if (inc !== undefined)
|
|
68
|
+
out.incompleteSubmit = inc;
|
|
52
69
|
if (typeof r.screenError === "boolean")
|
|
53
70
|
out.screenError = r.screenError;
|
|
54
71
|
if (typeof r.message === "string" && r.message.trim())
|
|
@@ -100,6 +117,7 @@ export function rulesFor(config, screenId) {
|
|
|
100
117
|
ignore: over.ignore ?? false,
|
|
101
118
|
idle: over.idle ?? config.idle,
|
|
102
119
|
repeatedErrors: over.repeatedErrors ?? config.repeatedErrors,
|
|
120
|
+
incompleteSubmit: over.incompleteSubmit ?? config.incompleteSubmit,
|
|
103
121
|
backAndForth: over.backAndForth ?? config.backAndForth,
|
|
104
122
|
rageTaps: over.rageTaps ?? config.rageTaps,
|
|
105
123
|
screenError: over.screenError ?? config.screenError,
|
|
@@ -109,6 +127,7 @@ export function rulesFor(config, screenId) {
|
|
|
109
127
|
function fill(template, vars) {
|
|
110
128
|
return template.replace(/\{(\w+)\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? "" : String(vars[k])));
|
|
111
129
|
}
|
|
130
|
+
const outstanding = (fields) => (fields ?? []).filter((f) => f.error || (!f.optional && (f.status === "invalid" || f.status === "empty")));
|
|
112
131
|
const stuckField = (fields) => (fields ?? []).find((f) => f.status === "invalid" || f.error) ?? (fields ?? []).find((f) => f.status === "empty");
|
|
113
132
|
export class FrictionDetector {
|
|
114
133
|
constructor(config) {
|
|
@@ -213,10 +232,26 @@ export class FrictionDetector {
|
|
|
213
232
|
}
|
|
214
233
|
onTrack(name, now) {
|
|
215
234
|
const rules = this.active();
|
|
216
|
-
if (!rules || !
|
|
235
|
+
if (!rules || !this.screen)
|
|
217
236
|
return [];
|
|
218
237
|
if (!name.startsWith("tap:"))
|
|
219
238
|
return [];
|
|
239
|
+
if (!rules.rageTaps && !rules.incompleteSubmit)
|
|
240
|
+
return [];
|
|
241
|
+
const label0 = name.slice(4);
|
|
242
|
+
if (rules.incompleteSubmit) {
|
|
243
|
+
const lower = label0.toLowerCase();
|
|
244
|
+
const isSubmit = rules.incompleteSubmit.taps.some((t) => lower.includes(t.toLowerCase()));
|
|
245
|
+
const left = outstanding(this.screen.fields);
|
|
246
|
+
if (isSubmit && left.length) {
|
|
247
|
+
const names = left.map((f) => f.label || f.name).filter(Boolean);
|
|
248
|
+
const s = this.signal("incomplete_submit", { tap: label0, count: left.length, fields: names.join(", "), first: names[0] ?? "" }, "The customer pressed \"{tap}\" on the \"{title}\" screen with {count} still to fill: {fields}. Tell them what is missing before they hit the error.", now);
|
|
249
|
+
if (s)
|
|
250
|
+
return [s];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!rules.rageTaps)
|
|
254
|
+
return [];
|
|
220
255
|
const { taps, windowMs } = rules.rageTaps;
|
|
221
256
|
const key = `${this.screen.screen}:${name}`;
|
|
222
257
|
const times = (this.taps.get(key) ?? []).filter((t) => now - t <= windowMs);
|
|
@@ -225,7 +260,7 @@ export class FrictionDetector {
|
|
|
225
260
|
if (times.length < taps)
|
|
226
261
|
return [];
|
|
227
262
|
this.taps.set(key, []);
|
|
228
|
-
const label =
|
|
263
|
+
const label = label0;
|
|
229
264
|
const s = this.signal("rage_taps", { tap: label, count: times.length, seconds: (windowMs / 1000).toFixed(1) }, label === "screen"
|
|
230
265
|
? `The customer tapped the "{title}" screen {count} times in {seconds}s — something on it may not be responding or not doing what they expect.`
|
|
231
266
|
: `The customer tapped "{tap}" {count} times in {seconds}s on the "{title}" screen — it may not be responding or not doing what they expect.`, now);
|
package/dist/index.d.ts
CHANGED
|
@@ -7,9 +7,9 @@ export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
|
7
7
|
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
|
|
8
8
|
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
9
9
|
export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
|
|
10
|
-
export { UNNAMED_TAP } from "./protocol.js";
|
|
10
|
+
export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
|
|
11
11
|
export { resolveNativeAppearance, type NativeAppearance, type NativeAppearanceFallback, } from "./react-native/appearance.js";
|
|
12
12
|
export { RtcVoiceSessionController, RtcVoiceError, type RtcVoiceState, type RtcVoiceErrorCode, type RtcVoiceSession, type RtcVoiceOptions, } from "./react-native/voice-rtc.js";
|
|
13
13
|
export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui.js";
|
|
14
|
-
export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
|
|
14
|
+
export { GraineVoiceLauncher, CAPTION_HISTORY, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher.js";
|
|
15
15
|
export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation.js";
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,9 @@ export { addMaskRule, maskDeep, maskString } from "./mask.js";
|
|
|
6
6
|
export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
|
|
7
7
|
export { requestMicrophonePermission } from "./react-native/permissions.js";
|
|
8
8
|
export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
|
|
9
|
-
export { UNNAMED_TAP } from "./protocol.js";
|
|
9
|
+
export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
|
|
10
10
|
export { resolveNativeAppearance, } from "./react-native/appearance.js";
|
|
11
11
|
export { RtcVoiceSessionController, RtcVoiceError, } from "./react-native/voice-rtc.js";
|
|
12
12
|
export { GraineAgentBar, GraineLauncher, } from "./react-native/ui.js";
|
|
13
|
-
export { GraineVoiceLauncher, } from "./react-native/voice-launcher.js";
|
|
13
|
+
export { GraineVoiceLauncher, CAPTION_HISTORY, } from "./react-native/voice-launcher.js";
|
|
14
14
|
export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation.js";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface ScreenField {
|
|
|
4
4
|
value?: string | number | null;
|
|
5
5
|
status?: "empty" | "filled" | "invalid" | "pending" | "locked";
|
|
6
6
|
error?: string;
|
|
7
|
+
optional?: boolean;
|
|
7
8
|
}
|
|
8
9
|
export interface ScreenContext {
|
|
9
10
|
screen: string;
|
|
@@ -12,6 +13,7 @@ export interface ScreenContext {
|
|
|
12
13
|
name: string;
|
|
13
14
|
step?: number;
|
|
14
15
|
of?: number;
|
|
16
|
+
next?: string;
|
|
15
17
|
};
|
|
16
18
|
fields?: ScreenField[];
|
|
17
19
|
error?: string;
|
|
@@ -59,3 +61,4 @@ export type ActionHandler = (args: Record<string, any>) => AppActionResult | voi
|
|
|
59
61
|
export declare const ACTION_DEADLINE_MS = 6000;
|
|
60
62
|
export declare const CONTEXT_THROTTLE_MS = 400;
|
|
61
63
|
export declare const UNNAMED_TAP = "tap:screen";
|
|
64
|
+
export declare function resolveWidgetImages(data: Record<string, unknown>, baseUrl: string): Record<string, unknown>;
|
package/dist/protocol.js
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
1
|
export const ACTION_DEADLINE_MS = 6000;
|
|
2
2
|
export const CONTEXT_THROTTLE_MS = 400;
|
|
3
3
|
export const UNNAMED_TAP = "tap:screen";
|
|
4
|
+
const IMAGE_KEY = /^(image|imageurl|image_url|icon|iconurl|icon_url|logo|logourl|logo_url|photo|photourl|photo_url|thumbnail|thumbnailurl|thumbnail_url)$/i;
|
|
5
|
+
function resolveOne(value, base) {
|
|
6
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//"))
|
|
7
|
+
return value;
|
|
8
|
+
return `${base.replace(/\/+$/, "")}${value}`;
|
|
9
|
+
}
|
|
10
|
+
export function resolveWidgetImages(data, baseUrl) {
|
|
11
|
+
if (!data || typeof data !== "object" || !baseUrl)
|
|
12
|
+
return data;
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [k, v] of Object.entries(data)) {
|
|
15
|
+
if (IMAGE_KEY.test(k)) {
|
|
16
|
+
out[k] = resolveOne(v, baseUrl);
|
|
17
|
+
}
|
|
18
|
+
else if (Array.isArray(v)) {
|
|
19
|
+
out[k] = v.map((row) => row && typeof row === "object" && !Array.isArray(row)
|
|
20
|
+
? Object.fromEntries(Object.entries(row).map(([rk, rv]) => [
|
|
21
|
+
rk,
|
|
22
|
+
IMAGE_KEY.test(rk) ? resolveOne(rv, baseUrl) : rv,
|
|
23
|
+
]))
|
|
24
|
+
: row);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
out[k] = v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|
|
3
3
|
import { AppState } from "react-native";
|
|
4
4
|
import { GraineInAppClient } from "../client.js";
|
|
5
5
|
import { VoiceSession } from "../voice.js";
|
|
6
|
+
import { resolveWidgetImages } from "../protocol.js";
|
|
6
7
|
import { GraineFab, GraineTouchCapture } from "./fab.js";
|
|
7
8
|
export { GraineFab, GraineTouchCapture } from "./fab.js";
|
|
8
9
|
import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
|
|
@@ -395,7 +396,7 @@ export function useGraineWidget() {
|
|
|
395
396
|
setWidget({
|
|
396
397
|
id: String(w.widget_id),
|
|
397
398
|
kind: String(w.kind || ""),
|
|
398
|
-
data: (w.data || {}),
|
|
399
|
+
data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
|
|
399
400
|
fallbackText: String(w.fallback_text || ""),
|
|
400
401
|
});
|
|
401
402
|
}), [client]);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { type Caption } from "./index.js";
|
|
2
3
|
export interface GraineVoiceLauncherProps {
|
|
3
4
|
transport?: "ws" | "rtc";
|
|
4
5
|
onTransport?: (info: {
|
|
@@ -32,5 +33,11 @@ export interface GraineVoiceApi {
|
|
|
32
33
|
start: () => void;
|
|
33
34
|
end: () => void;
|
|
34
35
|
setMuted: (m: boolean) => void;
|
|
36
|
+
captions: Caption[];
|
|
37
|
+
caption: Caption | null;
|
|
38
|
+
captionsOn: boolean;
|
|
39
|
+
setCaptionsOn: (on: boolean) => void;
|
|
40
|
+
clearCaptions: () => void;
|
|
35
41
|
}
|
|
42
|
+
export declare const CAPTION_HISTORY = 12;
|
|
36
43
|
export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
|
|
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { AppState, View } from "react-native";
|
|
4
4
|
import { useGraineAgent } from "./index.js";
|
|
5
5
|
import { requestMicrophonePermission } from "./permissions.js";
|
|
6
|
+
export const CAPTION_HISTORY = 12;
|
|
6
7
|
export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
|
|
7
8
|
const { client, appearance } = useGraineAgent();
|
|
8
9
|
const ref = useRef(null);
|
|
@@ -14,6 +15,22 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
14
15
|
const [connecting, setConnecting] = useState(false);
|
|
15
16
|
const [muted, setMutedState] = useState(false);
|
|
16
17
|
const [ready, setReady] = useState(false);
|
|
18
|
+
const [captions, setCaptions] = useState([]);
|
|
19
|
+
const [captionsOn, setCaptionsOn] = useState(true);
|
|
20
|
+
const foldCaption = useCallback((next) => {
|
|
21
|
+
setCaptions((prev) => {
|
|
22
|
+
const tail = prev[prev.length - 1];
|
|
23
|
+
if (!next.text) {
|
|
24
|
+
if (tail && tail.live && tail.role === next.role) {
|
|
25
|
+
return [...prev.slice(0, -1), { ...tail, live: false }];
|
|
26
|
+
}
|
|
27
|
+
return prev;
|
|
28
|
+
}
|
|
29
|
+
const continues = tail && tail.live && tail.role === next.role;
|
|
30
|
+
const merged = continues ? [...prev.slice(0, -1), next] : [...prev, next];
|
|
31
|
+
return merged.length > CAPTION_HISTORY ? merged.slice(-CAPTION_HISTORY) : merged;
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
17
34
|
const post = useCallback((msg) => {
|
|
18
35
|
ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
|
|
19
36
|
}, []);
|
|
@@ -132,9 +149,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
132
149
|
case "graine:muted":
|
|
133
150
|
setMutedState(!!msg.muted);
|
|
134
151
|
return;
|
|
135
|
-
case "graine:caption":
|
|
136
|
-
|
|
152
|
+
case "graine:caption": {
|
|
153
|
+
const line = {
|
|
154
|
+
role: msg.role === "agent" ? "agent" : "customer",
|
|
155
|
+
text: String(msg.text ?? ""),
|
|
156
|
+
live: !!msg.live,
|
|
157
|
+
};
|
|
158
|
+
foldCaption(line);
|
|
159
|
+
onCaption?.(line);
|
|
137
160
|
return;
|
|
161
|
+
}
|
|
138
162
|
case "graine:ended":
|
|
139
163
|
setConnected(false);
|
|
140
164
|
setConnecting(false);
|
|
@@ -161,15 +185,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
161
185
|
return;
|
|
162
186
|
}
|
|
163
187
|
}
|
|
164
|
-
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext]);
|
|
188
|
+
}, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
|
|
165
189
|
const pendingStartRef = useRef(false);
|
|
166
190
|
const api = {
|
|
167
191
|
connected,
|
|
168
192
|
connecting,
|
|
169
193
|
muted,
|
|
170
194
|
ready: !!voiceAgentId && ready,
|
|
195
|
+
captions,
|
|
196
|
+
caption: captions.length ? captions[captions.length - 1] : null,
|
|
197
|
+
captionsOn,
|
|
198
|
+
setCaptionsOn,
|
|
199
|
+
clearCaptions: useCallback(() => setCaptions([]), []),
|
|
171
200
|
start: useCallback(() => {
|
|
172
201
|
setConnecting(true);
|
|
202
|
+
setCaptions([]);
|
|
173
203
|
if (!ready) {
|
|
174
204
|
pendingStartRef.current = true;
|
|
175
205
|
return;
|
|
@@ -191,6 +221,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
191
221
|
if (autoStart) {
|
|
192
222
|
if (startedRef.current)
|
|
193
223
|
return;
|
|
224
|
+
setConnecting(true);
|
|
225
|
+
setCaptions([]);
|
|
194
226
|
if (ready) {
|
|
195
227
|
startedRef.current = true;
|
|
196
228
|
void beginCall();
|
|
@@ -205,10 +237,13 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
|
|
|
205
237
|
}
|
|
206
238
|
if (first)
|
|
207
239
|
return;
|
|
208
|
-
|
|
209
|
-
post({ type: "graine:end-call" });
|
|
240
|
+
post({ type: "graine:end-call" });
|
|
210
241
|
startedRef.current = false;
|
|
211
242
|
pendingStartRef.current = false;
|
|
243
|
+
setConnected(false);
|
|
244
|
+
setConnecting(false);
|
|
245
|
+
setCaptions([]);
|
|
246
|
+
onCallState?.({ connected: false, connecting: false });
|
|
212
247
|
}, [active]);
|
|
213
248
|
useEffect(() => {
|
|
214
249
|
let last = AppState.currentState;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graineai/inapp-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Graine in-app agent for React Native
|
|
5
|
+
"description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
8
8
|
"main": "dist/index.js",
|