@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,906 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WebchatContext = void 0;
7
+ exports.WebchatProvider = WebchatProvider;
8
+ const jsx_runtime_1 = require("react/jsx-runtime");
9
+ // src/state/WebchatProvider.tsx
10
+ const react_1 = require("react");
11
+ const react_native_1 = require("react-native");
12
+ const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
13
+ const messagesReducer_1 = require("../core/messagesReducer");
14
+ const WebchatClient_1 = require("../core/WebchatClient");
15
+ const persistence_1 = require("../core/persistence");
16
+ const unread_1 = require("../core/unread");
17
+ const greet_1 = require("../core/greet");
18
+ const types_1 = require("../core/types");
19
+ const configClient_1 = require("../core/configClient");
20
+ const themeFactory_1 = require("../theme/themeFactory");
21
+ const expoDefaults_1 = require("../adapters/expoDefaults");
22
+ const media_1 = require("../core/media");
23
+ const picker_1 = require("../adapters/picker");
24
+ const VideoCallClient_1 = require("../core/VideoCallClient");
25
+ exports.WebchatContext = (0, react_1.createContext)(null);
26
+ const DAY_MS = 24 * 60 * 60 * 1000;
27
+ /**
28
+ * Resolve the localize default language without statically importing
29
+ * `react-native-localize` (an optional native peer). Lazy-required so chat-only
30
+ * consumers without the lib — and tests that don't mock it — never load it. Any
31
+ * failure (lib absent / native module not linked) falls back to 'en'.
32
+ */
33
+ function localizeDefaultLanguage() {
34
+ var _a, _b;
35
+ try {
36
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
37
+ const localize = require("react-native-localize");
38
+ const code = (_b = (_a = localize.getLocales) === null || _a === void 0 ? void 0 : _a.call(localize)[0]) === null || _b === void 0 ? void 0 : _b.languageCode;
39
+ return code === "ar" ? "ar" : "en";
40
+ }
41
+ catch {
42
+ return "en";
43
+ }
44
+ }
45
+ /** Lazy-require initI18n (pulls react-native-localize); never throws into the provider. */
46
+ function safeInitI18n(language) {
47
+ try {
48
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
49
+ require("../i18n").initI18n(language);
50
+ }
51
+ catch {
52
+ /* i18n is best-effort; the lib/native module may be absent */
53
+ }
54
+ }
55
+ function WebchatProvider(props) {
56
+ var _a;
57
+ // store: a host-injected KeyValueStore ALWAYS wins (tests inject a MemoryStore).
58
+ // When the host injects NONE, the backend is config-driven (Tier C #33, web
59
+ // `config.storage`): "sessionStorage"/"memory" -> an ephemeral MemoryStore (history
60
+ // does NOT survive a relaunch), "localStorage" (default) -> the persistent
61
+ // AsyncStorage store (24h sliding TTL). Resolved ONCE per mount via selectStore so
62
+ // the ephemeral path doesn't churn a new Map every render. `injectedStore` is left
63
+ // undefined (not defaulted) so selectStore can tell host-injected from absent.
64
+ const { config, socketFactory, store: injectedStore, now = () => Date.now(), children,
65
+ // #1/#2 config fetch: inject a fetch for tests; default to the ambient global
66
+ // inside fetchConfig. With no `configUrl` we skip the fetch and use the prop config.
67
+ fetchImpl,
68
+ // Phase 2 injectables — default to identity/limits so nothing native is touched
69
+ // unless the host wires a real picker/reader. `readBase64` turns an asset uri into
70
+ // raw base64 (eager `asset.base64` short-circuits it); `mediaLimits` overrides caps.
71
+ readBase64, mediaLimits = media_1.DEFAULT_LIMITS,
72
+ // Phase 3 video seams (Group E) — ALL optional and lazily resolved inside startCall,
73
+ // so non-video flows (and every non-video test) never touch a native module:
74
+ // - `webrtcFactory` defaults to the real react-native-webrtc factory at call time.
75
+ // - `audioRoute` defaults to the real react-native-incall-manager wrapper.
76
+ // - `createVideoClient` defaults to the real createVideoCallClient.
77
+ // Tests inject fakes so startCall/endCall are exercised with no native calls.
78
+ webrtcFactory, audioRoute, createVideoClient = VideoCallClient_1.createVideoCallClient,
79
+ // Received-voice playback seam (audit #23): the injected AudioAdapter the live
80
+ // Bubble uses to render an inline <VoiceMessage> for audio attachments. Optional
81
+ // (chat-only consumers never wire it); exposed on context so the Surface/MessageList
82
+ // don't have to thread it as a prop.
83
+ audioAdapter,
84
+ // Tenant custom-font loader (item 28, web `publicStyle.defaultFontFamily`).
85
+ // Lazily resolved like the other expo seams: defaults to createExpoFontLoader()
86
+ // which returns `undefined` when expo-font is absent (Metro/jest never break),
87
+ // so the require is the only touch of the native module and only when a tenant
88
+ // font is actually configured. Tests inject a fake loader. When the loader is
89
+ // undefined (no expo-font) OR no font is configured, fonts simply never load and
90
+ // every component keeps the system font (current behaviour).
91
+ fontLoader = (0, expoDefaults_1.createExpoFontLoader)(), } = props;
92
+ // Resolve the persistence store ONCE per mount (Tier C #33). A host-injected store
93
+ // wins; otherwise config.storage selects ephemeral (sessionStorage/memory) vs the
94
+ // persistent AsyncStorage backend. config.storage is read off the prop config (the
95
+ // backend choice is stable from mount and the server fetch can't re-bind a live
96
+ // store). useRef keeps the ephemeral MemoryStore identity stable across renders.
97
+ const storeRef = (0, react_1.useRef)((0, persistence_1.selectStore)(injectedStore, config === null || config === void 0 ? void 0 : config.storage, async_storage_1.default));
98
+ const store = storeRef.current;
99
+ const [state, dispatch] = (0, react_1.useReducer)(messagesReducer_1.messagesReducer, messagesReducer_1.initialState);
100
+ const [status, setStatus] = (0, react_1.useState)("idle");
101
+ const [isStorageReady, setStorageReady] = (0, react_1.useState)(false); // B9: hard precondition gate
102
+ // Item 34 — latched once we first reach a CONFIRMED session ("session-confirmed");
103
+ // never resets. Gates hideWhenNotConnected so we only hide AFTER a real
104
+ // established-then-lost drop. We latch on session-confirmed (not bare transport
105
+ // "connected") so the initial handshake window (connect -> session-pending) is NOT
106
+ // treated as "previously connected" — otherwise the surface/launcher would vanish
107
+ // mid-handshake before the user ever had a usable connection.
108
+ const [wasConnected, setWasConnected] = (0, react_1.useState)(false);
109
+ // #1/#2: resolvedConfig/theme/dir/language. Start from the merged prop+default so the
110
+ // very first render (pre-fetch) already has a coherent theme/dir; the server merge
111
+ // refines it once the fetch resolves.
112
+ const [resolved, setResolved] = (0, react_1.useState)(() => {
113
+ var _a, _b, _c, _d, _e, _f, _g;
114
+ const merged = (0, configClient_1.mergeConfig)(config, null);
115
+ const language = ((_b = (_a = config.language) !== null && _a !== void 0 ? _a : merged.language) !== null && _b !== void 0 ? _b : "en");
116
+ const variant = (0, themeFactory_1.resolveVariant)(merged.interfaceTheme);
117
+ return {
118
+ config: merged,
119
+ language,
120
+ dir: (language === "ar" ? "rtl" : "ltr"),
121
+ variant,
122
+ // First render uses NO loaded font map (undefined) => theme.font.family stays
123
+ // undefined and every component renders with the system font. The brand fonts
124
+ // load asynchronously (best-effort, below) and rebuild the theme when they
125
+ // resolve — an accepted FOUT (flash of system text, then the brand font).
126
+ theme: (0, themeFactory_1.buildTheme)(variant, (_c = merged.publicStyle) !== null && _c !== void 0 ? _c : {}, (_d = merged.chatSectionStyle) !== null && _d !== void 0 ? _d : {}, (_e = merged.headerSectionStyle) !== null && _e !== void 0 ? _e : {}, (_f = merged.sendSectionStyle) !== null && _f !== void 0 ? _f : {}, (_g = merged.wedgitSectionStyle) !== null && _g !== void 0 ? _g : {}),
127
+ };
128
+ });
129
+ // item 28 — loaded weight->family map from the brand-font loader. `undefined`
130
+ // until the tenant fonts resolve (and forever when none are configured / expo-font
131
+ // is absent); once set it is fed into buildTheme so theme.font.family/familyMap
132
+ // become defined. NEVER blocks render — the theme rebuilds in a later commit.
133
+ const [loadedFonts, setLoadedFonts] = (0, react_1.useState)(undefined);
134
+ const sessionId = (0, react_1.useRef)("");
135
+ const connectedRef = (0, react_1.useRef)(false);
136
+ // Always-current mirrors so persistence/handlers running in stale effect closures
137
+ // (greet on session_confirm) read the latest values, not the mount-render snapshot.
138
+ const messagesRef = (0, react_1.useRef)(state.messages);
139
+ messagesRef.current = state.messages;
140
+ const lastReadRef = (0, react_1.useRef)(state.lastReadAt);
141
+ lastReadRef.current = state.lastReadAt;
142
+ // S7: a chat that is mounted with no explicit `open` control stays connected so it can
143
+ // receive while closed (the host toggles surface visibility). Only a deliberately-passed
144
+ // `open={false}` (with autoConnection off) keeps the socket closed.
145
+ const openRef = (0, react_1.useRef)(props.open !== undefined ? Boolean(props.open) : true);
146
+ const lastActivity = (0, react_1.useRef)(now()); // #16 sliding TTL anchor
147
+ const ttlRef = (0, react_1.useRef)(DAY_MS); // #16: refined from resolvedConfig
148
+ const pending = (0, react_1.useRef)([]); // B10: sends queued while disconnected
149
+ const greeted = (0, react_1.useRef)(new Set()); // C3/#17: per-session greet dedup
150
+ const client = (0, react_1.useRef)(null);
151
+ const mediaKeys = (0, react_1.useRef)(new Set()); // P2: outgoing media messageKeys awaiting an UPDATE_MSG echo
152
+ const queuedMedia = (0, react_1.useRef)(new Map()); // P2: media[] for sends queued while disconnected
153
+ const recvSeq = (0, react_1.useRef)(0); // monotonic suffix so received-message keys never collide within a ms
154
+ // resolvedConfig snapshot for callbacks that run outside render (AppState/netinfo).
155
+ const cfgRef = (0, react_1.useRef)(resolved.config);
156
+ cfgRef.current = resolved.config;
157
+ const lastUnread = (0, react_1.useRef)(0); // #4: change-detection for onUnreadChange
158
+ // P3 video: the live chat socket instance (captured below by wrapping socketFactory),
159
+ // reused by the VideoCallClient so the call rides the EXISTING connection (no second socket).
160
+ const socketRef = (0, react_1.useRef)(null);
161
+ const videoClient = (0, react_1.useRef)(null);
162
+ const audioRouteRef = (0, react_1.useRef)(null);
163
+ const [videoCallStarted, setVideoCallStarted] = (0, react_1.useState)(false);
164
+ // --- Prechat / greet coordination (web parity) -----------------------------
165
+ // The greet (/chitchat.greet) carries the prechat form data. To match web — which
166
+ // defers the session/greet until the form is submitted, then sends the collected
167
+ // fields as `prechatFormSubmission` (ChatContainer.tsx:167-185) — we DEFER the greet
168
+ // while a prechat form is showing and unsubmitted, then send it (with the data) once
169
+ // the values arrive. A RESUMED session never shows the form, so it greets normally
170
+ // (and is already in `greeted`, so shouldGreet short-circuits anyway).
171
+ const [resumedSession, setResumedSession] = (0, react_1.useState)(false); // reactive (UI gate)
172
+ const resumedSessionRef = (0, react_1.useRef)(false); // sync (greet handler)
173
+ const prechatSubmittedRef = (0, react_1.useRef)(false); // values arrived this session
174
+ const pendingGreetIdRef = (0, react_1.useRef)(null); // confirmed id awaiting a deferred greet
175
+ // Live prechat values (props.prechatValues is captured stale in the mount-effect
176
+ // socket handler; a ref keeps the greet reading the CURRENT submitted values).
177
+ const prechatValuesRef = (0, react_1.useRef)(props.prechatValues);
178
+ prechatValuesRef.current = props.prechatValues;
179
+ // configReady: gate the greet until the server config has resolved when a configUrl
180
+ // is actually fetched — otherwise a server-driven prechatForm.enabled could arrive
181
+ // AFTER session_confirm and the greet would already have fired without the form.
182
+ // No real fetch (no configUrl / no fetch impl) => ready immediately (offline/tests).
183
+ const configReadyRef = (0, react_1.useRef)(!((config === null || config === void 0 ? void 0 : config.configUrl) && (fetchImpl !== null && fetchImpl !== void 0 ? fetchImpl : globalThis.fetch)));
184
+ // Defer the greet while a prechat form will collect data first: enabled (server
185
+ // config OR host fields), a NEW (non-resumed) session, not yet submitted — or while
186
+ // the server config is still loading and could turn the form on. Recomputed each
187
+ // render into a ref so the mount-effect socket handler reads the CURRENT decision.
188
+ const prechatEnabled = ((_a = resolved.config.prechatForm) === null || _a === void 0 ? void 0 : _a.enabled) === true || props.hasPrechatFields === true;
189
+ const deferGreetRef = (0, react_1.useRef)(false);
190
+ deferGreetRef.current =
191
+ !resumedSessionRef.current &&
192
+ !prechatSubmittedRef.current &&
193
+ (prechatEnabled || !configReadyRef.current);
194
+ // #16: anything that counts as activity refreshes the sliding-TTL anchor + persists it
195
+ // so the window slides from last use, not only from the last new message.
196
+ const touchActivity = (0, react_1.useCallback)(() => { lastActivity.current = now(); }, [now]);
197
+ // Whether the socket should be connected right now (S7/S8): the master switch must be on,
198
+ // and either we auto-connect or the surface is open.
199
+ const shouldConnect = (0, react_1.useCallback)(() => {
200
+ const c = cfgRef.current;
201
+ if (c.enableConnection === false)
202
+ return false; // master switch off -> never connect
203
+ return c.autoConnection === true || openRef.current; // auto, or open-gated
204
+ }, []);
205
+ // --- Config fetch + merge (#1/#2) ------------------------------------------
206
+ // Runs once on mount (channel-keyed). Fetches the server config when a configUrl is
207
+ // present + a fetch is available (injected for tests; default to ambient inside the
208
+ // client), merges prop>server>default, and recomputes theme/dir/language/variant.
209
+ (0, react_1.useEffect)(() => {
210
+ let cancelled = false;
211
+ (async () => {
212
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
213
+ let server = null;
214
+ // Fetch the server config only when a fetch is explicitly injected (the documented
215
+ // test seam) or the host opts into ambient-fetch with `useAmbientFetch`. Without
216
+ // either we default to the prop+default config (no network) — keeping unit tests
217
+ // and chat-only embeds offline.
218
+ // Fetch the server config by DEFAULT (the whole point of configUrl): use an
219
+ // injected fetch in tests, else the ambient global fetch (present in RN + node).
220
+ // Tests stay offline via a jest stub that rejects fetch (see jest.setup.ui.js).
221
+ const doFetch = fetchImpl !== null && fetchImpl !== void 0 ? fetchImpl : globalThis.fetch;
222
+ if (config.configUrl && doFetch) {
223
+ try {
224
+ server = await (0, configClient_1.fetchConfig)(config.configUrl, config.channelId, doFetch);
225
+ }
226
+ catch (e) {
227
+ (_a = props.onError) === null || _a === void 0 ? void 0 : _a.call(props, e); // recoverable config-fetch error (C5); fall back to prop+default
228
+ }
229
+ }
230
+ if (cancelled)
231
+ return;
232
+ const merged = (0, configClient_1.mergeConfig)(config, server);
233
+ // language precedence: prop > server config > localize default
234
+ const language = ((_c = (_b = config.language) !== null && _b !== void 0 ? _b : merged.language) !== null && _c !== void 0 ? _c : localizeDefaultLanguage());
235
+ merged.language = language;
236
+ const variant = (0, themeFactory_1.resolveVariant)(merged.interfaceTheme);
237
+ ttlRef.current = (_d = merged.ttlMs) !== null && _d !== void 0 ? _d : DAY_MS; // #16
238
+ setResolved({
239
+ config: merged,
240
+ language,
241
+ dir: (language === "ar" ? "rtl" : "ltr"),
242
+ variant,
243
+ // Build with the CURRENT loaded map (undefined on first resolve => system
244
+ // font). If the fonts have already loaded by a later config refresh the map
245
+ // is reused; the separate font-load effect below rebuilds the theme when the
246
+ // map first arrives.
247
+ theme: (0, themeFactory_1.buildTheme)(variant, (_e = merged.publicStyle) !== null && _e !== void 0 ? _e : {}, (_f = merged.chatSectionStyle) !== null && _f !== void 0 ? _f : {}, (_g = merged.headerSectionStyle) !== null && _g !== void 0 ? _g : {}, (_h = merged.sendSectionStyle) !== null && _h !== void 0 ? _h : {}, (_j = merged.wedgitSectionStyle) !== null && _j !== void 0 ? _j : {}, loadedFonts),
248
+ });
249
+ safeInitI18n(language); // swap browser detector for explicit/localize language (C23)
250
+ // Config is now resolved — the prechat decision (server prechatForm.enabled) is
251
+ // known. Release any greet deferred purely on "config still loading": decide from
252
+ // the freshly-merged config (deferGreetRef still holds the pre-resolve render's
253
+ // value here). If no form is needed, fire the pending greet now.
254
+ configReadyRef.current = true;
255
+ const stillDefer = !resumedSessionRef.current &&
256
+ !prechatSubmittedRef.current &&
257
+ (((_k = merged.prechatForm) === null || _k === void 0 ? void 0 : _k.enabled) === true || props.hasPrechatFields === true);
258
+ const pendingId = pendingGreetIdRef.current;
259
+ if (pendingId && !stillDefer) {
260
+ pendingGreetIdRef.current = null;
261
+ sendGreet(pendingId);
262
+ }
263
+ // item 28 — load tenant custom fonts best-effort, AFTER the theme is set with
264
+ // the system font (so the FIRST render never waits on the network — accepted
265
+ // FOUT). Only when a loader exists (expo-font present) AND the tenant actually
266
+ // shipped a `defaultFontFamily.fonts[]`. The loader tolerates per-weight
267
+ // failures and resolves a weight->family map; an empty map / any failure leaves
268
+ // the system font. NEVER throws into the provider.
269
+ const fontFamily = (_l = merged.publicStyle) === null || _l === void 0 ? void 0 : _l.defaultFontFamily;
270
+ if (fontLoader && (fontFamily === null || fontFamily === void 0 ? void 0 : fontFamily.fonts) && fontFamily.fonts.length > 0) {
271
+ // fire-and-forget: do NOT await — render proceeds with the system font and the
272
+ // theme rebuilds in a later commit once this resolves (the font-load effect).
273
+ void (async () => {
274
+ try {
275
+ const map = await fontLoader(fontFamily);
276
+ if (cancelled)
277
+ return;
278
+ // Only promote to a defined family when at least one weight loaded.
279
+ if (map && Object.keys(map).length > 0)
280
+ setLoadedFonts(map);
281
+ }
282
+ catch {
283
+ /* loader is best-effort (it already tolerates per-weight failures);
284
+ a total failure leaves the system font — never throw. */
285
+ }
286
+ })();
287
+ }
288
+ })();
289
+ return () => { cancelled = true; };
290
+ // eslint-disable-next-line react-hooks/exhaustive-deps
291
+ }, [config.channelId]);
292
+ // item 28 — when the brand fonts resolve (loadedFonts goes from undefined to a
293
+ // non-empty map), rebuild the theme so theme.font.family/familyMap become defined
294
+ // and every component picks up the loaded family for the weight it renders. Runs in
295
+ // its OWN commit (after the system-font render) — the accepted FOUT. A no-op while
296
+ // loadedFonts is undefined (the common case: no tenant font / expo-font absent).
297
+ (0, react_1.useEffect)(() => {
298
+ if (!loadedFonts)
299
+ return;
300
+ setResolved((prev) => {
301
+ var _a, _b, _c, _d, _e;
302
+ const c = prev.config;
303
+ return {
304
+ ...prev,
305
+ theme: (0, themeFactory_1.buildTheme)(prev.variant, (_a = c.publicStyle) !== null && _a !== void 0 ? _a : {}, (_b = c.chatSectionStyle) !== null && _b !== void 0 ? _b : {}, (_c = c.headerSectionStyle) !== null && _c !== void 0 ? _c : {}, (_d = c.sendSectionStyle) !== null && _d !== void 0 ? _d : {}, (_e = c.wedgitSectionStyle) !== null && _e !== void 0 ? _e : {}, loadedFonts),
306
+ };
307
+ });
308
+ // eslint-disable-next-line react-hooks/exhaustive-deps
309
+ }, [loadedFonts]);
310
+ (0, react_1.useEffect)(() => {
311
+ let cancelled = false;
312
+ (async () => {
313
+ var _a, _b;
314
+ const restored = (await (0, persistence_1.loadSession)(store, config.channelId, {
315
+ now: now(), ttlMs: ttlRef.current,
316
+ }));
317
+ if (cancelled)
318
+ return;
319
+ if (restored) {
320
+ dispatch({ type: "HYDRATE", messages: restored.messages });
321
+ sessionId.current = restored.sessionId;
322
+ if (restored.sessionId) {
323
+ greeted.current.add(restored.sessionId); // restored session already greeted (C3)
324
+ // Returning user: this session is a RESUME, not a new one — suppress the
325
+ // prechat form (web `!sessionId` gate). Set the ref synchronously for the
326
+ // greet handler + the state for the UI gate (useWebchat → Surface).
327
+ resumedSessionRef.current = true;
328
+ setResumedSession(true);
329
+ }
330
+ // #17: rehydrate the explicit greetSent set so a cold start doesn't re-greet.
331
+ ((_a = restored.greetSent) !== null && _a !== void 0 ? _a : []).forEach((id) => greeted.current.add(id));
332
+ // #4: seed lastReadAt to the newest restored bot ts (restored history is NOT unread).
333
+ const newestBot = restored.messages
334
+ .filter((m) => m.sender === "response")
335
+ .reduce((acc, m) => Math.max(acc, m.timestamp), 0);
336
+ const seed = (_b = restored.lastReadAt) !== null && _b !== void 0 ? _b : newestBot;
337
+ if (seed > 0)
338
+ dispatch({ type: "MARK_READ", at: seed });
339
+ if (restored.lastActivity)
340
+ lastActivity.current = restored.lastActivity;
341
+ }
342
+ setStorageReady(true); // B9: hydration done -> safe to connect + evaluate prechat gate
343
+ // Wrap the factory so we keep a handle on the SAME socket instance the chat client
344
+ // uses (P3): a video call reuses this exact connection rather than opening a second one.
345
+ const c = (0, WebchatClient_1.createWebchatClient)({
346
+ socketFactory: () => {
347
+ const s = socketFactory(config);
348
+ socketRef.current = s;
349
+ return s;
350
+ },
351
+ getStoredSessionId: () => sessionId.current, // B8 resume by stored id on (re)connect
352
+ });
353
+ c.on("status", (s) => {
354
+ var _a, _b;
355
+ setStatus(s);
356
+ const isUp = s === "connected" || s === "session-confirmed";
357
+ connectedRef.current = isUp;
358
+ // item 34: latch only on a CONFIRMED session (not bare transport connect) so
359
+ // the initial handshake never counts as "previously connected".
360
+ if (s === "session-confirmed")
361
+ setWasConnected(true);
362
+ if (isUp) {
363
+ (_a = props.onConnected) === null || _a === void 0 ? void 0 : _a.call(props);
364
+ // flush any sends queued while disconnected (B10): emit + mark sent now.
365
+ // Media sends carry their encoded media[] (stashed by key) and DO NOT auto-ACK —
366
+ // they reconcile to 'sent' on the server UPDATE_MSG echo instead.
367
+ pending.current.splice(0).forEach((p) => {
368
+ const media = queuedMedia.current.get(p.key);
369
+ if (media) {
370
+ queuedMedia.current.delete(p.key);
371
+ emitMessage(p.key, p.text, media); // stays pending until UPDATE_MSG
372
+ }
373
+ else {
374
+ emitMessage(p.key, p.text);
375
+ dispatch({ type: "ACK", key: p.key });
376
+ }
377
+ });
378
+ }
379
+ if (s === "reconnecting" || s === "session-failed")
380
+ (_b = props.onDisconnected) === null || _b === void 0 ? void 0 : _b.call(props);
381
+ });
382
+ c.on("error", (e) => { var _a; return (_a = props.onError) === null || _a === void 0 ? void 0 : _a.call(props, e); }); // typed WebChatError from the client (B7/C5)
383
+ c.on("session", (id) => {
384
+ sessionId.current = id;
385
+ // greet only on a genuinely new session_confirm; survives cold start (C3/#17).
386
+ if (!(0, greet_1.shouldGreet)({ sessionId: id, greetedSessions: greeted.current }))
387
+ return;
388
+ // Defer when a prechat form will collect data first (web parity): the greet
389
+ // carries that data as `prechatFormSubmission`, so it must wait for submit.
390
+ // sendGreet() runs from the prechat-values effect (on submit) or the config
391
+ // effect (once config resolves and confirms no form is needed).
392
+ if (deferGreetRef.current) {
393
+ pendingGreetIdRef.current = id;
394
+ return;
395
+ }
396
+ sendGreet(id);
397
+ });
398
+ c.on("message", (m) => {
399
+ var _a, _b, _c;
400
+ // Item 26 — autoJoinVideoCall (web parity: redux/messages/actions.ts:128-138).
401
+ // When the tenant enables autoJoinVideoCall, the server signals an incoming call
402
+ // by pushing a bot message whose text is the sentinel "Start recording..."; the
403
+ // web build dispatches START_VIDEO_CALL on it. The SDK's analog is startCall().
404
+ // We auto-invoke it here, reading the flag off cfgRef (this handler runs outside
405
+ // render). startCall is idempotent (no-ops if a call is already active or the
406
+ // socket isn't wired), so this never double-starts or regresses the manual flow.
407
+ if ((m === null || m === void 0 ? void 0 : m.text) === "Start recording..." && cfgRef.current.autoJoinVideoCall === true) {
408
+ startCall();
409
+ }
410
+ // P2 reconciliation: the server echoes our own media send back tagged as the
411
+ // client sender (`sender: "client"`) carrying the HOSTED media URL — so the
412
+ // optimistic bubble can swap its heavy base64 for the lightweight URL instead of
413
+ // keeping the data URL forever (and instead of appending a duplicate bubble).
414
+ //
415
+ // The LIVE backend echo is bare — NO messageKey, NO text, e.g.
416
+ // {"media":["https://…s3…png"],"sender":"client"}
417
+ // so we cannot always match by key. Target resolution (in order):
418
+ // 1. explicit messageKey echo (if the server ever sends one) -> that exact send;
419
+ // 2. otherwise FIFO — the OLDEST still-pending media send. `mediaKeys` is a Set
420
+ // (insertion order preserved) and the server confirms sends in order over the
421
+ // single socket, so the oldest unreconciled media key is the right target.
422
+ // (Out-of-order confirmation without a key is unrecoverable, but doesn't occur
423
+ // on a single ordered socket.)
424
+ // A `sender: "client"` message is NEVER a bot message, so we always consume it here
425
+ // and never let it fall through to RECEIVE (that was the duplicate-bubble bug).
426
+ const echoKey = m === null || m === void 0 ? void 0 : m.messageKey;
427
+ const isClientEcho = (m === null || m === void 0 ? void 0 : m.sender) === "client" || (echoKey && mediaKeys.current.has(echoKey));
428
+ if (isClientEcho) {
429
+ const hasMediaEcho = Array.isArray(m === null || m === void 0 ? void 0 : m.media) ? m.media.length > 0 : Boolean(m === null || m === void 0 ? void 0 : m.mediaUrl);
430
+ // matching messageKey wins; else the oldest pending media key (FIFO).
431
+ const targetKey = echoKey && mediaKeys.current.has(echoKey)
432
+ ? echoKey
433
+ : mediaKeys.current.values().next().value;
434
+ if (targetKey && hasMediaEcho) {
435
+ mediaKeys.current.delete(targetKey);
436
+ const url = Array.isArray(m.media) ? m.media[0] : m.mediaUrl;
437
+ dispatch({
438
+ type: "UPDATE_MSG",
439
+ key: targetKey,
440
+ patch: { mediaUrl: url, media: m.media, ...(m.type !== undefined ? { type: m.type } : {}) },
441
+ });
442
+ }
443
+ // A text-only client echo (no media, nothing pending) has nothing to reconcile —
444
+ // just don't append it as a bot bubble.
445
+ (_a = props.onMessageReceived) === null || _a === void 0 ? void 0 : _a.call(props, m);
446
+ return;
447
+ }
448
+ // Preserve the server-driven reaction contract (web parity): the stable
449
+ // server id (`messageKey`, distinct from the SDK's own `key`), the per-message
450
+ // `options` (incl. `enableReaction` gating the thumbs row), and any prior
451
+ // `userReaction`. Dropping these would make the feedback feature unreachable.
452
+ dispatch({
453
+ type: "RECEIVE",
454
+ message: {
455
+ // Unique key: `now()` alone collides when the server bursts more than one
456
+ // message in the same millisecond (React "two children with the same key").
457
+ // A monotonic suffix guarantees uniqueness.
458
+ key: (_b = m.key) !== null && _b !== void 0 ? _b : `b${now()}-${(recvSeq.current += 1)}`,
459
+ sender: "response",
460
+ text: m.text,
461
+ quick_replies: m.quick_replies,
462
+ timestamp: now(),
463
+ // Preserve incoming MEDIA (web RecieverMsg parity): files[] objects and
464
+ // media[]/mediaUrl URL strings. Dropping these is why received photo /
465
+ // video / document attachments never rendered (Bubble.collectAttachments
466
+ // found nothing on the normalized message).
467
+ ...(m.files !== undefined ? { files: m.files } : {}),
468
+ ...(m.media !== undefined ? { media: m.media } : {}),
469
+ ...(m.mediaUrl !== undefined ? { mediaUrl: m.mediaUrl } : {}),
470
+ ...(m.type !== undefined ? { type: m.type } : {}),
471
+ ...(m.messageKey !== undefined ? { messageKey: m.messageKey } : {}),
472
+ ...(m.options !== undefined ? { options: m.options } : {}),
473
+ ...(m.userReaction !== undefined ? { userReaction: m.userReaction } : {}),
474
+ },
475
+ });
476
+ (_c = props.onMessageReceived) === null || _c === void 0 ? void 0 : _c.call(props, m);
477
+ });
478
+ // Section 4 — inbound feedback reflection (web parity). The server echoes a
479
+ // like/dislike toggle back on "activityAck" with { messageKey, type }. Map the
480
+ // backend verb onto the stored reaction ("like"/"dislike" set it; any toggle-off
481
+ // verb — "unlike"/"undislike" — or anything else clears it) and dispatch REACT to
482
+ // reconcile the optimistic state with the server's truth.
483
+ c.on("activityAck", (d) => {
484
+ const reaction = (d === null || d === void 0 ? void 0 : d.type) === "like" ? "like" : (d === null || d === void 0 ? void 0 : d.type) === "dislike" ? "dislike" : null;
485
+ if (d === null || d === void 0 ? void 0 : d.messageKey)
486
+ dispatch({ type: "REACT", messageKey: d.messageKey, reaction });
487
+ });
488
+ client.current = c;
489
+ if (shouldConnect())
490
+ c.connect(); // S7/S8: don't connect a deliberately-closed/disabled socket
491
+ })();
492
+ return () => { var _a; cancelled = true; endCall(); (_a = client.current) === null || _a === void 0 ? void 0 : _a.disconnect(); };
493
+ // eslint-disable-next-line react-hooks/exhaustive-deps
494
+ }, [config.channelId]);
495
+ // persist on change (base64 stripped by callers in Phase 2). Also re-persists when the
496
+ // read marker moves (#4) so lastReadAt survives a cold start for the badge baseline.
497
+ (0, react_1.useEffect)(() => {
498
+ persistSession();
499
+ // eslint-disable-next-line react-hooks/exhaustive-deps
500
+ }, [state.messages, state.lastReadAt]);
501
+ // Prechat submit (web parity): once the form values arrive, mark submitted and flush
502
+ // any greet that was deferred on session_confirm — so the greet carries the data as
503
+ // `prechatFormSubmission`. If the session hasn't confirmed yet, the deferred branch is
504
+ // cleared (deferGreetRef now false) and the session handler greets directly on confirm.
505
+ (0, react_1.useEffect)(() => {
506
+ const pv = props.prechatValues;
507
+ if (pv && Object.keys(pv).length > 0) {
508
+ prechatSubmittedRef.current = true;
509
+ const pendingId = pendingGreetIdRef.current;
510
+ if (pendingId) {
511
+ pendingGreetIdRef.current = null;
512
+ sendGreet(pendingId);
513
+ }
514
+ }
515
+ // eslint-disable-next-line react-hooks/exhaustive-deps
516
+ }, [props.prechatValues]);
517
+ // #4: notify the host whenever the unread count changes (and reset is observed on open).
518
+ const unread = (0, unread_1.unreadCount)(state.messages, state.lastReadAt);
519
+ (0, react_1.useEffect)(() => {
520
+ var _a;
521
+ if (unread !== lastUnread.current) {
522
+ lastUnread.current = unread;
523
+ (_a = props.onUnreadChange) === null || _a === void 0 ? void 0 : _a.call(props, unread);
524
+ }
525
+ // eslint-disable-next-line react-hooks/exhaustive-deps
526
+ }, [unread]);
527
+ // #4: reflect the controlled `open` prop. Opening marks-read (resets unread) + refreshes
528
+ // the sliding-TTL anchor, and connects a deliberately-opened socket if it wasn't auto.
529
+ (0, react_1.useEffect)(() => {
530
+ if (props.open === undefined)
531
+ return;
532
+ openRef.current = props.open;
533
+ if (props.open)
534
+ markReadAndTouch();
535
+ if (isStorageReady)
536
+ reconcileConnection();
537
+ // eslint-disable-next-line react-hooks/exhaustive-deps
538
+ }, [props.open, isStorageReady]);
539
+ // openOnLoad (#7/S8): once config resolves + storage is ready, an openOnLoad config
540
+ // (with no controlling `open` prop) opens the surface + connects.
541
+ (0, react_1.useEffect)(() => {
542
+ if (props.open !== undefined)
543
+ return; // controlled open wins
544
+ if (!isStorageReady)
545
+ return;
546
+ if (cfgRef.current.openOnLoad && !openRef.current) {
547
+ openRef.current = true;
548
+ markReadAndTouch();
549
+ reconcileConnection();
550
+ }
551
+ // eslint-disable-next-line react-hooks/exhaustive-deps
552
+ }, [isStorageReady, resolved.config]);
553
+ // #6: AppState + connectivity reconnect. On returning to 'active' (and on regained
554
+ // network connectivity) reconnect the socket — respecting the open/enable gates — and
555
+ // re-request the session by stored id. Sliding-TTL anchor refreshes on foreground.
556
+ (0, react_1.useEffect)(() => {
557
+ var _a;
558
+ const onForeground = () => {
559
+ markReadAndTouch(); // foreground counts as read + activity (#4/#16)
560
+ if (shouldConnect() && client.current) {
561
+ // re-establish + resume by stored id (the client re-emits session_request on connect)
562
+ client.current.disconnect();
563
+ client.current.connect();
564
+ }
565
+ };
566
+ const sub = react_native_1.AppState.addEventListener("change", (s) => {
567
+ if (s === "active")
568
+ onForeground();
569
+ });
570
+ // Lazy-require netinfo (optional peer): on regained connectivity, reconnect.
571
+ let netUnsub;
572
+ try {
573
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
574
+ const NetInfo = (_a = require("@react-native-community/netinfo").default) !== null && _a !== void 0 ? _a : require("@react-native-community/netinfo");
575
+ netUnsub = NetInfo.addEventListener((s) => {
576
+ if ((s === null || s === void 0 ? void 0 : s.isConnected) && shouldConnect() && client.current && !connectedRef.current) {
577
+ client.current.connect();
578
+ }
579
+ });
580
+ }
581
+ catch {
582
+ /* netinfo absent — AppState reconnect still covers foreground churn */
583
+ }
584
+ return () => {
585
+ var _a;
586
+ // AppState.addEventListener's return shape has varied across RN versions
587
+ // (subscription object vs legacy void); guard the remove so unmount never throws.
588
+ try {
589
+ (_a = sub === null || sub === void 0 ? void 0 : sub.remove) === null || _a === void 0 ? void 0 : _a.call(sub);
590
+ }
591
+ catch { /* already removed / legacy API */ }
592
+ try {
593
+ netUnsub === null || netUnsub === void 0 ? void 0 : netUnsub();
594
+ }
595
+ catch { /* already removed */ }
596
+ };
597
+ // eslint-disable-next-line react-hooks/exhaustive-deps
598
+ }, []);
599
+ // Persist the full session snapshot (history + read marker + greet set). base64 is
600
+ // stripped by stripBase64OnAck in the reducer, so messages here never carry data URLs
601
+ // once acked; the optimistic-pending base64 window is acceptable (overwritten on ack).
602
+ const persistSession = () => {
603
+ const snapshot = {
604
+ sessionId: sessionId.current,
605
+ messages: messagesRef.current,
606
+ lastActivity: lastActivity.current,
607
+ lastReadAt: lastReadRef.current,
608
+ greetSent: Array.from(greeted.current),
609
+ };
610
+ (0, persistence_1.saveSession)(store, snapshot, config.channelId);
611
+ };
612
+ // #4/#16: mark everything read up to now + refresh the activity anchor (open/foreground).
613
+ const markReadAndTouch = () => {
614
+ touchActivity();
615
+ dispatch({ type: "MARK_READ", at: now() });
616
+ };
617
+ // Bring the socket into line with the open/enable gates (open after a closed mount, etc.).
618
+ const reconcileConnection = () => {
619
+ if (!client.current)
620
+ return;
621
+ if (shouldConnect()) {
622
+ if (!connectedRef.current)
623
+ client.current.connect();
624
+ }
625
+ };
626
+ const emitMessage = (key, text, media, metadata, payload, extra) => {
627
+ var _a, _b;
628
+ return (_a = client.current) === null || _a === void 0 ? void 0 : _a.sendMessage({
629
+ // #5: `payload` is the backend message body; `text` is the display value.
630
+ message: payload !== null && payload !== void 0 ? payload : text,
631
+ ...(payload && text ? { text } : {}),
632
+ session_id: sessionId.current,
633
+ customData: { language: (_b = cfgRef.current.language) !== null && _b !== void 0 ? _b : "en" },
634
+ messageKey: key,
635
+ type: "EMIT_NEW_USER_MESSAGE", // #5: inject the message type
636
+ // WIRE FORMAT (web parity, DigitalGovernmentFooter handleSend): `media` is an
637
+ // array of base64 data-URL STRINGS — `media: filesBase64` where each entry is a
638
+ // `FileReader.readAsDataURL` string. Sending the MediaEntry OBJECTS
639
+ // ({data,name,mimeType,size}) makes the backend reject the send with
640
+ // "MsgToExp: invalid media string" (it reads each media[] entry as a string).
641
+ // `typeMedia` mirrors the web (the first file's mime type).
642
+ ...(media && media.length
643
+ ? { media: media.map((m) => m.data), typeMedia: media[0].mimeType }
644
+ : {}),
645
+ ...(metadata ? { metadata } : {}), // #5: forward host metadata untouched (S9)
646
+ ...(extra !== null && extra !== void 0 ? extra : {}), // greet's prechatFormSubmission (web parity)
647
+ });
648
+ };
649
+ // Send the welcome/prechat greet for a confirmed session (web ChatContainer:167-185).
650
+ // Carries the collected prechat fields as `prechatFormSubmission` + a Form-Response
651
+ // message body; the welcome flag gates the TEXT only — prechat data is always sent.
652
+ // Reads LIVE prechat values (prechatValuesRef), so a value submitted after mount is
653
+ // included. Idempotent per session via `greeted`.
654
+ const sendGreet = (id) => {
655
+ var _a;
656
+ if (!(0, greet_1.shouldGreet)({ sessionId: id, greetedSessions: greeted.current }))
657
+ return;
658
+ greeted.current.add(id);
659
+ persistSession(); // #17: persist greetSent BEFORE greeting so a crash mid-greet doesn't double-greet
660
+ const welcome = cfgRef.current.sendWelcomeMessage !== false;
661
+ const pv = (_a = prechatValuesRef.current) !== null && _a !== void 0 ? _a : {};
662
+ const hasPrechat = Object.keys(pv).length > 0;
663
+ if (welcome || hasPrechat) {
664
+ const g = (0, greet_1.buildGreet)(pv);
665
+ emitMessage(`g${now()}`, g.message, undefined, undefined, undefined, g.prechatFormSubmission ? { prechatFormSubmission: g.prechatFormSubmission } : undefined);
666
+ }
667
+ };
668
+ // B10: NEVER mark un-acked as sent. Mark 'sent' only when connected at emit time;
669
+ // otherwise keep 'pending' and flush on reconnect (above). No synchronous auto-ACK.
670
+ //
671
+ // #5: object form. A bare string is `{ text }`. `payload` is what the backend receives
672
+ // as the body (text is the display value); metadata is forwarded on the OutgoingPayload.
673
+ const send = (input) => {
674
+ var _a, _b, _c, _d;
675
+ const norm = typeof input === "string" ? { text: input } : input;
676
+ const display = (_b = (_a = norm.text) !== null && _a !== void 0 ? _a : norm.payload) !== null && _b !== void 0 ? _b : "";
677
+ const body = (_d = (_c = norm.payload) !== null && _c !== void 0 ? _c : norm.text) !== null && _d !== void 0 ? _d : "";
678
+ const metadata = norm.metadata;
679
+ touchActivity(); // a send is activity (#16)
680
+ const key = `u${now()}`;
681
+ dispatch({ type: "SEND", message: { key, sender: "user", text: display, timestamp: now() } }); // status: pending
682
+ if (connectedRef.current) {
683
+ emitMessage(key, display, undefined, metadata, body);
684
+ dispatch({ type: "ACK", key });
685
+ }
686
+ else {
687
+ // queue with the resolved backend body so the flush emits the right payload
688
+ pending.current.push({ key, text: body });
689
+ }
690
+ };
691
+ // P2: send picked/recorded media.
692
+ //
693
+ // Flow: validate → encode → optimistic SEND (status 'pending', carrying the base64 media[]
694
+ // so the bubble previews immediately) → emit `user_uttered` with media[] + messageKey (or
695
+ // queue while disconnected, B10). The message flips to 'sent' ONLY on the server UPDATE_MSG
696
+ // echo, which also patches `mediaUrl` and strips the heavy base64 (no base64 ever persists
697
+ // after ack). Any failure (size/count reject, encode fail, emit fail) sets 'failed' and
698
+ // emits a typed WebChatError (C5) via onError. Validation happens BEFORE encoding so an
699
+ // oversize/over-count selection is never base64-inflated.
700
+ const attach = async (assets, text) => {
701
+ var _a, _b, _c, _d, _e;
702
+ const key = `m${now()}`;
703
+ touchActivity(); // #16
704
+ // 1) validate BEFORE encoding (reject oversize/over-count/unsupported before any inflation).
705
+ const files = assets.map((a) => ({ name: a.name, mimeType: a.mimeType, size: a.size, uri: a.uri, base64: a.base64 }));
706
+ const { accepted, rejected } = (0, media_1.validateSelection)(files, mediaLimits);
707
+ if (rejected.length > 0) {
708
+ (_a = props.onError) === null || _a === void 0 ? void 0 : _a.call(props, new types_1.WebChatError("send", `attachment rejected: ${rejected[0].reason}`, false, rejected));
709
+ }
710
+ if (accepted.length === 0)
711
+ return; // nothing left to send; no bubble created
712
+ // keep only accepted assets (validateSelection preserves order)
713
+ const acceptedNames = new Set(accepted.map((f) => f.name));
714
+ const keptAssets = assets.filter((a) => acceptedNames.has(a.name));
715
+ // 2) encode -> media[] data URLs (queued -> encoding). Reader is injected; the eager
716
+ // asset.base64 short-circuits it (see toMediaEntry).
717
+ const reader = readBase64 !== null && readBase64 !== void 0 ? readBase64 : (async () => { throw new types_1.WebChatError("send", "no base64 reader injected", false); });
718
+ let media;
719
+ try {
720
+ media = await Promise.all(keptAssets.map((a) => (0, picker_1.toMediaEntry)(a, reader)));
721
+ }
722
+ catch (e) {
723
+ // create the bubble so the failure is visible, then mark it failed
724
+ dispatch({ type: "SEND", message: { key, sender: "user", files: keptAssets, timestamp: now() } });
725
+ dispatch({ type: "FAIL", key });
726
+ (_b = props.onError) === null || _b === void 0 ? void 0 : _b.call(props, e instanceof types_1.WebChatError ? e : new types_1.WebChatError("send", "media encode failed", false, e));
727
+ return;
728
+ }
729
+ // 3) optimistic bubble carrying the base64 data URLs for immediate preview (status 'pending').
730
+ dispatch({
731
+ type: "SEND",
732
+ message: {
733
+ key,
734
+ sender: "user",
735
+ // Web parity (handleSend): one message carries the optional caption + media.
736
+ ...(text && text.trim() ? { text: text.trim() } : {}),
737
+ media: media.map((m) => m.data),
738
+ files: keptAssets,
739
+ timestamp: now(),
740
+ },
741
+ });
742
+ // 4) emit (sending), or queue while disconnected (B10). Either way this key is marked as
743
+ // awaiting reconciliation so the server echo routes to UPDATE_MSG (NOT auto-ACK).
744
+ mediaKeys.current.add(key);
745
+ try {
746
+ if (connectedRef.current) {
747
+ emitMessage(key, (_c = text === null || text === void 0 ? void 0 : text.trim()) !== null && _c !== void 0 ? _c : "", media);
748
+ }
749
+ else {
750
+ pending.current.push({ key, text: (_d = text === null || text === void 0 ? void 0 : text.trim()) !== null && _d !== void 0 ? _d : "" });
751
+ queuedMedia.current.set(key, media); // re-attached on flush
752
+ }
753
+ }
754
+ catch (e) {
755
+ mediaKeys.current.delete(key);
756
+ dispatch({ type: "FAIL", key });
757
+ (_e = props.onError) === null || _e === void 0 ? void 0 : _e.call(props, e instanceof types_1.WebChatError ? e : new types_1.WebChatError("send", "media send failed", true, e));
758
+ }
759
+ };
760
+ // Section 4 — apply a like/dislike reaction to a received message (web parity,
761
+ // mirror of redux/messages/actions.ts reactToMessage). Reads the message's CURRENT
762
+ // userReaction (from the always-current mirror so a stale render closure can't read a
763
+ // wrong value), computes the toggle-clear next state + the backend verb, dispatches
764
+ // REACT optimistically, and emits the "activity" event. A reaction is user activity (#16).
765
+ const react = (messageKey, reaction) => {
766
+ var _a, _b, _c;
767
+ touchActivity();
768
+ const current = (_b = (_a = messagesRef.current.find((m) => m.messageKey === messageKey)) === null || _a === void 0 ? void 0 : _a.userReaction) !== null && _b !== void 0 ? _b : null;
769
+ // toggle-clear: tapping the ACTIVE reaction clears it; otherwise switch to it.
770
+ const next = current === reaction ? null : reaction;
771
+ // backend verb: like when not-liked -> "like", already-liked -> "unlike";
772
+ // dislike when not-disliked -> "dislike", already-disliked -> "undislike".
773
+ const type = reaction === "like"
774
+ ? current === "like" ? "unlike" : "like"
775
+ : current === "dislike" ? "undislike" : "dislike";
776
+ dispatch({ type: "REACT", messageKey, reaction: next }); // optimistic
777
+ (_c = client.current) === null || _c === void 0 ? void 0 : _c.emitActivity({ messageKey, type });
778
+ };
779
+ // B19: real erasure — purge persisted storage + reset state; resetSession also restarts the session.
780
+ // clearHistory drops messages only and keeps the live session (web parity: clearing messages does
781
+ // NOT clear sessionId, so a dismissed prechat stays dismissed). resetSession zeroes the session and
782
+ // reconnects for a brand-new one, then fires onSessionReset so the host can reset session-scoped UI
783
+ // (prechat form re-appears, mirroring web's `!sessionId` gate — without it, a one-time prechatDone
784
+ // latch would keep the form hidden for the new session).
785
+ const clearHistory = () => { dispatch({ type: "CLEAR" }); (0, persistence_1.clearSession)(store, config.channelId); };
786
+ const resetSession = () => {
787
+ var _a, _b, _c;
788
+ clearHistory();
789
+ sessionId.current = "";
790
+ greeted.current.clear();
791
+ // A reset forces a brand-new session: re-arm the prechat/greet flow so the form
792
+ // shows again and the greet waits for a fresh submit (web `!sessionId` semantics).
793
+ resumedSessionRef.current = false;
794
+ setResumedSession(false);
795
+ prechatSubmittedRef.current = false;
796
+ pendingGreetIdRef.current = null;
797
+ (_a = client.current) === null || _a === void 0 ? void 0 : _a.disconnect();
798
+ (_b = client.current) === null || _b === void 0 ? void 0 : _b.connect();
799
+ (_c = props.onSessionReset) === null || _c === void 0 ? void 0 : _c.call(props);
800
+ };
801
+ // --- Phase 3: video calls (Group E) ----------------------------------------
802
+ //
803
+ // startCall builds a VideoCallClient over the SAME chat socket the WebchatClient
804
+ // already owns (socketRef, captured above) — no second connection. The call is
805
+ // scoped by the live chat session (`session_id` on join, C8/web parity). The
806
+ // WebRTC factory + audio route are lazily resolved here (defaults = real native
807
+ // adapters) so non-video flows never load native bindings; tests inject fakes.
808
+ //
809
+ // Resilience seam: the client's "ended" event (inbound peer-left / hangup, B16/C9,
810
+ // or its own leave) flips videoCallStarted back to false so the Surface returns to
811
+ // the thread without a silent dangling call.
812
+ const startCall = () => {
813
+ if (videoClient.current)
814
+ return; // already in a call
815
+ const socket = socketRef.current;
816
+ if (!socket)
817
+ return; // socket not wired yet (pre-connect)
818
+ // Lazily resolve the native seams ONLY when a call is actually placed.
819
+ const factory = webrtcFactory !== null && webrtcFactory !== void 0 ? webrtcFactory : require("../adapters/webrtc").reactNativeWebRTCFactory();
820
+ const route = audioRoute !== null && audioRoute !== void 0 ? audioRoute : require("../adapters/audioRoute").reactNativeAudioRoute();
821
+ audioRouteRef.current = route;
822
+ const vc = createVideoClient({
823
+ socket: socket,
824
+ webrtcFactory: factory,
825
+ getSessionId: () => sessionId.current,
826
+ });
827
+ videoClient.current = vc;
828
+ // Inbound hangup / teardown -> clear the active-call flag + release the audio session.
829
+ vc.on("ended", () => {
830
+ var _a;
831
+ videoClient.current = null;
832
+ try {
833
+ (_a = audioRouteRef.current) === null || _a === void 0 ? void 0 : _a.stop();
834
+ }
835
+ catch { /* never started */ }
836
+ audioRouteRef.current = null;
837
+ setVideoCallStarted(false);
838
+ });
839
+ vc.on("error", () => {
840
+ var _a;
841
+ // a start/getUserMedia failure releases the route; the Surface drops back to the thread.
842
+ try {
843
+ (_a = audioRouteRef.current) === null || _a === void 0 ? void 0 : _a.stop();
844
+ }
845
+ catch { /* never started */ }
846
+ });
847
+ try {
848
+ route.start({ media: "video" });
849
+ }
850
+ catch { /* audio route optional */ }
851
+ void vc.start();
852
+ setVideoCallStarted(true);
853
+ };
854
+ // Route the live call's audio output (audit #25). Delegates to the provider's REAL
855
+ // AudioRoute (audioRouteRef, started in startCall) so the in-call speaker toggle
856
+ // reaches the actual audio session rather than a no-op stub. A no-op (best-effort)
857
+ // when no call is active / the route lacks setSpeaker.
858
+ const setSpeaker = (on) => {
859
+ var _a, _b;
860
+ try {
861
+ (_b = (_a = audioRouteRef.current) === null || _a === void 0 ? void 0 : _a.setSpeaker) === null || _b === void 0 ? void 0 : _b.call(_a, on);
862
+ }
863
+ catch { /* route unavailable */ }
864
+ };
865
+ // Tear the active call down. Idempotent: safe on a double-call / unmount.
866
+ const endCall = () => {
867
+ var _a;
868
+ const vc = videoClient.current;
869
+ videoClient.current = null;
870
+ try {
871
+ vc === null || vc === void 0 ? void 0 : vc.leave();
872
+ }
873
+ catch { /* already torn down */ }
874
+ try {
875
+ (_a = audioRouteRef.current) === null || _a === void 0 ? void 0 : _a.stop();
876
+ }
877
+ catch { /* already stopped */ }
878
+ audioRouteRef.current = null;
879
+ setVideoCallStarted(false);
880
+ };
881
+ // Item 23 — "awaiting a bot reply": the last message in the thread is a user
882
+ // message (no `response` message has landed since). Derived from the live message
883
+ // list (no extra reducer state needed); a subsequent bot RECEIVE flips it false.
884
+ const lastMsg = state.messages[state.messages.length - 1];
885
+ const awaitingReply = (lastMsg === null || lastMsg === void 0 ? void 0 : lastMsg.sender) === "user";
886
+ const value = {
887
+ messages: state.messages, status, isStorageReady, unread,
888
+ awaitingReply,
889
+ wasConnected,
890
+ resumedSession,
891
+ audioAdapter, // audit #23: received-voice playback seam (may be undefined)
892
+ setSpeaker, // audit #25: in-call speaker route -> provider AudioRoute
893
+ resolvedConfig: resolved.config,
894
+ theme: resolved.theme,
895
+ dir: resolved.dir,
896
+ language: resolved.language,
897
+ send, attach, clearHistory, resetSession, react,
898
+ videoCallStarted, startCall, endCall,
899
+ _video: {
900
+ socket: socketRef.current,
901
+ getSessionId: () => sessionId.current,
902
+ client: videoClient.current,
903
+ },
904
+ };
905
+ return (0, jsx_runtime_1.jsx)(exports.WebchatContext.Provider, { value: value, children: children });
906
+ }