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,145 @@
|
|
|
1
|
+
// HubspotConfig.swift
|
|
2
|
+
// Hubspot Mobile SDK
|
|
3
|
+
//
|
|
4
|
+
// Copyright © 2024 Hubspot, Inc.
|
|
5
|
+
|
|
6
|
+
import Foundation
|
|
7
|
+
|
|
8
|
+
/// Enum used during configuration. The default is production - if in doubt choose production
|
|
9
|
+
public enum HubspotEnvironment: String, Codable, CustomStringConvertible, Sendable {
|
|
10
|
+
/// QA environment , mostly for internal use
|
|
11
|
+
case qa
|
|
12
|
+
/// Production environment, the most commonly used environment
|
|
13
|
+
case production = "prod"
|
|
14
|
+
|
|
15
|
+
/// Display friendly name for the environment
|
|
16
|
+
public var description: String {
|
|
17
|
+
switch self {
|
|
18
|
+
case .production:
|
|
19
|
+
return "Production"
|
|
20
|
+
case .qa:
|
|
21
|
+
return "QA"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Encapsulates some of the logic around hublets, as some are treated differently than others. Right now we know of only 2, but this might expand in the future?
|
|
27
|
+
struct Hublet {
|
|
28
|
+
/// This is the default(?) hublet, it uses just plain sub domain
|
|
29
|
+
let defaultUS = "na1"
|
|
30
|
+
|
|
31
|
+
let id: String
|
|
32
|
+
let environment: HubspotEnvironment
|
|
33
|
+
|
|
34
|
+
/// The format of subdomain varies between hublets
|
|
35
|
+
private var appsSubDomain: String {
|
|
36
|
+
let id = id.lowercased()
|
|
37
|
+
if id == defaultUS {
|
|
38
|
+
return "app"
|
|
39
|
+
} else {
|
|
40
|
+
// other hublets like eu1 have hublet in the subdomain
|
|
41
|
+
return "app-\(id)"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private var appsDomain: String {
|
|
46
|
+
// Right now, qa env has its own domain
|
|
47
|
+
switch environment {
|
|
48
|
+
case .production:
|
|
49
|
+
return "hubspot.com"
|
|
50
|
+
case .qa:
|
|
51
|
+
return "hubspotqa.com"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The format of subdomain varies between hublets
|
|
56
|
+
private var apiSubDomain: String {
|
|
57
|
+
let id = id.lowercased()
|
|
58
|
+
if id == defaultUS {
|
|
59
|
+
return "api"
|
|
60
|
+
} else {
|
|
61
|
+
// other hublets like eu1 have hublet in the subdomain
|
|
62
|
+
return "api-\(id)"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private var apiDomain: String {
|
|
67
|
+
// Right now, qa env has its own domain
|
|
68
|
+
switch environment {
|
|
69
|
+
case .production:
|
|
70
|
+
return "hubapi.com"
|
|
71
|
+
case .qa:
|
|
72
|
+
return "hubapiqa.com"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// hostname used for the embedded chat page
|
|
77
|
+
var hostname: String {
|
|
78
|
+
return appsSubDomain + "." + appsDomain
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// hostname used for api calls
|
|
82
|
+
var apiHostname: String {
|
|
83
|
+
return apiSubDomain + "." + apiDomain
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// base url for api calls - append path before using
|
|
87
|
+
var apiURL: URL {
|
|
88
|
+
var components = URLComponents()
|
|
89
|
+
components.scheme = "https"
|
|
90
|
+
components.host = apiHostname
|
|
91
|
+
guard let url = components.url else {
|
|
92
|
+
fatalError("Unable to build URL from configuration")
|
|
93
|
+
}
|
|
94
|
+
return url
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Errors relating to setting up SDK
|
|
99
|
+
public enum HubspotConfigError: LocalizedError, Sendable {
|
|
100
|
+
/// Missing config file, or missing value within - if this error occurs, make sure hubspot info file is bundled in app, and that the manager configure method ``HubspotManager/configure()-swift.type.method`` has been called .
|
|
101
|
+
case missingConfiguration
|
|
102
|
+
|
|
103
|
+
/// Chat flow is needed to correctly show a chat
|
|
104
|
+
case missingChatFlow
|
|
105
|
+
|
|
106
|
+
/// Description of the error and reason for failure, same as ``failureReason`` currently.
|
|
107
|
+
public var errorDescription: String? {
|
|
108
|
+
switch self {
|
|
109
|
+
case .missingConfiguration:
|
|
110
|
+
return "Couldn't find a configuration at the expected path \(HubspotConfig.defaultConfigFileName)"
|
|
111
|
+
case .missingChatFlow:
|
|
112
|
+
return "No chat flow provided, and no default found"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/// Description of the error and reason for failure, same as ``errorDescription`` currently.
|
|
116
|
+
public var failureReason: String? {
|
|
117
|
+
switch self {
|
|
118
|
+
case .missingConfiguration:
|
|
119
|
+
return "Couldn't find a configuration at the expected path \(HubspotConfig.defaultConfigFileName)"
|
|
120
|
+
case .missingChatFlow:
|
|
121
|
+
return "No chat flow provided, and no default found"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// This struct for decoding the config file bundled in app - the config file contains the required pieces of info needed to connect to the correct hubspot endponts like that , account specific info like portal id and hublet
|
|
127
|
+
///
|
|
128
|
+
/// By default, the SDK will initialise using a known file path, Hubspot-Info.plist
|
|
129
|
+
///
|
|
130
|
+
public struct HubspotConfig: Codable, Sendable {
|
|
131
|
+
/// This is the default, assumed filename for the plist bunded in app containing the config values
|
|
132
|
+
public static let defaultConfigFileName: String = "Hubspot-Info.plist"
|
|
133
|
+
|
|
134
|
+
/// The hubspot environment to use
|
|
135
|
+
public let environment: HubspotEnvironment
|
|
136
|
+
|
|
137
|
+
/// The hublet the portal is in, for example "na1" or "eu1"
|
|
138
|
+
public let hublet: String
|
|
139
|
+
|
|
140
|
+
/// The unique id for the customers hubspot portal
|
|
141
|
+
public let portalId: String
|
|
142
|
+
|
|
143
|
+
/// The default chat flow value to use if not specified when creating a chat view
|
|
144
|
+
public let defaultChatFlow: String?
|
|
145
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// HubspotManager+Notifications.swift
|
|
2
|
+
// Hubspot Mobile SDK
|
|
3
|
+
//
|
|
4
|
+
// Copyright © 2024 Hubspot, Inc.
|
|
5
|
+
|
|
6
|
+
import Foundation
|
|
7
|
+
import NotificationCenter
|
|
8
|
+
|
|
9
|
+
extension HubspotManager {
|
|
10
|
+
/// Alternative to ``newMessage`` publisher or ``newMessageCallback`` property , potentially useful in a SwiftUI view
|
|
11
|
+
///
|
|
12
|
+
/// This could be used in a stand alone task like so:
|
|
13
|
+
///
|
|
14
|
+
/// ```swift
|
|
15
|
+
/// Task {
|
|
16
|
+
/// for await notification in HubspotManager.shared.newMessages() {
|
|
17
|
+
/// self.triggerChatFlow()
|
|
18
|
+
/// }
|
|
19
|
+
///
|
|
20
|
+
/// }
|
|
21
|
+
/// ```
|
|
22
|
+
/// or attached to a view:
|
|
23
|
+
/// ```swift
|
|
24
|
+
/// myView
|
|
25
|
+
/// .frame(...)
|
|
26
|
+
/// .background(...)
|
|
27
|
+
/// .task {
|
|
28
|
+
/// for await _ in HubspotManager.shared.newMessages() {
|
|
29
|
+
/// presentChatView = true
|
|
30
|
+
/// }
|
|
31
|
+
/// }
|
|
32
|
+
/// ```
|
|
33
|
+
/// > Warning: This async stream normally will never terminate.
|
|
34
|
+
///
|
|
35
|
+
public func newMessages() -> AsyncStream<PushNotificationChatData> {
|
|
36
|
+
return AsyncStream { cont in
|
|
37
|
+
|
|
38
|
+
// Supressing sendability issue with cancellable - should be ok to send to the onTermination, as nothing else will have a reference to it
|
|
39
|
+
nonisolated(unsafe) let pubCancellable = self.newMessage.sink(
|
|
40
|
+
receiveCompletion: { _ in
|
|
41
|
+
// doesn't matter if the subscription fails or finishes, our sequence is done - in this specific case we expect it never to do either , but just incase, end the stream.
|
|
42
|
+
cont.finish()
|
|
43
|
+
},
|
|
44
|
+
receiveValue: { data in
|
|
45
|
+
cont.yield(data)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
cont.onTermination = { termination in
|
|
49
|
+
switch termination {
|
|
50
|
+
case .cancelled:
|
|
51
|
+
// If the termination was from the task / stream side , the subscription itself is still active, so attempt to cancel it
|
|
52
|
+
pubCancellable.cancel()
|
|
53
|
+
case .finished:
|
|
54
|
+
// Do nothing if we finished the stream - that only happens when the subscription itself finished
|
|
55
|
+
break
|
|
56
|
+
@unknown default:
|
|
57
|
+
pubCancellable.cancel()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// This is a convenience method for allowing this HubspotManager instance to act as your UNUserNotificationCenterDelegate. This will check any notifications received for hubspot messages.
|
|
64
|
+
///
|
|
65
|
+
/// If using the HubspotManager to handle notifications, call this before the end of app launch to ensure all notifications are handled.
|
|
66
|
+
///
|
|
67
|
+
/// If you would prefer to present notification permission dialog at a specific time, or as part of a primer screen, set promptForNotificationPermissions to false. If you have no preference for when to prompt the user , set promptForNotificationPermissions to true, and the user will be prompted if permissions are not yet granted.
|
|
68
|
+
///
|
|
69
|
+
/// It's also ok to call this multiple times - you might call `configurePushMessaging(promptForNotificationPermissions: false, allowProvisionalNotifications: true, newMessageCallback: myHandler)` during your app start up or initial setup, and later after user enables a setting or accesses a particular feature, call `configurePushMessaging(promptForNotificationPermissions: true, allowProvisionalNotifications: true, newMessageCallback: myHandler)` to trigger the prompt for permissions.
|
|
70
|
+
///
|
|
71
|
+
/// **Warning** This will set the delegate for the current UNUserNotificationCenter - if you need to handle UNUserNotificationCenterDelegate callbacks in your app, do not use this method.
|
|
72
|
+
///
|
|
73
|
+
/// If the app should show chat UI in response to the user tapping a notification, newMessageCallback is triggered. Use this closure to configure your UI.
|
|
74
|
+
/// - Parameters:
|
|
75
|
+
/// - promptForNotificationPermissions: If true, and notification permissions are not yet granted, the user is prompted to allow notifications
|
|
76
|
+
/// - allowProvisionalNotifications: If `promptForNotificationPermissions` is false, set this to true to enable provisional notifications if not already granted. Has no effect if `promptForNotificationPermissions` is true.
|
|
77
|
+
/// - newMessageCallback: Use this closure to configure your UI to show chat view. Called on the main thread. If nil, the call back isn't changed from any previous configuration. Alternatively, leave as nil , and set ``HubspotManager/newMessageCallback`` property directly. or use the ``HubspotManager/newMessage`` publisher property
|
|
78
|
+
public func configurePushMessaging(
|
|
79
|
+
promptForNotificationPermissions: Bool,
|
|
80
|
+
allowProvisionalNotifications: Bool,
|
|
81
|
+
newMessageCallback: ((PushNotificationChatData) -> Void)? = nil
|
|
82
|
+
) {
|
|
83
|
+
UIApplication.shared.registerForRemoteNotifications()
|
|
84
|
+
|
|
85
|
+
// Act as the delegate for opening notifications so we can tell when someone opens one.
|
|
86
|
+
UNUserNotificationCenter.current().delegate = self
|
|
87
|
+
|
|
88
|
+
if promptForNotificationPermissions {
|
|
89
|
+
Task.detached {
|
|
90
|
+
let currentSettings = await UNUserNotificationCenter.current().notificationSettings()
|
|
91
|
+
// We only want to request auth if not yet asked or just provisional
|
|
92
|
+
if currentSettings.authorizationStatus == .notDetermined || currentSettings.authorizationStatus == .provisional {
|
|
93
|
+
do {
|
|
94
|
+
let authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
|
|
95
|
+
if authorized {
|
|
96
|
+
await self.logger.trace("Notification permission granted")
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
await self.logger.error("Unable to request notification permissions: \(error)")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else if allowProvisionalNotifications {
|
|
104
|
+
Task.detached {
|
|
105
|
+
let currentSettings = await UNUserNotificationCenter.current().notificationSettings()
|
|
106
|
+
// We only want to request auth if not yet asked
|
|
107
|
+
if currentSettings.authorizationStatus == .notDetermined {
|
|
108
|
+
do {
|
|
109
|
+
let authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: [.provisional])
|
|
110
|
+
if authorized {
|
|
111
|
+
await self.logger.trace("Provisional notification settings enabled")
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
await self.logger.error("Unable to request notification permissions: \(error)")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if let newMessageCallback {
|
|
121
|
+
self.newMessageCallback = newMessageCallback
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Making the `HubspotManager` your user notification delete is not required, but its an option for convenience in situations where another delegate doesn't already exist.
|
|
127
|
+
extension HubspotManager: UNUserNotificationCenterDelegate {
|
|
128
|
+
/// Use this method to help identify incoming notifications that are hubspot related , incase you wish to handle them differently
|
|
129
|
+
public nonisolated func isHubspotNotification(notification: UNNotification) -> Bool {
|
|
130
|
+
let notificationData = notification.request.content.userInfo
|
|
131
|
+
return isHubspotNotification(notificationData: notificationData)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Use this method to help identify incoming notifications that are hubspot related , incase you wish to handle them differently
|
|
135
|
+
public nonisolated func isHubspotNotification(notificationData: [AnyHashable: Any]) -> Bool {
|
|
136
|
+
let hasAHubspotKey = notificationData.contains(where: { key, _ in
|
|
137
|
+
guard let key = key as? String else {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// There's a few of keys we can potentially have here
|
|
142
|
+
return
|
|
143
|
+
key.hasPrefix(PushNotificationChatData.chatflowIdKey) || key.hasPrefix(PushNotificationChatData.chatflowKey) || key.hasPrefix(PushNotificationChatData.portalIdKey) || key.hasPrefix(PushNotificationChatData.threadIdKey)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return hasAHubspotKey
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// A ``HubspotManager`` instanance, like ``HubspotManager/shared`` can be used as a notification centre delegate, in situations where all notifications are from hubspot. If you have your own notification delegate, instead call this method from within your own delegate for notifications that are hubspot related.
|
|
150
|
+
///
|
|
151
|
+
///
|
|
152
|
+
public nonisolated func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
153
|
+
if isHubspotNotification(notification: response.notification) {
|
|
154
|
+
guard let chatData = PushNotificationChatData(notification: response.notification) else {
|
|
155
|
+
// none of the expected data in the message
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// dispatching on main as most likely will result in ui changes - incase not everyone remembers to enforce processing on main thread for presentations
|
|
160
|
+
DispatchQueue.main.async {
|
|
161
|
+
self.newMessageCallback(chatData)
|
|
162
|
+
self.newMessage.send(chatData)
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
let requestId = response.notification.request.identifier
|
|
166
|
+
Task {
|
|
167
|
+
await self.logger.info("Push message handled by HubspotManager that isn't detected as as Hubspot notifiation. This may be a misconfiguration. Response id: \(requestId)")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
completionHandler()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// A ``HubspotManager`` instanance, like ``HubspotManager/shared`` can be used as a notification centre delegate, in situations where all notifications are from hubspot. If you have your own notification delegate, instead call this method from within your own delegate for notifications that are hubspot related.
|
|
174
|
+
///
|
|
175
|
+
public nonisolated func userNotificationCenter(_: UNUserNotificationCenter, willPresent _: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
|
176
|
+
completionHandler([.banner, .sound])
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// HubspotManager+Properties.swift
|
|
2
|
+
// Hubspot Mobile SDK
|
|
3
|
+
//
|
|
4
|
+
// Copyright © 2024 Hubspot, Inc.
|
|
5
|
+
|
|
6
|
+
import Foundation
|
|
7
|
+
import UIKit // Needed for UIDevice
|
|
8
|
+
|
|
9
|
+
extension HubspotManager {
|
|
10
|
+
/// Called by ui components if a chat might potentially happen - allows manager class to enable anything that needs prep time - like battery monitoring ahead of gathering it.
|
|
11
|
+
/// Internal for now - if its not enough to be called in the chat view, it can be made public and a requirement to be called from custom components.
|
|
12
|
+
func prepareForPotentialChat() {
|
|
13
|
+
enableBatteryMonitoring()
|
|
14
|
+
enableOrientationMonitoring()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func enableOrientationMonitoring() {
|
|
18
|
+
if !UIDevice.current.isGeneratingDeviceOrientationNotifications {
|
|
19
|
+
didWeEnableOrientationMonitoring = true
|
|
20
|
+
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func enableBatteryMonitoring() {
|
|
25
|
+
if !UIDevice.current.isBatteryMonitoringEnabled {
|
|
26
|
+
didWeEnableBatterMonitoring = true
|
|
27
|
+
UIDevice.current.isBatteryMonitoringEnabled = true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Fetch the system model , converting c struct into normal string. Uses utsname function and reflection.
|
|
32
|
+
/// The result is the apple model number, like iPhone15,4 , iPhone16,1 , etc rather than marketing name like "Pro Max"
|
|
33
|
+
func deviceModel() -> String {
|
|
34
|
+
var info = utsname() // create empty struct
|
|
35
|
+
uname(&info) // populate it
|
|
36
|
+
|
|
37
|
+
/// We can't iterate over a tuple of characters, nor can we use the init methods that take an array of cchars, so reflection to loop over them instead
|
|
38
|
+
let mirror = Mirror(reflecting: info.machine)
|
|
39
|
+
let parts: [String] = mirror.children.compactMap { charProperty -> String? in
|
|
40
|
+
// 0 would be end of string
|
|
41
|
+
guard let intChar = charProperty.value as? Int8, intChar > 0 else {
|
|
42
|
+
return nil
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
guard let scalar = UnicodeScalar(UInt32(intChar)) else {
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
return String(scalar)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return parts.joined()
|
|
52
|
+
}
|
|
53
|
+
}
|