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