@experiaapp/webchat-react-native 2.0.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.
Files changed (101) hide show
  1. package/README.md +254 -0
  2. package/app.plugin.js +6 -0
  3. package/lib/adapters/audio.d.ts +74 -0
  4. package/lib/adapters/audio.js +39 -0
  5. package/lib/adapters/audioRoute.d.ts +57 -0
  6. package/lib/adapters/audioRoute.js +77 -0
  7. package/lib/adapters/expoDefaults.d.ts +77 -0
  8. package/lib/adapters/expoDefaults.js +539 -0
  9. package/lib/adapters/picker.d.ts +67 -0
  10. package/lib/adapters/picker.js +37 -0
  11. package/lib/adapters/webrtc.d.ts +131 -0
  12. package/lib/adapters/webrtc.js +70 -0
  13. package/lib/core/VideoCallClient.d.ts +106 -0
  14. package/lib/core/VideoCallClient.js +302 -0
  15. package/lib/core/WebchatClient.d.ts +34 -0
  16. package/lib/core/WebchatClient.js +132 -0
  17. package/lib/core/configClient.d.ts +42 -0
  18. package/lib/core/configClient.js +302 -0
  19. package/lib/core/greet.d.ts +11 -0
  20. package/lib/core/greet.js +17 -0
  21. package/lib/core/ice.d.ts +31 -0
  22. package/lib/core/ice.js +48 -0
  23. package/lib/core/linkify.d.ts +11 -0
  24. package/lib/core/linkify.js +25 -0
  25. package/lib/core/logger.d.ts +17 -0
  26. package/lib/core/logger.js +53 -0
  27. package/lib/core/media.d.ts +52 -0
  28. package/lib/core/media.js +115 -0
  29. package/lib/core/mediaType.d.ts +21 -0
  30. package/lib/core/mediaType.js +66 -0
  31. package/lib/core/messagesReducer.d.ts +36 -0
  32. package/lib/core/messagesReducer.js +58 -0
  33. package/lib/core/persistence.d.ts +45 -0
  34. package/lib/core/persistence.js +63 -0
  35. package/lib/core/socketFactory.d.ts +16 -0
  36. package/lib/core/socketFactory.js +82 -0
  37. package/lib/core/types.d.ts +320 -0
  38. package/lib/core/types.js +30 -0
  39. package/lib/core/unread.d.ts +2 -0
  40. package/lib/core/unread.js +5 -0
  41. package/lib/i18n/ar.json +1 -0
  42. package/lib/i18n/en.json +1 -0
  43. package/lib/i18n/index.d.ts +7 -0
  44. package/lib/i18n/index.js +43 -0
  45. package/lib/index.d.ts +59 -0
  46. package/lib/index.js +142 -0
  47. package/lib/plugin/withWebchat.d.ts +53 -0
  48. package/lib/plugin/withWebchat.js +164 -0
  49. package/lib/state/WebchatProvider.d.ts +132 -0
  50. package/lib/state/WebchatProvider.js +906 -0
  51. package/lib/state/useWebchat.d.ts +1 -0
  52. package/lib/state/useWebchat.js +12 -0
  53. package/lib/theme/dir.d.ts +14 -0
  54. package/lib/theme/dir.js +20 -0
  55. package/lib/theme/themeFactory.d.ts +219 -0
  56. package/lib/theme/themeFactory.js +182 -0
  57. package/lib/ui/AttachButton.d.ts +35 -0
  58. package/lib/ui/AttachButton.js +26 -0
  59. package/lib/ui/AudioRecorder.d.ts +25 -0
  60. package/lib/ui/AudioRecorder.js +228 -0
  61. package/lib/ui/Bubble.d.ts +1 -0
  62. package/lib/ui/Bubble.js +265 -0
  63. package/lib/ui/CallControls.d.ts +27 -0
  64. package/lib/ui/CallControls.js +92 -0
  65. package/lib/ui/CallPlaceholder.d.ts +16 -0
  66. package/lib/ui/CallPlaceholder.js +73 -0
  67. package/lib/ui/Composer.d.ts +5 -0
  68. package/lib/ui/Composer.js +272 -0
  69. package/lib/ui/FileTile.d.ts +9 -0
  70. package/lib/ui/FileTile.js +31 -0
  71. package/lib/ui/Header.d.ts +52 -0
  72. package/lib/ui/Header.js +236 -0
  73. package/lib/ui/Icon.d.ts +21 -0
  74. package/lib/ui/Icon.js +110 -0
  75. package/lib/ui/ImageBubble.d.ts +11 -0
  76. package/lib/ui/ImageBubble.js +16 -0
  77. package/lib/ui/MediaUploadMenu.d.ts +23 -0
  78. package/lib/ui/MediaUploadMenu.js +68 -0
  79. package/lib/ui/MessageList.d.ts +1 -0
  80. package/lib/ui/MessageList.js +46 -0
  81. package/lib/ui/PoweredBy.d.ts +8 -0
  82. package/lib/ui/PoweredBy.js +14 -0
  83. package/lib/ui/PrechatForm.d.ts +1 -0
  84. package/lib/ui/PrechatForm.js +230 -0
  85. package/lib/ui/QuickReplies.d.ts +1 -0
  86. package/lib/ui/QuickReplies.js +24 -0
  87. package/lib/ui/TypingIndicator.d.ts +9 -0
  88. package/lib/ui/TypingIndicator.js +88 -0
  89. package/lib/ui/VideoBubble.d.ts +10 -0
  90. package/lib/ui/VideoBubble.js +130 -0
  91. package/lib/ui/VideoCall.d.ts +34 -0
  92. package/lib/ui/VideoCall.js +191 -0
  93. package/lib/ui/VideoTile.d.ts +25 -0
  94. package/lib/ui/VideoTile.js +13 -0
  95. package/lib/ui/VoiceMessage.d.ts +19 -0
  96. package/lib/ui/VoiceMessage.js +127 -0
  97. package/lib/ui/WebChat.d.ts +10 -0
  98. package/lib/ui/WebChat.js +386 -0
  99. package/lib/ui/openLink.d.ts +1 -0
  100. package/lib/ui/openLink.js +16 -0
  101. package/package.json +94 -0
@@ -0,0 +1,386 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebChat = void 0;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ // src/ui/WebChat.tsx
6
+ const react_1 = require("react");
7
+ const react_native_1 = require("react-native");
8
+ const react_native_safe_area_context_1 = require("react-native-safe-area-context");
9
+ const WebchatProvider_1 = require("../state/WebchatProvider");
10
+ const useWebchat_1 = require("../state/useWebchat");
11
+ const socketFactory_1 = require("../core/socketFactory");
12
+ const MessageList_1 = require("./MessageList");
13
+ const Composer_1 = require("./Composer");
14
+ const Header_1 = require("./Header");
15
+ const Icon_1 = require("./Icon");
16
+ const PoweredBy_1 = require("./PoweredBy");
17
+ const PrechatForm_1 = require("./PrechatForm");
18
+ const dir_1 = require("../theme/dir");
19
+ const i18n_1 = require("../i18n");
20
+ const expoDefaults_1 = require("../adapters/expoDefaults");
21
+ const webrtc_1 = require("../adapters/webrtc");
22
+ // <VideoCall> is lazy-required (NOT statically imported) so the native binding it
23
+ // transitively pulls in (react-native-webrtc's RTCView, which runs a top-level
24
+ // NativeEventEmitter at module load) is only loaded when a call is actually rendered.
25
+ // This keeps chat-only consumers — and every non-video test that renders <WebChat>
26
+ // without mocking react-native-webrtc — free of the native module (C15/C16).
27
+ function loadVideoCall() {
28
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
29
+ return require("./VideoCall").VideoCall;
30
+ }
31
+ // secureScreen (S10): when the host opts in, lazily flag the surface as
32
+ // screen-capture-protected. expo-screen-capture (preventScreenCaptureAsync) is an
33
+ // OPTIONAL peer; the require is guarded so a chat-only consumer without it — and every
34
+ // unit test — degrades to a no-op rather than crashing. Default off (tenant opts in).
35
+ function applySecureScreen() {
36
+ var _a;
37
+ try {
38
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
39
+ const cap = require("expo-screen-capture");
40
+ (_a = cap.preventScreenCaptureAsync) === null || _a === void 0 ? void 0 : _a.call(cap);
41
+ return () => {
42
+ var _a;
43
+ try {
44
+ (_a = cap.allowScreenCaptureAsync) === null || _a === void 0 ? void 0 : _a.call(cap);
45
+ }
46
+ catch {
47
+ /* best-effort release */
48
+ }
49
+ };
50
+ }
51
+ catch {
52
+ // expo-screen-capture absent — secure-screen is best-effort, so no-op.
53
+ return undefined;
54
+ }
55
+ }
56
+ function Surface({ config, title, prechatFields, prechatHeadline: prechatHeadlineProp, prechatSubmitLabel: prechatSubmitLabelProp, onPrechatSubmit, prechatDone, picker, audioAdapter: audioAdapterProp, secureScreen, disabledInput, onClose, }) {
57
+ var _a, _b, _c, _d, _e, _f, _g;
58
+ // Context contract: theme / dir / language / resolvedConfig are the single source of
59
+ // truth (provider derives them from prop>server>default). The Surface NEVER hardcodes
60
+ // a palette/direction — it reads them here and threads them to Header/MessageList/Composer.
61
+ // audioAdapter (received-voice playback, #23) and setSpeaker (in-call route, #25) are read
62
+ // from context so the live path reaches them; an explicit prop still wins (testability).
63
+ const { messages, send, attach, isStorageReady, language, theme, dir, resolvedConfig, videoCallStarted, startCall, endCall, _video, status, awaitingReply, wasConnected, audioAdapter: audioAdapterCtx, setSpeaker, resumedSession, } = (0, useWebchat_1.useWebchat)();
64
+ const audioAdapter = audioAdapterProp !== null && audioAdapterProp !== void 0 ? audioAdapterProp : audioAdapterCtx;
65
+ const variant = theme === null || theme === void 0 ? void 0 : theme.variant; // experia | digitalGovernment (resolved from interfaceTheme)
66
+ // Connection status for the header's online/offline indicator (web parity).
67
+ const connected = status === "connected" || status === "session-confirmed";
68
+ // typeWidget (Tier C #30): the SDK has a SINGLE Surface (+ optional FAB Launcher)
69
+ // and no DrawerWidget/embedded-shell analog beyond the `embedded` flag below, so
70
+ // any non-"default" value is treated as "default" — accepted, never errors. This
71
+ // key is resolved by DOCUMENTATION, not new behaviour (see CONFIG-PARITY-AUDIT.md).
72
+ // (Intentionally not branched on: there is only one shell to render.)
73
+ // MOBILE LAYOUT: the chat surface ALWAYS fills its parent (flex:1) — it is full
74
+ // screen on a phone, full stop. The web `fullScreenHeight` flag (Tier C #32) is a
75
+ // DESKTOP floating-panel notion (a bounded corner widget capped to part of the
76
+ // viewport) that does NOT translate to mobile, where the chat takes the whole screen
77
+ // (or fills whatever container a host deliberately mounts WebChat into). So we still
78
+ // ACCEPT `fullScreenHeight` in config (web parity, never errors) but intentionally
79
+ // DO NOT apply it here — it must never shrink the mobile view. (The bug: a server
80
+ // config with `fullScreenHeight:false` left the surface capped at maxHeight:"80%".)
81
+ // `embedded` (#31) stays a host opt-in for inline layouts: it still flex-fills, so
82
+ // the bounding is governed by the container the host provides, not a web flag.
83
+ const embedded = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.embedded) === true;
84
+ const containerStyle = embedded
85
+ ? { alignSelf: "stretch", flex: 1 }
86
+ : { flex: 1 };
87
+ // hideWhenNotConnected (Tier C #34): hide the surface ONLY after a real
88
+ // established-then-lost drop — i.e. we reached a CONFIRMED session at least once
89
+ // (wasConnected, latched in the provider on "session-confirmed") and are now no
90
+ // longer connected. NEVER on the initial pre-connect / handshake (wasConnected
91
+ // false) — otherwise an open surface mounted before the first connection would
92
+ // vanish and the user could never connect. Default off (opt-in). When the gate is
93
+ // active we render the empty surface shell (keeps the testID stable) — no chrome.
94
+ const hideWhenNotConnected = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.hideWhenNotConnected) === true;
95
+ const hiddenForDisconnect = hideWhenNotConnected && wasConnected && !connected;
96
+ // subTitle / statusIndicator are server-driven config keys the header reads
97
+ // (web parity). They are not in the core Config type yet, so read them off a
98
+ // loosely-typed view of the resolved config rather than widening the type here.
99
+ const headerConfig = (resolvedConfig !== null && resolvedConfig !== void 0 ? resolvedConfig : {});
100
+ // Header title: the host prop wins; otherwise fall back to server config.title
101
+ // (web parity — config.title drives the header when the host omits it).
102
+ const headerTitle = title !== null && title !== void 0 ? title : headerConfig.title;
103
+ // Per-message timestamp toggle (web showMessageDate; default true). Threaded
104
+ // resolvedConfig -> MessageList -> Bubble so the server flag actually reaches it.
105
+ const showMessageDate = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.showMessageDate) !== false;
106
+ // Typing indicator (item 23, web Body/index.tsx:134): shown only when the server
107
+ // flag is on AND we're awaiting a bot reply (the SDK-faithful trigger from the
108
+ // provider). Threaded resolvedConfig + awaitingReply -> MessageList -> TypingIndicator.
109
+ const showTyping = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.displayTypingIndication) === true && awaitingReply;
110
+ // Powered-by badge (item 24, web Body/index.tsx:92): rendered below the message
111
+ // list, gated by showPoweredBy (default ON — only an explicit false hides it).
112
+ const showPoweredBy = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.showPoweredBy) !== false;
113
+ // Agent avatar (item 25, web showAvatar/avatar; default showAvatar true). Threaded
114
+ // to the Header (header avatar + status dot) and each received Bubble (per-message
115
+ // avatar). A graceful fallback applies when no avatar.url is configured.
116
+ const showAvatar = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.showAvatar) !== false;
117
+ const avatar = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.avatar;
118
+ // Custom header close image + launcher images (item 29, web close/launcher images).
119
+ const closeImage = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.closeImage;
120
+ // B21 focus-on-open: move the accessibility focus to the header when the surface
121
+ // mounts so a screen-reader user lands on the chat title, not wherever it was.
122
+ const headerRef = (0, react_1.useRef)(null);
123
+ (0, react_1.useEffect)(() => {
124
+ const node = (0, react_native_1.findNodeHandle)(headerRef.current);
125
+ if (node != null)
126
+ react_native_1.AccessibilityInfo.setAccessibilityFocus(node);
127
+ }, []);
128
+ // secureScreen (S10): apply on mount when the host opted in; release on unmount.
129
+ (0, react_1.useEffect)(() => {
130
+ if (!secureScreen)
131
+ return;
132
+ const release = applySecureScreen();
133
+ return release;
134
+ }, [secureScreen]);
135
+ // B9: don't render/evaluate prechat until hydrated.
136
+ if (!isStorageReady)
137
+ return (0, jsx_runtime_1.jsx)(react_native_safe_area_context_1.SafeAreaView, { testID: "webchat-surface" });
138
+ // hideWhenNotConnected (item 34): after a connect-then-disconnect, collapse the
139
+ // surface to an empty shell (keeps the testID for the harness; renders no chrome).
140
+ if (hiddenForDisconnect)
141
+ return (0, jsx_runtime_1.jsx)(react_native_safe_area_context_1.SafeAreaView, { testID: "webchat-surface" });
142
+ // Attach flow (P2): the composer's attach button opens a media selector with
143
+ // up to three rows (image / document / record-audio). Each picker row runs the
144
+ // injected PickerAdapter method, then routes the picked assets through the
145
+ // provider's attach(). Rows are feature-detected: a method absent on the
146
+ // adapter simply omits its row. The audio row is driven by audioAdapter inside
147
+ // the Composer. Documents are restricted to the allow-listed mime set.
148
+ // Server-config gating (web parity: FooterIcons reads `config.uploadMedia` /
149
+ // `config.uploadAudio`, default true). A flag set to false hides that control
150
+ // even when an adapter is wired. Received-voice PLAYBACK is NOT gated (below).
151
+ //
152
+ // Upload sub-types (web parity, audit #17): the web widget gates image /
153
+ // document / video INDEPENDENTLY. `uploadMedia` is the master gate; each
154
+ // sub-type then has its own flag (uploadImage / uploadDocuments / uploadVideo),
155
+ // all default true. A row shows only when BOTH the master and its sub-flag are
156
+ // not false.
157
+ const uploadMedia = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.uploadMedia) !== false;
158
+ const uploadAudio = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.uploadAudio) !== false;
159
+ const uploadImage = uploadMedia && (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.uploadImage) !== false;
160
+ const uploadDocuments = uploadMedia && (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.uploadDocuments) !== false;
161
+ // uploadVideo (web parity, audit #17): the master uploadMedia gate AND the
162
+ // per-type uploadVideo flag must both be enabled. The video row shows only when
163
+ // the picker exposes a `pickVideos` capability (feature-detected below) so a
164
+ // picker without it stays hidden rather than faking a non-functional row.
165
+ const uploadVideo = uploadMedia && (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.uploadVideo) !== false;
166
+ // Web parity (handleSetFileValue): picking only STAGES the assets — the Composer
167
+ // previews them and SENDS on the Send tap (via onAttach), NOT immediately on pick.
168
+ // So these just RETURN the picked Asset[] for the Composer to stage; the actual
169
+ // attach()/emit happens when the user taps Send.
170
+ const onPickVideos = uploadVideo && (picker === null || picker === void 0 ? void 0 : picker.pickVideos)
171
+ ? () => picker.pickVideos({ allowsMultiple: true })
172
+ : undefined;
173
+ const onPickImages = uploadImage && (picker === null || picker === void 0 ? void 0 : picker.pickImages)
174
+ ? () => picker.pickImages({ allowsMultiple: true })
175
+ : undefined;
176
+ const onPickDocuments = uploadDocuments && (picker === null || picker === void 0 ? void 0 : picker.pickDocuments)
177
+ ? // No `type` filter: the allow-list contains PARAMETRIZED mimes (e.g.
178
+ // "audio/ogg;codecs=opus") that iOS can't map to a UTI, which wedges
179
+ // UIDocumentPickerViewController and HANGS getDocumentAsync (it never
180
+ // presents or resolves). Present a universal picker; the picked file is
181
+ // still enforced against the allow-list post-pick by validateSelection.
182
+ () => picker.pickDocuments()
183
+ : undefined;
184
+ // Voice RECORDING is gated by config.uploadAudio; received-voice PLAYBACK
185
+ // (MessageList) keeps the full adapter so the agent's notes still play.
186
+ const recordAudioAdapter = uploadAudio ? audioAdapter : undefined;
187
+ // When prechat is enabled and there is no session yet, render <PrechatForm/>;
188
+ // its submit feeds prechatValues into the greet flow. Otherwise render the thread.
189
+ //
190
+ // Gate (audit #20): the form shows when it is NOT done AND either (a) the server
191
+ // config explicitly enables it (resolvedConfig.prechatForm.enabled) OR (b) the
192
+ // existing host-prop path supplied a non-empty prechatFields array. (a) lets a
193
+ // server-driven prechat config turn the form on without the host hand-passing
194
+ // fields; (b) preserves the current host-only behaviour.
195
+ const prechatConfigEnabled = ((_a = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.prechatForm) === null || _a === void 0 ? void 0 : _a.enabled) === true;
196
+ const hasHostFields = Array.isArray(prechatFields) && prechatFields.length > 0;
197
+ // Prefer host-supplied fields; otherwise use the server-config fields (so a
198
+ // server-enabled form has something to render).
199
+ const effectivePrechatFields = hasHostFields
200
+ ? prechatFields
201
+ : (_b = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.prechatForm) === null || _b === void 0 ? void 0 : _b.fields;
202
+ // Web parity (ChatContainer `!sessionId` gate): show the form only for a NEW session.
203
+ // A RESUMED session (returning user, restored session id) continues the conversation
204
+ // without re-asking. `prechatDone` is the one-shot per-mount submit latch.
205
+ const showPrechat = !prechatDone &&
206
+ !resumedSession &&
207
+ (prechatConfigEnabled || hasHostFields) &&
208
+ Array.isArray(effectivePrechatFields) &&
209
+ effectivePrechatFields.length > 0;
210
+ // Localized headline / submit label come from the server prechat config (web
211
+ // parity: PrechatForm.tsx headline, PrechatSubmitFooter.tsx submitLabel). Host
212
+ // props still win when supplied (mirrors the fields precedence above).
213
+ const prechatHeadline = prechatHeadlineProp !== null && prechatHeadlineProp !== void 0 ? prechatHeadlineProp : (_c = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.prechatForm) === null || _c === void 0 ? void 0 : _c.headline;
214
+ const prechatSubmitLabel = prechatSubmitLabelProp !== null && prechatSubmitLabelProp !== void 0 ? prechatSubmitLabelProp : (_d = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.prechatForm) === null || _d === void 0 ? void 0 : _d.submitLabel;
215
+ // P3: video is opt-out (web parity: defualtConfig.makeVideoCall === true). Gate on the
216
+ // RESOLVED config (prop > server > default true) so BOTH a host prop AND a server
217
+ // config can disable it with makeVideoCall:false — matching web, which hides the
218
+ // button on a server-driven flag. Default-on flows from DEFAULT_CONFIG.makeVideoCall.
219
+ const videoEnabled = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.makeVideoCall) !== false;
220
+ // #disabledInput: the composer is hard-disabled when the host prop OR the resolved
221
+ // config asks for it (server can lock input per tenant/conversation state).
222
+ const inputDisabled = disabledInput !== null && disabledInput !== void 0 ? disabledInput : (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.disabledInput) === true;
223
+ // Close button (BUG 4): shown unless the config explicitly disables it. Closing the
224
+ // surface also tears down any active call so we never leave a dangling call behind.
225
+ const showClose = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.showCloseButton) !== false;
226
+ const onHeaderClose = () => {
227
+ try {
228
+ endCall === null || endCall === void 0 ? void 0 : endCall();
229
+ }
230
+ catch { /* no active call */ }
231
+ onClose === null || onClose === void 0 ? void 0 : onClose();
232
+ };
233
+ // P3: once a call is ACTIVE, render <VideoCall> ABOVE the thread — gated on
234
+ // videoCallStarted only, NOT videoEnabled, so an auto-joined call (autoJoinVideoCall)
235
+ // always shows even under a contradictory config (makeVideoCall:false + auto-join).
236
+ // It renders as a view over the PROVIDER's single VideoCallClient (createClient returns
237
+ // it; the provider owns the audio session). start()/stop() stay no-ops here because the
238
+ // provider already brackets the call's audio, but setSpeaker DELEGATES to the provider's
239
+ // real AudioRoute (audit #25) so the in-call speaker toggle reaches the live audio route
240
+ // rather than a stub. onEnd routes back through endCall so leave/inbound-hangup flips the flag.
241
+ if (videoCallStarted && (_video === null || _video === void 0 ? void 0 : _video.socket)) {
242
+ const VideoCall = loadVideoCall();
243
+ return ((0, jsx_runtime_1.jsx)(react_native_safe_area_context_1.SafeAreaView, { testID: "webchat-surface", style: containerStyle, children: (0, jsx_runtime_1.jsx)(VideoCall, { socket: _video.socket, getSessionId: _video.getSessionId, onEnd: endCall, createClient: () => _video.client, audioRoute: { start() { }, stop() { }, setSpeaker: (on) => setSpeaker(on) }, dir: dir, theme: theme, language: language,
244
+ // Agent identity for the "connecting" placeholder (before the remote joins):
245
+ // the header title (agent/tenant name) + the configured avatar, gated by showAvatar.
246
+ agentName: headerTitle !== null && headerTitle !== void 0 ? headerTitle : avatar === null || avatar === void 0 ? void 0 : avatar.name, agentAvatarUrl: showAvatar ? avatar === null || avatar === void 0 ? void 0 : avatar.url : undefined }) }));
247
+ }
248
+ // Surface sits on the chat background (web bgChatColor / theme.chat.bg) so the
249
+ // thread area matches the message list; falls back to the surface colour.
250
+ const surfaceBg = (_f = (_e = theme === null || theme === void 0 ? void 0 : theme.chat) === null || _e === void 0 ? void 0 : _e.bg) !== null && _f !== void 0 ? _f : (_g = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _g === void 0 ? void 0 : _g.surface;
251
+ return (
252
+ // edges EXCLUDE "top": the Header owns the top safe-area inset itself
253
+ // (paddingTop: insets.top) so its brand color fills the status-bar strip. If
254
+ // this SafeAreaView also padded the top, the inset would be applied TWICE
255
+ // (surface + header) AND the strip would show the chat bg, not the header color.
256
+ (0, jsx_runtime_1.jsxs)(react_native_safe_area_context_1.SafeAreaView, { testID: "webchat-surface", edges: ["left", "right", "bottom"], style: { ...containerStyle, backgroundColor: surfaceBg }, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { ref: headerRef, accessible: true, accessibilityRole: "header", children: (0, jsx_runtime_1.jsx)(Header_1.Header, { variant: variant, dir: dir, theme: theme, title: headerTitle, subtitle: headerConfig.subTitle, language: language, onClose: onHeaderClose, showClose: showClose, connected: connected, statusIndicator: headerConfig.statusIndicator !== false,
257
+ // Connecting banner (web parity): show the configured connectingText
258
+ // while not yet connected (the Header hides it once connected).
259
+ connectingText: headerConfig.connectingText,
260
+ // Agent avatar + status dot (item 25) and custom close image (item 29).
261
+ avatar: avatar, showAvatar: showAvatar, closeImage: closeImage,
262
+ // Video-call button lives INSIDE the header now (web parity). Wired only
263
+ // when video is enabled and the thread (not prechat) is showing; pressing
264
+ // it starts the call exactly like the old floating button did.
265
+ onStartCall: videoEnabled && !showPrechat ? startCall : undefined }) }), showPrechat ? ((0, jsx_runtime_1.jsx)(PrechatForm_1.PrechatForm, { fields: effectivePrechatFields, onSubmit: onPrechatSubmit, dir: dir, theme: theme, language: language, headline: prechatHeadline, submitLabel: prechatSubmitLabel })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(MessageList_1.MessageList, { messages: messages, dir: dir, theme: theme, audioAdapter: audioAdapter, showMessageDate: showMessageDate, showTyping: showTyping, showAvatar: showAvatar, avatar: avatar }), showPoweredBy ? (0, jsx_runtime_1.jsx)(PoweredBy_1.PoweredBy, { theme: theme }) : null, (0, jsx_runtime_1.jsx)(Composer_1.Composer, { onSend: send, onPickImages: onPickImages, onPickDocuments: onPickDocuments, onPickVideos: onPickVideos, onAttach: attach, audioAdapter: recordAudioAdapter, disabledInput: inputDisabled, dir: dir, theme: theme, language: language,
266
+ // web config.emoji gates the emoji affordance (default false per web).
267
+ emojiEnabled: (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.emoji) === true,
268
+ // web config.inputTextFieldHint overrides the composer placeholder.
269
+ placeholder: headerConfig.inputTextFieldHint })] }))] }));
270
+ }
271
+ // Bridges the imperative handle into provider state (clearHistory/resetSession/unread).
272
+ function ImperativeBridge({ handleRef, setOpen }) {
273
+ const { send, clearHistory, resetSession, unread } = (0, useWebchat_1.useWebchat)();
274
+ (0, react_1.useImperativeHandle)(handleRef, () => ({
275
+ open: () => setOpen(true),
276
+ close: () => setOpen(false),
277
+ sendMessage: (t) => send(t),
278
+ getUnreadCount: () => unread, // real value (B19/B20)
279
+ clearHistory, // real purge (B19)
280
+ resetSession, // real reset (B19)
281
+ }), [send, clearHistory, resetSession, unread]);
282
+ return null;
283
+ }
284
+ // Default FAB launcher. Reads theme/dir from context (NOT props) so the brand color and
285
+ // logical-end placement follow the resolved config: the FAB anchors to the logical end
286
+ // (left under RTL) instead of a hardcoded 'right'.
287
+ function Launcher({ renderLauncher, setOpen }) {
288
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
289
+ const { theme, dir, language, resolvedConfig, status, wasConnected } = (0, useWebchat_1.useWebchat)();
290
+ const d = (0, dir_1.dirStyles)(dir);
291
+ const t = (0, i18n_1.makeT)(language);
292
+ // showButtonChat (item 27, web parity): hide the launcher FAB entirely when the
293
+ // server config explicitly disables it. Only an explicit `false` hides it (default on).
294
+ if ((resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.showButtonChat) === false)
295
+ return null;
296
+ // hideWhenNotConnected (item 34): hide the launcher ONLY after a real
297
+ // connect-then-disconnect (reached "up" once, then dropped). NEVER on the initial
298
+ // pre-connect (wasConnected false) — the user must still be able to tap to connect.
299
+ // Default off (opt-in). Mirrors the Surface gate so launcher + surface hide together.
300
+ const connected = status === "connected" || status === "session-confirmed";
301
+ if ((resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.hideWhenNotConnected) === true && wasConnected && !connected)
302
+ return null;
303
+ if (renderLauncher)
304
+ return renderLauncher({ open: () => setOpen(true) });
305
+ // Launcher dimensions/colors come from the launcher token group (web
306
+ // wedgitSectionStyle). Defaults match the historical hardcoded look (56×56,
307
+ // r28, primary bg, white icon) so an unset config keeps today's launcher.
308
+ const launcher = (_a = theme === null || theme === void 0 ? void 0 : theme.launcher) !== null && _a !== void 0 ? _a : {};
309
+ const buttonBg = (_b = launcher.buttonBg) !== null && _b !== void 0 ? _b : theme.colors.primary;
310
+ const buttonColor = (_c = launcher.buttonColor) !== null && _c !== void 0 ? _c : "#fff";
311
+ // Dimensions are typed number|string on the token (web allows e.g. "56px");
312
+ // RN's DimensionValue is stricter, so cast at the style boundary.
313
+ const buttonWidth = ((_d = launcher.buttonWidth) !== null && _d !== void 0 ? _d : 56);
314
+ const buttonHeight = ((_e = launcher.buttonHeight) !== null && _e !== void 0 ? _e : 56);
315
+ const buttonBorderRadius = ((_f = launcher.buttonBorderRadius) !== null && _f !== void 0 ? _f : 28);
316
+ // Custom launcher image (item 29, web `openLauncherImage`/`closeLauncherImage`):
317
+ // when an open-launcher image url is configured, render it as an <Image> instead
318
+ // of the built-in chat Icon (the closed/minimized variant maps to closeLauncherImage).
319
+ // Closed surface => the launcher is the "open" affordance, so prefer openLauncherImage;
320
+ // fall back to closeLauncherImage, then the Icon.
321
+ const launcherImageUrl = (_h = (_g = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.openLauncherImage) === null || _g === void 0 ? void 0 : _g.url) !== null && _h !== void 0 ? _h : (_j = resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.closeLauncherImage) === null || _j === void 0 ? void 0 : _j.url;
322
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "webchat-launcher", accessibilityRole: "button", accessibilityLabel: t("open_chat"), onPress: () => setOpen(true), style: {
323
+ position: "absolute",
324
+ bottom: 24,
325
+ right: d.isRtl ? undefined : 24,
326
+ left: d.isRtl ? 24 : undefined,
327
+ backgroundColor: launcherImageUrl ? undefined : buttonBg,
328
+ borderRadius: buttonBorderRadius,
329
+ width: buttonWidth,
330
+ height: buttonHeight,
331
+ alignItems: "center",
332
+ justifyContent: "center",
333
+ overflow: "hidden",
334
+ }, children: launcherImageUrl ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { testID: "webchat-launcher-image", accessibilityRole: "image", source: { uri: launcherImageUrl }, resizeMode: "cover", style: { width: buttonWidth, height: buttonHeight, borderRadius: buttonBorderRadius } })) : ((0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "chat", color: buttonColor, size: 26 })) }));
335
+ }
336
+ exports.WebChat = (0, react_1.forwardRef)(function WebChat(props, ref) {
337
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
338
+ const { config, renderLauncher, onOpen, onClose, title, prechatFields, prechatHeadline, prechatSubmitLabel, } = props;
339
+ const [open, setOpenState] = (0, react_1.useState)((_a = props.open) !== null && _a !== void 0 ? _a : false);
340
+ const [prechatDone, setPrechatDone] = (0, react_1.useState)(false);
341
+ const [prechatValues, setPrechatValues] = (0, react_1.useState)(props.prechatValues);
342
+ const setOpen = (v) => {
343
+ setOpenState(v);
344
+ v ? onOpen === null || onOpen === void 0 ? void 0 : onOpen() : onClose === null || onClose === void 0 ? void 0 : onClose();
345
+ };
346
+ const onPrechatSubmit = (values) => {
347
+ setPrechatValues(values);
348
+ setPrechatDone(true);
349
+ };
350
+ // BATTERIES-INCLUDED defaults (zero-adapter path): build each default adapter
351
+ // ONCE per mount and let an injected prop (or the `_`-prefixed test seam) ALWAYS
352
+ // win (`prop ?? default`). Each create*() lazily requires its Expo peer in
353
+ // try/catch and returns `undefined` when absent — so a host without the peer
354
+ // degrades exactly like the absent-prop case (attach row hidden, recorder
355
+ // suppressed, throwing-reader fallback). Memoized so the factories aren't rebuilt
356
+ // every render (and so the peer require runs at most once per mount).
357
+ const defaultPicker = (0, react_1.useMemo)(() => (0, expoDefaults_1.createExpoPicker)(), []);
358
+ const defaultAudioAdapter = (0, react_1.useMemo)(() => (0, expoDefaults_1.createExpoAudioAdapter)(), []);
359
+ const defaultReadBase64 = (0, react_1.useMemo)(() => (0, expoDefaults_1.createExpoReadBase64)(), []);
360
+ // webrtcFactory already defaults to reactNativeWebRTCFactory() lazily inside the
361
+ // provider's startCall; we thread the memoized real factory here too so the
362
+ // default is explicit at the public seam (a host/test prop still wins).
363
+ const defaultWebrtcFactory = (0, react_1.useMemo)(() => (0, webrtc_1.reactNativeWebRTCFactory)(), []);
364
+ const picker = (_c = (_b = props._picker) !== null && _b !== void 0 ? _b : props.picker) !== null && _c !== void 0 ? _c : defaultPicker;
365
+ const audioAdapter = (_e = (_d = props._audioAdapter) !== null && _d !== void 0 ? _d : props.audioAdapter) !== null && _e !== void 0 ? _e : defaultAudioAdapter;
366
+ const readBase64 = (_g = (_f = props._readBase64) !== null && _f !== void 0 ? _f : props.readBase64) !== null && _g !== void 0 ? _g : defaultReadBase64;
367
+ const webrtcFactory = (_j = (_h = props._webrtcFactory) !== null && _h !== void 0 ? _h : props.webrtcFactory) !== null && _j !== void 0 ? _j : defaultWebrtcFactory;
368
+ // Provider always mounts so the socket can stay connected while closed (S7) and the
369
+ // imperative handle/callbacks remain live. Surface toggles on open. theme/dir/language
370
+ // are owned by the provider (context contract) — WebChat no longer carries hardcoded
371
+ // theme/dir defaults; consumers read them from useWebchat().
372
+ return ((0, jsx_runtime_1.jsxs)(WebchatProvider_1.WebchatProvider, { config: config, socketFactory: (_k = props._socketFactory) !== null && _k !== void 0 ? _k : ((cfg) => (0, socketFactory_1.createSocket)(cfg.connectionUrl, cfg.channelId, cfg.debug)), store: props._store,
373
+ // config-fetch seam: `_fetch` (tests) or a host-provided `fetchImpl`; the provider
374
+ // falls back to the ambient global fetch when neither is set.
375
+ fetchImpl: (_l = props._fetch) !== null && _l !== void 0 ? _l : props.fetchImpl, onMessageReceived: props.onMessageReceived, onConnected: props.onConnected, onDisconnected: props.onDisconnected, onError: props.onError, onUnreadChange: props.onUnreadChange, prechatValues: prechatValues,
376
+ // Host-prop prechat fields present — lets the provider know a form will collect
377
+ // data (so it defers the greet until submit), alongside server prechatForm.enabled.
378
+ hasPrechatFields: Array.isArray(prechatFields) && prechatFields.length > 0,
379
+ // Web parity (!sessionId gate): a session reset re-presents the prechat form.
380
+ // resetSession() fires this so the one-time prechatDone latch is cleared and the
381
+ // next (fresh) session shows the form again instead of dropping into an empty thread.
382
+ onSessionReset: () => {
383
+ setPrechatDone(false);
384
+ setPrechatValues(undefined);
385
+ }, readBase64: readBase64, mediaLimits: props.mediaLimits, webrtcFactory: webrtcFactory, audioRoute: (_m = props._audioRoute) !== null && _m !== void 0 ? _m : props.audioRoute, createVideoClient: props._createVideoClient, audioAdapter: audioAdapter, children: [(0, jsx_runtime_1.jsx)(ImperativeBridge, { handleRef: ref, setOpen: setOpen }), open ? ((0, jsx_runtime_1.jsx)(Surface, { config: config, title: title, prechatFields: prechatFields, prechatHeadline: prechatHeadline, prechatSubmitLabel: prechatSubmitLabel, onPrechatSubmit: onPrechatSubmit, prechatDone: prechatDone, picker: picker, audioAdapter: audioAdapter, secureScreen: props.secureScreen, disabledInput: props.disabledInput, onClose: () => setOpen(false) })) : ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { flex: 1 }, pointerEvents: "box-none", children: (0, jsx_runtime_1.jsx)(Launcher, { renderLauncher: renderLauncher, setOpen: setOpen }) }))] }));
386
+ });
@@ -0,0 +1 @@
1
+ export declare function openLink(href: string): Promise<any>;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openLink = openLink;
4
+ // src/ui/openLink.ts — the ONE shared scheme gate (reused by Phase 2 attachment-open too)
5
+ const react_native_1 = require("react-native");
6
+ async function openLink(href) {
7
+ if (/^https:\/\//.test(href))
8
+ return react_native_1.Linking.openURL(href); // safe: open
9
+ if (/^(mailto:|tel:)/.test(href)) { // confirm first
10
+ return new Promise((resolve) => react_native_1.Alert.alert("Open link?", href, [
11
+ { text: "Cancel", style: "cancel", onPress: () => resolve() },
12
+ { text: "Open", onPress: () => { react_native_1.Linking.openURL(href); resolve(); } },
13
+ ]));
14
+ }
15
+ return; // reject ftp:/file:/intent:/custom — bot text is untrusted (B13)
16
+ }
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@experiaapp/webchat-react-native",
3
+ "version": "2.0.1",
4
+ "description": "Experia App live-chat SDK for React Native — drop a real-time support channel (chat, media & video calls) into your app so your customers can reach you with ease.",
5
+ "license": "UNLICENSED",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "react-native": "lib/index.js",
9
+ "files": [
10
+ "lib",
11
+ "app.plugin.js"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "typecheck": "tsc --noEmit",
21
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-assets.js",
22
+ "prepack": "npm run build",
23
+ "test": "jest",
24
+ "test:core": "jest src/core src/theme",
25
+ "setup:example": "npm run build && npm pack && node -e \"const v=require('./package.json').version;require('fs').copyFileSync('experiaapp-webchat-react-native-'+v+'.tgz','example/webchat-sdk.tgz')\" && cd example && npm install ./webchat-sdk.tgz",
26
+ "smoke:live": "npm run build && node scripts/live-smoke.js",
27
+ "sync:example": "npm run build && node scripts/sync-example.js"
28
+ },
29
+ "dependencies": {
30
+ "expo-audio": "~56.0.12",
31
+ "expo-font": "~56.0.5",
32
+ "expo-video": "~56.1.4",
33
+ "i18next": "^26.3.1",
34
+ "socket.io-client": "2.5.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@react-native-async-storage/async-storage": ">=1.21",
38
+ "react": ">=18",
39
+ "react-native": ">=0.74",
40
+ "react-native-incall-manager": ">=4",
41
+ "react-native-localize": ">=3",
42
+ "react-native-safe-area-context": ">=4",
43
+ "react-native-webrtc": ">=124",
44
+ "react-native-svg": ">=13",
45
+ "@react-native-community/netinfo": ">=11",
46
+ "expo-image-picker": ">=14",
47
+ "expo-document-picker": ">=11",
48
+ "expo-file-system": ">=15"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "react-native-webrtc": {
52
+ "optional": true
53
+ },
54
+ "react-native-incall-manager": {
55
+ "optional": true
56
+ },
57
+ "react-native-svg": {
58
+ "optional": true
59
+ },
60
+ "@react-native-community/netinfo": {
61
+ "optional": true
62
+ },
63
+ "expo-image-picker": {
64
+ "optional": true
65
+ },
66
+ "expo-document-picker": {
67
+ "optional": true
68
+ },
69
+ "expo-file-system": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "//peerNote": "The expo-* peers above are OPTIONAL and auto-wired by <WebChat> when installed. In a bare React Native app (no Expo prebuild) they require the expo-modules-core runtime: run `npx install-expo-modules` once so the autolinked native side exists. Expo-managed / dev-client apps already have it. Pin SDK-correct versions with `npx expo install`.",
74
+ "devDependencies": {
75
+ "@react-native-async-storage/async-storage": "^3.1.1",
76
+ "@react-native-community/netinfo": "^12.0.1",
77
+ "@react-native/babel-preset": "^0.85.3",
78
+ "@react-native/jest-preset": "^0.85.3",
79
+ "@testing-library/react-native": "^13.3.3",
80
+ "@types/jest": "^29.5.12",
81
+ "@types/node": "^20.11.30",
82
+ "@types/react": "~19.0.0",
83
+ "jest": "^29.7.0",
84
+ "react": "^19.2.3",
85
+ "react-native": "^0.85.3",
86
+ "react-native-incall-manager": "^4.2.1",
87
+ "react-native-localize": "^3.7.0",
88
+ "react-native-safe-area-context": "^5.8.0",
89
+ "react-native-webrtc": "^124.0.7",
90
+ "react-test-renderer": "^19.2.3",
91
+ "ts-jest": "^29.1.2",
92
+ "typescript": "^5.4.5"
93
+ }
94
+ }