@experiaapp/webchat-react-native 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +254 -0
  2. package/app.plugin.js +6 -0
  3. package/lib/adapters/audio.d.ts +74 -0
  4. package/lib/adapters/audio.js +39 -0
  5. package/lib/adapters/audioRoute.d.ts +57 -0
  6. package/lib/adapters/audioRoute.js +77 -0
  7. package/lib/adapters/expoDefaults.d.ts +77 -0
  8. package/lib/adapters/expoDefaults.js +539 -0
  9. package/lib/adapters/picker.d.ts +67 -0
  10. package/lib/adapters/picker.js +37 -0
  11. package/lib/adapters/webrtc.d.ts +131 -0
  12. package/lib/adapters/webrtc.js +70 -0
  13. package/lib/core/VideoCallClient.d.ts +106 -0
  14. package/lib/core/VideoCallClient.js +302 -0
  15. package/lib/core/WebchatClient.d.ts +34 -0
  16. package/lib/core/WebchatClient.js +132 -0
  17. package/lib/core/configClient.d.ts +42 -0
  18. package/lib/core/configClient.js +302 -0
  19. package/lib/core/greet.d.ts +11 -0
  20. package/lib/core/greet.js +17 -0
  21. package/lib/core/ice.d.ts +31 -0
  22. package/lib/core/ice.js +48 -0
  23. package/lib/core/linkify.d.ts +11 -0
  24. package/lib/core/linkify.js +25 -0
  25. package/lib/core/logger.d.ts +17 -0
  26. package/lib/core/logger.js +53 -0
  27. package/lib/core/media.d.ts +52 -0
  28. package/lib/core/media.js +115 -0
  29. package/lib/core/mediaType.d.ts +21 -0
  30. package/lib/core/mediaType.js +66 -0
  31. package/lib/core/messagesReducer.d.ts +36 -0
  32. package/lib/core/messagesReducer.js +58 -0
  33. package/lib/core/persistence.d.ts +45 -0
  34. package/lib/core/persistence.js +63 -0
  35. package/lib/core/socketFactory.d.ts +16 -0
  36. package/lib/core/socketFactory.js +82 -0
  37. package/lib/core/types.d.ts +320 -0
  38. package/lib/core/types.js +30 -0
  39. package/lib/core/unread.d.ts +2 -0
  40. package/lib/core/unread.js +5 -0
  41. package/lib/i18n/ar.json +1 -0
  42. package/lib/i18n/en.json +1 -0
  43. package/lib/i18n/index.d.ts +7 -0
  44. package/lib/i18n/index.js +43 -0
  45. package/lib/index.d.ts +59 -0
  46. package/lib/index.js +142 -0
  47. package/lib/plugin/withWebchat.d.ts +53 -0
  48. package/lib/plugin/withWebchat.js +164 -0
  49. package/lib/state/WebchatProvider.d.ts +132 -0
  50. package/lib/state/WebchatProvider.js +906 -0
  51. package/lib/state/useWebchat.d.ts +1 -0
  52. package/lib/state/useWebchat.js +12 -0
  53. package/lib/theme/dir.d.ts +14 -0
  54. package/lib/theme/dir.js +20 -0
  55. package/lib/theme/themeFactory.d.ts +219 -0
  56. package/lib/theme/themeFactory.js +182 -0
  57. package/lib/ui/AttachButton.d.ts +35 -0
  58. package/lib/ui/AttachButton.js +26 -0
  59. package/lib/ui/AudioRecorder.d.ts +25 -0
  60. package/lib/ui/AudioRecorder.js +228 -0
  61. package/lib/ui/Bubble.d.ts +1 -0
  62. package/lib/ui/Bubble.js +265 -0
  63. package/lib/ui/CallControls.d.ts +27 -0
  64. package/lib/ui/CallControls.js +92 -0
  65. package/lib/ui/CallPlaceholder.d.ts +16 -0
  66. package/lib/ui/CallPlaceholder.js +73 -0
  67. package/lib/ui/Composer.d.ts +5 -0
  68. package/lib/ui/Composer.js +272 -0
  69. package/lib/ui/FileTile.d.ts +9 -0
  70. package/lib/ui/FileTile.js +31 -0
  71. package/lib/ui/Header.d.ts +52 -0
  72. package/lib/ui/Header.js +236 -0
  73. package/lib/ui/Icon.d.ts +21 -0
  74. package/lib/ui/Icon.js +110 -0
  75. package/lib/ui/ImageBubble.d.ts +11 -0
  76. package/lib/ui/ImageBubble.js +16 -0
  77. package/lib/ui/MediaUploadMenu.d.ts +23 -0
  78. package/lib/ui/MediaUploadMenu.js +68 -0
  79. package/lib/ui/MessageList.d.ts +1 -0
  80. package/lib/ui/MessageList.js +46 -0
  81. package/lib/ui/PoweredBy.d.ts +8 -0
  82. package/lib/ui/PoweredBy.js +14 -0
  83. package/lib/ui/PrechatForm.d.ts +1 -0
  84. package/lib/ui/PrechatForm.js +230 -0
  85. package/lib/ui/QuickReplies.d.ts +1 -0
  86. package/lib/ui/QuickReplies.js +24 -0
  87. package/lib/ui/TypingIndicator.d.ts +9 -0
  88. package/lib/ui/TypingIndicator.js +88 -0
  89. package/lib/ui/VideoBubble.d.ts +10 -0
  90. package/lib/ui/VideoBubble.js +130 -0
  91. package/lib/ui/VideoCall.d.ts +34 -0
  92. package/lib/ui/VideoCall.js +191 -0
  93. package/lib/ui/VideoTile.d.ts +25 -0
  94. package/lib/ui/VideoTile.js +13 -0
  95. package/lib/ui/VoiceMessage.d.ts +19 -0
  96. package/lib/ui/VoiceMessage.js +127 -0
  97. package/lib/ui/WebChat.d.ts +10 -0
  98. package/lib/ui/WebChat.js +386 -0
  99. package/lib/ui/openLink.d.ts +1 -0
  100. package/lib/ui/openLink.js +16 -0
  101. package/package.json +94 -0
@@ -0,0 +1,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 {};