@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,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
|
+
}
|