@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,154 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ struct ThreadRepresentable: UIViewControllerRepresentable {
6
+ let controller: ThreadController
7
+ let theme: ChatTheme
8
+ let services: ThreadServices
9
+
10
+ func makeUIViewController(context: Context) -> ThreadViewController {
11
+ ThreadViewController(controller: controller, theme: theme, services: services)
12
+ }
13
+
14
+ func updateUIViewController(_ uiViewController: ThreadViewController, context: Context) {
15
+ uiViewController.update(theme: theme, services: services)
16
+ }
17
+
18
+ static func dismantleUIViewController(_ uiViewController: ThreadViewController, coordinator: ()) {
19
+ uiViewController.teardown()
20
+ }
21
+ }
22
+
23
+ struct ThreadScreen: View {
24
+ @Environment(\.chatTheme) private var theme
25
+ @ObservedObject var model: ChatModel
26
+ let onClose: () -> Void
27
+
28
+ private var title: String {
29
+ if case .thread(let id?) = model.route, let name = model.conversations.first(where: { $0.id == id })?.agentName, !name.isEmpty { return name }
30
+ return theme.brandName
31
+ }
32
+
33
+ private var subtitle: String {
34
+ let strings = theme.strings
35
+ if model.config?.online == false { return strings.string("banner.outsideHours") }
36
+ return model.config?.snapshot.behavior.replyExpectation.resolve(theme.locale) ?? strings.string("banner.online")
37
+ }
38
+
39
+ private var services: ThreadServices {
40
+ let engine = model.engine
41
+ let config = model.config
42
+ return ThreadServices(
43
+ canAttach: model.allowsAttachments,
44
+ greeting: config?.snapshot.screens.greeting.resolve(theme.locale) ?? theme.strings.string("thread.greeting"),
45
+ soundEnabled: config?.snapshot.appearance.soundEnabled ?? false,
46
+ upload: { data, name, mime, progress in try await engine.upload(data: data, fileName: name, mimeType: mime, progress: progress) }
47
+ )
48
+ }
49
+
50
+ var body: some View {
51
+ VStack(spacing: 0) {
52
+ bar
53
+ DividerLine()
54
+ if let banner = model.banner { BannerBar(banner: banner) }
55
+ if let controller = model.activeThread {
56
+ ThreadRepresentable(controller: controller, theme: theme, services: services)
57
+ .ignoresSafeArea(.keyboard, edges: .bottom)
58
+ .id(ObjectIdentifier(controller))
59
+ } else {
60
+ Spacer()
61
+ }
62
+ }
63
+ .background(theme.bg.ignoresSafeArea())
64
+ }
65
+
66
+ private var bar: some View {
67
+ let strings = theme.strings
68
+ return HStack(spacing: 6) {
69
+ if model.canGoBack && !model.isChatOnly {
70
+ IconButton(symbol: "chevron.backward", label: strings.string("common.back"), color: theme.ink, action: { model.goBack() })
71
+ } else {
72
+ Spacer().frame(width: 8)
73
+ }
74
+ LogoView(logoURL: model.config?.logoUrl, size: 32, radius: 10)
75
+ VStack(alignment: .leading, spacing: 0) {
76
+ Text(title).chatFont(.screenTitle).foregroundColor(theme.ink).lineLimit(1)
77
+ .accessibilityAddTraits(.isHeader)
78
+ HStack(spacing: 5) {
79
+ if model.config?.online != false { StatusDot(color: theme.ok) }
80
+ Text(subtitle).chatFont(.secondary, size: 12.5).foregroundColor(theme.ink2).lineLimit(1)
81
+ }
82
+ }
83
+ .padding(.leading, 4)
84
+ .frame(maxWidth: .infinity, alignment: .leading)
85
+ IconButton(symbol: "xmark", label: strings.string("common.close"), color: theme.ink2, action: onClose)
86
+ }
87
+ .padding(.horizontal, 8)
88
+ .padding(.vertical, 4)
89
+ .background(theme.bg)
90
+ }
91
+ }
92
+
93
+ /// The whole chat UI: routes between screens and sets language, direction and theme for everything inside.
94
+ struct ChatRootView: View {
95
+ @ObservedObject var model: ChatModel
96
+ @Environment(\.colorScheme) private var colorScheme
97
+ let onClose: () -> Void
98
+ var onStatusBarStyle: ((UIStatusBarStyle) -> Void)?
99
+
100
+ /// On Home the status bar sits on the header. Everywhere else it sits on the background.
101
+ private func statusBarStyle(_ theme: ChatTheme) -> UIStatusBarStyle {
102
+ let lightContent: Bool
103
+ if model.route == .home, theme.headerStyle != .light {
104
+ lightContent = Contrast.luminance(theme.palette.onPrimary) > 0.5
105
+ } else {
106
+ lightContent = theme.palette.isDark
107
+ }
108
+ return lightContent ? .lightContent : .darkContent
109
+ }
110
+
111
+ var body: some View {
112
+ let theme = model.theme(systemDark: colorScheme == .dark)
113
+ let style = statusBarStyle(theme)
114
+ content(theme)
115
+ .onAppear { onStatusBarStyle?(style) }
116
+ .onChange(of: style) { onStatusBarStyle?($0) }
117
+ .environment(\.chatTheme, theme)
118
+ .environment(\.layoutDirection, theme.layoutDirection)
119
+ .environment(\.locale, ChatFormat.formatter(theme.locale))
120
+ .task { await model.start() }
121
+ }
122
+
123
+ @ViewBuilder
124
+ private func content(_ theme: ChatTheme) -> some View {
125
+ ZStack {
126
+ if model.route != .consent {
127
+ theme.bg.ignoresSafeArea()
128
+ }
129
+ switch model.route {
130
+ case .loading:
131
+ SkeletonView()
132
+ case .consent:
133
+ ConsentView(model: model, onDecline: onClose)
134
+ case .home:
135
+ HomeView(model: model, onClose: onClose)
136
+ case .list:
137
+ ConversationListView(model: model, onClose: onClose)
138
+ case .thread:
139
+ ThreadScreen(model: model, onClose: onClose)
140
+ case .unavailable:
141
+ MessageStateView(symbol: "headphones", text: theme.strings.string("state.unavailable"),
142
+ buttonTitle: theme.strings.string("attachment.close"), onClose: onClose, onButton: onClose)
143
+ case .loginNeeded:
144
+ MessageStateView(symbol: "person.crop.circle", text: theme.strings.string("state.loginNeeded"),
145
+ buttonTitle: theme.strings.string("attachment.close"), onClose: onClose, onButton: onClose)
146
+ case .loadFailed:
147
+ MessageStateView(symbol: "wifi.slash", text: theme.strings.string("state.loadFailed"),
148
+ buttonTitle: theme.strings.string("state.retry"), onClose: onClose, onButton: { Task { await model.retry() } })
149
+ }
150
+ }
151
+ .animation(MotionPolicy.reduced ? nil : .easeOut(duration: 0.18), value: model.route)
152
+ }
153
+ }
154
+ #endif
@@ -0,0 +1,335 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ /// Loads images lazily. Memory cache plus a size-limited disk cache. Only https.
6
+ final class ImageLoader {
7
+ static let shared = ImageLoader()
8
+
9
+ private let memory = NSCache<NSString, UIImage>()
10
+ private let session: URLSession
11
+
12
+ private init() {
13
+ memory.totalCostLimit = 40 * 1024 * 1024
14
+ let configuration = URLSessionConfiguration.default
15
+ configuration.urlCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, diskPath: "ai.zellio.chat.images")
16
+ configuration.requestCachePolicy = .returnCacheDataElseLoad
17
+ configuration.timeoutIntervalForRequest = 30
18
+ session = URLSession(configuration: configuration)
19
+ }
20
+
21
+ func cached(_ urlString: String) -> UIImage? { memory.object(forKey: urlString as NSString) }
22
+
23
+ /// Logout: customer photos must not stay on the phone.
24
+ func clear() {
25
+ memory.removeAllObjects()
26
+ session.configuration.urlCache?.removeAllCachedResponses()
27
+ }
28
+
29
+ enum Failure: Error { case notAllowed, expired, failed }
30
+
31
+ /// A larger file is never downloaded.
32
+ private static let maxDownloadBytes = 25 * 1024 * 1024
33
+ /// The long edge after decoding, so one photo cannot take hundreds of MB of memory.
34
+ private static let maxPixelEdge = 2048
35
+
36
+ /// Throws `.expired` for 403 so the caller can read the message page once more.
37
+ func load(_ urlString: String) async throws -> UIImage {
38
+ if let image = cached(urlString) { return image }
39
+ guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else { throw Failure.notAllowed }
40
+ do {
41
+ let (bytes, response) = try await session.bytes(from: url)
42
+ if let http = response as? HTTPURLResponse {
43
+ if http.statusCode == 403 { throw Failure.expired }
44
+ if !(200..<300).contains(http.statusCode) { throw Failure.failed }
45
+ }
46
+ let declared = response.expectedContentLength
47
+ if declared > Int64(Self.maxDownloadBytes) { throw Failure.failed }
48
+ var buffer = [UInt8]()
49
+ if declared > 0 { buffer.reserveCapacity(Int(declared)) }
50
+ for try await byte in bytes {
51
+ buffer.append(byte)
52
+ if buffer.count > Self.maxDownloadBytes { throw Failure.failed }
53
+ }
54
+ guard let image = ImagePrep.decode(Data(buffer), maxPixelEdge: Self.maxPixelEdge) else { throw Failure.failed }
55
+ let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? buffer.count
56
+ memory.setObject(image, forKey: urlString as NSString, cost: cost)
57
+ return image
58
+ } catch let failure as Failure {
59
+ throw failure
60
+ } catch {
61
+ throw Failure.failed
62
+ }
63
+ }
64
+ }
65
+
66
+ struct RemoteImage<Placeholder: View>: View {
67
+ let urlString: String?
68
+ let placeholder: Placeholder
69
+ @State private var image: UIImage?
70
+
71
+ init(urlString: String?, @ViewBuilder placeholder: () -> Placeholder) {
72
+ self.urlString = urlString
73
+ self.placeholder = placeholder()
74
+ if let urlString = urlString { _image = State(initialValue: ImageLoader.shared.cached(urlString)) }
75
+ }
76
+
77
+ var body: some View {
78
+ Group {
79
+ if let image = image {
80
+ Image(uiImage: image).resizable().scaledToFill()
81
+ } else {
82
+ placeholder
83
+ }
84
+ }
85
+ .task(id: urlString) {
86
+ guard let urlString = urlString, image == nil else { return }
87
+ image = try? await ImageLoader.shared.load(urlString)
88
+ }
89
+ }
90
+ }
91
+
92
+ /// The brand logo, or a monogram of the first letter.
93
+ struct LogoView: View {
94
+ @Environment(\.chatTheme) private var theme
95
+ let logoURL: String?
96
+ let size: CGFloat
97
+ let radius: CGFloat
98
+ var onGradient = false
99
+
100
+ var body: some View {
101
+ let monogram = String(theme.brandName.first ?? "Z").uppercased()
102
+ RemoteImage(urlString: logoURL) {
103
+ ZStack {
104
+ (onGradient ? Color.white.opacity(0.2) : theme.primary)
105
+ Text(monogram)
106
+ .font(theme.fontName.map { Font.custom($0, fixedSize: size * 0.45) } ?? Font.system(size: size * 0.45, weight: .bold))
107
+ .foregroundColor(onGradient ? theme.onPrimary : theme.onPrimary)
108
+ }
109
+ }
110
+ .frame(width: size, height: size)
111
+ .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
112
+ .accessibilityHidden(true)
113
+ }
114
+ }
115
+
116
+ struct AvatarView: View {
117
+ @Environment(\.chatTheme) private var theme
118
+ let name: String?
119
+ let urlString: String?
120
+ let size: CGFloat
121
+
122
+ var body: some View {
123
+ let initial = String((name ?? theme.brandName).first ?? "Z").uppercased()
124
+ RemoteImage(urlString: urlString) {
125
+ ZStack {
126
+ theme.avatarTone
127
+ Text(initial)
128
+ .font(Font.system(size: size * 0.42, weight: .semibold))
129
+ .foregroundColor(theme.avatarInk)
130
+ }
131
+ }
132
+ .frame(width: size, height: size)
133
+ .clipShape(Circle())
134
+ .accessibilityHidden(true)
135
+ }
136
+ }
137
+
138
+ struct IconButton: View {
139
+ let symbol: String
140
+ let label: String
141
+ let color: Color
142
+ var background: Color = .clear
143
+ let action: () -> Void
144
+
145
+ var body: some View {
146
+ Button(action: action) {
147
+ Image(systemName: symbol)
148
+ .font(.system(size: 18, weight: .semibold))
149
+ .foregroundColor(color)
150
+ .frame(width: 44, height: 44)
151
+ .background(Circle().fill(background))
152
+ .contentShape(Circle())
153
+ }
154
+ .buttonStyle(PlainButtonStyle())
155
+ .accessibilityLabel(Text(label))
156
+ }
157
+ }
158
+
159
+ struct ChatCard<Content: View>: View {
160
+ @Environment(\.chatTheme) private var theme
161
+ let content: Content
162
+
163
+ init(@ViewBuilder content: () -> Content) { self.content = content() }
164
+
165
+ var body: some View {
166
+ VStack(spacing: 0) { content }
167
+ .background(theme.card)
168
+ .clipShape(RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius), style: .continuous))
169
+ .overlay(RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius), style: .continuous).stroke(theme.line, lineWidth: 1))
170
+ .shadow(color: theme.cardShadow, radius: 10, x: 0, y: 4)
171
+ }
172
+ }
173
+
174
+ struct DividerLine: View {
175
+ @Environment(\.chatTheme) private var theme
176
+ var body: some View { Rectangle().fill(theme.line).frame(height: 1) }
177
+ }
178
+
179
+ struct StatusDot: View {
180
+ let color: Color
181
+ var size: CGFloat = 7
182
+ var body: some View { Circle().fill(color).frame(width: size, height: size).accessibilityHidden(true) }
183
+ }
184
+
185
+ struct AttributionView: View {
186
+ @Environment(\.chatTheme) private var theme
187
+ var body: some View {
188
+ if theme.showAttribution {
189
+ Text(theme.strings.string("common.poweredBy"))
190
+ .chatFont(.caption)
191
+ .foregroundColor(theme.ink2)
192
+ .frame(maxWidth: .infinity)
193
+ .padding(.top, 14)
194
+ .padding(.bottom, 20)
195
+ }
196
+ }
197
+ }
198
+
199
+ /// A thin bar under the top bar.
200
+ struct BannerBar: View {
201
+ @Environment(\.chatTheme) private var theme
202
+ let banner: ChatBanner
203
+
204
+ var body: some View {
205
+ let colors = palette
206
+ HStack(spacing: 8) {
207
+ Image(systemName: symbol).font(.system(size: 14, weight: .semibold)).accessibilityHidden(true)
208
+ Text(text).chatFont(.secondary, size: 13)
209
+ Spacer(minLength: 0)
210
+ }
211
+ .foregroundColor(colors.1)
212
+ .padding(.horizontal, 16)
213
+ .padding(.vertical, 9)
214
+ .frame(maxWidth: .infinity, alignment: .leading)
215
+ .background(colors.0)
216
+ .accessibilityElement(children: .combine)
217
+ }
218
+
219
+ private var symbol: String {
220
+ switch banner {
221
+ case .reconnecting: return "wifi.slash"
222
+ case .outsideHours: return "clock"
223
+ }
224
+ }
225
+
226
+ private var text: String {
227
+ switch banner {
228
+ case .reconnecting: return theme.strings.string("banner.reconnecting")
229
+ case .outsideHours(let text): return text
230
+ }
231
+ }
232
+
233
+ private var palette: (Color, Color) {
234
+ switch banner {
235
+ case .reconnecting:
236
+ return theme.palette.isDark
237
+ ? (Color(HexColor("#2E1A0E") ?? .black), Color(HexColor("#FDBA74") ?? .white))
238
+ : (Color(HexColor("#FFF3E8") ?? .white), Color(HexColor("#9A3D00") ?? .black))
239
+ case .outsideHours:
240
+ return (theme.bgAlt, theme.ink2)
241
+ }
242
+ }
243
+ }
244
+
245
+ struct PillButton: View {
246
+ @Environment(\.chatTheme) private var theme
247
+ let title: String
248
+ let symbol: String?
249
+ let action: () -> Void
250
+
251
+ var body: some View {
252
+ Button(action: action) {
253
+ HStack(spacing: 8) {
254
+ if let symbol = symbol { Image(systemName: symbol).font(.system(size: 18, weight: .semibold)).accessibilityHidden(true) }
255
+ Text(title).chatFont(.message, weight: .semibold)
256
+ }
257
+ .foregroundColor(theme.onPrimary)
258
+ .padding(.horizontal, 24)
259
+ .frame(minHeight: 52)
260
+ .background(Capsule().fill(theme.primary))
261
+ .shadow(color: theme.primary.opacity(0.35), radius: 12, x: 0, y: 8)
262
+ }
263
+ .buttonStyle(PlainButtonStyle())
264
+ }
265
+ }
266
+
267
+ /// A calm placeholder while the first config loads.
268
+ struct SkeletonView: View {
269
+ @Environment(\.chatTheme) private var theme
270
+ @State private var pulse = false
271
+
272
+ var body: some View {
273
+ VStack(alignment: .leading, spacing: 16) {
274
+ RoundedRectangle(cornerRadius: 12).fill(theme.bgAlt).frame(width: 40, height: 40)
275
+ RoundedRectangle(cornerRadius: 8).fill(theme.bgAlt).frame(width: 220, height: 28)
276
+ RoundedRectangle(cornerRadius: 8).fill(theme.bgAlt).frame(width: 160, height: 16)
277
+ RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius)).fill(theme.bgAlt).frame(height: 120)
278
+ RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius)).fill(theme.bgAlt).frame(height: 72)
279
+ Spacer()
280
+ }
281
+ .padding(20)
282
+ .opacity(pulse ? 0.55 : 1)
283
+ .onAppear {
284
+ if !MotionPolicy.reduced { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { pulse = true } }
285
+ }
286
+ .accessibilityElement(children: .ignore)
287
+ .accessibilityLabel(Text(theme.strings.string("state.loading")))
288
+ }
289
+ }
290
+
291
+ /// An icon, one line, and a button. No technical words.
292
+ struct MessageStateView: View {
293
+ @Environment(\.chatTheme) private var theme
294
+ let symbol: String
295
+ let text: String
296
+ let buttonTitle: String
297
+ let onClose: () -> Void
298
+ let onButton: () -> Void
299
+
300
+ var body: some View {
301
+ VStack(spacing: 0) {
302
+ HStack {
303
+ Spacer()
304
+ IconButton(symbol: "xmark", label: theme.strings.string("common.close"), color: theme.ink2, action: onClose)
305
+ }
306
+ .padding(.horizontal, 8)
307
+ Spacer()
308
+ VStack(spacing: 14) {
309
+ Image(systemName: symbol)
310
+ .font(.system(size: 28, weight: .regular))
311
+ .foregroundColor(theme.ink2)
312
+ .frame(width: 64, height: 64)
313
+ .background(RoundedRectangle(cornerRadius: 20, style: .continuous).fill(theme.bgAlt))
314
+ .accessibilityHidden(true)
315
+ Text(text)
316
+ .chatFont(.screenTitle, weight: .medium)
317
+ .foregroundColor(theme.ink)
318
+ .multilineTextAlignment(.center)
319
+ Button(action: onButton) {
320
+ Text(buttonTitle)
321
+ .chatFont(.message, size: 15.5, weight: .medium)
322
+ .foregroundColor(theme.ink)
323
+ .padding(.horizontal, 26)
324
+ .frame(minHeight: 48)
325
+ .overlay(Capsule().stroke(theme.line, lineWidth: 1))
326
+ }
327
+ .buttonStyle(PlainButtonStyle())
328
+ }
329
+ .padding(.horizontal, 44)
330
+ Spacer()
331
+ Spacer().frame(height: 80)
332
+ }
333
+ }
334
+ }
335
+ #endif