@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,865 @@
1
+ //
2
+ // PylonChat.swift
3
+ // PylonChat
4
+ //
5
+ // Created by Ben Soh on 10/7/25.
6
+ //
7
+
8
+ import Foundation
9
+ import UIKit
10
+ import WebKit
11
+
12
+ // MARK: - PylonConfig
13
+
14
+ public struct PylonConfig {
15
+ public let appId: String
16
+ public let enableLogging: Bool
17
+ public let primaryColor: String?
18
+ public let debugMode: Bool
19
+ public let widgetBaseUrl: String
20
+ public let widgetScriptUrl: String
21
+
22
+ private static let defaultWidgetBaseUrl = "https://widget.usepylon.com"
23
+
24
+ public init(appId: String,
25
+ enableLogging: Bool = true,
26
+ primaryColor: String? = nil,
27
+ debugMode: Bool = false,
28
+ widgetBaseUrl: String? = nil,
29
+ widgetScriptUrl: String? = nil) {
30
+ self.appId = appId
31
+ self.enableLogging = enableLogging
32
+ self.primaryColor = primaryColor
33
+ self.debugMode = debugMode
34
+ self.widgetBaseUrl = widgetBaseUrl ?? Self.defaultWidgetBaseUrl
35
+
36
+ // URL-encode the appId for the script URL
37
+ let encodedAppId = appId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? appId
38
+ self.widgetScriptUrl = widgetScriptUrl ?? "\(Self.defaultWidgetBaseUrl)/widget/\(encodedAppId)"
39
+ }
40
+ }
41
+
42
+ // MARK: - PylonUser
43
+
44
+ public struct PylonUser {
45
+ public let email: String
46
+ public let name: String
47
+ public let avatarUrl: String?
48
+ public let emailHash: String?
49
+ public let accountId: String?
50
+ public let accountExternalId: String?
51
+
52
+ public init(email: String,
53
+ name: String,
54
+ avatarUrl: String? = nil,
55
+ emailHash: String? = nil,
56
+ accountId: String? = nil,
57
+ accountExternalId: String? = nil) {
58
+ self.email = email
59
+ self.name = name
60
+ self.avatarUrl = avatarUrl
61
+ self.emailHash = emailHash
62
+ self.accountId = accountId
63
+ self.accountExternalId = accountExternalId
64
+ }
65
+ }
66
+
67
+ // MARK: - PylonChatListener
68
+
69
+ public protocol PylonChatListener: AnyObject {
70
+ func onPylonLoaded()
71
+ func onPylonInitialized()
72
+ func onPylonReady()
73
+ func onMessageReceived(message: String)
74
+ func onChatOpened()
75
+ func onChatClosed(wasOpen: Bool)
76
+ func onPylonError(error: String)
77
+ func onUnreadCountChanged(count: Int)
78
+ }
79
+
80
+ public extension PylonChatListener {
81
+ func onPylonLoaded() {}
82
+ func onPylonInitialized() {}
83
+ func onPylonReady() {}
84
+ func onMessageReceived(message: String) {}
85
+ func onChatOpened() {}
86
+ func onChatClosed(wasOpen: Bool) {}
87
+ func onPylonError(error: String) {}
88
+ func onUnreadCountChanged(count: Int) {}
89
+ }
90
+
91
+ // MARK: - Pylon (Main SDK Entry Point)
92
+
93
+ public class Pylon {
94
+ public static let shared = Pylon()
95
+
96
+ private var config: PylonConfig?
97
+ private var user: PylonUser?
98
+
99
+ private init() {}
100
+
101
+ public func initialize(config: PylonConfig) {
102
+ self.config = config
103
+ }
104
+
105
+ public func initialize(appId: String,
106
+ enableLogging: Bool = true,
107
+ primaryColor: String? = nil,
108
+ debugMode: Bool = false,
109
+ widgetBaseUrl: String? = nil,
110
+ widgetScriptUrl: String? = nil) {
111
+ let config = PylonConfig(
112
+ appId: appId,
113
+ enableLogging: enableLogging,
114
+ primaryColor: primaryColor,
115
+ debugMode: debugMode,
116
+ widgetBaseUrl: widgetBaseUrl,
117
+ widgetScriptUrl: widgetScriptUrl
118
+ )
119
+ self.config = config
120
+ }
121
+
122
+ public func setUser(_ user: PylonUser) {
123
+ self.user = user
124
+ }
125
+
126
+ public func setUser(email: String, name: String,
127
+ avatarUrl: String? = nil,
128
+ emailHash: String? = nil,
129
+ accountId: String? = nil,
130
+ accountExternalId: String? = nil) {
131
+ self.user = PylonUser(
132
+ email: email,
133
+ name: name,
134
+ avatarUrl: avatarUrl,
135
+ emailHash: emailHash,
136
+ accountId: accountId,
137
+ accountExternalId: accountExternalId
138
+ )
139
+ }
140
+
141
+ public func clearUser() {
142
+ self.user = nil
143
+ }
144
+
145
+ public func setEmailHash(_ emailHash: String?) {
146
+ guard var user = self.user else {
147
+ print("⚠️ Set user before calling setEmailHash()")
148
+ return
149
+ }
150
+ self.user = PylonUser(
151
+ email: user.email,
152
+ name: user.name,
153
+ avatarUrl: user.avatarUrl,
154
+ emailHash: emailHash,
155
+ accountId: user.accountId,
156
+ accountExternalId: user.accountExternalId
157
+ )
158
+ }
159
+
160
+ public func createChat() -> PylonChatView {
161
+ guard let config = config else {
162
+ fatalError("Pylon SDK not initialized. Call Pylon.shared.initialize() first.")
163
+ }
164
+ return PylonChatView(config: config, user: user)
165
+ }
166
+
167
+ internal func requireConfig() -> PylonConfig {
168
+ guard let config = config else {
169
+ fatalError("Pylon SDK not initialized. Call Pylon.shared.initialize() first.")
170
+ }
171
+ return config
172
+ }
173
+
174
+ internal func currentUser() -> PylonUser? {
175
+ return user
176
+ }
177
+ }
178
+
179
+ // MARK: - PylonChatView
180
+
181
+ public class PylonChatView: UIView {
182
+ private let config: PylonConfig
183
+ private var user: PylonUser?
184
+ private var webView: WKWebView!
185
+ private var hasStartedLoading = false
186
+ private var isLoaded = false
187
+ private var isChatWindowOpen = false
188
+
189
+ // Top inset for coordinate space adjustment (e.g., status bar height in React Native)
190
+ public var topInset: CGFloat = 0 {
191
+ didSet {
192
+ log("📱 Top inset updated to: \(topInset)")
193
+ if config.debugMode {
194
+ debugOverlay.setNeedsDisplay()
195
+ }
196
+ }
197
+ }
198
+
199
+ public weak var listener: PylonChatListener?
200
+
201
+ // Interactive element IDs to track
202
+ private enum InteractiveElementId: String {
203
+ case fab = "pylon-chat-bubble"
204
+ case survey = "pylon-chat-popup-survey"
205
+ case message = "pylon-chat-popup-message"
206
+ }
207
+
208
+ // Track bounds of interactive elements
209
+ private var interactiveBounds: [String: CGRect] = [
210
+ InteractiveElementId.fab.rawValue: .zero,
211
+ InteractiveElementId.survey.rawValue: .zero,
212
+ InteractiveElementId.message.rawValue: .zero
213
+ ]
214
+
215
+ init(config: PylonConfig, user: PylonUser?) {
216
+ self.config = config
217
+ self.user = user
218
+ super.init(frame: .zero)
219
+ setupWebView()
220
+ }
221
+
222
+ required init?(coder: NSCoder) {
223
+ fatalError("init(coder:) has not been implemented")
224
+ }
225
+
226
+ private lazy var debugOverlay: DebugOverlayView = {
227
+ let overlay = DebugOverlayView()
228
+ overlay.translatesAutoresizingMaskIntoConstraints = false
229
+ overlay.isUserInteractionEnabled = false
230
+ overlay.backgroundColor = .clear
231
+ return overlay
232
+ }()
233
+
234
+ private func log(_ message: String) {
235
+ if config.enableLogging {
236
+ NSLog(message)
237
+ }
238
+ }
239
+
240
+ private func setupWebView() {
241
+ log("🚀 PylonChatView: setupWebView called")
242
+
243
+ let configuration = WKWebViewConfiguration()
244
+ configuration.allowsInlineMediaPlayback = true
245
+ configuration.mediaTypesRequiringUserActionForPlayback = []
246
+
247
+ let userContentController = WKUserContentController()
248
+
249
+ // Add message handler for JavaScript bridge
250
+ userContentController.add(WeakScriptMessageHandler(delegate: self), name: "PylonNative")
251
+ configuration.userContentController = userContentController
252
+
253
+ webView = WKWebView(frame: .zero, configuration: configuration)
254
+ webView.translatesAutoresizingMaskIntoConstraints = false
255
+ webView.backgroundColor = .clear
256
+ webView.isOpaque = false
257
+ webView.scrollView.backgroundColor = .clear
258
+ webView.navigationDelegate = self
259
+ webView.uiDelegate = self
260
+
261
+ // Make webView not block touches when chat is closed
262
+ webView.isUserInteractionEnabled = true
263
+
264
+ addSubview(webView)
265
+
266
+ NSLayoutConstraint.activate([
267
+ webView.topAnchor.constraint(equalTo: topAnchor),
268
+ webView.leadingAnchor.constraint(equalTo: leadingAnchor),
269
+ webView.trailingAnchor.constraint(equalTo: trailingAnchor),
270
+ webView.bottomAnchor.constraint(equalTo: bottomAnchor)
271
+ ])
272
+
273
+ // Add debug overlay if in debug mode
274
+ if config.debugMode {
275
+ addSubview(debugOverlay)
276
+ NSLayoutConstraint.activate([
277
+ debugOverlay.topAnchor.constraint(equalTo: topAnchor),
278
+ debugOverlay.leadingAnchor.constraint(equalTo: leadingAnchor),
279
+ debugOverlay.trailingAnchor.constraint(equalTo: trailingAnchor),
280
+ debugOverlay.bottomAnchor.constraint(equalTo: bottomAnchor)
281
+ ])
282
+ }
283
+
284
+ log("🚀 PylonChatView: setupWebView completed")
285
+ }
286
+
287
+ public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
288
+ log("🔍 PylonChatView.hitTest - point: (\(point.x), \(point.y)), topInset: \(topInset), isChatWindowOpen: \(isChatWindowOpen)")
289
+
290
+ // If chat window is open, pass all touches to webView
291
+ if isChatWindowOpen {
292
+ log("✅ Chat is OPEN - passing touches to webView")
293
+ return webView.hitTest(point, with: event)
294
+ }
295
+
296
+ // Adjust point by top inset to match WebView's coordinate space
297
+ // The WebView reports bounds in viewport coordinates (starting below status bar)
298
+ // but hitTest receives points in this view's coordinate space
299
+ let adjustedPoint = CGPoint(x: point.x, y: point.y + topInset)
300
+
301
+ // Check if adjusted touch is within interactive bounds
302
+ let shouldHandleTap = interactiveBounds.values.contains { bounds in
303
+ !bounds.isEmpty && bounds.contains(adjustedPoint)
304
+ }
305
+
306
+ if shouldHandleTap {
307
+ log("✅ Touch is within interactive bounds (adjusted: \(adjustedPoint)) - passing to webView")
308
+ return webView.hitTest(point, with: event)
309
+ }
310
+
311
+ // Let touches fall through to views behind this view
312
+ log("❌ Touch outside interactive area - passing through")
313
+ return nil
314
+ }
315
+
316
+ public override func didMoveToWindow() {
317
+ super.didMoveToWindow()
318
+ if window != nil {
319
+ ensurePylonLoaded()
320
+ }
321
+ }
322
+
323
+ public func ensurePylonLoaded(forceReload: Bool = false) {
324
+ if forceReload {
325
+ hasStartedLoading = false
326
+ isLoaded = false
327
+ }
328
+
329
+ guard !hasStartedLoading else { return }
330
+
331
+ let html = generateHTML()
332
+ hasStartedLoading = true
333
+ webView.loadHTMLString(html, baseURL: URL(string: config.widgetBaseUrl))
334
+ }
335
+
336
+ private func generateHTML() -> String {
337
+ let chatSettings = buildChatSettings()
338
+
339
+ return """
340
+ <!DOCTYPE html>
341
+ <html>
342
+ <head>
343
+ <meta charset="UTF-8">
344
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
345
+ <style>
346
+ body {
347
+ margin: 0;
348
+ padding: 0;
349
+ background-color: transparent;
350
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
351
+ pointer-events: none;
352
+ }
353
+ .pylon-widget, [id*="pylon"], [class*="pylon"] {
354
+ pointer-events: auto !important;
355
+ }
356
+ </style>
357
+ </head>
358
+ <body>
359
+ <script>
360
+ if (!window.pylon) {
361
+ window.pylon = {};
362
+ }
363
+ window.pylon.chat_settings = \(chatSettings);
364
+ console.log("Pylon initialized with:", window.pylon.chat_settings);
365
+ </script>
366
+ <script>
367
+ (function(){
368
+ var e=window;
369
+ var t=document;
370
+ var n=function(){n.e(arguments)};
371
+ n.q=[];
372
+ n.e=function(e){n.q.push(e)};
373
+ e.Pylon=n;
374
+ var r=function(){
375
+ var e=t.createElement("script");
376
+ e.setAttribute("type","text/javascript");
377
+ e.setAttribute("async","true");
378
+ e.setAttribute("src","\(config.widgetScriptUrl)");
379
+ var n=t.getElementsByTagName("script")[0];
380
+ n.parentNode.insertBefore(e,n)
381
+ };
382
+ if(t.readyState==="complete"){r()}
383
+ else if(e.addEventListener){e.addEventListener("load",r,false)}
384
+ })();
385
+ </script>
386
+ <script>
387
+ window.pylonReady = function() {
388
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.PylonNative) {
389
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onReady'});
390
+ }
391
+ };
392
+ if (window.Pylon) {
393
+ window.pylonReady();
394
+ }
395
+ </script>
396
+ </body>
397
+ </html>
398
+ """
399
+ }
400
+
401
+ private func buildChatSettings() -> String {
402
+ var fields: [String] = ["app_id: '\(escapeJavaScriptString(config.appId))'"]
403
+
404
+ if let primaryColor = config.primaryColor {
405
+ fields.append("primary_color: '\(escapeJavaScriptString(primaryColor))'")
406
+ }
407
+
408
+ if let user = user {
409
+ fields.append("email: '\(escapeJavaScriptString(user.email))'")
410
+ fields.append("name: '\(escapeJavaScriptString(user.name))'")
411
+ if let avatarUrl = user.avatarUrl {
412
+ fields.append("avatar_url: '\(escapeJavaScriptString(avatarUrl))'")
413
+ }
414
+ if let emailHash = user.emailHash {
415
+ fields.append("email_hash: '\(escapeJavaScriptString(emailHash))'")
416
+ }
417
+ if let accountId = user.accountId {
418
+ fields.append("account_id: '\(escapeJavaScriptString(accountId))'")
419
+ }
420
+ if let accountExternalId = user.accountExternalId {
421
+ fields.append("account_external_id: '\(escapeJavaScriptString(accountExternalId))'")
422
+ }
423
+ }
424
+
425
+ return "{\n " + fields.joined(separator: ",\n ") + "\n }"
426
+ }
427
+
428
+ private func escapeJavaScriptString(_ string: String) -> String {
429
+ return string
430
+ .replacingOccurrences(of: "\\", with: "\\\\")
431
+ .replacingOccurrences(of: "'", with: "\\'")
432
+ .replacingOccurrences(of: "\"", with: "\\\"")
433
+ .replacingOccurrences(of: "\n", with: "\\n")
434
+ .replacingOccurrences(of: "\r", with: "\\r")
435
+ .replacingOccurrences(of: "\t", with: "\\t")
436
+ }
437
+
438
+ private func initializePylon() {
439
+ let js = """
440
+ (function() {
441
+ if (window.Pylon) {
442
+ window.Pylon('onShow', function() {
443
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onChatWindowOpened'});
444
+ });
445
+
446
+ window.Pylon('onHide', function() {
447
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onChatWindowClosed'});
448
+ });
449
+
450
+ window.Pylon('onShowChatBubble', function() {
451
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInteractiveElementUpdate', selector: '\(InteractiveElementId.fab.rawValue)'});
452
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInteractiveElementUpdate', selector: '\(InteractiveElementId.message.rawValue)'});
453
+ });
454
+
455
+ window.Pylon('onHideChatBubble', function() {
456
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInteractiveElementUpdate', selector: '\(InteractiveElementId.fab.rawValue)'});
457
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInteractiveElementUpdate', selector: '\(InteractiveElementId.message.rawValue)'});
458
+ });
459
+
460
+ window.Pylon('onPopupSurveyVisibilityChange', function(isShowing) {
461
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInteractiveElementUpdate', selector: '\(InteractiveElementId.survey.rawValue)'});
462
+ });
463
+
464
+ window.Pylon('onPopupMessageVisibilityChange', function(isShowing) {
465
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInteractiveElementUpdate', selector: '\(InteractiveElementId.message.rawValue)'});
466
+ });
467
+
468
+ window.Pylon('onChangeUnreadMessagesCount', function(unreadCount) {
469
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onUnreadCountChanged', count: unreadCount});
470
+ });
471
+ }
472
+
473
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.PylonNative) {
474
+ window.webkit.messageHandlers.PylonNative.postMessage({type: 'onInitialized'});
475
+ }
476
+
477
+ console.log('Pylon initialized with settings:', window.pylon.chat_settings);
478
+ })();
479
+ """
480
+
481
+ webView.evaluateJavaScript(js, completionHandler: nil)
482
+ }
483
+
484
+ private func findInteractiveElementPosition(selector: String) {
485
+ let js = """
486
+ (function() {
487
+ var element = document.querySelector('[id="\(selector)"]');
488
+ var rect = element ? element.getBoundingClientRect() : null;
489
+
490
+ if (rect !== null && rect.width > 0) {
491
+ window.webkit.messageHandlers.PylonNative.postMessage({
492
+ type: 'updateInteractiveBounds',
493
+ selector: '\(selector)',
494
+ left: rect.left,
495
+ top: rect.top,
496
+ right: rect.right,
497
+ bottom: rect.bottom
498
+ });
499
+ } else {
500
+ window.webkit.messageHandlers.PylonNative.postMessage({
501
+ type: 'updateInteractiveBounds',
502
+ selector: '\(selector)',
503
+ left: 0,
504
+ top: 0,
505
+ right: 0,
506
+ bottom: 0
507
+ });
508
+ }
509
+ })();
510
+ """
511
+
512
+ webView.evaluateJavaScript(js, completionHandler: nil)
513
+ }
514
+
515
+ // MARK: - Public API
516
+
517
+ public func openChat() {
518
+ log("📱 Pylon API: openChat() called")
519
+ executeJavaScript("if(window.Pylon) { window.Pylon('show'); }")
520
+ }
521
+
522
+ public func closeChat() {
523
+ log("📱 Pylon API: closeChat() called")
524
+ executeJavaScript("if(window.Pylon) { window.Pylon('hide'); }")
525
+ }
526
+
527
+ public func showChatBubble() {
528
+ log("📱 Pylon API: showChatBubble() called")
529
+ executeJavaScript("if(window.Pylon) { window.Pylon('showChatBubble'); }")
530
+ }
531
+
532
+ public func hideChatBubble() {
533
+ log("📱 Pylon API: hideChatBubble() called")
534
+ executeJavaScript("if(window.Pylon) { window.Pylon('hideChatBubble'); }")
535
+ }
536
+
537
+ public func setNewIssueCustomFields(_ fields: [String: Any]) {
538
+ let jsObject = buildJavaScriptObject(from: fields)
539
+ log("📱 Pylon API: setNewIssueCustomFields with object: \(jsObject)")
540
+ invokePylonCommand("setNewIssueCustomFields", arguments: [jsObject], isJsonObject: true)
541
+ }
542
+
543
+ public func setTicketFormFields(_ fields: [String: Any]) {
544
+ let jsObject = buildJavaScriptObject(from: fields)
545
+ log("📱 Pylon API: setTicketFormFields with object: \(jsObject)")
546
+ invokePylonCommand("setTicketFormFields", arguments: [jsObject], isJsonObject: true)
547
+ }
548
+
549
+ private func buildJavaScriptObject(from dict: [String: Any]) -> String {
550
+ let pairs = dict.map { key, value -> String in
551
+ let jsValue: String
552
+ if let stringValue = value as? String {
553
+ // Escape single quotes and newlines in strings
554
+ let escaped = stringValue
555
+ .replacingOccurrences(of: "\\", with: "\\\\")
556
+ .replacingOccurrences(of: "'", with: "\\'")
557
+ .replacingOccurrences(of: "\n", with: "\\n")
558
+ .replacingOccurrences(of: "\r", with: "\\r")
559
+ jsValue = "'\(escaped)'"
560
+ } else if let boolValue = value as? Bool {
561
+ jsValue = boolValue ? "true" : "false"
562
+ } else if let numberValue = value as? NSNumber {
563
+ jsValue = "\(numberValue)"
564
+ } else {
565
+ // Fallback for other types
566
+ jsValue = "'\(value)'"
567
+ }
568
+ return "\(key): \(jsValue)"
569
+ }
570
+ return "{ " + pairs.joined(separator: ", ") + " }"
571
+ }
572
+
573
+ public func showNewMessage(_ message: String, isHtml: Bool = false) {
574
+ let escapedMessage = message.replacingOccurrences(of: "'", with: "\\'")
575
+ .replacingOccurrences(of: "\n", with: "\\n")
576
+ if isHtml {
577
+ invokePylonCommand("showNewMessage", arguments: ["'\(escapedMessage)'", "{ isHtml: true }"])
578
+ } else {
579
+ invokePylonCommand("showNewMessage", arguments: ["'\(escapedMessage)'"])
580
+ }
581
+ }
582
+
583
+ public func showTicketForm(_ ticketFormSlug: String) {
584
+ invokePylonCommand("showTicketForm", arguments: ["'\(ticketFormSlug)'"])
585
+ }
586
+
587
+ public func showKnowledgeBaseArticle(_ articleId: String) {
588
+ invokePylonCommand("showKnowledgeBaseArticle", arguments: ["'\(articleId)'"])
589
+ }
590
+
591
+ public func updateEmailHash(_ emailHash: String?) {
592
+ Pylon.shared.setEmailHash(emailHash)
593
+ if let currentUser = self.user {
594
+ self.user = PylonUser(
595
+ email: currentUser.email,
596
+ name: currentUser.name,
597
+ avatarUrl: currentUser.avatarUrl,
598
+ emailHash: emailHash,
599
+ accountId: currentUser.accountId,
600
+ accountExternalId: currentUser.accountExternalId
601
+ )
602
+ }
603
+ initializePylon()
604
+ }
605
+
606
+ public func updateUser(_ user: PylonUser) {
607
+ Pylon.shared.setUser(user)
608
+ self.user = user
609
+ initializePylon()
610
+ }
611
+
612
+ public func destroy() {
613
+ listener = nil
614
+ webView.stopLoading()
615
+ webView.configuration.userContentController.removeScriptMessageHandler(forName: "PylonNative")
616
+ }
617
+
618
+ // MARK: - Private Helpers
619
+
620
+ private func executeJavaScript(_ script: String) {
621
+ webView.evaluateJavaScript(script, completionHandler: nil)
622
+ }
623
+
624
+ private func invokePylonCommand(_ command: String, arguments: [String] = [], isJsonObject: Bool = false) {
625
+ let script: String
626
+ if arguments.isEmpty {
627
+ script = "if(window.Pylon){ window.Pylon('\(command)'); }"
628
+ } else {
629
+ // If isJsonObject is true, don't quote the arguments (they're already JSON strings)
630
+ let formattedArgs = arguments.joined(separator: ", ")
631
+ script = "if(window.Pylon){ window.Pylon('\(command)', \(formattedArgs)); }"
632
+ }
633
+ log("📱 Executing JS: \(script)")
634
+ executeJavaScript(script)
635
+ }
636
+ }
637
+
638
+ // MARK: - WKNavigationDelegate
639
+
640
+ extension PylonChatView: WKNavigationDelegate {
641
+ public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
642
+ if !isLoaded {
643
+ isLoaded = true
644
+ initializePylon()
645
+ listener?.onPylonLoaded()
646
+ }
647
+ }
648
+
649
+ public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
650
+ listener?.onPylonError(error: error.localizedDescription)
651
+ }
652
+ }
653
+
654
+ // MARK: - WKUIDelegate
655
+
656
+ extension PylonChatView: WKUIDelegate {
657
+ public func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
658
+ // Handle window.open() and target="_blank" links
659
+ if let url = navigationAction.request.url {
660
+ log("📱 Opening external URL: \(url)")
661
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
662
+ }
663
+ return nil
664
+ }
665
+ }
666
+
667
+ // MARK: - WKScriptMessageHandler
668
+
669
+ extension PylonChatView: WKScriptMessageHandler {
670
+ public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
671
+ guard let body = message.body as? [String: Any],
672
+ let type = body["type"] as? String else {
673
+ return
674
+ }
675
+
676
+ DispatchQueue.main.async { [weak self] in
677
+ guard let self = self else { return }
678
+
679
+ switch type {
680
+ case "onInitialized":
681
+ self.log("📱 Pylon: onInitialized")
682
+ self.listener?.onPylonInitialized()
683
+ case "onReady":
684
+ self.log("📱 Pylon: onReady")
685
+ self.listener?.onPylonReady()
686
+ case "onChatWindowOpened":
687
+ self.log("📱 Pylon: Chat Window OPENED ✅")
688
+ self.isChatWindowOpen = true
689
+ self.listener?.onChatOpened()
690
+ case "onChatWindowClosed":
691
+ let wasOpen = self.isChatWindowOpen
692
+ self.log("📱 Pylon: Chat Window CLOSED ❌ (wasOpen: \(wasOpen))")
693
+ self.isChatWindowOpen = false
694
+ self.listener?.onChatClosed(wasOpen: wasOpen)
695
+ case "onUnreadCountChanged":
696
+ if let count = body["count"] as? Int {
697
+ self.log("📱 Pylon: Unread count changed to \(count)")
698
+ self.listener?.onUnreadCountChanged(count: count)
699
+ }
700
+ case "onInteractiveElementUpdate":
701
+ if let selector = body["selector"] as? String {
702
+ self.log("📱 Pylon: Interactive element update for \(selector)")
703
+ self.findInteractiveElementPosition(selector: selector)
704
+ }
705
+ case "updateInteractiveBounds":
706
+ if let selector = body["selector"] as? String,
707
+ let left = body["left"] as? CGFloat,
708
+ let top = body["top"] as? CGFloat,
709
+ let right = body["right"] as? CGFloat,
710
+ let bottom = body["bottom"] as? CGFloat {
711
+ let rect = CGRect(x: left, y: top, width: right - left, height: bottom - top)
712
+ self.log("📱 Pylon: Updating bounds for \(selector): \(rect)")
713
+ self.interactiveBounds[selector] = rect
714
+
715
+ // Update debug overlay
716
+ if self.config.debugMode {
717
+ self.debugOverlay.interactiveBounds = self.interactiveBounds
718
+ self.debugOverlay.topInset = self.topInset
719
+ }
720
+ }
721
+ default:
722
+ self.log("📱 Pylon: Unknown message type: \(type)")
723
+ break
724
+ }
725
+ }
726
+ }
727
+ }
728
+
729
+ // MARK: - SwiftUI Wrapper
730
+
731
+ import SwiftUI
732
+
733
+ public struct PylonChatHostView: UIViewRepresentable {
734
+ @Binding public var chatView: PylonChatView?
735
+ @Binding public var unreadCount: Int
736
+
737
+ public init(chatView: Binding<PylonChatView?>, unreadCount: Binding<Int>) {
738
+ self._chatView = chatView
739
+ self._unreadCount = unreadCount
740
+ }
741
+
742
+ public func makeCoordinator() -> Coordinator {
743
+ Coordinator(unreadCount: $unreadCount)
744
+ }
745
+
746
+ public func makeUIView(context: Context) -> PylonChatView {
747
+ let chatView = Pylon.shared.createChat()
748
+ chatView.listener = context.coordinator
749
+
750
+ DispatchQueue.main.async {
751
+ self.chatView = chatView
752
+ }
753
+
754
+ return chatView
755
+ }
756
+
757
+ public func updateUIView(_ uiView: PylonChatView, context: Context) {
758
+ // No updates needed
759
+ }
760
+
761
+ public static func dismantleUIView(_ uiView: PylonChatView, coordinator: Coordinator) {
762
+ uiView.destroy()
763
+ }
764
+
765
+ public class Coordinator: PylonChatListener {
766
+ @Binding var unreadCount: Int
767
+
768
+ init(unreadCount: Binding<Int>) {
769
+ _unreadCount = unreadCount
770
+ }
771
+
772
+ public func onUnreadCountChanged(count: Int) {
773
+ unreadCount = count
774
+ }
775
+ }
776
+ }
777
+
778
+ // MARK: - Weak Script Message Handler Wrapper
779
+
780
+ private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
781
+ weak var delegate: WKScriptMessageHandler?
782
+
783
+ init(delegate: WKScriptMessageHandler) {
784
+ self.delegate = delegate
785
+ }
786
+
787
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
788
+ delegate?.userContentController(userContentController, didReceive: message)
789
+ }
790
+ }
791
+
792
+ // MARK: - Debug Overlay View
793
+
794
+ private class DebugOverlayView: UIView {
795
+ var interactiveBounds: [String: CGRect] = [:] {
796
+ didSet {
797
+ setNeedsDisplay()
798
+ }
799
+ }
800
+
801
+ var topInset: CGFloat = 0
802
+
803
+ override init(frame: CGRect) {
804
+ super.init(frame: frame)
805
+ backgroundColor = .clear
806
+ }
807
+
808
+ required init?(coder: NSCoder) {
809
+ fatalError("init(coder:) has not been implemented")
810
+ }
811
+
812
+ override func draw(_ rect: CGRect) {
813
+ super.draw(rect)
814
+
815
+ guard let context = UIGraphicsGetCurrentContext() else { return }
816
+
817
+ for (selector, rect) in interactiveBounds {
818
+ guard !rect.isEmpty else { continue }
819
+
820
+ // Adjust the bounds by subtracting topInset for display
821
+ // This shows where the bounds actually are in this view's coordinate space
822
+ let adjustedRect = CGRect(
823
+ x: rect.origin.x,
824
+ y: rect.origin.y - topInset,
825
+ width: rect.width,
826
+ height: rect.height
827
+ )
828
+
829
+ let color = getColor(for: selector)
830
+
831
+ // Draw filled rectangle with transparency
832
+ context.setFillColor(color.withAlphaComponent(0.3).cgColor)
833
+ context.fill(adjustedRect)
834
+
835
+ // Draw border
836
+ context.setStrokeColor(color.cgColor)
837
+ context.setLineWidth(4)
838
+ context.stroke(adjustedRect)
839
+
840
+ // Draw label
841
+ let attributes: [NSAttributedString.Key: Any] = [
842
+ .font: UIFont.systemFont(ofSize: 12, weight: .bold),
843
+ .foregroundColor: color,
844
+ .strokeColor: UIColor.black,
845
+ .strokeWidth: -2.0
846
+ ]
847
+
848
+ let labelText = selector as NSString
849
+ let labelPoint = CGPoint(x: adjustedRect.origin.x + 5, y: max(adjustedRect.origin.y - 20, 5))
850
+ labelText.draw(at: labelPoint, withAttributes: attributes)
851
+ }
852
+ }
853
+
854
+ private func getColor(for selector: String) -> UIColor {
855
+ // Generate consistent color from string hash
856
+ let hash = abs(selector.hashValue)
857
+
858
+ // Use HSB to ensure colors are vibrant and distinct
859
+ let hue = CGFloat((hash & 0xFFFF) % 360) / 360.0
860
+ let saturation = 0.7 + CGFloat((hash >> 16) & 0xFF) / 255.0 * 0.3
861
+ let brightness = 0.8 + CGFloat((hash >> 24) & 0xFF) / 255.0 * 0.2
862
+
863
+ return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
864
+ }
865
+ }