@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.
- package/README.md +254 -0
- package/app.plugin.js +6 -0
- package/lib/adapters/audio.d.ts +74 -0
- package/lib/adapters/audio.js +39 -0
- package/lib/adapters/audioRoute.d.ts +57 -0
- package/lib/adapters/audioRoute.js +77 -0
- package/lib/adapters/expoDefaults.d.ts +77 -0
- package/lib/adapters/expoDefaults.js +539 -0
- package/lib/adapters/picker.d.ts +67 -0
- package/lib/adapters/picker.js +37 -0
- package/lib/adapters/webrtc.d.ts +131 -0
- package/lib/adapters/webrtc.js +70 -0
- package/lib/core/VideoCallClient.d.ts +106 -0
- package/lib/core/VideoCallClient.js +302 -0
- package/lib/core/WebchatClient.d.ts +34 -0
- package/lib/core/WebchatClient.js +132 -0
- package/lib/core/configClient.d.ts +42 -0
- package/lib/core/configClient.js +302 -0
- package/lib/core/greet.d.ts +11 -0
- package/lib/core/greet.js +17 -0
- package/lib/core/ice.d.ts +31 -0
- package/lib/core/ice.js +48 -0
- package/lib/core/linkify.d.ts +11 -0
- package/lib/core/linkify.js +25 -0
- package/lib/core/logger.d.ts +17 -0
- package/lib/core/logger.js +53 -0
- package/lib/core/media.d.ts +52 -0
- package/lib/core/media.js +115 -0
- package/lib/core/mediaType.d.ts +21 -0
- package/lib/core/mediaType.js +66 -0
- package/lib/core/messagesReducer.d.ts +36 -0
- package/lib/core/messagesReducer.js +58 -0
- package/lib/core/persistence.d.ts +45 -0
- package/lib/core/persistence.js +63 -0
- package/lib/core/socketFactory.d.ts +16 -0
- package/lib/core/socketFactory.js +82 -0
- package/lib/core/types.d.ts +320 -0
- package/lib/core/types.js +30 -0
- package/lib/core/unread.d.ts +2 -0
- package/lib/core/unread.js +5 -0
- package/lib/i18n/ar.json +1 -0
- package/lib/i18n/en.json +1 -0
- package/lib/i18n/index.d.ts +7 -0
- package/lib/i18n/index.js +43 -0
- package/lib/index.d.ts +59 -0
- package/lib/index.js +142 -0
- package/lib/plugin/withWebchat.d.ts +53 -0
- package/lib/plugin/withWebchat.js +164 -0
- package/lib/state/WebchatProvider.d.ts +132 -0
- package/lib/state/WebchatProvider.js +906 -0
- package/lib/state/useWebchat.d.ts +1 -0
- package/lib/state/useWebchat.js +12 -0
- package/lib/theme/dir.d.ts +14 -0
- package/lib/theme/dir.js +20 -0
- package/lib/theme/themeFactory.d.ts +219 -0
- package/lib/theme/themeFactory.js +182 -0
- package/lib/ui/AttachButton.d.ts +35 -0
- package/lib/ui/AttachButton.js +26 -0
- package/lib/ui/AudioRecorder.d.ts +25 -0
- package/lib/ui/AudioRecorder.js +228 -0
- package/lib/ui/Bubble.d.ts +1 -0
- package/lib/ui/Bubble.js +265 -0
- package/lib/ui/CallControls.d.ts +27 -0
- package/lib/ui/CallControls.js +92 -0
- package/lib/ui/CallPlaceholder.d.ts +16 -0
- package/lib/ui/CallPlaceholder.js +73 -0
- package/lib/ui/Composer.d.ts +5 -0
- package/lib/ui/Composer.js +272 -0
- package/lib/ui/FileTile.d.ts +9 -0
- package/lib/ui/FileTile.js +31 -0
- package/lib/ui/Header.d.ts +52 -0
- package/lib/ui/Header.js +236 -0
- package/lib/ui/Icon.d.ts +21 -0
- package/lib/ui/Icon.js +110 -0
- package/lib/ui/ImageBubble.d.ts +11 -0
- package/lib/ui/ImageBubble.js +16 -0
- package/lib/ui/MediaUploadMenu.d.ts +23 -0
- package/lib/ui/MediaUploadMenu.js +68 -0
- package/lib/ui/MessageList.d.ts +1 -0
- package/lib/ui/MessageList.js +46 -0
- package/lib/ui/PoweredBy.d.ts +8 -0
- package/lib/ui/PoweredBy.js +14 -0
- package/lib/ui/PrechatForm.d.ts +1 -0
- package/lib/ui/PrechatForm.js +230 -0
- package/lib/ui/QuickReplies.d.ts +1 -0
- package/lib/ui/QuickReplies.js +24 -0
- package/lib/ui/TypingIndicator.d.ts +9 -0
- package/lib/ui/TypingIndicator.js +88 -0
- package/lib/ui/VideoBubble.d.ts +10 -0
- package/lib/ui/VideoBubble.js +130 -0
- package/lib/ui/VideoCall.d.ts +34 -0
- package/lib/ui/VideoCall.js +191 -0
- package/lib/ui/VideoTile.d.ts +25 -0
- package/lib/ui/VideoTile.js +13 -0
- package/lib/ui/VoiceMessage.d.ts +19 -0
- package/lib/ui/VoiceMessage.js +127 -0
- package/lib/ui/WebChat.d.ts +10 -0
- package/lib/ui/WebChat.js +386 -0
- package/lib/ui/openLink.d.ts +1 -0
- package/lib/ui/openLink.js +16 -0
- 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
|
+
}
|