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