@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.
Files changed (58) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -2
  3. package/ZellioChatReactNative.podspec +30 -0
  4. package/android/build.gradle +62 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatModule.kt +214 -0
  7. package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatPackage.kt +25 -0
  8. package/app.plugin.js +4 -0
  9. package/ios/RNZellioChat.h +5 -0
  10. package/ios/RNZellioChat.mm +116 -0
  11. package/ios/ZCRNBridge.swift +217 -0
  12. package/ios/vendor/resources/PrivacyInfo.xcprivacy +99 -0
  13. package/ios/vendor/swift/ZellioChat/Model/ChatModel.swift +261 -0
  14. package/ios/vendor/swift/ZellioChat/Platform/Platform.swift +124 -0
  15. package/ios/vendor/swift/ZellioChat/Platform/URLSessionTransport.swift +175 -0
  16. package/ios/vendor/swift/ZellioChat/Preview/Previews.swift +62 -0
  17. package/ios/vendor/swift/ZellioChat/Public/InAppBanner.swift +155 -0
  18. package/ios/vendor/swift/ZellioChat/Public/PublicTypes.swift +141 -0
  19. package/ios/vendor/swift/ZellioChat/Public/Runtime.swift +406 -0
  20. package/ios/vendor/swift/ZellioChat/Public/ZellioChat.swift +209 -0
  21. package/ios/vendor/swift/ZellioChat/Theme/ChatTheme.swift +185 -0
  22. package/ios/vendor/swift/ZellioChat/Thread/AttachmentPicker.swift +354 -0
  23. package/ios/vendor/swift/ZellioChat/Thread/ComposerBar.swift +355 -0
  24. package/ios/vendor/swift/ZellioChat/Thread/ThreadCells.swift +776 -0
  25. package/ios/vendor/swift/ZellioChat/Thread/ThreadViewController.swift +703 -0
  26. package/ios/vendor/swift/ZellioChat/Views/ChatRootView.swift +154 -0
  27. package/ios/vendor/swift/ZellioChat/Views/Components.swift +335 -0
  28. package/ios/vendor/swift/ZellioChat/Views/HomeView.swift +413 -0
  29. package/ios/vendor/swift/ZellioChatCore/ChatModels.swift +373 -0
  30. package/ios/vendor/swift/ZellioChatCore/ConfigModels.swift +389 -0
  31. package/ios/vendor/swift/ZellioChatCore/Engine.swift +325 -0
  32. package/ios/vendor/swift/ZellioChatCore/Errors.swift +158 -0
  33. package/ios/vendor/swift/ZellioChatCore/Generated/ChatStringTable.swift +125 -0
  34. package/ios/vendor/swift/ZellioChatCore/Outbox.swift +223 -0
  35. package/ios/vendor/swift/ZellioChatCore/Push.swift +74 -0
  36. package/ios/vendor/swift/ZellioChatCore/Realtime.swift +328 -0
  37. package/ios/vendor/swift/ZellioChatCore/Session.swift +210 -0
  38. package/ios/vendor/swift/ZellioChatCore/Storage.swift +201 -0
  39. package/ios/vendor/swift/ZellioChatCore/Strings.swift +104 -0
  40. package/ios/vendor/swift/ZellioChatCore/Theme.swift +238 -0
  41. package/ios/vendor/swift/ZellioChatCore/ThreadController.swift +319 -0
  42. package/ios/vendor/swift/ZellioChatCore/Timeline.swift +157 -0
  43. package/ios/vendor/swift/ZellioChatCore/Transport.swift +236 -0
  44. package/ios/vendor/swift/ZellioChatCore/Util.swift +137 -0
  45. package/lib/NativeZellioChat.d.ts +34 -0
  46. package/lib/NativeZellioChat.js +4 -0
  47. package/lib/index.d.ts +37 -0
  48. package/lib/index.js +241 -0
  49. package/lib/types.d.ts +54 -0
  50. package/lib/types.js +2 -0
  51. package/lib/version.d.ts +4 -0
  52. package/lib/version.js +7 -0
  53. package/package.json +75 -4
  54. package/plugin/build/index.js +73 -0
  55. package/src/NativeZellioChat.ts +30 -0
  56. package/src/index.ts +237 -0
  57. package/src/types.ts +56 -0
  58. 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