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.
Files changed (36) hide show
  1. package/HUBSPOT_IOS_SDK_VERSION.json +5 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +19 -0
  4. package/README.md +119 -0
  5. package/ReactNativeHubspotWrapper.podspec +27 -0
  6. package/android/build.gradle +49 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/com/reactnativehubspotwrapper/HubspotWrapperModule.kt +83 -0
  9. package/android/src/main/java/com/reactnativehubspotwrapper/HubspotWrapperPackage.kt +32 -0
  10. package/ios/HubspotMobileSDK/API/APIModels.swift +50 -0
  11. package/ios/HubspotMobileSDK/API/HubspotAPI.swift +168 -0
  12. package/ios/HubspotMobileSDK/DeviceTokenSyncState.swift +13 -0
  13. package/ios/HubspotMobileSDK/HubspotConfig.swift +145 -0
  14. package/ios/HubspotMobileSDK/HubspotManager+Notifications.swift +178 -0
  15. package/ios/HubspotMobileSDK/HubspotManager+Properties.swift +53 -0
  16. package/ios/HubspotMobileSDK/HubspotManager.swift +548 -0
  17. package/ios/HubspotMobileSDK/HubspotMobileSDK.swift +7 -0
  18. package/ios/HubspotMobileSDK/HubspotUserProperties.swift +115 -0
  19. package/ios/HubspotMobileSDK/LICENSE.txt +19 -0
  20. package/ios/HubspotMobileSDK/PushNotificationChatData.swift +63 -0
  21. package/ios/HubspotMobileSDK/Resources/Images.xcassets/Contents.json +6 -0
  22. package/ios/HubspotMobileSDK/Resources/Images.xcassets/GenericChatIcon.imageset/Contents.json +16 -0
  23. package/ios/HubspotMobileSDK/Resources/Images.xcassets/GenericChatIcon.imageset/chat-open-svg.svg +1 -0
  24. package/ios/HubspotMobileSDK/Resources/Localizable.xcstrings +28 -0
  25. package/ios/HubspotMobileSDK/Resources/PrivacyInfo.xcprivacy +62 -0
  26. package/ios/HubspotMobileSDK/Views/Buttons/FloatingActionButton.swift +126 -0
  27. package/ios/HubspotMobileSDK/Views/Buttons/TextChatButtonChatButton.swift +78 -0
  28. package/ios/HubspotMobileSDK/Views/ChatView/HubspotChatView.swift +612 -0
  29. package/ios/HubspotWrapperImpl.swift +108 -0
  30. package/ios/RNHubspotWrapper.h +9 -0
  31. package/ios/RNHubspotWrapper.mm +66 -0
  32. package/package.json +55 -0
  33. package/react-native.config.js +11 -0
  34. package/scripts/update-hubspot-ios-sdk.sh +142 -0
  35. package/src/index.ts +41 -0
  36. 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
+ }