@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,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single ICE server entry, web-parity shape (see `core/ice.ts`). Mirrors the
|
|
3
|
+
* `RTCIceServer` dictionary: one or more URLs plus optional TURN credentials.
|
|
4
|
+
*/
|
|
5
|
+
export interface IceServer {
|
|
6
|
+
urls: string | string[];
|
|
7
|
+
username?: string;
|
|
8
|
+
credential?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The `pc_config` passed to `createPeerConnection`, web parity. */
|
|
11
|
+
export interface PeerConnectionConfig {
|
|
12
|
+
iceServers: IceServer[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Media constraints handed to `getUserMedia`. Loosely typed (the native module
|
|
16
|
+
* accepts a superset of the DOM `MediaStreamConstraints`); the signaling core
|
|
17
|
+
* only ever passes `{ video, audio }`.
|
|
18
|
+
*/
|
|
19
|
+
export interface MediaStreamConstraints {
|
|
20
|
+
audio?: boolean | Record<string, unknown>;
|
|
21
|
+
video?: boolean | Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The subset of a media track the core touches: kind (`"audio" | "video"`),
|
|
25
|
+
* an `enabled` flag (mute/camera toggle), and `stop()` (camera release on
|
|
26
|
+
* teardown, C10).
|
|
27
|
+
*
|
|
28
|
+
* `_switchCamera()` is a `react-native-webrtc` extension present on local VIDEO
|
|
29
|
+
* tracks: the native layer flips front<->back and resolves to the correct
|
|
30
|
+
* DEFAULT camera on multi-camera phones (wide/ultrawide/tele exposed as separate
|
|
31
|
+
* devices). It is optional so web/fakes that lack it satisfy the type.
|
|
32
|
+
*/
|
|
33
|
+
export interface MediaStreamTrackLike {
|
|
34
|
+
kind: string;
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
stop(): void;
|
|
37
|
+
/** react-native-webrtc local-video extension: flip front/back reliably. */
|
|
38
|
+
_switchCamera?(): void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The subset of a media stream the core touches. `toURL()` feeds `RTCView`
|
|
42
|
+
* (Task 3); `getTracks()` + `release()` drive teardown / camera release (C10).
|
|
43
|
+
* `getVideoTracks()` / `getAudioTracks()` are used by the camera-switch fallback
|
|
44
|
+
* and the replaceTrack path; optional so minimal fakes still satisfy the type.
|
|
45
|
+
*/
|
|
46
|
+
export interface MediaStreamLike {
|
|
47
|
+
getTracks(): MediaStreamTrackLike[];
|
|
48
|
+
getVideoTracks?(): MediaStreamTrackLike[];
|
|
49
|
+
getAudioTracks?(): MediaStreamTrackLike[];
|
|
50
|
+
toURL?(): string;
|
|
51
|
+
release?(): void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A device descriptor returned by `mediaDevices.enumerateDevices()`. `facing`
|
|
55
|
+
* is the react-native-webrtc extension that tags a camera `"environment"`
|
|
56
|
+
* (back) or `"user"` (front); the camera-switch fallback prefers the FIRST
|
|
57
|
+
* `"environment"` device (the primary back camera) over ultrawide/tele.
|
|
58
|
+
*/
|
|
59
|
+
export interface MediaDeviceInfoLike {
|
|
60
|
+
deviceId: string;
|
|
61
|
+
kind: string;
|
|
62
|
+
facing?: string;
|
|
63
|
+
label?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The subset of `RTCPeerConnection` the signaling state machine drives. Kept
|
|
67
|
+
* minimal and structural so a fake (plain object literal) satisfies it in tests.
|
|
68
|
+
*
|
|
69
|
+
* Teardown contract (C10/C11): callers MUST `getSenders()` → `track.stop()` on
|
|
70
|
+
* every local track, `stream.release()` each captured stream, then `close()` the
|
|
71
|
+
* connection so the camera/mic are fully released (verified by restart×5).
|
|
72
|
+
*/
|
|
73
|
+
export interface RTCPeerConnectionLike {
|
|
74
|
+
addTrack(track: MediaStreamTrackLike, stream: MediaStreamLike): unknown;
|
|
75
|
+
getSenders(): Array<{
|
|
76
|
+
track: MediaStreamTrackLike | null;
|
|
77
|
+
replaceTrack?(track: MediaStreamTrackLike | null): Promise<void>;
|
|
78
|
+
}>;
|
|
79
|
+
getTransceivers(): Array<{
|
|
80
|
+
sender: {
|
|
81
|
+
track: MediaStreamTrackLike | null;
|
|
82
|
+
replaceTrack?(track: MediaStreamTrackLike | null): Promise<void>;
|
|
83
|
+
};
|
|
84
|
+
}>;
|
|
85
|
+
createAnswer(options?: unknown): Promise<unknown>;
|
|
86
|
+
setLocalDescription(desc: unknown): Promise<void>;
|
|
87
|
+
setRemoteDescription(desc: unknown): Promise<void>;
|
|
88
|
+
addIceCandidate(candidate: unknown): Promise<void>;
|
|
89
|
+
restartIce?(): void;
|
|
90
|
+
close(): void;
|
|
91
|
+
signalingState?: string;
|
|
92
|
+
remoteDescription?: unknown;
|
|
93
|
+
onicecandidate?: ((event: unknown) => void) | null;
|
|
94
|
+
ontrack?: ((event: unknown) => void) | null;
|
|
95
|
+
onconnectionstatechange?: ((event: unknown) => void) | null;
|
|
96
|
+
onnegotiationneeded?: ((event: unknown) => void) | null;
|
|
97
|
+
connectionState?: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The injectable WebRTC surface. `createPeerConnection` builds a peer connection
|
|
101
|
+
* from a web-parity {@link PeerConnectionConfig}; `getUserMedia` captures the
|
|
102
|
+
* local camera/mic. Real impls wrap `react-native-webrtc`; tests inject a fake.
|
|
103
|
+
*/
|
|
104
|
+
export interface WebRTCFactory {
|
|
105
|
+
createPeerConnection(config: PeerConnectionConfig): RTCPeerConnectionLike;
|
|
106
|
+
getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStreamLike>;
|
|
107
|
+
/**
|
|
108
|
+
* Lists the available media input/output devices (camera-switch fallback,
|
|
109
|
+
* when a track lacks `_switchCamera`). Optional so a minimal fake factory
|
|
110
|
+
* that never exercises the fallback still satisfies the type.
|
|
111
|
+
*/
|
|
112
|
+
enumerateDevices?(): Promise<MediaDeviceInfoLike[]>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Real factory backed by `react-native-webrtc`. The native module is required
|
|
116
|
+
* lazily inside the methods so simply importing this file (e.g. for the
|
|
117
|
+
* {@link WebRTCFactory} type) does not pull in native bindings — chat-only
|
|
118
|
+
* consumers and the unit-test core never load it (C15/C16).
|
|
119
|
+
*
|
|
120
|
+
* `RTCIceCandidate` / `RTCSessionDescription` are re-exported through the loader
|
|
121
|
+
* so the signaling core can wrap inbound SDP/candidates with the same native
|
|
122
|
+
* classes the web UI uses (`new RTCSessionDescription(sdp)` etc.).
|
|
123
|
+
*/
|
|
124
|
+
export declare function reactNativeWebRTCFactory(): WebRTCFactory;
|
|
125
|
+
/**
|
|
126
|
+
* Fully release the camera/mic for a call (C10). Stops every local track, calls
|
|
127
|
+
* `release()` on each captured stream if available, then `close()`s the peer
|
|
128
|
+
* connection. Null-safe so it can run from an error path or double-leave without
|
|
129
|
+
* throwing. Pure orchestration over the injected handles — no native import.
|
|
130
|
+
*/
|
|
131
|
+
export declare function teardownPeerConnection(pc: RTCPeerConnectionLike | null | undefined, localStreams?: Array<MediaStreamLike | null | undefined>): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/adapters/webrtc.ts
|
|
3
|
+
//
|
|
4
|
+
// WebRTC capability behind an injectable factory so the framework-agnostic
|
|
5
|
+
// signaling core (VideoCallClient) never imports a native module directly. The
|
|
6
|
+
// real impl ({@link reactNativeWebRTCFactory}) is constructed in the example
|
|
7
|
+
// apps and injected; unit tests inject a FAKE factory so the state machine can
|
|
8
|
+
// be exercised with no native calls (Plan Task 3).
|
|
9
|
+
//
|
|
10
|
+
// RTCView rendering note (Task 3): the UI renders local/remote tiles with
|
|
11
|
+
// `<RTCView streamURL={stream.toURL()} />` from `react-native-webrtc`. That is a
|
|
12
|
+
// view component (not a factory capability) so it is imported directly in the UI
|
|
13
|
+
// layer; this adapter only owns peer-connection + getUserMedia creation and the
|
|
14
|
+
// teardown contract documented on {@link RTCPeerConnectionLike}.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.reactNativeWebRTCFactory = reactNativeWebRTCFactory;
|
|
17
|
+
exports.teardownPeerConnection = teardownPeerConnection;
|
|
18
|
+
/**
|
|
19
|
+
* Real factory backed by `react-native-webrtc`. The native module is required
|
|
20
|
+
* lazily inside the methods so simply importing this file (e.g. for the
|
|
21
|
+
* {@link WebRTCFactory} type) does not pull in native bindings — chat-only
|
|
22
|
+
* consumers and the unit-test core never load it (C15/C16).
|
|
23
|
+
*
|
|
24
|
+
* `RTCIceCandidate` / `RTCSessionDescription` are re-exported through the loader
|
|
25
|
+
* so the signaling core can wrap inbound SDP/candidates with the same native
|
|
26
|
+
* classes the web UI uses (`new RTCSessionDescription(sdp)` etc.).
|
|
27
|
+
*/
|
|
28
|
+
function reactNativeWebRTCFactory() {
|
|
29
|
+
// Lazy require keeps native bindings out of the import graph until a real
|
|
30
|
+
// call is placed; typed as the module's shape via a structural cast.
|
|
31
|
+
const load = () =>
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
33
|
+
require("react-native-webrtc");
|
|
34
|
+
return {
|
|
35
|
+
createPeerConnection(config) {
|
|
36
|
+
const { RTCPeerConnection } = load();
|
|
37
|
+
return new RTCPeerConnection(config);
|
|
38
|
+
},
|
|
39
|
+
getUserMedia(constraints) {
|
|
40
|
+
const { mediaDevices } = load();
|
|
41
|
+
return mediaDevices.getUserMedia(constraints);
|
|
42
|
+
},
|
|
43
|
+
enumerateDevices() {
|
|
44
|
+
const { mediaDevices } = load();
|
|
45
|
+
return mediaDevices.enumerateDevices();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fully release the camera/mic for a call (C10). Stops every local track, calls
|
|
51
|
+
* `release()` on each captured stream if available, then `close()`s the peer
|
|
52
|
+
* connection. Null-safe so it can run from an error path or double-leave without
|
|
53
|
+
* throwing. Pure orchestration over the injected handles — no native import.
|
|
54
|
+
*/
|
|
55
|
+
function teardownPeerConnection(pc, localStreams = []) {
|
|
56
|
+
var _a;
|
|
57
|
+
for (const stream of localStreams) {
|
|
58
|
+
if (!stream)
|
|
59
|
+
continue;
|
|
60
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
61
|
+
(_a = stream.release) === null || _a === void 0 ? void 0 : _a.call(stream);
|
|
62
|
+
}
|
|
63
|
+
if (pc) {
|
|
64
|
+
pc.onicecandidate = null;
|
|
65
|
+
pc.ontrack = null;
|
|
66
|
+
pc.onconnectionstatechange = null;
|
|
67
|
+
pc.onnegotiationneeded = null;
|
|
68
|
+
pc.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type PcConfig } from "./ice";
|
|
2
|
+
/** Minimal socket surface (same shape as core `MinimalSocket`). */
|
|
3
|
+
export interface MinimalSocket {
|
|
4
|
+
on(event: string, cb: Function): void;
|
|
5
|
+
off(event: string, cb?: Function): void;
|
|
6
|
+
emit(event: string, data?: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
/** A media track (subset of MediaStreamTrack we touch). */
|
|
9
|
+
export interface FakeableTrack {
|
|
10
|
+
kind: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
stop(): void;
|
|
13
|
+
/**
|
|
14
|
+
* react-native-webrtc local-video extension: flips front<->back and resolves
|
|
15
|
+
* to the correct DEFAULT camera on multi-camera phones. Optional/structural
|
|
16
|
+
* so web tracks and fakes that lack it still satisfy the type.
|
|
17
|
+
*/
|
|
18
|
+
_switchCamera?(): void;
|
|
19
|
+
}
|
|
20
|
+
/** A media stream (subset of MediaStream we touch). */
|
|
21
|
+
export interface FakeableStream {
|
|
22
|
+
getTracks(): FakeableTrack[];
|
|
23
|
+
getAudioTracks?(): FakeableTrack[];
|
|
24
|
+
getVideoTracks?(): FakeableTrack[];
|
|
25
|
+
/** native MediaStream exposes toURL() (for RTCView) + release(); optional for fakes. */
|
|
26
|
+
toURL?(): string;
|
|
27
|
+
release?(): void;
|
|
28
|
+
}
|
|
29
|
+
/** An RTP sender (subset we touch): its current track + replaceTrack. */
|
|
30
|
+
export interface FakeableSender {
|
|
31
|
+
track: FakeableTrack | null;
|
|
32
|
+
replaceTrack?(track: FakeableTrack | null): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** A media-device descriptor from `enumerateDevices` (camera-switch fallback). */
|
|
35
|
+
export interface FakeableDeviceInfo {
|
|
36
|
+
deviceId: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
/** react-native-webrtc tag: "environment" (back) / "user" (front). */
|
|
39
|
+
facing?: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
}
|
|
42
|
+
/** A peer connection (subset we touch), with assignable `on*` handlers. */
|
|
43
|
+
export interface FakeablePeerConnection {
|
|
44
|
+
signalingState?: string;
|
|
45
|
+
connectionState?: string;
|
|
46
|
+
remoteDescription?: unknown;
|
|
47
|
+
setRemoteDescription(desc: unknown): Promise<void>;
|
|
48
|
+
setLocalDescription(desc?: unknown): Promise<void>;
|
|
49
|
+
createAnswer(options?: unknown): Promise<unknown>;
|
|
50
|
+
addIceCandidate(candidate: unknown): Promise<void>;
|
|
51
|
+
addTrack(track: FakeableTrack, stream: FakeableStream): unknown;
|
|
52
|
+
getTransceivers(): Array<{
|
|
53
|
+
sender: FakeableSender;
|
|
54
|
+
}>;
|
|
55
|
+
getSenders(): FakeableSender[];
|
|
56
|
+
restartIce?(): void;
|
|
57
|
+
close(): void;
|
|
58
|
+
onicecandidate?: ((e: {
|
|
59
|
+
candidate: {
|
|
60
|
+
type?: string;
|
|
61
|
+
} | null;
|
|
62
|
+
}) => void) | null;
|
|
63
|
+
ontrack?: ((e: {
|
|
64
|
+
track: FakeableTrack;
|
|
65
|
+
streams: FakeableStream[];
|
|
66
|
+
}) => void) | null;
|
|
67
|
+
onconnectionstatechange?: ((e?: unknown) => void) | null;
|
|
68
|
+
onnegotiationneeded?: ((e?: unknown) => void) | null;
|
|
69
|
+
}
|
|
70
|
+
/** Injected WebRTC factory — the only native seam. */
|
|
71
|
+
export interface WebRTCFactory {
|
|
72
|
+
createPeerConnection(config: PcConfig): FakeablePeerConnection;
|
|
73
|
+
getUserMedia(constraints: unknown): Promise<FakeableStream>;
|
|
74
|
+
/** Camera-switch fallback: list devices when a track lacks `_switchCamera`. */
|
|
75
|
+
enumerateDevices?(): Promise<FakeableDeviceInfo[]>;
|
|
76
|
+
}
|
|
77
|
+
export interface VideoCallClientOptions {
|
|
78
|
+
socket: MinimalSocket;
|
|
79
|
+
webrtcFactory: WebRTCFactory;
|
|
80
|
+
/**
|
|
81
|
+
* The existing chat session id. Retained on the options (public API; the
|
|
82
|
+
* provider still passes it) but NOT sent on the video-call `join`/`leave_room`
|
|
83
|
+
* anymore — web parity: the web widget emits a bare `join` and a `leave_room`
|
|
84
|
+
* with a room literal (see `start()`/`leave()` below).
|
|
85
|
+
*/
|
|
86
|
+
getSessionId: () => string;
|
|
87
|
+
/** Override the pc_config; defaults to web-parity `iceServers()`. */
|
|
88
|
+
pcConfig?: PcConfig;
|
|
89
|
+
/** Override getUserMedia constraints; defaults to web parity. */
|
|
90
|
+
mediaConstraints?: unknown;
|
|
91
|
+
}
|
|
92
|
+
export type VideoCallEvent = "localStream" | "remoteStream" | "ended" | "error";
|
|
93
|
+
type Listener = (data?: any) => void;
|
|
94
|
+
export declare function createVideoCallClient(opts: VideoCallClientOptions): {
|
|
95
|
+
on(event: VideoCallEvent, cb: Listener): void;
|
|
96
|
+
start: () => Promise<void>;
|
|
97
|
+
leave: () => void;
|
|
98
|
+
toggleMute: () => boolean;
|
|
99
|
+
toggleVideo: () => boolean;
|
|
100
|
+
switchCamera: () => Promise<void>;
|
|
101
|
+
/** test/inspection seam */
|
|
102
|
+
isStarted: () => boolean;
|
|
103
|
+
getPeerConnection: () => FakeablePeerConnection | null;
|
|
104
|
+
};
|
|
105
|
+
export type VideoCallClient = ReturnType<typeof createVideoCallClient>;
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/core/VideoCallClient.ts
|
|
3
|
+
// Answerer-only signaling state machine for 1:1 video calls.
|
|
4
|
+
//
|
|
5
|
+
// Ports the web widget `VideoCall.tsx` handlers 1:1 over the EXISTING chat socket
|
|
6
|
+
// (no second connection). WebRTC lives behind an injected factory so this state
|
|
7
|
+
// machine is fully unit-testable with a FakeSocket + a fake factory (no native
|
|
8
|
+
// modules). Media/audio is validated on real devices, not here.
|
|
9
|
+
//
|
|
10
|
+
// Call scoping (C8 — web parity): the call is scoped by the existing chat session,
|
|
11
|
+
// so `session_id` is sent on `join` and `leave_room` exactly as the web widget does.
|
|
12
|
+
//
|
|
13
|
+
// B15: remote AUDIO tracks are NOT dropped (the web `ontrack` skipped audio for its
|
|
14
|
+
// DOM-element rendering hack; RN keeps audio so the agent is audible).
|
|
15
|
+
// B16/C9: an inbound peer-left / call-ended signal triggers full teardown.
|
|
16
|
+
// C6: ICE-restart on `connectionState === "failed"` mirrors web (restartIce()); the
|
|
17
|
+
// server re-offer contract for restart is documented in the plan, handled here via
|
|
18
|
+
// the normal `offer`/`negotiation` -> createAnswer path.
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.createVideoCallClient = createVideoCallClient;
|
|
21
|
+
const ice_1 = require("./ice");
|
|
22
|
+
/** Default constraints mirror the web widget (`initVideoCall`). */
|
|
23
|
+
const DEFAULT_CONSTRAINTS = {
|
|
24
|
+
video: true,
|
|
25
|
+
audio: { echoCancellation: true, noiseSuppression: true },
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Inbound signals from the agent/server that mean "the other side is gone" and the
|
|
29
|
+
* call must be torn down (B16/C9). The web widget had no explicit handler, so we
|
|
30
|
+
* accept the common server names; any of them triggers a full teardown.
|
|
31
|
+
*/
|
|
32
|
+
const END_SIGNALS = ["leave_room", "peer_left", "call_ended", "room_closed"];
|
|
33
|
+
function createVideoCallClient(opts) {
|
|
34
|
+
var _a, _b;
|
|
35
|
+
// `getSessionId` is intentionally NOT destructured: web parity dropped it from
|
|
36
|
+
// both the `join` and `leave_room` payloads (see `start()`/`leave()`). It stays
|
|
37
|
+
// on VideoCallClientOptions for API stability and possible future use.
|
|
38
|
+
const { socket, webrtcFactory } = opts;
|
|
39
|
+
const pcConfig = (_a = opts.pcConfig) !== null && _a !== void 0 ? _a : (0, ice_1.iceServers)();
|
|
40
|
+
const constraints = (_b = opts.mediaConstraints) !== null && _b !== void 0 ? _b : DEFAULT_CONSTRAINTS;
|
|
41
|
+
const listeners = {};
|
|
42
|
+
const emit = (event, data) => { var _a; return ((_a = listeners[event]) !== null && _a !== void 0 ? _a : []).forEach((l) => l(data)); };
|
|
43
|
+
let pc = null;
|
|
44
|
+
let localStream = null;
|
|
45
|
+
let started = false;
|
|
46
|
+
let torndown = false;
|
|
47
|
+
// Bound socket handlers, kept so leave()/teardown can `off` them precisely.
|
|
48
|
+
const bound = [];
|
|
49
|
+
function bind(event, cb) {
|
|
50
|
+
socket.on(event, cb);
|
|
51
|
+
bound.push({ event, cb });
|
|
52
|
+
}
|
|
53
|
+
/** Forward a local ICE candidate iff it passes the web-parity filter. */
|
|
54
|
+
function wireIceCandidate(connection) {
|
|
55
|
+
connection.onicecandidate = (e) => {
|
|
56
|
+
if (e.candidate && (0, ice_1.isForwardableCandidate)(e.candidate.type)) {
|
|
57
|
+
socket.emit("candidate", JSON.stringify(e.candidate));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Ported 1:1 from web `createAnswer` (incl. glare/rollback handling).
|
|
62
|
+
async function createAnswer(sdp) {
|
|
63
|
+
if (!pc)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
if (pc.signalingState !== "stable") {
|
|
67
|
+
// glare: roll our local back and accept theirs (no answer this round)
|
|
68
|
+
await Promise.all([
|
|
69
|
+
pc.setLocalDescription({ type: "rollback" }),
|
|
70
|
+
pc.setRemoteDescription(sdp),
|
|
71
|
+
]);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await pc.setRemoteDescription(sdp);
|
|
75
|
+
const mySdp = await pc.createAnswer({
|
|
76
|
+
offerToReceiveVideo: true,
|
|
77
|
+
offerToReceiveAudio: true,
|
|
78
|
+
});
|
|
79
|
+
await pc.setLocalDescription(mySdp);
|
|
80
|
+
socket.emit("answer", JSON.stringify(mySdp));
|
|
81
|
+
wireIceCandidate(pc);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
emit("error", e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function wireSocketHandlers() {
|
|
88
|
+
bind("offer", (offer) => {
|
|
89
|
+
const parsed = JSON.parse(offer);
|
|
90
|
+
createAnswer(parsed);
|
|
91
|
+
if (pc) {
|
|
92
|
+
pc.onnegotiationneeded = () => {
|
|
93
|
+
if (!pc || pc.signalingState !== "stable")
|
|
94
|
+
return;
|
|
95
|
+
try {
|
|
96
|
+
socket.emit("negotiation", "");
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
emit("error", e);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
bind("negotiation", (offer) => {
|
|
105
|
+
const parsed = JSON.parse(offer);
|
|
106
|
+
createAnswer(parsed);
|
|
107
|
+
});
|
|
108
|
+
bind("candidate", async (candidate) => {
|
|
109
|
+
if (!pc)
|
|
110
|
+
return;
|
|
111
|
+
if (candidate === null)
|
|
112
|
+
return;
|
|
113
|
+
if (pc.remoteDescription == null)
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
await pc.addIceCandidate(JSON.parse(candidate));
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
emit("error", e);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
bind("answer", async (answer) => {
|
|
123
|
+
if (!pc)
|
|
124
|
+
return;
|
|
125
|
+
const parsed = JSON.parse(answer);
|
|
126
|
+
try {
|
|
127
|
+
await pc.setRemoteDescription(parsed);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
emit("error", e);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// B16/C9: inbound end-signal -> full teardown + emit "ended".
|
|
134
|
+
END_SIGNALS.forEach((sig) => bind(sig, () => {
|
|
135
|
+
teardown();
|
|
136
|
+
emit("ended");
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
// Ported from web `initVideoCall`: getUserMedia -> addTrack -> wire handlers -> join.
|
|
140
|
+
async function start() {
|
|
141
|
+
if (started)
|
|
142
|
+
return;
|
|
143
|
+
started = true;
|
|
144
|
+
torndown = false;
|
|
145
|
+
try {
|
|
146
|
+
pc = webrtcFactory.createPeerConnection(pcConfig);
|
|
147
|
+
wireSocketHandlers();
|
|
148
|
+
const stream = await webrtcFactory.getUserMedia(constraints);
|
|
149
|
+
localStream = stream;
|
|
150
|
+
emit("localStream", stream);
|
|
151
|
+
stream.getTracks().forEach((track) => {
|
|
152
|
+
pc === null || pc === void 0 ? void 0 : pc.addTrack(track, stream);
|
|
153
|
+
});
|
|
154
|
+
// C6: failed connection attempts ICE recovery instead of terminating.
|
|
155
|
+
pc.onconnectionstatechange = () => {
|
|
156
|
+
var _a;
|
|
157
|
+
if (pc && pc.connectionState === "failed") {
|
|
158
|
+
(_a = pc.restartIce) === null || _a === void 0 ? void 0 : _a.call(pc);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
wireIceCandidate(pc);
|
|
162
|
+
// B15: keep remote audio tracks. The web widget dropped audio because it
|
|
163
|
+
// rendered each track into its own DOM <video>; RN surfaces the whole
|
|
164
|
+
// remote stream (audio + video) so the agent is audible.
|
|
165
|
+
pc.ontrack = (event) => {
|
|
166
|
+
var _a;
|
|
167
|
+
const remote = (_a = event.streams) === null || _a === void 0 ? void 0 : _a[0];
|
|
168
|
+
if (remote)
|
|
169
|
+
emit("remoteStream", remote);
|
|
170
|
+
};
|
|
171
|
+
// WEB PARITY: the web widget emits `join` with NO payload (new-webchat
|
|
172
|
+
// VideoCall.tsx — `socketConnection.emit("join")`). It is purely the
|
|
173
|
+
// "ready, send me the offer" trigger; the server already knows the session
|
|
174
|
+
// from the shared chat socket. Do not add a payload here.
|
|
175
|
+
socket.emit("join");
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
started = false;
|
|
179
|
+
emit("error", e);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Toggle local mic tracks (mute/unmute). Returns the new muted state. */
|
|
183
|
+
function toggleMute() {
|
|
184
|
+
let nowEnabled = true;
|
|
185
|
+
pc === null || pc === void 0 ? void 0 : pc.getSenders().forEach((s) => {
|
|
186
|
+
if (s.track && s.track.kind === "audio") {
|
|
187
|
+
s.track.enabled = !s.track.enabled;
|
|
188
|
+
nowEnabled = s.track.enabled;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return !nowEnabled;
|
|
192
|
+
}
|
|
193
|
+
/** Toggle local camera tracks. Returns the new "video off" state. */
|
|
194
|
+
function toggleVideo() {
|
|
195
|
+
let nowEnabled = true;
|
|
196
|
+
pc === null || pc === void 0 ? void 0 : pc.getSenders().forEach((s) => {
|
|
197
|
+
if (s.track && s.track.kind === "video") {
|
|
198
|
+
s.track.enabled = !s.track.enabled;
|
|
199
|
+
nowEnabled = s.track.enabled;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
return !nowEnabled;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Flip the local camera front<->back RELIABLY.
|
|
206
|
+
*
|
|
207
|
+
* On RN the web approach (replace by an arbitrary `deviceId`) picks the WRONG
|
|
208
|
+
* back camera when a phone exposes wide/ultrawide/tele as separate devices.
|
|
209
|
+
*
|
|
210
|
+
* PRIMARY: react-native-webrtc local-video tracks expose `_switchCamera()`,
|
|
211
|
+
* which the native layer resolves to the correct DEFAULT camera (handling
|
|
212
|
+
* multi-camera devices). We prefer it whenever present.
|
|
213
|
+
*
|
|
214
|
+
* FALLBACK (no `_switchCamera`, e.g. fakes/web): enumerate the devices, pick
|
|
215
|
+
* the PRIMARY back camera (the FIRST `facing === "environment"` device — not
|
|
216
|
+
* ultrawide/tele), `getUserMedia` that camera, and `replaceTrack` on the video
|
|
217
|
+
* sender. Never blindly pick an arbitrary deviceId.
|
|
218
|
+
*/
|
|
219
|
+
async function switchCamera() {
|
|
220
|
+
var _a, _b, _c, _d, _e;
|
|
221
|
+
if (!pc)
|
|
222
|
+
return;
|
|
223
|
+
const videoTrack = (_b = (_a = localStream === null || localStream === void 0 ? void 0 : localStream.getVideoTracks) === null || _a === void 0 ? void 0 : _a.call(localStream)[0]) !== null && _b !== void 0 ? _b : null;
|
|
224
|
+
// PRIMARY: native flip resolves the correct default camera itself.
|
|
225
|
+
if (videoTrack && typeof videoTrack._switchCamera === "function") {
|
|
226
|
+
videoTrack._switchCamera();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// FALLBACK: pick the primary back camera explicitly, then replaceTrack.
|
|
230
|
+
if (typeof webrtcFactory.enumerateDevices !== "function")
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
const devices = await webrtcFactory.enumerateDevices();
|
|
234
|
+
const cameras = devices.filter((d) => d.kind === "videoinput");
|
|
235
|
+
// First environment-facing camera = the primary back lens (not wide/tele).
|
|
236
|
+
const backCamera = cameras.find((d) => d.facing === "environment");
|
|
237
|
+
const video = backCamera
|
|
238
|
+
? { facingMode: "environment", deviceId: backCamera.deviceId }
|
|
239
|
+
: { facingMode: "environment" };
|
|
240
|
+
const stream = await webrtcFactory.getUserMedia({ video, audio: false });
|
|
241
|
+
const newVideoTrack = (_d = (_c = stream.getVideoTracks) === null || _c === void 0 ? void 0 : _c.call(stream)[0]) !== null && _d !== void 0 ? _d : null;
|
|
242
|
+
if (!newVideoTrack)
|
|
243
|
+
return;
|
|
244
|
+
const videoSender = pc
|
|
245
|
+
.getSenders()
|
|
246
|
+
.find((s) => s.track && s.track.kind === "video");
|
|
247
|
+
await ((_e = videoSender === null || videoSender === void 0 ? void 0 : videoSender.replaceTrack) === null || _e === void 0 ? void 0 : _e.call(videoSender, newVideoTrack));
|
|
248
|
+
// Stop the old local video track and swap it into the local stream so the
|
|
249
|
+
// local preview + subsequent teardown release the new camera, not the old.
|
|
250
|
+
if (videoTrack)
|
|
251
|
+
videoTrack.stop();
|
|
252
|
+
localStream = stream;
|
|
253
|
+
emit("localStream", stream);
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
emit("error", e);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Full teardown (C10): off socket handlers, stop+release local media, close pc.
|
|
260
|
+
// Idempotent so an inbound end-signal and a user leave() can't double-free.
|
|
261
|
+
function teardown() {
|
|
262
|
+
var _a;
|
|
263
|
+
if (torndown)
|
|
264
|
+
return;
|
|
265
|
+
torndown = true;
|
|
266
|
+
started = false;
|
|
267
|
+
bound.forEach(({ event, cb }) => socket.off(event, cb));
|
|
268
|
+
bound.length = 0;
|
|
269
|
+
localStream === null || localStream === void 0 ? void 0 : localStream.getTracks().forEach((track) => track.stop());
|
|
270
|
+
(_a = localStream === null || localStream === void 0 ? void 0 : localStream.release) === null || _a === void 0 ? void 0 : _a.call(localStream);
|
|
271
|
+
localStream = null;
|
|
272
|
+
if (pc) {
|
|
273
|
+
pc.close();
|
|
274
|
+
pc = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// User-initiated hangup: emit leave_room then teardown.
|
|
278
|
+
// WEB PARITY: the web widget emits `leave_room` with a HARD-CODED literal
|
|
279
|
+
// `{ room: "1234f" }` (new-webchat VideoCall.tsx handleLeaveRoom). We mirror it
|
|
280
|
+
// exactly so the SDK hangup matches the production-proven web client. NOTE: if
|
|
281
|
+
// the signaling server expects a real room/session key here, change the value
|
|
282
|
+
// to `getSessionId()` — the field name `room` is what the server reads.
|
|
283
|
+
function leave() {
|
|
284
|
+
socket.emit("leave_room", { room: "1234f" });
|
|
285
|
+
teardown();
|
|
286
|
+
emit("ended");
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
on(event, cb) {
|
|
290
|
+
var _a;
|
|
291
|
+
((_a = listeners[event]) !== null && _a !== void 0 ? _a : (listeners[event] = [])).push(cb);
|
|
292
|
+
},
|
|
293
|
+
start,
|
|
294
|
+
leave,
|
|
295
|
+
toggleMute,
|
|
296
|
+
toggleVideo,
|
|
297
|
+
switchCamera,
|
|
298
|
+
/** test/inspection seam */
|
|
299
|
+
isStarted: () => started,
|
|
300
|
+
getPeerConnection: () => pc,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
type Listener = (data: any) => void;
|
|
2
|
+
export interface MinimalSocket {
|
|
3
|
+
on(event: string, cb: Function): void;
|
|
4
|
+
off(event: string, cb?: Function): void;
|
|
5
|
+
emit(event: string, data?: unknown): void;
|
|
6
|
+
connect?(): void;
|
|
7
|
+
disconnect(): void;
|
|
8
|
+
connected?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface WebchatClientOptions {
|
|
11
|
+
socketFactory: () => MinimalSocket;
|
|
12
|
+
sessionTimeoutMs?: number;
|
|
13
|
+
/** B8 resume seam — host/persistence layer provides the stored session id. */
|
|
14
|
+
getStoredSessionId?: () => string;
|
|
15
|
+
/** B7: how many session_request retries before giving up (default 3). */
|
|
16
|
+
maxSessionRetries?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function createWebchatClient(opts: WebchatClientOptions): {
|
|
19
|
+
on(event: string, cb: Listener): void;
|
|
20
|
+
connect(): void;
|
|
21
|
+
sendMessage(payload: import("./types").OutgoingPayload): void;
|
|
22
|
+
/**
|
|
23
|
+
* Emit a feedback activity (web parity): `socket.emit("activity", { messageKey, type })`.
|
|
24
|
+
* `type` is the backend verb the provider computes from the toggle —
|
|
25
|
+
* "like" | "unlike" | "dislike" | "undislike". The matching inbound reflection
|
|
26
|
+
* arrives on "activityAck" (re-emitted as the client's "activityAck" event).
|
|
27
|
+
*/
|
|
28
|
+
emitActivity(payload: {
|
|
29
|
+
messageKey: string;
|
|
30
|
+
type: string;
|
|
31
|
+
}): void;
|
|
32
|
+
disconnect(): void;
|
|
33
|
+
};
|
|
34
|
+
export {};
|