@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,157 @@
1
+ import Foundation
2
+
3
+ /// The messages of one conversation, oldest first, plus the cursors. Merges by `clientMessageId`.
4
+ public struct Timeline: Codable, Equatable, Sendable {
5
+ public static let maxStored = 200
6
+
7
+ public var messages: [ChatMessage]
8
+ public var nextBefore: String?
9
+ public var latest: String?
10
+
11
+ public init(messages: [ChatMessage] = [], nextBefore: String? = nil, latest: String? = nil) {
12
+ self.messages = messages
13
+ self.nextBefore = nextBefore
14
+ self.latest = latest
15
+ }
16
+
17
+ public var hasOlder: Bool { nextBefore != nil }
18
+
19
+ /// Adds or replaces one message. Returns true when the list changed.
20
+ ///
21
+ /// A server message with the same `clientMessageId` replaces the local one in place, so the row keeps its
22
+ /// position and its `stableId`: no scroll jump and no flash. Otherwise a message with the same server id is
23
+ /// replaced in place. A new message goes in by time, after the last message with the same or an earlier time.
24
+ @discardableResult
25
+ public mutating func upsert(_ message: ChatMessage) -> Bool {
26
+ if let cid = message.clientMessageId, let index = messages.firstIndex(where: { $0.clientMessageId == cid }) {
27
+ return replace(at: index, with: message)
28
+ }
29
+ if let sid = message.serverId, let index = messages.firstIndex(where: { $0.serverId == sid }) {
30
+ return replace(at: index, with: message)
31
+ }
32
+ let date = message.date
33
+ var position = 0
34
+ var index = messages.count - 1
35
+ while index >= 0 {
36
+ if messages[index].date <= date { position = index + 1; break }
37
+ index -= 1
38
+ }
39
+ messages.insert(message, at: position)
40
+ return true
41
+ }
42
+
43
+ private mutating func replace(at index: Int, with message: ChatMessage) -> Bool {
44
+ var next = message
45
+ // A server copy never goes back to a local state, and keeps the local identity.
46
+ if next.clientMessageId == nil { next.clientMessageId = messages[index].clientMessageId }
47
+ if next == messages[index] { return false }
48
+ messages[index] = next
49
+ return true
50
+ }
51
+
52
+ /// Applies a page from the server. `older` pages move the `nextBefore` cursor; newer pages move `latest`.
53
+ public mutating func apply(page: MessagesPage, older: Bool, initial: Bool = false) {
54
+ for message in page.messages { upsert(message) }
55
+ if older || initial { nextBefore = page.nextBefore }
56
+ if !older, let latest = page.latest { self.latest = latest }
57
+ if initial, let latest = page.latest { self.latest = latest }
58
+ }
59
+
60
+ public mutating func setDelivery(clientMessageId: String, _ delivery: DeliveryState, reason: String? = nil) {
61
+ guard let index = messages.firstIndex(where: { $0.clientMessageId == clientMessageId }) else { return }
62
+ guard messages[index].serverId == nil else { return }
63
+ messages[index].delivery = delivery
64
+ messages[index].failureReason = reason
65
+ }
66
+
67
+ /// Keeps the newest `limit` messages. Cuts the older cursor off when anything was dropped, so paging stays honest.
68
+ public mutating func trim(limit: Int = Timeline.maxStored) {
69
+ guard messages.count > limit else { return }
70
+ messages.removeFirst(messages.count - limit)
71
+ }
72
+
73
+ /// What is saved to disk: server-confirmed messages only, newest 200. Queued messages live in the queue.
74
+ public func persisted() -> Timeline {
75
+ var copy = self
76
+ copy.messages = messages.filter { $0.serverId != nil }
77
+ copy.trim()
78
+ return copy
79
+ }
80
+ }
81
+
82
+ public enum ThreadRow: Equatable, Sendable, Identifiable {
83
+ case date(key: String, label: String)
84
+ case message(MessageRow)
85
+ case typing(agentName: String?)
86
+
87
+ public var id: String {
88
+ switch self {
89
+ case .date(let key, _): return "date-" + key
90
+ case .message(let row): return "msg-" + row.message.stableId
91
+ case .typing: return "typing"
92
+ }
93
+ }
94
+ }
95
+
96
+ public struct MessageRow: Equatable, Sendable {
97
+ public var message: ChatMessage
98
+ /// First message of a group: the larger gap above it.
99
+ public var startsGroup: Bool
100
+ /// Last message of a group: it has the tail and the avatar.
101
+ public var endsGroup: Bool
102
+ /// The agent name shows above the first agent message of a group.
103
+ public var showsName: Bool
104
+ /// The delivery line shows under the newest visitor message only. Failed messages always show theirs.
105
+ public var showsDelivery: Bool
106
+ public var timeLabel: String
107
+ }
108
+
109
+ public enum ThreadLayout {
110
+ /// Builds the rows: date separators, grouping within 5 minutes by the same sender, delivery lines.
111
+ public static func rows(messages: [ChatMessage], typing: Bool, typingName: String?, now: Date, locale: ChatLocale, timeZone: TimeZone = .current) -> [ThreadRow] {
112
+ var rows: [ThreadRow] = []
113
+ var calendar = Calendar(identifier: .gregorian)
114
+ calendar.timeZone = timeZone
115
+ let newestVisitor = messages.lastIndex { $0.from == .visitor }
116
+ var previous: ChatMessage?
117
+ for (index, message) in messages.enumerated() {
118
+ let date = message.date
119
+ let newDay = previous.map { !calendar.isDate($0.date, inSameDayAs: date) } ?? true
120
+ if newDay {
121
+ let comps = calendar.dateComponents([.year, .month, .day], from: date)
122
+ let key = "\(comps.year ?? 0)-\(comps.month ?? 0)-\(comps.day ?? 0)"
123
+ rows.append(.date(key: key, label: ChatFormat.daySeparator(date, now: now, locale: locale, timeZone: timeZone)))
124
+ }
125
+ let sameGroupAsPrevious = !newDay && previous.map { isSameGroup($0, message) } ?? false
126
+ var endsGroup = true
127
+ if index + 1 < messages.count {
128
+ let next = messages[index + 1]
129
+ endsGroup = !(calendar.isDate(next.date, inSameDayAs: date) && isSameGroup(message, next))
130
+ }
131
+ let showsName = message.from == .agent && !sameGroupAsPrevious && !(message.agentName ?? "").isEmpty
132
+ let showsDelivery = message.from == .visitor && (index == newestVisitor || message.delivery == .failed)
133
+ rows.append(.message(MessageRow(message: message, startsGroup: !sameGroupAsPrevious, endsGroup: endsGroup,
134
+ showsName: showsName, showsDelivery: showsDelivery,
135
+ timeLabel: ChatFormat.time(date, locale: locale, timeZone: timeZone))))
136
+ previous = message
137
+ }
138
+ if typing { rows.append(.typing(agentName: typingName)) }
139
+ return rows
140
+ }
141
+
142
+ public static func isSameGroup(_ a: ChatMessage, _ b: ChatMessage) -> Bool {
143
+ guard a.from == b.from else { return false }
144
+ if a.from == .agent && a.agentName != b.agentName && a.agentName != nil && b.agentName != nil { return false }
145
+ return abs(b.date.timeIntervalSince(a.date)) <= Metrics.groupWindowSeconds
146
+ }
147
+
148
+ /// The delivery text under a visitor message. Nil when nothing shows.
149
+ public static func deliveryText(_ message: ChatMessage, strings: ChatStrings) -> String? {
150
+ switch message.delivery {
151
+ case .sending: return strings.string("delivery.sending")
152
+ case .waiting: return strings.string("delivery.waiting")
153
+ case .failed: return strings.string("delivery.failed")
154
+ case .sent: return strings.string("delivery.sent")
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,236 @@
1
+ import Foundation
2
+ #if canImport(FoundationNetworking)
3
+ import FoundationNetworking
4
+ #endif
5
+
6
+ public struct HTTPRequest: Sendable {
7
+ public var method: String
8
+ public var url: URL
9
+ public var headers: [String: String]
10
+ public var body: Data?
11
+ public var timeout: TimeInterval
12
+
13
+ public init(method: String, url: URL, headers: [String: String] = [:], body: Data? = nil, timeout: TimeInterval = 30) {
14
+ self.method = method
15
+ self.url = url
16
+ self.headers = headers
17
+ self.body = body
18
+ self.timeout = timeout
19
+ }
20
+ }
21
+
22
+ public struct HTTPResponse: Sendable {
23
+ public var status: Int
24
+ public var headers: [String: String]
25
+ public var body: Data
26
+
27
+ public init(status: Int, headers: [String: String] = [:], body: Data = Data()) {
28
+ self.status = status
29
+ self.headers = headers
30
+ self.body = body
31
+ }
32
+
33
+ public func header(_ name: String) -> String? {
34
+ let wanted = name.lowercased()
35
+ return headers.first { $0.key.lowercased() == wanted }?.value
36
+ }
37
+ }
38
+
39
+ public enum StreamChunk: Sendable, Equatable {
40
+ case opened
41
+ case data(Data)
42
+ }
43
+
44
+ /// The network seam. The app target has a URLSession version; tests and demo mode use fakes.
45
+ public protocol HTTPTransport: Sendable {
46
+ /// A normal request. Throws `ChatAPIError.network` or `.timeout`.
47
+ func send(_ request: HTTPRequest) async throws -> HTTPResponse
48
+ /// A request with a body that reports progress from 0 to 1. Cancelling the task cancels the upload.
49
+ func upload(_ request: HTTPRequest, progress: @escaping @Sendable (Double) -> Void) async throws -> HTTPResponse
50
+ /// An SSE stream. Yields `.opened` for a 2xx response, then bytes. Throws `ChatAPIError.http` for other statuses.
51
+ func stream(_ request: HTTPRequest) -> AsyncThrowingStream<StreamChunk, Error>
52
+ }
53
+
54
+ public struct ChatAPIConfig: Sendable {
55
+ public var baseURL: URL
56
+ public var appKey: String
57
+ public var platform: String
58
+ public var sdkVersion: String
59
+ public var wrapper: String?
60
+
61
+ public init(baseURL: URL, appKey: String, platform: String = SDKInfo.platform, sdkVersion: String = SDKInfo.version, wrapper: String? = nil) {
62
+ self.baseURL = baseURL
63
+ self.appKey = appKey
64
+ self.platform = platform
65
+ self.sdkVersion = sdkVersion
66
+ self.wrapper = wrapper
67
+ }
68
+ }
69
+
70
+ /// Typed calls to contract v1. The session token comes in per call.
71
+ public final class ChatAPI: @unchecked Sendable {
72
+ public let config: ChatAPIConfig
73
+ private let transport: HTTPTransport
74
+
75
+ public static let connectTimeout: TimeInterval = 10
76
+ public static let readTimeout: TimeInterval = 30
77
+ public static let uploadTimeout: TimeInterval = 120
78
+
79
+ public init(config: ChatAPIConfig, transport: HTTPTransport) {
80
+ self.config = config
81
+ self.transport = transport
82
+ }
83
+
84
+ // MARK: Request building
85
+
86
+ func makeRequest(_ method: String, _ path: String, query: [(String, String)] = [], body: Data? = nil, token: String?, timeout: TimeInterval = ChatAPI.readTimeout, contentType: String? = nil) -> HTTPRequest {
87
+ var base = config.baseURL.absoluteString
88
+ while base.hasSuffix("/") { base.removeLast() }
89
+ var text = base + SDKInfo.apiPath + path
90
+ if !query.isEmpty {
91
+ var components = URLComponents()
92
+ components.queryItems = query.map { URLQueryItem(name: $0.0, value: $0.1) }
93
+ if let encoded = components.percentEncodedQuery { text += "?" + encoded }
94
+ }
95
+ let url = URL(string: text) ?? config.baseURL
96
+ var headers: [String: String] = [
97
+ "x-zee-app-key": config.appKey,
98
+ "x-zee-sdk": "\(config.platform)/\(config.sdkVersion)",
99
+ "accept": "application/json",
100
+ ]
101
+ if let wrapper = config.wrapper { headers["x-zee-sdk-wrapper"] = wrapper }
102
+ if let token = token { headers["x-zee-session-token"] = token }
103
+ if body != nil { headers["content-type"] = contentType ?? "application/json" }
104
+ return HTTPRequest(method: method, url: url, headers: headers, body: body, timeout: timeout)
105
+ }
106
+
107
+ private func json(_ object: [String: Any?]) -> Data? {
108
+ var clean: [String: Any] = [:]
109
+ for (key, value) in object { if let value = value { clean[key] = value } }
110
+ return try? JSONSerialization.data(withJSONObject: clean)
111
+ }
112
+
113
+ // MARK: Response handling
114
+
115
+ static func checked(_ response: HTTPResponse) throws -> Data {
116
+ if (200..<300).contains(response.status) { return response.body }
117
+ let retry = response.header("retry-after").flatMap { TimeInterval($0.trimmingCharacters(in: .whitespaces)) }
118
+ throw ChatAPIError.http(status: response.status, code: ErrorPolicy.parseCode(from: response.body), retryAfter: retry)
119
+ }
120
+
121
+ private struct Envelope<T: Decodable>: Decodable { let data: T }
122
+
123
+ static func unwrap<T: Decodable>(_ type: T.Type, _ body: Data) throws -> T {
124
+ do { return try JSONDecoder().decode(Envelope<T>.self, from: body).data } catch { throw ChatAPIError.decoding }
125
+ }
126
+
127
+ private func call<T: Decodable>(_ type: T.Type, _ request: HTTPRequest) async throws -> T {
128
+ let response = try await transport.send(request)
129
+ return try ChatAPI.unwrap(type, try ChatAPI.checked(response))
130
+ }
131
+
132
+ // MARK: Endpoints
133
+
134
+ public func fetchConfig() async throws -> ChatConfig {
135
+ let response = try await transport.send(makeRequest("GET", "/config", token: nil))
136
+ return try ChatConfig.parse(try ChatAPI.checked(response))
137
+ }
138
+
139
+ /// The raw body of GET /config, for the cache.
140
+ public func fetchConfigBody() async throws -> Data {
141
+ let response = try await transport.send(makeRequest("GET", "/config", token: nil))
142
+ return try ChatAPI.checked(response)
143
+ }
144
+
145
+ public func createSession(installId: String, locale: ChatLocale, identityToken: String?) async throws -> SessionInfo {
146
+ let body = json(["installId": installId, "platform": config.platform, "locale": locale.rawValue, "identityToken": identityToken])
147
+ return try await call(SessionInfo.self, makeRequest("POST", "/session", body: body, token: nil))
148
+ }
149
+
150
+ public func identify(identityToken: String, token: String) async throws -> SessionInfo {
151
+ try await call(SessionInfo.self, makeRequest("POST", "/session/identify", body: json(["identityToken": identityToken]), token: token))
152
+ }
153
+
154
+ public func logout(token: String) async throws {
155
+ let response = try await transport.send(makeRequest("POST", "/session/logout", body: Data("{}".utf8), token: token))
156
+ _ = try ChatAPI.checked(response)
157
+ }
158
+
159
+ public func conversations(cursor: String?, limit: Int = 20, token: String) async throws -> ConversationsPage {
160
+ var query: [(String, String)] = [("limit", String(min(50, max(1, limit))))]
161
+ if let cursor = cursor { query.append(("cursor", cursor)) }
162
+ return try await call(ConversationsPage.self, makeRequest("GET", "/conversations", query: query, token: token))
163
+ }
164
+
165
+ public func messages(conversationId: String, before: String? = nil, after: String? = nil, limit: Int = 50, token: String) async throws -> MessagesPage {
166
+ var query: [(String, String)] = [("limit", String(min(100, max(1, limit))))]
167
+ // Never both cursors.
168
+ if let before = before, after == nil { query.append(("before", before)) }
169
+ if let after = after { query.append(("after", after)) }
170
+ let encodedId = conversationId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? conversationId
171
+ return try await call(MessagesPage.self, makeRequest("GET", "/conversations/\(encodedId)/messages", query: query, token: token))
172
+ }
173
+
174
+ public func sendMessage(text: String, clientMessageId: String, attachmentIds: [String], context: ChatContext?, token: String) async throws -> SendReceipt {
175
+ var object: [String: Any] = ["text": text.clipped(toUTF16: 4000), "clientMessageId": clientMessageId]
176
+ if !attachmentIds.isEmpty { object["attachmentIds"] = attachmentIds }
177
+ if let context = context?.sanitized(), let data = try? JSONEncoder().encode(context),
178
+ let dictionary = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
179
+ object["context"] = dictionary
180
+ }
181
+ let body = try? JSONSerialization.data(withJSONObject: object)
182
+ return try await call(SendReceipt.self, makeRequest("POST", "/messages", body: body, token: token))
183
+ }
184
+
185
+ public func uploadAttachment(data: Data, fileName: String, mimeType: String, token: String, progress: @escaping @Sendable (Double) -> Void) async throws -> Attachment {
186
+ let boundary = "zee-" + UUID().uuidString
187
+ var body = Data()
188
+ func add(_ text: String) { body.append(Data(text.utf8)) }
189
+ let safeName = fileName.replacingOccurrences(of: "\"", with: "_").replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
190
+ add("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\nContent-Type: \(mimeType)\r\n\r\n")
191
+ body.append(data)
192
+ add("\r\n--\(boundary)--\r\n")
193
+ let request = makeRequest("POST", "/attachments", body: body, token: token, timeout: ChatAPI.uploadTimeout, contentType: "multipart/form-data; boundary=\(boundary)")
194
+ let response = try await transport.upload(request, progress: progress)
195
+ return try ChatAPI.unwrap(Attachment.self, try ChatAPI.checked(response))
196
+ }
197
+
198
+ public func markRead(conversationId: String, upTo: String, token: String) async throws -> Int {
199
+ let body = json(["conversationId": conversationId, "upTo": upTo])
200
+ return try await call(UnreadBody.self, makeRequest("POST", "/read", body: body, token: token)).unreadTotal
201
+ }
202
+
203
+ public func unread(token: String) async throws -> Int {
204
+ try await call(UnreadBody.self, makeRequest("GET", "/unread", token: token)).unreadTotal
205
+ }
206
+
207
+ public func registerDevice(provider: String, pushToken: String, environment: String?, locale: ChatLocale, appVersion: String?, token: String) async throws {
208
+ let body = json(["provider": provider, "pushToken": pushToken, "environment": environment, "locale": locale.rawValue, "appVersion": appVersion])
209
+ let response = try await transport.send(makeRequest("PUT", "/device", body: body, token: token))
210
+ _ = try ChatAPI.checked(response)
211
+ }
212
+
213
+ public func deleteDevice(token: String) async throws {
214
+ let response = try await transport.send(makeRequest("DELETE", "/device", token: token))
215
+ _ = try ChatAPI.checked(response)
216
+ }
217
+
218
+ public func eventsRequest(token: String) -> HTTPRequest {
219
+ var request = makeRequest("GET", "/events", token: token, timeout: 0)
220
+ request.headers["accept"] = "text/event-stream"
221
+ return request
222
+ }
223
+
224
+ public func openStream(token: String) -> AsyncThrowingStream<StreamChunk, Error> {
225
+ transport.stream(eventsRequest(token: token))
226
+ }
227
+ }
228
+
229
+ private struct UnreadBody: Decodable {
230
+ let unreadTotal: Int
231
+ private enum CodingKeys: String, CodingKey { case unreadTotal }
232
+ init(from decoder: Decoder) throws {
233
+ let c = try decoder.container(keyedBy: CodingKeys.self)
234
+ unreadTotal = c.lenient(Int.self, .unreadTotal) ?? 0
235
+ }
236
+ }
@@ -0,0 +1,137 @@
1
+ import Foundation
2
+ #if canImport(FoundationNetworking)
3
+ import FoundationNetworking
4
+ #endif
5
+
6
+ /// SDK constants.
7
+ public enum SDKInfo {
8
+ public static let version = "1.0.0"
9
+ public static let platform = "ios"
10
+ public static let defaultBaseURL = "https://app.zellio.ai"
11
+ public static let apiPath = "/api/v1/zeechat/mobile"
12
+ }
13
+
14
+ /// A small lock around a value. Used where an actor would be too heavy.
15
+ public final class Locked<Value>: @unchecked Sendable {
16
+ private let lock = NSLock()
17
+ private var value: Value
18
+
19
+ public init(_ value: Value) { self.value = value }
20
+
21
+ @discardableResult
22
+ public func with<R>(_ body: (inout Value) -> R) -> R {
23
+ lock.lock()
24
+ defer { lock.unlock() }
25
+ return body(&value)
26
+ }
27
+
28
+ public var snapshot: Value { with { $0 } }
29
+ }
30
+
31
+ /// Time and sleeping, injectable so tests never wait.
32
+ public protocol ChatClock: Sendable {
33
+ func now() -> Date
34
+ func sleep(seconds: Double) async throws
35
+ }
36
+
37
+ public struct SystemClock: ChatClock {
38
+ public init() {}
39
+ public func now() -> Date { Date() }
40
+ public func sleep(seconds: Double) async throws {
41
+ if seconds <= 0 { return }
42
+ try await Task.sleep(nanoseconds: UInt64(min(seconds, 3600) * 1_000_000_000))
43
+ }
44
+ }
45
+
46
+ /// Random source, injectable for jitter tests.
47
+ public protocol RandomSource: Sendable {
48
+ /// A value in 0..<1.
49
+ func unit() -> Double
50
+ }
51
+
52
+ public struct SystemRandom: RandomSource {
53
+ public init() {}
54
+ public func unit() -> Double { Double.random(in: 0..<1) }
55
+ }
56
+
57
+ public enum ISODate {
58
+ private static let withFraction: ISO8601DateFormatter = {
59
+ let formatter = ISO8601DateFormatter()
60
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
61
+ return formatter
62
+ }()
63
+ private static let plain: ISO8601DateFormatter = {
64
+ let formatter = ISO8601DateFormatter()
65
+ formatter.formatOptions = [.withInternetDateTime]
66
+ return formatter
67
+ }()
68
+ private static let lock = NSLock()
69
+
70
+ public static func parse(_ text: String) -> Date? {
71
+ lock.lock()
72
+ defer { lock.unlock() }
73
+ return withFraction.date(from: text) ?? plain.date(from: text)
74
+ }
75
+
76
+ public static func string(_ date: Date) -> String {
77
+ lock.lock()
78
+ defer { lock.unlock() }
79
+ return withFraction.string(from: date)
80
+ }
81
+ }
82
+
83
+ public enum SemVer {
84
+ /// Compares dotted numeric versions. Missing parts count as 0. Returns -1, 0 or 1.
85
+ public static func compare(_ a: String, _ b: String) -> Int {
86
+ let left = parts(a)
87
+ let right = parts(b)
88
+ let count = max(left.count, right.count)
89
+ for index in 0..<count {
90
+ let x = index < left.count ? left[index] : 0
91
+ let y = index < right.count ? right[index] : 0
92
+ if x != y { return x < y ? -1 : 1 }
93
+ }
94
+ return 0
95
+ }
96
+
97
+ private static func parts(_ text: String) -> [Int] {
98
+ let core = text.split(separator: "-").first.map(String.init) ?? text
99
+ return core.split(separator: ".").map { Int($0.filter { $0.isNumber }) ?? 0 }
100
+ }
101
+ }
102
+
103
+ /// Decodes a JSON value of any shape without keeping it. Used to step over a bad array element.
104
+ struct SkipValue: Decodable {
105
+ init(from decoder: Decoder) throws {}
106
+ }
107
+
108
+ /// An array that skips elements that do not decode.
109
+ struct LossyArray<Element: Decodable>: Decodable {
110
+ let items: [Element]
111
+
112
+ init(from decoder: Decoder) throws {
113
+ var container = try decoder.unkeyedContainer()
114
+ var result: [Element] = []
115
+ while !container.isAtEnd {
116
+ if let item = try? container.decode(Element.self) {
117
+ result.append(item)
118
+ } else {
119
+ _ = try? container.decode(SkipValue.self)
120
+ }
121
+ }
122
+ items = result
123
+ }
124
+ }
125
+
126
+ extension KeyedDecodingContainer {
127
+ /// Decodes a value, or nil when the key is missing or has the wrong type.
128
+ func lenient<T: Decodable>(_ type: T.Type, _ key: Key) -> T? {
129
+ guard let value = try? decodeIfPresent(type, forKey: key) else { return nil }
130
+ return value
131
+ }
132
+ }
133
+
134
+ enum JSON {
135
+ static func decoder() -> JSONDecoder { JSONDecoder() }
136
+ static func encoder() -> JSONEncoder { JSONEncoder() }
137
+ }
@@ -0,0 +1,34 @@
1
+ import type { CodegenTypes, TurboModule } from 'react-native';
2
+ /**
3
+ * Codegen spec. Do not import this file from app code: use the typed API in `index.ts`.
4
+ * Options travel as plain objects so the generated native code stays small; `index.ts` types them.
5
+ */
6
+ export interface Spec extends TurboModule {
7
+ configure(appKey: string, options: CodegenTypes.UnsafeObject): void;
8
+ /** Registers the token provider on the native side. Native then asks JS through `onTokenRequest`. */
9
+ identify(): void;
10
+ /** Answers one `onTokenRequest`. Exactly one of `token` and `errorCode` is set. */
11
+ resolveToken(requestId: string, token: string | null, errorCode: string | null): void;
12
+ logout(): void;
13
+ present(): void;
14
+ presentConversation(conversationId: string | null): void;
15
+ dismiss(): void;
16
+ setContext(context: CodegenTypes.UnsafeObject | null): void;
17
+ getUnreadCount(): Promise<number>;
18
+ setPushToken(token: string, environment: string | null): void;
19
+ isZellioPush(payloadJson: string): boolean;
20
+ handlePush(payloadJson: string): Promise<boolean>;
21
+ handleNotificationTap(payloadJson: string): Promise<boolean>;
22
+ readonly onUnread: CodegenTypes.EventEmitter<{
23
+ count: number;
24
+ }>;
25
+ readonly onEvent: CodegenTypes.EventEmitter<{
26
+ name: string;
27
+ code?: string;
28
+ }>;
29
+ readonly onTokenRequest: CodegenTypes.EventEmitter<{
30
+ requestId: string;
31
+ }>;
32
+ }
33
+ declare const _default: Spec;
34
+ export default _default;
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const react_native_1 = require("react-native");
4
+ exports.default = react_native_1.TurboModuleRegistry.getEnforcing('RNZellioChat');
package/lib/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { ZellioChatConfig, ZellioChatEvent, ZellioContext, ZellioPushEnvironment, ZellioPushPayload, ZellioSubscription, ZellioTokenProvider } from './types';
2
+ export type * from './types';
3
+ export { VERSION } from './version';
4
+ /** Native asks for a token at most this long before it gives up. */
5
+ export declare const TOKEN_TIMEOUT_MS = 15000;
6
+ /** Call once at app start. Cheap: no network on the calling thread, no UI. */
7
+ export declare function configure(config: ZellioChatConfig): void;
8
+ /**
9
+ * Marks a signed-in customer. `tokenProvider` returns a JWT from your server. The SDK calls it when it needs a
10
+ * session and again if the server rejects the token. The token is never logged. Calling `identify` again
11
+ * replaces the provider.
12
+ */
13
+ export declare function identify(provider: ZellioTokenProvider): void;
14
+ /** Ends the session, deletes all local chat data and removes the push token from the server. */
15
+ export declare function logout(): void;
16
+ /** Shows chat on top of the app. */
17
+ export declare function present(): void;
18
+ /** Opens one conversation, or a new one when `conversationId` is omitted. */
19
+ export declare function presentConversation(conversationId?: string | null): void;
20
+ /** Closes chat. */
21
+ export declare function dismiss(): void;
22
+ /** Tells agents what the customer looks at. It is sent with the next messages. `null` clears it. */
23
+ export declare function setContext(context: ZellioContext | null): void;
24
+ /** The number of unread agent messages, across all conversations. Resolves 0 if the SDK is not ready. */
25
+ export declare function getUnreadCount(): Promise<number>;
26
+ /** Calls back now with the current count and on every change. Call `remove()` to stop. */
27
+ export declare function addUnreadListener(callback: (count: number) => void): ZellioSubscription;
28
+ /** Chat events for your own analytics. Call `remove()` to stop. */
29
+ export declare function addEventListener(callback: (event: ZellioChatEvent) => void): ZellioSubscription;
30
+ /** Gives the SDK the push token: the APNs token as a hex string on iOS, the FCM token on Android. */
31
+ export declare function setPushToken(token: string, environment?: ZellioPushEnvironment): void;
32
+ /** True only if the payload has the `zellio` key. */
33
+ export declare function isZellioPush(payload: ZellioPushPayload | null | undefined): boolean;
34
+ /** Refreshes the unread state. Resolves true if the push was ours. */
35
+ export declare function handlePush(payload: ZellioPushPayload | null | undefined): Promise<boolean>;
36
+ /** Opens the conversation that the push names. Resolves true if the push was ours. */
37
+ export declare function handleNotificationTap(payload: ZellioPushPayload | null | undefined): Promise<boolean>;