@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,703 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import UIKit
3
+
4
+ struct ThreadServices {
5
+ var canAttach: Bool
6
+ var greeting: String
7
+ var soundEnabled: Bool
8
+ var upload: (Data, String, String, @escaping @Sendable (Double) -> Void) async throws -> Attachment
9
+ }
10
+
11
+ private final class PendingUpload {
12
+ let id = UUID().uuidString
13
+ let file: PickedFile
14
+ let chip: PendingChip
15
+ var attachment: Attachment?
16
+ var failed = false
17
+ var task: Task<Void, Never>?
18
+
19
+ init(file: PickedFile) {
20
+ self.file = file
21
+ self.chip = PendingChip(id: UUID().uuidString, thumbnail: file.thumbnail, fileName: file.fileName)
22
+ }
23
+ }
24
+
25
+ /// The thread, as a UITableView in a UIViewController.
26
+ ///
27
+ /// Why not SwiftUI List: on iOS 15 a List cannot keep the newest message anchored at the bottom when the
28
+ /// keyboard opens, cannot prepend older messages without a jump, and cannot dismiss the keyboard by drag while
29
+ /// the composer follows it. A UITableView with the composer as `inputAccessoryView` does all three natively.
30
+ ///
31
+ /// iPad: an input accessory view sits at the bottom of the window, outside the form sheet. There the composer
32
+ /// is a subview on the keyboard layout guide.
33
+ final class ThreadViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
34
+ /// One message carries at most this many files on the client. The server allows 5; Android uses 4 too.
35
+ static let maxAttachmentsPerMessage = 4
36
+
37
+ private let controller: ThreadController
38
+ private var theme: ChatTheme
39
+ private var services: ThreadServices
40
+
41
+ private let tableView = UITableView(frame: .zero, style: .plain)
42
+ private let composer = ComposerBar()
43
+ private let picker = AttachmentPicker()
44
+ private let pill = UIButton(type: .system)
45
+ private let olderSpinner = UIActivityIndicatorView(style: .medium)
46
+ private let centerStack = UIStackView()
47
+ private let centerSpinner = UIActivityIndicatorView(style: .medium)
48
+ private let centerLabel = UILabel()
49
+ private let centerButton = UIButton(type: .system)
50
+ private let toast = UILabel()
51
+ private var pillBottom: NSLayoutConstraint?
52
+
53
+ private var rows: [ThreadRow] = []
54
+ private var snapshot = ThreadSnapshot()
55
+ private var heights: [String: CGFloat] = [:]
56
+ private var lastIncoming = 0
57
+ private var overlapBase: CGFloat = 0
58
+ private var overlap: CGFloat = 0
59
+ private var lastWidth: CGFloat = 0
60
+ private var uploads: [PendingUpload] = []
61
+ private var started = false
62
+ private var tornDown = false
63
+ private var autoDismissTask: Task<Void, Never>?
64
+ private let embedsComposer = UIDevice.current.userInterfaceIdiom == .pad
65
+
66
+ init(controller: ThreadController, theme: ChatTheme, services: ThreadServices) {
67
+ self.controller = controller
68
+ self.theme = theme
69
+ self.services = services
70
+ super.init(nibName: nil, bundle: nil)
71
+ }
72
+
73
+ required init?(coder: NSCoder) { nil }
74
+
75
+ override var canBecomeFirstResponder: Bool { !embedsComposer }
76
+ override var inputAccessoryView: UIView? { embedsComposer ? nil : composer }
77
+
78
+ // MARK: Lifecycle
79
+
80
+ override func viewDidLoad() {
81
+ super.viewDidLoad()
82
+ view.clipsToBounds = true
83
+
84
+ tableView.dataSource = self
85
+ tableView.delegate = self
86
+ tableView.separatorStyle = .none
87
+ tableView.keyboardDismissMode = .interactive
88
+ tableView.rowHeight = UITableView.automaticDimension
89
+ tableView.estimatedRowHeight = 64
90
+ tableView.allowsSelection = false
91
+ tableView.contentInsetAdjustmentBehavior = .never
92
+ tableView.alwaysBounceVertical = true
93
+ tableView.register(MessageCell.self, forCellReuseIdentifier: MessageCell.identifier)
94
+ tableView.register(DateCell.self, forCellReuseIdentifier: DateCell.identifier)
95
+ tableView.register(TypingCell.self, forCellReuseIdentifier: TypingCell.identifier)
96
+ tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 8))
97
+ tableView.translatesAutoresizingMaskIntoConstraints = false
98
+ view.addSubview(tableView)
99
+ NSLayoutConstraint.activate([
100
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
101
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
102
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
103
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
104
+ ])
105
+
106
+ olderSpinner.hidesWhenStopped = true
107
+ olderSpinner.translatesAutoresizingMaskIntoConstraints = false
108
+ view.addSubview(olderSpinner)
109
+ NSLayoutConstraint.activate([
110
+ olderSpinner.topAnchor.constraint(equalTo: view.topAnchor, constant: 8),
111
+ olderSpinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
112
+ ])
113
+
114
+ centerStack.axis = .vertical
115
+ centerStack.alignment = .center
116
+ centerStack.spacing = 14
117
+ centerStack.translatesAutoresizingMaskIntoConstraints = false
118
+ centerLabel.numberOfLines = 0
119
+ centerLabel.textAlignment = .center
120
+ centerButton.layer.cornerRadius = 24
121
+ centerButton.layer.borderWidth = 1
122
+ centerButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 26, bottom: 0, right: 26)
123
+ centerButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 48).isActive = true
124
+ centerButton.addTarget(self, action: #selector(retryLoad), for: .touchUpInside)
125
+ centerStack.addArrangedSubview(centerSpinner)
126
+ centerStack.addArrangedSubview(centerLabel)
127
+ centerStack.addArrangedSubview(centerButton)
128
+ view.addSubview(centerStack)
129
+ NSLayoutConstraint.activate([
130
+ centerStack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
131
+ centerStack.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
132
+ centerStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
133
+ centerStack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
134
+ ])
135
+
136
+ pill.contentEdgeInsets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
137
+ pill.layer.cornerRadius = 20
138
+ pill.setImage(UIImage(systemName: "arrow.down"), for: .normal)
139
+ pill.isHidden = true
140
+ pill.addTarget(self, action: #selector(pillTapped), for: .touchUpInside)
141
+ pill.translatesAutoresizingMaskIntoConstraints = false
142
+ view.addSubview(pill)
143
+ let bottom = pill.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12)
144
+ pillBottom = bottom
145
+ NSLayoutConstraint.activate([bottom, pill.centerXAnchor.constraint(equalTo: view.centerXAnchor), pill.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)])
146
+
147
+ toast.alpha = 0
148
+ toast.textAlignment = .center
149
+ toast.layer.cornerRadius = 14
150
+ toast.clipsToBounds = true
151
+ toast.translatesAutoresizingMaskIntoConstraints = false
152
+ view.addSubview(toast)
153
+ NSLayoutConstraint.activate([
154
+ toast.topAnchor.constraint(equalTo: view.topAnchor, constant: 12),
155
+ toast.centerXAnchor.constraint(equalTo: view.centerXAnchor),
156
+ toast.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
157
+ toast.heightAnchor.constraint(equalToConstant: 32),
158
+ ])
159
+
160
+ if embedsComposer {
161
+ composer.translatesAutoresizingMaskIntoConstraints = false
162
+ view.addSubview(composer)
163
+ NSLayoutConstraint.activate([
164
+ composer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
165
+ composer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
166
+ composer.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
167
+ ])
168
+ }
169
+
170
+ composer.onSend = { [weak self] text in self?.sendTapped(text) }
171
+ composer.onAttach = { [weak self] in self?.attachTapped() }
172
+ composer.onTextChange = { [weak self] text in
173
+ guard let self = self else { return }
174
+ let controller = self.controller
175
+ Task { await controller.saveDraft(text) }
176
+ }
177
+ composer.onHeightChange = { [weak self] in self?.composerHeightChanged() }
178
+ picker.presenter = self
179
+ picker.onPicked = { [weak self] file in self?.startUpload(file) }
180
+ picker.onProblem = { [weak self] problem in self?.showProblem(problem) }
181
+
182
+ applyTheme()
183
+
184
+ NotificationCenter.default.addObserver(self, selector: #selector(keyboardChanged(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
185
+ NotificationCenter.default.addObserver(self, selector: #selector(keyboardChanged(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
186
+ NotificationCenter.default.addObserver(self, selector: #selector(contentSizeChanged), name: UIContentSizeCategory.didChangeNotification, object: nil)
187
+
188
+ let controller = self.controller
189
+ Task {
190
+ await controller.setObserver { [weak self] snap in
191
+ DispatchQueue.main.async { self?.apply(snap) }
192
+ }
193
+ let draft = await controller.draft()
194
+ await MainActor.run { [weak self] in self?.composer.text = draft }
195
+ await controller.start()
196
+ }
197
+ started = true
198
+ }
199
+
200
+ override func viewDidAppear(_ animated: Bool) {
201
+ super.viewDidAppear(animated)
202
+ becomeFirstResponder()
203
+ }
204
+
205
+ override func viewWillDisappear(_ animated: Bool) {
206
+ super.viewWillDisappear(animated)
207
+ if isMovingFromParent || isBeingDismissed { teardown() }
208
+ }
209
+
210
+ /// Called when the screen goes away: stop the controller, cancel uploads, release the accessory view.
211
+ func teardown() {
212
+ if tornDown { return }
213
+ tornDown = true
214
+ NotificationCenter.default.removeObserver(self)
215
+ for upload in uploads { upload.task?.cancel() }
216
+ composer.resignFirstResponder()
217
+ resignFirstResponder()
218
+ let controller = self.controller
219
+ Task { await controller.stop() }
220
+ }
221
+
222
+ deinit { NotificationCenter.default.removeObserver(self) }
223
+
224
+ // MARK: Theme
225
+
226
+ func update(theme: ChatTheme, services: ThreadServices) {
227
+ let themeChanged = self.theme.palette != theme.palette || self.theme.locale != theme.locale || self.theme.fontName != theme.fontName
228
+ self.theme = theme
229
+ self.services = services
230
+ if isViewLoaded {
231
+ applyTheme()
232
+ if themeChanged { heights.removeAll(); tableView.reloadData() }
233
+ }
234
+ }
235
+
236
+ private func applyTheme() {
237
+ let semantic: UISemanticContentAttribute = theme.isRTL ? .forceRightToLeft : .forceLeftToRight
238
+ view.semanticContentAttribute = semantic
239
+ tableView.semanticContentAttribute = semantic
240
+ view.backgroundColor = UIColor(hex: theme.palette.bg)
241
+ tableView.backgroundColor = .clear
242
+ overrideUserInterfaceStyle = theme.palette.isDark ? .dark : .light
243
+ composer.overrideUserInterfaceStyle = theme.palette.isDark ? .dark : .light
244
+ composer.apply(theme: theme, canAttach: services.canAttach)
245
+ picker.theme = theme
246
+ let strings = theme.strings
247
+ olderSpinner.color = UIColor(hex: theme.palette.ink2)
248
+ olderSpinner.accessibilityLabel = strings.string("thread.loadingOlder")
249
+ centerSpinner.color = UIColor(hex: theme.palette.ink2)
250
+ centerLabel.font = theme.uiFont(.message)
251
+ centerLabel.adjustsFontForContentSizeCategory = true
252
+ centerLabel.textColor = UIColor(hex: theme.palette.ink2)
253
+ centerButton.setTitle(strings.string("state.retry"), for: .normal)
254
+ centerButton.titleLabel?.font = theme.uiFont(.message, size: 15.5, weight: .medium)
255
+ centerButton.setTitleColor(UIColor(hex: theme.palette.ink), for: .normal)
256
+ centerButton.layer.borderColor = UIColor(hex: theme.palette.line).cgColor
257
+ pill.backgroundColor = UIColor(hex: theme.palette.primary)
258
+ pill.tintColor = UIColor(hex: theme.palette.onPrimary)
259
+ pill.setTitle(" " + strings.string("thread.newMessage"), for: .normal)
260
+ pill.setTitleColor(UIColor(hex: theme.palette.onPrimary), for: .normal)
261
+ pill.titleLabel?.font = theme.uiFont(.secondary, size: 13.5, weight: .semibold)
262
+ pill.titleLabel?.adjustsFontForContentSizeCategory = true
263
+ pill.layer.shadowColor = UIColor.black.cgColor
264
+ pill.layer.shadowOpacity = 0.2
265
+ pill.layer.shadowRadius = 8
266
+ pill.layer.shadowOffset = CGSize(width: 0, height: 4)
267
+ toast.font = theme.uiFont(.secondary, size: 13, weight: .medium)
268
+ toast.backgroundColor = UIColor(hex: theme.palette.ink)
269
+ toast.textColor = UIColor(hex: theme.palette.bg)
270
+ }
271
+
272
+ @objc private func contentSizeChanged() {
273
+ heights.removeAll()
274
+ tableView.reloadData()
275
+ }
276
+
277
+ // MARK: Layout and keyboard
278
+
279
+ override func viewDidLayoutSubviews() {
280
+ super.viewDidLayoutSubviews()
281
+ if abs(view.bounds.width - lastWidth) > 0.5 {
282
+ lastWidth = view.bounds.width
283
+ heights.removeAll()
284
+ tableView.reloadData()
285
+ }
286
+ // The layout guide moves the composer. The last row stays above it.
287
+ if embedsComposer { setOverlap(view.bounds.maxY - composer.frame.minY, duration: 0, options: []) }
288
+ updateTopInset()
289
+ }
290
+
291
+ /// Short conversations sit at the bottom, like the canvas.
292
+ private func updateTopInset() {
293
+ let free = tableView.bounds.height - tableView.contentSize.height - tableView.contentInset.bottom
294
+ let top = Swift.max(0, free)
295
+ if abs(tableView.contentInset.top - top) > 0.5 {
296
+ tableView.contentInset.top = top
297
+ }
298
+ }
299
+
300
+ @objc private func keyboardChanged(_ note: Notification) {
301
+ guard !embedsComposer, isViewLoaded, view.window != nil else { return }
302
+ let info = note.userInfo ?? [:]
303
+ guard let end = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
304
+ let local = view.convert(end, from: view.window)
305
+ let newOverlap = Swift.max(0, view.bounds.maxY - local.minY)
306
+ let duration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
307
+ let curveRaw = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? UIView.AnimationCurve.easeInOut.rawValue
308
+ let options = UIView.AnimationOptions(rawValue: UInt(curveRaw << 16))
309
+ // The frame includes the composer. Keep the part that is the keyboard alone.
310
+ overlapBase = Swift.max(0, newOverlap - composerHeight)
311
+ setOverlap(newOverlap, duration: duration, options: options)
312
+ }
313
+
314
+ private var composerHeight: CGFloat { composer.bounds.height }
315
+
316
+ private func composerHeightChanged() {
317
+ // The composer grew or shrank (more lines, a thumbnail row). Keep the bottom where it was.
318
+ DispatchQueue.main.async { [weak self] in
319
+ guard let self = self, self.isViewLoaded else { return }
320
+ if self.embedsComposer { self.view.setNeedsLayout(); return }
321
+ self.setOverlap(self.overlapBase + self.composerHeight, duration: 0.15, options: .curveEaseOut)
322
+ }
323
+ }
324
+
325
+ private func setOverlap(_ value: CGFloat, duration: Double, options: UIView.AnimationOptions) {
326
+ let delta = value - overlap
327
+ if abs(delta) < 0.5 { return }
328
+ let wasAtBottom = isNearBottom
329
+ overlap = value
330
+ let apply = {
331
+ self.tableView.contentInset.bottom = value
332
+ self.tableView.verticalScrollIndicatorInsets.bottom = value
333
+ self.pillBottom?.constant = -(value + 12)
334
+ if wasAtBottom && !self.rows.isEmpty {
335
+ let y = Swift.max(-self.tableView.contentInset.top, self.tableView.contentSize.height + value - self.tableView.bounds.height)
336
+ self.tableView.contentOffset = CGPoint(x: 0, y: y)
337
+ }
338
+ self.view.layoutIfNeeded()
339
+ }
340
+ UIView.animate(withDuration: duration, delay: 0, options: [options, .beginFromCurrentState], animations: apply)
341
+ }
342
+
343
+ // MARK: Snapshot
344
+
345
+ private var isNearBottom: Bool {
346
+ let maxOffset = tableView.contentSize.height + tableView.contentInset.bottom - tableView.bounds.height
347
+ if maxOffset <= -tableView.contentInset.top { return true }
348
+ return tableView.contentOffset.y >= maxOffset - 60
349
+ }
350
+
351
+ private func apply(_ snap: ThreadSnapshot) {
352
+ if tornDown { return }
353
+ let previous = snapshot
354
+ snapshot = snap
355
+ let newRows = ThreadLayout.rows(messages: snap.messages, typing: snap.typing, typingName: snap.typingName, now: Date(), locale: theme.locale)
356
+ let wasAtBottom = isNearBottom
357
+ let incoming = snap.incomingCount - lastIncoming
358
+ lastIncoming = snap.incomingCount
359
+ updateRows(newRows, wasAtBottom: wasAtBottom, animateTail: incoming > 0 || previous.messages.count < snap.messages.count)
360
+
361
+ if snap.isLoadingOlder { olderSpinner.startAnimating() } else { olderSpinner.stopAnimating() }
362
+ updateCenter()
363
+
364
+ if incoming > 0 {
365
+ if services.soundEnabled { Haptics.incomingSound() }
366
+ Haptics.incomingTick()
367
+ announce(snap)
368
+ if !wasAtBottom { showPill() }
369
+ }
370
+ if let error = snap.error, case .rateLimited = error {
371
+ composer.setStatus(theme.strings.string("state.rateLimited"))
372
+ }
373
+ let failedNow = snap.messages.filter { $0.delivery == .failed }.count
374
+ let failedBefore = previous.messages.filter { $0.delivery == .failed }.count
375
+ if failedNow > failedBefore { Haptics.failure() }
376
+ }
377
+
378
+ private func announce(_ snap: ThreadSnapshot) {
379
+ guard let last = snap.messages.last(where: { $0.from == .agent }) else { return }
380
+ let sender = last.agentName ?? theme.strings.string("a11y.team")
381
+ let text = theme.strings.string("thread.newMessage") + ", " + sender
382
+ let announcement = NSAttributedString(string: text, attributes: [.accessibilitySpeechQueueAnnouncement: true])
383
+ UIAccessibility.post(notification: .announcement, argument: announcement)
384
+ }
385
+
386
+ private func updateRows(_ newRows: [ThreadRow], wasAtBottom: Bool, animateTail: Bool) {
387
+ let oldIds = rows.map { $0.id }
388
+ let newIds = newRows.map { $0.id }
389
+
390
+ if oldIds == newIds {
391
+ var changed: [IndexPath] = []
392
+ for index in newRows.indices where rows[index] != newRows[index] { changed.append(IndexPath(row: index, section: 0)) }
393
+ rows = newRows
394
+ if !changed.isEmpty {
395
+ UIView.performWithoutAnimation {
396
+ self.tableView.reloadRows(at: changed, with: .none)
397
+ self.tableView.layoutIfNeeded()
398
+ }
399
+ if wasAtBottom { scrollToBottom(animated: false) }
400
+ }
401
+ return
402
+ }
403
+
404
+ if oldIds.isEmpty {
405
+ rows = newRows
406
+ tableView.reloadData()
407
+ tableView.layoutIfNeeded()
408
+ updateTopInset()
409
+ scrollToBottom(animated: false)
410
+ DispatchQueue.main.async { [weak self] in self?.scrollToBottom(animated: false) }
411
+ return
412
+ }
413
+
414
+ // Older messages added above: keep what the customer reads in the same place.
415
+ if let first = oldIds.first, let offset = newIds.firstIndex(of: first), offset > 0, Array(newIds[offset...].prefix(oldIds.count)) == oldIds {
416
+ let oldHeight = tableView.contentSize.height
417
+ let oldOffset = tableView.contentOffset.y
418
+ rows = newRows
419
+ UIView.performWithoutAnimation {
420
+ self.tableView.reloadData()
421
+ self.tableView.layoutIfNeeded()
422
+ let grown = self.tableView.contentSize.height - oldHeight
423
+ self.tableView.contentOffset = CGPoint(x: 0, y: oldOffset + grown)
424
+ }
425
+ return
426
+ }
427
+
428
+ // Appended at the end.
429
+ if newIds.count > oldIds.count, Array(newIds.prefix(oldIds.count)) == oldIds {
430
+ let start = oldIds.count
431
+ rows = newRows
432
+ let paths = (start..<newRows.count).map { IndexPath(row: $0, section: 0) }
433
+ UIView.performWithoutAnimation {
434
+ self.tableView.insertRows(at: paths, with: .none)
435
+ self.tableView.layoutIfNeeded()
436
+ }
437
+ updateTopInset()
438
+ if wasAtBottom {
439
+ scrollToBottom(animated: !MotionPolicy.reduced)
440
+ if animateTail { animateAppearance(of: paths) }
441
+ }
442
+ return
443
+ }
444
+
445
+ // Anything else: draw again and keep the bottom if the customer was there.
446
+ rows = newRows
447
+ UIView.performWithoutAnimation {
448
+ self.tableView.reloadData()
449
+ self.tableView.layoutIfNeeded()
450
+ }
451
+ updateTopInset()
452
+ if wasAtBottom { scrollToBottom(animated: false) }
453
+ }
454
+
455
+ /// A new row slides up 8 pt with a fade, in 180 ms, ease out. With reduced motion: fade only.
456
+ private func animateAppearance(of paths: [IndexPath]) {
457
+ for path in paths {
458
+ guard let cell = tableView.cellForRow(at: path), !(cell is DateCell) else { continue }
459
+ let reduced = MotionPolicy.reduced
460
+ cell.alpha = 0
461
+ if !reduced { cell.transform = CGAffineTransform(translationX: 0, y: 8) }
462
+ UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseOut], animations: {
463
+ cell.alpha = 1
464
+ cell.transform = .identity
465
+ })
466
+ }
467
+ }
468
+
469
+ private func scrollToBottom(animated: Bool) {
470
+ guard !rows.isEmpty else { return }
471
+ tableView.layoutIfNeeded()
472
+ let y = Swift.max(-tableView.contentInset.top, tableView.contentSize.height + tableView.contentInset.bottom - tableView.bounds.height)
473
+ tableView.setContentOffset(CGPoint(x: 0, y: y), animated: animated)
474
+ pill.isHidden = true
475
+ }
476
+
477
+ private func updateCenter() {
478
+ let strings = theme.strings
479
+ let hasRows = !rows.isEmpty
480
+ if hasRows {
481
+ centerStack.isHidden = true
482
+ return
483
+ }
484
+ centerStack.isHidden = false
485
+ if snapshot.isLoading {
486
+ centerSpinner.startAnimating()
487
+ centerSpinner.isHidden = false
488
+ centerLabel.text = strings.string("state.loading")
489
+ centerButton.isHidden = true
490
+ } else if let error = snapshot.error {
491
+ centerSpinner.stopAnimating()
492
+ centerSpinner.isHidden = true
493
+ switch error {
494
+ case .rateLimited: centerLabel.text = strings.string("state.rateLimited")
495
+ case .loginNeeded: centerLabel.text = strings.string("state.loginNeeded")
496
+ case .unavailable: centerLabel.text = strings.string("state.unavailable")
497
+ default: centerLabel.text = strings.string("state.loadFailed")
498
+ }
499
+ centerButton.isHidden = (error == .loginNeeded || error == .unavailable)
500
+ } else {
501
+ centerSpinner.stopAnimating()
502
+ centerSpinner.isHidden = true
503
+ centerLabel.text = services.greeting
504
+ centerButton.isHidden = true
505
+ }
506
+ }
507
+
508
+ private func showPill() {
509
+ guard !MotionPolicy.reduced else { pill.isHidden = false; return }
510
+ pill.alpha = 0
511
+ pill.isHidden = false
512
+ UIView.animate(withDuration: 0.18) { self.pill.alpha = 1 }
513
+ }
514
+
515
+ @objc private func pillTapped() { scrollToBottom(animated: true) }
516
+
517
+ @objc private func retryLoad() {
518
+ let controller = self.controller
519
+ Task { await controller.retryLoad() }
520
+ }
521
+
522
+ // MARK: Sending
523
+
524
+ private func sendTapped(_ text: String) {
525
+ if uploads.contains(where: { $0.attachment == nil && !$0.failed }) { return }
526
+ let attachments = uploads.compactMap { $0.attachment }
527
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
528
+ if trimmed.isEmpty && attachments.isEmpty { return }
529
+ Haptics.sendTap()
530
+ composer.clear()
531
+ composer.setStatus(nil)
532
+ uploads.removeAll()
533
+ composer.setChips([])
534
+ composer.setAttachEnabled(true)
535
+ let controller = self.controller
536
+ Task {
537
+ await controller.send(text: trimmed, attachments: attachments)
538
+ await controller.saveDraft("")
539
+ }
540
+ // The customer's own message always brings the list to the bottom.
541
+ DispatchQueue.main.async { [weak self] in self?.scrollToBottom(animated: true) }
542
+ }
543
+
544
+ private func attachTapped() {
545
+ if uploads.count >= ThreadViewController.maxAttachmentsPerMessage { return }
546
+ picker.showSheet(sourceView: composer.attachAnchor)
547
+ }
548
+
549
+ private func startUpload(_ file: PickedFile) {
550
+ if uploads.count >= ThreadViewController.maxAttachmentsPerMessage { return }
551
+ let upload = PendingUpload(file: file)
552
+ uploads.append(upload)
553
+ upload.chip.apply(.uploading(0))
554
+ upload.chip.addAction(UIAction { [weak self, weak upload] _ in
555
+ guard let self = self, let upload = upload else { return }
556
+ upload.task?.cancel()
557
+ self.uploads.removeAll { $0 === upload }
558
+ self.refreshChips()
559
+ }, for: .touchUpInside)
560
+ refreshChips()
561
+ let chip = upload.chip
562
+ let uploader = services.upload
563
+ upload.task = Task { [weak self, weak upload] in
564
+ do {
565
+ let attachment = try await uploader(file.data, file.fileName, file.mimeType) { progress in
566
+ DispatchQueue.main.async { chip.apply(.uploading(progress)) }
567
+ }
568
+ await MainActor.run {
569
+ upload?.attachment = attachment
570
+ chip.apply(.ready)
571
+ self?.refreshChips()
572
+ }
573
+ } catch {
574
+ let mapped = ErrorPolicy.map(error)
575
+ await MainActor.run {
576
+ if mapped == .cancelled { return }
577
+ upload?.failed = true
578
+ chip.apply(.failed)
579
+ self?.showProblem(mapped)
580
+ self?.refreshChips()
581
+ }
582
+ }
583
+ }
584
+ }
585
+
586
+ private func refreshChips() {
587
+ composer.setAttachEnabled(uploads.count < ThreadViewController.maxAttachmentsPerMessage)
588
+ composer.setChips(uploads.map { $0.chip })
589
+ composer.setChipsReady(!uploads.contains { $0.attachment == nil && !$0.failed } && !uploads.contains { $0.failed })
590
+ }
591
+
592
+ private func showProblem(_ error: ChatError) {
593
+ let strings = theme.strings
594
+ switch error {
595
+ case .fileTooLarge: composer.setStatus(strings.string("attachment.tooLarge"))
596
+ case .fileTypeDenied: composer.setStatus(strings.string("attachment.typeDenied"))
597
+ case .filesDisabled:
598
+ services.canAttach = false
599
+ composer.apply(theme: theme, canAttach: false)
600
+ default: composer.setStatus(strings.string("attachment.failed"))
601
+ }
602
+ Haptics.failure()
603
+ autoDismissTask?.cancel()
604
+ autoDismissTask = Task { [weak self] in
605
+ try? await Task.sleep(nanoseconds: 6_000_000_000)
606
+ if Task.isCancelled { return }
607
+ await MainActor.run { self?.composer.setStatus(nil) }
608
+ }
609
+ }
610
+
611
+ // MARK: Table
612
+
613
+ func numberOfSections(in tableView: UITableView) -> Int { 1 }
614
+
615
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
616
+
617
+ private func cellContext() -> CellContext {
618
+ CellContext(
619
+ theme: theme,
620
+ tableWidth: tableView.bounds.width,
621
+ isPad: traitCollection.userInterfaceIdiom == .pad,
622
+ onRetry: { [weak self] id in
623
+ guard let self = self else { return }
624
+ let controller = self.controller
625
+ Task { await controller.retry(clientMessageId: id) }
626
+ },
627
+ onOpenImage: { [weak self] image, _ in
628
+ guard let self = self else { return }
629
+ let viewer = ImageViewerController(image: image, closeLabel: self.theme.strings.string("attachment.close"))
630
+ self.present(viewer, animated: true)
631
+ },
632
+ onOpenURL: { url in
633
+ // Only https, mailto and tel. The system asks before it dials or writes.
634
+ guard LinkPolicy.isSafe(url.absoluteString) else { return }
635
+ UIApplication.shared.open(url)
636
+ },
637
+ onCopy: { [weak self] text in
638
+ UIPasteboard.general.string = text
639
+ self?.showToast()
640
+ },
641
+ onImageSized: { [weak self] in
642
+ guard let self = self else { return }
643
+ let wasAtBottom = self.isNearBottom
644
+ UIView.performWithoutAnimation {
645
+ self.heights.removeAll()
646
+ self.tableView.beginUpdates()
647
+ self.tableView.endUpdates()
648
+ }
649
+ if wasAtBottom { self.scrollToBottom(animated: false) }
650
+ },
651
+ onImageExpired: { [weak self] in
652
+ guard let self = self else { return }
653
+ let controller = self.controller
654
+ Task { await controller.reloadAfterExpiredAttachment() }
655
+ }
656
+ )
657
+ }
658
+
659
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
660
+ let row = rows[indexPath.row]
661
+ switch row {
662
+ case .date(_, let label):
663
+ guard let cell = tableView.dequeueReusableCell(withIdentifier: DateCell.identifier, for: indexPath) as? DateCell else { return UITableViewCell() }
664
+ cell.configure(text: label, theme: theme)
665
+ return cell
666
+ case .typing(let name):
667
+ guard let cell = tableView.dequeueReusableCell(withIdentifier: TypingCell.identifier, for: indexPath) as? TypingCell else { return UITableViewCell() }
668
+ cell.configure(name: name, theme: theme)
669
+ return cell
670
+ case .message(let message):
671
+ guard let cell = tableView.dequeueReusableCell(withIdentifier: MessageCell.identifier, for: indexPath) as? MessageCell else { return UITableViewCell() }
672
+ cell.configure(row: message, context: cellContext())
673
+ return cell
674
+ }
675
+ }
676
+
677
+ func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
678
+ guard indexPath.row < rows.count else { return 64 }
679
+ return heights[rows[indexPath.row].id] ?? 64
680
+ }
681
+
682
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
683
+ guard indexPath.row < rows.count else { return }
684
+ heights[rows[indexPath.row].id] = cell.frame.height
685
+ }
686
+
687
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
688
+ if isNearBottom { pill.isHidden = true }
689
+ if scrollView.contentOffset.y < -scrollView.contentInset.top + 120, snapshot.hasOlder, !snapshot.isLoadingOlder, scrollView.isTracking || scrollView.isDecelerating {
690
+ let controller = self.controller
691
+ Task { await controller.loadOlder() }
692
+ }
693
+ }
694
+
695
+ private func showToast() {
696
+ toast.text = " " + theme.strings.string("common.copied") + " "
697
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
698
+ UIView.animate(withDuration: 0.15, animations: { self.toast.alpha = 1 }) { _ in
699
+ UIView.animate(withDuration: 0.25, delay: 1.0, options: [], animations: { self.toast.alpha = 0 })
700
+ }
701
+ }
702
+ }
703
+ #endif