@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,261 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import Combine
3
+ import Foundation
4
+ import SwiftUI
5
+
6
+ enum ChatRoute: Equatable {
7
+ case loading
8
+ case consent
9
+ case home
10
+ case list
11
+ case thread(String?)
12
+ case unavailable
13
+ case loginNeeded
14
+ case loadFailed
15
+ }
16
+
17
+ enum ChatBanner: Equatable {
18
+ case reconnecting
19
+ case outsideHours(String)
20
+ }
21
+
22
+ /// What the screen asked for when it was presented.
23
+ enum ChatEntry: Equatable {
24
+ case `default`
25
+ case conversation(String?)
26
+ }
27
+
28
+ /// The state behind the SwiftUI screens. Thin: all rules live in ZellioChatCore.
29
+ final class ChatModel: ObservableObject {
30
+ @Published private(set) var route: ChatRoute = .loading
31
+ @Published private(set) var config: ChatConfig?
32
+ @Published private(set) var conversations: [ConversationSummary] = []
33
+ @Published private(set) var locale: ChatLocale = .en
34
+ @Published private(set) var banner: ChatBanner?
35
+ @Published private(set) var isIdentified = false
36
+ @Published var themeOverrides: ZellioChatTheme
37
+
38
+ let engine: ChatEngine
39
+ private let entry: ChatEntry
40
+ private var history: [ChatRoute] = []
41
+ private var offlineTimer: Task<Void, Never>?
42
+ private var isOffline = false
43
+ private var started = false
44
+ private(set) var activeThread: ThreadController?
45
+
46
+ var onClose: () -> Void = {}
47
+ var emit: (ZellioChatEvent) -> Void = { _ in }
48
+
49
+ init(engine: ChatEngine, entry: ChatEntry, theme: ZellioChatTheme) {
50
+ self.engine = engine
51
+ self.entry = entry
52
+ self.themeOverrides = theme
53
+ self.locale = engine.locale()
54
+ self.config = engine.cachedConfig
55
+ if let saved = engine.cachedConversations() { self.conversations = saved.conversations }
56
+ self.route = ChatModel.initialRoute(config: engine.cachedConfig, entry: entry)
57
+ if case .thread(let id) = route { activeThread = engine.makeThread(conversationId: id) }
58
+ }
59
+
60
+ /// A model for SwiftUI previews and tests. It never touches the network.
61
+ static func preview(config: ChatConfig?, conversations: [ConversationSummary] = [], route: ChatRoute, locale: ChatLocale, banner: ChatBanner? = nil) -> ChatModel {
62
+ let transport = PreviewTransport()
63
+ let engine = ChatEngine(options: EngineOptions(baseURL: URL(string: "https://preview.invalid") ?? URL(fileURLWithPath: "/"), appKey: "preview", localeOption: locale),
64
+ transport: transport, secrets: MemorySecretStore(), storage: MemoryStorage())
65
+ let model = ChatModel(engine: engine, entry: .default, theme: ZellioChatTheme())
66
+ model.config = config
67
+ model.conversations = conversations
68
+ model.route = route
69
+ model.locale = locale
70
+ model.banner = banner
71
+ model.started = true
72
+ return model
73
+ }
74
+
75
+ private static func initialRoute(config: ChatConfig?, entry: ChatEntry) -> ChatRoute {
76
+ guard let config = config else { return .loading }
77
+ if case .conversation(let id) = entry { return .thread(id) }
78
+ return config.snapshot.layout == .chatOnly ? .thread(nil) : .home
79
+ }
80
+
81
+ var theme: ChatTheme {
82
+ ChatTheme.make(config: config, locale: locale, systemDark: false, overrides: themeOverrides)
83
+ }
84
+
85
+ func theme(systemDark: Bool) -> ChatTheme {
86
+ ChatTheme.make(config: config, locale: locale, systemDark: systemDark, overrides: themeOverrides)
87
+ }
88
+
89
+ var isChatOnly: Bool { config?.snapshot.layout == .chatOnly }
90
+ var canGoBack: Bool { !history.isEmpty }
91
+ var allowsAttachments: Bool { config?.snapshot.behavior.allowAttachments ?? true }
92
+
93
+ // MARK: Loading
94
+
95
+ @MainActor
96
+ func start() async {
97
+ guard !started else { return }
98
+ started = true
99
+ await loadConfigAndContinue()
100
+ }
101
+
102
+ @MainActor
103
+ func retry() async {
104
+ route = config == nil ? .loading : route
105
+ await loadConfigAndContinue()
106
+ }
107
+
108
+ @MainActor
109
+ private func loadConfigAndContinue() async {
110
+ do {
111
+ let fresh = try await engine.refreshConfig()
112
+ config = fresh
113
+ locale = engine.locale()
114
+ } catch let error as ChatError {
115
+ if error == .unavailable { route = .unavailable; emit(.error(code: error.eventCode)); return }
116
+ if config == nil { route = .loadFailed; emit(.error(code: error.eventCode)); return }
117
+ } catch {
118
+ if config == nil { route = .loadFailed; return }
119
+ }
120
+ guard let config = config else { route = .loadFailed; return }
121
+ if ConsentGate.state(config: config, store: engine.consentStore) == .required {
122
+ route = .consent
123
+ return
124
+ }
125
+ await proceedAfterConsent()
126
+ }
127
+
128
+ @MainActor
129
+ func acceptConsent() async {
130
+ engine.acceptConsent()
131
+ await proceedAfterConsent()
132
+ }
133
+
134
+ @MainActor
135
+ private func proceedAfterConsent() async {
136
+ guard let config = config else { return }
137
+ locale = engine.locale()
138
+ let target = ChatModel.initialRoute(config: config, entry: entry)
139
+ if case .thread(let id) = target {
140
+ if activeThread == nil { activeThread = engine.makeThread(conversationId: id) }
141
+ route = target
142
+ await engine.startRealtime()
143
+ await refreshIdentity()
144
+ updateBanner()
145
+ return
146
+ }
147
+ route = target
148
+ await reloadConversations()
149
+ await engine.startRealtime()
150
+ await refreshIdentity()
151
+ updateBanner()
152
+ }
153
+
154
+ @MainActor
155
+ private func refreshIdentity() async {
156
+ let identified = await engine.session.isIdentified
157
+ if identified != isIdentified { isIdentified = identified }
158
+ }
159
+
160
+ @MainActor
161
+ func reloadConversations() async {
162
+ do {
163
+ let page = try await engine.loadConversations()
164
+ conversations = page.conversations
165
+ await refreshIdentity()
166
+ } catch let error as ChatError {
167
+ handle(error)
168
+ } catch {
169
+ // Keep the saved list.
170
+ }
171
+ }
172
+
173
+ @MainActor
174
+ private func handle(_ error: ChatError) {
175
+ switch error {
176
+ case .unavailable:
177
+ route = .unavailable
178
+ case .loginNeeded:
179
+ route = .loginNeeded
180
+ default:
181
+ break
182
+ }
183
+ emit(.error(code: error.eventCode))
184
+ }
185
+
186
+ // MARK: Navigation
187
+
188
+ @MainActor
189
+ func open(_ next: ChatRoute) {
190
+ if next == route { return }
191
+ history.append(route)
192
+ if case .thread(let id) = next {
193
+ activeThread = engine.makeThread(conversationId: id)
194
+ }
195
+ route = next
196
+ }
197
+
198
+ @MainActor
199
+ func goBack() {
200
+ guard let previous = history.popLast() else { return }
201
+ // The thread that was on screen may be another one now (a push tap opened it). Make the earlier one again.
202
+ if case .thread(let id) = previous { activeThread = engine.makeThread(conversationId: id) }
203
+ route = previous
204
+ Task { await reloadConversations() }
205
+ }
206
+
207
+ @MainActor
208
+ func openNewConversation() {
209
+ // The server decides which conversation a message goes to. A new thread starts empty.
210
+ open(.thread(nil))
211
+ }
212
+
213
+ // MARK: Network banner
214
+
215
+ func networkChanged(online: Bool) {
216
+ DispatchQueue.main.async { [weak self] in
217
+ guard let self = self else { return }
218
+ self.isOffline = !online
219
+ self.offlineTimer?.cancel()
220
+ if online {
221
+ self.updateBanner()
222
+ } else {
223
+ // "Reconnecting" shows after 3 seconds offline.
224
+ self.offlineTimer = Task { [weak self] in
225
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
226
+ if Task.isCancelled { return }
227
+ await MainActor.run { self?.updateBanner() }
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ @MainActor
234
+ private func updateBanner() {
235
+ if isOffline {
236
+ banner = .reconnecting
237
+ } else if let config = config, !config.online {
238
+ banner = .outsideHours(config.snapshot.screens.offline.resolve(locale) ?? ChatStrings(locale: locale).string("banner.offline"))
239
+ } else {
240
+ banner = nil
241
+ }
242
+ }
243
+
244
+ @MainActor
245
+ func refreshBanner() { updateBanner() }
246
+
247
+ func stop() {
248
+ offlineTimer?.cancel()
249
+ if let thread = activeThread { Task { await thread.stop() } }
250
+ }
251
+ }
252
+
253
+ /// Never reaches a network. For previews.
254
+ final class PreviewTransport: HTTPTransport, @unchecked Sendable {
255
+ func send(_ request: HTTPRequest) async throws -> HTTPResponse { throw ChatAPIError.network }
256
+ func upload(_ request: HTTPRequest, progress: @escaping @Sendable (Double) -> Void) async throws -> HTTPResponse { throw ChatAPIError.network }
257
+ func stream(_ request: HTTPRequest) -> AsyncThrowingStream<StreamChunk, Error> {
258
+ AsyncThrowingStream { $0.finish(throwing: ChatAPIError.network) }
259
+ }
260
+ }
261
+ #endif
@@ -0,0 +1,124 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import Foundation
3
+ import Network
4
+ import Security
5
+ import UIKit
6
+ import AudioToolbox
7
+
8
+ /// Session token store in the Keychain. Accessible after first unlock, never synced or restored to another device.
9
+ final class KeychainSecretStore: SecretStore, @unchecked Sendable {
10
+ private let service: String
11
+
12
+ init(service: String = "ai.zellio.chat") { self.service = service }
13
+
14
+ private func baseQuery(_ key: String) -> [String: Any] {
15
+ [
16
+ kSecClass as String: kSecClassGenericPassword,
17
+ kSecAttrService as String: service,
18
+ kSecAttrAccount as String: key,
19
+ ]
20
+ }
21
+
22
+ func read(_ key: String) -> String? {
23
+ var query = baseQuery(key)
24
+ query[kSecReturnData as String] = true
25
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
26
+ var result: AnyObject?
27
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
28
+ guard status == errSecSuccess, let data = result as? Data else { return nil }
29
+ return String(data: data, encoding: .utf8)
30
+ }
31
+
32
+ func write(_ key: String, value: String) {
33
+ let data = Data(value.utf8)
34
+ let update: [String: Any] = [kSecValueData as String: data]
35
+ let status = SecItemUpdate(baseQuery(key) as CFDictionary, update as CFDictionary)
36
+ if status == errSecItemNotFound {
37
+ var add = baseQuery(key)
38
+ add[kSecValueData as String] = data
39
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
40
+ _ = SecItemAdd(add as CFDictionary, nil)
41
+ }
42
+ }
43
+
44
+ func delete(_ key: String) {
45
+ _ = SecItemDelete(baseQuery(key) as CFDictionary)
46
+ }
47
+ }
48
+
49
+ enum AppleStorage {
50
+ /// Application Support/ZellioChat/<appKey hash>. Excluded from backups.
51
+ static func make(appKey: String) -> FileStorage {
52
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
53
+ ?? URL(fileURLWithPath: NSTemporaryDirectory())
54
+ let safeKey = String(appKey.map { $0.isLetter || $0.isNumber ? $0 : "_" }.prefix(48))
55
+ var directory = base.appendingPathComponent("ZellioChat", isDirectory: true).appendingPathComponent(safeKey, isDirectory: true)
56
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
57
+ var values = URLResourceValues()
58
+ values.isExcludedFromBackup = true
59
+ try? directory.setResourceValues(values)
60
+ // The parent folder too, so no other file of ours is backed up.
61
+ var parent = directory.deletingLastPathComponent()
62
+ try? parent.setResourceValues(values)
63
+ return FileStorage(directory: directory)
64
+ }
65
+ }
66
+
67
+ /// Reports whether the network is reachable.
68
+ final class NetworkMonitor: @unchecked Sendable {
69
+ private let monitor = NWPathMonitor()
70
+ private let queue = DispatchQueue(label: "ai.zellio.chat.network")
71
+ private let lock = NSLock()
72
+ private var online = true
73
+ var onChange: (@Sendable (Bool) -> Void)?
74
+
75
+ var isOnline: Bool { lock.lock(); defer { lock.unlock() }; return online }
76
+
77
+ func start() {
78
+ monitor.pathUpdateHandler = { [weak self] path in
79
+ guard let self = self else { return }
80
+ let now = path.status == .satisfied
81
+ self.lock.lock()
82
+ let changed = self.online != now
83
+ self.online = now
84
+ self.lock.unlock()
85
+ if changed { self.onChange?(now) }
86
+ }
87
+ monitor.start(queue: queue)
88
+ }
89
+
90
+ func stop() { monitor.cancel() }
91
+ }
92
+
93
+ enum Haptics {
94
+ /// A light tap on send.
95
+ static func sendTap() {
96
+ DispatchQueue.main.async { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
97
+ }
98
+
99
+ /// A soft tick on a new message while chat is open.
100
+ static func incomingTick() {
101
+ DispatchQueue.main.async { UIImpactFeedbackGenerator(style: .soft).impactOccurred() }
102
+ }
103
+
104
+ /// A notice on a failed send.
105
+ static func failure() {
106
+ DispatchQueue.main.async { UINotificationFeedbackGenerator().notificationOccurred(.error) }
107
+ }
108
+
109
+ /// Only when the brand turned sound on. System sounds follow the ring switch.
110
+ static func incomingSound() {
111
+ AudioServicesPlaySystemSound(1007)
112
+ }
113
+ }
114
+
115
+ enum SDKLog {
116
+ static var level: ZellioChatLogLevel = .error
117
+
118
+ /// Never pass message text, tokens or identities here.
119
+ static func log(_ level: ZellioChatLogLevel, _ message: @autoclosure () -> String) {
120
+ guard level.rawValue <= SDKLog.level.rawValue, level != .none else { return }
121
+ NSLog("[ZellioChat] %@", message())
122
+ }
123
+ }
124
+ #endif
@@ -0,0 +1,175 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import Foundation
3
+
4
+ /// The real network layer. URLSession only. No third-party code.
5
+ ///
6
+ /// Timeouts: connect and idle reads use the request timeout (30 s). Uploads use 120 s.
7
+ /// The SSE stream has no read timeout here; `RealtimeClient` treats 45 s without bytes as dead.
8
+ final class URLSessionTransport: HTTPTransport, @unchecked Sendable {
9
+ private let session: URLSession
10
+
11
+ init() {
12
+ let configuration = URLSessionConfiguration.default
13
+ configuration.waitsForConnectivity = false
14
+ configuration.httpCookieStorage = nil
15
+ configuration.urlCache = nil
16
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
17
+ session = URLSession(configuration: configuration)
18
+ }
19
+
20
+ private static func urlRequest(_ request: HTTPRequest) -> URLRequest {
21
+ var result = URLRequest(url: request.url)
22
+ result.httpMethod = request.method
23
+ result.timeoutInterval = request.timeout > 0 ? request.timeout : 60 * 60 * 24
24
+ for (key, value) in request.headers { result.setValue(value, forHTTPHeaderField: key) }
25
+ result.httpBody = request.body
26
+ return result
27
+ }
28
+
29
+ static func headers(_ response: HTTPURLResponse) -> [String: String] {
30
+ var result: [String: String] = [:]
31
+ for (key, value) in response.allHeaderFields {
32
+ if let key = key as? String, let value = value as? String { result[key] = value }
33
+ }
34
+ return result
35
+ }
36
+
37
+ static func map(_ error: Error) -> Error {
38
+ if let urlError = error as? URLError {
39
+ switch urlError.code {
40
+ case .cancelled: return CancellationError()
41
+ case .timedOut: return ChatAPIError.timeout
42
+ default: return ChatAPIError.network
43
+ }
44
+ }
45
+ if error is CancellationError { return error }
46
+ return ChatAPIError.network
47
+ }
48
+
49
+ func send(_ request: HTTPRequest) async throws -> HTTPResponse {
50
+ do {
51
+ let (data, response) = try await session.data(for: URLSessionTransport.urlRequest(request))
52
+ guard let http = response as? HTTPURLResponse else { throw ChatAPIError.network }
53
+ return HTTPResponse(status: http.statusCode, headers: URLSessionTransport.headers(http), body: data)
54
+ } catch {
55
+ throw URLSessionTransport.map(error)
56
+ }
57
+ }
58
+
59
+ func upload(_ request: HTTPRequest, progress: @escaping @Sendable (Double) -> Void) async throws -> HTTPResponse {
60
+ let box = UploadBox(progress: progress)
61
+ let uploadSession = URLSession(configuration: session.configuration, delegate: box, delegateQueue: nil)
62
+ defer { uploadSession.finishTasksAndInvalidate() }
63
+ var urlRequest = URLSessionTransport.urlRequest(request)
64
+ let body = urlRequest.httpBody ?? Data()
65
+ urlRequest.httpBody = nil
66
+ return try await withTaskCancellationHandler {
67
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HTTPResponse, Error>) in
68
+ let task = uploadSession.uploadTask(with: urlRequest, from: body) { data, response, error in
69
+ if let error = error {
70
+ continuation.resume(throwing: URLSessionTransport.map(error))
71
+ } else if let http = response as? HTTPURLResponse {
72
+ continuation.resume(returning: HTTPResponse(status: http.statusCode, headers: URLSessionTransport.headers(http), body: data ?? Data()))
73
+ } else {
74
+ continuation.resume(throwing: ChatAPIError.network)
75
+ }
76
+ }
77
+ box.task = task
78
+ task.resume()
79
+ }
80
+ } onCancel: {
81
+ box.cancel()
82
+ }
83
+ }
84
+
85
+ func stream(_ request: HTTPRequest) -> AsyncThrowingStream<StreamChunk, Error> {
86
+ AsyncThrowingStream { continuation in
87
+ let delegate = StreamDelegate(continuation: continuation)
88
+ let configuration = self.session.configuration
89
+ configuration.timeoutIntervalForRequest = 60 * 60 * 24
90
+ configuration.timeoutIntervalForResource = 60 * 60 * 24
91
+ let streamSession = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
92
+ let task = streamSession.dataTask(with: URLSessionTransport.urlRequest(request))
93
+ continuation.onTermination = { _ in
94
+ task.cancel()
95
+ streamSession.invalidateAndCancel()
96
+ }
97
+ task.resume()
98
+ }
99
+ }
100
+ }
101
+
102
+ private final class UploadBox: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
103
+ private let progress: @Sendable (Double) -> Void
104
+ private let lock = NSLock()
105
+ private var current: URLSessionTask?
106
+ private var cancelled = false
107
+
108
+ init(progress: @escaping @Sendable (Double) -> Void) { self.progress = progress }
109
+
110
+ var task: URLSessionTask? {
111
+ get { lock.lock(); defer { lock.unlock() }; return current }
112
+ set {
113
+ lock.lock()
114
+ current = newValue
115
+ let shouldCancel = cancelled
116
+ lock.unlock()
117
+ if shouldCancel { newValue?.cancel() }
118
+ }
119
+ }
120
+
121
+ func cancel() {
122
+ lock.lock()
123
+ cancelled = true
124
+ let running = current
125
+ lock.unlock()
126
+ running?.cancel()
127
+ }
128
+
129
+ func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
130
+ guard totalBytesExpectedToSend > 0 else { return }
131
+ progress(min(1, Double(totalBytesSent) / Double(totalBytesExpectedToSend)))
132
+ }
133
+ }
134
+
135
+ private final class StreamDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable {
136
+ private let continuation: AsyncThrowingStream<StreamChunk, Error>.Continuation
137
+ private var status = 0
138
+ private var headers: [String: String] = [:]
139
+ private var errorBody = Data()
140
+
141
+ init(continuation: AsyncThrowingStream<StreamChunk, Error>.Continuation) { self.continuation = continuation }
142
+
143
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
144
+ if let http = response as? HTTPURLResponse {
145
+ status = http.statusCode
146
+ headers = URLSessionTransport.headers(http)
147
+ }
148
+ if (200..<300).contains(status) { continuation.yield(.opened) }
149
+ completionHandler(.allow)
150
+ }
151
+
152
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
153
+ if (200..<300).contains(status) {
154
+ continuation.yield(.data(data))
155
+ } else if errorBody.count < 16_384 {
156
+ errorBody.append(data)
157
+ }
158
+ }
159
+
160
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
161
+ if !(200..<300).contains(status), status != 0 {
162
+ let retry = headers.first { $0.key.lowercased() == "retry-after" }.flatMap { TimeInterval($0.value) }
163
+ continuation.finish(throwing: ChatAPIError.http(status: status, code: ErrorPolicy.parseCode(from: errorBody), retryAfter: retry))
164
+ } else if let error = error {
165
+ if let urlError = error as? URLError, urlError.code == .cancelled {
166
+ continuation.finish()
167
+ } else {
168
+ continuation.finish(throwing: ChatAPIError.network)
169
+ }
170
+ } else {
171
+ continuation.finish()
172
+ }
173
+ }
174
+ }
175
+ #endif
@@ -0,0 +1,62 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit) && DEBUG
2
+ import SwiftUI
3
+
4
+ /// Sample data for the Xcode previews. The text matches the contract fixtures.
5
+ enum PreviewData {
6
+ static let configJSON = """
7
+ {"data":{"protocol":"studio-v1","snapshot":{"schemaVersion":1,"siteId":"ws_1~site_1","versionId":"ver_1","layout":"support",
8
+ "language":{"mode":"both","defaultLocale":"en","startIn":"browser"},
9
+ "appearance":{"brandName":"Nile Store","logoFileId":"file_1","logoAlt":{"en":"Nile Store","ar":"متجر النيل"},"primaryColor":"#7D3BED","customerMessageColor":"#7D3BED","surfaceColor":"#FFFFFF","theme":"auto","density":"comfortable","corners":"rounded","position":"right","desktopOffset":20,"mobileOffset":16,"launcherSize":56,"launcherLabel":{},"showAgentAvatar":true,"soundEnabled":false,"unreadBadge":true,"showAttribution":true,"launcherIcon":"bubbles","launcherShape":"circle","headerStyle":"gradient","mirrorInRtl":true,"welcomeBubble":{"enabled":false,"text":{},"delaySeconds":5}},
10
+ "modules":[{"id":"m1","type":"start-conversation","label":{"en":"Talk to our team","ar":"كلّم فريقنا"},"visibility":{},"primary":true},
11
+ {"id":"m2","type":"announcement","label":{"en":"Eid delivery","ar":"توصيل العيد"},"description":{"en":"Orders placed today arrive on Sunday.","ar":"الطلبات النهاردة توصل يوم الحد."},"visibility":{}},
12
+ {"id":"m3","type":"external-link","label":{"en":"Track my order","ar":"تتبّع طلبي"},"href":"https://shop.example/track","visibility":{}}],
13
+ "screens":{"heading":{"en":"How can we help?","ar":"إزاي نقدر نساعدك؟"},"greeting":{"en":"Hi! Tell us what you need help with.","ar":"أهلاً! قولّنا محتاج إيه."},"description":{"en":"We usually reply in a few minutes.","ar":"غالباً بنرد في دقايق."},"offline":{"en":"We are away.","ar":"إحنا مش متاحين دلوقتي."}},
14
+ "behavior":{"allowAttachments":true,"inheritHours":true,"replyExpectation":{"en":"Usually within minutes","ar":"غالباً في دقايق"},"consentRequired":true,"consentText":{"en":"We store your messages and share them with our support team.","ar":"هنحفظ رسايلك ونشاركها مع فريق الدعم."},"privacyUrl":"https://shop.example/privacy"}},
15
+ "assets":{"logoUrl":null},"availability":{"online":true},"app":{"allowGuests":true,"minSdkVersion":null}}}
16
+ """
17
+
18
+ static let conversationsJSON = """
19
+ {"data":{"conversations":[
20
+ {"id":"conv_2","status":"open","lastMessageAt":"2026-11-03T10:00:00.000Z","lastMessage":{"from":"agent","text":"Your order is on the way.","hasAttachment":false},"agentName":"Mona","agentAvatarUrl":null,"unread":1},
21
+ {"id":"conv_1","status":"resolved","lastMessageAt":"2026-10-20T08:30:00.000Z","lastMessage":{"from":"visitor","text":"","hasAttachment":true},"agentName":null,"agentAvatarUrl":null,"unread":0}],
22
+ "nextCursor":null,"unreadTotal":1}}
23
+ """
24
+
25
+ static var config: ChatConfig? { try? ChatConfig.parse(Data(configJSON.utf8)) }
26
+
27
+ static var conversations: [ConversationSummary] {
28
+ guard let root = (try? JSONSerialization.jsonObject(with: Data(conversationsJSON.utf8))) as? [String: Any],
29
+ let inner = root["data"],
30
+ let data = try? JSONSerialization.data(withJSONObject: inner),
31
+ let page = try? JSONDecoder().decode(ConversationsPage.self, from: data) else { return [] }
32
+ return page.conversations
33
+ }
34
+
35
+ static func model(_ route: ChatRoute, locale: ChatLocale, banner: ChatBanner? = nil) -> ChatModel {
36
+ ChatModel.preview(config: config, conversations: conversations, route: route, locale: locale, banner: banner)
37
+ }
38
+ }
39
+
40
+ private struct ScreenPreview: View {
41
+ let route: ChatRoute
42
+ let locale: ChatLocale
43
+ let dark: Bool
44
+
45
+ var body: some View {
46
+ ChatRootView(model: PreviewData.model(route, locale: locale), onClose: {})
47
+ .preferredColorScheme(dark ? .dark : .light)
48
+ }
49
+ }
50
+
51
+ struct ChatScreens_Previews: PreviewProvider {
52
+ static var previews: some View {
53
+ Group {
54
+ ForEach(Array([ChatRoute.home, .list, .thread(nil), .consent, .loadFailed].enumerated()), id: \.offset) { _, route in
55
+ ScreenPreview(route: route, locale: .en, dark: false).previewDisplayName("\(String(describing: route)) light")
56
+ ScreenPreview(route: route, locale: .en, dark: true).previewDisplayName("\(String(describing: route)) dark")
57
+ ScreenPreview(route: route, locale: .ar, dark: false).previewDisplayName("\(String(describing: route)) arabic")
58
+ }
59
+ }
60
+ }
61
+ }
62
+ #endif