@qafka/react-native 2.6.0 → 2.7.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.7.0] — 2026-09-16
11
+
12
+ ### Added
13
+
14
+ - Voice session ended by the server: when the backend ends a voice session because voice time is no longer available for the project, the SDK now closes voice cleanly, returns the user to the text chat and shows a neutral, localized message ("Voice chat is currently unavailable. You can keep chatting by text." / Turkish when `locale` starts with `tr`). Text chat keeps working. This applies both when voice is opened and during an ongoing call.
15
+
16
+ ### Changed
17
+
18
+ - Voice sessions closed by the server mid-call are now distinguished from network drops and user-initiated disconnects, so an unexpected close no longer leaves the voice screen in a connecting or listening state. No public API changes.
19
+
10
20
  ## [2.6.0] — 2026-09-15
11
21
 
12
22
  ### Added
@@ -135,6 +135,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
135
135
  apiKey: resolvedApiKey || '',
136
136
  userContext: currentContext,
137
137
  contextDescription,
138
+ locale,
138
139
  onToolSuggested,
139
140
  toolRenderMode,
140
141
  // Forward the device-attestation session token so the realtime WS
@@ -148,7 +149,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
148
149
  endUserData: validatedEndUserData,
149
150
  onNavigationSuggest,
150
151
  });
151
- const { toolStatus: voiceToolStatus, renderedTools: voiceRenderedTools, transcriptOverrideForTurn, setToolStatusManually, clearRenderedTools, connect: voiceConnect, disconnect: voiceDisconnect, pauseMic: voicePauseMic, resumeMic: voiceResumeMic, isMuted: voiceIsMuted, mute: voiceMute, unmute: voiceUnmute, toggleMute: voiceToggleMute, navigationSuggestion: voiceNavigationSuggestion, acceptNavigationSuggestion: voiceAcceptNavigation, dismissNavigationSuggestion: voiceDismissNavigation, } = voiceChat;
152
+ const { toolStatus: voiceToolStatus, renderedTools: voiceRenderedTools, transcriptOverrideForTurn, setToolStatusManually, clearRenderedTools, connect: voiceConnect, disconnect: voiceDisconnect, pauseMic: voicePauseMic, resumeMic: voiceResumeMic, isMuted: voiceIsMuted, mute: voiceMute, unmute: voiceUnmute, toggleMute: voiceToggleMute, navigationSuggestion: voiceNavigationSuggestion, acceptNavigationSuggestion: voiceAcceptNavigation, dismissNavigationSuggestion: voiceDismissNavigation, voiceCloseReason, voiceCloseMessage, } = voiceChat;
152
153
  const { messages, isTyping, isSending, streamingMessage, toolStatus, flatListRef, handleSend, setMessages, setIsTyping, setStreamingMessage, } = (0, useChatMessages_1.useChatMessages)({
153
154
  enableStreaming,
154
155
  context: currentContext,
@@ -398,6 +399,34 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
398
399
  voiceChat.pauseMic();
399
400
  }
400
401
  }, [voiceChat]);
402
+ // The server can end a voice session for a reason the user should be told
403
+ // about (currently only when it can no longer continue). useVoiceChat
404
+ // already tears the session down; here we mirror the user's own "swipe
405
+ // back to chat" gesture and drop its message into the transcript the same
406
+ // way a failed send does (see useChatMessages' fallback error bubble) —
407
+ // reusing the existing chat-bubble UI instead of adding a new surface.
408
+ const handledVoiceCloseRef = (0, react_1.useRef)(false);
409
+ (0, react_1.useEffect)(() => {
410
+ if (voiceCloseReason === 'minutes_exhausted') {
411
+ if (handledVoiceCloseRef.current)
412
+ return;
413
+ handledVoiceCloseRef.current = true;
414
+ scrollViewRef.current?.scrollTo({ x: 0, animated: true });
415
+ setMessages((prev) => [
416
+ ...prev,
417
+ {
418
+ id: `voice_closed_${Date.now()}`,
419
+ role: 'assistant',
420
+ text: voiceCloseMessage ?? '',
421
+ content: voiceCloseMessage ?? '',
422
+ timestamp: new Date(),
423
+ },
424
+ ]);
425
+ }
426
+ else {
427
+ handledVoiceCloseRef.current = false;
428
+ }
429
+ }, [voiceCloseReason, voiceCloseMessage, setMessages]);
401
430
  // Card CTA host callbacks bundle. Stable reference via useMemo
402
431
  // so MessageBubble's CardRuntimeProvider doesn't re-mount on every render.
403
432
  // MUST be declared before the conditional returns below — Rules of Hooks.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Classifies why a voice session's WebSocket closed, from the close code
3
+ * and/or reason string the server sends, into an outcome the caller can act
4
+ * on without needing to know the raw close-code values itself. Kept as a
5
+ * pure function so the mapping is unit-testable without loading the
6
+ * native-bound RealtimeService.
7
+ */
8
+ export type VoiceCloseReason = 'minutes_exhausted' | 'not_allowed' | 'project_unavailable' | 'other';
9
+ export declare function classifyVoiceClose(event: {
10
+ code?: number;
11
+ reason?: string;
12
+ } | null | undefined): VoiceCloseReason;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyVoiceClose = classifyVoiceClose;
4
+ function classifyVoiceClose(event) {
5
+ const code = event?.code;
6
+ const reason = event?.reason;
7
+ if (code === 4011 || reason === 'VOICE_MINUTES_EXHAUSTED') {
8
+ return 'minutes_exhausted';
9
+ }
10
+ if (code === 4003 || reason === 'VOICE_NOT_ALLOWED') {
11
+ return 'not_allowed';
12
+ }
13
+ if (code === 4010 || reason === 'project_unavailable') {
14
+ return 'project_unavailable';
15
+ }
16
+ return 'other';
17
+ }
@@ -64,6 +64,11 @@ interface UseVoiceChatOptions {
64
64
  apiKey: string;
65
65
  userContext?: Record<string, any>;
66
66
  contextDescription?: string;
67
+ /**
68
+ * BCP 47 locale, forwarded to the neutral message shown when the server
69
+ * ends the voice session (e.g. `voiceCloseMessage` after minutes run out).
70
+ */
71
+ locale?: string;
67
72
  onToolSuggested?: VoiceOnToolSuggested;
68
73
  toolRenderMode?: VoiceToolRenderMode;
69
74
  /**
@@ -119,5 +124,14 @@ interface UseVoiceChatResult {
119
124
  acceptNavigationSuggestion: () => void;
120
125
  /** Dismiss the pending suggestion without navigating. */
121
126
  dismissNavigationSuggestion: () => void;
127
+ /**
128
+ * Set when the server closed the voice session for a reason the user
129
+ * should be told about (currently only `'minutes_exhausted'`). Cleared on
130
+ * the next `connect()`. `null` in every other case, including a normal
131
+ * user-initiated `disconnect()`.
132
+ */
133
+ voiceCloseReason: 'minutes_exhausted' | null;
134
+ /** Locale-aware copy to show alongside `voiceCloseReason`, or `null`. */
135
+ voiceCloseMessage: string | null;
122
136
  }
123
- export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
137
+ export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
@@ -6,8 +6,26 @@ const react_native_1 = require("react-native");
6
6
  const RealtimeService_1 = require("../services/RealtimeService");
7
7
  const QafkaAudio_1 = require("../native/QafkaAudio");
8
8
  const decide_voice_navigation_1 = require("../decide-voice-navigation");
9
- function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, onNavigationSuggest, }) {
9
+ const decide_voice_close_1 = require("../decide-voice-close");
10
+ /**
11
+ * Neutral, locale-aware copy shown when the server ends a voice session
12
+ * because it can no longer be continued. Mirrors `unavailableMessage()` in
13
+ * `services/projectUnavailable.ts` — deliberately generic, no mention of
14
+ * limits or plans.
15
+ */
16
+ function voiceUnavailableMessage(locale) {
17
+ return locale?.toLowerCase().startsWith('tr')
18
+ ? 'Sesli görüşme şu anda kullanılamıyor. Yazarak sohbete devam edebilirsiniz.'
19
+ : 'Voice chat is currently unavailable. You can keep chatting by text.';
20
+ }
21
+ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, onNavigationSuggest, }) {
10
22
  const [state, setState] = (0, react_1.useState)('idle');
23
+ const [voiceCloseReason, setVoiceCloseReason] = (0, react_1.useState)(null);
24
+ const [voiceCloseMessage, setVoiceCloseMessage] = (0, react_1.useState)(null);
25
+ const localeRef = (0, react_1.useRef)(locale);
26
+ (0, react_1.useEffect)(() => {
27
+ localeRef.current = locale;
28
+ }, [locale]);
11
29
  const [transcript, setTranscript] = (0, react_1.useState)('');
12
30
  const [userTranscript, setUserTranscript] = (0, react_1.useState)('');
13
31
  const [transcriptHistory, setTranscriptHistory] = (0, react_1.useState)([]);
@@ -147,11 +165,25 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
147
165
  setState('listening');
148
166
  return;
149
167
  }
150
- case 'session.closed':
168
+ case 'session.closed': {
169
+ const closeReason = (0, decide_voice_close_1.classifyVoiceClose)({ code: event.code, reason: event.reason });
170
+ if (closeReason === 'minutes_exhausted') {
171
+ // Surface the neutral message and fall back to text chat. The server
172
+ // has already closed the socket, so this only tears down local state
173
+ // (native audio engine, refs, mute flags) via the same path a
174
+ // user-initiated disconnect takes.
175
+ setVoiceCloseReason('minutes_exhausted');
176
+ setVoiceCloseMessage(voiceUnavailableMessage(localeRef.current));
177
+ disconnectRef.current?.();
178
+ return;
179
+ }
180
+ setVoiceCloseReason(null);
181
+ setVoiceCloseMessage(null);
151
182
  setState('idle');
152
183
  setNavigationSuggestion(null);
153
184
  pendingNavDisconnectRef.current = false;
154
185
  return;
186
+ }
155
187
  case 'error':
156
188
  setState('idle');
157
189
  return;
@@ -392,6 +424,8 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
392
424
  return;
393
425
  }
394
426
  setState('connecting');
427
+ setVoiceCloseReason(null);
428
+ setVoiceCloseMessage(null);
395
429
  const service = new RealtimeService_1.RealtimeService(apiUrl, apiKey);
396
430
  if (getSessionToken)
397
431
  service.setSessionTokenGetter(getSessionToken);
@@ -523,5 +557,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
523
557
  navigationSuggestion,
524
558
  acceptNavigationSuggestion,
525
559
  dismissNavigationSuggestion,
560
+ voiceCloseReason,
561
+ voiceCloseMessage,
526
562
  };
527
563
  }
@@ -3,6 +3,7 @@ export type RealtimeEvent = {
3
3
  } | {
4
4
  type: 'session.closed';
5
5
  reason: string;
6
+ code?: number;
6
7
  } | {
7
8
  type: 'response.audio.delta';
8
9
  delta: string;
@@ -145,6 +145,7 @@ class RealtimeService {
145
145
  this.eventHandler?.({
146
146
  type: 'session.closed',
147
147
  reason: event.reason || 'CONNECTION_CLOSED',
148
+ code: event.code,
148
149
  });
149
150
  };
150
151
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Drop-in AI assistant for React Native: chat, voice, and tool execution with screen-aware navigation.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",