@robylon/react-native-sdk 2.2.1-staging.1 → 2.2.3-staging.0

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 (31) hide show
  1. package/README.md +206 -0
  2. package/lib/commonjs/Chatbotsdk.js +6 -3
  3. package/lib/commonjs/Chatbotsdk.js.map +1 -1
  4. package/lib/commonjs/openChatbot.js +2 -1
  5. package/lib/commonjs/openChatbot.js.map +1 -1
  6. package/lib/commonjs/utils/session.js +1 -1
  7. package/lib/commonjs/utils/session.js.map +1 -1
  8. package/lib/commonjs/utils/webViewNavigation.js +2 -0
  9. package/lib/commonjs/utils/webViewNavigation.js.map +1 -0
  10. package/lib/commonjs/versions/version.staging.js +1 -1
  11. package/lib/module/Chatbotsdk.js +6 -3
  12. package/lib/module/Chatbotsdk.js.map +1 -1
  13. package/lib/module/utils/session.js +1 -1
  14. package/lib/module/utils/session.js.map +1 -1
  15. package/lib/module/utils/webViewNavigation.js +2 -0
  16. package/lib/module/utils/webViewNavigation.js.map +1 -0
  17. package/lib/module/versions/version.staging.js +1 -1
  18. package/lib/typescript/Chatbotsdk.d.ts +1 -0
  19. package/lib/typescript/Chatbotsdk.d.ts.map +1 -1
  20. package/lib/typescript/utils/session.d.ts +11 -0
  21. package/lib/typescript/utils/session.d.ts.map +1 -1
  22. package/lib/typescript/utils/webViewNavigation.d.ts +34 -0
  23. package/lib/typescript/utils/webViewNavigation.d.ts.map +1 -0
  24. package/lib/typescript/versions/version.staging.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/robylon-react-native-sdk-2.2.1-staging.1.tgz +0 -0
  27. package/src/Chatbotsdk.tsx +121 -13
  28. package/src/utils/session.ts +12 -0
  29. package/src/utils/webViewNavigation.ts +132 -0
  30. package/src/versions/version.staging.ts +1 -1
  31. package/robylon-react-native-sdk-2.2.1-staging.0.tgz +0 -0
@@ -49,9 +49,14 @@ import {
49
49
  } from "./utils/webViewStorage";
50
50
  import { animateWebViewOpen, animateWebViewClose } from "./utils/animations";
51
51
  import { handleDownloadRequest, DownloadRequest } from "./utils/fileDownload";
52
+ import {
53
+ handleWebViewNavigationRequest,
54
+ CHATBOT_ORIGIN_WHITELIST,
55
+ } from "./utils/webViewNavigation";
52
56
  import {
53
57
  SESSION_UPDATED_MESSAGE_TYPE,
54
58
  RESUME_SESSION_KEY,
59
+ SWITCH_SESSION_ACTION,
55
60
  extractSessionId,
56
61
  } from "./utils/session";
57
62
 
@@ -113,6 +118,10 @@ export interface ChatbotRef {
113
118
  toggle: () => void;
114
119
  isReady: () => boolean;
115
120
  getSessionId: () => string | undefined;
121
+ openToSession: (
122
+ session_id: string,
123
+ session_context?: Record<string, any>,
124
+ ) => void;
116
125
  }
117
126
 
118
127
  interface InternalChatbotProps extends ChatbotProps {
@@ -254,6 +263,29 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
254
263
  discreteRef.current = discrete;
255
264
  loadingScreenRef.current = loading_screen;
256
265
 
266
+ // openToSession's target, kept apart from the prop mirror above rather
267
+ // than written into it. That line reassigns on every render, so an
268
+ // imperative value stored there would be erased by the very state update
269
+ // that opens the WebView. This one outranks the prop while it is set.
270
+ const imperativeSessionRef = useRef<string | undefined>(undefined);
271
+ const resolveResumeTarget = () =>
272
+ imperativeSessionRef.current ?? resumeSessionRef.current;
273
+ // Same treatment for the context openToSession was called with. Writing it
274
+ // into sessionContextRef would be erased on the next render for the same
275
+ // reason, so a push-initiated switch would report the mounting screen's
276
+ // context instead of the one describing why the switch happened.
277
+ const imperativeContextRef = useRef<Record<string, any> | undefined>(
278
+ undefined,
279
+ );
280
+ // A session openToSession was asked to show before the SDK could act on it.
281
+ const pendingOpenRef = useRef<string | undefined>(undefined);
282
+ // The init effect runs above where these are declared and must not list
283
+ // them as dependencies, so it reaches them through refs kept in step below.
284
+ const openWebViewRef = useRef<(() => void) | undefined>(undefined);
285
+ const postSessionSwitchRef = useRef<((id: string) => void) | undefined>(
286
+ undefined,
287
+ );
288
+
257
289
  // Same reasoning as above: the branding hint is read inside handleMessage,
258
290
  // which does not list chatbotConfig among its dependencies.
259
291
  const chatbotConfigRef = useRef<ChatbotConfig | null>(null);
@@ -315,6 +347,29 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
315
347
  },
316
348
  isReady: () => isInitializedRef.current,
317
349
  getSessionId: () => sessionIdRef.current,
350
+ openToSession: (
351
+ sessionId: string,
352
+ sessionContext?: Record<string, any>,
353
+ ) => {
354
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
355
+ // Armed before anything is injected. Opening the WebView for the
356
+ // first time boots the chat surface, and the registerUserId that
357
+ // follows has to carry the same target this switch asks for.
358
+ imperativeSessionRef.current = sessionId;
359
+ imperativeContextRef.current = sessionContext;
360
+ // Held rather than dropped when the chat surface has not finished
361
+ // booting. A notification tapped from a killed app arrives long
362
+ // before this component is ready, which is the most common way one
363
+ // is opened at all — returning silently there would land the user in
364
+ // whatever conversation the app happened to restore. Applied by the
365
+ // effect that watches isInitialized.
366
+ if (!isInitializedRef.current) {
367
+ pendingOpenRef.current = sessionId;
368
+ return;
369
+ }
370
+ openWebView();
371
+ postSessionSwitch(sessionId);
372
+ },
318
373
  }),
319
374
  [isInitialized, isWebViewVisible],
320
375
  );
@@ -324,11 +379,24 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
324
379
  // throwing client handler cannot break message handling.
325
380
  const emitSession = useCallback((data: any) => {
326
381
  const sessionId = extractSessionId(data);
327
- if (!sessionId || sessionId === sessionIdRef.current) return;
382
+ if (!sessionId) return;
383
+ // Captured before the clears below so the context that arrived with
384
+ // openToSession still reaches the handler for the session it described.
385
+ const context = imperativeContextRef.current ?? sessionContextRef.current;
386
+ // The imperative target is an intent about one initialization, matching
387
+ // the web app's own one-shot handling of it. Once a session id comes
388
+ // back — whether the backend honoured the target or quietly fell back to
389
+ // a fresh session — it is spent, so a later reopen cannot revive the
390
+ // conversation the user has since left. Cleared ahead of the dedupe
391
+ // below, which returns early when the switch resolved to the session
392
+ // already on screen.
393
+ imperativeSessionRef.current = undefined;
394
+ imperativeContextRef.current = undefined;
395
+ if (sessionId === sessionIdRef.current) return;
328
396
  sessionIdRef.current = sessionId;
329
397
 
330
398
  try {
331
- onSessionRef.current?.(sessionId, sessionContextRef.current ?? {});
399
+ onSessionRef.current?.(sessionId, context ?? {});
332
400
  } catch (error) {
333
401
  errorTracker.trackError(
334
402
  error instanceof Error
@@ -356,6 +424,29 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
356
424
  }
357
425
  };
358
426
 
427
+ // Asks the chat surface to abandon whatever conversation it is showing and
428
+ // re-initialize on this one. Sent alongside the resume target rather than
429
+ // instead of it: a WebView that has not booted yet reaches the chat surface
430
+ // through APP_READY -> registerUserId and resumes from there, while one
431
+ // that is already live needs this message, because by then its resume
432
+ // target is only read when a session is being created.
433
+ const postSessionSwitch = useCallback((sessionId: string) => {
434
+ if (!webViewRef.current) return;
435
+ const messageData = {
436
+ name: SWITCH_SESSION_ACTION,
437
+ action: SWITCH_SESSION_ACTION,
438
+ data: { [RESUME_SESSION_KEY]: sessionId },
439
+ };
440
+ // Not logged, even redacted: there is nothing in this payload but the
441
+ // session id.
442
+ webViewRef.current.injectJavaScript(`
443
+ window.postMessage(${JSON.stringify(messageData)}, '*');
444
+ true;
445
+ `);
446
+ }, []);
447
+
448
+ postSessionSwitchRef.current = postSessionSwitch;
449
+
359
450
  const executeStorageOp = useCallback(
360
451
  (operation: () => void) => {
361
452
  if (isStorageReady) {
@@ -503,7 +594,7 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
503
594
  data: {
504
595
  userId: effectiveUserId,
505
596
  ...initLoadHint,
506
- [RESUME_SESSION_KEY]: resumeSessionRef.current || undefined,
597
+ [RESUME_SESSION_KEY]: resolveResumeTarget() || undefined,
507
598
  // Sent only when opted in. false is the default and is
508
599
  // conveyed by the key's absence, so integrations that do not
509
600
  // use it keep sending an unchanged payload.
@@ -527,7 +618,7 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
527
618
  ...messageData,
528
619
  data: {
529
620
  ...messageData.data,
530
- [RESUME_SESSION_KEY]: resumeSessionRef.current
621
+ [RESUME_SESSION_KEY]: resolveResumeTarget()
531
622
  ? "[redacted]"
532
623
  : undefined,
533
624
  },
@@ -679,7 +770,7 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
679
770
  // does not re-trigger the config fetch. Consistent with session_id
680
771
  // being an initialization-time input. JSON.stringify drops the key
681
772
  // when there is nothing to resume.
682
- resumable_session_id: resumeSessionRef.current || undefined,
773
+ resumable_session_id: resolveResumeTarget() || undefined,
683
774
  // See the registerUserId payload — omitted when false.
684
775
  discrete: discreteRef.current ? true : undefined,
685
776
  org_id: api_key,
@@ -819,6 +910,8 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
819
910
  }
820
911
  }, [enableAnimation, animatedValues, externalOnOpen, emitEvent]);
821
912
 
913
+ openWebViewRef.current = openWebView;
914
+
822
915
  // Handle closing the chatbot
823
916
  const closeWebView = useCallback(() => {
824
917
  if (webViewRef.current) {
@@ -862,6 +955,16 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
862
955
  isInitializedRef.current = true;
863
956
  setIsInitialized(true);
864
957
  onReady?.();
958
+
959
+ // Apply a session the client asked for before the SDK could act on it.
960
+ // After onReady, so a client that also opens the chatbot from there
961
+ // has already run and this lands on top rather than being overwritten.
962
+ const pending = pendingOpenRef.current;
963
+ if (pending) {
964
+ pendingOpenRef.current = undefined;
965
+ openWebViewRef.current?.();
966
+ postSessionSwitchRef.current?.(pending);
967
+ }
865
968
  }
866
969
  }, [loading, chatbotConfig, effectiveUserId, isInitialized, onReady]);
867
970
 
@@ -1037,14 +1140,19 @@ const Chatbot = forwardRef<ChatbotRef, InternalChatbotProps>(
1037
1140
  cacheEnabled={true}
1038
1141
  scrollEnabled={true}
1039
1142
  bounces={false}
1040
- onShouldStartLoadWithRequest={(request) => {
1041
- return (
1042
- request.url.startsWith(BASE_CHATBOT_URL) ||
1043
- request.url.startsWith(
1044
- chatbotConfig?.chat_iframe_url || "",
1045
- )
1046
- );
1047
- }}
1143
+ // The library's default whitelist is http/https, and it
1144
+ // answers anything outside it itself rather than calling
1145
+ // the handler below — which is why `tel:` used to die in
1146
+ // its canOpenURL check. This adds the handoff schemes to
1147
+ // that default instead of replacing it, so every other
1148
+ // scheme keeps the library's handling.
1149
+ originWhitelist={CHATBOT_ORIGIN_WHITELIST}
1150
+ onShouldStartLoadWithRequest={(request) =>
1151
+ handleWebViewNavigationRequest(request, [
1152
+ BASE_CHATBOT_URL,
1153
+ chatbotConfig?.chat_iframe_url,
1154
+ ])
1155
+ }
1048
1156
  startInLoadingState={isFirstLoadRef.current}
1049
1157
  onLoadEnd={() => {
1050
1158
  if (__DEV__) {
@@ -13,6 +13,18 @@ export const SESSION_UPDATED_MESSAGE_TYPE = "SESSION_UPDATED";
13
13
  /** Key carrying the resume target inside the `registerUserId` payload. */
14
14
  export const RESUME_SESSION_KEY = "sessionId";
15
15
 
16
+ /**
17
+ * Action the web app treats as an authoritative switch to another conversation.
18
+ *
19
+ * The resume target on `registerUserId` is only consulted while a session is
20
+ * being created, so re-sending it to a chat surface that has already booted
21
+ * arms a value nothing goes on to consume. A notification tap is exactly that
22
+ * case — the WebView is warm and usually showing a different conversation — so
23
+ * the switch travels as its own intent, which the web app answers by tearing
24
+ * down the conversation on screen and re-initializing on the target.
25
+ */
26
+ export const SWITCH_SESSION_ACTION = "switch_session";
27
+
16
28
  /**
17
29
  * Pulls the session id out of a `SESSION_UPDATED` payload.
18
30
  *
@@ -0,0 +1,132 @@
1
+ /**
2
+ * File: src/utils/webViewNavigation.ts
3
+ * Decides which navigations stay inside the chatbot WebView and hands the rest
4
+ * to the OS.
5
+ *
6
+ * Returning `false` from `onShouldStartLoadWithRequest` only cancels the
7
+ * navigation: neither platform then falls back to the OS, so without this an
8
+ * external link (`https://…`) or an app handoff (`tel:`, `mailto:`) silently
9
+ * does nothing.
10
+ */
11
+
12
+ import { Linking } from "react-native";
13
+ import { logger } from "./logger";
14
+
15
+ /**
16
+ * Schemes the WebView resolves against its own document. Handing these to
17
+ * `Linking` would only produce an "unsupported URL" error.
18
+ */
19
+ const INTERNAL_SCHEMES = ["about:", "javascript:", "data:", "blob:"];
20
+
21
+ /**
22
+ * Schemes that hand the URL to another app rather than rendering a document.
23
+ *
24
+ * The chat surface requests these from a detached iframe on purpose: the
25
+ * WebView cannot render them, so navigating the page itself would leave the
26
+ * user looking at an ERR_UNKNOWN_URL_SCHEME error instead of the conversation.
27
+ * That makes them the one kind of sub-frame navigation worth acting on.
28
+ */
29
+ const APP_HANDOFF_SCHEMES = [
30
+ "tel:",
31
+ "sms:",
32
+ "mailto:",
33
+ "facetime:",
34
+ "whatsapp:",
35
+ "geo:",
36
+ "maps:",
37
+ "intent:",
38
+ ];
39
+
40
+ /**
41
+ * What to pass as the WebView's `originWhitelist`.
42
+ *
43
+ * The library's default is http/https only, and it answers anything outside
44
+ * that itself — with a `canOpenURL` check that Android 11 makes fail for an
45
+ * installed dialer. So the handoff schemes are added to the default rather
46
+ * than the default being replaced: every other scheme keeps the library's
47
+ * handling, and this list cannot drift from the one above.
48
+ */
49
+ export const CHATBOT_ORIGIN_WHITELIST: string[] = [
50
+ "http://*",
51
+ "https://*",
52
+ ...APP_HANDOFF_SCHEMES.map((scheme) => `${scheme}*`),
53
+ ];
54
+
55
+ const isAppHandoffScheme = (url?: string): boolean => {
56
+ const lowerUrl = trimUrl(url)?.toLowerCase();
57
+ return !!APP_HANDOFF_SCHEMES?.some((scheme) => lowerUrl?.startsWith(scheme));
58
+ };
59
+
60
+ /** `hasTargetFrame` is sent by iOS but missing from the library's TS types. */
61
+ export interface WebViewNavigationRequestLike {
62
+ url?: string;
63
+ isTopFrame?: boolean;
64
+ hasTargetFrame?: boolean;
65
+ }
66
+
67
+ const trimUrl = (url?: string): string => url?.trim() || "";
68
+
69
+ const isInternalScheme = (url?: string): boolean => {
70
+ const lowerUrl = trimUrl(url)?.toLowerCase();
71
+ return !!INTERNAL_SCHEMES?.some((scheme) => lowerUrl?.startsWith(scheme));
72
+ };
73
+
74
+ const matchesAnyPrefix = (
75
+ url: string,
76
+ prefixes?: (string | undefined)[],
77
+ ): boolean =>
78
+ !!prefixes?.some((prefix) => !!prefix?.trim() && url?.startsWith(prefix));
79
+
80
+ export const shouldLoadInChatbotWebView = (
81
+ request?: WebViewNavigationRequestLike,
82
+ chatbotUrlPrefixes?: (string | undefined)[],
83
+ ): boolean => matchesAnyPrefix(trimUrl(request?.url), chatbotUrlPrefixes);
84
+
85
+ /**
86
+ * A sub-frame load is a resource the page pulled in — an embedded player, a
87
+ * tracking pixel — not a destination the user asked for, so it must not launch
88
+ * an app. A popup (`window.open`, `target="_blank"`) reports no target frame
89
+ * and is a real destination.
90
+ *
91
+ * Both flags are iOS-only; Android only reports frame-level navigations here.
92
+ */
93
+ const isUserDestination = (request?: WebViewNavigationRequestLike): boolean =>
94
+ request?.isTopFrame !== false ||
95
+ request?.hasTargetFrame === false ||
96
+ // The chat surface asks for these from a hidden frame deliberately, so the
97
+ // sub-frame rule above would reject exactly the case it was written for. A
98
+ // pixel or embedded player is http(s) and is still rejected.
99
+ isAppHandoffScheme(request?.url);
100
+
101
+ const canLeaveWebView = (request?: WebViewNavigationRequestLike): boolean =>
102
+ !!trimUrl(request?.url) &&
103
+ !isInternalScheme(request?.url) &&
104
+ isUserDestination(request);
105
+
106
+ export const openUrlOutsideWebView = async (url?: string): Promise<boolean> => {
107
+ try {
108
+ await Linking.openURL(trimUrl(url));
109
+ return true;
110
+ } catch (error) {
111
+ logger.warn("Failed to open URL outside the WebView", {
112
+ url,
113
+ error: error instanceof Error ? error?.message : String(error),
114
+ });
115
+ return false;
116
+ }
117
+ };
118
+
119
+ /**
120
+ * Synchronous by contract: `onShouldStartLoadWithRequest` needs its answer
121
+ * immediately, so the external launch is fired off without being awaited.
122
+ */
123
+ export const handleWebViewNavigationRequest = (
124
+ request?: WebViewNavigationRequestLike,
125
+ chatbotUrlPrefixes?: (string | undefined)[],
126
+ ): boolean => {
127
+ if (shouldLoadInChatbotWebView(request, chatbotUrlPrefixes)) return true;
128
+ if (!canLeaveWebView(request)) return false;
129
+ logger.debug("Opening navigation outside the WebView", { url: request?.url });
130
+ void openUrlOutsideWebView(request?.url);
131
+ return false;
132
+ };
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated. Do not modify it manually.
2
- export const SDK_VERSION = '2.2.1-staging.1';
2
+ export const SDK_VERSION = '2.2.3-staging.0';