@robylon/react-native-sdk 2.2.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.
- package/README.md +149 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/robylonreactnativesdk/RobylonDownloadModule.java +83 -0
- package/android/src/main/java/com/robylonreactnativesdk/RobylonReactNativeSdkPackage.java +24 -0
- package/lib/commonjs/Chatbotsdk.js +6 -3
- package/lib/commonjs/Chatbotsdk.js.map +1 -1
- package/lib/commonjs/config.js +1 -1
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/constants.js +1 -1
- package/lib/commonjs/constants.js.map +1 -1
- package/lib/commonjs/openChatbot.js +2 -1
- package/lib/commonjs/openChatbot.js.map +1 -1
- package/lib/commonjs/utils/session.js +1 -1
- package/lib/commonjs/utils/session.js.map +1 -1
- package/lib/commonjs/utils/webViewNavigation.js +2 -0
- package/lib/commonjs/utils/webViewNavigation.js.map +1 -0
- package/lib/commonjs/versions/version.staging.js +1 -1
- package/lib/module/Chatbotsdk.js +6 -3
- package/lib/module/Chatbotsdk.js.map +1 -1
- package/lib/module/config.js +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/constants.js +1 -1
- package/lib/module/constants.js.map +1 -1
- package/lib/module/utils/session.js +1 -1
- package/lib/module/utils/session.js.map +1 -1
- package/lib/module/utils/webViewNavigation.js +2 -0
- package/lib/module/utils/webViewNavigation.js.map +1 -0
- package/lib/module/versions/version.staging.js +1 -1
- package/lib/typescript/Chatbotsdk.d.ts +1 -0
- package/lib/typescript/Chatbotsdk.d.ts.map +1 -1
- package/lib/typescript/utils/session.d.ts +11 -0
- package/lib/typescript/utils/session.d.ts.map +1 -1
- package/lib/typescript/utils/webViewNavigation.d.ts +34 -0
- package/lib/typescript/utils/webViewNavigation.d.ts.map +1 -0
- package/lib/typescript/versions/version.staging.d.ts +1 -1
- package/package.json +1 -1
- package/src/Chatbotsdk.tsx +1299 -0
- package/src/FloatingButton/launchers/ImageLauncher.tsx +75 -0
- package/src/FloatingButton/launchers/TextLauncher.tsx +84 -0
- package/src/FloatingButton/launchers/TextualImageLauncher.tsx +133 -0
- package/src/FloatingButton.tsx +97 -0
- package/src/LoadingIndicator.tsx +11 -0
- package/src/Toast.tsx +65 -0
- package/src/components/DebugButton.tsx +46 -0
- package/src/components/ErrorBoundary.tsx +38 -0
- package/src/config.ts +4 -0
- package/src/constants/errorConstants.ts +21 -0
- package/src/constants/fontStyles.ts +3 -0
- package/src/constants.ts +5 -0
- package/src/global.d.ts +11 -0
- package/src/hooks/useChatbotEvents.ts +117 -0
- package/src/index.tsx +14 -0
- package/src/openChatbot.ts +11 -0
- package/src/openChatbot.tsx +0 -0
- package/src/services/ErrorTrackingService.ts +217 -0
- package/src/types/events.ts +28 -0
- package/src/types/react-native-flipper-performance-plugin.d.ts +3 -0
- package/src/types/react-native-globals.d.ts +10 -0
- package/src/utils/animations.ts +120 -0
- package/src/utils/colorUtils.ts +35 -0
- package/src/utils/cookieUtils.ts +7 -0
- package/src/utils/debugConfig.ts +56 -0
- package/src/utils/debugMenu.ts +14 -0
- package/src/utils/errorHandler.ts +58 -0
- package/src/utils/fileDownload.ts +198 -0
- package/src/utils/logger.ts +14 -0
- package/src/utils/session.ts +38 -0
- package/src/utils/systemInfo.ts +110 -0
- package/src/utils/webViewNavigation.ts +132 -0
- package/src/utils/webViewStorage.ts +62 -0
- package/src/version.ts +2 -0
- package/src/versions/version.dev.ts +2 -0
- package/src/versions/version.production.ts +2 -0
- package/src/versions/version.staging.ts +2 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { logger } from "./logger";
|
|
2
|
+
import { ErrorTypes } from "../constants/errorConstants";
|
|
3
|
+
import { Platform } from "react-native";
|
|
4
|
+
import { errorTracker } from "../services/ErrorTrackingService";
|
|
5
|
+
|
|
6
|
+
// Global error handler for uncaught errors and promise rejections
|
|
7
|
+
export const setupGlobalErrorHandlers = () => {
|
|
8
|
+
if (__DEV__) {
|
|
9
|
+
// Handle synchronous errors
|
|
10
|
+
const errorHandler = (error: Error, isFatal?: boolean) => {
|
|
11
|
+
errorTracker.trackError(error, "GlobalErrorHandler", {
|
|
12
|
+
isFatal,
|
|
13
|
+
type: ErrorTypes.RUNTIME_ERROR,
|
|
14
|
+
platform: Platform.OS,
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Handle promise rejections
|
|
19
|
+
const rejectionHandler = (event: any) => {
|
|
20
|
+
errorTracker.trackError(
|
|
21
|
+
event?.reason || new Error("Unknown Promise Error"),
|
|
22
|
+
"UnhandledPromiseRejection",
|
|
23
|
+
{
|
|
24
|
+
type: ErrorTypes.RUNTIME_ERROR,
|
|
25
|
+
platform: Platform.OS,
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Set up handlers
|
|
31
|
+
if ((global as any).ErrorUtils) {
|
|
32
|
+
const prevHandler = (global as any).ErrorUtils.getGlobalHandler();
|
|
33
|
+
(global as any).ErrorUtils.setGlobalHandler(
|
|
34
|
+
(error: Error, isFatal?: boolean) => {
|
|
35
|
+
errorHandler(error, isFatal);
|
|
36
|
+
prevHandler(error, isFatal);
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Handle promise rejections
|
|
42
|
+
const setupPromiseRejectionTracking = () => {
|
|
43
|
+
const tracking = require("promise/setimmediate/rejection-tracking");
|
|
44
|
+
tracking.enable({
|
|
45
|
+
allRejections: true,
|
|
46
|
+
onUnhandled: (id: number, error: Error) => {
|
|
47
|
+
rejectionHandler({ reason: error });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
setupPromiseRejectionTracking();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.warn("Promise rejection tracking setup failed:", error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: src/utils/fileDownload.ts
|
|
3
|
+
* Utility functions for handling file downloads
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Linking, NativeModules, Platform } from "react-native";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
|
|
9
|
+
export interface DownloadRequest {
|
|
10
|
+
url: string;
|
|
11
|
+
filename?: string;
|
|
12
|
+
inPlace?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const normalizeUrl = (url: string): string => {
|
|
16
|
+
return url?.trim();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getUrlPathTail = (url?: string): string => {
|
|
20
|
+
const cleanedUrl = url?.split("?")?.[0] || "";
|
|
21
|
+
return cleanedUrl?.split("/")?.pop()?.trim() || "";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const getExtensionFromName = (name?: string): string => {
|
|
25
|
+
if (!name?.includes(".")) return "";
|
|
26
|
+
return name.substring(name.lastIndexOf("."));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const resolveDownloadFileName = (filename?: string, url?: string): string => {
|
|
30
|
+
const trimmedName = filename?.trim();
|
|
31
|
+
if (trimmedName) {
|
|
32
|
+
const sanitizedName = trimmedName.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
|
|
33
|
+
const urlExtension = getExtensionFromName(getUrlPathTail(url));
|
|
34
|
+
if (getExtensionFromName(sanitizedName) || !urlExtension)
|
|
35
|
+
return sanitizedName;
|
|
36
|
+
return `${sanitizedName}${urlExtension}`;
|
|
37
|
+
}
|
|
38
|
+
const tailName = getUrlPathTail(url);
|
|
39
|
+
if (tailName) return tailName.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
|
|
40
|
+
return `download-robylon-${Date.now()}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type RobylonDownloadNativeModule = {
|
|
44
|
+
downloadFile: (url: string, filename: string) => Promise<string>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isValidHttpUrl = (url: string): boolean => {
|
|
48
|
+
return /^https?:\/\//i.test(url);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getNativeDownloadModule = (): RobylonDownloadNativeModule | null => {
|
|
52
|
+
return (
|
|
53
|
+
(NativeModules?.RobylonDownloadModule as RobylonDownloadNativeModule) ||
|
|
54
|
+
null
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const openUrlWithFallback = async (url: string): Promise<void> => {
|
|
59
|
+
try {
|
|
60
|
+
await Linking.openURL(url);
|
|
61
|
+
return;
|
|
62
|
+
} catch (primaryError) {
|
|
63
|
+
logger.warn("Primary URL open failed, trying browser fallback", {
|
|
64
|
+
url,
|
|
65
|
+
primaryError:
|
|
66
|
+
primaryError instanceof Error
|
|
67
|
+
? primaryError?.message
|
|
68
|
+
: String(primaryError),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await Linking.openURL(
|
|
73
|
+
`https://docs.google.com/viewer?url=${encodeURIComponent(url)}`,
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const downloadOnAndroid = async (
|
|
78
|
+
url: string,
|
|
79
|
+
filename: string,
|
|
80
|
+
): Promise<void> => {
|
|
81
|
+
const module = getNativeDownloadModule();
|
|
82
|
+
if (!module?.downloadFile) {
|
|
83
|
+
throw new Error("Robylon native download module is not linked on Android");
|
|
84
|
+
}
|
|
85
|
+
await module.downloadFile(url, filename);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const downloadOniOS = async (url: string, filename: string): Promise<void> => {
|
|
89
|
+
const module = getNativeDownloadModule();
|
|
90
|
+
if (!module?.downloadFile) {
|
|
91
|
+
throw new Error("Robylon native download module is not linked on iOS");
|
|
92
|
+
}
|
|
93
|
+
await module.downloadFile(url, filename);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const downloadWithNativeStorage = async (
|
|
97
|
+
url: string,
|
|
98
|
+
filename?: string,
|
|
99
|
+
): Promise<void> => {
|
|
100
|
+
const safeName = resolveDownloadFileName(filename, url);
|
|
101
|
+
if (Platform.OS === "android") {
|
|
102
|
+
await downloadOnAndroid(url, safeName);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (Platform.OS === "ios") {
|
|
106
|
+
await downloadOniOS(url, safeName);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await openUrlWithFallback(url);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const shouldUseInPlaceDownload = (
|
|
113
|
+
downloadRequest?: DownloadRequest,
|
|
114
|
+
): boolean => {
|
|
115
|
+
return downloadRequest?.inPlace === true;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Downloads a file using native storage when possible
|
|
120
|
+
* @param url - The URL of the file to download
|
|
121
|
+
* @param filename - Optional filename for the download
|
|
122
|
+
*/
|
|
123
|
+
export const downloadFile = async (
|
|
124
|
+
url: string,
|
|
125
|
+
filename?: string,
|
|
126
|
+
inPlace?: boolean,
|
|
127
|
+
): Promise<void> => {
|
|
128
|
+
try {
|
|
129
|
+
const normalizedUrl = normalizeUrl(url);
|
|
130
|
+
logger.debug("Initiating file download", { url: normalizedUrl, filename });
|
|
131
|
+
|
|
132
|
+
// Validate URL
|
|
133
|
+
if (!normalizedUrl || typeof normalizedUrl !== "string") {
|
|
134
|
+
throw new Error("Invalid URL provided for download");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!isValidHttpUrl(normalizedUrl)) {
|
|
138
|
+
throw new Error("Download URL must be an http/https URL");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (inPlace) {
|
|
142
|
+
try {
|
|
143
|
+
await downloadWithNativeStorage(normalizedUrl, filename);
|
|
144
|
+
logger.debug("File downloaded with native storage flow");
|
|
145
|
+
} catch (nativeDownloadError) {
|
|
146
|
+
logger.warn("Native download failed, falling back to URL open", {
|
|
147
|
+
url: normalizedUrl,
|
|
148
|
+
nativeDownloadError:
|
|
149
|
+
nativeDownloadError instanceof Error
|
|
150
|
+
? nativeDownloadError?.message
|
|
151
|
+
: String(nativeDownloadError),
|
|
152
|
+
});
|
|
153
|
+
await openUrlWithFallback(normalizedUrl);
|
|
154
|
+
logger.debug("File download opened with fallback browser flow");
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await openUrlWithFallback(normalizedUrl);
|
|
160
|
+
logger.debug("File download opened with legacy URL flow");
|
|
161
|
+
} catch (error) {
|
|
162
|
+
logger.error("File download failed", {
|
|
163
|
+
url,
|
|
164
|
+
filename,
|
|
165
|
+
error: error instanceof Error ? error?.message : String(error),
|
|
166
|
+
});
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Handles download request from WebView post message
|
|
173
|
+
* @param downloadRequest - The download request object
|
|
174
|
+
*/
|
|
175
|
+
export const handleDownloadRequest = async (
|
|
176
|
+
downloadRequest: DownloadRequest,
|
|
177
|
+
): Promise<void> => {
|
|
178
|
+
const { url, filename } = downloadRequest;
|
|
179
|
+
|
|
180
|
+
if (!url) {
|
|
181
|
+
logger.error("Download request missing URL");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await downloadFile(
|
|
187
|
+
url,
|
|
188
|
+
filename,
|
|
189
|
+
shouldUseInPlaceDownload(downloadRequest),
|
|
190
|
+
);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
logger.error("Failed to handle download request", {
|
|
193
|
+
downloadRequest,
|
|
194
|
+
error,
|
|
195
|
+
});
|
|
196
|
+
// Don't re-throw to avoid breaking the WebView message handling
|
|
197
|
+
}
|
|
198
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const logger = {
|
|
2
|
+
error: (message: string, error?: any) => {
|
|
3
|
+
console.error(message, error);
|
|
4
|
+
},
|
|
5
|
+
info: (message: string, data?: any) => {
|
|
6
|
+
console.info(message, data);
|
|
7
|
+
},
|
|
8
|
+
warn: (message: string, data?: any) => {
|
|
9
|
+
console.warn(message, data);
|
|
10
|
+
},
|
|
11
|
+
debug: (message: string, data?: any) => {
|
|
12
|
+
console.log(message, data);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session id pass-through between the chatbot web app and the SDK client.
|
|
3
|
+
*
|
|
4
|
+
* The session id travels on its own message type so that it never reaches
|
|
5
|
+
* `onEvent`, `onMessage` or the internal `/users/sdk/record-logs/` endpoint.
|
|
6
|
+
* Its only exits from the SDK are the `onSession` prop and `getSessionId()`
|
|
7
|
+
* on the ref, both of which the client has to opt into explicitly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Message the web app posts whenever the active session id changes. */
|
|
11
|
+
export const SESSION_UPDATED_MESSAGE_TYPE = "SESSION_UPDATED";
|
|
12
|
+
|
|
13
|
+
/** Key carrying the resume target inside the `registerUserId` payload. */
|
|
14
|
+
export const RESUME_SESSION_KEY = "sessionId";
|
|
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
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pulls the session id out of a `SESSION_UPDATED` payload.
|
|
30
|
+
*
|
|
31
|
+
* Accepts the snake_case, camelCase and nested spellings so a naming slip on
|
|
32
|
+
* the web side degrades to a no-op rather than a broken build. Anything that
|
|
33
|
+
* is not a non-empty string is dropped.
|
|
34
|
+
*/
|
|
35
|
+
export const extractSessionId = (data: any): string | undefined => {
|
|
36
|
+
const raw = data?.session_id ?? data?.sessionId ?? data?.session?.id;
|
|
37
|
+
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
38
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Platform, Dimensions, PixelRatio } from "react-native";
|
|
2
|
+
import { SDK_VERSION } from "../version";
|
|
3
|
+
|
|
4
|
+
interface ScreenSize {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface SystemInfo {
|
|
10
|
+
platform: string;
|
|
11
|
+
os: string;
|
|
12
|
+
browser: string;
|
|
13
|
+
browser_version: string | null;
|
|
14
|
+
browser_language: string | null;
|
|
15
|
+
sdk_version: string;
|
|
16
|
+
device: string;
|
|
17
|
+
screen_size: ScreenSize;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Get device type based on platform and screen dimensions
|
|
21
|
+
const getDeviceType = (): string => {
|
|
22
|
+
const { width, height } = Dimensions.get("window");
|
|
23
|
+
const aspectRatio = height / width;
|
|
24
|
+
|
|
25
|
+
if (Platform.OS === "ios") {
|
|
26
|
+
return Platform.isPad ? "iPad" : "iPhone";
|
|
27
|
+
} else if (Platform.OS === "android") {
|
|
28
|
+
return aspectRatio < 1.6 ? "Android Tablet" : "Android Phone";
|
|
29
|
+
}
|
|
30
|
+
return "Desktop";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Get screen dimensions with pixel ratio
|
|
34
|
+
const getScreenSize = (): ScreenSize => {
|
|
35
|
+
return {
|
|
36
|
+
width: Math.round(Dimensions.get("window").width * PixelRatio.get()),
|
|
37
|
+
height: Math.round(Dimensions.get("window").height * PixelRatio.get()),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Get browser detection script
|
|
42
|
+
export const getBrowserAndOSInfoScript = (): string => {
|
|
43
|
+
return `
|
|
44
|
+
function getOSAndBrowser() {
|
|
45
|
+
const userAgent = navigator.userAgent;
|
|
46
|
+
|
|
47
|
+
// OS Detection
|
|
48
|
+
let os = "unknown";
|
|
49
|
+
if (userAgent.indexOf("Win") !== -1) os = "Windows";
|
|
50
|
+
else if (userAgent.indexOf("Mac") !== -1) os = "MacOS";
|
|
51
|
+
else if (userAgent.indexOf("Linux") !== -1) os = "Linux";
|
|
52
|
+
else if (userAgent.indexOf("Android") !== -1) os = "Android";
|
|
53
|
+
else if (userAgent.indexOf("like Mac") !== -1) os = "iOS";
|
|
54
|
+
|
|
55
|
+
// Browser Detection
|
|
56
|
+
let browser = "unknown";
|
|
57
|
+
let browserVersion = null;
|
|
58
|
+
if (userAgent.indexOf("Chrome") !== -1) {
|
|
59
|
+
browser = "Chrome";
|
|
60
|
+
const chromeMatch = userAgent.match(/Chrome\\/([0-9.]+)/);
|
|
61
|
+
browserVersion = chromeMatch ? chromeMatch[1] : null;
|
|
62
|
+
} else if (userAgent.indexOf("Safari") !== -1) {
|
|
63
|
+
browser = "Safari";
|
|
64
|
+
const safariMatch = userAgent.match(/Version\\/([0-9.]+)/);
|
|
65
|
+
browserVersion = safariMatch ? safariMatch[1] : null;
|
|
66
|
+
} else if (userAgent.indexOf("Firefox") !== -1) {
|
|
67
|
+
browser = "Firefox";
|
|
68
|
+
const firefoxMatch = userAgent.match(/Firefox\\/([0-9.]+)/);
|
|
69
|
+
browserVersion = firefoxMatch ? firefoxMatch[1] : null;
|
|
70
|
+
} else if (userAgent.indexOf("Edge") !== -1) {
|
|
71
|
+
browser = "Edge";
|
|
72
|
+
const edgeMatch = userAgent.match(/Edge\\/([0-9.]+)/);
|
|
73
|
+
browserVersion = edgeMatch ? edgeMatch[1] : null;
|
|
74
|
+
} else if (userAgent.indexOf("MSIE") !== -1 || userAgent.indexOf("Trident/") !== -1) {
|
|
75
|
+
browser = "IE";
|
|
76
|
+
const ieMatch = userAgent.match(/(?:MSIE |rv:)([0-9.]+)/);
|
|
77
|
+
browserVersion = ieMatch ? ieMatch[1] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Browser Language
|
|
81
|
+
const browserLanguage = navigator.language || navigator.languages?.[0] || null;
|
|
82
|
+
|
|
83
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
84
|
+
type: 'SYSTEM_INFO',
|
|
85
|
+
data: { os, browser, browser_version: browserVersion, browser_language: browserLanguage }
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
getOSAndBrowser();
|
|
89
|
+
true;
|
|
90
|
+
`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Get complete system information
|
|
94
|
+
export const getSystemInfo = (webViewSystemInfo: {
|
|
95
|
+
os: string;
|
|
96
|
+
browser: string;
|
|
97
|
+
browser_version?: string | null;
|
|
98
|
+
browser_language?: string | null;
|
|
99
|
+
}): SystemInfo => {
|
|
100
|
+
return {
|
|
101
|
+
platform: Platform.OS,
|
|
102
|
+
os: webViewSystemInfo.os || Platform.OS,
|
|
103
|
+
browser: webViewSystemInfo.browser || "WebView",
|
|
104
|
+
browser_version: webViewSystemInfo.browser_version || null,
|
|
105
|
+
browser_language: webViewSystemInfo.browser_language || null,
|
|
106
|
+
sdk_version: SDK_VERSION,
|
|
107
|
+
device: getDeviceType(),
|
|
108
|
+
screen_size: getScreenSize(),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { logger } from "./logger";
|
|
2
|
+
|
|
3
|
+
export enum StorageMessageType {
|
|
4
|
+
GET_STORAGE = "GET_STORAGE",
|
|
5
|
+
SET_STORAGE = "SET_STORAGE",
|
|
6
|
+
STORAGE_READY = "STORAGE_READY",
|
|
7
|
+
STORAGE_ERROR = "STORAGE_ERROR",
|
|
8
|
+
STORAGE_RESPONSE = "STORAGE_RESPONSE",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StorageMessage {
|
|
12
|
+
type: StorageMessageType;
|
|
13
|
+
key?: string;
|
|
14
|
+
value?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const getStorageScript = () => `
|
|
19
|
+
window.handleStorageOperation = function(message) {
|
|
20
|
+
try {
|
|
21
|
+
switch(message.type) {
|
|
22
|
+
case '${StorageMessageType.GET_STORAGE}':
|
|
23
|
+
const value = localStorage.getItem(message.key);
|
|
24
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
25
|
+
type: '${StorageMessageType.STORAGE_RESPONSE}',
|
|
26
|
+
key: message.key,
|
|
27
|
+
value: value
|
|
28
|
+
}));
|
|
29
|
+
break;
|
|
30
|
+
case '${StorageMessageType.SET_STORAGE}':
|
|
31
|
+
localStorage.setItem(message.key, message.value);
|
|
32
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
33
|
+
type: '${StorageMessageType.STORAGE_RESPONSE}',
|
|
34
|
+
key: message.key,
|
|
35
|
+
value: message.value
|
|
36
|
+
}));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
41
|
+
type: '${StorageMessageType.STORAGE_ERROR}',
|
|
42
|
+
error: error.message
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
48
|
+
type: '${StorageMessageType.STORAGE_READY}'
|
|
49
|
+
}));
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
export const createStorageMessage = (
|
|
53
|
+
type: StorageMessageType,
|
|
54
|
+
key?: string,
|
|
55
|
+
value?: string
|
|
56
|
+
): string => {
|
|
57
|
+
return `window.handleStorageOperation(${JSON.stringify({
|
|
58
|
+
type,
|
|
59
|
+
key,
|
|
60
|
+
value,
|
|
61
|
+
})});`;
|
|
62
|
+
};
|
package/src/version.ts
ADDED