@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.
Files changed (40) hide show
  1. package/README.md +503 -0
  2. package/RNPylonChat.podspec +33 -0
  3. package/android/build.gradle +74 -0
  4. package/android/gradle.properties +5 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/pylon/chatwidget/Pylon.kt +149 -0
  7. package/android/src/main/java/com/pylon/chatwidget/PylonChat.kt +715 -0
  8. package/android/src/main/java/com/pylon/chatwidget/PylonChatController.kt +63 -0
  9. package/android/src/main/java/com/pylon/chatwidget/PylonChatListener.kt +76 -0
  10. package/android/src/main/java/com/pylon/chatwidget/PylonChatView.kt +7 -0
  11. package/android/src/main/java/com/pylon/chatwidget/PylonConfig.kt +62 -0
  12. package/android/src/main/java/com/pylon/chatwidget/PylonDebugView.kt +76 -0
  13. package/android/src/main/java/com/pylon/chatwidget/PylonUser.kt +41 -0
  14. package/android/src/main/java/com/pylonchat/reactnative/RNPylonChatPackage.kt +17 -0
  15. package/android/src/main/java/com/pylonchat/reactnative/RNPylonChatView.kt +298 -0
  16. package/android/src/main/java/com/pylonchat/reactnative/RNPylonChatViewManager.kt +201 -0
  17. package/ios/PylonChat/PylonChat.swift +865 -0
  18. package/ios/RNPylonChatView.swift +332 -0
  19. package/ios/RNPylonChatViewManager.m +55 -0
  20. package/ios/RNPylonChatViewManager.swift +23 -0
  21. package/lib/PylonChatView.d.ts +27 -0
  22. package/lib/PylonChatView.js +78 -0
  23. package/lib/PylonChatWidget.android.d.ts +19 -0
  24. package/lib/PylonChatWidget.android.js +144 -0
  25. package/lib/PylonChatWidget.ios.d.ts +14 -0
  26. package/lib/PylonChatWidget.ios.js +79 -0
  27. package/lib/PylonModule.d.ts +32 -0
  28. package/lib/PylonModule.js +44 -0
  29. package/lib/index.d.ts +5 -0
  30. package/lib/index.js +15 -0
  31. package/lib/types.d.ts +34 -0
  32. package/lib/types.js +2 -0
  33. package/package.json +39 -0
  34. package/src/PylonChatView.tsx +170 -0
  35. package/src/PylonChatWidget.android.tsx +165 -0
  36. package/src/PylonChatWidget.d.ts +15 -0
  37. package/src/PylonChatWidget.ios.tsx +79 -0
  38. package/src/PylonModule.ts +52 -0
  39. package/src/index.ts +15 -0
  40. 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
+ }