@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,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
|
+
}
|