@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,325 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct EngineOptions: Sendable {
|
|
4
|
+
public var baseURL: URL
|
|
5
|
+
public var appKey: String
|
|
6
|
+
/// nil follows the device.
|
|
7
|
+
public var localeOption: ChatLocale?
|
|
8
|
+
public var deviceLanguageCode: String?
|
|
9
|
+
public var wrapper: String?
|
|
10
|
+
public var appVersion: String?
|
|
11
|
+
|
|
12
|
+
public init(baseURL: URL, appKey: String, localeOption: ChatLocale? = nil, deviceLanguageCode: String? = nil, wrapper: String? = nil, appVersion: String? = nil) {
|
|
13
|
+
self.baseURL = baseURL
|
|
14
|
+
self.appKey = appKey
|
|
15
|
+
self.localeOption = localeOption
|
|
16
|
+
self.deviceLanguageCode = deviceLanguageCode
|
|
17
|
+
self.wrapper = wrapper
|
|
18
|
+
self.appVersion = appVersion
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public enum HubEvent: Sendable {
|
|
23
|
+
case realtime(RealtimeEvent)
|
|
24
|
+
case reconnected
|
|
25
|
+
case outbox(Outbox.Update)
|
|
26
|
+
/// Logout or a customer switch removed the local data. Caches outside the storage must go too.
|
|
27
|
+
case localDataWiped
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Ties the parts together: config, consent, session, conversations, outbox, stream, push, logout.
|
|
31
|
+
/// Every method is safe from any thread. Nothing here blocks the caller.
|
|
32
|
+
public final class ChatEngine: @unchecked Sendable {
|
|
33
|
+
public let options: EngineOptions
|
|
34
|
+
public let api: ChatAPI
|
|
35
|
+
public let storage: StorageBackend
|
|
36
|
+
public let secrets: SecretStore
|
|
37
|
+
public let clock: ChatClock
|
|
38
|
+
public let installId: String
|
|
39
|
+
public let queue: OfflineQueue
|
|
40
|
+
public let outbox: Outbox
|
|
41
|
+
public let session: SessionCoordinator
|
|
42
|
+
public let unread = UnreadTracker()
|
|
43
|
+
public let consentStore: ConsentStore
|
|
44
|
+
public let drafts: DraftStore
|
|
45
|
+
private let configCache: ConfigCache
|
|
46
|
+
|
|
47
|
+
private struct State {
|
|
48
|
+
var config: ChatConfig?
|
|
49
|
+
var freshConfig = false
|
|
50
|
+
var context: ChatContext?
|
|
51
|
+
var localeOption: ChatLocale?
|
|
52
|
+
var listeners: [Int: @Sendable (HubEvent) -> Void] = [:]
|
|
53
|
+
var nextListener = 0
|
|
54
|
+
var streamConversationId: String?
|
|
55
|
+
var realtime: RealtimeClient?
|
|
56
|
+
var pushToken: PushRegistration?
|
|
57
|
+
}
|
|
58
|
+
private let state = Locked(State())
|
|
59
|
+
|
|
60
|
+
public struct PushRegistration: Codable, Equatable, Sendable {
|
|
61
|
+
public var token: String
|
|
62
|
+
public var environment: String?
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public init(options: EngineOptions, transport: HTTPTransport, secrets: SecretStore, storage: StorageBackend, clock: ChatClock = SystemClock(), random: RandomSource = SystemRandom()) {
|
|
66
|
+
let install = InstallId.load(storage)
|
|
67
|
+
let api = ChatAPI(config: ChatAPIConfig(baseURL: options.baseURL, appKey: options.appKey, wrapper: options.wrapper), transport: transport)
|
|
68
|
+
let queue = OfflineQueue(storage: storage, clock: clock)
|
|
69
|
+
let box = Locked<ChatEngine?>(nil)
|
|
70
|
+
let sessionRef = Locked<SessionCoordinator?>(nil)
|
|
71
|
+
let outbox = Outbox(queue: queue, clock: clock, random: random) { item in
|
|
72
|
+
guard let coordinator = sessionRef.snapshot else { throw ChatError.network }
|
|
73
|
+
return try await coordinator.perform { token in
|
|
74
|
+
try await api.sendMessage(text: item.text, clientMessageId: item.clientMessageId, attachmentIds: item.attachmentIds, context: item.context, token: token)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let session = SessionCoordinator(
|
|
78
|
+
api: api, secrets: secrets, storage: storage, clock: clock, installId: install,
|
|
79
|
+
locale: { box.snapshot?.locale() ?? .en },
|
|
80
|
+
allowed: { try await box.snapshot?.requireSessionAllowed() },
|
|
81
|
+
onWipe: { box.snapshot?.didWipe() },
|
|
82
|
+
onSessionStarted: {
|
|
83
|
+
guard let engine = box.snapshot else { return }
|
|
84
|
+
Task { await engine.registerPushIfPossible() }
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
self.options = options
|
|
88
|
+
self.storage = storage
|
|
89
|
+
self.secrets = secrets
|
|
90
|
+
self.clock = clock
|
|
91
|
+
self.installId = install
|
|
92
|
+
self.api = api
|
|
93
|
+
self.queue = queue
|
|
94
|
+
self.outbox = outbox
|
|
95
|
+
self.session = session
|
|
96
|
+
self.consentStore = ConsentStore(storage: storage)
|
|
97
|
+
self.drafts = DraftStore(storage: storage)
|
|
98
|
+
self.configCache = ConfigCache(storage: storage)
|
|
99
|
+
sessionRef.with { $0 = session }
|
|
100
|
+
box.with { $0 = self }
|
|
101
|
+
state.with { s in
|
|
102
|
+
s.localeOption = options.localeOption
|
|
103
|
+
s.config = self.configCache.load()
|
|
104
|
+
s.pushToken = storage.load(PushRegistration.self, StorageName.push)
|
|
105
|
+
}
|
|
106
|
+
let engine = self
|
|
107
|
+
Task { await engine.outbox.setListener { [weak engine] update in engine?.emit(.outbox(update)) } }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: Config, locale, consent
|
|
111
|
+
|
|
112
|
+
public var cachedConfig: ChatConfig? { state.with { $0.config } }
|
|
113
|
+
public var isConfigFresh: Bool { state.with { $0.freshConfig } }
|
|
114
|
+
|
|
115
|
+
/// Reads the config again. On success the new one is saved. Offline keeps the saved one.
|
|
116
|
+
@discardableResult
|
|
117
|
+
public func refreshConfig() async throws -> ChatConfig {
|
|
118
|
+
do {
|
|
119
|
+
let body = try await api.fetchConfigBody()
|
|
120
|
+
let config = try ChatConfig.parse(body)
|
|
121
|
+
if config.sdkTooOld { throw ChatError.unavailable }
|
|
122
|
+
configCache.save(body: body)
|
|
123
|
+
state.with { s in
|
|
124
|
+
s.config = config
|
|
125
|
+
s.freshConfig = true
|
|
126
|
+
}
|
|
127
|
+
return config
|
|
128
|
+
} catch {
|
|
129
|
+
throw ErrorPolicy.map(error)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public func locale() -> ChatLocale {
|
|
134
|
+
let (config, option) = state.with { ($0.config, $0.localeOption) }
|
|
135
|
+
return LocaleChoice.choose(option: option, policy: config?.snapshot.language, deviceLanguageCode: options.deviceLanguageCode)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public func setLocaleOption(_ locale: ChatLocale?) { state.with { $0.localeOption = locale } }
|
|
139
|
+
|
|
140
|
+
public func consentState() -> ConsentState? {
|
|
141
|
+
guard let config = cachedConfig else { return nil }
|
|
142
|
+
return ConsentGate.state(config: config, store: consentStore)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public func acceptConsent() {
|
|
146
|
+
guard let config = cachedConfig else { return }
|
|
147
|
+
consentStore.accept(ConsentTerms(behavior: config.snapshot.behavior))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// A session is never started from a saved config alone: the config is read fresh first, then consent is checked.
|
|
151
|
+
func requireSessionAllowed() async throws {
|
|
152
|
+
if !isConfigFresh { try await refreshConfig() }
|
|
153
|
+
guard let config = cachedConfig else { throw ChatError.network }
|
|
154
|
+
if config.sdkTooOld { throw ChatError.unavailable }
|
|
155
|
+
if !ConsentGate.allowsSession(config: config, store: consentStore) { throw ChatError.consentRequired }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func didWipe() {
|
|
159
|
+
unread.set(0)
|
|
160
|
+
state.with { s in
|
|
161
|
+
s.streamConversationId = nil
|
|
162
|
+
}
|
|
163
|
+
emit(.localDataWiped)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// MARK: Context
|
|
167
|
+
|
|
168
|
+
public func setContext(_ context: ChatContext?) { state.with { $0.context = context?.sanitized() } }
|
|
169
|
+
public var context: ChatContext? { state.with { $0.context } }
|
|
170
|
+
|
|
171
|
+
// MARK: Identity
|
|
172
|
+
|
|
173
|
+
public func identify(_ provider: @escaping IdentityTokenProvider) {
|
|
174
|
+
let engine = self
|
|
175
|
+
Task {
|
|
176
|
+
await engine.session.setProvider(provider)
|
|
177
|
+
// Only when a session exists already. Otherwise the token is used when the session is made.
|
|
178
|
+
try? await engine.session.identifyCurrentSession()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public func logout() async {
|
|
183
|
+
await stopRealtime()
|
|
184
|
+
await outbox.cancelAll()
|
|
185
|
+
// Tell the server which push token to forget, then end the session. Local data goes in both cases.
|
|
186
|
+
if let token = await session.token { try? await api.deleteDevice(token: token) }
|
|
187
|
+
await session.logout()
|
|
188
|
+
queue.clear()
|
|
189
|
+
state.with { s in
|
|
190
|
+
// The device token stays: the next session on this phone registers it again.
|
|
191
|
+
s.context = nil
|
|
192
|
+
}
|
|
193
|
+
unread.set(0)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MARK: Conversations, unread
|
|
197
|
+
|
|
198
|
+
public func cachedConversations() -> ConversationsPage? { storage.load(ConversationsPage.self, StorageName.conversations) }
|
|
199
|
+
|
|
200
|
+
public func loadConversations(cursor: String? = nil) async throws -> ConversationsPage {
|
|
201
|
+
do {
|
|
202
|
+
let page = try await session.perform { token in try await self.api.conversations(cursor: cursor, token: token) }
|
|
203
|
+
if cursor == nil { storage.save(page, StorageName.conversations) }
|
|
204
|
+
unread.set(page.unreadTotal)
|
|
205
|
+
return page
|
|
206
|
+
} catch {
|
|
207
|
+
throw ErrorPolicy.map(error)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public func refreshUnread() async {
|
|
212
|
+
guard ConsentGate.allowsSession(config: cachedConfig, store: consentStore), await session.token != nil else { return }
|
|
213
|
+
if let total = try? await session.perform({ token in try await self.api.unread(token: token) }) { unread.set(total) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public func markRead(conversationId: String, upTo: String) async {
|
|
217
|
+
if let total = try? await session.perform({ token in try await self.api.markRead(conversationId: conversationId, upTo: upTo, token: token) }) {
|
|
218
|
+
unread.set(total)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// MARK: Attachments
|
|
223
|
+
|
|
224
|
+
public func upload(data: Data, fileName: String, mimeType: String, progress: @escaping @Sendable (Double) -> Void) async throws -> Attachment {
|
|
225
|
+
do {
|
|
226
|
+
return try await session.perform { token in
|
|
227
|
+
try await self.api.uploadAttachment(data: data, fileName: fileName, mimeType: mimeType, token: token, progress: progress)
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
throw ErrorPolicy.map(error)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// MARK: Push
|
|
235
|
+
|
|
236
|
+
public func setPushToken(_ token: String, environment: String?) {
|
|
237
|
+
let registration = PushRegistration(token: token, environment: environment)
|
|
238
|
+
state.with { $0.pushToken = registration }
|
|
239
|
+
storage.save(registration, StorageName.push)
|
|
240
|
+
let engine = self
|
|
241
|
+
Task { await engine.registerPushIfPossible() }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Sends the stored token when a session exists. It never creates a session by itself.
|
|
245
|
+
public func registerPushIfPossible() async {
|
|
246
|
+
guard let registration = state.with({ $0.pushToken }), await session.token != nil else { return }
|
|
247
|
+
let locale = self.locale()
|
|
248
|
+
_ = try? await session.perform { token in
|
|
249
|
+
try await self.api.registerDevice(provider: "apns", pushToken: registration.token, environment: registration.environment, locale: locale, appVersion: self.options.appVersion, token: token)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// MARK: Events hub
|
|
254
|
+
|
|
255
|
+
@discardableResult
|
|
256
|
+
public func addListener(_ listener: @escaping @Sendable (HubEvent) -> Void) -> Int {
|
|
257
|
+
state.with { s in
|
|
258
|
+
s.nextListener += 1
|
|
259
|
+
s.listeners[s.nextListener] = listener
|
|
260
|
+
return s.nextListener
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public func removeListener(_ token: Int) { state.with { $0.listeners[token] = nil } }
|
|
265
|
+
|
|
266
|
+
func emit(_ event: HubEvent) {
|
|
267
|
+
let listeners: [@Sendable (HubEvent) -> Void] = state.with { s in
|
|
268
|
+
if case .realtime(let inner) = event {
|
|
269
|
+
if case .ready(let id) = inner { s.streamConversationId = id }
|
|
270
|
+
}
|
|
271
|
+
return Array(s.listeners.values)
|
|
272
|
+
}
|
|
273
|
+
if case .realtime(let inner) = event, case .unread(let total) = inner { unread.set(total) }
|
|
274
|
+
for listener in listeners { listener(event) }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public var streamConversationId: String? { state.with { $0.streamConversationId } }
|
|
278
|
+
|
|
279
|
+
// MARK: Realtime and lifecycle
|
|
280
|
+
|
|
281
|
+
public func startRealtime() async {
|
|
282
|
+
let existing = state.with { $0.realtime }
|
|
283
|
+
if let existing = existing { await existing.start(); return }
|
|
284
|
+
let client = RealtimeClient(
|
|
285
|
+
api: api, clock: clock,
|
|
286
|
+
tokenSource: { [weak self] in
|
|
287
|
+
guard let self = self else { throw ChatError.cancelled }
|
|
288
|
+
return try await self.session.ensureSession()
|
|
289
|
+
},
|
|
290
|
+
onEvent: { [weak self] event in self?.emit(.realtime(event)) },
|
|
291
|
+
catchUp: { [weak self] in
|
|
292
|
+
guard let self = self else { return }
|
|
293
|
+
await self.refreshUnread()
|
|
294
|
+
self.emit(.reconnected)
|
|
295
|
+
},
|
|
296
|
+
onFailure: { [weak self] error in
|
|
297
|
+
guard let self = self else { return }
|
|
298
|
+
if ErrorPolicy.reaction(for: error) == .recreateSessionThenRetry { await self.session.invalidateToken() }
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
state.with { $0.realtime = client }
|
|
302
|
+
await client.start()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
public func stopRealtime() async {
|
|
306
|
+
if let client = state.with({ $0.realtime }) { await client.stop() }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// The app went to the background: close the stream.
|
|
310
|
+
public func enteredBackground() async {
|
|
311
|
+
if let client = state.with({ $0.realtime }) { await client.enteredBackground() }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// The app came back: read unread, read newer messages, reopen the stream. Also sends the queue.
|
|
315
|
+
public func enteredForeground() async {
|
|
316
|
+
if let client = state.with({ $0.realtime }) { await client.enteredForeground() } else { await refreshUnread() }
|
|
317
|
+
await outbox.kick()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
public func networkReturned() async { await outbox.networkReturned() }
|
|
321
|
+
|
|
322
|
+
public func makeThread(conversationId: String?) -> ThreadController {
|
|
323
|
+
ThreadController(engine: self, conversationId: conversationId)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The stable error codes of contract v1.
|
|
4
|
+
public enum ChatErrorCode: String, Equatable, Sendable {
|
|
5
|
+
case appKeyInvalid = "CHAT_APP_KEY_INVALID"
|
|
6
|
+
case appDisabled = "CHAT_APP_DISABLED"
|
|
7
|
+
case entitlementDenied = "CHAT_ENTITLEMENT_DENIED"
|
|
8
|
+
case sessionInvalid = "CHAT_SESSION_INVALID"
|
|
9
|
+
case sessionRequired = "CHAT_SESSION_REQUIRED"
|
|
10
|
+
case identityInvalid = "CHAT_IDENTITY_INVALID"
|
|
11
|
+
case guestsDisabled = "CHAT_GUESTS_DISABLED"
|
|
12
|
+
case conversationNotFound = "CHAT_CONVERSATION_NOT_FOUND"
|
|
13
|
+
case fileTooLarge = "CHAT_FILE_TOO_LARGE"
|
|
14
|
+
case fileTypeDenied = "CHAT_FILE_TYPE_DENIED"
|
|
15
|
+
case filesDisabled = "CHAT_FILES_DISABLED"
|
|
16
|
+
case attachmentNotFound = "CHAT_ATTACHMENT_NOT_FOUND"
|
|
17
|
+
case rateLimited = "CHAT_RATE_LIMITED"
|
|
18
|
+
case sdkTooOld = "CHAT_SDK_TOO_OLD"
|
|
19
|
+
case siteNotFound = "CHAT_SITE_NOT_FOUND"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// What the SDK does for an error code.
|
|
23
|
+
public enum ErrorReaction: Equatable, Sendable {
|
|
24
|
+
case stopUnavailable
|
|
25
|
+
case recreateSessionThenRetry
|
|
26
|
+
case refreshIdentityOnce
|
|
27
|
+
case showLoginNeeded
|
|
28
|
+
case reloadConversationList
|
|
29
|
+
case showFileTooLarge
|
|
30
|
+
case showFileTypeDenied
|
|
31
|
+
case hideAttach
|
|
32
|
+
case backOff
|
|
33
|
+
case none
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// A failure of one API call.
|
|
37
|
+
public enum ChatAPIError: Error, Equatable, Sendable {
|
|
38
|
+
case http(status: Int, code: String?, retryAfter: TimeInterval?)
|
|
39
|
+
case network
|
|
40
|
+
case timeout
|
|
41
|
+
case decoding
|
|
42
|
+
case cancelled
|
|
43
|
+
|
|
44
|
+
public var status: Int? {
|
|
45
|
+
if case .http(let status, _, _) = self { return status }
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public var errorCode: ChatErrorCode? {
|
|
50
|
+
if case .http(_, let code, _) = self, let code = code { return ChatErrorCode(rawValue: code) }
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The error the app-facing layers see. Mapped to a screen state.
|
|
56
|
+
public enum ChatError: Error, Equatable, Sendable {
|
|
57
|
+
case unavailable
|
|
58
|
+
case loginNeeded
|
|
59
|
+
case rateLimited(retryAfter: TimeInterval?)
|
|
60
|
+
case fileTooLarge
|
|
61
|
+
case fileTypeDenied
|
|
62
|
+
case filesDisabled
|
|
63
|
+
case conversationNotFound
|
|
64
|
+
case consentRequired
|
|
65
|
+
case network
|
|
66
|
+
case server(status: Int)
|
|
67
|
+
case decoding
|
|
68
|
+
case cancelled
|
|
69
|
+
|
|
70
|
+
/// A short code for the public `error(code)` event. No message text.
|
|
71
|
+
public var eventCode: String {
|
|
72
|
+
switch self {
|
|
73
|
+
case .unavailable: return "unavailable"
|
|
74
|
+
case .loginNeeded: return "login_needed"
|
|
75
|
+
case .rateLimited: return "rate_limited"
|
|
76
|
+
case .fileTooLarge: return "file_too_large"
|
|
77
|
+
case .fileTypeDenied: return "file_type_denied"
|
|
78
|
+
case .filesDisabled: return "files_disabled"
|
|
79
|
+
case .conversationNotFound: return "conversation_not_found"
|
|
80
|
+
case .consentRequired: return "consent_required"
|
|
81
|
+
case .network: return "network"
|
|
82
|
+
case .server: return "server"
|
|
83
|
+
case .decoding: return "decoding"
|
|
84
|
+
case .cancelled: return "cancelled"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public enum ErrorPolicy {
|
|
90
|
+
/// Reads the code from `{ "code": ... }` or `{ "error": { "code": ... } }`.
|
|
91
|
+
public static func parseCode(from body: Data?) -> String? {
|
|
92
|
+
guard let body = body, !body.isEmpty,
|
|
93
|
+
let root = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { return nil }
|
|
94
|
+
if let code = root["code"] as? String { return code }
|
|
95
|
+
if let error = root["error"] as? [String: Any], let code = error["code"] as? String { return code }
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public static func reaction(forCode code: String?) -> ErrorReaction {
|
|
100
|
+
guard let code = code, let known = ChatErrorCode(rawValue: code) else { return .none }
|
|
101
|
+
switch known {
|
|
102
|
+
case .appKeyInvalid, .appDisabled, .entitlementDenied, .sdkTooOld, .siteNotFound: return .stopUnavailable
|
|
103
|
+
case .sessionInvalid, .sessionRequired: return .recreateSessionThenRetry
|
|
104
|
+
case .identityInvalid: return .refreshIdentityOnce
|
|
105
|
+
case .guestsDisabled: return .showLoginNeeded
|
|
106
|
+
case .conversationNotFound: return .reloadConversationList
|
|
107
|
+
case .fileTooLarge: return .showFileTooLarge
|
|
108
|
+
case .fileTypeDenied: return .showFileTypeDenied
|
|
109
|
+
case .filesDisabled: return .hideAttach
|
|
110
|
+
// The message fails for good. The customer attaches the file again.
|
|
111
|
+
case .attachmentNotFound: return .none
|
|
112
|
+
case .rateLimited: return .backOff
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// A known code decides. An unknown code falls back to the HTTP status. No code (a proxy, for example) gives `.none`, except a 429.
|
|
117
|
+
public static func reaction(status: Int, code: String?) -> ErrorReaction {
|
|
118
|
+
guard let code = code else { return status == 429 ? .backOff : .none }
|
|
119
|
+
if ChatErrorCode(rawValue: code) != nil { return reaction(forCode: code) }
|
|
120
|
+
switch status {
|
|
121
|
+
case 401: return .recreateSessionThenRetry
|
|
122
|
+
case 403: return .stopUnavailable
|
|
123
|
+
case 429: return .backOff
|
|
124
|
+
default: return .none
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public static func reaction(for error: Error) -> ErrorReaction {
|
|
129
|
+
guard let api = error as? ChatAPIError, case .http(let status, let code, _) = api else { return .none }
|
|
130
|
+
return reaction(status: status, code: code)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Maps any error to the app-facing error.
|
|
134
|
+
public static func map(_ error: Error) -> ChatError {
|
|
135
|
+
if let chat = error as? ChatError { return chat }
|
|
136
|
+
if error is CancellationError { return .cancelled }
|
|
137
|
+
guard let api = error as? ChatAPIError else { return .network }
|
|
138
|
+
switch api {
|
|
139
|
+
case .network, .timeout: return .network
|
|
140
|
+
case .decoding: return .decoding
|
|
141
|
+
case .cancelled: return .cancelled
|
|
142
|
+
case .http(let status, let code, let retryAfter):
|
|
143
|
+
switch reaction(status: status, code: code) {
|
|
144
|
+
case .stopUnavailable: return .unavailable
|
|
145
|
+
case .showLoginNeeded, .refreshIdentityOnce: return .loginNeeded
|
|
146
|
+
case .reloadConversationList: return .conversationNotFound
|
|
147
|
+
case .showFileTooLarge: return .fileTooLarge
|
|
148
|
+
case .showFileTypeDenied: return .fileTypeDenied
|
|
149
|
+
case .hideAttach: return .filesDisabled
|
|
150
|
+
case .backOff: return .rateLimited(retryAfter: retryAfter)
|
|
151
|
+
case .recreateSessionThenRetry: return .loginNeeded
|
|
152
|
+
case .none:
|
|
153
|
+
if status == 429 { return .rateLimited(retryAfter: retryAfter) }
|
|
154
|
+
return .server(status: status)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// GENERATED by scripts/generate-strings.mjs from sdks/design/strings.json. Do not edit by hand.
|
|
2
|
+
|
|
3
|
+
enum ChatStringTable {
|
|
4
|
+
static let en: [String: String] = [
|
|
5
|
+
"a11y.team": "Support team",
|
|
6
|
+
"a11y.unread": "Unread",
|
|
7
|
+
"a11y.you": "You",
|
|
8
|
+
"attachment.cancel": "Cancel upload",
|
|
9
|
+
"attachment.close": "Close",
|
|
10
|
+
"attachment.failed": "We could not upload this file. Try again.",
|
|
11
|
+
"attachment.file": "File",
|
|
12
|
+
"attachment.photo": "Photo",
|
|
13
|
+
"attachment.tooLarge": "This file is too large. The limit is 10 MB.",
|
|
14
|
+
"attachment.typeDenied": "You can send photos and PDF files only.",
|
|
15
|
+
"banner.offline": "We are away right now. Leave a message and we will reply when we are back.",
|
|
16
|
+
"banner.online": "Our team is here to help",
|
|
17
|
+
"banner.outsideHours": "Outside support hours",
|
|
18
|
+
"banner.reconnecting": "Reconnecting…",
|
|
19
|
+
"common.back": "Back",
|
|
20
|
+
"common.close": "Close chat",
|
|
21
|
+
"common.copied": "Copied",
|
|
22
|
+
"common.copy": "Copy",
|
|
23
|
+
"common.poweredBy": "Powered by Zellio",
|
|
24
|
+
"composer.attach": "Attach a file",
|
|
25
|
+
"composer.camera": "Camera",
|
|
26
|
+
"composer.cameraDenied": "Allow camera access in Settings to take a photo.",
|
|
27
|
+
"composer.files": "File",
|
|
28
|
+
"composer.openSettings": "Open Settings",
|
|
29
|
+
"composer.photos": "Photo library",
|
|
30
|
+
"composer.placeholder": "Write your message…",
|
|
31
|
+
"composer.send": "Send",
|
|
32
|
+
"consent.accept": "Accept and start",
|
|
33
|
+
"consent.decline": "Not now",
|
|
34
|
+
"consent.default": "To help you, we store your messages and share them with our support team.",
|
|
35
|
+
"consent.privacy": "Privacy policy",
|
|
36
|
+
"delivery.failed": "Not sent",
|
|
37
|
+
"delivery.retry": "Try again",
|
|
38
|
+
"delivery.sending": "Sending…",
|
|
39
|
+
"delivery.sent": "Sent",
|
|
40
|
+
"delivery.waiting": "Sends when you are back online",
|
|
41
|
+
"home.fallbackHeading": "How can we help?",
|
|
42
|
+
"home.recent": "Recent conversations",
|
|
43
|
+
"home.seeAll": "See all",
|
|
44
|
+
"home.talkToTeam": "Talk to our team",
|
|
45
|
+
"list.empty": "You have no conversations yet.",
|
|
46
|
+
"list.new": "New conversation",
|
|
47
|
+
"list.title": "Conversations",
|
|
48
|
+
"push.newMessage": "New message",
|
|
49
|
+
"state.loadFailed": "We could not load your conversation. Try again.",
|
|
50
|
+
"state.loading": "Loading…",
|
|
51
|
+
"state.loginNeeded": "Log in to the app to chat with us.",
|
|
52
|
+
"state.rateLimited": "You are sending messages too fast. Please wait a few minutes, then try again.",
|
|
53
|
+
"state.retry": "Try again",
|
|
54
|
+
"state.sendFailed": "We could not send your message. Try again.",
|
|
55
|
+
"state.unavailable": "Chat is not available right now.",
|
|
56
|
+
"thread.greeting": "Hi! Tell us what you need help with.",
|
|
57
|
+
"thread.loadingOlder": "Loading earlier messages…",
|
|
58
|
+
"thread.newMessage": "New message",
|
|
59
|
+
"thread.title": "Your conversation",
|
|
60
|
+
"thread.today": "Today",
|
|
61
|
+
"thread.typing": "Our team is typing…",
|
|
62
|
+
"thread.yesterday": "Yesterday",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
static let ar: [String: String] = [
|
|
66
|
+
"a11y.team": "فريق الدعم",
|
|
67
|
+
"a11y.unread": "مش مقروءة",
|
|
68
|
+
"a11y.you": "إنت",
|
|
69
|
+
"attachment.cancel": "إلغاء الرفع",
|
|
70
|
+
"attachment.close": "إغلاق",
|
|
71
|
+
"attachment.failed": "معرفناش نرفع الملف. جرّب تاني.",
|
|
72
|
+
"attachment.file": "ملف",
|
|
73
|
+
"attachment.photo": "صورة",
|
|
74
|
+
"attachment.tooLarge": "الملف كبير أوي. الحد الأقصى 10 ميجا.",
|
|
75
|
+
"attachment.typeDenied": "تقدر تبعت صور وملفات PDF بس.",
|
|
76
|
+
"banner.offline": "إحنا مش متاحين دلوقتي. سيب رسالتك وهنرد عليك أول ما نرجع.",
|
|
77
|
+
"banner.online": "فريقنا موجود عشان يساعدك",
|
|
78
|
+
"banner.outsideHours": "خارج مواعيد العمل",
|
|
79
|
+
"banner.reconnecting": "بنحاول نرجّع الاتصال…",
|
|
80
|
+
"common.back": "رجوع",
|
|
81
|
+
"common.close": "إغلاق الشات",
|
|
82
|
+
"common.copied": "اتنسخ",
|
|
83
|
+
"common.copy": "نسخ",
|
|
84
|
+
"common.poweredBy": "بدعم من Zellio",
|
|
85
|
+
"composer.attach": "إرفاق ملف",
|
|
86
|
+
"composer.camera": "الكاميرا",
|
|
87
|
+
"composer.cameraDenied": "اسمح باستخدام الكاميرا من الإعدادات عشان تصوّر.",
|
|
88
|
+
"composer.files": "ملف",
|
|
89
|
+
"composer.openSettings": "افتح الإعدادات",
|
|
90
|
+
"composer.photos": "الصور",
|
|
91
|
+
"composer.placeholder": "اكتب رسالتك…",
|
|
92
|
+
"composer.send": "إرسال",
|
|
93
|
+
"consent.accept": "موافق، يلا نبدأ",
|
|
94
|
+
"consent.decline": "مش دلوقتي",
|
|
95
|
+
"consent.default": "عشان نقدر نساعدك، هنحفظ رسايلك ونشاركها مع فريق الدعم.",
|
|
96
|
+
"consent.privacy": "سياسة الخصوصية",
|
|
97
|
+
"delivery.failed": "ماتبعتتش",
|
|
98
|
+
"delivery.retry": "جرّب تاني",
|
|
99
|
+
"delivery.sending": "جارٍ الإرسال…",
|
|
100
|
+
"delivery.sent": "اتبعتت",
|
|
101
|
+
"delivery.waiting": "هتتبعت أول ما النت يرجع",
|
|
102
|
+
"home.fallbackHeading": "إزاي نقدر نساعدك؟",
|
|
103
|
+
"home.recent": "آخر المحادثات",
|
|
104
|
+
"home.seeAll": "شوف الكل",
|
|
105
|
+
"home.talkToTeam": "كلّم فريقنا",
|
|
106
|
+
"list.empty": "لسه مفيش محادثات.",
|
|
107
|
+
"list.new": "محادثة جديدة",
|
|
108
|
+
"list.title": "المحادثات",
|
|
109
|
+
"push.newMessage": "رسالة جديدة",
|
|
110
|
+
"state.loadFailed": "معرفناش نحمّل المحادثة. جرّب تاني.",
|
|
111
|
+
"state.loading": "جارٍ التحميل…",
|
|
112
|
+
"state.loginNeeded": "سجّل دخولك في التطبيق عشان تكلّمنا.",
|
|
113
|
+
"state.rateLimited": "بعت رسايل كتير ورا بعض. استنى كام دقيقة وجرّب تاني.",
|
|
114
|
+
"state.retry": "جرّب تاني",
|
|
115
|
+
"state.sendFailed": "معرفناش نبعت الرسالة. جرّب تاني.",
|
|
116
|
+
"state.unavailable": "الشات مش متاح دلوقتي.",
|
|
117
|
+
"thread.greeting": "أهلاً! قولّنا محتاج إيه وهنساعدك.",
|
|
118
|
+
"thread.loadingOlder": "بنحمّل الرسايل القديمة…",
|
|
119
|
+
"thread.newMessage": "رسالة جديدة",
|
|
120
|
+
"thread.title": "محادثتك",
|
|
121
|
+
"thread.today": "النهاردة",
|
|
122
|
+
"thread.typing": "الفريق بيكتب…",
|
|
123
|
+
"thread.yesterday": "إمبارح",
|
|
124
|
+
]
|
|
125
|
+
}
|