react-native-hubspot-wrapper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HUBSPOT_IOS_SDK_VERSION.json +5 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +19 -0
- package/README.md +119 -0
- package/ReactNativeHubspotWrapper.podspec +27 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/reactnativehubspotwrapper/HubspotWrapperModule.kt +83 -0
- package/android/src/main/java/com/reactnativehubspotwrapper/HubspotWrapperPackage.kt +32 -0
- package/ios/HubspotMobileSDK/API/APIModels.swift +50 -0
- package/ios/HubspotMobileSDK/API/HubspotAPI.swift +168 -0
- package/ios/HubspotMobileSDK/DeviceTokenSyncState.swift +13 -0
- package/ios/HubspotMobileSDK/HubspotConfig.swift +145 -0
- package/ios/HubspotMobileSDK/HubspotManager+Notifications.swift +178 -0
- package/ios/HubspotMobileSDK/HubspotManager+Properties.swift +53 -0
- package/ios/HubspotMobileSDK/HubspotManager.swift +548 -0
- package/ios/HubspotMobileSDK/HubspotMobileSDK.swift +7 -0
- package/ios/HubspotMobileSDK/HubspotUserProperties.swift +115 -0
- package/ios/HubspotMobileSDK/LICENSE.txt +19 -0
- package/ios/HubspotMobileSDK/PushNotificationChatData.swift +63 -0
- package/ios/HubspotMobileSDK/Resources/Images.xcassets/Contents.json +6 -0
- package/ios/HubspotMobileSDK/Resources/Images.xcassets/GenericChatIcon.imageset/Contents.json +16 -0
- package/ios/HubspotMobileSDK/Resources/Images.xcassets/GenericChatIcon.imageset/chat-open-svg.svg +1 -0
- package/ios/HubspotMobileSDK/Resources/Localizable.xcstrings +28 -0
- package/ios/HubspotMobileSDK/Resources/PrivacyInfo.xcprivacy +62 -0
- package/ios/HubspotMobileSDK/Views/Buttons/FloatingActionButton.swift +126 -0
- package/ios/HubspotMobileSDK/Views/Buttons/TextChatButtonChatButton.swift +78 -0
- package/ios/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift +612 -0
- package/ios/HubspotWrapperImpl.swift +108 -0
- package/ios/RNHubspotWrapper.h +9 -0
- package/ios/RNHubspotWrapper.mm +66 -0
- package/package.json +55 -0
- package/react-native.config.js +11 -0
- package/scripts/update-hubspot-ios-sdk.sh +142 -0
- package/src/index.ts +41 -0
- package/src/specs/NativeHubspotWrapper.ts +17 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// HubspotChatView.swift
|
|
2
|
+
// Hubspot Mobile SDK
|
|
3
|
+
//
|
|
4
|
+
// Copyright © 2025 Hubspot, Inc.
|
|
5
|
+
|
|
6
|
+
import SwiftUI
|
|
7
|
+
import WebKit
|
|
8
|
+
|
|
9
|
+
/// A SwiftUI view containing Hubspots chat interface. This chat view is intended to be presented modally, with a sheet, for easy dismissal, or as a full screen cover.
|
|
10
|
+
///
|
|
11
|
+
/// > Warning: The chat view also includes an option to take a photo - be sure to include NSCameraUsageDescription in your apps info plist to enable camera functionality. Not doing so may result in a crash if your user attempts to attach a photo.
|
|
12
|
+
///
|
|
13
|
+
/// As an example for how to present a chat view from a user button:
|
|
14
|
+
/// ```swift
|
|
15
|
+
/// Button(action: {
|
|
16
|
+
/// showChat.toggle()
|
|
17
|
+
/// }, label: {
|
|
18
|
+
/// Text("\(Image(systemName: "message.badge.circle.fill")) Chat Now (sheet/modal)")
|
|
19
|
+
///
|
|
20
|
+
///
|
|
21
|
+
/// }).sheet(isPresented: $showChat, content: {
|
|
22
|
+
/// HubspotChatView(manager: HubspotManager.shared)
|
|
23
|
+
/// })
|
|
24
|
+
/// ```
|
|
25
|
+
///
|
|
26
|
+
/// If you want to show a specific chat flow, that flow can be specificed by setting the optional chat flow parameter when creating the view:
|
|
27
|
+
///
|
|
28
|
+
/// ```swift
|
|
29
|
+
/// HubspotChatView(manager: HubspotManager.shared, chatFlow: "support")
|
|
30
|
+
/// ```
|
|
31
|
+
///
|
|
32
|
+
/// ### Handling Links (Optional)
|
|
33
|
+
///
|
|
34
|
+
/// The chat may have links within that are targeted at opening in a new window. The ``HubspotChatView`` triggers these using the SwiftUI open url environment action. By default, this will cause the system to open the url in the external browser.
|
|
35
|
+
///
|
|
36
|
+
/// These can be handled in an alternative way if desired by providing an alternative url handler using SwiftUIs exsting open url environment feature
|
|
37
|
+
///
|
|
38
|
+
/// ```swift
|
|
39
|
+
/// HubspotChatView()
|
|
40
|
+
/// .environment(\.openURL, OpenURLAction(handler: { URL in
|
|
41
|
+
/// /// Handle opening of link in chat in some in app browser, or some other method
|
|
42
|
+
/// return OpenURLAction.Result.systemAction
|
|
43
|
+
/// }))
|
|
44
|
+
/// ```
|
|
45
|
+
///
|
|
46
|
+
/// ### Handling Close Action (Optional)
|
|
47
|
+
///
|
|
48
|
+
/// The chat may have a close action triggered. If not customised, it will use the default ``dismiss`` action provided by the SwiftUI environment. Alternatively, you can pass a custom action incase you need to manage some non standard view presentation embed or dismiss method.
|
|
49
|
+
///
|
|
50
|
+
/// Example of using button within chat:
|
|
51
|
+
///
|
|
52
|
+
/// ```
|
|
53
|
+
/// .fullScreenCover(
|
|
54
|
+
/// isPresented: $showChatFullscreen,
|
|
55
|
+
/// content: {
|
|
56
|
+
/// HubspotChatView(dismissChat: {
|
|
57
|
+
/// withAnimation {
|
|
58
|
+
/// showChatFullscreen = false
|
|
59
|
+
/// }
|
|
60
|
+
/// })
|
|
61
|
+
/// })
|
|
62
|
+
/// ```
|
|
63
|
+
///
|
|
64
|
+
/// However, if your chat flow is not configured with a close option within, and there's no visible dismiss option, consider adding a close button to the view yourself, for example:
|
|
65
|
+
///
|
|
66
|
+
/// ```
|
|
67
|
+
/// NavigationStack {
|
|
68
|
+
/// HubspotChatView()
|
|
69
|
+
/// .toolbar {
|
|
70
|
+
/// ToolbarItem(placement: .topBarTrailing) {
|
|
71
|
+
/// Button(action: {
|
|
72
|
+
/// showChatFullscreen = false // Dismiss the full-screen view
|
|
73
|
+
/// }) {
|
|
74
|
+
/// Text("Close")
|
|
75
|
+
/// }
|
|
76
|
+
/// }
|
|
77
|
+
/// }
|
|
78
|
+
/// }
|
|
79
|
+
/// ```
|
|
80
|
+
///
|
|
81
|
+
/// ### Opening Chat From Push Notification
|
|
82
|
+
///
|
|
83
|
+
/// If opening chat view in response to a push notification , ideally extract important information from the notification using the ``PushNotificationChatData`` struct , and pass to the initialiser like so:
|
|
84
|
+
/// ```swift
|
|
85
|
+
/// HubspotChatView(manager: HubspotManager.shared, pushData: selectedChatData)
|
|
86
|
+
/// ```
|
|
87
|
+
///
|
|
88
|
+
/// > Important: HubspotChatView doesn't insert any close buttons when overlaid with sheet or full screen cover. Consider adding a close toolbar button if that is needed.
|
|
89
|
+
///
|
|
90
|
+
///
|
|
91
|
+
///
|
|
92
|
+
///
|
|
93
|
+
public struct HubspotChatView: View {
|
|
94
|
+
private let manager: HubspotManager
|
|
95
|
+
private let chatFlow: String?
|
|
96
|
+
private let pushData: PushNotificationChatData?
|
|
97
|
+
|
|
98
|
+
/// If set , use a custom dismiss action, otherwise use environment dismiss
|
|
99
|
+
private let customDismiss: (() -> Void)?
|
|
100
|
+
|
|
101
|
+
@StateObject var viewModel = ChatViewModel()
|
|
102
|
+
|
|
103
|
+
@Environment(\.dismiss) var dismiss
|
|
104
|
+
|
|
105
|
+
/// Create the chat view, optionally specifying the HubspotManager and Chat Flow to use.
|
|
106
|
+
///
|
|
107
|
+
/// > Info: chatFlow may only take effect if a valid user identity is configured. See ``HubspotManager/setUserIdentity(identityToken:email:)``
|
|
108
|
+
///
|
|
109
|
+
/// - Parameters:
|
|
110
|
+
/// - manager: manager to use when creating urls for account and getting user properties
|
|
111
|
+
/// - pushData: Struct containing any of the hubspot values from the push body payload.
|
|
112
|
+
/// - chatFlow: The specific chat flow to open, if any
|
|
113
|
+
/// - dismissChat: If the chat has a close option, you can optionally set this closure to handle closing chat. If you do not set this, then the chat view will use the standard `dismiss` action from the SwiftUI environment - Use this if you show or embed the chat in some custom or non standard presentation.
|
|
114
|
+
public init(
|
|
115
|
+
manager: HubspotManager? = nil,
|
|
116
|
+
pushData: PushNotificationChatData? = nil,
|
|
117
|
+
chatFlow: String? = nil,
|
|
118
|
+
dismissChat: (() -> Void)? = nil
|
|
119
|
+
) {
|
|
120
|
+
self.manager = manager ?? .shared
|
|
121
|
+
self.chatFlow = chatFlow
|
|
122
|
+
self.pushData = pushData
|
|
123
|
+
customDismiss = dismissChat
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public var body: some View {
|
|
127
|
+
if viewModel.isFailure {
|
|
128
|
+
errorView
|
|
129
|
+
} else {
|
|
130
|
+
HubspotChatWebView(
|
|
131
|
+
manager: manager,
|
|
132
|
+
pushData: pushData,
|
|
133
|
+
chatFlow: chatFlow,
|
|
134
|
+
viewModel: viewModel,
|
|
135
|
+
dismissAction: {
|
|
136
|
+
dismissChat()
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
.overlay(content: {
|
|
140
|
+
loadingView
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@ViewBuilder
|
|
146
|
+
var loadingView: some View {
|
|
147
|
+
if viewModel.loadingState == .loading {
|
|
148
|
+
ProgressView()
|
|
149
|
+
.progressViewStyle(.circular)
|
|
150
|
+
} else {
|
|
151
|
+
EmptyView()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@ViewBuilder
|
|
156
|
+
var errorView: some View {
|
|
157
|
+
if let configError = viewModel.configError {
|
|
158
|
+
switch configError {
|
|
159
|
+
case .missingChatFlow:
|
|
160
|
+
if #available(iOS 17.0, *) {
|
|
161
|
+
ContentUnavailableView("Missing Chat Flow", systemImage: "questionmark.bubble")
|
|
162
|
+
} else {
|
|
163
|
+
// Fallback on earlier versions
|
|
164
|
+
ContentUnavailableViewCompat("Missing Chat Flow", systemImage: "questionmark.bubble")
|
|
165
|
+
}
|
|
166
|
+
case .missingConfiguration:
|
|
167
|
+
if #available(iOS 17.0, *) {
|
|
168
|
+
ContentUnavailableView("Missing Configuration", systemImage: "gear.badge.questionmark")
|
|
169
|
+
} else {
|
|
170
|
+
ContentUnavailableViewCompat("Missing Configuration", systemImage: "gear.badge.questionmark")
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else if viewModel.failedToLoadWidget {
|
|
174
|
+
if #available(iOS 17.0, *) {
|
|
175
|
+
ContentUnavailableView("Failed to load chat", systemImage: "network.slash")
|
|
176
|
+
} else {
|
|
177
|
+
ContentUnavailableViewCompat("Failed to load chat", systemImage: "network.slash")
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func dismissChat() {
|
|
183
|
+
if let customDismiss {
|
|
184
|
+
manager.logger.trace("dismissing chat with custom action")
|
|
185
|
+
customDismiss()
|
|
186
|
+
} else {
|
|
187
|
+
manager.logger.trace("Using dismiss from environment to close chat")
|
|
188
|
+
dismiss()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// This is the WebView used witin the chat view - its wrapped with ``HubspotChatView`` incase we need to overlay or inline any errors or loading indicators
|
|
194
|
+
struct HubspotChatWebView: UIViewRepresentable {
|
|
195
|
+
public typealias UIViewType = WKWebView
|
|
196
|
+
|
|
197
|
+
/// This is the application name appended to the user agent when embedding chat, including a version, which is manually set for planned release version
|
|
198
|
+
let applicationNameForUserAgent = "HubspotMobileSDK/1.0.7"
|
|
199
|
+
|
|
200
|
+
private let manager: HubspotManager
|
|
201
|
+
private let chatFlow: String?
|
|
202
|
+
private let pushData: PushNotificationChatData?
|
|
203
|
+
|
|
204
|
+
@Environment(\.openURL)
|
|
205
|
+
var openURLAction
|
|
206
|
+
|
|
207
|
+
/// Custom dismiss action - the parent view will decide how to dismiss
|
|
208
|
+
var dismissAction: () -> Void
|
|
209
|
+
|
|
210
|
+
// Note - not a state , or observed object - we don't need to monitor it here
|
|
211
|
+
let viewModel: ChatViewModel
|
|
212
|
+
|
|
213
|
+
/// Create the chat view, optionally specifying the HubspotManager and Chat Flow to use.
|
|
214
|
+
///
|
|
215
|
+
/// > Info: chatFlow may only take effect if a valid user identity is configured. See ``HubspotManager/setUserIdentity(identityToken:email:)``
|
|
216
|
+
///
|
|
217
|
+
/// - Parameters:
|
|
218
|
+
/// - manager: manager to use when creating urls for account and getting user properties
|
|
219
|
+
/// - pushData: Struct containing any of the hubspot values from the push body payload.
|
|
220
|
+
/// - chatFlow: The specific chat flow to open, if any
|
|
221
|
+
init(
|
|
222
|
+
manager: HubspotManager,
|
|
223
|
+
pushData: PushNotificationChatData?,
|
|
224
|
+
chatFlow: String?,
|
|
225
|
+
viewModel: ChatViewModel,
|
|
226
|
+
dismissAction: @escaping () -> Void
|
|
227
|
+
) {
|
|
228
|
+
self.manager = manager
|
|
229
|
+
self.chatFlow = chatFlow
|
|
230
|
+
self.pushData = pushData
|
|
231
|
+
self.viewModel = viewModel
|
|
232
|
+
self.dismissAction = dismissAction
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func makeCoordinator() -> WebviewCoordinator {
|
|
236
|
+
WebviewCoordinator(manager: manager, viewModel: viewModel, urlHandler: openURLAction, dismissHandler: dismissAction)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func makeUIView(context: Context) -> WKWebView {
|
|
240
|
+
let configuration = WKWebViewConfiguration()
|
|
241
|
+
|
|
242
|
+
let coordinator = context.coordinator
|
|
243
|
+
coordinator.urlHandler = context.environment.openURL
|
|
244
|
+
|
|
245
|
+
configuration.applicationNameForUserAgent = applicationNameForUserAgent
|
|
246
|
+
configuration.websiteDataStore = .default()
|
|
247
|
+
|
|
248
|
+
configuration.dataDetectorTypes = [.phoneNumber]
|
|
249
|
+
|
|
250
|
+
if #available(iOS 15.4, *) {
|
|
251
|
+
configuration.preferences.isElementFullscreenEnabled = true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
configuration.preferences.isTextInteractionEnabled = true
|
|
255
|
+
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
256
|
+
|
|
257
|
+
configuration.userContentController = context.coordinator.contentController
|
|
258
|
+
context.coordinator.setupScripts()
|
|
259
|
+
|
|
260
|
+
let webview = WKWebView(frame: .zero, configuration: configuration)
|
|
261
|
+
|
|
262
|
+
#if DEBUG
|
|
263
|
+
// This allows safari
|
|
264
|
+
if #available(iOS 16.4, *) {
|
|
265
|
+
webview.isInspectable = true
|
|
266
|
+
}
|
|
267
|
+
#endif
|
|
268
|
+
|
|
269
|
+
webview.isOpaque = false
|
|
270
|
+
webview.backgroundColor = UIColor.systemBackground
|
|
271
|
+
webview.navigationDelegate = context.coordinator
|
|
272
|
+
webview.uiDelegate = context.coordinator
|
|
273
|
+
|
|
274
|
+
// Its likely safe to trigger this from here - if we are preparing our ui for chat, likelihood is we will soon have an id and need to report properties.
|
|
275
|
+
manager.prepareForPotentialChat()
|
|
276
|
+
|
|
277
|
+
return webview
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// This will load the chat url in the website, if available. Called automatically.
|
|
281
|
+
func updateUIView(_ webView: WKWebView, context: Context) {
|
|
282
|
+
do {
|
|
283
|
+
// If we have already failed to load the widget, we don't want to try again - what happens is as the webview isn't loaded, it triggers the update view, attempts to load fails, the view reloads, thinks it needs to update, and repeats infinitely
|
|
284
|
+
guard !viewModel.failedToLoadWidget else {
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// lets also update our link handler, incase the reason for the update is the handler changing
|
|
289
|
+
context.coordinator.urlHandler = context.environment.openURL
|
|
290
|
+
|
|
291
|
+
// We also don't want to re-trigger a load of the same url again in the webview after we've already finished loading
|
|
292
|
+
// Unrelated SwiftUI environment changes might trigger the updateUIView method - so if we loaded successfully, do nothing.
|
|
293
|
+
// Otherwise continue with the main load attempt
|
|
294
|
+
if viewModel.loadingState == .finished {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let urlToLoad = try manager.chatUrl(
|
|
299
|
+
withPushData: pushData,
|
|
300
|
+
forChatFlow: chatFlow
|
|
301
|
+
)
|
|
302
|
+
let request = URLRequest(url: urlToLoad)
|
|
303
|
+
|
|
304
|
+
Task {
|
|
305
|
+
await viewModel.didStartLoading()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let mainLoadNavReference = webView.load(request)
|
|
309
|
+
context.coordinator.mainLoadNavReference = mainLoadNavReference
|
|
310
|
+
|
|
311
|
+
} catch {
|
|
312
|
+
DispatchQueue.main.async {
|
|
313
|
+
viewModel.setError(error)
|
|
314
|
+
}
|
|
315
|
+
manager.logger.error("Unable to load chat. Webview will be blank. \(error)")
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// The coordinator helps with the Swift View to UIView lifecycle , and stays alive (along with the UIKit views) when the swift view itself may be recreated.
|
|
320
|
+
/// This is the sensible place for our delegate callbacks
|
|
321
|
+
class WebviewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
|
|
322
|
+
let viewModel: ChatViewModel
|
|
323
|
+
let manager: HubspotManager
|
|
324
|
+
|
|
325
|
+
var urlHandler: OpenURLAction
|
|
326
|
+
|
|
327
|
+
var dismissHandler: () -> Void
|
|
328
|
+
|
|
329
|
+
init(manager: HubspotManager, viewModel: ChatViewModel, urlHandler: OpenURLAction, dismissHandler: @escaping () -> Void) {
|
|
330
|
+
self.manager = manager
|
|
331
|
+
self.viewModel = viewModel
|
|
332
|
+
self.urlHandler = urlHandler
|
|
333
|
+
self.dismissHandler = dismissHandler
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/// This is the handler used to respond to events on the hubspot conversations object and forward them to the native app
|
|
337
|
+
let handlerName = "nativeApp"
|
|
338
|
+
let closeHandlerName = "closeChatHandler"
|
|
339
|
+
let contentController = WKUserContentController()
|
|
340
|
+
|
|
341
|
+
var mainLoadNavReference: WKNavigation?
|
|
342
|
+
|
|
343
|
+
func setupScripts() {
|
|
344
|
+
contentController.add(self, name: handlerName)
|
|
345
|
+
contentController.add(self, name: closeHandlerName)
|
|
346
|
+
let js = """
|
|
347
|
+
window.webkit.messageHandlers.nativeApp.postMessage({"info":"setupScripts"});
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
contentController.addUserScript(WKUserScript(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
|
|
351
|
+
|
|
352
|
+
// create script that triggers on hubspot event, and calls our message handler
|
|
353
|
+
|
|
354
|
+
let configCallbacksJS = """
|
|
355
|
+
function configureHubspotConversations() {
|
|
356
|
+
if (window.HubSpotConversations) {
|
|
357
|
+
window.webkit.messageHandlers.nativeApp.postMessage({ "info": "Setting up handlers" });
|
|
358
|
+
window.HubSpotConversations.on('conversationStarted', payload => {
|
|
359
|
+
window.webkit.messageHandlers.nativeApp.postMessage(payload);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
window.HubSpotConversations.on('widgetLoaded', payload => {
|
|
363
|
+
window.webkit.messageHandlers.nativeApp.postMessage(payload);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
window.HubSpotConversations.on('userInteractedWithWidget', payload => {
|
|
367
|
+
window.webkit.messageHandlers.nativeApp.postMessage(payload);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
window.HubSpotConversations.on('userSelectedThread', payload => {
|
|
371
|
+
window.webkit.messageHandlers.nativeApp.postMessage(payload);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
window.HubSpotConversations.on('sdkCloseButtonClick', payload => {
|
|
375
|
+
window.webkit.messageHandlers.closeChatHandler.postMessage(payload);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
window.webkit.messageHandlers.nativeApp.postMessage({ "info": "Finished setting up handlers" });
|
|
379
|
+
} else {
|
|
380
|
+
window.webkit.messageHandlers.nativeApp.postMessage({ "info": "no object to set handlers on still" });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
window.webkit.messageHandlers.nativeApp.postMessage({ "info": "starting main load script" });
|
|
385
|
+
|
|
386
|
+
if (window.HubSpotConversations) {
|
|
387
|
+
configureHubspotConversations();
|
|
388
|
+
} else if (Array.isArray(window.hsConversationsOnReady)) {
|
|
389
|
+
window.hsConversationsOnReady.push(configureHubspotConversations);
|
|
390
|
+
} else {
|
|
391
|
+
window.hsConversationsOnReady = [configureHubspotConversations];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
window.webkit.messageHandlers.nativeApp.postMessage({ "info": "finished main load script" });
|
|
395
|
+
"""
|
|
396
|
+
contentController.addUserScript(WKUserScript(source: configCallbacksJS, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
func webView(_: WKWebView, didCommit navigation: WKNavigation!) {
|
|
400
|
+
let isMain = navigation == mainLoadNavReference
|
|
401
|
+
|
|
402
|
+
if isMain {
|
|
403
|
+
Task {
|
|
404
|
+
await viewModel.didStartLoading()
|
|
405
|
+
}
|
|
406
|
+
// create script that triggers on hubspot event, and calls our message handler
|
|
407
|
+
// Set earlier currently, but might need to move back there
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func webView(_: WKWebView, didFinish navigation: WKNavigation!) {
|
|
412
|
+
let isMain = navigation == mainLoadNavReference
|
|
413
|
+
|
|
414
|
+
if isMain {
|
|
415
|
+
Task {
|
|
416
|
+
await self.viewModel.didLoadUrl()
|
|
417
|
+
}
|
|
418
|
+
// create script that triggers on hubspot event, and calls our message handler
|
|
419
|
+
// Set earlier currently, but might need to move back there
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
func webView(_: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) {
|
|
424
|
+
let isMain = navigation == mainLoadNavReference
|
|
425
|
+
|
|
426
|
+
if isMain {
|
|
427
|
+
Task {
|
|
428
|
+
await self.viewModel.didFailToLoadUrl(error: error)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
func webView(_: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) {
|
|
434
|
+
let isMain = navigation == mainLoadNavReference
|
|
435
|
+
|
|
436
|
+
if isMain {
|
|
437
|
+
Task {
|
|
438
|
+
await self.viewModel.didFailToLoadUrl(error: error)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
444
|
+
let handlerName = message.name
|
|
445
|
+
|
|
446
|
+
switch handlerName {
|
|
447
|
+
case closeHandlerName:
|
|
448
|
+
manager.logger.trace("ChatView recieved message to close handler - this means chat webpage wants to close.")
|
|
449
|
+
dismissHandler()
|
|
450
|
+
case handlerName:
|
|
451
|
+
guard let dict = message.body as? [String: Any] else {
|
|
452
|
+
// Without body, there's no action to take
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// this message is sent on widget loading
|
|
457
|
+
if let message = dict["message"] as? String, message == "widget has loaded" {
|
|
458
|
+
Task {
|
|
459
|
+
await viewModel.didLoadWidget()
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// We are looking to get conversation object , if sent.
|
|
464
|
+
if let conversationDict = dict["conversation"] as? [String: Any],
|
|
465
|
+
let conversationId = conversationDict["conversationId"] as? Int
|
|
466
|
+
{
|
|
467
|
+
#if compiler(<6)
|
|
468
|
+
// Adding an assume isolated for Xcode 15 support - this isn't needed in Xcode 16, but the WKScriptMessageHandler doesn't have the main actor isolation
|
|
469
|
+
MainActor.assumeIsolated {
|
|
470
|
+
manager.handleThreadOpened(threadId: String(conversationId))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#else
|
|
474
|
+
// Now we know the id of newly selected thread, we can inform the manager which will handle next steps for data
|
|
475
|
+
manager.handleThreadOpened(threadId: String(conversationId))
|
|
476
|
+
#endif
|
|
477
|
+
}
|
|
478
|
+
default:
|
|
479
|
+
manager.logger.warning("Message handled for handler \(handlerName, privacy: .public), but thats not a known handler - ignoring it, but if something else was expecting to handle it, there might be an issue")
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
|
484
|
+
// Most navigations are allowed, as that matches default behaviour. But for links specifically, we do additional checks
|
|
485
|
+
switch navigationAction.navigationType {
|
|
486
|
+
case .linkActivated:
|
|
487
|
+
if navigationAction.targetFrame?.isMainFrame ?? false {
|
|
488
|
+
// For links specifically targeting the main frame, lets assume that's intentional to replace chat?
|
|
489
|
+
// If links are incorrectly being sent targeting the main frame handle it like the else branch for all link activated
|
|
490
|
+
return .allow
|
|
491
|
+
} else if let url = navigationAction.request.url {
|
|
492
|
+
// A link not targeting the main frame would be a pop up, other tab type attempt at opening. Use the system open URL and cancel any nav within the webview
|
|
493
|
+
urlHandler(url)
|
|
494
|
+
return .cancel
|
|
495
|
+
} else {
|
|
496
|
+
// Not sure what the link type would be without a url - whatever it is , just default to allowing it
|
|
497
|
+
return .allow
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
case .formSubmitted, .backForward, .reload, .formResubmitted, .other:
|
|
501
|
+
return .allow
|
|
502
|
+
|
|
503
|
+
@unknown default:
|
|
504
|
+
return .allow
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// We use this to hold a loading state - using a state & binding into the web view represetable causes some infinte loading to occur
|
|
511
|
+
/// May migrate more functionality that was direct to manager here in future
|
|
512
|
+
@MainActor
|
|
513
|
+
class ChatViewModel: ObservableObject {
|
|
514
|
+
/// Enum for tracking our progress loading the main url we embed in the webview
|
|
515
|
+
enum MainURLLoadState {
|
|
516
|
+
case notLoaded
|
|
517
|
+
case loading
|
|
518
|
+
case finished
|
|
519
|
+
case failed
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
@Published private(set) var loadingState: MainURLLoadState = .notLoaded
|
|
523
|
+
|
|
524
|
+
var failedToLoadWidget: Bool {
|
|
525
|
+
loadingState == .failed
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/// used to show error instead of chat view webview
|
|
529
|
+
var isFailure: Bool {
|
|
530
|
+
// TODO: - add generic error also?
|
|
531
|
+
configError != nil || failedToLoadWidget
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@Published var configError: HubspotConfigError?
|
|
535
|
+
|
|
536
|
+
/// Call when we are going to load the widget embed url
|
|
537
|
+
func didStartLoading() async {
|
|
538
|
+
// Reset and update loading flags, but only if set to avoid unneeded mutations
|
|
539
|
+
if loadingState != .loading {
|
|
540
|
+
loadingState = .loading
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/// Call when url is loaded - we may or may not want to consider this the final step
|
|
545
|
+
func didLoadUrl() async {
|
|
546
|
+
if loadingState != .finished {
|
|
547
|
+
loadingState = .finished
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func didFailToLoadUrl(error: Error) async {
|
|
552
|
+
if let urlError = error as? URLError {
|
|
553
|
+
switch urlError.code {
|
|
554
|
+
case URLError.cancelled:
|
|
555
|
+
// ignore cancels as they can trigger during retry I believe, so this isn't the end
|
|
556
|
+
// loadingState = .notLoaded
|
|
557
|
+
break
|
|
558
|
+
default:
|
|
559
|
+
// All other cases, consider the widget not loaded
|
|
560
|
+
if loadingState != .failed {
|
|
561
|
+
loadingState = .failed
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
/// We want to change our loading state back to the start for any other error
|
|
566
|
+
loadingState = .notLoaded
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/// Call when the widget emits a loaded message - this might be our indication that the widget has loaded at all
|
|
571
|
+
func didLoadWidget() async {
|
|
572
|
+
// nothing - removing our custom error display as there's one as part of the widget now
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
func setError(_ error: Error) {
|
|
576
|
+
guard let hsError = error as? HubspotConfigError else {
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
configError = hsError
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/// Something similar to content unavailable, doesn't need to be exact - ideally end user never has configuration issues that cause these to show by release
|
|
585
|
+
private struct ContentUnavailableViewCompat: View {
|
|
586
|
+
let message: LocalizedStringKey
|
|
587
|
+
let systemImage: String
|
|
588
|
+
var body: some View {
|
|
589
|
+
// Stack is here as modifiers complained about view type otherwise
|
|
590
|
+
VStack(spacing: 8) {
|
|
591
|
+
Text("\(Image(systemName: systemImage))")
|
|
592
|
+
.font(.title)
|
|
593
|
+
.bold()
|
|
594
|
+
.foregroundStyle(.secondary)
|
|
595
|
+
|
|
596
|
+
Text(message).bold()
|
|
597
|
+
.font(.title2)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.multilineTextAlignment(.center)
|
|
601
|
+
.frame(maxHeight: .infinity, alignment: .center)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
init(_ message: LocalizedStringKey, systemImage: String) {
|
|
605
|
+
self.message = message
|
|
606
|
+
self.systemImage = systemImage
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#Preview {
|
|
611
|
+
ContentUnavailableViewCompat("Failed to load chat", systemImage: "network.slash")
|
|
612
|
+
}
|