@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,776 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import UIKit
3
+
4
+ /// Rounded rectangle path with a different radius on each corner.
5
+ enum CornerPath {
6
+ static func make(_ rect: CGRect, topLeft: CGFloat, topRight: CGFloat, bottomRight: CGFloat, bottomLeft: CGFloat) -> UIBezierPath {
7
+ let path = UIBezierPath()
8
+ let w = rect.width, h = rect.height
9
+ let tl = min(topLeft, min(w, h) / 2), tr = min(topRight, min(w, h) / 2)
10
+ let br = min(bottomRight, min(w, h) / 2), bl = min(bottomLeft, min(w, h) / 2)
11
+ path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
12
+ path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
13
+ path.addArc(withCenter: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), radius: tr, startAngle: -.pi / 2, endAngle: 0, clockwise: true)
14
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
15
+ path.addArc(withCenter: CGPoint(x: rect.maxX - br, y: rect.maxY - br), radius: br, startAngle: 0, endAngle: .pi / 2, clockwise: true)
16
+ path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
17
+ path.addArc(withCenter: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), radius: bl, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
18
+ path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
19
+ path.addArc(withCenter: CGPoint(x: rect.minX + tl, y: rect.minY + tl), radius: tl, startAngle: .pi, endAngle: -.pi / 2, clockwise: true)
20
+ path.close()
21
+ return path
22
+ }
23
+ }
24
+
25
+ /// The bubble background. It clips its content (a photo fills the bubble edge to edge).
26
+ final class BubbleView: UIView {
27
+ var topLeft: CGFloat = 20 { didSet { setNeedsLayout() } }
28
+ var topRight: CGFloat = 20 { didSet { setNeedsLayout() } }
29
+ var bottomLeft: CGFloat = 20 { didSet { setNeedsLayout() } }
30
+ var bottomRight: CGFloat = 20 { didSet { setNeedsLayout() } }
31
+ private let maskLayer = CAShapeLayer()
32
+
33
+ override init(frame: CGRect) {
34
+ super.init(frame: frame)
35
+ layer.mask = maskLayer
36
+ }
37
+
38
+ required init?(coder: NSCoder) { nil }
39
+
40
+ override func layoutSubviews() {
41
+ super.layoutSubviews()
42
+ maskLayer.path = CornerPath.make(bounds, topLeft: topLeft, topRight: topRight, bottomRight: bottomRight, bottomLeft: bottomLeft).cgPath
43
+ }
44
+ }
45
+
46
+ /// Remembers the real size of images once they loaded, so a row keeps its height when it is drawn again.
47
+ enum ImageRatioCache {
48
+ private static var ratios: [String: CGFloat] = [:]
49
+ private static let lock = NSLock()
50
+ static func ratio(_ key: String) -> CGFloat? { lock.lock(); defer { lock.unlock() }; return ratios[key] }
51
+ static func set(_ key: String, _ value: CGFloat) { lock.lock(); ratios[key] = value; lock.unlock() }
52
+ }
53
+
54
+ struct CellContext {
55
+ var theme: ChatTheme
56
+ var tableWidth: CGFloat
57
+ var isPad: Bool
58
+ var onRetry: (String) -> Void
59
+ var onOpenImage: (UIImage, String) -> Void
60
+ var onOpenURL: (URL) -> Void
61
+ var onCopy: (String) -> Void
62
+ var onImageSized: () -> Void
63
+ var onImageExpired: () -> Void
64
+ }
65
+
66
+ // MARK: - Message cell
67
+
68
+ final class MessageCell: UITableViewCell {
69
+ static let identifier = "MessageCell"
70
+
71
+ private let outer = UIStackView()
72
+ private let nameLabel = UILabel()
73
+ private let nameContainer = UIView()
74
+ private var fileStackRef: UIStackView?
75
+ private var deliveryInnerRef: UIStackView?
76
+ private let rowStack = UIStackView()
77
+ private let avatar = UIView()
78
+ private let avatarImage = UIImageView()
79
+ private let avatarInitial = UILabel()
80
+ private let flexible = UIView()
81
+ private let bubble = BubbleView()
82
+ private let content = UIStackView()
83
+ private let imageHolder = UIView()
84
+ private let photo = UIImageView()
85
+ private let photoPlaceholder = UIView()
86
+ private let photoFailed = UIStackView()
87
+ private let photoFailedIcon = UIImageView()
88
+ private let photoFailedLabel = UILabel()
89
+ private let fileRow = UIControl()
90
+ private let fileIcon = UIImageView()
91
+ private let fileName = UILabel()
92
+ private let fileSize = UILabel()
93
+ private let textHolder = UIView()
94
+ private let textView = UITextView()
95
+ private let deliveryRow = UIStackView()
96
+ private let deliveryIcon = UIImageView()
97
+ private let deliveryLabel = UILabel()
98
+ private let retryButton = UIButton(type: .system)
99
+ private var topGap: NSLayoutConstraint?
100
+ private var bubbleMax: NSLayoutConstraint?
101
+ private var photoHeight: NSLayoutConstraint?
102
+ private var photoWidth: NSLayoutConstraint?
103
+ private var context: CellContext?
104
+ private var row: MessageRow?
105
+ private var imageTask: Task<Void, Never>?
106
+ private var firstImageURL: String?
107
+ private var firstImageFailed = false
108
+ private var currentTheme: ChatTheme?
109
+ private var fileURL: String?
110
+ private var textBottomCollapsed: NSLayoutConstraint?
111
+
112
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
113
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
114
+ build()
115
+ }
116
+
117
+ required init?(coder: NSCoder) { nil }
118
+
119
+ private func build() {
120
+ backgroundColor = .clear
121
+ selectionStyle = .none
122
+ contentView.backgroundColor = .clear
123
+
124
+ outer.axis = .vertical
125
+ outer.spacing = 0
126
+ outer.translatesAutoresizingMaskIntoConstraints = false
127
+ contentView.addSubview(outer)
128
+ let top = outer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 2)
129
+ topGap = top
130
+ NSLayoutConstraint.activate([
131
+ top,
132
+ outer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
133
+ outer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 14),
134
+ outer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -14),
135
+ ])
136
+
137
+ // Name above the first agent message of a group.
138
+ nameLabel.numberOfLines = 1
139
+ nameLabel.translatesAutoresizingMaskIntoConstraints = false
140
+ nameContainer.addSubview(nameLabel)
141
+ NSLayoutConstraint.activate([
142
+ nameLabel.topAnchor.constraint(equalTo: nameContainer.topAnchor, constant: 10),
143
+ nameLabel.bottomAnchor.constraint(equalTo: nameContainer.bottomAnchor, constant: -4),
144
+ nameLabel.leadingAnchor.constraint(equalTo: nameContainer.leadingAnchor, constant: 36),
145
+ nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: nameContainer.trailingAnchor),
146
+ ])
147
+
148
+ rowStack.axis = .horizontal
149
+ rowStack.alignment = .bottom
150
+ rowStack.spacing = 8
151
+
152
+ avatar.translatesAutoresizingMaskIntoConstraints = false
153
+ avatar.layer.cornerRadius = 14
154
+ avatar.clipsToBounds = true
155
+ NSLayoutConstraint.activate([avatar.widthAnchor.constraint(equalToConstant: 28), avatar.heightAnchor.constraint(equalToConstant: 28)])
156
+ avatarImage.contentMode = .scaleAspectFill
157
+ avatarImage.translatesAutoresizingMaskIntoConstraints = false
158
+ avatarInitial.textAlignment = .center
159
+ avatarInitial.translatesAutoresizingMaskIntoConstraints = false
160
+ avatar.addSubview(avatarInitial)
161
+ avatar.addSubview(avatarImage)
162
+ NSLayoutConstraint.activate([
163
+ avatarInitial.centerXAnchor.constraint(equalTo: avatar.centerXAnchor),
164
+ avatarInitial.centerYAnchor.constraint(equalTo: avatar.centerYAnchor),
165
+ avatarImage.leadingAnchor.constraint(equalTo: avatar.leadingAnchor),
166
+ avatarImage.trailingAnchor.constraint(equalTo: avatar.trailingAnchor),
167
+ avatarImage.topAnchor.constraint(equalTo: avatar.topAnchor),
168
+ avatarImage.bottomAnchor.constraint(equalTo: avatar.bottomAnchor),
169
+ ])
170
+
171
+ flexible.setContentHuggingPriority(.defaultLow, for: .horizontal)
172
+ flexible.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
173
+
174
+ bubble.translatesAutoresizingMaskIntoConstraints = false
175
+ bubble.setContentHuggingPriority(.required, for: .horizontal)
176
+ let max = bubble.widthAnchor.constraint(lessThanOrEqualToConstant: 300)
177
+ max.priority = .required
178
+ bubbleMax = max
179
+ max.isActive = true
180
+
181
+ content.axis = .vertical
182
+ content.spacing = 0
183
+ content.translatesAutoresizingMaskIntoConstraints = false
184
+ bubble.addSubview(content)
185
+ NSLayoutConstraint.activate([
186
+ content.topAnchor.constraint(equalTo: bubble.topAnchor),
187
+ content.bottomAnchor.constraint(equalTo: bubble.bottomAnchor),
188
+ content.leadingAnchor.constraint(equalTo: bubble.leadingAnchor),
189
+ content.trailingAnchor.constraint(equalTo: bubble.trailingAnchor),
190
+ ])
191
+
192
+ // Photo, edge to edge.
193
+ photo.contentMode = .scaleAspectFill
194
+ photo.clipsToBounds = true
195
+ photo.isUserInteractionEnabled = true
196
+ photo.translatesAutoresizingMaskIntoConstraints = false
197
+ photoPlaceholder.translatesAutoresizingMaskIntoConstraints = false
198
+ // The failed state sits under the photo view, which stays empty and takes the tap.
199
+ photoFailedIcon.image = UIImage(systemName: "photo")
200
+ photoFailedIcon.contentMode = .scaleAspectFit
201
+ photoFailedLabel.numberOfLines = 0
202
+ photoFailedLabel.textAlignment = .center
203
+ photoFailed.axis = .vertical
204
+ photoFailed.alignment = .center
205
+ photoFailed.spacing = 6
206
+ photoFailed.isHidden = true
207
+ photoFailed.translatesAutoresizingMaskIntoConstraints = false
208
+ photoFailed.addArrangedSubview(photoFailedIcon)
209
+ photoFailed.addArrangedSubview(photoFailedLabel)
210
+ imageHolder.addSubview(photoPlaceholder)
211
+ imageHolder.addSubview(photoFailed)
212
+ imageHolder.addSubview(photo)
213
+ let width = imageHolder.widthAnchor.constraint(equalToConstant: 240)
214
+ let height = imageHolder.heightAnchor.constraint(equalToConstant: 180)
215
+ photoWidth = width
216
+ photoHeight = height
217
+ NSLayoutConstraint.activate([
218
+ width, height,
219
+ photo.leadingAnchor.constraint(equalTo: imageHolder.leadingAnchor),
220
+ photo.trailingAnchor.constraint(equalTo: imageHolder.trailingAnchor),
221
+ photo.topAnchor.constraint(equalTo: imageHolder.topAnchor),
222
+ photo.bottomAnchor.constraint(equalTo: imageHolder.bottomAnchor),
223
+ photoPlaceholder.leadingAnchor.constraint(equalTo: imageHolder.leadingAnchor),
224
+ photoPlaceholder.trailingAnchor.constraint(equalTo: imageHolder.trailingAnchor),
225
+ photoPlaceholder.topAnchor.constraint(equalTo: imageHolder.topAnchor),
226
+ photoPlaceholder.bottomAnchor.constraint(equalTo: imageHolder.bottomAnchor),
227
+ photoFailed.centerXAnchor.constraint(equalTo: imageHolder.centerXAnchor),
228
+ photoFailed.centerYAnchor.constraint(equalTo: imageHolder.centerYAnchor),
229
+ photoFailed.leadingAnchor.constraint(greaterThanOrEqualTo: imageHolder.leadingAnchor, constant: 8),
230
+ photoFailedIcon.widthAnchor.constraint(equalToConstant: 28),
231
+ photoFailedIcon.heightAnchor.constraint(equalToConstant: 28),
232
+ ])
233
+ photo.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(photoTapped)))
234
+
235
+ // File row.
236
+ fileIcon.contentMode = .center
237
+ fileIcon.layer.cornerRadius = 11
238
+ fileIcon.clipsToBounds = true
239
+ fileIcon.translatesAutoresizingMaskIntoConstraints = false
240
+ let fileText = UIStackView(arrangedSubviews: [fileName, fileSize])
241
+ fileText.axis = .vertical
242
+ fileText.isUserInteractionEnabled = false
243
+ let fileStack = UIStackView(arrangedSubviews: [fileIcon, fileText])
244
+ fileStackRef = fileStack
245
+ fileStack.axis = .horizontal
246
+ fileStack.spacing = 10
247
+ fileStack.alignment = .center
248
+ fileStack.isUserInteractionEnabled = false
249
+ fileStack.translatesAutoresizingMaskIntoConstraints = false
250
+ fileRow.addSubview(fileStack)
251
+ NSLayoutConstraint.activate([
252
+ fileIcon.widthAnchor.constraint(equalToConstant: 40), fileIcon.heightAnchor.constraint(equalToConstant: 40),
253
+ fileStack.topAnchor.constraint(equalTo: fileRow.topAnchor, constant: 9),
254
+ fileStack.bottomAnchor.constraint(equalTo: fileRow.bottomAnchor, constant: -9),
255
+ fileStack.leadingAnchor.constraint(equalTo: fileRow.leadingAnchor, constant: 14),
256
+ fileStack.trailingAnchor.constraint(equalTo: fileRow.trailingAnchor, constant: -14),
257
+ fileRow.widthAnchor.constraint(greaterThanOrEqualToConstant: 210),
258
+ ])
259
+ fileRow.addTarget(self, action: #selector(fileTapped), for: .touchUpInside)
260
+
261
+ // Text.
262
+ textView.isEditable = false
263
+ textView.isSelectable = false
264
+ textView.isScrollEnabled = false
265
+ textView.isUserInteractionEnabled = false
266
+ textView.backgroundColor = .clear
267
+ textView.textContainerInset = .zero
268
+ textView.textContainer.lineFragmentPadding = 0
269
+ textView.adjustsFontForContentSizeCategory = true
270
+ textView.translatesAutoresizingMaskIntoConstraints = false
271
+ textHolder.addSubview(textView)
272
+ NSLayoutConstraint.activate([
273
+ textView.topAnchor.constraint(equalTo: textHolder.topAnchor, constant: 9),
274
+ textView.bottomAnchor.constraint(equalTo: textHolder.bottomAnchor, constant: -9),
275
+ textView.leadingAnchor.constraint(equalTo: textHolder.leadingAnchor, constant: 14),
276
+ textView.trailingAnchor.constraint(equalTo: textHolder.trailingAnchor, constant: -14),
277
+ ])
278
+ let tap = UITapGestureRecognizer(target: self, action: #selector(bubbleTapped(_:)))
279
+ bubble.addGestureRecognizer(tap)
280
+ let press = UILongPressGestureRecognizer(target: self, action: #selector(bubblePressed(_:)))
281
+ bubble.addGestureRecognizer(press)
282
+
283
+ content.addArrangedSubview(imageHolder)
284
+ content.addArrangedSubview(fileRow)
285
+ content.addArrangedSubview(textHolder)
286
+
287
+ // Delivery line.
288
+ deliveryRow.axis = .horizontal
289
+ deliveryRow.spacing = 8
290
+ deliveryRow.alignment = .center
291
+ deliveryIcon.contentMode = .scaleAspectFit
292
+ deliveryIcon.translatesAutoresizingMaskIntoConstraints = false
293
+ NSLayoutConstraint.activate([deliveryIcon.widthAnchor.constraint(equalToConstant: 14), deliveryIcon.heightAnchor.constraint(equalToConstant: 14)])
294
+ let deliveryInner = UIStackView(arrangedSubviews: [deliveryIcon, deliveryLabel])
295
+ deliveryInnerRef = deliveryInner
296
+ deliveryInner.axis = .horizontal
297
+ deliveryInner.spacing = 4
298
+ deliveryInner.alignment = .center
299
+ retryButton.layer.cornerRadius = 18
300
+ retryButton.layer.borderWidth = 1
301
+ retryButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 14, bottom: 0, right: 14)
302
+ retryButton.translatesAutoresizingMaskIntoConstraints = false
303
+ retryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
304
+ retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
305
+ let deliverySpacer = UIView()
306
+ deliverySpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
307
+ deliveryRow.addArrangedSubview(deliverySpacer)
308
+ deliveryRow.addArrangedSubview(deliveryInner)
309
+ deliveryRow.addArrangedSubview(retryButton)
310
+ deliveryRow.isLayoutMarginsRelativeArrangement = true
311
+ deliveryRow.layoutMargins = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 2)
312
+
313
+ outer.addArrangedSubview(nameContainer)
314
+ outer.addArrangedSubview(rowStack)
315
+ outer.addArrangedSubview(deliveryRow)
316
+ }
317
+
318
+ override func prepareForReuse() {
319
+ super.prepareForReuse()
320
+ imageTask?.cancel()
321
+ imageTask = nil
322
+ photo.image = nil
323
+ avatarImage.image = nil
324
+ firstImageURL = nil
325
+ firstImageFailed = false
326
+ photoFailed.isHidden = true
327
+ fileURL = nil
328
+ }
329
+
330
+ // MARK: Configure
331
+
332
+ func configure(row: MessageRow, context: CellContext) {
333
+ self.row = row
334
+ self.context = context
335
+ let theme = context.theme
336
+ let message = row.message
337
+ let mine = message.from == .visitor
338
+ let rtl = theme.isRTL
339
+ let semantic: UISemanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
340
+ for view in [contentView, outer, rowStack, deliveryRow, content, textHolder, fileRow] { view.semanticContentAttribute = semantic }
341
+ let extraViews: [UIView?] = [nameContainer, fileStackRef, deliveryInnerRef]
342
+ for view in extraViews.compactMap({ $0 }) { view.semanticContentAttribute = semantic }
343
+ semanticContentAttribute = semantic
344
+
345
+ topGap?.constant = row.startsGroup ? CGFloat(Metrics.senderGap(theme.density)) : CGFloat(Metrics.groupGap)
346
+
347
+ // Name.
348
+ let showName = row.showsName
349
+ nameContainer.isHidden = !showName
350
+ nameLabel.text = message.agentName
351
+ nameLabel.font = theme.uiFont(.secondary, size: 12.5, weight: .medium)
352
+ nameLabel.adjustsFontForContentSizeCategory = true
353
+ nameLabel.textColor = UIColor(hex: theme.palette.ink2)
354
+ nameLabel.textAlignment = rtl ? .right : .left
355
+
356
+ // Bubble shape and color.
357
+ let radius = CGFloat(Metrics.bubbleRadius(theme.corners))
358
+ let tail = row.endsGroup ? CGFloat(Metrics.tailRadius(theme.corners)) : radius
359
+ bubble.topLeft = radius
360
+ bubble.topRight = radius
361
+ // The tail sits at the bottom corner on the sender's side: end side for the customer, start side for the team.
362
+ let tailOnRight = mine ? !rtl : rtl
363
+ bubble.bottomRight = tailOnRight ? tail : radius
364
+ bubble.bottomLeft = tailOnRight ? radius : tail
365
+ bubble.backgroundColor = UIColor(hex: mine ? theme.palette.customer : theme.palette.agentBubble)
366
+ let ink = UIColor(hex: mine ? theme.palette.onCustomer : theme.palette.ink)
367
+
368
+ // Width limit: 78 percent, 520 pt on iPad.
369
+ let limit = context.isPad ? CGFloat(Metrics.maxBubbleWidthPad) : context.tableWidth * CGFloat(Metrics.maxBubbleFraction)
370
+ bubbleMax?.constant = Swift.max(120, Swift.min(limit, context.tableWidth - 28 - 36))
371
+
372
+ // Row arrangement: [avatar] bubble [flex] or [flex] bubble.
373
+ for view in rowStack.arrangedSubviews { rowStack.removeArrangedSubview(view); view.removeFromSuperview() }
374
+ if mine {
375
+ rowStack.addArrangedSubview(flexible)
376
+ rowStack.addArrangedSubview(bubble)
377
+ } else {
378
+ rowStack.addArrangedSubview(avatar)
379
+ rowStack.addArrangedSubview(bubble)
380
+ rowStack.addArrangedSubview(flexible)
381
+ configureAvatar(message: message, theme: theme, visible: row.endsGroup && theme.showAgentAvatar)
382
+ }
383
+
384
+ // Content.
385
+ let images = message.attachments.filter { $0.isImage }
386
+ let files = message.attachments.filter { !$0.isImage }
387
+ currentTheme = theme
388
+ photoFailedIcon.tintColor = ink
389
+ photoFailedLabel.textColor = ink
390
+ photoFailedLabel.font = theme.uiFont(.secondary, size: 13, weight: .medium)
391
+ photoFailedLabel.adjustsFontForContentSizeCategory = true
392
+ photoFailedLabel.text = theme.strings.string("state.retry")
393
+ configurePhoto(images.first, theme: theme)
394
+ if let file = files.first {
395
+ fileRow.isHidden = false
396
+ fileName.text = file.name
397
+ fileName.font = theme.uiFont(.message, size: 14.5, weight: .medium)
398
+ fileName.textColor = ink
399
+ fileName.adjustsFontForContentSizeCategory = true
400
+ fileSize.text = file.size.map { ChatFormat.fileSize($0) }
401
+ fileSize.font = theme.uiFont(.secondary, size: 12.5)
402
+ fileSize.textColor = ink.withAlphaComponent(0.7)
403
+ fileSize.adjustsFontForContentSizeCategory = true
404
+ fileIcon.image = UIImage(systemName: "doc")
405
+ fileIcon.tintColor = UIColor(hex: theme.palette.ink2)
406
+ fileIcon.backgroundColor = mine ? UIColor.white.withAlphaComponent(0.2) : UIColor(hex: theme.palette.bg)
407
+ fileURL = file.url
408
+ } else {
409
+ fileRow.isHidden = true
410
+ }
411
+ if message.text.isEmpty {
412
+ textHolder.isHidden = true
413
+ } else {
414
+ textHolder.isHidden = false
415
+ textView.attributedText = attributed(message.text, theme: theme, ink: ink, mine: mine)
416
+ textView.textAlignment = .natural
417
+ }
418
+ // A photo with a caption: the caption has its own padding below the photo. A photo alone: nothing below.
419
+ bubble.setNeedsLayout()
420
+
421
+ // Delivery.
422
+ configureDelivery(row: row, theme: theme, mine: mine)
423
+
424
+ isAccessibilityElement = true
425
+ accessibilityTraits = .staticText
426
+ accessibilityLabel = accessibilityText(row: row, theme: theme)
427
+ updateAccessibilityActions()
428
+ }
429
+
430
+ private func accessibilityText(row: MessageRow, theme: ChatTheme) -> String {
431
+ let strings = theme.strings
432
+ let message = row.message
433
+ let sender = message.from == .visitor ? strings.string("a11y.you") : (message.agentName ?? strings.string("a11y.team"))
434
+ var text = message.text
435
+ if text.isEmpty, let first = message.attachments.first { text = first.isImage ? strings.string("attachment.photo") : (first.name.isEmpty ? strings.string("attachment.file") : first.name) }
436
+ let delivery = message.from == .visitor && row.showsDelivery ? ThreadLayout.deliveryText(message, strings: strings) : nil
437
+ return strings.accessibilitySummary(sender: sender, text: text, time: row.timeLabel, delivery: delivery)
438
+ }
439
+
440
+ private func attributed(_ text: String, theme: ChatTheme, ink: UIColor, mine: Bool) -> NSAttributedString {
441
+ let font = theme.uiFont(.message)
442
+ let paragraph = NSMutableParagraphStyle()
443
+ paragraph.alignment = .natural
444
+ paragraph.baseWritingDirection = .natural // each paragraph follows its own direction
445
+ paragraph.lineBreakMode = .byWordWrapping
446
+ paragraph.lineHeightMultiple = CGFloat(Metrics.lineHeightMultiple(theme.locale)) / 1.2
447
+ let result = NSMutableAttributedString(string: text, attributes: [.font: font, .foregroundColor: ink, .paragraphStyle: paragraph])
448
+ // Links: only https, mailto and tel. Underlined.
449
+ let linkColor = mine ? ink : UIColor(hex: theme.palette.link)
450
+ if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) {
451
+ let whole = NSRange(location: 0, length: (text as NSString).length)
452
+ detector.enumerateMatches(in: text, options: [], range: whole) { match, _, _ in
453
+ guard let match = match, let url = match.url, LinkPolicy.isSafe(url.absoluteString) else { return }
454
+ result.addAttributes([.link: url, .underlineStyle: NSUnderlineStyle.single.rawValue, .foregroundColor: linkColor], range: match.range)
455
+ }
456
+ }
457
+ return result
458
+ }
459
+
460
+ private func configureAvatar(message: ChatMessage, theme: ChatTheme, visible: Bool) {
461
+ avatar.alpha = visible ? 1 : 0
462
+ avatar.backgroundColor = UIColor(hex: theme.palette.avatarTone)
463
+ let name = message.agentName ?? theme.brandName
464
+ avatarInitial.text = String(name.first ?? "Z").uppercased()
465
+ avatarInitial.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
466
+ avatarInitial.textColor = UIColor(hex: theme.palette.avatarInk)
467
+ avatar.isHidden = !theme.showAgentAvatar
468
+ if visible, let url = message.agentAvatarUrl {
469
+ Task { [weak self] in
470
+ guard let image = try? await ImageLoader.shared.load(url) else { return }
471
+ await MainActor.run { if self?.row?.message.agentAvatarUrl == url { self?.avatarImage.image = image } }
472
+ }
473
+ }
474
+ }
475
+
476
+ /// The cell is one VoiceOver element, so everything the customer can tap is also an action.
477
+ private func updateAccessibilityActions() {
478
+ guard let theme = currentTheme, let message = row?.message else { return }
479
+ let strings = theme.strings
480
+ var actions: [UIAccessibilityCustomAction] = []
481
+ if message.delivery == .failed {
482
+ actions.append(UIAccessibilityCustomAction(name: strings.string("delivery.retry")) { [weak self] _ in self?.retryTapped(); return true })
483
+ }
484
+ if firstImageURL != nil {
485
+ let name = firstImageFailed ? strings.string("state.retry") : strings.string("attachment.photo")
486
+ actions.append(UIAccessibilityCustomAction(name: name) { [weak self] _ in self?.photoTapped(); return true })
487
+ }
488
+ if fileURL != nil, let name = fileName.text {
489
+ actions.append(UIAccessibilityCustomAction(name: name) { [weak self] _ in self?.fileTapped(); return true })
490
+ }
491
+ accessibilityCustomActions = actions.isEmpty ? nil : actions
492
+ }
493
+
494
+ private func configurePhoto(_ attachment: Attachment?, theme: ChatTheme) {
495
+ firstImageFailed = false
496
+ photoFailed.isHidden = true
497
+ guard let attachment = attachment else {
498
+ imageHolder.isHidden = true
499
+ firstImageURL = nil
500
+ return
501
+ }
502
+ imageHolder.isHidden = false
503
+ firstImageURL = attachment.url
504
+ photoPlaceholder.backgroundColor = UIColor(hex: theme.palette.ink2).withAlphaComponent(0.18)
505
+ let width: CGFloat = CGFloat(Metrics.maxImageWidth)
506
+ var ratio: CGFloat = 0.75
507
+ if let w = attachment.width, let h = attachment.height, w > 0, h > 0 { ratio = CGFloat(h) / CGFloat(w) }
508
+ if let known = ImageRatioCache.ratio(attachment.url) { ratio = known }
509
+ photoWidth?.constant = width
510
+ photoHeight?.constant = Swift.min(Swift.max(width * ratio, 100), 320)
511
+ photo.accessibilityLabel = theme.strings.string("attachment.photo")
512
+ photo.isAccessibilityElement = false
513
+ if let cached = ImageLoader.shared.cached(attachment.url) {
514
+ photo.image = cached
515
+ photoPlaceholder.isHidden = true
516
+ } else {
517
+ photo.image = nil
518
+ photoPlaceholder.isHidden = false
519
+ loadPhoto(attachment.url)
520
+ }
521
+ }
522
+
523
+ private func loadPhoto(_ url: String) {
524
+ imageTask?.cancel()
525
+ imageTask = Task { [weak self] in
526
+ do {
527
+ let image = try await ImageLoader.shared.load(url)
528
+ await MainActor.run { self?.imageLoaded(image, url: url) }
529
+ } catch ImageLoader.Failure.expired {
530
+ if Task.isCancelled { return }
531
+ // Fresh links replace this one when the page reloads. Until then the row says what to do.
532
+ await MainActor.run {
533
+ self?.showPhotoFailed(url: url)
534
+ self?.context?.onImageExpired()
535
+ }
536
+ } catch {
537
+ if Task.isCancelled { return }
538
+ await MainActor.run { self?.showPhotoFailed(url: url) }
539
+ }
540
+ }
541
+ }
542
+
543
+ /// The row keeps its size. A tap tries again.
544
+ private func showPhotoFailed(url: String) {
545
+ guard firstImageURL == url else { return }
546
+ firstImageFailed = true
547
+ photoFailed.isHidden = false
548
+ updateAccessibilityActions()
549
+ }
550
+
551
+ private func imageLoaded(_ image: UIImage, url: String) {
552
+ guard firstImageURL == url else { return }
553
+ photo.image = image
554
+ photoPlaceholder.isHidden = true
555
+ firstImageFailed = false
556
+ photoFailed.isHidden = true
557
+ updateAccessibilityActions()
558
+ if image.size.width > 0 {
559
+ let ratio = image.size.height / image.size.width
560
+ if ImageRatioCache.ratio(url) == nil {
561
+ ImageRatioCache.set(url, ratio)
562
+ context?.onImageSized()
563
+ }
564
+ }
565
+ }
566
+
567
+ private func configureDelivery(row: MessageRow, theme: ChatTheme, mine: Bool) {
568
+ let message = row.message
569
+ guard mine, row.showsDelivery, let text = ThreadLayout.deliveryText(message, strings: theme.strings) else {
570
+ deliveryRow.isHidden = true
571
+ return
572
+ }
573
+ deliveryRow.isHidden = false
574
+ deliveryLabel.text = text
575
+ deliveryLabel.font = theme.uiFont(.caption, size: message.delivery == .failed ? 12.5 : 12, weight: message.delivery == .failed ? .medium : .regular)
576
+ deliveryLabel.adjustsFontForContentSizeCategory = true
577
+ deliveryLabel.numberOfLines = 0
578
+ let color: UIColor = message.delivery == .failed ? UIColor(hex: theme.palette.bad) : UIColor(hex: theme.palette.ink2)
579
+ deliveryLabel.textColor = color
580
+ deliveryIcon.tintColor = color
581
+ switch message.delivery {
582
+ case .waiting:
583
+ deliveryIcon.image = UIImage(systemName: "clock")
584
+ deliveryIcon.isHidden = false
585
+ case .failed:
586
+ deliveryIcon.image = UIImage(systemName: "exclamationmark.circle")
587
+ deliveryIcon.isHidden = false
588
+ default:
589
+ deliveryIcon.image = nil
590
+ deliveryIcon.isHidden = true
591
+ }
592
+ let failed = message.delivery == .failed
593
+ retryButton.isHidden = !failed
594
+ retryButton.setTitle(theme.strings.string("delivery.retry"), for: .normal)
595
+ retryButton.titleLabel?.font = theme.uiFont(.secondary, size: 13.5, weight: .medium)
596
+ retryButton.titleLabel?.adjustsFontForContentSizeCategory = true
597
+ retryButton.setTitleColor(UIColor(hex: theme.palette.ink), for: .normal)
598
+ retryButton.layer.borderColor = UIColor(hex: theme.palette.line).cgColor
599
+ }
600
+
601
+ // MARK: Actions
602
+
603
+ @objc private func retryTapped() {
604
+ guard let id = row?.message.clientMessageId else { return }
605
+ context?.onRetry(id)
606
+ }
607
+
608
+ @objc private func photoTapped() {
609
+ if firstImageFailed, let url = firstImageURL {
610
+ firstImageFailed = false
611
+ photoFailed.isHidden = true
612
+ updateAccessibilityActions()
613
+ loadPhoto(url)
614
+ return
615
+ }
616
+ guard let image = photo.image, let url = firstImageURL else { return }
617
+ context?.onOpenImage(image, url)
618
+ }
619
+
620
+ @objc private func fileTapped() {
621
+ guard let raw = fileURL, LinkPolicy.isSafe(raw), let url = URL(string: raw) else { return }
622
+ context?.onOpenURL(url)
623
+ }
624
+
625
+ @objc private func bubbleTapped(_ gesture: UITapGestureRecognizer) {
626
+ guard !textHolder.isHidden else { return }
627
+ let point = gesture.location(in: textView)
628
+ guard textView.bounds.contains(point) else { return }
629
+ let inset = textView.textContainerInset
630
+ let local = CGPoint(x: point.x - inset.left, y: point.y - inset.top)
631
+ let storage = textView.textStorage
632
+ guard storage.length > 0 else { return }
633
+ let index = textView.layoutManager.characterIndex(for: local, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
634
+ guard index < storage.length, let url = storage.attribute(.link, at: index, effectiveRange: nil) as? URL else { return }
635
+ context?.onOpenURL(url)
636
+ }
637
+
638
+ @objc private func bubblePressed(_ gesture: UILongPressGestureRecognizer) {
639
+ guard gesture.state == .began, let text = row?.message.text, !text.isEmpty else { return }
640
+ context?.onCopy(text)
641
+ }
642
+ }
643
+
644
+ // MARK: - Date and typing cells
645
+
646
+ final class DateCell: UITableViewCell {
647
+ static let identifier = "DateCell"
648
+ private let label = UILabel()
649
+
650
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
651
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
652
+ backgroundColor = .clear
653
+ selectionStyle = .none
654
+ label.textAlignment = .center
655
+ label.translatesAutoresizingMaskIntoConstraints = false
656
+ contentView.addSubview(label)
657
+ NSLayoutConstraint.activate([
658
+ label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
659
+ label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6),
660
+ label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
661
+ label.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 12),
662
+ ])
663
+ }
664
+
665
+ required init?(coder: NSCoder) { nil }
666
+
667
+ func configure(text: String, theme: ChatTheme) {
668
+ label.text = text
669
+ label.font = theme.uiFont(.caption, weight: .medium)
670
+ label.adjustsFontForContentSizeCategory = true
671
+ label.textColor = UIColor(hex: theme.palette.ink2)
672
+ isAccessibilityElement = true
673
+ accessibilityLabel = text
674
+ accessibilityTraits = .header
675
+ }
676
+ }
677
+
678
+ final class TypingCell: UITableViewCell {
679
+ static let identifier = "TypingCell"
680
+ private let bubble = BubbleView()
681
+ private let dots = [UIView(), UIView(), UIView()]
682
+ private let avatar = UIView()
683
+ private let initial = UILabel()
684
+ private let stack = UIStackView()
685
+
686
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
687
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
688
+ backgroundColor = .clear
689
+ selectionStyle = .none
690
+ stack.axis = .horizontal
691
+ stack.alignment = .bottom
692
+ stack.spacing = 8
693
+ stack.translatesAutoresizingMaskIntoConstraints = false
694
+ contentView.addSubview(stack)
695
+ avatar.layer.cornerRadius = 14
696
+ avatar.translatesAutoresizingMaskIntoConstraints = false
697
+ initial.translatesAutoresizingMaskIntoConstraints = false
698
+ avatar.addSubview(initial)
699
+ NSLayoutConstraint.activate([
700
+ avatar.widthAnchor.constraint(equalToConstant: 28), avatar.heightAnchor.constraint(equalToConstant: 28),
701
+ initial.centerXAnchor.constraint(equalTo: avatar.centerXAnchor), initial.centerYAnchor.constraint(equalTo: avatar.centerYAnchor),
702
+ ])
703
+ let dotStack = UIStackView(arrangedSubviews: dots)
704
+ dotStack.axis = .horizontal
705
+ dotStack.spacing = 5
706
+ dotStack.alignment = .center
707
+ dotStack.translatesAutoresizingMaskIntoConstraints = false
708
+ for dot in dots {
709
+ dot.layer.cornerRadius = 3.5
710
+ dot.translatesAutoresizingMaskIntoConstraints = false
711
+ NSLayoutConstraint.activate([dot.widthAnchor.constraint(equalToConstant: 7), dot.heightAnchor.constraint(equalToConstant: 7)])
712
+ }
713
+ bubble.translatesAutoresizingMaskIntoConstraints = false
714
+ bubble.addSubview(dotStack)
715
+ NSLayoutConstraint.activate([
716
+ dotStack.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 14),
717
+ dotStack.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -14),
718
+ dotStack.centerYAnchor.constraint(equalTo: bubble.centerYAnchor),
719
+ bubble.heightAnchor.constraint(equalToConstant: 38),
720
+ ])
721
+ let spacer = UIView()
722
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
723
+ stack.addArrangedSubview(avatar)
724
+ stack.addArrangedSubview(bubble)
725
+ stack.addArrangedSubview(spacer)
726
+ NSLayoutConstraint.activate([
727
+ stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
728
+ stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -2),
729
+ stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 14),
730
+ stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -14),
731
+ ])
732
+ }
733
+
734
+ required init?(coder: NSCoder) { nil }
735
+
736
+ func configure(name: String?, theme: ChatTheme) {
737
+ let rtl = theme.isRTL
738
+ let semantic: UISemanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
739
+ contentView.semanticContentAttribute = semantic
740
+ stack.semanticContentAttribute = semantic
741
+ avatar.backgroundColor = UIColor(hex: theme.palette.avatarTone)
742
+ avatar.isHidden = !theme.showAgentAvatar
743
+ initial.text = String((name ?? theme.brandName).first ?? "Z").uppercased()
744
+ initial.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
745
+ initial.textColor = UIColor(hex: theme.palette.avatarInk)
746
+ let radius = CGFloat(Metrics.bubbleRadius(theme.corners))
747
+ let tail = CGFloat(Metrics.tailRadius(theme.corners))
748
+ bubble.topLeft = radius
749
+ bubble.topRight = radius
750
+ bubble.bottomRight = rtl ? tail : radius
751
+ bubble.bottomLeft = rtl ? radius : tail
752
+ bubble.backgroundColor = UIColor(hex: theme.palette.agentBubble)
753
+ let opacities: [Float] = [0.9, 0.6, 0.35]
754
+ for (index, dot) in dots.enumerated() {
755
+ dot.backgroundColor = UIColor(hex: theme.palette.ink2)
756
+ dot.layer.removeAllAnimations()
757
+ dot.alpha = CGFloat(opacities[index])
758
+ if !UIAccessibility.isReduceMotionEnabled {
759
+ // A slow wave. With reduced motion the dots stay still.
760
+ let wave = CABasicAnimation(keyPath: "transform.translation.y")
761
+ wave.fromValue = 0
762
+ wave.toValue = -3
763
+ wave.duration = 0.6
764
+ wave.autoreverses = true
765
+ wave.repeatCount = .infinity
766
+ wave.beginTime = CACurrentMediaTime() + Double(index) * 0.15
767
+ wave.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
768
+ dot.layer.add(wave, forKey: "wave")
769
+ }
770
+ }
771
+ isAccessibilityElement = true
772
+ accessibilityLabel = theme.strings.string("thread.typing")
773
+ accessibilityTraits = .updatesFrequently
774
+ }
775
+ }
776
+ #endif