@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,238 @@
1
+ import Foundation
2
+
3
+ /// A #RRGGBB color. Parsing never fails the caller: use the failable init and fall back.
4
+ public struct HexColor: Equatable, Sendable {
5
+ public let r: Int
6
+ public let g: Int
7
+ public let b: Int
8
+
9
+ public init(r: Int, g: Int, b: Int) {
10
+ self.r = min(255, max(0, r))
11
+ self.g = min(255, max(0, g))
12
+ self.b = min(255, max(0, b))
13
+ }
14
+
15
+ public init?(_ text: String) {
16
+ var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
17
+ if value.hasPrefix("#") { value.removeFirst() }
18
+ guard value.count == 6, let number = Int(value, radix: 16) else { return nil }
19
+ self.init(r: (number >> 16) & 255, g: (number >> 8) & 255, b: number & 255)
20
+ }
21
+
22
+ public var hex: String {
23
+ func two(_ v: Int) -> String {
24
+ let s = String(v, radix: 16, uppercase: true)
25
+ return s.count == 1 ? "0" + s : s
26
+ }
27
+ return "#" + two(r) + two(g) + two(b)
28
+ }
29
+
30
+ public static let black = HexColor(r: 0, g: 0, b: 0)
31
+ public static let white = HexColor(r: 255, g: 255, b: 255)
32
+ }
33
+
34
+ /// Ports packages/zeechat-ui/src/color-contrast.ts.
35
+ public enum Contrast {
36
+ /// WCAG relative luminance.
37
+ public static func luminance(_ color: HexColor) -> Double {
38
+ func channel(_ value: Int) -> Double {
39
+ let c = Double(value) / 255.0
40
+ return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
41
+ }
42
+ return channel(color.r) * 0.2126 + channel(color.g) * 0.7152 + channel(color.b) * 0.0722
43
+ }
44
+
45
+ public static func ratio(_ a: HexColor, _ b: HexColor) -> Double {
46
+ let x = luminance(a)
47
+ let y = luminance(b)
48
+ return (max(x, y) + 0.05) / (min(x, y) + 0.05)
49
+ }
50
+
51
+ /// Black or white, whichever has the higher contrast on the background.
52
+ public static func readable(on background: HexColor) -> (foreground: HexColor, ratio: Double) {
53
+ let black = ratio(background, .black)
54
+ let white = ratio(background, .white)
55
+ return black >= white ? (.black, black) : (.white, white)
56
+ }
57
+
58
+ private static let lightBrand = 0.4
59
+
60
+ /// A brand color for surfaces that carry text. Mid-tone brands get darker until white text passes 4.5:1.
61
+ public static func brandSurface(_ color: HexColor) -> (background: HexColor, foreground: HexColor) {
62
+ if ratio(color, .white) >= 4.5 { return (color, .white) }
63
+ if luminance(color) > lightBrand { return (color, .black) }
64
+ let (h, s, l) = toHSL(color)
65
+ var next = l
66
+ while next >= 0 {
67
+ let shade = fromHSL(h, s, next)
68
+ if ratio(shade, .white) >= 4.5 { return (shade, .white) }
69
+ next -= 0.01
70
+ }
71
+ return (.black, .white)
72
+ }
73
+
74
+ public static func toHSL(_ color: HexColor) -> (Double, Double, Double) {
75
+ let r = Double(color.r) / 255, g = Double(color.g) / 255, b = Double(color.b) / 255
76
+ let maxV = max(r, g, b), minV = min(r, g, b)
77
+ let light = (maxV + minV) / 2
78
+ if maxV == minV { return (0, 0, light) }
79
+ let delta = maxV - minV
80
+ let saturation = light > 0.5 ? delta / (2 - maxV - minV) : delta / (maxV + minV)
81
+ let hue: Double
82
+ if maxV == r { hue = (g - b) / delta + (g < b ? 6 : 0) } else if maxV == g { hue = (b - r) / delta + 2 } else { hue = (r - g) / delta + 4 }
83
+ return (hue / 6, saturation, light)
84
+ }
85
+
86
+ public static func fromHSL(_ h: Double, _ s: Double, _ l: Double) -> HexColor {
87
+ let q = l < 0.5 ? l * (1 + s) : l + s - l * s
88
+ let p = 2 * l - q
89
+ func channel(_ t0: Double) -> Int {
90
+ var t = t0
91
+ if t < 0 { t += 1 }
92
+ if t > 1 { t -= 1 }
93
+ let value: Double
94
+ if t < 1.0 / 6 { value = p + (q - p) * 6 * t } else if t < 0.5 { value = q } else if t < 2.0 / 3 { value = p + (q - p) * (2.0 / 3 - t) * 6 } else { value = p }
95
+ return Int((value * 255).rounded())
96
+ }
97
+ return HexColor(r: channel(h + 1.0 / 3), g: channel(h), b: channel(h - 1.0 / 3))
98
+ }
99
+
100
+ /// Lightness scaled down by a percent. Used for the header gradient end (18 percent).
101
+ public static func darken(_ color: HexColor, percent: Double) -> HexColor {
102
+ let (h, s, l) = toHSL(color)
103
+ return fromHSL(h, s, max(0, l * (1 - percent / 100)))
104
+ }
105
+
106
+ /// `top` over `bottom` at an alpha from 0 to 1.
107
+ public static func blend(_ top: HexColor, over bottom: HexColor, alpha: Double) -> HexColor {
108
+ func mix(_ a: Int, _ b: Int) -> Int { Int((Double(a) * alpha + Double(b) * (1 - alpha)).rounded()) }
109
+ return HexColor(r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b))
110
+ }
111
+ }
112
+
113
+ /// All colors for one screen, resolved from the snapshot and the dark flag. Hex strings.
114
+ public struct ChatPalette: Equatable, Sendable {
115
+ public var isDark: Bool
116
+ public var primary: HexColor
117
+ public var onPrimary: HexColor
118
+ public var headerEnd: HexColor
119
+ public var customer: HexColor
120
+ public var onCustomer: HexColor
121
+ public var bg: HexColor
122
+ public var bgAlt: HexColor
123
+ public var ink: HexColor
124
+ public var ink2: HexColor
125
+ public var line: HexColor
126
+ public var agentBubble: HexColor
127
+ public var ok: HexColor
128
+ public var bad: HexColor
129
+ public var link: HexColor
130
+ public var avatarTone: HexColor
131
+ public var avatarInk: HexColor
132
+ public var disabledSend: HexColor
133
+
134
+ /// Resolves the palette. `systemDark` is the device setting; the snapshot theme and `override` can force it.
135
+ public static func resolve(appearance: Appearance, systemDark: Bool, override: ThemeMode? = nil) -> ChatPalette {
136
+ let mode = override ?? appearance.theme
137
+ let dark = mode == .dark || (mode == .auto && systemDark)
138
+ let primary = HexColor(appearance.primaryColor) ?? HexColor(r: 125, g: 59, b: 237)
139
+ let customer = HexColor(appearance.customerMessageColor) ?? primary
140
+ let surface = HexColor(appearance.surfaceColor) ?? .white
141
+
142
+ let bg: HexColor = dark ? HexColor("#0F1525") ?? .black : surface
143
+ // A dark surface in light mode needs light ink: pick by contrast.
144
+ let ink: HexColor
145
+ let ink2: HexColor
146
+ if dark {
147
+ ink = HexColor("#F4F6FC") ?? .white
148
+ ink2 = HexColor("#AEB6CC") ?? .white
149
+ } else if Contrast.readable(on: bg).foreground == .white {
150
+ ink = HexColor("#F4F6FC") ?? .white
151
+ ink2 = HexColor("#AEB6CC") ?? .white
152
+ } else {
153
+ ink = HexColor("#111426") ?? .black
154
+ ink2 = HexColor("#50556B") ?? .black
155
+ }
156
+ let inkIsLight = Contrast.luminance(ink) > 0.5
157
+ let bgAlt: HexColor = dark ? HexColor("#161D30") ?? bg : Contrast.blend(ink, over: bg, alpha: 0.04)
158
+ let agent: HexColor = dark ? HexColor("#1E2740") ?? bg : (inkIsLight ? Contrast.blend(.white, over: bg, alpha: 0.12) : HexColor("#F1F1F6") ?? bg)
159
+ let line: HexColor = dark ? HexColor("#222B42") ?? ink2 : (inkIsLight ? Contrast.blend(.white, over: bg, alpha: 0.16) : HexColor("#E7E7EF") ?? ink2)
160
+
161
+ // Link: primary if it has 4.5:1 on bg, else ink.
162
+ let link = Contrast.ratio(primary, bg) >= 4.5 ? primary : ink
163
+
164
+ return ChatPalette(
165
+ isDark: dark,
166
+ primary: primary,
167
+ onPrimary: Contrast.readable(on: primary).foreground,
168
+ headerEnd: Contrast.darken(primary, percent: 18),
169
+ customer: customer,
170
+ onCustomer: Contrast.readable(on: customer).foreground,
171
+ bg: bg,
172
+ bgAlt: bgAlt,
173
+ ink: ink,
174
+ ink2: ink2,
175
+ line: line,
176
+ agentBubble: agent,
177
+ ok: HexColor(dark ? "#34D399" : "#16A34A") ?? .black,
178
+ bad: HexColor(dark ? "#F87171" : "#D92D32") ?? .black,
179
+ link: link,
180
+ avatarTone: HexColor(dark ? "#2B3556" : "#D9D3F5") ?? .white,
181
+ avatarInk: HexColor(dark ? "#E7E9F6" : "#3B2A86") ?? .black,
182
+ disabledSend: HexColor(dark ? "#2B3556" : "#E3E3EB") ?? .white
183
+ )
184
+ }
185
+ }
186
+
187
+ /// Sizes from the spec and the approved canvas. Points.
188
+ public enum Metrics {
189
+ public static let bubbleRadius: Double = 20
190
+ public static let tailRadius: Double = 6
191
+ public static let squareBubbleRadius: Double = 8
192
+ public static let squareTailRadius: Double = 4
193
+ public static let cardRadius: Double = 16
194
+ public static let controlRadius: Double = 12
195
+ public static let imageRadius: Double = 14
196
+ public static let maxBubbleFraction: Double = 0.78
197
+ public static let maxBubbleWidthPad: Double = 520
198
+ public static let maxImageWidth: Double = 240
199
+ public static let minTouch: Double = 44
200
+ public static let composerSend: Double = 36
201
+ public static let groupGap: Double = 2
202
+ public static let groupGapCompact: Double = 2
203
+ public static let newSenderGap: Double = 12
204
+ public static let newSenderGapCompact: Double = 8
205
+ public static let groupWindowSeconds: Double = 300
206
+ public static let homeOverlap: Double = 20
207
+
208
+ public static func bubbleRadius(_ corners: Corners) -> Double { corners == .square ? squareBubbleRadius : bubbleRadius }
209
+ public static func tailRadius(_ corners: Corners) -> Double { corners == .square ? squareTailRadius : tailRadius }
210
+ public static func senderGap(_ density: Density) -> Double { density == .compact ? newSenderGapCompact : newSenderGap }
211
+
212
+ /// 1.45 for Arabic, 1.35 for English.
213
+ public static func lineHeightMultiple(_ locale: ChatLocale) -> Double { locale == .ar ? 1.45 : 1.35 }
214
+ }
215
+
216
+ /// Type roles: size and weight. Weight uses the CSS numbers (400, 600, 700).
217
+ public enum TypeRole: Sendable {
218
+ case homeHeading, screenTitle, message, sectionTitle, secondary, caption
219
+
220
+ public var size: Double {
221
+ switch self {
222
+ case .homeHeading: return 28
223
+ case .screenTitle: return 17
224
+ case .message: return 16
225
+ case .sectionTitle: return 15
226
+ case .secondary: return 13
227
+ case .caption: return 12
228
+ }
229
+ }
230
+
231
+ public var weight: Int {
232
+ switch self {
233
+ case .homeHeading: return 700
234
+ case .screenTitle, .sectionTitle: return 600
235
+ case .message, .secondary, .caption: return 400
236
+ }
237
+ }
238
+ }
@@ -0,0 +1,319 @@
1
+ import Foundation
2
+
3
+ public struct ThreadSnapshot: Equatable, Sendable {
4
+ public var conversationId: String?
5
+ public var messages: [ChatMessage] = []
6
+ public var hasOlder = false
7
+ public var isLoadingOlder = false
8
+ public var isLoading = true
9
+ public var typing = false
10
+ public var typingName: String?
11
+ public var error: ChatError?
12
+ public var canAttach = true
13
+ /// Counts agent messages that arrived live. The view uses it for the "New message" pill, the tick and the announcement.
14
+ public var incomingCount = 0
15
+ /// True when a visible message changed in place or was added at the end by this controller.
16
+ public var revision = 0
17
+
18
+ public init(conversationId: String? = nil) { self.conversationId = conversationId }
19
+ }
20
+
21
+ /// The logic of one thread: load, page, send, retry, merge live messages. The view only draws snapshots.
22
+ public actor ThreadController {
23
+ public typealias Observer = @Sendable (ThreadSnapshot) -> Void
24
+
25
+ private let engine: ChatEngine
26
+ private var timeline = Timeline()
27
+ private var snapshot: ThreadSnapshot
28
+ private var observer: Observer?
29
+ private var listenerToken: Int?
30
+ private var typingTask: Task<Void, Never>?
31
+ private var reloadedForExpiredAttachment = false
32
+ private var started = false
33
+
34
+ public init(engine: ChatEngine, conversationId: String?) {
35
+ self.engine = engine
36
+ self.snapshot = ThreadSnapshot(conversationId: conversationId)
37
+ }
38
+
39
+ public func setObserver(_ observer: Observer?) {
40
+ self.observer = observer
41
+ observer?(snapshot)
42
+ }
43
+
44
+ public var conversationId: String? { snapshot.conversationId }
45
+
46
+ private func publish() {
47
+ snapshot.messages = timeline.messages
48
+ snapshot.hasOlder = timeline.hasOlder
49
+ snapshot.revision += 1
50
+ observer?(snapshot)
51
+ }
52
+
53
+ // MARK: Start and stop
54
+
55
+ /// Draws what is saved at once, then reads the newest page.
56
+ public func start() async {
57
+ guard !started else { return }
58
+ started = true
59
+ if let id = snapshot.conversationId, let saved = engine.storage.load(Timeline.self, StorageName.messages(id)) {
60
+ timeline = saved
61
+ }
62
+ snapshot.canAttach = engine.cachedConfig?.snapshot.behavior.allowAttachments ?? true
63
+ mergeQueue()
64
+ snapshot.isLoading = timeline.messages.isEmpty && snapshot.conversationId != nil
65
+ publish()
66
+
67
+ let engineRef = engine
68
+ let token = engineRef.addListener { [weak self] event in
69
+ guard let self = self else { return }
70
+ Task { await self.handle(event) }
71
+ }
72
+ listenerToken = token
73
+
74
+ await loadLatest()
75
+ await engine.outbox.kick()
76
+ }
77
+
78
+ public func stop() {
79
+ if let token = listenerToken { engine.removeListener(token) }
80
+ listenerToken = nil
81
+ typingTask?.cancel()
82
+ observer = nil
83
+ persist()
84
+ }
85
+
86
+ private func persist() {
87
+ guard let id = snapshot.conversationId else { return }
88
+ engine.storage.save(timeline.persisted(), StorageName.messages(id))
89
+ }
90
+
91
+ // MARK: Loading
92
+
93
+ private func loadLatest() async {
94
+ guard let id = snapshot.conversationId else {
95
+ snapshot.isLoading = false
96
+ publish()
97
+ return
98
+ }
99
+ do {
100
+ let page = try await engine.session.perform { token in try await self.engine.api.messages(conversationId: id, token: token) }
101
+ timeline.apply(page: page, older: false, initial: true)
102
+ timeline.trim()
103
+ snapshot.error = nil
104
+ snapshot.isLoading = false
105
+ mergeQueue()
106
+ persist()
107
+ publish()
108
+ await markReadIfNeeded()
109
+ } catch {
110
+ fail(error)
111
+ }
112
+ }
113
+
114
+ private func fail(_ error: Error) {
115
+ let mapped = ErrorPolicy.map(error)
116
+ snapshot.isLoading = false
117
+ // A thread that has messages keeps them. The view shows a banner, not an empty error screen.
118
+ snapshot.error = mapped
119
+ publish()
120
+ if mapped == .conversationNotFound { Task { _ = try? await engine.loadConversations() } }
121
+ }
122
+
123
+ public func retryLoad() async {
124
+ snapshot.isLoading = timeline.messages.isEmpty
125
+ snapshot.error = nil
126
+ publish()
127
+ await loadLatest()
128
+ }
129
+
130
+ /// Older messages, when the customer scrolls to the top.
131
+ public func loadOlder() async {
132
+ guard let id = snapshot.conversationId, let cursor = timeline.nextBefore, !snapshot.isLoadingOlder else { return }
133
+ snapshot.isLoadingOlder = true
134
+ publish()
135
+ do {
136
+ let page = try await engine.session.perform { token in try await self.engine.api.messages(conversationId: id, before: cursor, token: token) }
137
+ timeline.apply(page: page, older: true)
138
+ snapshot.isLoadingOlder = false
139
+ persist()
140
+ publish()
141
+ } catch {
142
+ snapshot.isLoadingOlder = false
143
+ publish()
144
+ }
145
+ }
146
+
147
+ /// Reads messages newer than the last one we have. Used after a gap: reconnect, foreground, push.
148
+ public func catchUp() async {
149
+ if snapshot.conversationId == nil { await adoptConversationFromList() }
150
+ guard let id = snapshot.conversationId else { return }
151
+ guard let cursor = timeline.latest else { await loadLatest(); return }
152
+ do {
153
+ let page = try await engine.session.perform { token in try await self.engine.api.messages(conversationId: id, after: cursor, token: token) }
154
+ let before = timeline.messages.count
155
+ timeline.apply(page: page, older: false)
156
+ timeline.trim()
157
+ countIncoming(timeline.messages.count - before, in: page.messages)
158
+ snapshot.error = nil
159
+ persist()
160
+ publish()
161
+ await markReadIfNeeded()
162
+ } catch {
163
+ if ErrorPolicy.map(error) == .conversationNotFound { fail(error) }
164
+ }
165
+ }
166
+
167
+ /// An attachment URL expired (403): read the page again, once.
168
+ public func reloadAfterExpiredAttachment() async {
169
+ guard !reloadedForExpiredAttachment else { return }
170
+ reloadedForExpiredAttachment = true
171
+ await loadLatest()
172
+ }
173
+
174
+ private func adoptConversationFromList() async {
175
+ guard let page = try? await engine.loadConversations(), let newest = page.conversations.first else { return }
176
+ guard timeline.messages.contains(where: { $0.from == .visitor }) else { return }
177
+ snapshot.conversationId = newest.id
178
+ }
179
+
180
+ private func countIncoming(_ added: Int, in messages: [ChatMessage]) {
181
+ guard added > 0 else { return }
182
+ snapshot.incomingCount += messages.filter { $0.from == .agent }.count
183
+ }
184
+
185
+ private func markReadIfNeeded() async {
186
+ guard let id = snapshot.conversationId,
187
+ let last = timeline.messages.last(where: { $0.from == .agent && $0.serverId != nil }),
188
+ let serverId = last.serverId else { return }
189
+ await engine.markRead(conversationId: id, upTo: serverId)
190
+ }
191
+
192
+ // MARK: Live events
193
+
194
+ private func handle(_ event: HubEvent) async {
195
+ switch event {
196
+ case .localDataWiped:
197
+ break
198
+ case .reconnected:
199
+ await catchUp()
200
+ case .outbox(let update):
201
+ handleOutbox(update)
202
+ case .realtime(let realtime):
203
+ switch realtime {
204
+ case .ready(let id):
205
+ if snapshot.conversationId == nil, let id = id, timeline.messages.contains(where: { $0.from == .visitor }) {
206
+ snapshot.conversationId = id
207
+ publish()
208
+ }
209
+ case .message(let message, let conversationId):
210
+ guard accepts(streamConversation: conversationId ?? engine.streamConversationId) else { return }
211
+ if snapshot.conversationId == nil, let conversationId = conversationId { snapshot.conversationId = conversationId }
212
+ let isNew = !timeline.messages.contains { ($0.serverId != nil && $0.serverId == message.serverId) || ($0.clientMessageId != nil && $0.clientMessageId == message.clientMessageId) }
213
+ timeline.upsert(message)
214
+ timeline.trim()
215
+ if isNew && message.from == .agent {
216
+ snapshot.incomingCount += 1
217
+ snapshot.typing = false
218
+ }
219
+ persist()
220
+ publish()
221
+ if message.from == .agent { await markReadIfNeeded() }
222
+ case .typing(let active, let name):
223
+ setTyping(active, name: name)
224
+ case .agentJoined:
225
+ break
226
+ case .unread:
227
+ break
228
+ }
229
+ }
230
+ }
231
+
232
+ private func accepts(streamConversation: String?) -> Bool {
233
+ guard let mine = snapshot.conversationId else { return true }
234
+ return streamConversation == nil || streamConversation == mine
235
+ }
236
+
237
+ private func setTyping(_ active: Bool, name: String?) {
238
+ typingTask?.cancel()
239
+ snapshot.typing = active
240
+ snapshot.typingName = active ? name : nil
241
+ publish()
242
+ if active {
243
+ // Safety: if the "stopped" event is lost, the dots go away after 12 seconds.
244
+ typingTask = Task { [weak self] in
245
+ try? await Task.sleep(nanoseconds: 12_000_000_000)
246
+ if Task.isCancelled { return }
247
+ await self?.clearTyping()
248
+ }
249
+ }
250
+ }
251
+
252
+ private func clearTyping() {
253
+ snapshot.typing = false
254
+ publish()
255
+ }
256
+
257
+ // MARK: Sending
258
+
259
+ /// Saves the message to the queue first, shows it at once, then sends. Returns the client id.
260
+ @discardableResult
261
+ public func send(text: String, attachments: [Attachment] = []) -> String? {
262
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
263
+ if trimmed.isEmpty && attachments.isEmpty { return nil }
264
+ let item = engine.queue.enqueue(text: trimmed.clipped(toUTF16: 4000), attachments: attachments, context: engine.context, conversationId: snapshot.conversationId)
265
+ timeline.upsert(item.asMessage(delivery: .sending))
266
+ engine.drafts.set("", conversationId: snapshot.conversationId)
267
+ publish()
268
+ let engineRef = engine
269
+ Task { await engineRef.outbox.kick() }
270
+ return item.clientMessageId
271
+ }
272
+
273
+ /// "Try again" on a failed message.
274
+ public func retry(clientMessageId: String) {
275
+ engine.queue.retry(clientMessageId)
276
+ timeline.setDelivery(clientMessageId: clientMessageId, .sending)
277
+ publish()
278
+ let engineRef = engine
279
+ Task { await engineRef.outbox.kick() }
280
+ }
281
+
282
+ private func handleOutbox(_ update: Outbox.Update) {
283
+ switch update {
284
+ case .changed:
285
+ for item in engine.queue.pending where item.attempts > 0 {
286
+ timeline.setDelivery(clientMessageId: item.clientMessageId, .waiting)
287
+ }
288
+ publish()
289
+ case .sent(let cid, let receipt):
290
+ timeline.setDelivery(clientMessageId: cid, .sent)
291
+ if snapshot.conversationId == nil, let id = receipt.conversationId { snapshot.conversationId = id }
292
+ publish()
293
+ // The server copy arrives by the stream. If it does not, this read finds it.
294
+ Task { [weak self] in
295
+ try? await Task.sleep(nanoseconds: 1_500_000_000)
296
+ await self?.catchUp()
297
+ }
298
+ case .failed(let cid, let reason):
299
+ timeline.setDelivery(clientMessageId: cid, .failed, reason: reason)
300
+ publish()
301
+ }
302
+ }
303
+
304
+ /// Puts queue items that are not in the list yet into it. Used at start, after a restart.
305
+ private func mergeQueue() {
306
+ for item in engine.queue.all where item.conversationId == nil || item.conversationId == snapshot.conversationId {
307
+ let delivery: DeliveryState = item.status == .failed ? .failed : (item.attempts > 0 ? .waiting : .sending)
308
+ if timeline.messages.contains(where: { $0.clientMessageId == item.clientMessageId && $0.serverId != nil }) { continue }
309
+ var message = item.asMessage(delivery: delivery)
310
+ message.failureReason = item.failureReason
311
+ timeline.upsert(message)
312
+ }
313
+ }
314
+
315
+ // MARK: Draft
316
+
317
+ public func draft() -> String { engine.drafts.get(snapshot.conversationId) }
318
+ public func saveDraft(_ text: String) { engine.drafts.set(text, conversationId: snapshot.conversationId) }
319
+ }