@zellio/chat-react-native 0.0.0-stage → 1.0.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/LICENSE +202 -0
- package/README.md +239 -2
- package/ZellioChatReactNative.podspec +30 -0
- package/android/build.gradle +62 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatModule.kt +214 -0
- package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatPackage.kt +25 -0
- package/app.plugin.js +4 -0
- package/ios/RNZellioChat.h +5 -0
- package/ios/RNZellioChat.mm +116 -0
- package/ios/ZCRNBridge.swift +217 -0
- package/ios/vendor/resources/PrivacyInfo.xcprivacy +99 -0
- package/ios/vendor/swift/ZellioChat/Model/ChatModel.swift +261 -0
- package/ios/vendor/swift/ZellioChat/Platform/Platform.swift +124 -0
- package/ios/vendor/swift/ZellioChat/Platform/URLSessionTransport.swift +175 -0
- package/ios/vendor/swift/ZellioChat/Preview/Previews.swift +62 -0
- package/ios/vendor/swift/ZellioChat/Public/InAppBanner.swift +155 -0
- package/ios/vendor/swift/ZellioChat/Public/PublicTypes.swift +141 -0
- package/ios/vendor/swift/ZellioChat/Public/Runtime.swift +406 -0
- package/ios/vendor/swift/ZellioChat/Public/ZellioChat.swift +209 -0
- package/ios/vendor/swift/ZellioChat/Theme/ChatTheme.swift +185 -0
- package/ios/vendor/swift/ZellioChat/Thread/AttachmentPicker.swift +354 -0
- package/ios/vendor/swift/ZellioChat/Thread/ComposerBar.swift +355 -0
- package/ios/vendor/swift/ZellioChat/Thread/ThreadCells.swift +776 -0
- package/ios/vendor/swift/ZellioChat/Thread/ThreadViewController.swift +703 -0
- package/ios/vendor/swift/ZellioChat/Views/ChatRootView.swift +154 -0
- package/ios/vendor/swift/ZellioChat/Views/Components.swift +335 -0
- package/ios/vendor/swift/ZellioChat/Views/HomeView.swift +413 -0
- package/ios/vendor/swift/ZellioChatCore/ChatModels.swift +373 -0
- package/ios/vendor/swift/ZellioChatCore/ConfigModels.swift +389 -0
- package/ios/vendor/swift/ZellioChatCore/Engine.swift +325 -0
- package/ios/vendor/swift/ZellioChatCore/Errors.swift +158 -0
- package/ios/vendor/swift/ZellioChatCore/Generated/ChatStringTable.swift +125 -0
- package/ios/vendor/swift/ZellioChatCore/Outbox.swift +223 -0
- package/ios/vendor/swift/ZellioChatCore/Push.swift +74 -0
- package/ios/vendor/swift/ZellioChatCore/Realtime.swift +328 -0
- package/ios/vendor/swift/ZellioChatCore/Session.swift +210 -0
- package/ios/vendor/swift/ZellioChatCore/Storage.swift +201 -0
- package/ios/vendor/swift/ZellioChatCore/Strings.swift +104 -0
- package/ios/vendor/swift/ZellioChatCore/Theme.swift +238 -0
- package/ios/vendor/swift/ZellioChatCore/ThreadController.swift +319 -0
- package/ios/vendor/swift/ZellioChatCore/Timeline.swift +157 -0
- package/ios/vendor/swift/ZellioChatCore/Transport.swift +236 -0
- package/ios/vendor/swift/ZellioChatCore/Util.swift +137 -0
- package/lib/NativeZellioChat.d.ts +34 -0
- package/lib/NativeZellioChat.js +4 -0
- package/lib/index.d.ts +37 -0
- package/lib/index.js +241 -0
- package/lib/types.d.ts +54 -0
- package/lib/types.js +2 -0
- package/lib/version.d.ts +4 -0
- package/lib/version.js +7 -0
- package/package.json +75 -4
- package/plugin/build/index.js +73 -0
- package/src/NativeZellioChat.ts +30 -0
- package/src/index.ts +237 -0
- package/src/types.ts +56 -0
- package/src/version.ts +5 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && canImport(UIKit)
|
|
2
|
+
import Combine
|
|
3
|
+
import Foundation
|
|
4
|
+
import SwiftUI
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
/// The process-wide state behind the `ZellioChat` calls. Every public call goes through here.
|
|
8
|
+
/// All entry points are safe from any thread, never block the caller, and do nothing before `configure`.
|
|
9
|
+
final class ZellioChatRuntime: NSObject, UIAdaptivePresentationControllerDelegate {
|
|
10
|
+
static let shared = ZellioChatRuntime()
|
|
11
|
+
|
|
12
|
+
private let queue = DispatchQueue(label: "ai.zellio.chat.runtime")
|
|
13
|
+
private let lock = NSLock()
|
|
14
|
+
private var engine: ChatEngine?
|
|
15
|
+
private var options = ZellioChatOptions()
|
|
16
|
+
private var appKey = ""
|
|
17
|
+
private var eventListeners: [Int: (ZellioChatEvent) -> Void] = [:]
|
|
18
|
+
private var unreadListeners: [Int: (Int) -> Void] = [:]
|
|
19
|
+
private var nextToken = 0
|
|
20
|
+
private let network = NetworkMonitor()
|
|
21
|
+
private var observersAdded = false
|
|
22
|
+
private var pendingTapConversation: String??
|
|
23
|
+
let unreadState = ZellioChatUnread()
|
|
24
|
+
|
|
25
|
+
// Main thread only.
|
|
26
|
+
private var model: ChatModel?
|
|
27
|
+
private weak var hosting: ChatHostingController?
|
|
28
|
+
private var banner: InAppBannerController?
|
|
29
|
+
|
|
30
|
+
private override init() { super.init() }
|
|
31
|
+
|
|
32
|
+
private var currentEngine: ChatEngine? { lock.lock(); defer { lock.unlock() }; return engine }
|
|
33
|
+
private var currentOptions: ZellioChatOptions { lock.lock(); defer { lock.unlock() }; return options }
|
|
34
|
+
|
|
35
|
+
// MARK: Configure
|
|
36
|
+
|
|
37
|
+
func configure(appKey: String, options newOptions: ZellioChatOptions) {
|
|
38
|
+
queue.async { [self] in
|
|
39
|
+
SDKLog.level = newOptions.logLevel
|
|
40
|
+
if currentEngine != nil && self.appKey == appKey {
|
|
41
|
+
lock.lock(); options = newOptions; lock.unlock()
|
|
42
|
+
currentEngine?.setLocaleOption(newOptions.locale.core)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
guard !appKey.isEmpty else { SDKLog.log(.error, "configure: the app key is empty"); return }
|
|
46
|
+
let base = newOptions.baseURL ?? URL(string: SDKInfo.defaultBaseURL) ?? URL(fileURLWithPath: "/")
|
|
47
|
+
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
|
48
|
+
let engine = ChatEngine(
|
|
49
|
+
options: EngineOptions(baseURL: base, appKey: appKey, localeOption: newOptions.locale.core,
|
|
50
|
+
deviceLanguageCode: Locale.preferredLanguages.first, wrapper: newOptions.wrapper, appVersion: version),
|
|
51
|
+
transport: newOptions.transport ?? URLSessionTransport(), secrets: KeychainSecretStore(), storage: AppleStorage.make(appKey: appKey)
|
|
52
|
+
)
|
|
53
|
+
lock.lock()
|
|
54
|
+
self.engine = engine
|
|
55
|
+
self.appKey = appKey
|
|
56
|
+
self.options = newOptions
|
|
57
|
+
lock.unlock()
|
|
58
|
+
|
|
59
|
+
_ = engine.unread.add { [weak self] count in
|
|
60
|
+
DispatchQueue.main.async {
|
|
61
|
+
self?.unreadState.count = count
|
|
62
|
+
self?.notifyUnread(count)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Engine and network callbacks come from other threads. The main queue keeps their order.
|
|
66
|
+
_ = engine.addListener { [weak self] event in
|
|
67
|
+
DispatchQueue.main.async { MainActor.assumeIsolated { self?.handle(event) } }
|
|
68
|
+
}
|
|
69
|
+
network.onChange = { [weak self] online in
|
|
70
|
+
DispatchQueue.main.async {
|
|
71
|
+
MainActor.assumeIsolated {
|
|
72
|
+
guard let self = self, let engine = self.currentEngine else { return }
|
|
73
|
+
self.model?.networkChanged(online: online)
|
|
74
|
+
if online { Task { await engine.networkReturned() } }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
network.start()
|
|
79
|
+
DispatchQueue.main.async { self.addLifecycleObservers() }
|
|
80
|
+
Task { @MainActor in
|
|
81
|
+
await engine.refreshUnread()
|
|
82
|
+
await self.startStreamIfPossible()
|
|
83
|
+
if let pending = self.takePendingTap() { DispatchQueue.main.async { self.presentChat(entry: .conversation(pending)) } }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func takePendingTap() -> String?? {
|
|
89
|
+
lock.lock(); defer { lock.unlock() }
|
|
90
|
+
let value = pendingTapConversation
|
|
91
|
+
pendingTapConversation = nil
|
|
92
|
+
return value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// MARK: Lifecycle
|
|
96
|
+
|
|
97
|
+
private func addLifecycleObservers() {
|
|
98
|
+
guard !observersAdded else { return }
|
|
99
|
+
observersAdded = true
|
|
100
|
+
let center = NotificationCenter.default
|
|
101
|
+
center.addObserver(self, selector: #selector(didBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
102
|
+
center.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@objc private func didBecomeActive() {
|
|
106
|
+
guard let engine = currentEngine else { return }
|
|
107
|
+
Task {
|
|
108
|
+
await engine.enteredForeground()
|
|
109
|
+
await self.startStreamIfPossible()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@objc private func didEnterBackground() {
|
|
114
|
+
guard let engine = currentEngine else { return }
|
|
115
|
+
Task { await engine.enteredBackground() }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// The stream runs while the app is open and a session exists, so a new message can show the banner.
|
|
119
|
+
/// Never before consent: no session, no stream.
|
|
120
|
+
private func startStreamIfPossible() async {
|
|
121
|
+
guard let engine = currentEngine, await engine.session.token != nil,
|
|
122
|
+
ConsentGate.allowsSession(config: engine.cachedConfig, store: engine.consentStore) else { return }
|
|
123
|
+
await engine.startRealtime()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: Identity and context
|
|
127
|
+
|
|
128
|
+
func identify(_ provider: @escaping ZellioChatTokenProvider) {
|
|
129
|
+
queue.async { [self] in
|
|
130
|
+
guard let engine = currentEngine else { SDKLog.log(.error, "identify called before configure"); return }
|
|
131
|
+
engine.identify(provider)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func logout() {
|
|
136
|
+
queue.async { [self] in
|
|
137
|
+
guard let engine = currentEngine else { SDKLog.log(.error, "logout called before configure"); return }
|
|
138
|
+
Task {
|
|
139
|
+
await engine.logout()
|
|
140
|
+
DispatchQueue.main.async { self.dismissChat(emit: true) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func setContext(_ context: ZellioChatContext?) {
|
|
146
|
+
queue.async { [self] in
|
|
147
|
+
guard let engine = currentEngine else { return }
|
|
148
|
+
engine.setContext(context?.core)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MARK: Push
|
|
153
|
+
|
|
154
|
+
func setPushToken(_ token: String, environment: String?) {
|
|
155
|
+
queue.async { [self] in
|
|
156
|
+
guard let engine = currentEngine else { SDKLog.log(.error, "setPushToken called before configure"); return }
|
|
157
|
+
engine.setPushToken(token, environment: environment ?? ZellioChatRuntime.defaultPushEnvironment)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static var defaultPushEnvironment: String {
|
|
162
|
+
#if DEBUG
|
|
163
|
+
return "sandbox"
|
|
164
|
+
#else
|
|
165
|
+
return "production"
|
|
166
|
+
#endif
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func handlePush(_ payload: [AnyHashable: Any]) -> Bool {
|
|
170
|
+
guard PushPayload.isZellioPush(payload) else { return false }
|
|
171
|
+
queue.async { [self] in
|
|
172
|
+
guard let engine = currentEngine else { return }
|
|
173
|
+
Task {
|
|
174
|
+
await engine.refreshUnread()
|
|
175
|
+
DispatchQueue.main.async { self.model?.catchUpActiveThread() }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func handleNotificationTap(_ payload: [AnyHashable: Any]) -> Bool {
|
|
182
|
+
guard PushPayload.isZellioPush(payload) else { return false }
|
|
183
|
+
let conversationId = PushPayload.parse(payload)?.conversationId
|
|
184
|
+
queue.async { [self] in
|
|
185
|
+
if currentEngine == nil {
|
|
186
|
+
// The app is still starting. Open the conversation as soon as configure is done.
|
|
187
|
+
lock.lock(); pendingTapConversation = .some(conversationId); lock.unlock()
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
DispatchQueue.main.async { self.presentChat(entry: .conversation(conversationId)) }
|
|
191
|
+
}
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MARK: Events
|
|
196
|
+
|
|
197
|
+
@discardableResult
|
|
198
|
+
func addEventListener(_ listener: @escaping (ZellioChatEvent) -> Void) -> Int {
|
|
199
|
+
lock.lock(); defer { lock.unlock() }
|
|
200
|
+
nextToken += 1
|
|
201
|
+
eventListeners[nextToken] = listener
|
|
202
|
+
return nextToken
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func removeEventListener(_ token: Int) { lock.lock(); eventListeners[token] = nil; lock.unlock() }
|
|
206
|
+
|
|
207
|
+
@discardableResult
|
|
208
|
+
func addUnreadListener(_ listener: @escaping (Int) -> Void) -> Int {
|
|
209
|
+
lock.lock()
|
|
210
|
+
nextToken += 1
|
|
211
|
+
let token = nextToken
|
|
212
|
+
unreadListeners[token] = listener
|
|
213
|
+
lock.unlock()
|
|
214
|
+
let current = unreadState.count
|
|
215
|
+
DispatchQueue.main.async { listener(current) }
|
|
216
|
+
return token
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func removeUnreadListener(_ token: Int) { lock.lock(); unreadListeners[token] = nil; lock.unlock() }
|
|
220
|
+
|
|
221
|
+
func emit(_ event: ZellioChatEvent) {
|
|
222
|
+
lock.lock()
|
|
223
|
+
let listeners = Array(eventListeners.values)
|
|
224
|
+
lock.unlock()
|
|
225
|
+
DispatchQueue.main.async { for listener in listeners { listener(event) } }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func notifyUnread(_ count: Int) {
|
|
229
|
+
lock.lock()
|
|
230
|
+
let listeners = Array(unreadListeners.values)
|
|
231
|
+
lock.unlock()
|
|
232
|
+
for listener in listeners { listener(count) }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
var unreadCount: Int { currentEngine?.unread.count ?? 0 }
|
|
236
|
+
|
|
237
|
+
private func handle(_ event: HubEvent) {
|
|
238
|
+
switch event {
|
|
239
|
+
case .outbox(let update):
|
|
240
|
+
switch update {
|
|
241
|
+
case .sent: emit(.messageSent)
|
|
242
|
+
case .failed: emit(.error(code: "send_failed"))
|
|
243
|
+
case .changed: break
|
|
244
|
+
}
|
|
245
|
+
case .realtime(let realtime):
|
|
246
|
+
if case .message(let message, _) = realtime, message.from == .agent {
|
|
247
|
+
emit(.messageReceived)
|
|
248
|
+
DispatchQueue.main.async { self.showBanner(for: message) }
|
|
249
|
+
}
|
|
250
|
+
case .localDataWiped:
|
|
251
|
+
ImageLoader.shared.clear()
|
|
252
|
+
case .reconnected:
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// MARK: Presenting (main thread)
|
|
258
|
+
|
|
259
|
+
func present(entry: ChatEntry) {
|
|
260
|
+
// `configure` runs on `queue`. Going through the queue first puts the present after a configure that came before it.
|
|
261
|
+
queue.async { [self] in
|
|
262
|
+
DispatchQueue.main.async { self.presentChat(entry: entry) }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func dismiss() {
|
|
267
|
+
DispatchQueue.main.async { self.dismissChat(emit: true) }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// A model for the embedded view. Nil before `configure`.
|
|
271
|
+
func makeModel(entry: ChatEntry) -> ChatModel? {
|
|
272
|
+
guard let engine = currentEngine else { return nil }
|
|
273
|
+
let model = ChatModel(engine: engine, entry: entry, theme: currentOptions.theme)
|
|
274
|
+
model.emit = { [weak self] event in self?.emit(event) }
|
|
275
|
+
return model
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private func presentChat(entry: ChatEntry, canRetry: Bool = true) {
|
|
279
|
+
guard hosting == nil else {
|
|
280
|
+
if case .conversation = entry { model?.openFromOutside(entry) }
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
// A cold start from a push tap has no window yet. The presenter must be on screen.
|
|
284
|
+
guard let presenter = ChatPresenter.topViewController(), presenter.viewIfLoaded?.window != nil else {
|
|
285
|
+
retryPresent(entry: entry, canRetry: canRetry, reason: "no visible view controller to present from")
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
guard let model = makeModel(entry: entry) else { SDKLog.log(.error, "present called before configure"); return }
|
|
289
|
+
banner?.hide(animated: false)
|
|
290
|
+
self.model = model
|
|
291
|
+
let root = ChatRootView(model: model, onClose: { [weak self] in self?.dismissChat(emit: true) },
|
|
292
|
+
onStatusBarStyle: { [weak self] style in self?.hosting?.statusBarStyle = style })
|
|
293
|
+
let controller = ChatHostingController(rootView: root)
|
|
294
|
+
controller.view.backgroundColor = .clear
|
|
295
|
+
switch currentOptions.theme.mode {
|
|
296
|
+
case .light: controller.overrideUserInterfaceStyle = .light
|
|
297
|
+
case .dark: controller.overrideUserInterfaceStyle = .dark
|
|
298
|
+
case .auto: break
|
|
299
|
+
}
|
|
300
|
+
if presenter.traitCollection.userInterfaceIdiom == .pad {
|
|
301
|
+
// iPad: a form sheet. It works in split view and in every orientation.
|
|
302
|
+
controller.modalPresentationStyle = .formSheet
|
|
303
|
+
controller.preferredContentSize = CGSize(width: 640, height: 720)
|
|
304
|
+
} else {
|
|
305
|
+
controller.modalPresentationStyle = .overFullScreen
|
|
306
|
+
// Over full screen, the host app keeps the status bar unless the chat asks for it.
|
|
307
|
+
controller.modalPresentationCapturesStatusBarAppearance = true
|
|
308
|
+
}
|
|
309
|
+
controller.presentationController?.delegate = self
|
|
310
|
+
hosting = controller
|
|
311
|
+
presenter.present(controller, animated: true)
|
|
312
|
+
guard controller.presentingViewController != nil else {
|
|
313
|
+
// UIKit refused (an alert on top, or another transition). Clean up and do not report an opened chat.
|
|
314
|
+
controller.presentationController?.delegate = nil
|
|
315
|
+
hosting = nil
|
|
316
|
+
model.stop()
|
|
317
|
+
self.model = nil
|
|
318
|
+
retryPresent(entry: entry, canRetry: canRetry, reason: "the presenter refused the chat")
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
emit(.chatOpened)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func retryPresent(entry: ChatEntry, canRetry: Bool, reason: String) {
|
|
325
|
+
guard canRetry else { SDKLog.log(.error, "present failed: \(reason)"); return }
|
|
326
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.presentChat(entry: entry, canRetry: false) }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private func dismissChat(emit shouldEmit: Bool) {
|
|
330
|
+
guard let hosting = hosting else { return }
|
|
331
|
+
self.hosting = nil
|
|
332
|
+
model?.stop()
|
|
333
|
+
model = nil
|
|
334
|
+
hosting.dismiss(animated: true)
|
|
335
|
+
if shouldEmit { emit(.chatClosed) }
|
|
336
|
+
queue.async { [self] in
|
|
337
|
+
Task { await self.startStreamIfPossible() }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
342
|
+
// The customer swiped the form sheet down.
|
|
343
|
+
hosting = nil
|
|
344
|
+
model?.stop()
|
|
345
|
+
model = nil
|
|
346
|
+
emit(.chatClosed)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// MARK: In-app banner
|
|
350
|
+
|
|
351
|
+
private func showBanner(for message: ChatMessage) {
|
|
352
|
+
guard hosting == nil, currentOptions.showsInAppBanner, UIApplication.shared.applicationState == .active, let engine = currentEngine else { return }
|
|
353
|
+
let controller = banner ?? InAppBannerController()
|
|
354
|
+
banner = controller
|
|
355
|
+
let window = ChatPresenter.keyWindow()
|
|
356
|
+
let dark = (window?.traitCollection.userInterfaceStyle ?? .light) == .dark
|
|
357
|
+
let theme = ChatTheme.make(config: engine.cachedConfig, locale: engine.locale(), systemDark: dark, overrides: currentOptions.theme)
|
|
358
|
+
let strings = theme.strings
|
|
359
|
+
var line = message.text.split(separator: "\n").first.map(String.init) ?? ""
|
|
360
|
+
if line.isEmpty { line = message.attachments.isEmpty ? strings.string("thread.newMessage") : strings.string("attachment.photo") }
|
|
361
|
+
if !currentOptions.bannerShowsPreview { line = strings.string("thread.newMessage") }
|
|
362
|
+
let name = message.agentName ?? theme.brandName
|
|
363
|
+
controller.show(name: name, brand: theme.brandName, text: line, avatarURL: message.agentAvatarUrl, theme: theme, in: window) { [weak self] in
|
|
364
|
+
self?.presentChat(entry: .default)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
final class ChatHostingController: UIHostingController<ChatRootView> {
|
|
370
|
+
var statusBarStyle: UIStatusBarStyle = .default {
|
|
371
|
+
didSet { if statusBarStyle != oldValue { setNeedsStatusBarAppearanceUpdate() } }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
override var prefersStatusBarHidden: Bool { false }
|
|
375
|
+
override var preferredStatusBarStyle: UIStatusBarStyle { statusBarStyle }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
enum ChatPresenter {
|
|
379
|
+
static func keyWindow() -> UIWindow? {
|
|
380
|
+
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
381
|
+
let active = scenes.filter { $0.activationState == .foregroundActive }
|
|
382
|
+
let candidates = (active.isEmpty ? scenes : active).flatMap { $0.windows }
|
|
383
|
+
return candidates.first { $0.isKeyWindow } ?? candidates.first
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
static func topViewController() -> UIViewController? {
|
|
387
|
+
var top = keyWindow()?.rootViewController
|
|
388
|
+
while let presented = top?.presentedViewController, !presented.isBeingDismissed { top = presented }
|
|
389
|
+
return top
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
extension ChatModel {
|
|
394
|
+
/// Opens a conversation while the chat is already on screen (a push tap).
|
|
395
|
+
func openFromOutside(_ entry: ChatEntry) {
|
|
396
|
+
if case .conversation(let id) = entry {
|
|
397
|
+
DispatchQueue.main.async { self.open(.thread(id)) }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
func catchUpActiveThread() {
|
|
402
|
+
guard let thread = activeThread else { return }
|
|
403
|
+
Task { await thread.catchUp() }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
#endif
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && canImport(UIKit)
|
|
2
|
+
import Combine
|
|
3
|
+
import Foundation
|
|
4
|
+
import SwiftUI
|
|
5
|
+
import UIKit
|
|
6
|
+
|
|
7
|
+
/// The ZeeChat SDK. Every call is safe from any thread and returns at once.
|
|
8
|
+
/// Calls made before `configure` are ignored and logged when the log level allows.
|
|
9
|
+
public enum ZellioChat {
|
|
10
|
+
/// Starts the SDK. Call once at app launch. A second call with the same key only updates the options.
|
|
11
|
+
public static func configure(appKey: String, options: ZellioChatOptions = ZellioChatOptions()) {
|
|
12
|
+
ZellioChatRuntime.shared.configure(appKey: appKey, options: options)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Links chat to your signed-in customer. The provider returns a fresh identity token from your backend.
|
|
16
|
+
/// The SDK calls it again when the token is rejected.
|
|
17
|
+
public static func identify(tokenProvider: @escaping ZellioChatTokenProvider) {
|
|
18
|
+
ZellioChatRuntime.shared.identify(tokenProvider)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Ends the session, removes the push token on the server and deletes the local chat data.
|
|
22
|
+
public static func logout() { ZellioChatRuntime.shared.logout() }
|
|
23
|
+
|
|
24
|
+
/// Opens chat on the Home screen. Does nothing while chat is open.
|
|
25
|
+
public static func present() { ZellioChatRuntime.shared.present(entry: .default) }
|
|
26
|
+
|
|
27
|
+
/// Opens one conversation. `nil` opens a new one.
|
|
28
|
+
public static func presentConversation(id: String?) { ZellioChatRuntime.shared.present(entry: .conversation(id)) }
|
|
29
|
+
|
|
30
|
+
/// Closes chat if it is open.
|
|
31
|
+
public static func dismiss() { ZellioChatRuntime.shared.dismiss() }
|
|
32
|
+
|
|
33
|
+
/// The screen or item the customer looks at. Agents see it. Pass `nil` to clear.
|
|
34
|
+
public static func setContext(_ context: ZellioChatContext?) { ZellioChatRuntime.shared.setContext(context) }
|
|
35
|
+
|
|
36
|
+
/// The number of unread agent messages, across all conversations.
|
|
37
|
+
public static var unreadCount: Int { ZellioChatRuntime.shared.unreadCount }
|
|
38
|
+
|
|
39
|
+
/// An observable object for SwiftUI badges: `@ObservedObject var unread = ZellioChat.unread`.
|
|
40
|
+
public static var unread: ZellioChatUnread { ZellioChatRuntime.shared.unreadState }
|
|
41
|
+
|
|
42
|
+
/// Calls back on the main thread now and on every change. Returns a token for `removeUnreadListener`.
|
|
43
|
+
@discardableResult
|
|
44
|
+
public static func addUnreadListener(_ listener: @escaping (Int) -> Void) -> Int {
|
|
45
|
+
ZellioChatRuntime.shared.addUnreadListener(listener)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public static func removeUnreadListener(_ token: Int) { ZellioChatRuntime.shared.removeUnreadListener(token) }
|
|
49
|
+
|
|
50
|
+
/// Events on the main thread. Returns a token for `removeEventListener`.
|
|
51
|
+
@discardableResult
|
|
52
|
+
public static func addEventListener(_ listener: @escaping (ZellioChatEvent) -> Void) -> Int {
|
|
53
|
+
ZellioChatRuntime.shared.addEventListener(listener)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public static func removeEventListener(_ token: Int) { ZellioChatRuntime.shared.removeEventListener(token) }
|
|
57
|
+
|
|
58
|
+
/// A stream of events for Swift concurrency.
|
|
59
|
+
public static var events: AsyncStream<ZellioChatEvent> {
|
|
60
|
+
AsyncStream { continuation in
|
|
61
|
+
let token = ZellioChatRuntime.shared.addEventListener { continuation.yield($0) }
|
|
62
|
+
continuation.onTermination = { _ in
|
|
63
|
+
DispatchQueue.main.async { MainActor.assumeIsolated { ZellioChatRuntime.shared.removeEventListener(token) } }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// MARK: Push
|
|
69
|
+
|
|
70
|
+
/// Pass the token from `didRegisterForRemoteNotificationsWithDeviceToken`.
|
|
71
|
+
/// `environment` is "sandbox" or "production". Default: sandbox in debug builds, production otherwise.
|
|
72
|
+
public static func setPushToken(_ deviceToken: Data, environment: String? = nil) {
|
|
73
|
+
ZellioChatRuntime.shared.setPushToken(deviceToken.map { String(format: "%02x", $0) }.joined(), environment: environment)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Same, for a token you already turned into a hex string.
|
|
77
|
+
public static func setPushToken(hex: String, environment: String? = nil) {
|
|
78
|
+
ZellioChatRuntime.shared.setPushToken(hex, environment: environment)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// True when the payload came from Zellio.
|
|
82
|
+
public static func isZellioPush(_ userInfo: [AnyHashable: Any]) -> Bool { PushPayload.isZellioPush(userInfo) }
|
|
83
|
+
|
|
84
|
+
/// Call from `didReceiveRemoteNotification` or `willPresent`. Returns true when the payload is ours.
|
|
85
|
+
@discardableResult
|
|
86
|
+
public static func handlePush(_ userInfo: [AnyHashable: Any]) -> Bool { ZellioChatRuntime.shared.handlePush(userInfo) }
|
|
87
|
+
|
|
88
|
+
/// Call from `didReceive response`. Opens the conversation. Returns true when the payload is ours.
|
|
89
|
+
@discardableResult
|
|
90
|
+
public static func handleNotificationTap(_ userInfo: [AnyHashable: Any]) -> Bool {
|
|
91
|
+
ZellioChatRuntime.shared.handleNotificationTap(userInfo)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// The chat as a SwiftUI view, to put in your own navigation or sheet.
|
|
96
|
+
public struct ZellioChatView: View {
|
|
97
|
+
@StateObject private var holder = Holder()
|
|
98
|
+
private let onClose: () -> Void
|
|
99
|
+
|
|
100
|
+
public init(onClose: @escaping () -> Void = {}) { self.onClose = onClose }
|
|
101
|
+
|
|
102
|
+
public var body: some View {
|
|
103
|
+
Group {
|
|
104
|
+
if let model = holder.model {
|
|
105
|
+
ChatRootView(model: model, onClose: onClose)
|
|
106
|
+
} else {
|
|
107
|
+
Color.clear
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
.onAppear { holder.make() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private final class Holder: ObservableObject {
|
|
114
|
+
@Published var model: ChatModel?
|
|
115
|
+
|
|
116
|
+
func make() {
|
|
117
|
+
guard model == nil else { return }
|
|
118
|
+
model = ZellioChatRuntime.shared.makeModel(entry: .default)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// onDisappear also fires on a tab switch or a push, and the controller would not restart on return.
|
|
122
|
+
// The holder goes when the screen is really gone.
|
|
123
|
+
deinit { model?.stop() }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// The chat as a UIKit view controller, to push or present yourself.
|
|
128
|
+
public final class ZellioChatViewController: UIViewController {
|
|
129
|
+
/// Called when the customer taps close. Default: dismiss this controller.
|
|
130
|
+
public var onClose: (() -> Void)?
|
|
131
|
+
|
|
132
|
+
public override func viewDidLoad() {
|
|
133
|
+
super.viewDidLoad()
|
|
134
|
+
let child = UIHostingController(rootView: ZellioChatView(onClose: { [weak self] in
|
|
135
|
+
guard let self = self else { return }
|
|
136
|
+
if let handler = self.onClose { handler() } else { self.dismiss(animated: true) }
|
|
137
|
+
}))
|
|
138
|
+
addChild(child)
|
|
139
|
+
child.view.frame = view.bounds
|
|
140
|
+
child.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
141
|
+
view.addSubview(child.view)
|
|
142
|
+
child.didMove(toParent: self)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Objective-C facade. Same calls, plain types.
|
|
147
|
+
@objc(ZCZellioChat)
|
|
148
|
+
public final class ZCZellioChat: NSObject {
|
|
149
|
+
/// `locale`: "auto", "ar" or "en". `theme`: "auto", "light" or "dark". Nil `baseURL` uses the default.
|
|
150
|
+
@objc public static func configure(appKey: String, baseURL: URL?, locale: String?, theme: String?, primaryColor: UIColor?, fontName: String?, showsInAppBanner: Bool) {
|
|
151
|
+
var options = ZellioChatOptions(baseURL: baseURL, showsInAppBanner: showsInAppBanner)
|
|
152
|
+
switch locale {
|
|
153
|
+
case "ar"?: options.locale = .arabic
|
|
154
|
+
case "en"?: options.locale = .english
|
|
155
|
+
default: options.locale = .auto
|
|
156
|
+
}
|
|
157
|
+
let mode: ZellioChatThemeMode = theme == "light" ? .light : (theme == "dark" ? .dark : .auto)
|
|
158
|
+
options.theme = ZellioChatTheme(mode: mode, primaryColor: primaryColor, fontName: fontName)
|
|
159
|
+
ZellioChat.configure(appKey: appKey, options: options)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@objc public static func configure(appKey: String) { ZellioChat.configure(appKey: appKey) }
|
|
163
|
+
|
|
164
|
+
/// The block gets a completion. Call it with the token, or with nil and an error.
|
|
165
|
+
@objc public static func identify(tokenProvider: @escaping (@escaping (String?, NSError?) -> Void) -> Void) {
|
|
166
|
+
ZellioChat.identify {
|
|
167
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
168
|
+
tokenProvider { token, error in
|
|
169
|
+
if let token = token {
|
|
170
|
+
continuation.resume(returning: token)
|
|
171
|
+
} else {
|
|
172
|
+
continuation.resume(throwing: error ?? NSError(domain: "ai.zellio.chat", code: 1))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@objc public static func logout() { ZellioChat.logout() }
|
|
180
|
+
@objc public static func present() { ZellioChat.present() }
|
|
181
|
+
@objc public static func presentConversation(id: String?) { ZellioChat.presentConversation(id: id) }
|
|
182
|
+
@objc public static func dismiss() { ZellioChat.dismiss() }
|
|
183
|
+
|
|
184
|
+
@objc public static func setContext(screen: String?, itemType: String?, itemId: String?, itemTitle: String?, url: String?) {
|
|
185
|
+
ZellioChat.setContext(ZellioChatContext(screen: screen, itemType: itemType, itemId: itemId, itemTitle: itemTitle, url: url))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@objc public static func clearContext() { ZellioChat.setContext(nil) }
|
|
189
|
+
@objc public static var unreadCount: Int { ZellioChat.unreadCount }
|
|
190
|
+
|
|
191
|
+
@objc @discardableResult
|
|
192
|
+
public static func addUnreadListener(_ listener: @escaping (Int) -> Void) -> Int { ZellioChat.addUnreadListener(listener) }
|
|
193
|
+
@objc public static func removeUnreadListener(_ token: Int) { ZellioChat.removeUnreadListener(token) }
|
|
194
|
+
|
|
195
|
+
/// The block gets the event name ("chatOpened", "chatClosed", "messageSent", "messageReceived", "error") and an error code or nil.
|
|
196
|
+
@objc @discardableResult
|
|
197
|
+
public static func addEventListener(_ listener: @escaping (String, String?) -> Void) -> Int {
|
|
198
|
+
ZellioChat.addEventListener { listener($0.name, $0.code) }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@objc public static func removeEventListener(_ token: Int) { ZellioChat.removeEventListener(token) }
|
|
202
|
+
|
|
203
|
+
@objc public static func setPushToken(data: Data) { ZellioChat.setPushToken(data) }
|
|
204
|
+
@objc public static func setPushToken(data: Data, environment: String?) { ZellioChat.setPushToken(data, environment: environment) }
|
|
205
|
+
@objc public static func isZellioPush(_ userInfo: [AnyHashable: Any]) -> Bool { ZellioChat.isZellioPush(userInfo) }
|
|
206
|
+
@objc public static func handlePush(_ userInfo: [AnyHashable: Any]) -> Bool { ZellioChat.handlePush(userInfo) }
|
|
207
|
+
@objc public static func handleNotificationTap(_ userInfo: [AnyHashable: Any]) -> Bool { ZellioChat.handleNotificationTap(userInfo) }
|
|
208
|
+
}
|
|
209
|
+
#endif
|