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,548 @@
1
+ // HubspotManager.swift
2
+ // Hubspot Mobile SDK
3
+ //
4
+ // Copyright © 2024 Hubspot, Inc.
5
+
6
+ import Combine
7
+ import Foundation
8
+ import OSLog
9
+ import SwiftUI
10
+ @preconcurrency import UserNotifications
11
+ import WebKit
12
+
13
+ /// Logger is created in multiple places, so this is a helper for that to avoid repeating values
14
+ private func createDefaultHubspotLogger() -> Logger {
15
+ Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.hubspot.mobilesdk", category: "HubspotSDK")
16
+ }
17
+
18
+ /// The main interface for the Hubspot mobile SDK.
19
+ ///
20
+ /// Call ``configure()-swift.type.method`` before using. Chat sessions can be started & shown using ``HubspotChatView``
21
+ ///
22
+ /// Use ``setUserIdentity(identityToken:email:)`` to optionally identify users with server side generated tokens.
23
+ ///
24
+ /// Use ``setChatProperties(data:)`` to include additional data , including custom key value pairs that are useful during chat sessions.
25
+ ///
26
+ /// For more setup instructions, see <doc:GettingStarted>
27
+ ///
28
+ ///
29
+ @MainActor
30
+ public class HubspotManager: NSObject, ObservableObject {
31
+
32
+ /// These are the cookies HubSpot sets to manage identity for chat sessions; we want to remove them to allow fresh identities to be created on future chat sessions
33
+ /// More info on cookies that might be set are here: https://knowledge.hubspot.com/privacy-and-consent/what-cookies-does-hubspot-set-in-a-visitor-s-browser
34
+ private let cookiesToDeleteWhenClearingData = ["hubspotutk", "messagesUtk"]
35
+
36
+ /// Shared instance that can be used app wide, instead of creating an managing own instance.
37
+ /// If not using this instance, and instead managing your own instance, make sure to pass your instance as an argument to the ``HubspotChatView`` or other components.
38
+ public static let shared = HubspotManager()
39
+
40
+ /// The hublet to use, if configured
41
+ public private(set) var hublet: String?
42
+
43
+ /// The portalId to use, if configured
44
+ public private(set) var portalId: String?
45
+
46
+ /// The default chat flow, if configured
47
+ public private(set) var defaultChatFlow: String?
48
+
49
+ /// the currently configured environment
50
+ public private(set) var environment: HubspotEnvironment = .production
51
+
52
+ /// The identity token currently set for the user
53
+ /// - seeAlso: ``setUserIdentity(identityToken:email:)``
54
+ public private(set) var userIdentityToken: String?
55
+
56
+ /// The email address currently set for the user
57
+ /// - seeAlso: ``setUserIdentity(identityToken:email:)``
58
+ public private(set) var userEmailAddress: String?
59
+
60
+ /// The push token - this should be set by the app whenever it has access to the token.
61
+ /// - seeAlso: ``setPushToken(apnsPushToken:)``
62
+ public private(set) var pushToken: Data?
63
+
64
+ /// We might not be in a position to send the token immediately, so we need to track if we need to send it later, after configuration
65
+ private var pushTokenSyncState: DeviceTokenSyncState = .notSent
66
+ private var sendPushTokenTask: Task<Void, Never>?
67
+
68
+ /// This is the collection of user properties that are sent to Hubspot on opening chat sessions. set with ``setChatProperties(data:)``
69
+ /// - seeAlso: ChatPropertyKey
70
+ public private(set) var chatProperties: [String: String] = [:]
71
+
72
+ /// Callback triggered when user opens a hubspot message. Only triggered when manager is acting as the UNNotificationDelegate, or notifications are forwarded to the ``userNotificationCenter(_:didReceive:withCompletionHandler:)`` method on this manager instance.
73
+ public var newMessageCallback: (PushNotificationChatData) -> Void = { _ in }
74
+
75
+ /// Publisher triggered when user opens a hubspot message. Only triggered when manager is acting as the UNNotificationDelegate, or notifications are forwarded to the ``userNotificationCenter(_:didReceive:withCompletionHandler:)`` method on this manager instance.
76
+ public private(set) var newMessage: PassthroughSubject<PushNotificationChatData, Never> = PassthroughSubject()
77
+
78
+ /// The logger used by the SDK. to disable logging set to the disabled OSLog with `Logger(.disabled)`, or set a custom Logger with a preferred subsystem and category
79
+ public var logger = createDefaultHubspotLogger() {
80
+ didSet {
81
+ // Replace API with new instance with new logger
82
+ api = HubspotAPI(logger: logger)
83
+ }
84
+ }
85
+
86
+ private var hubletModel: Hublet? {
87
+ guard let hublet
88
+ else {
89
+ return nil
90
+ }
91
+
92
+ return Hublet(id: hublet, environment: environment)
93
+ }
94
+
95
+ /// Record if we turned on battery monitoring. If we didn't turn it on , we might not want to turn it off again.
96
+ var didWeEnableBatterMonitoring: Bool = false
97
+ /// Record if we turned on origentation monitoring. If we didn't turn it on , we might not want to turn it off again.
98
+ var didWeEnableOrientationMonitoring: Bool = false
99
+
100
+ private var api: HubspotAPI
101
+
102
+ /// Use the provided config values, applied to the shared instance ``shared``
103
+ /// - Parameters:
104
+ /// - portalId: Your portal id - you can find it from your hubspot account page
105
+ /// - hublet: Hublet name , typically "na1" or "eu1"
106
+ /// - defaultChatFlow: The default chat flow to use if none is specified per chat view
107
+ /// - environment: the environment to use
108
+ public static func configure(
109
+ portalId: String,
110
+ hublet: String,
111
+ defaultChatFlow: String?,
112
+ environment: HubspotEnvironment = .production
113
+ ) {
114
+ shared.configure(
115
+ portalId: portalId,
116
+ hublet: hublet,
117
+ defaultChatFlow: defaultChatFlow,
118
+ environment: environment
119
+ )
120
+ }
121
+
122
+ /// Load SDK configuration from bundled config file. Note this only applies to the shared instance ``shared`` , if you intend to create a new instance of `HubspotManager`, you should use the non static version on that instance , ``configure()-swift.method``
123
+ ///
124
+ /// throws ``HubspotConfigError`` if config file isnt as expected
125
+ public static func configure() throws {
126
+ try shared.configure()
127
+ }
128
+
129
+ /// Create unconfigured SDK instance - not currently set public, use shared instance for now.
130
+ override init() {
131
+ api = HubspotAPI(logger: logger)
132
+ }
133
+
134
+ /// Configure this SDK instance with the bundled `Hubspot-Info.plist` config file from the main bundle.
135
+ /// - throws: `HubspotConfigError.missingConfiguration` thrown if config file cannot be found in the bundle, or if it contains missing required items.
136
+ public func configure() throws {
137
+ guard let plistUrl = Bundle.main.url(forResource: HubspotConfig.defaultConfigFileName, withExtension: nil) else {
138
+ throw HubspotConfigError.missingConfiguration
139
+ }
140
+ let plistData = try Data(contentsOf: plistUrl)
141
+ let decoder = PropertyListDecoder()
142
+
143
+ do {
144
+ let config = try decoder.decode(HubspotConfig.self, from: plistData)
145
+ logger.trace("Loaded config with portal id of \(config.portalId)")
146
+ hublet = config.hublet
147
+ portalId = config.portalId
148
+ environment = config.environment
149
+ defaultChatFlow = config.defaultChatFlow
150
+ objectWillChange.send()
151
+
152
+ sendPushTokenIfNeeded()
153
+
154
+ } catch {
155
+ logger.error("Error decoding plist - check all expected keys exist: \(error)")
156
+ throw HubspotConfigError.missingConfiguration
157
+ }
158
+ }
159
+
160
+ /// Configure SDK with given values
161
+ /// - Parameters:
162
+ /// - portalId: Your portal id - you can find it from your hubspot account page
163
+ /// - hublet: Hublet name , typically "na1" or "eu1"
164
+ /// - defaultChatFlow: chat flow to use when none is specified when creating a chat. For example: sales
165
+ /// - environment: the environment to use
166
+ func configure(
167
+ portalId: String,
168
+ hublet: String,
169
+ defaultChatFlow: String?,
170
+ environment: HubspotEnvironment = .production
171
+ ) {
172
+ self.portalId = portalId
173
+ self.hublet = hublet
174
+ self.environment = environment
175
+ self.defaultChatFlow = defaultChatFlow
176
+
177
+ objectWillChange.send()
178
+ }
179
+
180
+ /// Convenience to set the logger to the disabled logger
181
+ public func disableLogging() {
182
+ logger = Logger(.disabled)
183
+ }
184
+
185
+ /// Re-configures ``logger`` with the default logger config - if you want a specific logger category, configure ``logger`` directly instead
186
+ public func enableLogging() {
187
+ logger = createDefaultHubspotLogger()
188
+ }
189
+
190
+ /// Set the push token for the app. Recommend calling this each app launch when push feature is enabled.
191
+ /// - Parameter apnsPushToken: The data token provided by iOS via didRegisterForRemoteNotificationsWithDeviceToken
192
+ public func setPushToken(apnsPushToken: Data) {
193
+ /// Only reset our state when it actually changes
194
+ if pushToken != apnsPushToken {
195
+ pushTokenSyncState = .notSent
196
+ }
197
+
198
+ pushToken = apnsPushToken
199
+ sendPushTokenIfNeeded()
200
+ }
201
+
202
+ /// Sends the token if not already sent - only sends data when we have the token and its not being sent recently
203
+ private func sendPushTokenIfNeeded() {
204
+ guard let pushToken,
205
+ let portalId
206
+ else {
207
+ // Not enough info, can't send yet
208
+ return
209
+ }
210
+
211
+ sendPushTokenTask = Task {
212
+ let shouldSendToken: Bool
213
+
214
+ switch self.pushTokenSyncState {
215
+ case .notSent:
216
+ shouldSendToken = true
217
+
218
+ case .sending(let lastActionDate):
219
+ let interval = abs(lastActionDate.timeIntervalSinceNow)
220
+
221
+ // If we have been 'sending' for more than a minute, clear and try again
222
+ if interval > 60 {
223
+ shouldSendToken = true
224
+ self.pushTokenSyncState = .notSent
225
+ } else {
226
+ shouldSendToken = false
227
+ }
228
+
229
+ case .sent(let lastActionDate):
230
+ // Ignore new attempt to send if its within a brief window
231
+ let interval = abs(lastActionDate.timeIntervalSinceNow)
232
+
233
+ // Arbitary 20 second time to prevent double triggers in the case of repeated registration, setting identity at the same time, etc
234
+ shouldSendToken = interval > 20
235
+ }
236
+
237
+ guard shouldSendToken else {
238
+ return
239
+ }
240
+
241
+ let previousState = self.pushTokenSyncState
242
+ self.pushTokenSyncState = .sending(.now)
243
+
244
+ do {
245
+ guard let hubletModel else {
246
+ throw HubspotConfigError.missingConfiguration
247
+ }
248
+ try await api.sendDeviceToken(hublet: hubletModel, token: pushToken, portalId: portalId)
249
+
250
+ if !Task.isCancelled {
251
+ self.pushTokenSyncState = .sent(.now)
252
+ } else {
253
+ self.pushTokenSyncState = previousState
254
+ }
255
+ } catch {
256
+ logger.error("Error registering push token with API: \(error)")
257
+ if case .sending = previousState {
258
+ // We shouldn't have had a previous state of sending ...
259
+ self.pushTokenSyncState = .notSent
260
+ } else {
261
+ // Leave the value of sync state as it was, either with an older date or not sent yet
262
+ self.pushTokenSyncState = previousState
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ /// Set the user id obtained from the [Visitor Identification API](https://developers.hubspot.com/docs/api/conversation/visitor-identification) , along with the users email address. These will be included when starting a chat session to identify the user. Its important to set these before starting a chat session, as they are needed during chat initialisation.
269
+ ///
270
+ /// Object will change is triggered after setting values, for anything that may be observing this manager with Combine or SwiftUI state
271
+ ///
272
+ /// Note: These values are only stored in memory and aren't persisted. Set them on each app launch or when changing user autentication status. This token has a short expiry, and should be re-set periodically.
273
+ ///
274
+ /// - Parameters:
275
+ /// - token: The token from the identity api. Must not be empty.
276
+ /// - email: The users email address, that matches the token. Must not be empty
277
+ public func setUserIdentity(identityToken: String, email: String) {
278
+ guard !identityToken.isEmpty, !email.isEmpty else {
279
+ return
280
+ }
281
+ userIdentityToken = identityToken
282
+ userEmailAddress = email
283
+ objectWillChange.send()
284
+
285
+ sendPushTokenIfNeeded()
286
+ }
287
+
288
+ /// Set a string key and value collection to be associate with any chat opened.
289
+ ///
290
+ /// Set optional and custom chat properies to send to hubspot on opening chat. These can be set at any time prior to starting a chat session.
291
+ /// The data included here is sent to the Hubspot API only when opening a chat - if no chat is started the data is not sent anywere.
292
+ ///
293
+ /// You can use any key value, including custom keys, whatever makes sense your your application and use of chat for support or troubleshooting.
294
+ ///
295
+ /// For common, optional keys and values to use, for example to specify user location or permissions , see ``ChatPropertyKey``
296
+ ///
297
+ /// Avoid including personal, private information in property values, and only include data that you have permission from the user to use.
298
+ ///
299
+ /// The data passed here is combined with some automatic properties determined at run time - see the descriptions of the keys in ``ChatPropertyKey`` for information on automatically set values
300
+ ///
301
+ /// An example of setting a mix of pre-defined properties, and custom properties
302
+ /// ```
303
+ /// var properties: [String: String] = [
304
+ /// ChatPropertyKey.cameraPermissions.rawValue: self.checkCameraPermissions(),
305
+ /// "myapp-install-id": appUniqueId,
306
+ /// "subscription-tier": "premium"
307
+ /// ]
308
+ /// HubspotManager.shared.setChatProperties(data: properties)
309
+ /// ```
310
+ ///
311
+ /// > Info: These properties are only retained in memory, and not persisted. Set preferred values at least once per app launch. They can be also replaced at any time by calling ``setChatProperties(data:)`` again.
312
+ ///
313
+ /// - seeAlso: ``ChatPropertyKey``
314
+ public func setChatProperties(data: [String: String]) {
315
+ chatProperties = data
316
+ }
317
+
318
+ /// Gathers together any user provided properties, as well as automatic properties into one collection. This will be the data included in API call
319
+ func finalizeChatProperties() async -> [String: String] {
320
+ // Start with developer provided ones, if any
321
+ var properties = chatProperties
322
+
323
+ let deviceModel = deviceModel()
324
+
325
+ if !deviceModel.isEmpty {
326
+ properties[ChatPropertyKey.deviceModel.rawValue] = deviceModel
327
+ }
328
+
329
+ if let pushToken {
330
+ let encodedPushToken = pushToken.toHexString()
331
+ properties[ChatPropertyKey.pushToken.rawValue] = encodedPushToken
332
+ }
333
+
334
+ let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
335
+
336
+ if notificationSettings.alertSetting == .enabled || notificationSettings.notificationCenterSetting == .enabled {
337
+ properties[ChatPropertyKey.notificationPermissions.rawValue] = "true"
338
+ } else {
339
+ properties[ChatPropertyKey.notificationPermissions.rawValue] = "false"
340
+ }
341
+
342
+ properties[ChatPropertyKey.operatingSystemVersion.rawValue] = "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)"
343
+
344
+ if let infoDict = Bundle.main.infoDictionary,
345
+ let shortVersion = infoDict["CFBundleShortVersionString"],
346
+ let buildVersion = infoDict["CFBundleVersion"]
347
+ {
348
+ properties[ChatPropertyKey.appVersion.rawValue] = "\(shortVersion).\(buildVersion)"
349
+ }
350
+
351
+ let screenBounds = UIScreen.main.bounds
352
+ let screenWidth = screenBounds.width
353
+ let screenHeight = screenBounds.height
354
+ let scale = UIScreen.main.scale
355
+
356
+ properties[ChatPropertyKey.screenSize.rawValue] = "\(Int(screenWidth))x\(Int(screenHeight))"
357
+ properties[ChatPropertyKey.screenResolution.rawValue] = "\(Int(screenWidth * scale))x\(Int(screenHeight * scale))"
358
+ properties[ChatPropertyKey.deviceOrientation.rawValue] = UIDevice.current.orientation.hubspotApiValue
359
+
360
+ // ignore when battery is reported as -1, or some negative to indicate its invalid
361
+ if UIDevice.current.batteryLevel >= 0 {
362
+ let batteryLevelRounded = Int((UIDevice.current.batteryLevel * 100).rounded())
363
+ properties[ChatPropertyKey.batteryLevel.rawValue] = String(batteryLevelRounded)
364
+ }
365
+ properties[ChatPropertyKey.batteryState.rawValue] = UIDevice.current.batteryState.hubspotApiValue
366
+
367
+ // Platform is just fixed, no point in trying to detect it at runtime
368
+ properties[ChatPropertyKey.platform.rawValue] = "ios"
369
+
370
+ return properties
371
+ }
372
+
373
+ /// Delete all user specific data like identity tokens , email address, custom chat properties etc from any in memory or local stores.
374
+ /// Note: does not remove any property or user data from Hubspot remotely, except for attempting to remove the push token from the current user.
375
+ public func clearUserData() {
376
+ sendPushTokenTask?.cancel()
377
+
378
+ /// First, remove the push token, if we can
379
+ if let pushToken, let portalId, let hubletModel {
380
+ Task {
381
+ do {
382
+ try await api.deleteDeviceToken(hublet: hubletModel, token: pushToken, portalId: portalId)
383
+ } catch {
384
+ logger.error("Error deleting push token from api: \(error)")
385
+ }
386
+
387
+ // either way, clear our stored token
388
+ self.pushToken = nil
389
+ self.pushTokenSyncState = .notSent
390
+ }
391
+ }
392
+
393
+ userIdentityToken = nil
394
+ userEmailAddress = nil
395
+ chatProperties = [:]
396
+
397
+ Task {
398
+ //The Hubspot chat view currently uses the default web data store
399
+ let cookieStore = WKWebsiteDataStore.default().httpCookieStore
400
+ let matchingCookies = await cookieStore.allCookies().filter { cookiesToDeleteWhenClearingData.contains($0.name) }
401
+ for cookie in matchingCookies {
402
+ await cookieStore.deleteCookie(cookie)
403
+ }
404
+ }
405
+
406
+ objectWillChange.send()
407
+ }
408
+
409
+ /// Computes the url for the current config for embedding chat, based on any known config for portal id, hublet, user id, etc
410
+ ///
411
+ /// The chat view , ``HubspotChatView`` calls this method when setting up its embedded chat
412
+ /// - Parameters:
413
+ /// - withPushData: The struct with data from push notification
414
+ /// - forChatFlow: The chat flow to open.
415
+ /// - Returns: URL to embed to show mobile chat
416
+ /// - Throws: ``HubspotConfigError.missingConfiguration`` if app settings like portal id or hublet are missing, or ``HubspotConfigError.missingChatFlow`` if no chat flow is provided and no default value exists
417
+ func chatUrl(
418
+ withPushData: PushNotificationChatData?,
419
+ forChatFlow: String?
420
+ ) throws -> URL {
421
+ guard let hublet,
422
+ let portalId
423
+ else {
424
+ throw HubspotConfigError.missingConfiguration
425
+ }
426
+
427
+ let hubletModel = Hublet(id: hublet, environment: environment)
428
+
429
+ var components = URLComponents()
430
+ components.scheme = "https"
431
+ components.host = hubletModel.hostname
432
+ components.path = "/conversations-visitor-embed"
433
+
434
+ var queryItems: [String: String] = [
435
+ "portalId": portalId,
436
+ "hublet": hubletModel.id,
437
+ "env": environment.rawValue,
438
+ ]
439
+
440
+ if let idToken = userIdentityToken {
441
+ queryItems["identificationToken"] = idToken
442
+ }
443
+
444
+ if let email = userEmailAddress {
445
+ queryItems["email"] = email
446
+ }
447
+
448
+ // Use chat flow from push data, if exsist, otherwise use chat flow dedicated property
449
+ if let chatFlow = withPushData?.chatflow, !chatFlow.isEmpty {
450
+ queryItems["chatflow"] = chatFlow
451
+ } else if let chatFlow = forChatFlow, !chatFlow.isEmpty {
452
+ queryItems["chatflow"] = chatFlow
453
+ } else if let defaultChatFlow, !defaultChatFlow.isEmpty {
454
+ queryItems["chatflow"] = defaultChatFlow
455
+ } else {
456
+ // No chatflow, but we know we need one
457
+ throw HubspotConfigError.missingChatFlow
458
+ }
459
+
460
+ var urlNoPlus = CharacterSet.urlQueryAllowed
461
+ urlNoPlus.remove("+")
462
+
463
+ // Manually encode query string, as + is a common component in emails and we don't want + appearing un-encoded as it would be if we used the query item collection on components
464
+ // Note: if we ever need values with spaces, we might need to update this handling to handle values when email is/isn't the key differently
465
+ components.percentEncodedQuery = queryItems.compactMap {
466
+ guard
467
+ let key = $0.addingPercentEncoding(withAllowedCharacters: urlNoPlus),
468
+ let value = $1.addingPercentEncoding(withAllowedCharacters: urlNoPlus)
469
+ else {
470
+ return nil
471
+ }
472
+
473
+ return key + "=" + value
474
+ }
475
+ .joined(separator: "&")
476
+
477
+ guard let url = components.url else {
478
+ throw HubspotConfigError.missingConfiguration
479
+ }
480
+
481
+ return url
482
+ }
483
+
484
+ /// Handle obtaining a thread id - once the thread id is known , we can post chat properties to the API. This method is used by chat views once they've extracted ID from UI / Javascript Bridge.
485
+ /// - Parameter threadId: the thread id retrieved from the active chat view
486
+ func handleThreadOpened(threadId: String) {
487
+ guard let portalId, let hubletModel else {
488
+ return
489
+ }
490
+
491
+ // Call the api. Creating a task here instead of making this method async because right now we don't need to do anything after
492
+ Task {
493
+ // Get the properties we want to send to the api
494
+ let props = await finalizeChatProperties()
495
+
496
+ do {
497
+ try await api.sendChatProperties(
498
+ hublet: hubletModel,
499
+ properties: props,
500
+ visitorIdToken: self.userIdentityToken,
501
+ email: self.userEmailAddress,
502
+ threadId: threadId,
503
+ portalId: portalId
504
+ )
505
+ } catch {
506
+ logger.error("Error sending chat properties: \(error)")
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ extension Image {
513
+ /// Exporting chat icon - initially for demo use - but maybe sharing some resources that aren't buttons or views might be needed eventually, if so refactor this
514
+ public static var hubspotChat: Image {
515
+ Image("GenericChatIcon", bundle: .hubspotResources)
516
+ }
517
+ }
518
+
519
+ extension Bundle {
520
+ static var hubspotResources: Bundle? {
521
+ // CocoaPods bundles resources into a separate bundle, unlike Swift Package's `Bundle.module`.
522
+ Bundle.main.url(forResource: "HubspotMobileSDKResources", withExtension: "bundle")
523
+ .flatMap { Bundle(url: $0) }
524
+ }
525
+ }
526
+
527
+ extension HubspotManager {
528
+ /// Create a visitor access token directly using app access token
529
+ ///
530
+ /// Convenience for creating a visitor identity token using the given details, for situations where server infrastructure isn't available during SDK development.
531
+ ///
532
+ /// > Warning: Embedding access token for your product in app is not recommended - This was originally for demo purposes, and may be removed. Strongly consider creating a token as part of app server infrastructure instead.
533
+ ///
534
+ /// - Parameters:
535
+ /// - accessToken: The access token for your application, as returned by the Hubspot dashboard
536
+ /// - email: the email of the user
537
+ /// - firstName: users first name
538
+ /// - lastName: users last name
539
+ /// - Returns: The generated JWT token
540
+ @available(*, deprecated, message: "This is for development only and may be removed - acquiring an access token should be done as part of your products server infrastructure")
541
+ public func aquireUserIdentityToken(accessToken: String, email: String, firstName: String, lastName: String) async throws -> String {
542
+ guard let hubletModel else {
543
+ throw HubspotConfigError.missingConfiguration
544
+ }
545
+
546
+ return try await api.createVisitorToken(hublet: hubletModel, accessToken: accessToken, email: email, firstName: firstName, lastName: lastName)
547
+ }
548
+ }
@@ -0,0 +1,7 @@
1
+ // HubspotMobileSDK.swift
2
+ // Hubspot Mobile SDK
3
+ //
4
+ // Copyright © 2024 Hubspot, Inc.
5
+
6
+ // The Swift Programming Language
7
+ // https://docs.swift.org/swift-book
@@ -0,0 +1,115 @@
1
+ // HubspotUserProperties.swift
2
+ // Hubspot Mobile SDK
3
+ //
4
+ // Copyright © 2024 Hubspot, Inc.
5
+
6
+ import Foundation
7
+ import UIKit
8
+
9
+ /// These are the known , pre-defined key values for chat properties.
10
+ ///
11
+ /// These values may be expected by Hubspot to be set in some situations, or to enable optional functionality if present.
12
+ ///
13
+ /// If including chat properties in your application, if possible use these keys where appropiate rather than custom key names. Use the raw value as key values when setting chat properties on ``HubspotManager`` using ``HubspotManager/setChatProperties(data:)`` function. Use the `rawValue` property like so:
14
+ ///
15
+ /// ```
16
+ /// manager.setChatProperties(data:[
17
+ /// ChatPropertyKey.location.rawValue: "....."
18
+ /// ])
19
+ /// ```
20
+ ///
21
+ /// Avoid including personal, private information in property values, and only include data that you have permission from the user to use.
22
+ public enum ChatPropertyKey: String, Sendable {
23
+ /// Optional. Use this key with the value 'true' or 'false' to record if your app has camera permissions granted.
24
+ ///
25
+ /// `api key: camera_permissions`
26
+ case cameraPermissions = "camera_permissions"
27
+ /// Optional. Use this key with the value 'true' or 'false' to record if your app has photo permissions granted.
28
+ ///
29
+ /// `api key: photo_library_permissions`
30
+ case photoPermissions = "photo_library_permissions"
31
+ /// Automatic. This key and value is set automatically. Its set to true when there's permission granted to show notifications as alerts or in the notification centre, either as full permission or provisional permission
32
+ ///
33
+ /// `api key: notification_permissions`
34
+ case notificationPermissions = "notification_permissions"
35
+ /// Optional. Use this key with the value 'true' or 'false' to record if your app has location permissions granted.
36
+ ///
37
+ /// `api key: location_permissions`
38
+ case locationPermissions = "location_permissions"
39
+ /// Optional. Use this key with a location for the user, formatted as latitude,longitude , for example , "51.51148,-0.12266". This may be useful information for your support requests.
40
+ ///
41
+ /// `api key: location`
42
+ case location
43
+ /// Automatic: This key value is set automatically when using push notification features of the sdk.
44
+ ///
45
+ /// `api key: push_token`
46
+ case pushToken = "push_token"
47
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations.
48
+ /// Note - iOS reports model numbers differently with the device code, than how they are marketed. Instead of iPhone 15 Pro , one would get "iPhone16,1"
49
+ ///
50
+ /// `api key: device_model`
51
+ case deviceModel = "device_model"
52
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. The value format is the platform, for example "ios"
53
+ ///
54
+ /// `api key: platform`
55
+ case platform
56
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. The value format is the platform followed by version , for example "iOS 17.2"
57
+ ///
58
+ /// `api key: os_version`
59
+ case operatingSystemVersion = "os_version"
60
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations - the value is read from the main budle version keys, formatted as (CFBundleShortVersionString).(CFBundleVersion)
61
+ ///
62
+ /// `api key: app_version`
63
+ case appVersion = "app_version"
64
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. Value is set to unknown, portrait, or landscape
65
+ ///
66
+ /// `api key: device_orientation`
67
+ case deviceOrientation = "device_orientation"
68
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. Value is formated as widthxheight
69
+ ///
70
+ /// `api key: screen_size`
71
+ case screenSize = "screen_size"
72
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. Value is formated as widthxheight
73
+ ///
74
+ /// `api key: screen_resolution`
75
+ case screenResolution = "screen_resolution"
76
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. The battery level is a percentage from 0 to 100.
77
+ ///
78
+ /// `api key: battery_level`
79
+ case batteryLevel = "battery_level"
80
+ /// Automatic: This key and value is set automatically by the sdk when opening chat conversations. The value is the same as the `UIDevice.BatteryState` enum names, and can be:
81
+ /// unknown, full, charging, unplugged
82
+ ///
83
+ /// `api key: battery_state`
84
+ case batteryState = "battery_state"
85
+ }
86
+
87
+ extension UIDeviceOrientation {
88
+ var hubspotApiValue: String {
89
+ switch self {
90
+ case .portrait, .portraitUpsideDown:
91
+ return "portrait"
92
+ case .landscapeLeft, .landscapeRight:
93
+ return "landscape"
94
+ default:
95
+ return "unknown"
96
+ }
97
+ }
98
+ }
99
+
100
+ extension UIDevice.BatteryState {
101
+ var hubspotApiValue: String {
102
+ switch self {
103
+ case .unknown:
104
+ return "unknown"
105
+ case .charging:
106
+ return "charging"
107
+ case .full:
108
+ return "full"
109
+ case .unplugged:
110
+ return "unplugged"
111
+ @unknown default:
112
+ return "unknown"
113
+ }
114
+ }
115
+ }