@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,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
|