@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,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AudioRecorder = AudioRecorder;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
// src/ui/AudioRecorder.tsx
|
|
6
|
+
// Voice-note recorder UI (Task 6 UI). Two capture paths, picked HOOK-SAFELY:
|
|
7
|
+
//
|
|
8
|
+
// 1. INJECTED IMPERATIVE ADAPTER (host-supplied, or the test fakes): when the
|
|
9
|
+
// adapter's `recordingDelegated` flag is NOT set, its imperative
|
|
10
|
+
// startRecording()/stopRecording() are the real capture surface and are used
|
|
11
|
+
// as-is (the original Task 6 behaviour, unchanged).
|
|
12
|
+
//
|
|
13
|
+
// 2. DEFAULT expo-audio PATH: the batteries-included default adapter
|
|
14
|
+
// (createExpoAudioAdapter) marks itself `recordingDelegated:true` because
|
|
15
|
+
// expo-audio's recorder is HOOK-ONLY (useAudioRecorder, Rules of Hooks) and
|
|
16
|
+
// cannot live on an object method. In that case this component drives the
|
|
17
|
+
// hook directly: it renders an inner <ExpoRecorderControls> that
|
|
18
|
+
// UNCONDITIONALLY calls useAudioRecorder(RecordingPresets.HIGH_QUALITY) and
|
|
19
|
+
// runs permission → setAudioModeAsync → prepare → record → stop → read uri,
|
|
20
|
+
// emitting onRecorded({ uri, mimeType:"audio/mp4" }). When expo-audio is
|
|
21
|
+
// absent the recorder stays hidden/disabled (graceful degradation).
|
|
22
|
+
//
|
|
23
|
+
// HOOK-SAFETY (mirrors VideoBubble.tsx): useAudioRecorder is a React hook and MUST
|
|
24
|
+
// NOT be called conditionally. So the present/absent decision picks a WHOLE
|
|
25
|
+
// component (the imperative <RecorderControls>, the hook-driven
|
|
26
|
+
// <ExpoRecorderControls>, or nothing) — never a branch inside one component's body
|
|
27
|
+
// — keeping each component's hook order stable across renders.
|
|
28
|
+
//
|
|
29
|
+
// State machine (shared by both paths): idle → recording → (stopped, emits
|
|
30
|
+
// onRecorded). The recorded asset is handed up via onRecorded so the host routes
|
|
31
|
+
// it through the same attach() path as picked files. The onRecorded → attach
|
|
32
|
+
// (base64-over-socket) flow + the autoStart behaviour are intact in both paths.
|
|
33
|
+
const react_1 = require("react");
|
|
34
|
+
const react_native_1 = require("react-native");
|
|
35
|
+
const Icon_1 = require("./Icon");
|
|
36
|
+
/** Container mime the expo-audio HIGH_QUALITY preset records (M4A/AAC, iOS+Android). */
|
|
37
|
+
const RECORDED_AUDIO_MIME = "audio/mp4";
|
|
38
|
+
/** mm:ss formatter for the live timer. */
|
|
39
|
+
function formatTimer(ms) {
|
|
40
|
+
const total = Math.floor(ms / 1000);
|
|
41
|
+
const m = Math.floor(total / 60);
|
|
42
|
+
const s = total % 60;
|
|
43
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Lazily resolve `expo-audio`'s recorder surface. Returns `null` when the optional
|
|
47
|
+
* dep is not installed (or fails to resolve under metro/jest) so the caller renders
|
|
48
|
+
* nothing instead of crashing. Mirrors VideoBubble.loadExpoVideo().
|
|
49
|
+
*/
|
|
50
|
+
function loadExpoRecorder() {
|
|
51
|
+
try {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
53
|
+
const ea = require("expo-audio");
|
|
54
|
+
if (ea && typeof ea.useAudioRecorder === "function" && ea.RecordingPresets) {
|
|
55
|
+
return {
|
|
56
|
+
useAudioRecorder: ea.useAudioRecorder,
|
|
57
|
+
RecordingPresets: ea.RecordingPresets,
|
|
58
|
+
requestRecordingPermissionsAsync: ea.requestRecordingPermissionsAsync,
|
|
59
|
+
setAudioModeAsync: ea.setAudioModeAsync,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Shared presentational state machine. PROP-DRIVEN by abstract `start`/`stop`
|
|
70
|
+
* async callbacks so the imperative-adapter path and the expo-audio hook path
|
|
71
|
+
* reuse one identical UI (toggle glyph, live timer, idle→recording→stopped). The
|
|
72
|
+
* testIDs (`audio-recorder`, `record-toggle`, `record-timer`) are stable across
|
|
73
|
+
* both paths.
|
|
74
|
+
*/
|
|
75
|
+
function RecorderUI({ start, stop, onRecorded, onError, disabled, autoStart, theme, iconColor, }) {
|
|
76
|
+
var _a, _b, _c, _d, _e, _f;
|
|
77
|
+
const [state, setState] = (0, react_1.useState)("idle");
|
|
78
|
+
const [elapsedMs, setElapsedMs] = (0, react_1.useState)(0);
|
|
79
|
+
const startedAtRef = (0, react_1.useRef)(0);
|
|
80
|
+
const tickRef = (0, react_1.useRef)(null);
|
|
81
|
+
const autoStartedRef = (0, react_1.useRef)(false);
|
|
82
|
+
const clearTick = () => {
|
|
83
|
+
if (tickRef.current != null) {
|
|
84
|
+
clearInterval(tickRef.current);
|
|
85
|
+
tickRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// Tear down the timer on unmount.
|
|
89
|
+
(0, react_1.useEffect)(() => clearTick, []);
|
|
90
|
+
// If a video call suspends us mid-record, stop the ticking timer.
|
|
91
|
+
(0, react_1.useEffect)(() => {
|
|
92
|
+
if (disabled && state === "recording") {
|
|
93
|
+
clearTick();
|
|
94
|
+
}
|
|
95
|
+
}, [disabled, state]);
|
|
96
|
+
const startRecording = async () => {
|
|
97
|
+
try {
|
|
98
|
+
await start();
|
|
99
|
+
startedAtRef.current = Date.now();
|
|
100
|
+
setElapsedMs(0);
|
|
101
|
+
setState("recording");
|
|
102
|
+
clearTick();
|
|
103
|
+
tickRef.current = setInterval(() => {
|
|
104
|
+
setElapsedMs(Date.now() - startedAtRef.current);
|
|
105
|
+
}, 250);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
clearTick();
|
|
109
|
+
setState("idle");
|
|
110
|
+
onError === null || onError === void 0 ? void 0 : onError(err);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const stopRecording = async () => {
|
|
114
|
+
clearTick();
|
|
115
|
+
try {
|
|
116
|
+
const result = await stop();
|
|
117
|
+
setState("stopped");
|
|
118
|
+
setElapsedMs(0);
|
|
119
|
+
onRecorded(result);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
setState("idle");
|
|
123
|
+
onError === null || onError === void 0 ? void 0 : onError(err);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
// Selector-driven launch: arm once on mount when asked to autoStart. Placed
|
|
127
|
+
// after startRecording/stopRecording so they are in scope (no TDZ).
|
|
128
|
+
(0, react_1.useEffect)(() => {
|
|
129
|
+
if (autoStart && !autoStartedRef.current && !disabled && state === "idle") {
|
|
130
|
+
autoStartedRef.current = true;
|
|
131
|
+
void startRecording();
|
|
132
|
+
}
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, [autoStart]);
|
|
135
|
+
const isRecording = state === "recording";
|
|
136
|
+
const primary = (_b = (_a = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _a === void 0 ? void 0 : _a.primary) !== null && _b !== void 0 ? _b : "#0B7A4B";
|
|
137
|
+
const recColor = "#E5484D"; // record red, distinct from the brand colour
|
|
138
|
+
const txt = (_d = (_c = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _c === void 0 ? void 0 : _c.text) !== null && _d !== void 0 ? _d : "#1A1A1A";
|
|
139
|
+
const fontSize = (_f = (_e = theme === null || theme === void 0 ? void 0 : theme.font) === null || _e === void 0 ? void 0 : _e.size) !== null && _f !== void 0 ? _f : 14;
|
|
140
|
+
const micColor = iconColor !== null && iconColor !== void 0 ? iconColor : primary;
|
|
141
|
+
// While recording: a red record dot + mm:ss timer + a round STOP button.
|
|
142
|
+
// Idle: a single mic affordance. The `record-toggle` testID is on BOTH branches
|
|
143
|
+
// (tests press it to start, then again to stop) and the a11y labels/state are
|
|
144
|
+
// preserved verbatim.
|
|
145
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: "audio-recorder", style: {
|
|
146
|
+
flexDirection: "row",
|
|
147
|
+
alignItems: "center",
|
|
148
|
+
gap: 10,
|
|
149
|
+
paddingVertical: 6,
|
|
150
|
+
paddingHorizontal: isRecording ? 12 : 0,
|
|
151
|
+
borderRadius: 20,
|
|
152
|
+
backgroundColor: isRecording ? "rgba(229,72,77,0.10)" : "transparent",
|
|
153
|
+
}, children: isRecording ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: 10, height: 10, borderRadius: 5, backgroundColor: recColor } }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { testID: "record-timer", style: { color: txt, fontSize, fontVariant: ["tabular-nums"] }, children: formatTimer(elapsedMs) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "record-toggle", accessibilityRole: "button", accessibilityLabel: "Stop recording", accessibilityState: { disabled, busy: true }, disabled: disabled, onPress: () => {
|
|
154
|
+
if (!disabled)
|
|
155
|
+
void stopRecording();
|
|
156
|
+
}, style: {
|
|
157
|
+
width: 36,
|
|
158
|
+
height: 36,
|
|
159
|
+
borderRadius: 18,
|
|
160
|
+
alignItems: "center",
|
|
161
|
+
justifyContent: "center",
|
|
162
|
+
backgroundColor: recColor,
|
|
163
|
+
opacity: disabled ? 0.4 : 1,
|
|
164
|
+
}, children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "stop", color: "#FFFFFF", size: 18 }) })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "record-toggle", accessibilityRole: "button", accessibilityLabel: "Record voice message", accessibilityState: { disabled, busy: false }, disabled: disabled, onPress: () => {
|
|
165
|
+
if (!disabled)
|
|
166
|
+
void startRecording();
|
|
167
|
+
}, style: { padding: 6, opacity: disabled ? 0.4 : 1 }, children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "mic", color: micColor, size: fontSize + 8 }) })) }));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* DEFAULT expo-audio capture path. Only mounted when expo-audio resolved, so the
|
|
171
|
+
* useAudioRecorder hook here is ALWAYS called (never conditionally). Drives the
|
|
172
|
+
* confirmed expo-audio recording lifecycle and hands abstract start/stop callbacks
|
|
173
|
+
* to the shared <RecorderUI>:
|
|
174
|
+
* useAudioRecorder(HIGH_QUALITY) → requestRecordingPermissionsAsync()
|
|
175
|
+
* → setAudioModeAsync({ playsInSilentMode:true, allowsRecording:true })
|
|
176
|
+
* → prepareToRecordAsync() → record() (start)
|
|
177
|
+
* → stop(); read recorder.uri (stop, emits audio/mp4)
|
|
178
|
+
*/
|
|
179
|
+
function ExpoRecorderControls({ expo, onRecorded, onError, disabled, autoStart, theme, iconColor, }) {
|
|
180
|
+
const recorder = expo.useAudioRecorder(expo.RecordingPresets.HIGH_QUALITY);
|
|
181
|
+
const startedAtRef = (0, react_1.useRef)(0);
|
|
182
|
+
const start = async () => {
|
|
183
|
+
// Permission first; a denial throws to onError (parity with the imperative path).
|
|
184
|
+
if (typeof expo.requestRecordingPermissionsAsync === "function") {
|
|
185
|
+
const perm = await expo.requestRecordingPermissionsAsync();
|
|
186
|
+
if (perm && perm.granted === false) {
|
|
187
|
+
throw new Error("Microphone permission denied");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (typeof expo.setAudioModeAsync === "function") {
|
|
191
|
+
await expo.setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
|
|
192
|
+
}
|
|
193
|
+
await recorder.prepareToRecordAsync();
|
|
194
|
+
recorder.record();
|
|
195
|
+
startedAtRef.current = Date.now();
|
|
196
|
+
};
|
|
197
|
+
const stop = async () => {
|
|
198
|
+
var _a;
|
|
199
|
+
await recorder.stop();
|
|
200
|
+
return {
|
|
201
|
+
uri: (_a = recorder.uri) !== null && _a !== void 0 ? _a : "",
|
|
202
|
+
// expo-audio HIGH_QUALITY records an M4A/AAC container on iOS+Android. We
|
|
203
|
+
// HONESTLY tag the real container mime (already in the media ALLOW_LIST);
|
|
204
|
+
// we do NOT mislabel it as the canonical audio/ogg;codecs=opus.
|
|
205
|
+
mimeType: RECORDED_AUDIO_MIME,
|
|
206
|
+
durationMs: startedAtRef.current ? Date.now() - startedAtRef.current : 0,
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
return ((0, jsx_runtime_1.jsx)(RecorderUI, { start: start, stop: stop, onRecorded: onRecorded, onError: onError, disabled: disabled, autoStart: autoStart, theme: theme, iconColor: iconColor }));
|
|
210
|
+
}
|
|
211
|
+
function AudioRecorder({ adapter, onRecorded, onError, disabled = false, autoStart = false, theme, iconColor, }) {
|
|
212
|
+
// PATH 1 — injected imperative adapter (host-supplied / test fakes). When the
|
|
213
|
+
// adapter does NOT delegate recording, its startRecording/stopRecording ARE the
|
|
214
|
+
// real capture surface; use them as-is (original Task 6 behaviour).
|
|
215
|
+
if (!(adapter === null || adapter === void 0 ? void 0 : adapter.recordingDelegated)) {
|
|
216
|
+
return ((0, jsx_runtime_1.jsx)(RecorderUI, { start: () => adapter.startRecording(), stop: () => adapter.stopRecording(), onRecorded: onRecorded, onError: onError, disabled: disabled, autoStart: autoStart, theme: theme, iconColor: iconColor }));
|
|
217
|
+
}
|
|
218
|
+
// PATH 2 — default adapter delegates recording to the expo-audio hook. Resolve
|
|
219
|
+
// the peer once at render; mount the hook-driven controls when present.
|
|
220
|
+
const expo = loadExpoRecorder();
|
|
221
|
+
if (expo) {
|
|
222
|
+
return ((0, jsx_runtime_1.jsx)(ExpoRecorderControls, { expo: expo, onRecorded: onRecorded, onError: onError, disabled: disabled, autoStart: autoStart, theme: theme, iconColor: iconColor }));
|
|
223
|
+
}
|
|
224
|
+
// PATH 3 — recording delegated but expo-audio is absent (chat-only consumer /
|
|
225
|
+
// jest without the mock): no capture surface exists, so the recorder stays
|
|
226
|
+
// hidden (graceful — calls no hook, pulls in no native dep).
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function Bubble({ message, dir, theme, testID, audioAdapter, onQuickReply, react, showMessageDate, showAvatar, avatar }: any): import("react/jsx-runtime").JSX.Element;
|
package/lib/ui/Bubble.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Bubble = Bubble;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_native_1 = require("react-native");
|
|
6
|
+
const linkify_1 = require("../core/linkify");
|
|
7
|
+
const openLink_1 = require("./openLink");
|
|
8
|
+
const dir_1 = require("../theme/dir");
|
|
9
|
+
const ImageBubble_1 = require("./ImageBubble");
|
|
10
|
+
const FileTile_1 = require("./FileTile");
|
|
11
|
+
const VideoBubble_1 = require("./VideoBubble");
|
|
12
|
+
const VoiceMessage_1 = require("./VoiceMessage");
|
|
13
|
+
const Icon_1 = require("./Icon");
|
|
14
|
+
const mediaType_1 = require("../core/mediaType");
|
|
15
|
+
const themeFactory_1 = require("../theme/themeFactory");
|
|
16
|
+
// Map the logical start/end edge (which flips under RTL) onto an RN alignSelf.
|
|
17
|
+
// Bot messages sit on the leading edge, user echoes on the trailing edge.
|
|
18
|
+
function bubbleAlignSelf(sender, d) {
|
|
19
|
+
const onStart = sender === "response";
|
|
20
|
+
return onStart ? "flex-start" : "flex-end";
|
|
21
|
+
}
|
|
22
|
+
// Translate the logical tail corner from dir.bubbleTail into the matching
|
|
23
|
+
// per-corner radius overrides. Web squares off the tail corner of the bubble
|
|
24
|
+
// (sent: bottom-end is squared; received: bottom-start is squared); every other
|
|
25
|
+
// corner keeps the configured radius.
|
|
26
|
+
function tailCornerStyle(corner, radius) {
|
|
27
|
+
return corner === "bottomLeft"
|
|
28
|
+
? { borderBottomLeftRadius: 0, borderBottomRightRadius: radius }
|
|
29
|
+
: { borderBottomRightRadius: 0, borderBottomLeftRadius: radius };
|
|
30
|
+
}
|
|
31
|
+
// Classify an attachment into image | video | audio | document, mirroring web
|
|
32
|
+
// FileDisplay. `detectMediaKind` (src/core/mediaType.ts) is the single shared
|
|
33
|
+
// classifier: MIME (type/mimeType) wins, else the extension of name/mediaUrl/uri.
|
|
34
|
+
// We also honor data-URLs (data:image / data:video / data:audio) which carry no
|
|
35
|
+
// extension by mapping the data: prefix onto a synthetic mime for the classifier.
|
|
36
|
+
function attachmentKind(uri, file) {
|
|
37
|
+
var _a;
|
|
38
|
+
let type = (_a = file === null || file === void 0 ? void 0 : file.type) !== null && _a !== void 0 ? _a : file === null || file === void 0 ? void 0 : file.mimeType;
|
|
39
|
+
if (!type && typeof uri === "string" && uri.startsWith("data:")) {
|
|
40
|
+
// data:<mime>;... — hand the embedded mime to the classifier (MIME wins).
|
|
41
|
+
type = uri.slice(5).split(/[;,]/)[0];
|
|
42
|
+
}
|
|
43
|
+
return (0, mediaType_1.detectMediaKind)({ name: file === null || file === void 0 ? void 0 : file.name, type, mediaUrl: file === null || file === void 0 ? void 0 : file.mediaUrl, uri });
|
|
44
|
+
}
|
|
45
|
+
// Normalize the web RecieverMsg shapes (files[] objects + media[]/mediaUrl URL
|
|
46
|
+
// strings) into a flat list of { uri, name, file } we can render.
|
|
47
|
+
function collectAttachments(message) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const files = Array.isArray(message.files) ? message.files : [];
|
|
50
|
+
const media = Array.isArray(message.media) ? message.media : [];
|
|
51
|
+
const meaningfulFiles = files.filter((f) => f && typeof f === "object" && Object.keys(f).length > 0 && (f.name || f.blobURL || f.mediaUrl || f.uri));
|
|
52
|
+
if (meaningfulFiles.length > 0) {
|
|
53
|
+
meaningfulFiles.forEach((f, i) => {
|
|
54
|
+
var _a, _b;
|
|
55
|
+
// Display-URL precedence:
|
|
56
|
+
// f.mediaUrl / f.blobURL — explicit hosted/blob URL on the file object
|
|
57
|
+
// message.mediaUrl — the server echo's hosted URL (after ack/relaunch)
|
|
58
|
+
// f.uri — the LOCAL file:// uri (alive in the optimistic window)
|
|
59
|
+
// media[i] — the inline base64 data: URL (last resort)
|
|
60
|
+
// CRITICAL: the local file:// uri must beat the base64 data: URL. iOS AVPlayer
|
|
61
|
+
// (expo-video) CANNOT open a `data:video/...;base64,...` URL — it throws
|
|
62
|
+
// "Cannot Open … media format is not supported", so a freshly-sent VIDEO showed
|
|
63
|
+
// an empty box. The local file:// (and the hosted URL after ack) ARE playable, so
|
|
64
|
+
// we prefer them; the data: URL stays only as a final fallback. `message.mediaUrl`
|
|
65
|
+
// stays ahead of f.uri so a persisted/relaunched sent message (whose file:// may
|
|
66
|
+
// be gone) still resolves to the hosted URL, not a dead local path.
|
|
67
|
+
const uri = f.mediaUrl || f.blobURL || message.mediaUrl || f.uri || media[i] || "";
|
|
68
|
+
// Classification mime: the file's own mime, else the message-level mime (web
|
|
69
|
+
// `message.type`) — so a hosted, extension-less URL still classifies as
|
|
70
|
+
// image/video instead of defaulting to a document/browser link.
|
|
71
|
+
if (uri)
|
|
72
|
+
out.push({ uri, name: f.name, file: { ...f, type: (_b = (_a = f.type) !== null && _a !== void 0 ? _a : f.mimeType) !== null && _b !== void 0 ? _b : message.type } });
|
|
73
|
+
});
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
// No usable file objects — fall back to media[] / mediaUrl URL strings.
|
|
77
|
+
const urls = media.length > 0 ? media : message.mediaUrl ? [message.mediaUrl] : [];
|
|
78
|
+
urls.forEach((url) => {
|
|
79
|
+
if (url)
|
|
80
|
+
out.push({ uri: url, name: undefined, file: { type: message.type } });
|
|
81
|
+
});
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
// Web formats the per-message time as "hh:mm a" (dayjs). Keep a tiny dependency-free
|
|
85
|
+
// equivalent so the timestamp row matches without pulling dayjs into the SDK.
|
|
86
|
+
function formatTime(ts) {
|
|
87
|
+
const dt = new Date(ts);
|
|
88
|
+
if (isNaN(dt.getTime()))
|
|
89
|
+
return "";
|
|
90
|
+
let h = dt.getHours();
|
|
91
|
+
const m = dt.getMinutes();
|
|
92
|
+
const ampm = h >= 12 ? "pm" : "am";
|
|
93
|
+
h = h % 12;
|
|
94
|
+
if (h === 0)
|
|
95
|
+
h = 12;
|
|
96
|
+
const hh = h < 10 ? `0${h}` : `${h}`;
|
|
97
|
+
const mm = m < 10 ? `0${m}` : `${m}`;
|
|
98
|
+
return `${hh}:${mm} ${ampm}`;
|
|
99
|
+
}
|
|
100
|
+
function Bubble({ message, dir, theme, testID, audioAdapter, onQuickReply, react, showMessageDate = true, showAvatar = false, avatar }) {
|
|
101
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13;
|
|
102
|
+
const isBot = message.sender === "response";
|
|
103
|
+
// Per-message dir: Arabic content forces rtl regardless of the surface dir;
|
|
104
|
+
// otherwise follow the resolved surface dir.
|
|
105
|
+
const d = (0, dir_1.dirStyles)(message.text && /[-ۿ]/.test(message.text) ? "rtl" : dir);
|
|
106
|
+
const tokens = (0, linkify_1.linkify)((_a = message.text) !== null && _a !== void 0 ? _a : "");
|
|
107
|
+
const attachments = collectAttachments(message);
|
|
108
|
+
const quickReplies = Array.isArray(message.quick_replies) ? message.quick_replies : [];
|
|
109
|
+
// Web-parity bubble tokens (theme.bubble). Legacy callers/tests without a `bubble`
|
|
110
|
+
// block still work via the surface/text fallbacks below.
|
|
111
|
+
const bubbleStyle = (_b = theme.bubble) !== null && _b !== void 0 ? _b : {};
|
|
112
|
+
const quickReplyStyle = (_c = theme.quickReply) !== null && _c !== void 0 ? _c : {};
|
|
113
|
+
const chatStyle = (_d = theme.chat) !== null && _d !== void 0 ? _d : {};
|
|
114
|
+
// Per-side corner radius + squared tail corner (web: sent uses sendMsgCornerRadius,
|
|
115
|
+
// received uses receivedMsgCornerRadius; the tail corner is squared off).
|
|
116
|
+
const radius = (_f = (_e = (isBot ? bubbleStyle.receivedRadius : bubbleStyle.sentRadius)) !== null && _e !== void 0 ? _e : bubbleStyle.radius) !== null && _f !== void 0 ? _f : 8;
|
|
117
|
+
const padding = (_g = bubbleStyle.padding) !== null && _g !== void 0 ? _g : 8;
|
|
118
|
+
const marginX = (_h = bubbleStyle.marginX) !== null && _h !== void 0 ? _h : 15;
|
|
119
|
+
// dir.ts consumers (audit #10): alignSelf from the logical start/end edge,
|
|
120
|
+
// and the squared tail corner from bubbleTail(sender).
|
|
121
|
+
const alignSelf = bubbleAlignSelf(message.sender, d);
|
|
122
|
+
const corner = tailCornerStyle(d.bubbleTail(isBot ? "response" : "user"), radius);
|
|
123
|
+
// Sender differentiation (audit: bubbles were all white). Bot ("response") uses
|
|
124
|
+
// the received palette; user echoes use the sent palette. Falls back to the old
|
|
125
|
+
// surface/text colours when a theme has no `bubble` block (legacy callers/tests).
|
|
126
|
+
const backgroundColor = isBot
|
|
127
|
+
? ((_j = bubbleStyle.receivedBg) !== null && _j !== void 0 ? _j : theme.colors.surface)
|
|
128
|
+
: ((_k = bubbleStyle.sentBg) !== null && _k !== void 0 ? _k : theme.colors.surface);
|
|
129
|
+
const textColor = isBot
|
|
130
|
+
? ((_l = bubbleStyle.receivedText) !== null && _l !== void 0 ? _l : theme.colors.text)
|
|
131
|
+
: ((_m = bubbleStyle.sentText) !== null && _m !== void 0 ? _m : theme.colors.text);
|
|
132
|
+
// Link colour stays the brand primary on bot bubbles, but on the (coloured) user
|
|
133
|
+
// bubble that is unreadable — use the sent text colour there for contrast.
|
|
134
|
+
const linkColor = isBot ? theme.colors.primary : textColor;
|
|
135
|
+
// Per-message timestamp colour (web sent/receivedDateFontColor). Each side now
|
|
136
|
+
// has its own token (chatStyle.sentDateColor / receivedDateColor); fall back to
|
|
137
|
+
// the shared chat date colour, then the bubble text colour, so legacy themes
|
|
138
|
+
// without the split tokens keep the old behaviour.
|
|
139
|
+
const dateColor = isBot
|
|
140
|
+
? ((_p = (_o = chatStyle.receivedDateColor) !== null && _o !== void 0 ? _o : chatStyle.dateColor) !== null && _p !== void 0 ? _p : textColor)
|
|
141
|
+
: ((_r = (_q = chatStyle.sentDateColor) !== null && _q !== void 0 ? _q : chatStyle.dateColor) !== null && _r !== void 0 ? _r : textColor);
|
|
142
|
+
// Body typography (web defaultFontWeight + chatSectionStyle msg*). The message
|
|
143
|
+
// text size/weight come from the chat tokens, falling back to the global font
|
|
144
|
+
// size / weight (undefined weight => RN default). #7/#8/#9.
|
|
145
|
+
const baseWeight = (_s = theme === null || theme === void 0 ? void 0 : theme.font) === null || _s === void 0 ? void 0 : _s.weight;
|
|
146
|
+
const msgFontSize = (_t = chatStyle.msgFontSize) !== null && _t !== void 0 ? _t : theme.font.size;
|
|
147
|
+
const msgFontWeight = (_u = chatStyle.msgFontWeight) !== null && _u !== void 0 ? _u : baseWeight;
|
|
148
|
+
// Date row size/weight (web dateFontSize / dateFontWeight); default 11 to keep
|
|
149
|
+
// the historical timestamp size when unset.
|
|
150
|
+
const dateFontSize = (_v = chatStyle.dateFontSize) !== null && _v !== void 0 ? _v : 11;
|
|
151
|
+
const dateFontWeight = (_w = chatStyle.dateFontWeight) !== null && _w !== void 0 ? _w : baseWeight;
|
|
152
|
+
// Quick-reply chip typography (web quickReplyFontSize / quickReplyFontWeight).
|
|
153
|
+
const quickReplyFontSize = (_x = chatStyle.quickReplyFontSize) !== null && _x !== void 0 ? _x : theme.font.size;
|
|
154
|
+
const quickReplyFontWeight = (_y = chatStyle.quickReplyFontWeight) !== null && _y !== void 0 ? _y : baseWeight;
|
|
155
|
+
// Tenant custom font (item 28): resolve the loaded RN family for the weight EACH
|
|
156
|
+
// row renders. `undefined` (no tenant font, or no entry for that weight) => the
|
|
157
|
+
// style omits fontFamily and RN uses the system font (today's look).
|
|
158
|
+
const familyMap = (_z = theme === null || theme === void 0 ? void 0 : theme.font) === null || _z === void 0 ? void 0 : _z.familyMap;
|
|
159
|
+
const msgFontFamily = (0, themeFactory_1.resolveFontFamily)(familyMap, msgFontWeight);
|
|
160
|
+
const dateFontFamily = (0, themeFactory_1.resolveFontFamily)(familyMap, dateFontWeight);
|
|
161
|
+
const quickReplyFontFamily = (0, themeFactory_1.resolveFontFamily)(familyMap, quickReplyFontWeight);
|
|
162
|
+
// Soft drop shadow (web theme.palette.general.boxShadow). RN needs shadow* on iOS
|
|
163
|
+
// and elevation on Android — without this a white received bubble is invisible on
|
|
164
|
+
// the white chat background (user's #1 complaint).
|
|
165
|
+
const shadow = {
|
|
166
|
+
shadowColor: (_0 = bubbleStyle.shadowColor) !== null && _0 !== void 0 ? _0 : "#000",
|
|
167
|
+
shadowOpacity: (_1 = bubbleStyle.shadowOpacity) !== null && _1 !== void 0 ? _1 : 0.12,
|
|
168
|
+
shadowRadius: (_2 = bubbleStyle.shadowRadius) !== null && _2 !== void 0 ? _2 : 6,
|
|
169
|
+
shadowOffset: (_3 = bubbleStyle.shadowOffset) !== null && _3 !== void 0 ? _3 : { width: 0, height: 2 },
|
|
170
|
+
elevation: (_4 = bubbleStyle.elevation) !== null && _4 !== void 0 ? _4 : 2,
|
|
171
|
+
};
|
|
172
|
+
const timeLabel = showMessageDate && message.timestamp != null ? formatTime(message.timestamp) : "";
|
|
173
|
+
// Per-message avatar (item 25, web RecieverMsg.tsx:76-90/100-118): an agent avatar
|
|
174
|
+
// <Image> rendered on the leading edge of a RECEIVED bubble, gated by showAvatar &&
|
|
175
|
+
// avatar?.url. Sized from theme.chat.avatarMsg* (web chatSectionStyle avatarMsg*),
|
|
176
|
+
// circular. No url => no avatar (graceful fallback; web shows a default image, but
|
|
177
|
+
// the SDK has no bundled asset so it omits the element rather than shipping one).
|
|
178
|
+
const showMsgAvatar = isBot && showAvatar && Boolean(avatar === null || avatar === void 0 ? void 0 : avatar.url);
|
|
179
|
+
const avatarW = (_5 = chatStyle.avatarMsgWidth) !== null && _5 !== void 0 ? _5 : 32;
|
|
180
|
+
const avatarH = (_6 = chatStyle.avatarMsgHeight) !== null && _6 !== void 0 ? _6 : 32;
|
|
181
|
+
const avatarNode = showMsgAvatar ? ((0, jsx_runtime_1.jsx)(react_native_1.Image, { testID: testID ? `${testID}-avatar` : "bubble-avatar", accessibilityRole: "image", accessibilityLabel: (_7 = avatar === null || avatar === void 0 ? void 0 : avatar.name) !== null && _7 !== void 0 ? _7 : "agent avatar", source: { uri: avatar.url }, resizeMode: "cover", style: {
|
|
182
|
+
width: avatarW,
|
|
183
|
+
height: avatarH,
|
|
184
|
+
borderRadius: Math.max(avatarW, avatarH) / 2,
|
|
185
|
+
marginHorizontal: 6,
|
|
186
|
+
alignSelf: "flex-end",
|
|
187
|
+
} })) : null;
|
|
188
|
+
// Feedback thumbs row (web parity, RecieverMsg.tsx:216-259). Shown ONLY on a
|
|
189
|
+
// received bubble that carries the server gate (`options.enableReaction`) AND a
|
|
190
|
+
// stable server id (`messageKey`) — both must be present or the row is unreachable.
|
|
191
|
+
// Active reaction = full-opacity primary (like) / danger (dislike); inactive = dimmed.
|
|
192
|
+
const showReactions = isBot && Boolean(message.messageKey) && Boolean((_8 = message.options) === null || _8 === void 0 ? void 0 : _8.enableReaction);
|
|
193
|
+
const reaction = (_9 = message.userReaction) !== null && _9 !== void 0 ? _9 : null;
|
|
194
|
+
const likeActive = reaction === "like";
|
|
195
|
+
const dislikeActive = reaction === "dislike";
|
|
196
|
+
// Danger colour for an active dislike (web error.main); fall back to a red when the
|
|
197
|
+
// theme has no dedicated danger token. Like uses the brand primary.
|
|
198
|
+
const dangerColor = (_13 = (_11 = (_10 = theme.colors) === null || _10 === void 0 ? void 0 : _10.danger) !== null && _11 !== void 0 ? _11 : (_12 = theme.colors) === null || _12 === void 0 ? void 0 : _12.error) !== null && _13 !== void 0 ? _13 : "#EB5757";
|
|
199
|
+
const likeColor = theme.colors.primary;
|
|
200
|
+
const DIMMED = 0.5;
|
|
201
|
+
// The bubble + its quick-reply chips share a column so the chips sit BELOW the
|
|
202
|
+
// bubble (web renders them outside/under the received bubble), aligned to the
|
|
203
|
+
// same leading edge and capped so a long thread doesn't span the full width.
|
|
204
|
+
// When a per-message avatar is shown, the column sits in a row beside the avatar
|
|
205
|
+
// (avatar on the leading edge, flipped under RTL via d.rowDirection).
|
|
206
|
+
const column = ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { alignSelf, maxWidth: avatarNode ? undefined : "85%", flexShrink: 1 }, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { testID: testID, accessible: true, accessibilityRole: "text",
|
|
207
|
+
// announce only bot messages (per Task 16 note); user echoes are not live regions
|
|
208
|
+
accessibilityLiveRegion: isBot ? "polite" : "none", style: {
|
|
209
|
+
backgroundColor,
|
|
210
|
+
padding,
|
|
211
|
+
borderRadius: radius,
|
|
212
|
+
marginHorizontal: marginX,
|
|
213
|
+
...corner,
|
|
214
|
+
...shadow,
|
|
215
|
+
}, children: [message.text ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: { color: textColor, fontSize: msgFontSize, fontWeight: msgFontWeight, fontFamily: msgFontFamily, textAlign: d.textAlign }, children: tokens.map((t, i) => t.type === "link"
|
|
216
|
+
? (0, jsx_runtime_1.jsx)(react_native_1.Text, { accessibilityRole: "link", style: { color: linkColor, textDecorationLine: "underline" }, onPress: () => (0, openLink_1.openLink)(t.href), children: t.value }, i)
|
|
217
|
+
: (0, jsx_runtime_1.jsx)(react_native_1.Text, { children: t.value }, i)) })) : null, attachments.map((a, i) => {
|
|
218
|
+
var _a, _b;
|
|
219
|
+
// Route every file (received AND sent) by its detected media kind, web
|
|
220
|
+
// FileDisplay parity: image -> ImageBubble, video -> VideoBubble, audio
|
|
221
|
+
// -> inline VoiceMessage (unchanged), document/other -> FileTile.
|
|
222
|
+
const kind = attachmentKind(a.uri, a.file);
|
|
223
|
+
// Received voice (audit #23): an audio attachment renders as an inline
|
|
224
|
+
// <VoiceMessage> when an audioAdapter is injected; otherwise we fall back
|
|
225
|
+
// to a plain FileTile (download link) so chat-only consumers still work.
|
|
226
|
+
if (kind === "audio" && audioAdapter) {
|
|
227
|
+
return ((0, jsx_runtime_1.jsx)(VoiceMessage_1.VoiceMessage, { testID: testID ? `${testID}-voice-${i}` : undefined, adapter: audioAdapter, uri: a.uri, durationMs: (_b = (_a = a.file) === null || _a === void 0 ? void 0 : _a.durationMs) !== null && _b !== void 0 ? _b : 0, theme: theme }, `att-${i}`));
|
|
228
|
+
}
|
|
229
|
+
if (kind === "image") {
|
|
230
|
+
return ((0, jsx_runtime_1.jsx)(ImageBubble_1.ImageBubble, { testID: testID ? `${testID}-image-${i}` : undefined, uri: a.uri, name: a.name, theme: theme }, `att-${i}`));
|
|
231
|
+
}
|
|
232
|
+
if (kind === "video") {
|
|
233
|
+
return ((0, jsx_runtime_1.jsx)(VideoBubble_1.VideoBubble, { testID: testID ? `${testID}-video-${i}` : undefined, uri: a.uri, name: a.name, theme: theme }, `att-${i}`));
|
|
234
|
+
}
|
|
235
|
+
// audio without an adapter, document, or anything unrecognized -> FileTile.
|
|
236
|
+
return ((0, jsx_runtime_1.jsx)(FileTile_1.FileTile, { testID: testID ? `${testID}-file-${i}` : undefined, uri: a.uri, name: a.name, theme: theme }, `att-${i}`));
|
|
237
|
+
}), timeLabel ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { testID: testID ? `${testID}-time` : undefined, style: { color: dateColor, fontSize: dateFontSize, fontWeight: dateFontWeight, fontFamily: dateFontFamily, marginTop: 2, alignSelf: "flex-end" }, children: timeLabel })) : null] }), showReactions ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: {
|
|
238
|
+
flexDirection: d.rowDirection,
|
|
239
|
+
gap: 12,
|
|
240
|
+
marginTop: 6,
|
|
241
|
+
marginHorizontal: marginX,
|
|
242
|
+
alignSelf,
|
|
243
|
+
}, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: testID ? `${testID}-reaction-like` : "reaction-like", accessibilityRole: "button", accessibilityLabel: "like", accessibilityState: { selected: likeActive }, onPress: () => react === null || react === void 0 ? void 0 : react(message.messageKey, "like"), style: { opacity: likeActive ? 1 : DIMMED }, children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "thumbUp", color: likeColor, size: 18 }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: testID ? `${testID}-reaction-dislike` : "reaction-dislike", accessibilityRole: "button", accessibilityLabel: "dislike", accessibilityState: { selected: dislikeActive }, onPress: () => react === null || react === void 0 ? void 0 : react(message.messageKey, "dislike"), style: { opacity: dislikeActive ? 1 : DIMMED }, children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "thumbDown", color: dangerColor, size: 18 }) })] })) : null, isBot && quickReplies.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
244
|
+
flexDirection: d.rowDirection,
|
|
245
|
+
flexWrap: "wrap",
|
|
246
|
+
gap: 8,
|
|
247
|
+
marginTop: 6,
|
|
248
|
+
marginHorizontal: marginX,
|
|
249
|
+
}, children: quickReplies.map((q) => {
|
|
250
|
+
var _a, _b, _c, _d, _e;
|
|
251
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: testID ? `${testID}-qr-${q.payload}` : undefined, accessibilityRole: "button", accessibilityLabel: q.title, onPress: () => onQuickReply === null || onQuickReply === void 0 ? void 0 : onQuickReply(q), style: {
|
|
252
|
+
backgroundColor: (_a = quickReplyStyle.bg) !== null && _a !== void 0 ? _a : "#FFFFFF",
|
|
253
|
+
borderWidth: (_b = quickReplyStyle.borderWidth) !== null && _b !== void 0 ? _b : 1,
|
|
254
|
+
borderColor: (_c = quickReplyStyle.borderColor) !== null && _c !== void 0 ? _c : theme.colors.primary,
|
|
255
|
+
borderRadius: (_d = quickReplyStyle.borderRadius) !== null && _d !== void 0 ? _d : 15,
|
|
256
|
+
paddingHorizontal: 12,
|
|
257
|
+
paddingVertical: 6,
|
|
258
|
+
}, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: { color: (_e = quickReplyStyle.color) !== null && _e !== void 0 ? _e : theme.colors.primary, fontSize: quickReplyFontSize, fontWeight: quickReplyFontWeight, fontFamily: quickReplyFontFamily }, children: q.title }) }, q.payload));
|
|
259
|
+
}) })) : null] }));
|
|
260
|
+
// No avatar: return the column directly (back-compat — existing layout unchanged).
|
|
261
|
+
if (!avatarNode)
|
|
262
|
+
return column;
|
|
263
|
+
// With an avatar: avatar + column in a row on the leading edge (RTL flips via row).
|
|
264
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { flexDirection: d.rowDirection, alignSelf, maxWidth: "90%" }, children: [avatarNode, column] }));
|
|
265
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Dir } from "../theme/dir";
|
|
2
|
+
export interface CallControlsProps {
|
|
3
|
+
/** toggle the local microphone track (mirror of web handleMuteUnmute) */
|
|
4
|
+
onToggleMute: () => void;
|
|
5
|
+
/** toggle the local camera track (mirror of web handleStartStopVideo) */
|
|
6
|
+
onToggleCamera: () => void;
|
|
7
|
+
/** flip the local camera front<->back (RN-only; reaches client.switchCamera) */
|
|
8
|
+
onFlipCamera?: () => void;
|
|
9
|
+
/** toggle the audio output route (speakerphone <-> earpiece, audit #25) */
|
|
10
|
+
onToggleSpeaker?: () => void;
|
|
11
|
+
/** leave the call — host tears down the PC + releases the camera */
|
|
12
|
+
onLeave: () => void;
|
|
13
|
+
/** reflects the current mic state so the label/icon track the live track */
|
|
14
|
+
isMuted?: boolean;
|
|
15
|
+
/** reflects the current camera state (true == camera off) */
|
|
16
|
+
isCameraOff?: boolean;
|
|
17
|
+
/** reflects the current speaker route (true == speakerphone, B14 default) */
|
|
18
|
+
isSpeaker?: boolean;
|
|
19
|
+
/** disable the whole bar (e.g. while connecting / after teardown) */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** layout direction; controls mirror under "rtl" */
|
|
22
|
+
dir?: Dir;
|
|
23
|
+
/** resolved language — localizes the control labels (audit #8). */
|
|
24
|
+
language?: string;
|
|
25
|
+
theme: any;
|
|
26
|
+
}
|
|
27
|
+
export declare function CallControls({ onToggleMute, onToggleCamera, onFlipCamera, onToggleSpeaker, onLeave, isMuted, isCameraOff, isSpeaker, disabled, dir, language, theme, }: CallControlsProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CallControls = CallControls;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_native_1 = require("react-native");
|
|
6
|
+
const dir_1 = require("../theme/dir");
|
|
7
|
+
const i18n_1 = require("../i18n");
|
|
8
|
+
const Icon_1 = require("./Icon");
|
|
9
|
+
// --- Call-dock visual constants ---------------------------------------------
|
|
10
|
+
const SIZE = 56; // circular toggle touch target (mic / camera / flip / speaker)
|
|
11
|
+
const END_SIZE = 64; // larger, prominent hang-up
|
|
12
|
+
const ICON = 26;
|
|
13
|
+
const END_ICON = 28;
|
|
14
|
+
const IDLE_FILL = "rgba(255,255,255,0.16)"; // translucent "glass" on the dark dock
|
|
15
|
+
const ENGAGED_FILL = "#FFFFFF"; // engaged toggle -> solid light chip (iOS idiom)
|
|
16
|
+
const IDLE_ICON = "#FFFFFF";
|
|
17
|
+
const ENGAGED_ICON = "#1A1A1A"; // dark glyph on the light chip
|
|
18
|
+
function CallControls({ onToggleMute, onToggleCamera, onFlipCamera, onToggleSpeaker, onLeave, isMuted = false, isCameraOff = false, isSpeaker = true, disabled = false, dir = "ltr", language, theme, }) {
|
|
19
|
+
var _a, _b, _c, _d;
|
|
20
|
+
const d = (0, dir_1.dirStyles)(dir);
|
|
21
|
+
const t = (0, i18n_1.makeT)(language);
|
|
22
|
+
// End-call reads as destructive: theme danger/error if the brand defines one,
|
|
23
|
+
// else the iOS system red. Used for the fill + a soft glow so it pops off the dock.
|
|
24
|
+
const endFill = (_d = (_b = (_a = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _a === void 0 ? void 0 : _a.danger) !== null && _b !== void 0 ? _b : (_c = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _c === void 0 ? void 0 : _c.error) !== null && _d !== void 0 ? _d : "#FF3B30";
|
|
25
|
+
// Round toggle button: a solid light chip when ENGAGED, translucent otherwise;
|
|
26
|
+
// gentle press feedback (dim + scale). `disabled` dims the whole control.
|
|
27
|
+
const toggleStyle = (engaged) => ({ pressed }) => ({
|
|
28
|
+
width: SIZE,
|
|
29
|
+
height: SIZE,
|
|
30
|
+
borderRadius: SIZE / 2,
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
justifyContent: "center",
|
|
33
|
+
backgroundColor: engaged ? ENGAGED_FILL : IDLE_FILL,
|
|
34
|
+
opacity: disabled ? 0.4 : pressed ? 0.7 : 1,
|
|
35
|
+
transform: [{ scale: pressed ? 0.94 : 1 }],
|
|
36
|
+
});
|
|
37
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { testID: "call-controls", accessibilityRole: "toolbar", style: {
|
|
38
|
+
flexDirection: d.rowDirection,
|
|
39
|
+
alignItems: "center",
|
|
40
|
+
// space-around keeps the large buttons evenly spaced and never overflows,
|
|
41
|
+
// whether the optional flip/speaker controls are present or not.
|
|
42
|
+
justifyContent: "space-around",
|
|
43
|
+
paddingTop: 18,
|
|
44
|
+
paddingBottom: 22,
|
|
45
|
+
paddingHorizontal: 10,
|
|
46
|
+
// The dock is a dark sheet rising from the bottom over the (black) video,
|
|
47
|
+
// with rounded top corners + a faint top hairline to lift it off the frame.
|
|
48
|
+
backgroundColor: "rgba(17,18,20,0.96)",
|
|
49
|
+
borderTopLeftRadius: 28,
|
|
50
|
+
borderTopRightRadius: 28,
|
|
51
|
+
borderTopWidth: 1,
|
|
52
|
+
borderTopColor: "rgba(255,255,255,0.08)",
|
|
53
|
+
}, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "call-mute", accessibilityRole: "button",
|
|
54
|
+
// Label describes the ACTION the press performs, and flips with state so
|
|
55
|
+
// screen-reader users hear "Mute" vs "Unmute" depending on the live track.
|
|
56
|
+
accessibilityLabel: isMuted ? t("unmute") : t("mute"), accessibilityState: { disabled, selected: isMuted }, disabled: disabled, onPress: () => {
|
|
57
|
+
if (!disabled)
|
|
58
|
+
onToggleMute();
|
|
59
|
+
}, style: toggleStyle(isMuted), children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: isMuted ? "micOff" : "mic", color: isMuted ? ENGAGED_ICON : IDLE_ICON, size: ICON }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "call-camera", accessibilityRole: "button", accessibilityLabel: isCameraOff ? t("camera_on") : t("camera_off"), accessibilityState: { disabled, selected: isCameraOff }, disabled: disabled, onPress: () => {
|
|
60
|
+
if (!disabled)
|
|
61
|
+
onToggleCamera();
|
|
62
|
+
}, style: toggleStyle(isCameraOff), children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: isCameraOff ? "videocamOff" : "videocam", color: isCameraOff ? ENGAGED_ICON : IDLE_ICON, size: ICON }) }), onFlipCamera ? ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "call-flip", accessibilityRole: "button", accessibilityLabel: t("flip_camera"), accessibilityState: { disabled }, disabled: disabled, onPress: () => {
|
|
63
|
+
if (!disabled)
|
|
64
|
+
onFlipCamera();
|
|
65
|
+
}, style: toggleStyle(false), children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "cameraSwitch", color: IDLE_ICON, size: ICON }) })) : null, onToggleSpeaker ? ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "call-speaker", accessibilityRole: "button",
|
|
66
|
+
// Label describes the route the press switches TO, and reflects the
|
|
67
|
+
// live route so screen-reader users hear the current state.
|
|
68
|
+
accessibilityLabel: isSpeaker ? "Switch to earpiece" : "Switch to speakerphone", accessibilityState: { disabled, selected: isSpeaker }, disabled: disabled, onPress: () => {
|
|
69
|
+
if (!disabled)
|
|
70
|
+
onToggleSpeaker();
|
|
71
|
+
}, style: toggleStyle(isSpeaker), children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: isSpeaker ? "speaker" : "speakerOff", color: isSpeaker ? ENGAGED_ICON : IDLE_ICON, size: ICON }) })) : null, (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "call-leave", accessibilityRole: "button", accessibilityLabel: t("leave_call"), accessibilityState: { disabled }, disabled: disabled, onPress: () => {
|
|
72
|
+
if (!disabled)
|
|
73
|
+
onLeave();
|
|
74
|
+
},
|
|
75
|
+
// The destructive hang-up: a larger solid-red circle with a white handset
|
|
76
|
+
// and a soft red glow so it's the unmistakable primary action on the dock.
|
|
77
|
+
style: ({ pressed }) => ({
|
|
78
|
+
width: END_SIZE,
|
|
79
|
+
height: END_SIZE,
|
|
80
|
+
borderRadius: END_SIZE / 2,
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "center",
|
|
83
|
+
backgroundColor: endFill,
|
|
84
|
+
opacity: disabled ? 0.4 : pressed ? 0.85 : 1,
|
|
85
|
+
transform: [{ scale: pressed ? 0.94 : 1 }],
|
|
86
|
+
shadowColor: endFill,
|
|
87
|
+
shadowOpacity: 0.5,
|
|
88
|
+
shadowRadius: 10,
|
|
89
|
+
shadowOffset: { width: 0, height: 4 },
|
|
90
|
+
elevation: 6,
|
|
91
|
+
}), children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: "callEnd", color: "#fff", size: END_ICON }) })] }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ViewStyle } from "react-native";
|
|
2
|
+
export interface CallPlaceholderProps {
|
|
3
|
+
variant: "waiting" | "cameraOff";
|
|
4
|
+
/** agent display name (waiting variant) */
|
|
5
|
+
name?: string;
|
|
6
|
+
/** agent avatar URL (waiting variant); falls back to initials when absent */
|
|
7
|
+
avatarUrl?: string;
|
|
8
|
+
theme: any;
|
|
9
|
+
/** resolved language — localizes the status copy */
|
|
10
|
+
language?: string;
|
|
11
|
+
/** layout/position style merged onto the container */
|
|
12
|
+
style?: ViewStyle;
|
|
13
|
+
testID?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function CallPlaceholder({ variant, name, avatarUrl, theme, language, style, testID, }: CallPlaceholderProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export default CallPlaceholder;
|