@qafka/react-native 2.7.0 → 2.8.1

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,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.8.1] — 2026-10-06
11
+
12
+ ### Added
13
+
14
+ - Close button on the voice page, in its top left corner. It only leaves voice: the voice session is ended and the widget returns to the chat page, so users no longer have to swipe back. The chat stays open and `onClose` is not called — closing the assistant is still done from the chat page.
15
+ - New optional `onVoiceClose` prop on `<Qafka />`, called when that button is pressed.
16
+ - New optional `voiceComponents.VoiceCloseButton` slot (`VoiceCloseButtonProps`) to replace the default button.
17
+
18
+ ## [2.8.0] — 2026-10-06
19
+
20
+ ### Added
21
+
22
+ - Unavailable state now also covers a project for which the mobile SDK is not enabled: the chat screen shows the same neutral, localized message as for a suspended project, `onUnavailable` is called and the status becomes `'unavailable'` (via `onStatusChange` and `QafkaSDK.getStatus()`). No error bubble is shown and `onError` is not called. A voice session rejected for the same reason ends the same way.
23
+ - New exported `FeatureNotAvailableError` (`code: 'FEATURE_NOT_AVAILABLE'`, with the `feature` name). SDK calls that fail because a feature is not enabled for the project reject with it, so you can tell this case apart from other failures. A feature other than the mobile SDK itself does not switch the SDK to the unavailable state — only that call fails.
24
+ - In development builds, the SDK logs a one-time console warning naming the feature that is not enabled. Production builds log nothing.
25
+
26
+ ### Fixed
27
+
28
+ - Voice could not be continued after returning to the voice page: when a voice session ended while the voice page stayed on screen (the assistant opened another screen and you came back, the app went to the background, or the connection dropped), the page stayed idle and only swiping to the text chat and back re-opened it. The voice page now shows "Tap to continue" (Turkish when `locale` starts with `tr`), and tapping the microphone button or the indicator re-opens the session. No public API changes.
29
+
10
30
  ## [2.7.0] — 2026-09-16
11
31
 
12
32
  ### Added
package/dist/QafkaSDK.js CHANGED
@@ -4,6 +4,7 @@ exports.getSDK = exports.QafkaSDK = void 0;
4
4
  const storage_1 = require("./services/storage");
5
5
  const BackendService_1 = require("./services/BackendService");
6
6
  const projectUnavailable_1 = require("./services/projectUnavailable");
7
+ const featureNotAvailable_1 = require("./services/featureNotAvailable");
7
8
  const AttestationManager_1 = require("./services/AttestationManager");
8
9
  const ConversationManager_1 = require("./services/ConversationManager");
9
10
  const NavigationHandler_1 = require("./services/NavigationHandler");
@@ -119,6 +120,21 @@ class QafkaSDK {
119
120
  }
120
121
  }
121
122
  catch (error) {
123
+ // Any plan-gated feature warns the integrator once in __DEV__, but only
124
+ // mobile_sdk — the feature that gates the whole SDK — flips status to
125
+ // the neutral 'unavailable' UX. A different gated feature (e.g.
126
+ // card_templates) falls through to the normal error/dev-bypass handling
127
+ // below instead of taking down the rest of the SDK.
128
+ if (error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
129
+ (0, featureNotAvailable_1.warnFeatureNotAvailable)(error.feature);
130
+ if ((0, featureNotAvailable_1.isMobileSdkDenied)(error)) {
131
+ this.status = 'unavailable';
132
+ if (config.onStatusChange) {
133
+ config.onStatusChange('unavailable');
134
+ }
135
+ return;
136
+ }
137
+ }
122
138
  // The unsupported-device fallback only applies on simulators/emulators.
123
139
  // On real devices, attestation errors must propagate — even in __DEV__ —
124
140
  // so that initialization cannot silently continue unverified.
@@ -132,16 +148,22 @@ class QafkaSDK {
132
148
  }
133
149
  }
134
150
  // Decide the final status after the theme prefetch settles — a
135
- // ProjectUnavailableError there means the project is suspended, which
136
- // takes priority over an otherwise-successful attestation. This never
137
- // rejects: the prefetch promise already swallows its own rejection
138
- // (see themePrefetchError above).
151
+ // ProjectUnavailableError / FeatureNotAvailableError there means the
152
+ // project is suspended or plan-gated, which takes priority over an
153
+ // otherwise-successful attestation. This never rejects: the prefetch
154
+ // promise already swallows its own rejection (see themePrefetchError
155
+ // above).
139
156
  if (this.themePrefetchPromise) {
140
157
  await this.themePrefetchPromise;
141
158
  }
142
- this.status = this.themePrefetchError instanceof projectUnavailable_1.ProjectUnavailableError
143
- ? 'unavailable'
144
- : 'ready';
159
+ if (this.themePrefetchError instanceof featureNotAvailable_1.FeatureNotAvailableError) {
160
+ (0, featureNotAvailable_1.warnFeatureNotAvailable)(this.themePrefetchError.feature);
161
+ }
162
+ this.status =
163
+ this.themePrefetchError instanceof projectUnavailable_1.ProjectUnavailableError ||
164
+ (0, featureNotAvailable_1.isMobileSdkDenied)(this.themePrefetchError)
165
+ ? 'unavailable'
166
+ : 'ready';
145
167
  if (config.onStatusChange) {
146
168
  config.onStatusChange(this.status);
147
169
  }
@@ -249,6 +271,17 @@ class QafkaSDK {
249
271
  return response;
250
272
  }
251
273
  catch (error) {
274
+ // Any plan-gated feature warns the integrator once in __DEV__, but
275
+ // only mobile_sdk flips the SDK to the neutral "unavailable" state a
276
+ // suspended project uses — a different gated feature still rejects
277
+ // this call with a typed FeatureNotAvailableError, but must not take
278
+ // down the rest of the SDK.
279
+ if (error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
280
+ (0, featureNotAvailable_1.warnFeatureNotAvailable)(error.feature);
281
+ if ((0, featureNotAvailable_1.isMobileSdkDenied)(error)) {
282
+ this.markUnavailable();
283
+ }
284
+ }
252
285
  throw error;
253
286
  }
254
287
  }
@@ -301,6 +334,14 @@ class QafkaSDK {
301
334
  }
302
335
  onComplete?.(response);
303
336
  }, (_error) => {
337
+ // Matches the sendMessage branch above; the streaming transport
338
+ // reports this via callback, not a rejection.
339
+ if (_error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
340
+ (0, featureNotAvailable_1.warnFeatureNotAvailable)(_error.feature);
341
+ if ((0, featureNotAvailable_1.isMobileSdkDenied)(_error)) {
342
+ this.markUnavailable();
343
+ }
344
+ }
304
345
  onError?.(_error);
305
346
  }, context, contextDescription, onToolSuggestions, // Pass tool suggestions callback
306
347
  isInitialMessage, onActionResult, // Pass action result callback
@@ -312,6 +353,12 @@ class QafkaSDK {
312
353
  onFinalChunk);
313
354
  }
314
355
  catch (error) {
356
+ if (error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
357
+ (0, featureNotAvailable_1.warnFeatureNotAvailable)(error.feature);
358
+ if ((0, featureNotAvailable_1.isMobileSdkDenied)(error)) {
359
+ this.markUnavailable();
360
+ }
361
+ }
315
362
  if (onError) {
316
363
  onError(error instanceof Error ? error : new Error('Unknown error'));
317
364
  }
@@ -87,7 +87,7 @@ const { width: SCREEN_WIDTH } = react_native_1.Dimensions.get('window');
87
87
  * />
88
88
  * ```
89
89
  */
90
- exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
90
+ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onVoiceClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
91
91
  // === ALL HOOKS FIRST (Rules of Hooks compliance) ===
92
92
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
93
93
  const { sdkReady, unavailable, error: sdkError, resolvedApiKey, resolvedApiUrl } = (0, useSDK_1.useSDK)({
@@ -149,7 +149,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
149
149
  endUserData: validatedEndUserData,
150
150
  onNavigationSuggest,
151
151
  });
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
+ 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, canResume: voiceCanResume, resume: voiceResume, resumeLabel: voiceResumeLabel, } = voiceChat;
153
153
  const { messages, isTyping, isSending, streamingMessage, toolStatus, flatListRef, handleSend, setMessages, setIsTyping, setStreamingMessage, } = (0, useChatMessages_1.useChatMessages)({
154
154
  enableStreaming,
155
155
  context: currentContext,
@@ -514,7 +514,14 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
514
514
 
515
515
  {/* Page 1: Voice — VoicePage paints Background edge-to-edge and
516
516
  handles its own internal safe-area padding for orb/transcript. */}
517
- <VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}/>
517
+ <VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} canResume={voiceCanResume} onResume={voiceResume} resumeLabel={voiceResumeLabel} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}
518
+ // The voice page's close button only leaves voice: end the session
519
+ // and slide back to the chat page, which stays open.
520
+ onClose={() => {
521
+ void voiceDisconnect();
522
+ scrollViewRef.current?.scrollTo({ x: 0, animated: true });
523
+ onVoiceClose?.();
524
+ }}/>
518
525
  </react_native_1.ScrollView>
519
526
  </react_native_1.View>) : (<ChatPage_1.ChatPage {...chatPageProps}/>);
520
527
  return (<react_native_1.View style={[
@@ -64,6 +64,14 @@ export interface VoiceMuteButtonProps {
64
64
  theme: Theme;
65
65
  state: VoiceChatState;
66
66
  }
67
+ /**
68
+ * Props passed to custom voice close button component
69
+ */
70
+ export interface VoiceCloseButtonProps {
71
+ onClose: () => void;
72
+ theme: Theme;
73
+ state: VoiceChatState;
74
+ }
67
75
  /**
68
76
  * Props passed to custom voice background component
69
77
  */
@@ -125,6 +133,11 @@ export interface VoiceComponents {
125
133
  * row in every voice layout. Defaults to a circular icon button.
126
134
  */
127
135
  VoiceMuteButton?: React.ComponentType<VoiceMuteButtonProps>;
136
+ /**
137
+ * Slot for the button that leaves the voice page and returns to the chat
138
+ * page, rendered in the top left corner. Defaults to a circular icon button.
139
+ */
140
+ VoiceCloseButton?: React.ComponentType<VoiceCloseButtonProps>;
128
141
  /**
129
142
  * Slot for the multi-chip list rendered when the AI calls the built-in
130
143
  * `qafka_display` tool with mode="chip".
@@ -689,6 +702,12 @@ export interface QafkaProps {
689
702
  */
690
703
  onClose?: () => void;
691
704
  /**
705
+ * Optional. Called when the close button on the voice page is pressed. That
706
+ * button only leaves voice: the voice session is ended and the widget
707
+ * returns to the chat page, which stays open — `onClose` is not called.
708
+ */
709
+ onVoiceClose?: () => void;
710
+ /**
692
711
  * Callback when back button is pressed
693
712
  * If provided, a back button will be shown in the top left corner
694
713
  */
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { QafkaProps } from './Qafka.types';
3
- type QafkaProviderProps = Pick<QafkaProps, 'style' | 'projectId' | 'apiUrl' | 'theme' | 'themeOverride' | 'customTheme' | 'enableStreaming' | 'isAuthenticated' | 'endUserId' | 'endUserData' | 'context' | 'contextDescription' | 'components' | 'showTimestamps' | 'placeholder' | 'maxMessageLength' | 'greetingMessage' | 'title' | 'showHeader' | 'onReady' | 'onMessageSent' | 'onResponseReceived' | 'onError' | 'onNavigationSuggest' | 'onNavigationAction' | 'onToolSuggested' | 'onActionResult' | 'onStepCompleted' | 'onClose' | 'onBack' | 'CloseComponent' | 'BackComponent' | 'navigationLabelFormat' | 'NavigationButtonComponent'> & {
3
+ type QafkaProviderProps = Pick<QafkaProps, 'style' | 'projectId' | 'apiUrl' | 'theme' | 'themeOverride' | 'customTheme' | 'enableStreaming' | 'isAuthenticated' | 'endUserId' | 'endUserData' | 'context' | 'contextDescription' | 'components' | 'showTimestamps' | 'placeholder' | 'maxMessageLength' | 'greetingMessage' | 'title' | 'showHeader' | 'onReady' | 'onMessageSent' | 'onResponseReceived' | 'onError' | 'onNavigationSuggest' | 'onNavigationAction' | 'onToolSuggested' | 'onActionResult' | 'onStepCompleted' | 'onClose' | 'onVoiceClose' | 'onBack' | 'CloseComponent' | 'BackComponent' | 'navigationLabelFormat' | 'NavigationButtonComponent'> & {
4
4
  mode?: 'floating' | 'fullscreen' | 'inline';
5
5
  position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
6
6
  };
@@ -44,10 +44,14 @@ interface VoicePageProps {
44
44
  }>;
45
45
  isMuted?: boolean;
46
46
  onToggleMute?: () => void;
47
+ canResume?: boolean;
48
+ onResume?: () => void;
49
+ resumeLabel?: string;
47
50
  navigationSuggestion?: NavigationSuggestion | null;
48
51
  onNavigationAccept?: () => void;
49
52
  onNavigationDismiss?: () => void;
50
53
  NavigationButtonComponent?: ComponentType<NavigationButtonProps>;
54
+ onClose?: () => void;
51
55
  }
52
- export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }: VoicePageProps): React.JSX.Element;
56
+ export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, canResume, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, onClose, }: VoicePageProps): React.JSX.Element;
53
57
  export {};
@@ -306,6 +306,19 @@ function DefaultVoiceMuteButton({ isMuted, onToggle, theme, }) {
306
306
  {isMuted ? <react_native_1.View style={[muteStyles.slash, { backgroundColor: fg }]}/> : null}
307
307
  </react_native_1.Pressable>);
308
308
  }
309
+ function DefaultVoiceCloseButton({ onClose, theme }) {
310
+ return (<react_native_1.Pressable onPress={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel="End voice chat" style={({ pressed }) => [
311
+ muteStyles.button,
312
+ {
313
+ backgroundColor: theme?.colors?.surface ?? 'rgba(0,0,0,0.06)',
314
+ opacity: pressed ? 0.75 : 1,
315
+ },
316
+ ]}>
317
+ <react_native_1.Text style={{ fontSize: 24, lineHeight: 26, color: theme?.colors?.text ?? '#111' }}>
318
+ {'×'}
319
+ </react_native_1.Text>
320
+ </react_native_1.Pressable>);
321
+ }
309
322
  /**
310
323
  * Minimal microphone glyph built with stock Views — capsule body, stand
311
324
  * post, and base bar. No external icon font, no SVG, no Skia.
@@ -408,7 +421,7 @@ function ChatTranscript({ history, liveAiText, state, primaryTextColor, secondar
408
421
  </react_native_1.View>);
409
422
  }
410
423
  // --- Main component ---
411
- function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }) {
424
+ function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, canResume = false, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, onClose, }) {
412
425
  const Indicator = voiceComponents?.VoiceIndicator || DefaultVoiceIndicator;
413
426
  const Background = voiceComponents?.VoiceBackground || DefaultVoiceBackground;
414
427
  const Transcript = voiceComponents?.VoiceTranscript || DefaultVoiceTranscript;
@@ -416,6 +429,19 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
416
429
  // Disable rendering the button entirely when there's nothing to drive it,
417
430
  // e.g. consumer drives the SDK without using useVoiceChat's mute layer.
418
431
  const showMute = typeof onToggleMute === 'function';
432
+ // The session ended while this page stayed on screen (see
433
+ // decide-voice-resume.ts): the mic button and the indicator re-open it
434
+ // instead of toggling mute.
435
+ const resumable = canResume && typeof onResume === 'function';
436
+ const onMicPress = resumable ? onResume : onToggleMute;
437
+ const resumeHint = resumable && resumeLabel ? (<react_native_1.Text style={{ marginTop: 12, fontSize: 14, color: STATE_COLORS.idle }}>
438
+ {resumeLabel}
439
+ </react_native_1.Text>) : null;
440
+ // Leaves voice and returns to the chat page, so that does not require
441
+ // swiping back. Top left: the mic button owns the top right corner.
442
+ const CloseButton = voiceComponents?.VoiceCloseButton || DefaultVoiceCloseButton;
443
+ const showClose = typeof onClose === 'function';
444
+ const closeButton = showClose ? (<CloseButton onClose={onClose} theme={theme} state={state}/>) : null;
419
445
  const hasToolUI = !transcriptOverrideForTurn && (renderedTools?.length ?? 0) > 0;
420
446
  // When tool UI is rendered, the compact status bar takes over regardless of
421
447
  // chosen mode — preserves the single-screen / hands-free experience.
@@ -455,6 +481,7 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
455
481
  borderBottomColor: 'rgba(0,0,0,0.08)',
456
482
  backgroundColor: theme?.colors?.background ?? '#fff',
457
483
  }}>
484
+ {closeButton}
458
485
  <react_native_1.View style={{
459
486
  width: 44,
460
487
  height: 44,
@@ -476,19 +503,19 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
476
503
  </react_native_1.View>
477
504
  </react_native_1.View>
478
505
 
479
- <react_native_1.View style={{ flex: 1 }}>
506
+ <react_native_1.Pressable style={{ flex: 1 }} disabled={!resumable} onPress={onResume}>
480
507
  <react_native_1.Text style={{
481
508
  fontSize: 13,
482
509
  fontWeight: react_native_1.Platform.select({ ios: '600', default: 'bold' }),
483
510
  color: STATE_COLORS[state],
484
511
  }} numberOfLines={1}>
485
- {STATE_LABELS[state]}
512
+ {resumable && resumeLabel ? resumeLabel : STATE_LABELS[state]}
486
513
  </react_native_1.Text>
487
514
  {toolStatus?.loadingMessage ? (<react_native_1.Text style={{ fontSize: 11, color: '#888', marginTop: 1 }} numberOfLines={1}>
488
515
  {toolStatus.loadingMessage}
489
516
  </react_native_1.Text>) : null}
490
- </react_native_1.View>
491
- {showMute ? (<MuteButton isMuted={isMuted} onToggle={onToggleMute} theme={theme} state={state}/>) : null}
517
+ </react_native_1.Pressable>
518
+ {showMute ? (<MuteButton isMuted={isMuted} onToggle={onMicPress} theme={theme} state={state}/>) : null}
492
519
  </react_native_1.View>) : isChatLayout ? (
493
520
  // Chat layout: orb anchored at the top, chat history fills the rest.
494
521
  // The orb moves up (vs. centered) to give the transcript room to breathe.
@@ -498,9 +525,10 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
498
525
  width: '100%',
499
526
  paddingTop: 32,
500
527
  }}>
501
- <react_native_1.View style={{ alignItems: 'center', justifyContent: 'center' }}>
528
+ <react_native_1.Pressable disabled={!resumable} onPress={onResume} accessibilityRole={resumable ? 'button' : undefined} accessibilityLabel={resumable ? resumeLabel : undefined} style={{ alignItems: 'center', justifyContent: 'center' }}>
502
529
  <Indicator state={state} amplitude={amplitude} theme={theme} isMuted={isMuted}/>
503
- </react_native_1.View>
530
+ </react_native_1.Pressable>
531
+ {resumeHint}
504
532
 
505
533
  {toolStatus ? (<react_native_1.View style={{ marginTop: 4, alignItems: 'center' }}>
506
534
  <ToolStatusPill_1.ToolStatusPill message={toolStatus.loadingMessage} theme={theme}/>
@@ -517,9 +545,10 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
517
545
  alignItems: 'center',
518
546
  justifyContent: 'center',
519
547
  }}>
520
- <react_native_1.View style={{ alignItems: 'center', justifyContent: 'center' }}>
548
+ <react_native_1.Pressable disabled={!resumable} onPress={onResume} accessibilityRole={resumable ? 'button' : undefined} accessibilityLabel={resumable ? resumeLabel : undefined} style={{ alignItems: 'center', justifyContent: 'center' }}>
521
549
  <Indicator state={state} amplitude={amplitude} theme={theme} isMuted={isMuted}/>
522
- </react_native_1.View>
550
+ </react_native_1.Pressable>
551
+ {resumeHint}
523
552
 
524
553
  {toolStatus ? (<react_native_1.View style={{ marginTop: 12, alignItems: 'center' }}>
525
554
  <ToolStatusPill_1.ToolStatusPill message={toolStatus.loadingMessage} theme={theme}/>
@@ -595,6 +624,14 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
595
624
  </react_native_1.ScrollView>) : null}
596
625
  </react_native_1.View>
597
626
 
627
+ {showClose && !hasToolUI ? (<react_native_1.View style={{
628
+ position: 'absolute',
629
+ top: insets.top + 12,
630
+ left: 16,
631
+ }}>
632
+ {closeButton}
633
+ </react_native_1.View>) : null}
634
+
598
635
  {showMute && !hasToolUI ? (<react_native_1.View style={{
599
636
  position: 'absolute',
600
637
  // Absolute children aren't pushed by the parent's safe-area paddingTop,
@@ -602,7 +639,7 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
602
639
  top: insets.top + 12,
603
640
  right: 16,
604
641
  }}>
605
- <MuteButton isMuted={isMuted} onToggle={onToggleMute} theme={theme} state={state}/>
642
+ <MuteButton isMuted={isMuted} onToggle={onMicPress} theme={theme} state={state}/>
606
643
  </react_native_1.View>) : null}
607
644
  </react_native_1.View>
608
645
  </Background>);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { QafkaProvider } from './QafkaProvider';
5
5
  export { Qafka } from './Qafka';
6
- export type { VoiceComponents, VoiceIndicatorProps, VoiceBackgroundProps, VoiceTranscriptProps, VoiceMuteButtonProps, QafkaHandle } from './Qafka.types';
6
+ export type { VoiceComponents, VoiceIndicatorProps, VoiceBackgroundProps, VoiceTranscriptProps, VoiceMuteButtonProps, VoiceCloseButtonProps, QafkaHandle } from './Qafka.types';
7
7
  export type { ToolResponseMode } from './Qafka.types';
8
8
  export type { QafkaProps, QafkaProjectIds, ProjectIdOf } from './Qafka.types';
9
9
  export { MessageBubble } from './MessageBubble';
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.classifyVoiceClose = classifyVoiceClose;
4
+ const featureNotAvailable_1 = require("./services/featureNotAvailable");
4
5
  function classifyVoiceClose(event) {
5
6
  const code = event?.code;
6
7
  const reason = event?.reason;
@@ -10,7 +11,15 @@ function classifyVoiceClose(event) {
10
11
  if (code === 4003 || reason === 'VOICE_NOT_ALLOWED') {
11
12
  return 'not_allowed';
12
13
  }
13
- if (code === 4010 || reason === 'project_unavailable') {
14
+ // 4010 = project suspended; close code 4012 is shared by every
15
+ // feature-gated close (see featureNotAvailable.ts), so only the
16
+ // mobile_sdk-specific reason string maps here — mobile_sdk gates the
17
+ // whole SDK, so it gets the same neutral "assistant unavailable" UX as a
18
+ // suspended project. A close for a different gated feature (were voice
19
+ // ever gated on one) falls through to 'other' instead.
20
+ if (code === 4010 ||
21
+ reason === 'project_unavailable' ||
22
+ reason === featureNotAvailable_1.MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON) {
14
23
  return 'project_unavailable';
15
24
  }
16
25
  return 'other';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Decides whether the voice page should offer to re-open the session.
3
+ *
4
+ * A session can end while the voice page stays on screen — the assistant
5
+ * navigated to a host screen and the user came back, the app went to the
6
+ * background, or the connection dropped. A session is otherwise only opened
7
+ * when the pager lands on the voice page, so without this offer the page
8
+ * would sit idle with no way back in short of swiping away and back.
9
+ *
10
+ * Not offered before the first session (the swipe opens it on its own), nor
11
+ * when the server ended the session because voice is unavailable — that case
12
+ * falls back to text chat instead. Kept as a pure function so it is
13
+ * unit-testable without loading the native-bound voice hook.
14
+ */
15
+ export declare function canResumeVoice(input: {
16
+ state: string;
17
+ sessionEnded: boolean;
18
+ closeReason: string | null | undefined;
19
+ }): boolean;
20
+ /** Locale-aware label for the resume affordance. */
21
+ export declare function voiceResumeLabel(locale?: string): string;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canResumeVoice = canResumeVoice;
4
+ exports.voiceResumeLabel = voiceResumeLabel;
5
+ /**
6
+ * Decides whether the voice page should offer to re-open the session.
7
+ *
8
+ * A session can end while the voice page stays on screen — the assistant
9
+ * navigated to a host screen and the user came back, the app went to the
10
+ * background, or the connection dropped. A session is otherwise only opened
11
+ * when the pager lands on the voice page, so without this offer the page
12
+ * would sit idle with no way back in short of swiping away and back.
13
+ *
14
+ * Not offered before the first session (the swipe opens it on its own), nor
15
+ * when the server ended the session because voice is unavailable — that case
16
+ * falls back to text chat instead. Kept as a pure function so it is
17
+ * unit-testable without loading the native-bound voice hook.
18
+ */
19
+ function canResumeVoice(input) {
20
+ return input.state === 'idle' && input.sessionEnded && !input.closeReason;
21
+ }
22
+ /** Locale-aware label for the resume affordance. */
23
+ function voiceResumeLabel(locale) {
24
+ return locale?.toLowerCase().startsWith('tr')
25
+ ? 'Devam etmek için dokunun'
26
+ : 'Tap to continue';
27
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useChatMessages = void 0;
4
4
  const react_1 = require("react");
5
5
  const QafkaSDK_1 = require("../QafkaSDK");
6
+ const featureNotAvailable_1 = require("../services/featureNotAvailable");
6
7
  const Qafka_utils_1 = require("../components/Qafka.utils");
7
8
  const joinNarration_1 = require("../utils/joinNarration");
8
9
  const extractionContinuation_1 = require("../utils/extractionContinuation");
@@ -534,11 +535,17 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
534
535
  setIsSending(false);
535
536
  setToolStatus(null);
536
537
  finalChunkBufferRef.current = '';
537
- // Project suspended mid-session: flip the SDK to 'unavailable' instead of
538
- // treating this as a normal error. QafkaSDK.markUnavailable() notifies the
539
- // same onStatusChange listener useSDK subscribes to, so `onUnavailable`
540
- // fires there — no onError call and no error bubble here.
541
- if (err?.code === 'PROJECT_UNAVAILABLE') {
538
+ // Project suspended, or the plan does not include mobile_sdk, mid-session:
539
+ // flip the SDK to 'unavailable' instead of treating this as a normal
540
+ // error. QafkaSDK.markUnavailable() notifies the same onStatusChange
541
+ // listener useSDK subscribes to, so `onUnavailable` fires there — no
542
+ // onError call and no error bubble here. The __DEV__ upgrade warning is
543
+ // already emitted by QafkaSDK before this hook sees the error, so it
544
+ // isn't repeated here. A FEATURE_NOT_AVAILABLE for a feature other than
545
+ // mobile_sdk (e.g. card_templates) is NOT this case — it falls through
546
+ // to the normal onError/error-bubble handling below, since it doesn't
547
+ // gate the whole SDK.
548
+ if (err?.code === 'PROJECT_UNAVAILABLE' || (0, featureNotAvailable_1.isMobileSdkDenied)(err)) {
542
549
  QafkaSDK_1.QafkaSDK.getInstance().markUnavailable();
543
550
  return;
544
551
  }
@@ -133,5 +133,14 @@ interface UseVoiceChatResult {
133
133
  voiceCloseReason: 'minutes_exhausted' | null;
134
134
  /** Locale-aware copy to show alongside `voiceCloseReason`, or `null`. */
135
135
  voiceCloseMessage: string | null;
136
+ /**
137
+ * True when the session has ended while the voice page may still be on
138
+ * screen, so the page should offer `resume` instead of sitting idle.
139
+ */
140
+ canResume: boolean;
141
+ /** Re-opens the voice session after it ended. */
142
+ resume: () => Promise<void>;
143
+ /** Locale-aware label for the resume affordance. */
144
+ resumeLabel: string;
136
145
  }
137
146
  export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
@@ -7,6 +7,7 @@ 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
9
  const decide_voice_close_1 = require("../decide-voice-close");
10
+ const decide_voice_resume_1 = require("../decide-voice-resume");
10
11
  /**
11
12
  * Neutral, locale-aware copy shown when the server ends a voice session
12
13
  * because it can no longer be continued. Mirrors `unavailableMessage()` in
@@ -21,6 +22,9 @@ function voiceUnavailableMessage(locale) {
21
22
  function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, onNavigationSuggest, }) {
22
23
  const [state, setState] = (0, react_1.useState)('idle');
23
24
  const [voiceCloseReason, setVoiceCloseReason] = (0, react_1.useState)(null);
25
+ // True once a session has ended while this hook stays mounted. Drives the
26
+ // voice page's resume affordance (see decide-voice-resume.ts).
27
+ const [sessionEnded, setSessionEnded] = (0, react_1.useState)(false);
24
28
  const [voiceCloseMessage, setVoiceCloseMessage] = (0, react_1.useState)(null);
25
29
  const localeRef = (0, react_1.useRef)(locale);
26
30
  (0, react_1.useEffect)(() => {
@@ -180,12 +184,14 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
180
184
  setVoiceCloseReason(null);
181
185
  setVoiceCloseMessage(null);
182
186
  setState('idle');
187
+ setSessionEnded(true);
183
188
  setNavigationSuggestion(null);
184
189
  pendingNavDisconnectRef.current = false;
185
190
  return;
186
191
  }
187
192
  case 'error':
188
193
  setState('idle');
194
+ setSessionEnded(true);
189
195
  return;
190
196
  case 'tool_status':
191
197
  setToolStatus({
@@ -424,6 +430,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
424
430
  return;
425
431
  }
426
432
  setState('connecting');
433
+ setSessionEnded(false);
427
434
  setVoiceCloseReason(null);
428
435
  setVoiceCloseMessage(null);
429
436
  const service = new RealtimeService_1.RealtimeService(apiUrl, apiKey);
@@ -459,6 +466,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
459
466
  }
460
467
  catch {
461
468
  setState('idle');
469
+ setSessionEnded(true);
462
470
  serviceRef.current = null;
463
471
  }
464
472
  }, [apiUrl, apiKey, handleEvent, userContext, contextDescription, getSessionToken, endUserId, endUserData]);
@@ -468,6 +476,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
468
476
  await serviceRef.current?.disconnect();
469
477
  serviceRef.current = null;
470
478
  setState('idle');
479
+ setSessionEnded(true);
471
480
  setTranscript('');
472
481
  setUserTranscript('');
473
482
  setTranscriptHistory([]);
@@ -483,6 +492,15 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
483
492
  (0, react_1.useEffect)(() => {
484
493
  disconnectRef.current = disconnect;
485
494
  }, [disconnect]);
495
+ // Re-opens the session from the voice page. A session that ended without
496
+ // going through disconnect() (connection error, unexpected close) leaves its
497
+ // service behind, which connect() would treat as still live — clear it first.
498
+ const resume = (0, react_1.useCallback)(async () => {
499
+ if (serviceRef.current && !serviceRef.current.isConnected) {
500
+ await disconnect();
501
+ }
502
+ await connect();
503
+ }, [connect, disconnect]);
486
504
  const pauseMic = (0, react_1.useCallback)(async () => {
487
505
  await serviceRef.current?.pauseMic();
488
506
  }, []);
@@ -559,5 +577,8 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
559
577
  dismissNavigationSuggestion,
560
578
  voiceCloseReason,
561
579
  voiceCloseMessage,
580
+ canResume: (0, decide_voice_resume_1.canResumeVoice)({ state, sessionEnded, closeReason: voiceCloseReason }),
581
+ resume,
582
+ resumeLabel: (0, decide_voice_resume_1.voiceResumeLabel)(locale),
562
583
  };
563
584
  }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * AI-powered conversational interface with navigation intelligence
4
4
  */
5
5
  export { QafkaSDK, getSDK } from './QafkaSDK';
6
+ export { FeatureNotAvailableError } from './services/featureNotAvailable';
6
7
  export declare const getVersion: () => string;
7
8
  export declare const isVersionAtLeast: (minVersion: string) => boolean;
8
9
  export * from './components';
package/dist/index.js CHANGED
@@ -18,11 +18,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
18
18
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.isVersionAtLeast = exports.getVersion = exports.getSDK = exports.QafkaSDK = void 0;
21
+ exports.isVersionAtLeast = exports.getVersion = exports.FeatureNotAvailableError = exports.getSDK = exports.QafkaSDK = void 0;
22
22
  // Main SDK export
23
23
  var QafkaSDK_1 = require("./QafkaSDK");
24
24
  Object.defineProperty(exports, "QafkaSDK", { enumerable: true, get: function () { return QafkaSDK_1.QafkaSDK; } });
25
25
  Object.defineProperty(exports, "getSDK", { enumerable: true, get: function () { return QafkaSDK_1.getSDK; } });
26
+ var featureNotAvailable_1 = require("./services/featureNotAvailable");
27
+ Object.defineProperty(exports, "FeatureNotAvailableError", { enumerable: true, get: function () { return featureNotAvailable_1.FeatureNotAvailableError; } });
26
28
  const package_json_1 = require("../package.json");
27
29
  const getVersion = () => package_json_1.version;
28
30
  exports.getVersion = getVersion;
@@ -38,6 +38,7 @@ const react_native_1 = require("react-native");
38
38
  const storage_1 = require("./storage");
39
39
  const NativeAttestation = __importStar(require("../native/QafkaAttestation"));
40
40
  const QafkaAttestation_1 = require("../native/QafkaAttestation");
41
+ const featureNotAvailable_1 = require("./featureNotAvailable");
41
42
  class AttestationManager {
42
43
  config;
43
44
  sessionToken = null;
@@ -273,6 +274,11 @@ class AttestationManager {
273
274
  }
274
275
  this.log(`[Qafka:init] challenge status=${response.status}`);
275
276
  if (!response.ok) {
277
+ const errorData = await response.json().catch(() => ({}));
278
+ const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(response.status, errorData);
279
+ if (unavailableFeature) {
280
+ throw new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, errorData.message);
281
+ }
276
282
  throw new Error(`Challenge request failed: ${response.status}`);
277
283
  }
278
284
  const data = await response.json();
@@ -300,6 +306,10 @@ class AttestationManager {
300
306
  this.log(`[Qafka:init] attest status=${response.status}`);
301
307
  if (!response.ok) {
302
308
  const errorData = await response.json().catch(() => ({}));
309
+ const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(response.status, errorData);
310
+ if (unavailableFeature) {
311
+ throw new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, errorData.message);
312
+ }
303
313
  throw new Error(`Attestation failed: ${response.status} — ${errorData.message || errorData.error || 'Unknown error'}`);
304
314
  }
305
315
  return response.json();
@@ -5,6 +5,7 @@ const react_native_1 = require("react-native");
5
5
  const package_json_1 = require("../../package.json");
6
6
  const QafkaAttestation_1 = require("../native/QafkaAttestation");
7
7
  const projectUnavailable_1 = require("./projectUnavailable");
8
+ const featureNotAvailable_1 = require("./featureNotAvailable");
8
9
  /**
9
10
  * Backend API Service using native fetch
10
11
  */
@@ -282,6 +283,13 @@ class BackendService {
282
283
  if ((0, projectUnavailable_1.isProjectUnavailable)(response.status, errorData)) {
283
284
  throw new projectUnavailable_1.ProjectUnavailableError(errorMessage);
284
285
  }
286
+ // Plan does not include this SDK channel (e.g. `mobile_sdk`) — a
287
+ // distinct signal from the other 403 branches below, callers branch on
288
+ // `FeatureNotAvailableError` / `code === 'FEATURE_NOT_AVAILABLE'`.
289
+ const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(response.status, errorData);
290
+ if (unavailableFeature) {
291
+ throw new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, errorMessage);
292
+ }
285
293
  // 401: Unauthorized (Invalid/Missing API Key)
286
294
  if (response.status === 401) {
287
295
  if (errorCode === 'MISSING_API_KEY') {
@@ -432,9 +440,10 @@ class BackendService {
432
440
  return this.handleResponse(response);
433
441
  }
434
442
  catch (error) {
435
- // Preserve ProjectUnavailableError as-is — callers (QafkaSDK.initialize)
436
- // branch on `instanceof`, so re-wrapping here would lose that signal.
437
- if (error instanceof projectUnavailable_1.ProjectUnavailableError) {
443
+ // Preserve ProjectUnavailableError / FeatureNotAvailableError as-is —
444
+ // callers (QafkaSDK.initialize) branch on `instanceof`, so re-wrapping
445
+ // here would lose that signal.
446
+ if (error instanceof projectUnavailable_1.ProjectUnavailableError || error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
438
447
  throw error;
439
448
  }
440
449
  throw new Error(error.message || 'Failed to fetch theme');
@@ -686,6 +695,13 @@ class BackendService {
686
695
  onError(unavailableErr);
687
696
  return;
688
697
  }
698
+ const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(xhr.status, parsedBody);
699
+ if (unavailableFeature) {
700
+ const featureErr = new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, serverMessage);
701
+ featureErr.status = xhr.status;
702
+ onError(featureErr);
703
+ return;
704
+ }
689
705
  // In dev, a 401 with a test key almost always means the rotated dev
690
706
  // key is stale. Surface an actionable hint instead of a bare HTTP error.
691
707
  if (__DEV__ && xhr.status === 401 && this.apiKey?.startsWith('qafka_test_')) {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared helpers for the "feature not available" state: when the project's
3
+ * plan does not include a given SDK feature (e.g. `mobile_sdk`), the backend
4
+ * answers HTTP 403 `{ error: 'feature_not_available', feature: '<f>',
5
+ * message }` on every request for that channel, and closes a voice
6
+ * WebSocket with code 4012. The SDK maps this to the same neutral
7
+ * "assistant unavailable" UX as a suspended project (see
8
+ * `./projectUnavailable.ts`) — no error bubble — and, in `__DEV__` only
9
+ * (repo rule: no bare `console.*`), warns the developer once per feature
10
+ * with an actionable upgrade message.
11
+ */
12
+ /** WebSocket close code the realtime/voice backend uses for this signal. */
13
+ export declare const FEATURE_NOT_AVAILABLE_CLOSE_CODE = 4012;
14
+ /**
15
+ * True when a response is the specific "feature not available" signal, as
16
+ * opposed to any other 403 (bundle id, platform, permission, project
17
+ * suspension, ...). Returns the gated feature name, or `'unknown'` when the
18
+ * body confirms the signal but omits the feature, or `null` when the
19
+ * response is not this signal at all.
20
+ */
21
+ export declare function getUnavailableFeature(status: number, body: unknown): string | null;
22
+ /**
23
+ * Thrown by the transport layer when a request fails because the project's
24
+ * plan does not include the requested feature. Callers branch on `code`,
25
+ * not `instanceof`, where they only have a plain error (e.g. across the
26
+ * streaming/XHR boundary) — mirroring `ProjectUnavailableError`.
27
+ */
28
+ export declare class FeatureNotAvailableError extends Error {
29
+ code: string;
30
+ feature: string;
31
+ /** HTTP status of the response that produced this error, when known. */
32
+ status?: number;
33
+ constructor(feature: string, message?: string);
34
+ }
35
+ /**
36
+ * Developer-facing (not end-user-facing) message: names the gated feature
37
+ * and points at the upgrade path. Used both as the default
38
+ * `FeatureNotAvailableError` message and as the `__DEV__` console warning.
39
+ */
40
+ export declare function featureNotAvailableMessage(feature: string): string;
41
+ /**
42
+ * Warns the developer, once per feature per app run, that a plan-gated SDK
43
+ * feature was rejected. Only runs in `__DEV__` — production builds never
44
+ * print this (repo rule: no bare `console.*`). The end user is unaffected;
45
+ * they see the neutral "unavailable" UX, not this message.
46
+ */
47
+ export declare function warnFeatureNotAvailable(feature: string): void;
48
+ /** Test-only: clears the once-per-feature warning dedupe state. */
49
+ export declare function resetFeatureWarningsForTests(): void;
50
+ /**
51
+ * True when `err` is a `FeatureNotAvailableError` for the `mobile_sdk`
52
+ * feature specifically. Only `mobile_sdk` justifies flipping the whole SDK
53
+ * to the neutral 'unavailable' state (the same UX as a suspended project):
54
+ * it gates every request the SDK makes. Any other plan-gated feature (e.g.
55
+ * a card-template or tool-specific flag) still surfaces as a typed
56
+ * `FeatureNotAvailableError` to the caller, but must not take down the rest
57
+ * of the SDK — a project can be denied one feature and fine on everything
58
+ * else. Callers still call `warnFeatureNotAvailable(err.feature)` for every
59
+ * feature; only the unavailable flip is mobile_sdk-specific.
60
+ */
61
+ export declare function isMobileSdkDenied(err: unknown): boolean;
62
+ /** WS close reason string the backend sends when a voice session is closed
63
+ * because the project's plan does not include `mobile_sdk`. Voice-only:
64
+ * used by `decide-voice-close.ts` to keep the 'unavailable' outcome scoped
65
+ * to this specific feature rather than any `feature_not_available` close. */
66
+ export declare const MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON = "FEATURE_NOT_AVAILABLE:mobile_sdk";
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * Shared helpers for the "feature not available" state: when the project's
4
+ * plan does not include a given SDK feature (e.g. `mobile_sdk`), the backend
5
+ * answers HTTP 403 `{ error: 'feature_not_available', feature: '<f>',
6
+ * message }` on every request for that channel, and closes a voice
7
+ * WebSocket with code 4012. The SDK maps this to the same neutral
8
+ * "assistant unavailable" UX as a suspended project (see
9
+ * `./projectUnavailable.ts`) — no error bubble — and, in `__DEV__` only
10
+ * (repo rule: no bare `console.*`), warns the developer once per feature
11
+ * with an actionable upgrade message.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON = exports.FeatureNotAvailableError = exports.FEATURE_NOT_AVAILABLE_CLOSE_CODE = void 0;
15
+ exports.getUnavailableFeature = getUnavailableFeature;
16
+ exports.featureNotAvailableMessage = featureNotAvailableMessage;
17
+ exports.warnFeatureNotAvailable = warnFeatureNotAvailable;
18
+ exports.resetFeatureWarningsForTests = resetFeatureWarningsForTests;
19
+ exports.isMobileSdkDenied = isMobileSdkDenied;
20
+ /** WebSocket close code the realtime/voice backend uses for this signal. */
21
+ exports.FEATURE_NOT_AVAILABLE_CLOSE_CODE = 4012;
22
+ /** Human-readable names for features callers may see in the 403 body. */
23
+ const FEATURE_DISPLAY_NAMES = {
24
+ mobile_sdk: 'Mobile SDK',
25
+ web_sdk: 'Web SDK',
26
+ };
27
+ /**
28
+ * True when a response is the specific "feature not available" signal, as
29
+ * opposed to any other 403 (bundle id, platform, permission, project
30
+ * suspension, ...). Returns the gated feature name, or `'unknown'` when the
31
+ * body confirms the signal but omits the feature, or `null` when the
32
+ * response is not this signal at all.
33
+ */
34
+ function getUnavailableFeature(status, body) {
35
+ if (status !== 403)
36
+ return null;
37
+ if (!body || typeof body !== 'object')
38
+ return null;
39
+ if (body.error !== 'feature_not_available')
40
+ return null;
41
+ const feature = body.feature;
42
+ return typeof feature === 'string' && feature.length > 0 ? feature : 'unknown';
43
+ }
44
+ /**
45
+ * Thrown by the transport layer when a request fails because the project's
46
+ * plan does not include the requested feature. Callers branch on `code`,
47
+ * not `instanceof`, where they only have a plain error (e.g. across the
48
+ * streaming/XHR boundary) — mirroring `ProjectUnavailableError`.
49
+ */
50
+ class FeatureNotAvailableError extends Error {
51
+ code = 'FEATURE_NOT_AVAILABLE';
52
+ feature;
53
+ /** HTTP status of the response that produced this error, when known. */
54
+ status;
55
+ constructor(feature, message) {
56
+ super(message ?? featureNotAvailableMessage(feature));
57
+ this.name = 'FeatureNotAvailableError';
58
+ this.feature = feature;
59
+ Object.setPrototypeOf(this, FeatureNotAvailableError.prototype);
60
+ }
61
+ }
62
+ exports.FeatureNotAvailableError = FeatureNotAvailableError;
63
+ /**
64
+ * Developer-facing (not end-user-facing) message: names the gated feature
65
+ * and points at the upgrade path. Used both as the default
66
+ * `FeatureNotAvailableError` message and as the `__DEV__` console warning.
67
+ */
68
+ function featureNotAvailableMessage(feature) {
69
+ const displayName = FEATURE_DISPLAY_NAMES[feature] ?? feature;
70
+ return `[Qafka] ${displayName} is not available on the current plan. Upgrade the project's plan in the Dashboard to enable it.`;
71
+ }
72
+ const warnedFeatures = new Set();
73
+ /**
74
+ * Warns the developer, once per feature per app run, that a plan-gated SDK
75
+ * feature was rejected. Only runs in `__DEV__` — production builds never
76
+ * print this (repo rule: no bare `console.*`). The end user is unaffected;
77
+ * they see the neutral "unavailable" UX, not this message.
78
+ */
79
+ function warnFeatureNotAvailable(feature) {
80
+ if (!__DEV__)
81
+ return;
82
+ if (warnedFeatures.has(feature))
83
+ return;
84
+ warnedFeatures.add(feature);
85
+ console.warn(featureNotAvailableMessage(feature));
86
+ }
87
+ /** Test-only: clears the once-per-feature warning dedupe state. */
88
+ function resetFeatureWarningsForTests() {
89
+ warnedFeatures.clear();
90
+ }
91
+ /**
92
+ * True when `err` is a `FeatureNotAvailableError` for the `mobile_sdk`
93
+ * feature specifically. Only `mobile_sdk` justifies flipping the whole SDK
94
+ * to the neutral 'unavailable' state (the same UX as a suspended project):
95
+ * it gates every request the SDK makes. Any other plan-gated feature (e.g.
96
+ * a card-template or tool-specific flag) still surfaces as a typed
97
+ * `FeatureNotAvailableError` to the caller, but must not take down the rest
98
+ * of the SDK — a project can be denied one feature and fine on everything
99
+ * else. Callers still call `warnFeatureNotAvailable(err.feature)` for every
100
+ * feature; only the unavailable flip is mobile_sdk-specific.
101
+ */
102
+ function isMobileSdkDenied(err) {
103
+ return err instanceof FeatureNotAvailableError && err.feature === 'mobile_sdk';
104
+ }
105
+ /** WS close reason string the backend sends when a voice session is closed
106
+ * because the project's plan does not include `mobile_sdk`. Voice-only:
107
+ * used by `decide-voice-close.ts` to keep the 'unavailable' outcome scoped
108
+ * to this specific feature rather than any `feature_not_available` close. */
109
+ exports.MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON = 'FEATURE_NOT_AVAILABLE:mobile_sdk';
@@ -288,9 +288,10 @@ You may optionally restyle the chips via `voiceComponents.dataChipList` / `voice
288
288
  the inverse of `context`.
289
289
  - **`subProjectId`** — multi-tenant routing.
290
290
  - **`locale`** — BCP-47 locale.
291
- - **`onUnavailable`** — fires when the project becomes unavailable (suspended). The chat
292
- screen stays mounted and shows a neutral message in place of the normal chat surface; not
293
- treated as an error (`onError` is not also called). Use it to hide your own entry point —
291
+ - **`onUnavailable`** — fires when the project becomes unavailable (suspended, or the mobile
292
+ SDK is not enabled for it). The chat screen stays mounted and shows a neutral message in
293
+ place of the normal chat surface; not treated as an error (`onError` is not also called).
294
+ Use it to hide your own entry point —
294
295
  e.g. a launcher button or nav item — so users can't open a chat screen with nothing to say:
295
296
  ```tsx
296
297
  const [chatAvailable, setChatAvailable] = useState(true);
@@ -298,6 +299,10 @@ You may optionally restyle the chips via `voiceComponents.dataChipList` / `voice
298
299
  <Qafka onUnavailable={() => setChatAvailable(false)} />
299
300
  {chatAvailable && <LauncherButton onPress={openChat} />}
300
301
  ```
302
+ - **`onVoiceClose`** — optional; fires when the close button on the voice page is pressed.
303
+ That button only leaves voice: the voice session ends and the widget returns to the chat
304
+ page, which stays open. `onClose` is not called — it still belongs to the chat page's own
305
+ close button. Restyle the button via `voiceComponents.VoiceCloseButton`.
301
306
 
302
307
  ---
303
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
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",