@rematter/pylon-react-native 0.1.4
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 +503 -0
- package/RNPylonChat.podspec +33 -0
- package/android/build.gradle +74 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/pylon/chatwidget/Pylon.kt +149 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonChat.kt +715 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonChatController.kt +63 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonChatListener.kt +76 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonChatView.kt +7 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonConfig.kt +62 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonDebugView.kt +76 -0
- package/android/src/main/java/com/pylon/chatwidget/PylonUser.kt +41 -0
- package/android/src/main/java/com/pylonchat/reactnative/RNPylonChatPackage.kt +17 -0
- package/android/src/main/java/com/pylonchat/reactnative/RNPylonChatView.kt +298 -0
- package/android/src/main/java/com/pylonchat/reactnative/RNPylonChatViewManager.kt +201 -0
- package/ios/PylonChat/PylonChat.swift +865 -0
- package/ios/RNPylonChatView.swift +332 -0
- package/ios/RNPylonChatViewManager.m +55 -0
- package/ios/RNPylonChatViewManager.swift +23 -0
- package/lib/PylonChatView.d.ts +27 -0
- package/lib/PylonChatView.js +78 -0
- package/lib/PylonChatWidget.android.d.ts +19 -0
- package/lib/PylonChatWidget.android.js +144 -0
- package/lib/PylonChatWidget.ios.d.ts +14 -0
- package/lib/PylonChatWidget.ios.js +79 -0
- package/lib/PylonModule.d.ts +32 -0
- package/lib/PylonModule.js +44 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +15 -0
- package/lib/types.d.ts +34 -0
- package/lib/types.js +2 -0
- package/package.json +39 -0
- package/src/PylonChatView.tsx +170 -0
- package/src/PylonChatWidget.android.tsx +165 -0
- package/src/PylonChatWidget.d.ts +15 -0
- package/src/PylonChatWidget.ios.tsx +79 -0
- package/src/PylonModule.ts +52 -0
- package/src/index.ts +15 -0
- package/src/types.ts +37 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
package com.pylon.chatwidget
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.graphics.Color
|
|
8
|
+
import android.graphics.Rect
|
|
9
|
+
import android.net.Uri
|
|
10
|
+
import android.util.AttributeSet
|
|
11
|
+
import android.util.Log
|
|
12
|
+
import android.view.MotionEvent
|
|
13
|
+
import android.view.View
|
|
14
|
+
import android.webkit.ConsoleMessage
|
|
15
|
+
import android.webkit.JavascriptInterface
|
|
16
|
+
import android.webkit.JsResult
|
|
17
|
+
import android.webkit.ValueCallback
|
|
18
|
+
import android.webkit.WebChromeClient
|
|
19
|
+
import android.webkit.WebResourceError
|
|
20
|
+
import android.webkit.WebResourceRequest
|
|
21
|
+
import android.webkit.WebSettings
|
|
22
|
+
import android.webkit.WebView
|
|
23
|
+
import android.webkit.WebViewClient
|
|
24
|
+
import android.widget.FrameLayout
|
|
25
|
+
import androidx.core.net.toUri
|
|
26
|
+
import org.json.JSONObject
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Custom WebView widget for Pylon Chat
|
|
30
|
+
*/
|
|
31
|
+
@SuppressLint("SetJavaScriptEnabled")
|
|
32
|
+
class PylonChat : FrameLayout {
|
|
33
|
+
|
|
34
|
+
private val config: PylonConfig
|
|
35
|
+
private var user: PylonUser?
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a PylonChat view with explicit configuration and user.
|
|
39
|
+
* This is the recommended constructor for React Native and programmatic usage.
|
|
40
|
+
*/
|
|
41
|
+
@JvmOverloads
|
|
42
|
+
constructor(
|
|
43
|
+
context: Context,
|
|
44
|
+
config: PylonConfig,
|
|
45
|
+
user: PylonUser? = null
|
|
46
|
+
) : super(context) {
|
|
47
|
+
this.config = config
|
|
48
|
+
this.user = user
|
|
49
|
+
initialize()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* XML/AttributeSet constructor - uses singleton Pylon configuration.
|
|
54
|
+
* Only for compatibility with XML layouts.
|
|
55
|
+
*/
|
|
56
|
+
@JvmOverloads
|
|
57
|
+
constructor(
|
|
58
|
+
context: Context,
|
|
59
|
+
attrs: AttributeSet? = null,
|
|
60
|
+
defStyleAttr: Int = 0
|
|
61
|
+
) : super(context, attrs, defStyleAttr) {
|
|
62
|
+
this.config = Pylon.requireConfig()
|
|
63
|
+
this.user = Pylon.currentUser()
|
|
64
|
+
initialize()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
companion object {
|
|
68
|
+
private const val TAG = "PylonWidget"
|
|
69
|
+
private const val FILE_CHOOSER_REQUEST_CODE = 0x5043 // "PC" in hex
|
|
70
|
+
|
|
71
|
+
enum class InteractiveElementId(val selector: String) {
|
|
72
|
+
FAB("pylon-chat-bubble"),
|
|
73
|
+
SURVEY("pylon-chat-popup-survey"),
|
|
74
|
+
MESSAGE("pylon-chat-popup-message")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private var activeFilePathCallback: ValueCallback<Array<Uri>>? = null
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Handle the file chooser result. Call this from your Activity's onActivityResult
|
|
81
|
+
* or from the ActivityResultLauncher callback.
|
|
82
|
+
*/
|
|
83
|
+
@Deprecated("Use handleActivityResult instead", ReplaceWith("handleActivityResult(resultCode, data)"))
|
|
84
|
+
@JvmStatic
|
|
85
|
+
fun handleFileChooserResult(resultCode: Int, data: Intent?) {
|
|
86
|
+
handleActivityResult(resultCode, data)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Consume an activity result if it belongs to a pending file picker request.
|
|
91
|
+
* Returns true when the SDK handled the result.
|
|
92
|
+
*/
|
|
93
|
+
@JvmStatic
|
|
94
|
+
fun handleActivityResult(resultCode: Int, data: Intent?): Boolean {
|
|
95
|
+
val callback = activeFilePathCallback
|
|
96
|
+
activeFilePathCallback = null
|
|
97
|
+
|
|
98
|
+
if (callback == null) {
|
|
99
|
+
Log.w(TAG, "No active file chooser callback")
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (resultCode == Activity.RESULT_OK && data != null) {
|
|
104
|
+
val result = when {
|
|
105
|
+
data.dataString != null -> arrayOf(data.dataString!!.toUri())
|
|
106
|
+
data.clipData != null -> {
|
|
107
|
+
val count = data.clipData!!.itemCount
|
|
108
|
+
Array(count) { i -> data.clipData!!.getItemAt(i).uri }
|
|
109
|
+
}
|
|
110
|
+
data.data != null -> arrayOf(data.data!!)
|
|
111
|
+
else -> null
|
|
112
|
+
}
|
|
113
|
+
callback.onReceiveValue(result)
|
|
114
|
+
} else {
|
|
115
|
+
callback.onReceiveValue(null)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private val webView: WebView = WebView(context).apply {
|
|
123
|
+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
124
|
+
}
|
|
125
|
+
private var listener: PylonChatListener? = null
|
|
126
|
+
private var hasStartedLoading = false
|
|
127
|
+
private var isLoaded = false
|
|
128
|
+
private var isChatWindowOpen = false
|
|
129
|
+
|
|
130
|
+
private val interactiveBounds = InteractiveElementId.entries
|
|
131
|
+
.associate { it.selector to Rect() }
|
|
132
|
+
.toMap()
|
|
133
|
+
|
|
134
|
+
private val debugOverlay = DebugOverlayView(context).apply {
|
|
135
|
+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
136
|
+
visibility = View.GONE
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun initialize() {
|
|
140
|
+
addView(webView)
|
|
141
|
+
if (config.debugMode) {
|
|
142
|
+
addView(debugOverlay)
|
|
143
|
+
debugOverlay.visibility = View.VISIBLE
|
|
144
|
+
debugOverlay.bounds = interactiveBounds
|
|
145
|
+
}
|
|
146
|
+
setupWebView()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override fun onAttachedToWindow() {
|
|
150
|
+
super.onAttachedToWindow()
|
|
151
|
+
ensurePylonLoaded()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a touch at the given coordinates should be handled by this view.
|
|
156
|
+
* Used by wrappers (e.g. React Native) to determine touch pass-through behavior.
|
|
157
|
+
*/
|
|
158
|
+
fun shouldHandleTouchAt(x: Float, y: Float): Boolean {
|
|
159
|
+
if (isChatWindowOpen) {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
val (ix, iy) = x.toInt() to y.toInt()
|
|
164
|
+
return interactiveBounds.any { (_, bounds) ->
|
|
165
|
+
!bounds.isEmpty && bounds.contains(ix, iy)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
|
170
|
+
if (isChatWindowOpen) {
|
|
171
|
+
return super.dispatchTouchEvent(ev)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
val (x, y) = ev.x.toInt() to ev.y.toInt()
|
|
175
|
+
val shouldHandleTap = interactiveBounds.any { (_, bounds) ->
|
|
176
|
+
!bounds.isEmpty && bounds.contains(x, y)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return if (shouldHandleTap) {
|
|
180
|
+
super.dispatchTouchEvent(ev)
|
|
181
|
+
} else {
|
|
182
|
+
false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private fun setupWebView() {
|
|
187
|
+
WebView.setWebContentsDebuggingEnabled(true)
|
|
188
|
+
webView.fitsSystemWindows = true
|
|
189
|
+
webView.settings.apply {
|
|
190
|
+
javaScriptEnabled = true
|
|
191
|
+
domStorageEnabled = true
|
|
192
|
+
databaseEnabled = true
|
|
193
|
+
allowFileAccess = true
|
|
194
|
+
allowContentAccess = true
|
|
195
|
+
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
|
196
|
+
useWideViewPort = true
|
|
197
|
+
loadWithOverviewMode = true
|
|
198
|
+
cacheMode = WebSettings.LOAD_DEFAULT
|
|
199
|
+
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
200
|
+
javaScriptCanOpenWindowsAutomatically = true
|
|
201
|
+
setSupportMultipleWindows(true)
|
|
202
|
+
allowFileAccessFromFileURLs = true
|
|
203
|
+
allowUniversalAccessFromFileURLs = true
|
|
204
|
+
mediaPlaybackRequiresUserGesture = false
|
|
205
|
+
setSupportZoom(false)
|
|
206
|
+
builtInZoomControls = false
|
|
207
|
+
displayZoomControls = false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
webView.setBackgroundColor(Color.TRANSPARENT)
|
|
211
|
+
|
|
212
|
+
webView.webViewClient = object : WebViewClient() {
|
|
213
|
+
override fun onPageFinished(view: WebView?, url: String?) {
|
|
214
|
+
super.onPageFinished(view, url)
|
|
215
|
+
Log.d(TAG, "Page finished loading: $url")
|
|
216
|
+
|
|
217
|
+
if (!isLoaded) {
|
|
218
|
+
isLoaded = true
|
|
219
|
+
initializePylon()
|
|
220
|
+
listener?.onPylonLoaded()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
|
225
|
+
super.onReceivedError(view, request, error)
|
|
226
|
+
val errorMsg = error?.description?.toString() ?: "Unknown error"
|
|
227
|
+
Log.e(TAG, "WebView Error: $errorMsg")
|
|
228
|
+
listener?.onPylonError(errorMsg)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
webView.webChromeClient = object : WebChromeClient() {
|
|
233
|
+
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
|
234
|
+
consoleMessage?.let { msg ->
|
|
235
|
+
if (config.enableLogging) {
|
|
236
|
+
val logMessage = "[${msg.messageLevel()}] ${msg.sourceId()}:${msg.lineNumber()} - ${msg.message()}"
|
|
237
|
+
Log.d(TAG, logMessage)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean {
|
|
244
|
+
Log.e(TAG, "Alert: $message from $url")
|
|
245
|
+
return super.onJsAlert(view, url, message, result)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: android.os.Message?): Boolean {
|
|
249
|
+
val href = view?.handler?.obtainMessage()
|
|
250
|
+
view?.requestFocusNodeHref(href)
|
|
251
|
+
|
|
252
|
+
href?.let {
|
|
253
|
+
val url = it.data.getString("url")
|
|
254
|
+
if (!url.isNullOrEmpty()) {
|
|
255
|
+
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
|
256
|
+
context.startActivity(intent)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override fun onShowFileChooser(
|
|
264
|
+
webView: WebView?,
|
|
265
|
+
filePathCallback: ValueCallback<Array<Uri>>?,
|
|
266
|
+
fileChooserParams: FileChooserParams?
|
|
267
|
+
): Boolean {
|
|
268
|
+
activeFilePathCallback?.onReceiveValue(null)
|
|
269
|
+
activeFilePathCallback = filePathCallback
|
|
270
|
+
|
|
271
|
+
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
272
|
+
addCategory(Intent.CATEGORY_OPENABLE)
|
|
273
|
+
val acceptTypes = fileChooserParams?.acceptTypes
|
|
274
|
+
type = when {
|
|
275
|
+
acceptTypes.isNullOrEmpty() -> "*/*"
|
|
276
|
+
acceptTypes.size == 1 -> acceptTypes[0].ifEmpty { "*/*" }
|
|
277
|
+
else -> "*/*"
|
|
278
|
+
}
|
|
279
|
+
if (fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
|
|
280
|
+
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
val chooserIntent = Intent.createChooser(intent, "Choose File")
|
|
285
|
+
|
|
286
|
+
return try {
|
|
287
|
+
if (context is Activity) {
|
|
288
|
+
(context as Activity).startActivityForResult(chooserIntent, FILE_CHOOSER_REQUEST_CODE)
|
|
289
|
+
} else {
|
|
290
|
+
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
291
|
+
context.startActivity(chooserIntent)
|
|
292
|
+
Log.w(
|
|
293
|
+
TAG,
|
|
294
|
+
"File chooser launched from non-Activity context. " +
|
|
295
|
+
"Developers should call PylonChat.handleFileChooserResult() " +
|
|
296
|
+
"from their Activity's onActivityResult or use ActivityResultLauncher"
|
|
297
|
+
)
|
|
298
|
+
listener?.onFileChooserLaunched(FILE_CHOOSER_REQUEST_CODE)
|
|
299
|
+
}
|
|
300
|
+
true
|
|
301
|
+
} catch (e: Exception) {
|
|
302
|
+
Log.e(TAG, "Cannot open file chooser", e)
|
|
303
|
+
filePathCallback?.onReceiveValue(null)
|
|
304
|
+
activeFilePathCallback = null
|
|
305
|
+
false
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
webView.addJavascriptInterface(PylonJSInterface(), "PylonNative")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private fun findInteractiveElementPosition(selector: String) {
|
|
314
|
+
val jsCode = """
|
|
315
|
+
javascript:(function() {
|
|
316
|
+
var element = document.querySelector('[id="$selector"]');
|
|
317
|
+
var dpr = window.devicePixelRatio || 1;
|
|
318
|
+
var rect = element ? element.getBoundingClientRect() : null;
|
|
319
|
+
|
|
320
|
+
if (rect !== null && rect.width > 0) {
|
|
321
|
+
window.PylonNative.updateInteractiveBounds(
|
|
322
|
+
"$selector",
|
|
323
|
+
rect.left * dpr,
|
|
324
|
+
rect.top * dpr,
|
|
325
|
+
rect.right * dpr,
|
|
326
|
+
rect.bottom * dpr
|
|
327
|
+
);
|
|
328
|
+
} else {
|
|
329
|
+
window.PylonNative.updateInteractiveBounds(
|
|
330
|
+
"$selector", 0, 0, 0, 0
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
})();
|
|
334
|
+
""".trimIndent()
|
|
335
|
+
|
|
336
|
+
webView.evaluateJavascript(jsCode, null)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Public API to force loading (or reloading) the pylon HTML manually.
|
|
341
|
+
*/
|
|
342
|
+
fun loadPylon(forceReload: Boolean = false) {
|
|
343
|
+
ensurePylonLoaded(forceReload)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
internal fun ensurePylonLoaded(forceReload: Boolean = false) {
|
|
347
|
+
if (forceReload) {
|
|
348
|
+
hasStartedLoading = false
|
|
349
|
+
isLoaded = false
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (hasStartedLoading) return
|
|
353
|
+
|
|
354
|
+
val html = generateHtml(config, user)
|
|
355
|
+
hasStartedLoading = true
|
|
356
|
+
webView.loadDataWithBaseURL(
|
|
357
|
+
config.widgetBaseUrl,
|
|
358
|
+
html,
|
|
359
|
+
"text/html",
|
|
360
|
+
"UTF-8",
|
|
361
|
+
null
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private fun initializePylon() {
|
|
366
|
+
val settingsObject = buildChatSettings(config, user)
|
|
367
|
+
val jsCode = """
|
|
368
|
+
javascript:(function() {
|
|
369
|
+
if (!window.pylon) {
|
|
370
|
+
window.pylon = {};
|
|
371
|
+
}
|
|
372
|
+
window.pylon.debug = ${config.debugMode};
|
|
373
|
+
window.pylon.chat_settings = $settingsObject;
|
|
374
|
+
|
|
375
|
+
if (window.Pylon) {
|
|
376
|
+
window.Pylon('onShow', function() {
|
|
377
|
+
window.PylonNative.onChatWindowOpened();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
window.Pylon('onHide', function() {
|
|
381
|
+
window.PylonNative.onChatWindowClosed();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
window.Pylon('onShowChatBubble', function() {
|
|
385
|
+
window.PylonNative.onInteractiveElementUpdate('${InteractiveElementId.FAB.selector}');
|
|
386
|
+
window.PylonNative.onInteractiveElementUpdate('${InteractiveElementId.MESSAGE.selector}');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
window.Pylon('onHideChatBubble', function() {
|
|
390
|
+
window.PylonNative.onInteractiveElementUpdate('${InteractiveElementId.FAB.selector}');
|
|
391
|
+
window.PylonNative.onInteractiveElementUpdate('${InteractiveElementId.MESSAGE.selector}');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
window.Pylon('onPopupSurveyVisibilityChange', function(isShowing) {
|
|
395
|
+
window.PylonNative.onInteractiveElementUpdate('${InteractiveElementId.SURVEY.selector}');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
window.Pylon('onPopupMessageVisibilityChange', function(isShowing) {
|
|
399
|
+
window.PylonNative.onInteractiveElementUpdate('${InteractiveElementId.MESSAGE.selector}');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
window.Pylon('onChangeUnreadMessagesCount', function(unreadCount) {
|
|
403
|
+
window.PylonNative.onUnreadCountChanged(unreadCount);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (window.PylonNative) {
|
|
408
|
+
window.PylonNative.onInitialized();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
console.log('Pylon initialized with user:', window.pylon.chat_settings);
|
|
412
|
+
})();
|
|
413
|
+
""".trimIndent()
|
|
414
|
+
|
|
415
|
+
webView.evaluateJavascript(jsCode, null)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fun openChat() {
|
|
419
|
+
webView.evaluateJavascript("javascript:if(window.Pylon) { window.Pylon('show'); }", null)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
fun closeChat() {
|
|
423
|
+
webView.evaluateJavascript("javascript:if(window.Pylon) { window.Pylon('hide'); }", null)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
fun showChatBubble() {
|
|
427
|
+
webView.evaluateJavascript("javascript:if(window.Pylon) { window.Pylon('showChatBubble'); }", null)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fun hideChatBubble() {
|
|
431
|
+
webView.evaluateJavascript("javascript:if(window.Pylon) { window.Pylon('hideChatBubble'); }", null)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
fun setNewIssueCustomFields(fields: Map<String, Any?>) {
|
|
435
|
+
val json = fields.toJsonString()
|
|
436
|
+
invokePylonCommand("setNewIssueCustomFields", json)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
fun setTicketFormFields(fields: Map<String, Any?>) {
|
|
440
|
+
val json = fields.toJsonString()
|
|
441
|
+
invokePylonCommand("setTicketFormFields", json)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
fun showNewMessage(message: String, isHtml: Boolean = false) {
|
|
445
|
+
val messageArg = JSONObject.quote(message)
|
|
446
|
+
if (isHtml) {
|
|
447
|
+
invokePylonCommand("showNewMessage", messageArg, "{ isHtml: true }")
|
|
448
|
+
} else {
|
|
449
|
+
invokePylonCommand("showNewMessage", messageArg)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
fun showTicketForm(ticketFormSlug: String) {
|
|
454
|
+
val slugArg = JSONObject.quote(ticketFormSlug)
|
|
455
|
+
invokePylonCommand("showTicketForm", slugArg)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fun showKnowledgeBaseArticle(articleId: String) {
|
|
459
|
+
val idArg = JSONObject.quote(articleId)
|
|
460
|
+
invokePylonCommand("showKnowledgeBaseArticle", idArg)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
fun clickElementBySelector(selector: String) {
|
|
464
|
+
// Trigger a click on the element with the given ID selector.
|
|
465
|
+
// Used by React Native's Android proxy-based touch pass-through system.
|
|
466
|
+
val jsCode = """
|
|
467
|
+
(function() {
|
|
468
|
+
var element = document.getElementById('$selector');
|
|
469
|
+
if (element && element.click) {
|
|
470
|
+
element.click();
|
|
471
|
+
}
|
|
472
|
+
})();
|
|
473
|
+
""".trimIndent()
|
|
474
|
+
webView.evaluateJavascript(jsCode, null)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fun updateEmailHash(emailHash: String?) {
|
|
478
|
+
Pylon.setEmailHash(emailHash)
|
|
479
|
+
initializePylon()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fun updateUser(user: PylonUser) {
|
|
483
|
+
Pylon.setUser(user)
|
|
484
|
+
initializePylon()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
fun setListener(listener: PylonChatListener?) {
|
|
488
|
+
this.listener = listener
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Update the user for this chat instance and reload.
|
|
493
|
+
*/
|
|
494
|
+
fun setUser(user: PylonUser?) {
|
|
495
|
+
this.user = user
|
|
496
|
+
if (isLoaded) {
|
|
497
|
+
initializePylon()
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Update the email hash for the current user.
|
|
503
|
+
*/
|
|
504
|
+
fun setEmailHash(emailHash: String?) {
|
|
505
|
+
val currentUser = this.user ?: error("Set user before calling setEmailHash().")
|
|
506
|
+
setUser(currentUser.copy(emailHash = emailHash))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fun setPylonListener(listener: PylonChatListener) {
|
|
510
|
+
setListener(listener)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
fun destroy() {
|
|
514
|
+
activeFilePathCallback?.onReceiveValue(null)
|
|
515
|
+
activeFilePathCallback = null
|
|
516
|
+
listener = null
|
|
517
|
+
webView.destroy()
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private fun invokePylonCommand(command: String, vararg arguments: String) {
|
|
521
|
+
val joinedArgs = arguments.joinToString(separator = ", ")
|
|
522
|
+
val script = if (joinedArgs.isEmpty()) {
|
|
523
|
+
"javascript:if(window.Pylon){ window.Pylon('$command'); }"
|
|
524
|
+
} else {
|
|
525
|
+
"javascript:if(window.Pylon){ window.Pylon('$command', $joinedArgs); }"
|
|
526
|
+
}
|
|
527
|
+
webView.evaluateJavascript(script, null)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private fun Map<String, Any?>.toJsonString(): String {
|
|
531
|
+
val json = JSONObject()
|
|
532
|
+
for ((key, value) in this) {
|
|
533
|
+
if (value == null) {
|
|
534
|
+
json.put(key, JSONObject.NULL)
|
|
535
|
+
} else {
|
|
536
|
+
json.put(key, value)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return json.toString()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private fun generateHtml(config: PylonConfig, user: PylonUser?): String {
|
|
543
|
+
|
|
544
|
+
val chatSettings = buildChatSettings(config, user)
|
|
545
|
+
|
|
546
|
+
val primaryColorStyles = config.primaryColor?.let {
|
|
547
|
+
"""
|
|
548
|
+
:root {
|
|
549
|
+
--pylon-primary-color: $it;
|
|
550
|
+
}
|
|
551
|
+
""".trimIndent()
|
|
552
|
+
} ?: ""
|
|
553
|
+
|
|
554
|
+
return """
|
|
555
|
+
<!DOCTYPE html>
|
|
556
|
+
<html>
|
|
557
|
+
<head>
|
|
558
|
+
<meta charset="UTF-8">
|
|
559
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
560
|
+
<style>
|
|
561
|
+
body {
|
|
562
|
+
margin: 0;
|
|
563
|
+
padding: 0;
|
|
564
|
+
padding-top: env(safe-area-inset-top);
|
|
565
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
566
|
+
padding-right: env(safe-area-inset-right);
|
|
567
|
+
padding-left: env(safe-area-inset-left);
|
|
568
|
+
background-color: transparent;
|
|
569
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
570
|
+
pointer-events: none;
|
|
571
|
+
}
|
|
572
|
+
.pylon-widget, [id*="pylon"], [class*="pylon"] {
|
|
573
|
+
pointer-events: auto !important;
|
|
574
|
+
}
|
|
575
|
+
$primaryColorStyles
|
|
576
|
+
</style>
|
|
577
|
+
</head>
|
|
578
|
+
<body>
|
|
579
|
+
<script>
|
|
580
|
+
if (!window.pylon) {
|
|
581
|
+
window.pylon = {};
|
|
582
|
+
}
|
|
583
|
+
window.pylon.debug = ${config.debugMode};
|
|
584
|
+
window.pylon.chat_settings = $chatSettings;
|
|
585
|
+
console.log("Pylon initialized with:", window.pylon.chat_settings);
|
|
586
|
+
</script>
|
|
587
|
+
<script>
|
|
588
|
+
(function(){
|
|
589
|
+
var e=window;
|
|
590
|
+
var t=document;
|
|
591
|
+
var n=function(){n.e(arguments)};
|
|
592
|
+
n.q=[];
|
|
593
|
+
n.e=function(e){n.q.push(e)};
|
|
594
|
+
e.Pylon=n;
|
|
595
|
+
var r=function(){
|
|
596
|
+
var e=t.createElement("script");
|
|
597
|
+
e.setAttribute("type","text/javascript");
|
|
598
|
+
e.setAttribute("async","true");
|
|
599
|
+
e.setAttribute("src","${config.widgetScriptUrl}");
|
|
600
|
+
var n=t.getElementsByTagName("script")[0];
|
|
601
|
+
n.parentNode.insertBefore(e,n)
|
|
602
|
+
};
|
|
603
|
+
if(t.readyState==="complete"){r()}
|
|
604
|
+
else if(e.addEventListener){e.addEventListener("load",r,false)}
|
|
605
|
+
})();
|
|
606
|
+
</script>
|
|
607
|
+
<script>
|
|
608
|
+
window.pylonReady = function() {
|
|
609
|
+
if (window.PylonNative) {
|
|
610
|
+
window.PylonNative.onReady();
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
if (window.Pylon) {
|
|
614
|
+
window.pylonReady();
|
|
615
|
+
}
|
|
616
|
+
</script>
|
|
617
|
+
</body>
|
|
618
|
+
</html>
|
|
619
|
+
""".trimIndent()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private fun buildChatSettings(config: PylonConfig, user: PylonUser?): String {
|
|
623
|
+
val fields = mutableListOf("app_id: '${escapeJavaScriptString(config.appId)}'")
|
|
624
|
+
config.primaryColor?.let { fields += "primary_color: '${escapeJavaScriptString(it)}'" }
|
|
625
|
+
if (user != null) {
|
|
626
|
+
fields += "email: '${escapeJavaScriptString(user.email)}'"
|
|
627
|
+
fields += "name: '${escapeJavaScriptString(user.name)}'"
|
|
628
|
+
user.avatarUrl?.let { fields += "avatar_url: '${escapeJavaScriptString(it)}'" }
|
|
629
|
+
user.emailHash?.let { fields += "email_hash: '${escapeJavaScriptString(it)}'" }
|
|
630
|
+
user.accountId?.let { fields += "account_id: '${escapeJavaScriptString(it)}'" }
|
|
631
|
+
user.accountExternalId?.let { fields += "account_external_id: '${escapeJavaScriptString(it)}'" }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
val joined = fields.joinToString(
|
|
635
|
+
separator = ",\n ",
|
|
636
|
+
prefix = "{\n ",
|
|
637
|
+
postfix = "\n }"
|
|
638
|
+
)
|
|
639
|
+
return joined
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private fun escapeJavaScriptString(string: String): String {
|
|
643
|
+
return string
|
|
644
|
+
.replace("\\", "\\\\")
|
|
645
|
+
.replace("'", "\\'")
|
|
646
|
+
.replace("\"", "\\\"")
|
|
647
|
+
.replace("\n", "\\n")
|
|
648
|
+
.replace("\r", "\\r")
|
|
649
|
+
.replace("\t", "\\t")
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
inner class PylonJSInterface {
|
|
654
|
+
@JavascriptInterface
|
|
655
|
+
fun log(value: String) {
|
|
656
|
+
post { Log.d(TAG, value) }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
@JavascriptInterface
|
|
660
|
+
fun onInitialized() {
|
|
661
|
+
post { listener?.onPylonInitialized() }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
@JavascriptInterface
|
|
665
|
+
fun onReady() {
|
|
666
|
+
post { listener?.onPylonReady() }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
@JavascriptInterface
|
|
670
|
+
fun onChatWindowOpened() {
|
|
671
|
+
post {
|
|
672
|
+
isChatWindowOpen = true
|
|
673
|
+
listener?.onChatOpened()
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
@JavascriptInterface
|
|
678
|
+
fun onChatWindowClosed() {
|
|
679
|
+
post {
|
|
680
|
+
isChatWindowOpen = false
|
|
681
|
+
listener?.onChatClosed()
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
@JavascriptInterface
|
|
686
|
+
fun onInteractiveElementUpdate(selector: String) {
|
|
687
|
+
post {
|
|
688
|
+
log("Finding interactive bounds for: $selector")
|
|
689
|
+
findInteractiveElementPosition(selector)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
@JavascriptInterface
|
|
694
|
+
fun updateInteractiveBounds(selector: String, left: Float, top: Float, right: Float, bottom: Float) {
|
|
695
|
+
post {
|
|
696
|
+
log("Updating interactive bounds for: $selector ($left, $top) - ($right, $bottom)")
|
|
697
|
+
interactiveBounds[selector]?.set(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
|
|
698
|
+
|
|
699
|
+
// Notify listener about bounds change
|
|
700
|
+
listener?.onInteractiveBoundsChanged(selector, left, top, right, bottom)
|
|
701
|
+
|
|
702
|
+
if (config.debugMode) {
|
|
703
|
+
debugOverlay.bounds = interactiveBounds
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
@JavascriptInterface
|
|
709
|
+
fun onUnreadCountChanged(unreadCount: Double) {
|
|
710
|
+
post {
|
|
711
|
+
listener?.onUnreadCountChanged(unreadCount.toInt())
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|