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