@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,223 @@
1
+ import Foundation
2
+
3
+ public enum QueueStatus: String, Codable, Equatable, Sendable { case pending, failed }
4
+
5
+ public struct QueuedMessage: Codable, Equatable, Sendable {
6
+ public var clientMessageId: String
7
+ public var text: String
8
+ public var attachments: [Attachment]
9
+ public var context: ChatContext?
10
+ public var createdAt: Date
11
+ public var sequence: Int
12
+ public var attempts: Int
13
+ public var status: QueueStatus
14
+ public var failureReason: String?
15
+ public var conversationId: String?
16
+
17
+ public var attachmentIds: [String] { attachments.compactMap { $0.id } }
18
+
19
+ public func asMessage(delivery: DeliveryState) -> ChatMessage {
20
+ ChatMessage(serverId: nil, clientMessageId: clientMessageId, from: .visitor, text: text, attachments: attachments,
21
+ createdAt: ISODate.string(createdAt), delivery: delivery, failureReason: failureReason)
22
+ }
23
+ }
24
+
25
+ /// The saved send queue. Order is the order the customer tapped send. Survives restarts.
26
+ public final class OfflineQueue: @unchecked Sendable {
27
+ public static let expiry: TimeInterval = 24 * 60 * 60
28
+
29
+ private let storage: StorageBackend
30
+ private let clock: ChatClock
31
+ private let lock = NSLock()
32
+ private var items: [QueuedMessage]
33
+
34
+ public init(storage: StorageBackend, clock: ChatClock = SystemClock()) {
35
+ self.storage = storage
36
+ self.clock = clock
37
+ self.items = storage.load([QueuedMessage].self, StorageName.queue) ?? []
38
+ }
39
+
40
+ private func persist() { storage.save(items, StorageName.queue) }
41
+
42
+ public var all: [QueuedMessage] { lock.lock(); defer { lock.unlock() }; return items.sorted { $0.sequence < $1.sequence } }
43
+ public var pending: [QueuedMessage] { all.filter { $0.status == .pending } }
44
+
45
+ @discardableResult
46
+ public func enqueue(clientMessageId: String = UUID().uuidString.lowercased(), text: String, attachments: [Attachment] = [], context: ChatContext?, conversationId: String?) -> QueuedMessage {
47
+ lock.lock(); defer { lock.unlock() }
48
+ let next = (items.map { $0.sequence }.max() ?? 0) + 1
49
+ let item = QueuedMessage(clientMessageId: clientMessageId, text: text, attachments: attachments, context: context,
50
+ createdAt: clock.now(), sequence: next, attempts: 0, status: .pending, failureReason: nil, conversationId: conversationId)
51
+ items.append(item)
52
+ persist()
53
+ return item
54
+ }
55
+
56
+ public func item(_ clientMessageId: String) -> QueuedMessage? {
57
+ lock.lock(); defer { lock.unlock() }
58
+ return items.first { $0.clientMessageId == clientMessageId }
59
+ }
60
+
61
+ public func remove(_ clientMessageId: String) {
62
+ lock.lock(); defer { lock.unlock() }
63
+ items.removeAll { $0.clientMessageId == clientMessageId }
64
+ persist()
65
+ }
66
+
67
+ public func recordAttempt(_ clientMessageId: String) {
68
+ lock.lock(); defer { lock.unlock() }
69
+ if let index = items.firstIndex(where: { $0.clientMessageId == clientMessageId }) {
70
+ items[index].attempts += 1
71
+ persist()
72
+ }
73
+ }
74
+
75
+ public func markFailed(_ clientMessageId: String, reason: String) {
76
+ lock.lock(); defer { lock.unlock() }
77
+ if let index = items.firstIndex(where: { $0.clientMessageId == clientMessageId }) {
78
+ items[index].status = .failed
79
+ items[index].failureReason = reason
80
+ persist()
81
+ }
82
+ }
83
+
84
+ /// "Try again": back to pending in the same place. The 24 hours start again.
85
+ public func retry(_ clientMessageId: String) {
86
+ lock.lock(); defer { lock.unlock() }
87
+ if let index = items.firstIndex(where: { $0.clientMessageId == clientMessageId }) {
88
+ items[index].status = .pending
89
+ items[index].failureReason = nil
90
+ items[index].attempts = 0
91
+ items[index].createdAt = clock.now()
92
+ persist()
93
+ }
94
+ }
95
+
96
+ /// Marks pending messages older than 24 hours as failed. Returns their ids.
97
+ @discardableResult
98
+ public func expireOld() -> [String] {
99
+ lock.lock(); defer { lock.unlock() }
100
+ let now = clock.now()
101
+ var expired: [String] = []
102
+ for index in items.indices where items[index].status == .pending && now.timeIntervalSince(items[index].createdAt) >= OfflineQueue.expiry {
103
+ items[index].status = .failed
104
+ items[index].failureReason = "expired"
105
+ expired.append(items[index].clientMessageId)
106
+ }
107
+ if !expired.isEmpty { persist() }
108
+ return expired
109
+ }
110
+
111
+ public func clear() {
112
+ lock.lock(); defer { lock.unlock() }
113
+ items = []
114
+ storage.remove(StorageName.queue)
115
+ }
116
+ }
117
+
118
+ public enum SendOutcome: Equatable, Sendable {
119
+ case sent(SendReceipt)
120
+ case retryLater(after: TimeInterval?)
121
+ case failedPermanently(reason: String)
122
+ }
123
+
124
+ public enum OutboxPolicy {
125
+ /// 401 and 429 and 5xx and network errors wait and retry. Any other 4xx fails for good.
126
+ public static func classify(_ error: Error) -> SendOutcome {
127
+ if error is CancellationError { return .retryLater(after: nil) }
128
+ let mapped = ErrorPolicy.map(error)
129
+ guard let api = error as? ChatAPIError else {
130
+ switch mapped {
131
+ case .rateLimited(let retry): return .retryLater(after: retry)
132
+ case .loginNeeded, .consentRequired, .network, .cancelled: return .retryLater(after: nil)
133
+ case .unavailable: return .failedPermanently(reason: "unavailable")
134
+ default: return .retryLater(after: nil)
135
+ }
136
+ }
137
+ switch api {
138
+ case .network, .timeout, .cancelled, .decoding: return .retryLater(after: nil)
139
+ case .http(let status, _, let retry):
140
+ if status == 401 || status == 429 { return .retryLater(after: retry) }
141
+ if (400..<500).contains(status) { return .failedPermanently(reason: "http_\(status)") }
142
+ return .retryLater(after: nil)
143
+ }
144
+ }
145
+ }
146
+
147
+ /// Sends the queue in order. One drain at a time.
148
+ public actor Outbox {
149
+ public enum Update: Equatable, Sendable {
150
+ case changed
151
+ case sent(clientMessageId: String, receipt: SendReceipt)
152
+ case failed(clientMessageId: String, reason: String)
153
+ }
154
+
155
+ public typealias Sender = @Sendable (QueuedMessage) async throws -> SendReceipt
156
+ public typealias Listener = @Sendable (Update) -> Void
157
+
158
+ public let queue: OfflineQueue
159
+ private let clock: ChatClock
160
+ private let random: RandomSource
161
+ private let sender: Sender
162
+ private var listener: Listener?
163
+ private var draining = false
164
+ private var task: Task<Void, Never>?
165
+ private var online = true
166
+
167
+ public init(queue: OfflineQueue, clock: ChatClock = SystemClock(), random: RandomSource = SystemRandom(), sender: @escaping Sender) {
168
+ self.queue = queue
169
+ self.clock = clock
170
+ self.random = random
171
+ self.sender = sender
172
+ }
173
+
174
+ public func setListener(_ listener: Listener?) { self.listener = listener }
175
+
176
+ /// Starts a drain when none runs.
177
+ public func kick() {
178
+ guard !draining else { return }
179
+ draining = true
180
+ task = Task { [weak self] in
181
+ await self?.drain()
182
+ }
183
+ }
184
+
185
+ public func networkReturned() { online = true; kick() }
186
+ public func networkLost() { online = false }
187
+
188
+ public func cancelAll() {
189
+ task?.cancel()
190
+ task = nil
191
+ draining = false
192
+ }
193
+
194
+ /// One full pass. Public so tests can await it.
195
+ public func drain() async {
196
+ defer { draining = false }
197
+ var waitAttempt = 0
198
+ while !Task.isCancelled {
199
+ for id in queue.expireOld() { listener?(.failed(clientMessageId: id, reason: "expired")) }
200
+ guard let next = queue.pending.first else { return }
201
+ queue.recordAttempt(next.clientMessageId)
202
+ do {
203
+ let receipt = try await sender(next)
204
+ queue.remove(next.clientMessageId)
205
+ listener?(.sent(clientMessageId: next.clientMessageId, receipt: receipt))
206
+ waitAttempt = 0
207
+ } catch {
208
+ switch OutboxPolicy.classify(error) {
209
+ case .failedPermanently(let reason):
210
+ queue.markFailed(next.clientMessageId, reason: reason)
211
+ listener?(.failed(clientMessageId: next.clientMessageId, reason: reason))
212
+ case .retryLater(let after):
213
+ listener?(.changed)
214
+ let delay = after ?? Backoff.delay(attempt: waitAttempt, random: random)
215
+ waitAttempt += 1
216
+ do { try await clock.sleep(seconds: min(delay, 300)) } catch { return }
217
+ case .sent:
218
+ break
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,74 @@
1
+ import Foundation
2
+
3
+ public struct ZellioPush: Equatable, Sendable {
4
+ public var version: Int
5
+ public var type: String
6
+ public var conversationId: String?
7
+ public var messageId: String?
8
+ }
9
+
10
+ public enum PushPayload {
11
+ public static let key = "zellio"
12
+
13
+ /// True only when the payload has the `zellio` key.
14
+ public static func isZellioPush(_ payload: [AnyHashable: Any]) -> Bool {
15
+ payload[key] != nil
16
+ }
17
+
18
+ /// Reads the `zellio` value. It is an object on APNs and a JSON string on FCM. Both work.
19
+ public static func parse(_ payload: [AnyHashable: Any]) -> ZellioPush? {
20
+ guard let raw = payload[key] else { return nil }
21
+ var object: [String: Any]?
22
+ if let dictionary = raw as? [String: Any] {
23
+ object = dictionary
24
+ } else if let dictionary = raw as? [AnyHashable: Any] {
25
+ var converted: [String: Any] = [:]
26
+ for (k, v) in dictionary { if let name = k as? String { converted[name] = v } }
27
+ object = converted
28
+ } else if let text = raw as? String, let data = text.data(using: .utf8) {
29
+ object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
30
+ }
31
+ guard let fields = object else { return nil }
32
+ let version = (fields["v"] as? Int) ?? Int((fields["v"] as? String) ?? "") ?? 1
33
+ return ZellioPush(version: version, type: (fields["type"] as? String) ?? "message",
34
+ conversationId: fields["conversationId"] as? String, messageId: fields["messageId"] as? String)
35
+ }
36
+ }
37
+
38
+ /// The unread number. Safe from any thread.
39
+ public final class UnreadTracker: @unchecked Sendable {
40
+ private struct State {
41
+ var count = 0
42
+ var listeners: [Int: @Sendable (Int) -> Void] = [:]
43
+ var nextToken = 0
44
+ }
45
+ private let state = Locked(State())
46
+
47
+ public init() {}
48
+
49
+ public var count: Int { state.with { $0.count } }
50
+
51
+ public func set(_ value: Int) {
52
+ let clean = max(0, value)
53
+ let (changed, listeners): (Bool, [@Sendable (Int) -> Void]) = state.with { s in
54
+ let changed = s.count != clean
55
+ s.count = clean
56
+ return (changed, Array(s.listeners.values))
57
+ }
58
+ if changed { for listener in listeners { listener(clean) } }
59
+ }
60
+
61
+ /// Returns a token for `remove`. The listener is also called once with the current value.
62
+ @discardableResult
63
+ public func add(_ listener: @escaping @Sendable (Int) -> Void) -> Int {
64
+ let (token, current): (Int, Int) = state.with { s in
65
+ s.nextToken += 1
66
+ s.listeners[s.nextToken] = listener
67
+ return (s.nextToken, s.count)
68
+ }
69
+ listener(current)
70
+ return token
71
+ }
72
+
73
+ public func remove(_ token: Int) { state.with { $0.listeners[token] = nil } }
74
+ }
@@ -0,0 +1,328 @@
1
+ import Foundation
2
+
3
+ public struct SSEEvent: Equatable, Sendable {
4
+ public var name: String?
5
+ public var data: String
6
+ public var id: String?
7
+ }
8
+
9
+ /// Incremental server-sent events parser. Feed bytes as they arrive.
10
+ /// Handles LF, CRLF and CR line ends, comments (`: ping`), multi-line data, and chunks that split a line or a UTF-8 character.
11
+ public struct SSEParser: Sendable {
12
+ private var buffer = [UInt8]()
13
+ private var name: String?
14
+ private var dataLines = [String]()
15
+ private var lastId: String?
16
+ private var sawCR = false
17
+
18
+ public init() {}
19
+
20
+ public mutating func feed(_ data: Data) -> [SSEEvent] {
21
+ var events: [SSEEvent] = []
22
+ for byte in data {
23
+ if byte == 10 { // LF
24
+ if sawCR { sawCR = false; continue } // second half of CRLF
25
+ finishLine(&events)
26
+ } else if byte == 13 { // CR
27
+ sawCR = true
28
+ finishLine(&events)
29
+ } else {
30
+ sawCR = false
31
+ buffer.append(byte)
32
+ }
33
+ }
34
+ return events
35
+ }
36
+
37
+ private mutating func finishLine(_ events: inout [SSEEvent]) {
38
+ let line = String(decoding: buffer, as: UTF8.self)
39
+ buffer.removeAll(keepingCapacity: true)
40
+ if line.isEmpty {
41
+ // A blank line ends the event.
42
+ if !dataLines.isEmpty || name != nil {
43
+ events.append(SSEEvent(name: name, data: dataLines.joined(separator: "\n"), id: lastId))
44
+ }
45
+ name = nil
46
+ dataLines = []
47
+ return
48
+ }
49
+ if line.hasPrefix(":") { return } // comment, for example ": ping"
50
+ var field = line
51
+ var value = ""
52
+ if let colon = line.firstIndex(of: ":") {
53
+ field = String(line[line.startIndex..<colon])
54
+ var rest = line[line.index(after: colon)...]
55
+ if rest.hasPrefix(" ") { rest = rest.dropFirst() }
56
+ value = String(rest)
57
+ }
58
+ switch field {
59
+ case "event": name = value
60
+ case "data": dataLines.append(value)
61
+ case "id": lastId = value
62
+ default: break // retry and unknown fields are ignored
63
+ }
64
+ }
65
+ }
66
+
67
+ /// Reconnect delays: 1, 2, 4, 8, 16, then 30 seconds, each with jitter.
68
+ public struct Backoff: Sendable {
69
+ public static let steps: [Double] = [1, 2, 4, 8, 16, 30]
70
+ public static let jitter = 0.25
71
+
72
+ /// `attempt` starts at 0. Jitter spreads the delay by plus or minus 25 percent.
73
+ public static func delay(attempt: Int, random: RandomSource) -> Double {
74
+ let base = steps[min(max(attempt, 0), steps.count - 1)]
75
+ let spread = (random.unit() * 2 - 1) * jitter
76
+ return base * (1 + spread)
77
+ }
78
+
79
+ public static func baseDelay(attempt: Int) -> Double { steps[min(max(attempt, 0), steps.count - 1)] }
80
+ }
81
+
82
+ public enum StreamState: Equatable, Sendable {
83
+ case idle
84
+ case connecting
85
+ case open
86
+ case waiting(attempt: Int)
87
+ case paused // app is in the background
88
+ }
89
+
90
+ public enum StreamInput: Equatable, Sendable {
91
+ case start
92
+ case opened
93
+ case bytes
94
+ case ended // closed by the server (10 minutes) or lost
95
+ case failed
96
+ case deadTimeout // 45 s with no bytes
97
+ case enteredBackground
98
+ case enteredForeground
99
+ case stop
100
+ }
101
+
102
+ public enum StreamAction: Equatable, Sendable {
103
+ case connect
104
+ case closeConnection
105
+ case scheduleReconnect(attempt: Int)
106
+ /// Read /unread, then messages with `after`, then reopen the stream.
107
+ case catchUp
108
+ }
109
+
110
+ /// The reconnect rules as a pure function of state and input.
111
+ public struct StreamMachine: Sendable {
112
+ public private(set) var state: StreamState = .idle
113
+ public private(set) var wanted = false
114
+ private var attempt = 0
115
+
116
+ public static let deadAfter: TimeInterval = 45
117
+
118
+ public init() {}
119
+
120
+ public mutating func handle(_ input: StreamInput) -> [StreamAction] {
121
+ switch input {
122
+ case .start:
123
+ wanted = true
124
+ guard state == .idle || state == .paused else { return [] }
125
+ attempt = 0
126
+ state = .connecting
127
+ return [.connect]
128
+ case .opened:
129
+ guard state == .connecting else { return [] }
130
+ attempt = 0
131
+ state = .open
132
+ return []
133
+ case .bytes:
134
+ return []
135
+ case .ended, .failed, .deadTimeout:
136
+ guard wanted, state == .connecting || state == .open else { return [] }
137
+ let next = attempt
138
+ attempt += 1
139
+ state = .waiting(attempt: next)
140
+ var actions: [StreamAction] = []
141
+ if input == .deadTimeout { actions.append(.closeConnection) }
142
+ actions.append(.scheduleReconnect(attempt: next))
143
+ return actions
144
+ case .enteredBackground:
145
+ guard wanted else { return [] }
146
+ let wasLive = state != .idle && state != .paused
147
+ state = .paused
148
+ return wasLive ? [.closeConnection] : []
149
+ case .enteredForeground:
150
+ guard wanted, state == .paused else { return [] }
151
+ attempt = 0
152
+ state = .connecting
153
+ return [.catchUp]
154
+ case .stop:
155
+ wanted = false
156
+ let wasLive = state != .idle
157
+ state = .idle
158
+ attempt = 0
159
+ return wasLive ? [.closeConnection] : []
160
+ }
161
+ }
162
+
163
+ /// The reconnect timer fired.
164
+ public mutating func reconnectTimerFired() -> [StreamAction] {
165
+ guard wanted, case .waiting = state else { return [] }
166
+ state = .connecting
167
+ return [.catchUp]
168
+ }
169
+ }
170
+
171
+ /// Runs the stream: connect, parse, reconnect with backoff, catch up after every gap.
172
+ public actor RealtimeClient {
173
+ public typealias TokenSource = @Sendable () async throws -> String
174
+ public typealias EventHandler = @Sendable (RealtimeEvent) -> Void
175
+ public typealias CatchUp = @Sendable () async -> Void
176
+ public typealias AuthFailure = @Sendable (ChatAPIError) async -> Void
177
+
178
+ private let api: ChatAPI
179
+ private let clock: ChatClock
180
+ private let random: RandomSource
181
+ private let tokenSource: TokenSource
182
+ private let onEvent: EventHandler
183
+ private let catchUp: CatchUp
184
+ private let onFailure: AuthFailure
185
+ private var machine = StreamMachine()
186
+ private var loop: Task<Void, Never>?
187
+ private var generation = 0
188
+
189
+ public init(api: ChatAPI, clock: ChatClock = SystemClock(), random: RandomSource = SystemRandom(),
190
+ tokenSource: @escaping TokenSource, onEvent: @escaping EventHandler,
191
+ catchUp: @escaping CatchUp, onFailure: @escaping AuthFailure) {
192
+ self.api = api
193
+ self.clock = clock
194
+ self.random = random
195
+ self.tokenSource = tokenSource
196
+ self.onEvent = onEvent
197
+ self.catchUp = catchUp
198
+ self.onFailure = onFailure
199
+ }
200
+
201
+ public var state: StreamState { machine.state }
202
+
203
+ public func start() { apply(machine.handle(.start)) }
204
+ public func stop() { apply(machine.handle(.stop)) }
205
+ public func enteredBackground() { apply(machine.handle(.enteredBackground)) }
206
+ public func enteredForeground() { apply(machine.handle(.enteredForeground)) }
207
+
208
+ private func apply(_ actions: [StreamAction]) {
209
+ for action in actions {
210
+ switch action {
211
+ case .connect, .catchUp:
212
+ launch(initialDelay: 0, runCatchUp: action == .catchUp)
213
+ case .closeConnection:
214
+ generation += 1
215
+ loop?.cancel()
216
+ loop = nil
217
+ case .scheduleReconnect(let attempt):
218
+ launch(initialDelay: Backoff.delay(attempt: attempt, random: random), runCatchUp: true, fromTimer: true)
219
+ }
220
+ }
221
+ }
222
+
223
+ private func launch(initialDelay: Double, runCatchUp: Bool, fromTimer: Bool = false) {
224
+ generation += 1
225
+ let mine = generation
226
+ loop?.cancel()
227
+ loop = Task { [weak self] in
228
+ await self?.run(generation: mine, delay: initialDelay, runCatchUp: runCatchUp, fromTimer: fromTimer)
229
+ }
230
+ }
231
+
232
+ private func isCurrent(_ mine: Int) -> Bool { mine == generation && !Task.isCancelled }
233
+
234
+ private func run(generation mine: Int, delay: Double, runCatchUp: Bool, fromTimer: Bool) async {
235
+ if delay > 0 {
236
+ do { try await clock.sleep(seconds: delay) } catch { return }
237
+ }
238
+ guard isCurrent(mine) else { return }
239
+ if fromTimer {
240
+ let actions = machine.reconnectTimerFired()
241
+ if actions.isEmpty { return }
242
+ }
243
+ if runCatchUp { await catchUp() }
244
+ guard isCurrent(mine) else { return }
245
+
246
+ let token: String
247
+ do { token = try await tokenSource() } catch {
248
+ await ended(mine, input: .failed)
249
+ return
250
+ }
251
+ guard isCurrent(mine) else { return }
252
+
253
+ let lastByte = Locked(clock.now())
254
+ let dead = Locked(false)
255
+ let stream = api.openStream(token: token)
256
+ let reader: Task<ChatAPIError?, Never> = Task { [weak self, clock] in
257
+ var parser = SSEParser()
258
+ do {
259
+ for try await chunk in stream {
260
+ lastByte.with { $0 = clock.now() }
261
+ switch chunk {
262
+ case .opened:
263
+ await self?.opened(mine)
264
+ case .data(let data):
265
+ for event in parser.feed(data) {
266
+ if let decoded = RealtimeEventDecoder.decode(event) { await self?.deliver(decoded, generation: mine) }
267
+ }
268
+ }
269
+ }
270
+ return nil as ChatAPIError?
271
+ } catch let error as ChatAPIError {
272
+ return error
273
+ } catch {
274
+ return ChatAPIError.network
275
+ }
276
+ }
277
+ let watchdog = Task { [clock] in
278
+ while !Task.isCancelled {
279
+ do { try await clock.sleep(seconds: 5) } catch { return }
280
+ let quiet = clock.now().timeIntervalSince(lastByte.snapshot)
281
+ if quiet >= StreamMachine.deadAfter {
282
+ dead.with { $0 = true }
283
+ reader.cancel()
284
+ return
285
+ }
286
+ }
287
+ }
288
+ let failure = await withTaskCancellationHandler {
289
+ await reader.value
290
+ } onCancel: {
291
+ reader.cancel()
292
+ }
293
+ watchdog.cancel()
294
+ guard isCurrent(mine) else { return }
295
+ if let failure = failure {
296
+ if failure.status == 401 || failure.status == 403 || failure.status == 426 {
297
+ await onFailure(failure)
298
+ }
299
+ // These do not change by trying again. `start()` opens the stream later.
300
+ let reaction = ErrorPolicy.reaction(for: failure)
301
+ if reaction == .stopUnavailable || reaction == .showLoginNeeded {
302
+ guard isCurrent(mine) else { return }
303
+ apply(machine.handle(.stop))
304
+ return
305
+ }
306
+ if case .http(429, _, let retryAfter?) = failure, retryAfter > 0 {
307
+ do { try await clock.sleep(seconds: min(retryAfter, 300)) } catch { return }
308
+ guard isCurrent(mine) else { return }
309
+ }
310
+ }
311
+ await ended(mine, input: dead.snapshot ? .deadTimeout : (failure == nil ? .ended : .failed))
312
+ }
313
+
314
+ private func opened(_ mine: Int) {
315
+ guard mine == generation else { return }
316
+ _ = machine.handle(.opened)
317
+ }
318
+
319
+ private func deliver(_ event: RealtimeEvent, generation mine: Int) {
320
+ guard mine == generation else { return }
321
+ onEvent(event)
322
+ }
323
+
324
+ private func ended(_ mine: Int, input: StreamInput) async {
325
+ guard mine == generation else { return }
326
+ apply(machine.handle(input))
327
+ }
328
+ }