@graineai/inapp-react-native 0.27.5 → 0.28.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 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/index.d.ts CHANGED
@@ -11,5 +11,5 @@ export { UNNAMED_TAP } 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
@@ -10,5 +10,5 @@ export { UNNAMED_TAP } 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";
@@ -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,16 @@ 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
+ const continues = tail && tail.live && tail.role === next.role;
24
+ const merged = continues ? [...prev.slice(0, -1), next] : [...prev, next];
25
+ return merged.length > CAPTION_HISTORY ? merged.slice(-CAPTION_HISTORY) : merged;
26
+ });
27
+ }, []);
17
28
  const post = useCallback((msg) => {
18
29
  ref.current?.injectJavaScript(`window.postMessage(${JSON.stringify(msg)}, "*"); true;`);
19
30
  }, []);
@@ -132,9 +143,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
132
143
  case "graine:muted":
133
144
  setMutedState(!!msg.muted);
134
145
  return;
135
- case "graine:caption":
136
- onCaption?.({ role: msg.role, text: msg.text, live: !!msg.live });
146
+ case "graine:caption": {
147
+ const line = {
148
+ role: msg.role === "agent" ? "agent" : "customer",
149
+ text: String(msg.text ?? ""),
150
+ live: !!msg.live,
151
+ };
152
+ foldCaption(line);
153
+ onCaption?.(line);
137
154
  return;
155
+ }
138
156
  case "graine:ended":
139
157
  setConnected(false);
140
158
  setConnecting(false);
@@ -161,15 +179,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
161
179
  return;
162
180
  }
163
181
  }
164
- }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext]);
182
+ }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
165
183
  const pendingStartRef = useRef(false);
166
184
  const api = {
167
185
  connected,
168
186
  connecting,
169
187
  muted,
170
188
  ready: !!voiceAgentId && ready,
189
+ captions,
190
+ caption: captions.length ? captions[captions.length - 1] : null,
191
+ captionsOn,
192
+ setCaptionsOn,
193
+ clearCaptions: useCallback(() => setCaptions([]), []),
171
194
  start: useCallback(() => {
172
195
  setConnecting(true);
196
+ setCaptions([]);
173
197
  if (!ready) {
174
198
  pendingStartRef.current = true;
175
199
  return;
@@ -191,6 +215,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
191
215
  if (autoStart) {
192
216
  if (startedRef.current)
193
217
  return;
218
+ setConnecting(true);
219
+ setCaptions([]);
194
220
  if (ready) {
195
221
  startedRef.current = true;
196
222
  void beginCall();
@@ -205,10 +231,13 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
205
231
  }
206
232
  if (first)
207
233
  return;
208
- if (startedRef.current || pendingStartRef.current)
209
- post({ type: "graine:end-call" });
234
+ post({ type: "graine:end-call" });
210
235
  startedRef.current = false;
211
236
  pendingStartRef.current = false;
237
+ setConnected(false);
238
+ setConnecting(false);
239
+ setCaptions([]);
240
+ onCallState?.({ connected: false, connecting: false });
212
241
  }, [active]);
213
242
  useEffect(() => {
214
243
  let last = AppState.currentState;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.27.5",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",