@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,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VideoCall = VideoCall;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
// src/ui/VideoCall.tsx
|
|
6
|
+
// In-call screen (Plan Task 5). Wires a VideoCallClient (core signaling state
|
|
7
|
+
// machine) to the UI: local + remote VideoTiles + a CallControls bar. WebRTC and
|
|
8
|
+
// audio routing are reached ONLY through injectable seams so the whole screen is
|
|
9
|
+
// exercisable in RNTL with fakes (no native modules):
|
|
10
|
+
//
|
|
11
|
+
// - `webrtcFactory` defaults to the real `reactNativeWebRTCFactory()`; tests
|
|
12
|
+
// inject a fake factory (createPeerConnection/getUserMedia).
|
|
13
|
+
// - `audioRoute` defaults to the real `reactNativeAudioRoute()`; tests inject
|
|
14
|
+
// a spy AudioRoute. start()/stop() bracket the call so remote
|
|
15
|
+
// audio is audible (speakerphone default, B14) and the session
|
|
16
|
+
// is restored on teardown (Plan Task 4).
|
|
17
|
+
// - `createClient` defaults to `createVideoCallClient`; tests can inject a
|
|
18
|
+
// FakeSocket-backed (or fully fake) client to drive events.
|
|
19
|
+
//
|
|
20
|
+
// Lifecycle: on mount we build the client, subscribe to localStream/remoteStream/
|
|
21
|
+
// ended/error, start the audio route, and `start()` the call (createAnswer-only
|
|
22
|
+
// signaling — see core/VideoCallClient). Leave (control or chat-close S6) tears
|
|
23
|
+
// down the client + stops the audio route + calls onEnd. A getUserMedia denial
|
|
24
|
+
// surfaces an in-call error with a Settings deep-link (C13).
|
|
25
|
+
const react_1 = require("react");
|
|
26
|
+
const react_native_1 = require("react-native");
|
|
27
|
+
const react_native_safe_area_context_1 = require("react-native-safe-area-context");
|
|
28
|
+
const CallControls_1 = require("./CallControls");
|
|
29
|
+
const CallPlaceholder_1 = require("./CallPlaceholder");
|
|
30
|
+
const VideoTile_1 = require("./VideoTile");
|
|
31
|
+
const VideoCallClient_1 = require("../core/VideoCallClient");
|
|
32
|
+
const webrtc_1 = require("../adapters/webrtc");
|
|
33
|
+
const audioRoute_1 = require("../adapters/audioRoute");
|
|
34
|
+
const i18n_1 = require("../i18n");
|
|
35
|
+
function VideoCall({ socket, getSessionId, onEnd, webrtcFactory, audioRoute, createClient = VideoCallClient_1.createVideoCallClient, dir = "ltr", language, agentName, agentAvatarUrl, theme, }) {
|
|
36
|
+
var _a, _b, _c, _d;
|
|
37
|
+
const t = (0, i18n_1.makeT)(language);
|
|
38
|
+
const [localStream, setLocalStream] = (0, react_1.useState)(null);
|
|
39
|
+
const [remoteStream, setRemoteStream] = (0, react_1.useState)(null);
|
|
40
|
+
const [isMuted, setIsMuted] = (0, react_1.useState)(false);
|
|
41
|
+
const [isCameraOff, setIsCameraOff] = (0, react_1.useState)(false);
|
|
42
|
+
// Speakerphone is the default route for a video call (B14); the toggle
|
|
43
|
+
// flips between speakerphone and the earpiece/OS-preferred route.
|
|
44
|
+
const [isSpeaker, setIsSpeaker] = (0, react_1.useState)(true);
|
|
45
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
46
|
+
const [ended, setEnded] = (0, react_1.useState)(false);
|
|
47
|
+
const clientRef = (0, react_1.useRef)(null);
|
|
48
|
+
const audioRef = (0, react_1.useRef)(null);
|
|
49
|
+
// onEnd kept in a ref so the mount effect can stay [] and not re-run/leak the call.
|
|
50
|
+
const onEndRef = (0, react_1.useRef)(onEnd);
|
|
51
|
+
onEndRef.current = onEnd;
|
|
52
|
+
(0, react_1.useEffect)(() => {
|
|
53
|
+
const factory = webrtcFactory !== null && webrtcFactory !== void 0 ? webrtcFactory : (0, webrtc_1.reactNativeWebRTCFactory)();
|
|
54
|
+
const route = audioRoute !== null && audioRoute !== void 0 ? audioRoute : (0, audioRoute_1.reactNativeAudioRoute)();
|
|
55
|
+
audioRef.current = route;
|
|
56
|
+
const client = createClient({
|
|
57
|
+
socket,
|
|
58
|
+
webrtcFactory: factory,
|
|
59
|
+
getSessionId,
|
|
60
|
+
});
|
|
61
|
+
clientRef.current = client;
|
|
62
|
+
client.on("localStream", (s) => setLocalStream(s));
|
|
63
|
+
client.on("remoteStream", (s) => setRemoteStream(s));
|
|
64
|
+
client.on("ended", () => {
|
|
65
|
+
var _a;
|
|
66
|
+
setEnded(true);
|
|
67
|
+
(_a = onEndRef.current) === null || _a === void 0 ? void 0 : _a.call(onEndRef);
|
|
68
|
+
});
|
|
69
|
+
client.on("error", (e) => {
|
|
70
|
+
var _a;
|
|
71
|
+
// getUserMedia denial (and any start/signaling failure) -> in-call error
|
|
72
|
+
// with a Settings deep-link (C13). The call never connected, so release
|
|
73
|
+
// the audio route too.
|
|
74
|
+
setError((_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : t("call_error"));
|
|
75
|
+
try {
|
|
76
|
+
route.stop();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* route may never have started */
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Bracket the call with the audio session: speakerphone default for video.
|
|
83
|
+
try {
|
|
84
|
+
route.start({ media: "video" });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* non-fatal: audio route may be unavailable; the call can still render */
|
|
88
|
+
}
|
|
89
|
+
void client.start();
|
|
90
|
+
// Cleanup (unmount / chat-close S6): leave fully tears down + releases camera.
|
|
91
|
+
return () => {
|
|
92
|
+
try {
|
|
93
|
+
client.leave();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* already torn down */
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
route.stop();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* already stopped */
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
// Mount-once: the seams are captured on first render by design (a live call
|
|
106
|
+
// must not be rebuilt when a parent re-renders). eslint-disable next line.
|
|
107
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
|
+
}, []);
|
|
109
|
+
const handleToggleMute = () => {
|
|
110
|
+
var _a, _b;
|
|
111
|
+
const muted = (_b = (_a = clientRef.current) === null || _a === void 0 ? void 0 : _a.toggleMute()) !== null && _b !== void 0 ? _b : false;
|
|
112
|
+
setIsMuted(muted);
|
|
113
|
+
};
|
|
114
|
+
const handleToggleCamera = () => {
|
|
115
|
+
var _a, _b;
|
|
116
|
+
const off = (_b = (_a = clientRef.current) === null || _a === void 0 ? void 0 : _a.toggleVideo()) !== null && _b !== void 0 ? _b : false;
|
|
117
|
+
setIsCameraOff(off);
|
|
118
|
+
};
|
|
119
|
+
// Flip front<->back. Reaches the foundation client.switchCamera(), which
|
|
120
|
+
// prefers the native track's _switchCamera() (multi-camera safe) and falls
|
|
121
|
+
// back to an explicit back-camera getUserMedia + replaceTrack.
|
|
122
|
+
const handleFlipCamera = () => {
|
|
123
|
+
var _a;
|
|
124
|
+
void ((_a = clientRef.current) === null || _a === void 0 ? void 0 : _a.switchCamera());
|
|
125
|
+
};
|
|
126
|
+
const handleToggleSpeaker = () => {
|
|
127
|
+
setIsSpeaker((prev) => {
|
|
128
|
+
var _a, _b;
|
|
129
|
+
const next = !prev;
|
|
130
|
+
try {
|
|
131
|
+
(_b = (_a = audioRef.current) === null || _a === void 0 ? void 0 : _a.setSpeaker) === null || _b === void 0 ? void 0 : _b.call(_a, next);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* audio route may be unavailable; keep the UI state in sync regardless */
|
|
135
|
+
}
|
|
136
|
+
return next;
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
const handleLeave = () => {
|
|
140
|
+
var _a, _b;
|
|
141
|
+
try {
|
|
142
|
+
(_a = clientRef.current) === null || _a === void 0 ? void 0 : _a.leave();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
try {
|
|
146
|
+
(_b = audioRef.current) === null || _b === void 0 ? void 0 : _b.stop();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* already stopped */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_safe_area_context_1.SafeAreaView, { testID: "video-call", style: { flex: 1, backgroundColor: "#000" }, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { flex: 1 }, children: [(0, jsx_runtime_1.jsx)(VideoTile_1.VideoTile, { kind: "remote", stream: remoteStream, objectFit: "cover", style: { flex: 1 } }), !remoteStream && !error && !ended ? ((0, jsx_runtime_1.jsx)(CallPlaceholder_1.CallPlaceholder, { variant: "waiting", name: agentName, avatarUrl: agentAvatarUrl, theme: theme, language: language, style: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 } })) : null, (0, jsx_runtime_1.jsx)(VideoTile_1.VideoTile, { kind: "local", stream: localStream, mirror: true, objectFit: "cover", style: {
|
|
154
|
+
position: "absolute",
|
|
155
|
+
top: 16,
|
|
156
|
+
right: dir === "rtl" ? undefined : 16,
|
|
157
|
+
left: dir === "rtl" ? 16 : undefined,
|
|
158
|
+
width: 110,
|
|
159
|
+
aspectRatio: 3 / 4,
|
|
160
|
+
borderRadius: 10,
|
|
161
|
+
} }), isCameraOff && !error ? ((0, jsx_runtime_1.jsx)(CallPlaceholder_1.CallPlaceholder, { variant: "cameraOff", theme: theme, language: language, testID: "call-local-camera-off", style: {
|
|
162
|
+
position: "absolute",
|
|
163
|
+
top: 16,
|
|
164
|
+
right: dir === "rtl" ? undefined : 16,
|
|
165
|
+
left: dir === "rtl" ? 16 : undefined,
|
|
166
|
+
width: 110,
|
|
167
|
+
aspectRatio: 3 / 4,
|
|
168
|
+
borderRadius: 10,
|
|
169
|
+
} })) : null, error ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { testID: "video-call-error", accessibilityRole: "alert", style: {
|
|
170
|
+
position: "absolute",
|
|
171
|
+
left: 16,
|
|
172
|
+
right: 16,
|
|
173
|
+
top: 0,
|
|
174
|
+
bottom: 0,
|
|
175
|
+
alignItems: "center",
|
|
176
|
+
justifyContent: "center",
|
|
177
|
+
padding: 24,
|
|
178
|
+
}, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: {
|
|
179
|
+
color: "#fff",
|
|
180
|
+
fontSize: ((_b = (_a = theme === null || theme === void 0 ? void 0 : theme.font) === null || _a === void 0 ? void 0 : _a.size) !== null && _b !== void 0 ? _b : 14) + 2,
|
|
181
|
+
textAlign: "center",
|
|
182
|
+
marginBottom: 16,
|
|
183
|
+
}, children: error }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: "video-call-settings", accessibilityRole: "button", accessibilityLabel: "Open settings to grant camera and microphone access", onPress: () => {
|
|
184
|
+
void react_native_1.Linking.openSettings();
|
|
185
|
+
}, style: {
|
|
186
|
+
paddingVertical: 10,
|
|
187
|
+
paddingHorizontal: 20,
|
|
188
|
+
borderRadius: 8,
|
|
189
|
+
backgroundColor: (_d = (_c = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _c === void 0 ? void 0 : _c.primary) !== null && _d !== void 0 ? _d : "#6C5CE7",
|
|
190
|
+
}, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: { color: "#fff" }, children: t("open_settings") }) })] })) : null] }), (0, jsx_runtime_1.jsx)(CallControls_1.CallControls, { onToggleMute: handleToggleMute, onToggleCamera: handleToggleCamera, onFlipCamera: handleFlipCamera, onToggleSpeaker: handleToggleSpeaker, onLeave: handleLeave, isMuted: isMuted, isCameraOff: isCameraOff, isSpeaker: isSpeaker, disabled: ended || !!error, dir: dir, language: language, theme: theme })] }));
|
|
191
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ViewStyle } from "react-native";
|
|
2
|
+
/** The subset of a media stream a tile needs: a `toURL()` for RTCView. */
|
|
3
|
+
export interface VideoTileStream {
|
|
4
|
+
toURL?(): string;
|
|
5
|
+
}
|
|
6
|
+
export interface VideoTileProps {
|
|
7
|
+
/** the stream to render; when null/undefined the tile is an empty placeholder */
|
|
8
|
+
stream?: VideoTileStream | null;
|
|
9
|
+
/** mirror the local preview so it reads like a front-facing camera */
|
|
10
|
+
mirror?: boolean;
|
|
11
|
+
/** "local" | "remote" — drives the testID + a11y label */
|
|
12
|
+
kind: "local" | "remote";
|
|
13
|
+
/** extra layout style merged onto the tile container */
|
|
14
|
+
style?: ViewStyle;
|
|
15
|
+
/** accessibility label override (defaults from `kind`) */
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
/**
|
|
18
|
+
* How the frame fits the tile. "cover" fills the tile and crops the edges
|
|
19
|
+
* (the mobile-native default — fullscreen remote, self-view thumbnail);
|
|
20
|
+
* "contain" letterboxes so the whole frame is visible. Tunable per-tile so the
|
|
21
|
+
* crop-vs-letterbox tradeoff can be flipped on-device without a layout rewrite.
|
|
22
|
+
*/
|
|
23
|
+
objectFit?: "contain" | "cover";
|
|
24
|
+
}
|
|
25
|
+
export declare function VideoTile({ stream, mirror, kind, style, accessibilityLabel, objectFit, }: VideoTileProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VideoTile = VideoTile;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_native_1 = require("react-native");
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
7
|
+
const react_native_webrtc_1 = require("react-native-webrtc");
|
|
8
|
+
function VideoTile({ stream, mirror = false, kind, style, accessibilityLabel, objectFit = "cover", }) {
|
|
9
|
+
var _a, _b;
|
|
10
|
+
const streamURL = (_b = (_a = stream === null || stream === void 0 ? void 0 : stream.toURL) === null || _a === void 0 ? void 0 : _a.call(stream)) !== null && _b !== void 0 ? _b : null;
|
|
11
|
+
const label = accessibilityLabel !== null && accessibilityLabel !== void 0 ? accessibilityLabel : (kind === "local" ? "Your camera preview" : "Remote video");
|
|
12
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { testID: `video-tile-${kind}`, accessibilityLabel: label, style: [{ backgroundColor: "#000", overflow: "hidden" }, style], children: streamURL ? ((0, jsx_runtime_1.jsx)(react_native_webrtc_1.RTCView, { testID: `rtcview-${kind}`, streamURL: streamURL, mirror: mirror, objectFit: objectFit, style: { flex: 1 } })) : null }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AudioAdapter } from "../adapters/audio";
|
|
2
|
+
export interface VoiceMessageProps {
|
|
3
|
+
/** injected playback surface (fake in tests, real player on device) */
|
|
4
|
+
adapter: AudioAdapter;
|
|
5
|
+
/** local or hosted URI of the voice note */
|
|
6
|
+
uri: string;
|
|
7
|
+
/** recorded length, for the inline duration label */
|
|
8
|
+
durationMs?: number;
|
|
9
|
+
/**
|
|
10
|
+
* suspend playback (mic/audio mutual-exclusion with a video call, §4.4).
|
|
11
|
+
* When true the toggle is disabled.
|
|
12
|
+
*/
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** optional typed-error sink for play/stop failures */
|
|
15
|
+
onError?: (err: unknown) => void;
|
|
16
|
+
theme: any;
|
|
17
|
+
testID?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function VoiceMessage({ adapter, uri, durationMs, disabled, onError, theme, testID, }: VoiceMessageProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoiceMessage = VoiceMessage;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
// src/ui/VoiceMessage.tsx
|
|
6
|
+
// Inline voice-note player (Task 6 UI). PROP-DRIVEN: playback is the injected
|
|
7
|
+
// AudioAdapter prop (never a direct native module), so the play/pause toggle is
|
|
8
|
+
// exercised in tests with a fake that resolves immediately.
|
|
9
|
+
const react_1 = require("react");
|
|
10
|
+
const react_native_1 = require("react-native");
|
|
11
|
+
const Icon_1 = require("./Icon");
|
|
12
|
+
// Static waveform motif (bar heights, px). Decorative — the AudioAdapter exposes
|
|
13
|
+
// only play()/stop() with no position callback, so there is no live scrubber; the
|
|
14
|
+
// whole waveform dims when paused and lights up while playing.
|
|
15
|
+
const WAVEFORM = [10, 16, 24, 14, 20, 12, 22, 28, 16, 10, 22, 14, 18, 12, 9];
|
|
16
|
+
/** mm:ss formatter for the duration label. */
|
|
17
|
+
function formatDuration(ms) {
|
|
18
|
+
const total = Math.floor(ms / 1000);
|
|
19
|
+
const m = Math.floor(total / 60);
|
|
20
|
+
const s = total % 60;
|
|
21
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
function VoiceMessage({ adapter, uri, durationMs = 0, disabled = false, onError, theme, testID = "voice-message", }) {
|
|
24
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
25
|
+
const [playing, setPlaying] = (0, react_1.useState)(false);
|
|
26
|
+
// Live playback position; total prefers the engine-reported duration (fills in for
|
|
27
|
+
// received notes that arrive with durationMs:0) and falls back to the prop.
|
|
28
|
+
const [positionMs, setPositionMs] = (0, react_1.useState)(0);
|
|
29
|
+
const [playedTotalMs, setPlayedTotalMs] = (0, react_1.useState)(0);
|
|
30
|
+
const mountedRef = (0, react_1.useRef)(true);
|
|
31
|
+
(0, react_1.useEffect)(() => {
|
|
32
|
+
mountedRef.current = true;
|
|
33
|
+
return () => {
|
|
34
|
+
mountedRef.current = false;
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
// A video call (or unmount-driven disable) stops any in-flight playback.
|
|
38
|
+
(0, react_1.useEffect)(() => {
|
|
39
|
+
if (disabled && playing) {
|
|
40
|
+
void adapter.stop().catch((err) => onError === null || onError === void 0 ? void 0 : onError(err));
|
|
41
|
+
setPlaying(false);
|
|
42
|
+
setPositionMs(0);
|
|
43
|
+
}
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, [disabled]);
|
|
46
|
+
// Live progress from the adapter (engines that report it). Advances the position,
|
|
47
|
+
// learns the real total when known, and resets to the start when the note ends.
|
|
48
|
+
const onStatus = (s) => {
|
|
49
|
+
if (!mountedRef.current)
|
|
50
|
+
return;
|
|
51
|
+
setPositionMs(s.positionMs);
|
|
52
|
+
if (s.durationMs > 0)
|
|
53
|
+
setPlayedTotalMs(s.durationMs);
|
|
54
|
+
if (s.didJustFinish) {
|
|
55
|
+
setPlaying(false);
|
|
56
|
+
setPositionMs(0);
|
|
57
|
+
void adapter.stop().catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const toggle = async () => {
|
|
61
|
+
if (disabled)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
if (playing) {
|
|
65
|
+
await adapter.stop();
|
|
66
|
+
if (mountedRef.current) {
|
|
67
|
+
setPlaying(false);
|
|
68
|
+
setPositionMs(0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
setPlaying(true);
|
|
73
|
+
await adapter.play(uri, { onStatus });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (mountedRef.current)
|
|
78
|
+
setPlaying(false);
|
|
79
|
+
onError === null || onError === void 0 ? void 0 : onError(err);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
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";
|
|
83
|
+
const surface = (_d = (_c = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _c === void 0 ? void 0 : _c.surface) !== null && _d !== void 0 ? _d : "#F2F4F7";
|
|
84
|
+
const textColor = (_f = (_e = theme === null || theme === void 0 ? void 0 : theme.colors) === null || _e === void 0 ? void 0 : _e.text) !== null && _f !== void 0 ? _f : "#1A1A1A";
|
|
85
|
+
const fontSize = (_h = (_g = theme === null || theme === void 0 ? void 0 : theme.font) === null || _g === void 0 ? void 0 : _g.size) !== null && _h !== void 0 ? _h : 14;
|
|
86
|
+
const BTN = 44; // round play/pause tap target (>= 44 a11y minimum)
|
|
87
|
+
// Total prefers the engine-reported duration (received notes often arrive with
|
|
88
|
+
// durationMs:0); falls back to the recorded prop. While PLAYING show "current /
|
|
89
|
+
// total"; when idle show just the total (matches the recorded-duration contract).
|
|
90
|
+
const totalMs = playedTotalMs || durationMs;
|
|
91
|
+
const durationLabel = playing
|
|
92
|
+
? `${formatDuration(positionMs)} / ${formatDuration(totalMs)}`
|
|
93
|
+
: formatDuration(totalMs);
|
|
94
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { testID: testID, style: {
|
|
95
|
+
flexDirection: "row",
|
|
96
|
+
alignItems: "center",
|
|
97
|
+
gap: 12,
|
|
98
|
+
paddingVertical: 10,
|
|
99
|
+
paddingHorizontal: 12,
|
|
100
|
+
borderRadius: 18,
|
|
101
|
+
minWidth: 200,
|
|
102
|
+
backgroundColor: surface,
|
|
103
|
+
borderWidth: 1,
|
|
104
|
+
borderColor: "rgba(0,0,0,0.06)",
|
|
105
|
+
}, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { testID: `${testID}-toggle`, accessibilityRole: "button", accessibilityLabel: playing ? "Pause voice message" : "Play voice message", accessibilityState: { disabled, selected: playing }, disabled: disabled, onPress: () => void toggle(), style: {
|
|
106
|
+
width: BTN,
|
|
107
|
+
height: BTN,
|
|
108
|
+
borderRadius: BTN / 2,
|
|
109
|
+
alignItems: "center",
|
|
110
|
+
justifyContent: "center",
|
|
111
|
+
backgroundColor: primary,
|
|
112
|
+
opacity: disabled ? 0.4 : 1,
|
|
113
|
+
}, children: (0, jsx_runtime_1.jsx)(Icon_1.Icon, { name: playing ? "pause" : "play", color: "#FFFFFF", size: 22 }) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
114
|
+
flex: 1,
|
|
115
|
+
flexDirection: "row",
|
|
116
|
+
alignItems: "center",
|
|
117
|
+
gap: 3,
|
|
118
|
+
height: 30,
|
|
119
|
+
opacity: playing ? 1 : 0.45,
|
|
120
|
+
}, children: WAVEFORM.map((h, i) => ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: 3, height: h, borderRadius: 2, backgroundColor: primary } }, i))) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { testID: `${testID}-duration`, style: {
|
|
121
|
+
color: textColor,
|
|
122
|
+
fontSize: fontSize - 1,
|
|
123
|
+
minWidth: playing ? 78 : 42,
|
|
124
|
+
textAlign: "right",
|
|
125
|
+
fontVariant: ["tabular-nums"],
|
|
126
|
+
}, children: durationLabel })] }));
|
|
127
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface WebChatHandle {
|
|
3
|
+
open(): void;
|
|
4
|
+
close(): void;
|
|
5
|
+
sendMessage(text: string): void;
|
|
6
|
+
getUnreadCount(): number;
|
|
7
|
+
clearHistory(): void;
|
|
8
|
+
resetSession(): void;
|
|
9
|
+
}
|
|
10
|
+
export declare const WebChat: React.ForwardRefExoticComponent<Omit<any, "ref"> & React.RefAttributes<WebChatHandle>>;
|