@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,413 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ struct HomeView: View {
6
+ @Environment(\.chatTheme) private var theme
7
+ @Environment(\.openURL) private var openURL
8
+ @ObservedObject var model: ChatModel
9
+ let onClose: () -> Void
10
+
11
+ private var snapshot: SiteSnapshot { model.config?.snapshot ?? SiteSnapshot() }
12
+ private var locale: ChatLocale { theme.locale }
13
+
14
+ var body: some View {
15
+ VStack(spacing: 0) {
16
+ header
17
+ if let banner = model.banner { BannerBar(banner: banner) }
18
+ ScrollView {
19
+ VStack(spacing: 12) {
20
+ ForEach(groups.indices, id: \.self) { index in
21
+ moduleGroup(groups[index])
22
+ }
23
+ recent
24
+ AttributionView()
25
+ }
26
+ .padding(.horizontal, 16)
27
+ .padding(.bottom, 8)
28
+ }
29
+ .padding(.top, -CGFloat(Metrics.homeOverlap))
30
+ }
31
+ .background(theme.bg.ignoresSafeArea())
32
+ }
33
+
34
+ // MARK: Header
35
+
36
+ private var headerForeground: Color { theme.headerStyle == .light ? theme.ink : theme.onPrimary }
37
+
38
+ @ViewBuilder
39
+ private var headerBackground: some View {
40
+ switch theme.headerStyle {
41
+ case .gradient:
42
+ // 160 degrees: mostly downward, a little toward the end side.
43
+ LinearGradient(colors: [theme.primary, Color(theme.palette.headerEnd)], startPoint: UnitPoint(x: 0.3, y: 0), endPoint: UnitPoint(x: 0.7, y: 1))
44
+ case .solid:
45
+ theme.primary
46
+ case .light:
47
+ theme.bgAlt
48
+ }
49
+ }
50
+
51
+ private var header: some View {
52
+ let strings = theme.strings
53
+ let heading = snapshot.screens.heading.resolve(locale) ?? strings.string("home.fallbackHeading")
54
+ let description = snapshot.screens.description.resolve(locale)
55
+ return VStack(alignment: .leading, spacing: 0) {
56
+ HStack {
57
+ LogoView(logoURL: model.config?.logoUrl, size: 40, radius: 12, onGradient: theme.headerStyle != .light)
58
+ Spacer()
59
+ IconButton(symbol: "xmark", label: strings.string("common.close"), color: headerForeground,
60
+ background: theme.headerStyle == .light ? theme.bg : Color.white.opacity(0.14), action: onClose)
61
+ }
62
+ Text(heading)
63
+ .chatFont(.homeHeading)
64
+ .foregroundColor(headerForeground)
65
+ .padding(.top, 14)
66
+ .accessibilityAddTraits(.isHeader)
67
+ if let description = description {
68
+ Text(description)
69
+ .chatFont(.sectionTitle, size: 15)
70
+ .foregroundColor(headerForeground.opacity(0.92))
71
+ .padding(.top, 6)
72
+ }
73
+ HStack(spacing: 6) {
74
+ StatusDot(color: Color(HexColor("#4ADE80") ?? .white), size: 8)
75
+ Text(availabilityText).chatFont(.secondary, size: 13, weight: .medium).foregroundColor(headerForeground)
76
+ }
77
+ .padding(.top, 12)
78
+ .accessibilityElement(children: .combine)
79
+ }
80
+ .padding(.horizontal, 20)
81
+ // The iPad sheet has no status bar above the header.
82
+ .padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 18 : 4)
83
+ .padding(.bottom, 44)
84
+ .frame(maxWidth: .infinity, alignment: .leading)
85
+ .background(headerBackground.ignoresSafeArea(edges: .top))
86
+ }
87
+
88
+ private var availabilityText: String {
89
+ let strings = theme.strings
90
+ if model.config?.online == false { return strings.string("banner.outsideHours") }
91
+ return snapshot.behavior.replyExpectation.resolve(locale) ?? strings.string("banner.online")
92
+ }
93
+
94
+ // MARK: Modules
95
+
96
+ private var groups: [[HomeModule]] {
97
+ let modules = model.config?.homeModules(locale: locale, verified: model.isIdentified) ?? []
98
+ var result: [[HomeModule]] = []
99
+ for module in modules {
100
+ let isLink = module.kind == .externalLink || module.kind == .contactLinks
101
+ if isLink, let last = result.last?.last, last.kind == .startConversation || last.kind == .externalLink || last.kind == .contactLinks {
102
+ result[result.count - 1].append(module)
103
+ } else {
104
+ result.append([module])
105
+ }
106
+ }
107
+ return result
108
+ }
109
+
110
+ @ViewBuilder
111
+ private func moduleGroup(_ modules: [HomeModule]) -> some View {
112
+ ChatCard {
113
+ ForEach(modules.indices, id: \.self) { index in
114
+ let module = modules[index]
115
+ if index > 0, module.kind != .externalLink, module.kind != .contactLinks { DividerLine() }
116
+ moduleView(module, isFirst: index == 0)
117
+ }
118
+ }
119
+ }
120
+
121
+ @ViewBuilder
122
+ private func moduleView(_ module: HomeModule, isFirst: Bool) -> some View {
123
+ switch module.kind {
124
+ case .startConversation:
125
+ startButton(module)
126
+ case .greeting, .announcement:
127
+ textCard(module)
128
+ case .externalLink:
129
+ if let href = module.href { linkRow(title: module.label.resolve(locale) ?? "", href: href, symbol: "bag") }
130
+ case .contactLinks:
131
+ ForEach(module.links.indices, id: \.self) { index in
132
+ let link = module.links[index]
133
+ linkRow(title: link.label.resolve(locale) ?? link.href, href: link.href, symbol: symbol(forContact: link.href))
134
+ }
135
+ }
136
+ }
137
+
138
+ private func symbol(forContact href: String) -> String {
139
+ if href.lowercased().hasPrefix("tel:") { return "phone" }
140
+ if href.lowercased().hasPrefix("mailto:") { return "envelope" }
141
+ return "link"
142
+ }
143
+
144
+ private func startButton(_ module: HomeModule) -> some View {
145
+ let title = module.label.resolve(locale) ?? theme.strings.string("home.talkToTeam")
146
+ return VStack(spacing: 0) {
147
+ Button(action: { model.open(.thread(nil)) }) {
148
+ HStack(spacing: 9) {
149
+ Image(systemName: "bubble.left").font(.system(size: 18, weight: .semibold)).accessibilityHidden(true)
150
+ Text(title).chatFont(.message, weight: .semibold)
151
+ }
152
+ .foregroundColor(module.primary ? theme.onPrimary : theme.ink)
153
+ .frame(maxWidth: .infinity, minHeight: 52)
154
+ .background(
155
+ RoundedRectangle(cornerRadius: 13, style: .continuous)
156
+ .fill(module.primary ? theme.primary : theme.bgAlt)
157
+ )
158
+ }
159
+ .buttonStyle(PlainButtonStyle())
160
+ .padding(14)
161
+ }
162
+ }
163
+
164
+ private func textCard(_ module: HomeModule) -> some View {
165
+ VStack(alignment: .leading, spacing: 3) {
166
+ if let title = module.label.resolve(locale) {
167
+ Text(title).chatFont(.sectionTitle, weight: .semibold).foregroundColor(theme.ink)
168
+ }
169
+ if let body = module.description.resolve(locale) {
170
+ Text(body).chatFont(.secondary, size: 14).foregroundColor(theme.ink2)
171
+ }
172
+ }
173
+ .frame(maxWidth: .infinity, alignment: .leading)
174
+ .padding(.horizontal, 16)
175
+ .padding(.vertical, 14)
176
+ .accessibilityElement(children: .combine)
177
+ }
178
+
179
+ private func linkRow(title: String, href: String, symbol: String) -> some View {
180
+ Button(action: { open(href) }) {
181
+ HStack(spacing: 12) {
182
+ Image(systemName: symbol)
183
+ .font(.system(size: 17, weight: .regular))
184
+ .foregroundColor(theme.ink2)
185
+ .frame(width: 36, height: 36)
186
+ .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(theme.bgAlt))
187
+ .accessibilityHidden(true)
188
+ Text(title).chatFont(.message, size: 15.5, weight: .medium).foregroundColor(theme.ink)
189
+ .frame(maxWidth: .infinity, alignment: .leading)
190
+ Image(systemName: "arrow.up.right").font(.system(size: 14, weight: .regular)).foregroundColor(theme.ink2).accessibilityHidden(true)
191
+ }
192
+ .padding(.horizontal, 14)
193
+ .frame(minHeight: 56)
194
+ .contentShape(Rectangle())
195
+ }
196
+ .buttonStyle(PlainButtonStyle())
197
+ }
198
+
199
+ /// Only https, mailto and tel. The system asks before it leaves the app.
200
+ private func open(_ href: String) {
201
+ guard LinkPolicy.isSafe(href), let url = URL(string: href) else { return }
202
+ openURL(url)
203
+ }
204
+
205
+ // MARK: Recent
206
+
207
+ @ViewBuilder
208
+ private var recent: some View {
209
+ let rows = Array(model.conversations.prefix(3))
210
+ if !rows.isEmpty {
211
+ HStack {
212
+ Text(theme.strings.string("home.recent")).chatFont(.sectionTitle, weight: .semibold).foregroundColor(theme.ink)
213
+ .accessibilityAddTraits(.isHeader)
214
+ Spacer()
215
+ Button(action: { model.open(.list) }) {
216
+ Text(theme.strings.string("home.seeAll")).chatFont(.secondary, size: 14, weight: .medium).foregroundColor(theme.link)
217
+ .chatTouchTarget()
218
+ }
219
+ .buttonStyle(PlainButtonStyle())
220
+ }
221
+ .padding(.horizontal, 2)
222
+ .padding(.top, 4)
223
+ ChatCard {
224
+ ForEach(rows.indices, id: \.self) { index in
225
+ if index > 0 { DividerLine() }
226
+ ConversationRow(summary: rows[index], compact: true) { model.open(.thread(rows[index].id)) }
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ /// One conversation: avatar, name, last message in one line, time, unread dot.
234
+ struct ConversationRow: View {
235
+ @Environment(\.chatTheme) private var theme
236
+ let summary: ConversationSummary
237
+ let compact: Bool
238
+ let action: () -> Void
239
+
240
+ private var name: String { summary.agentName ?? theme.brandName }
241
+
242
+ private var preview: (symbol: String?, text: String) {
243
+ let strings = theme.strings
244
+ if let last = summary.lastMessage {
245
+ if !last.text.isEmpty { return (nil, last.text) }
246
+ if last.hasAttachment {
247
+ return last.attachmentKind == "image"
248
+ ? ("photo", strings.string("attachment.photo"))
249
+ : ("paperclip", strings.string("attachment.file"))
250
+ }
251
+ }
252
+ return (nil, "")
253
+ }
254
+
255
+ private var timeText: String {
256
+ guard let date = summary.lastDate else { return "" }
257
+ return ChatFormat.listTime(date, now: Date(), locale: theme.locale)
258
+ }
259
+
260
+ var body: some View {
261
+ Button(action: action) {
262
+ HStack(spacing: compact ? 12 : 13) {
263
+ AvatarView(name: name, urlString: summary.agentAvatarUrl, size: compact ? 36 : 44)
264
+ VStack(alignment: .leading, spacing: 2) {
265
+ Text(name).chatFont(.message, size: compact ? 15 : 16, weight: .semibold).foregroundColor(theme.ink).lineLimit(1)
266
+ HStack(spacing: 5) {
267
+ if let symbol = preview.symbol { Image(systemName: symbol).font(.system(size: 13)).accessibilityHidden(true) }
268
+ Text(preview.text).chatFont(.secondary, size: compact ? 13.5 : 14, weight: summary.unread > 0 ? .medium : .regular).lineLimit(1)
269
+ }
270
+ .foregroundColor(summary.unread > 0 ? theme.ink : theme.ink2)
271
+ }
272
+ .frame(maxWidth: .infinity, alignment: .leading)
273
+ VStack(alignment: .trailing, spacing: 6) {
274
+ Text(timeText).chatFont(.caption, size: 12.5).foregroundColor(theme.ink2)
275
+ if summary.unread > 0 {
276
+ Circle().fill(theme.primary).frame(width: 10, height: 10)
277
+ } else {
278
+ Spacer().frame(height: 10)
279
+ }
280
+ }
281
+ }
282
+ .padding(.horizontal, compact ? 14 : 16)
283
+ .padding(.vertical, compact ? 12 : 10)
284
+ .frame(minHeight: compact ? 60 : 72)
285
+ .contentShape(Rectangle())
286
+ }
287
+ .buttonStyle(PlainButtonStyle())
288
+ .accessibilityElement(children: .ignore)
289
+ .accessibilityLabel(Text(accessibilityText))
290
+ }
291
+
292
+ private var accessibilityText: String {
293
+ var parts = [name, preview.text, timeText]
294
+ if summary.unread > 0 { parts.append(theme.strings.string("a11y.unread")) }
295
+ return parts.filter { !$0.isEmpty }.joined(separator: theme.isRTL ? "، " : ", ")
296
+ }
297
+ }
298
+
299
+ struct ConversationListView: View {
300
+ @Environment(\.chatTheme) private var theme
301
+ @ObservedObject var model: ChatModel
302
+ let onClose: () -> Void
303
+
304
+ var body: some View {
305
+ let strings = theme.strings
306
+ VStack(spacing: 0) {
307
+ HStack {
308
+ if model.canGoBack {
309
+ IconButton(symbol: "chevron.backward", label: strings.string("common.back"), color: theme.ink, action: { model.goBack() })
310
+ }
311
+ Spacer()
312
+ IconButton(symbol: "xmark", label: strings.string("common.close"), color: theme.ink2, action: onClose)
313
+ }
314
+ .padding(.horizontal, 8)
315
+ .padding(.top, 2)
316
+ Text(strings.string("list.title"))
317
+ .chatFont(.homeHeading, size: 30)
318
+ .foregroundColor(theme.ink)
319
+ .frame(maxWidth: .infinity, alignment: .leading)
320
+ .padding(.horizontal, 20)
321
+ .padding(.bottom, 8)
322
+ .accessibilityAddTraits(.isHeader)
323
+ if let banner = model.banner { BannerBar(banner: banner) }
324
+ DividerLine()
325
+ if model.conversations.isEmpty {
326
+ Spacer()
327
+ VStack(spacing: 16) {
328
+ Text(strings.string("list.empty")).chatFont(.message).foregroundColor(theme.ink2).multilineTextAlignment(.center)
329
+ PillButton(title: strings.string("list.new"), symbol: "plus", action: { model.openNewConversation() })
330
+ }
331
+ .padding(.horizontal, 32)
332
+ Spacer()
333
+ } else {
334
+ ScrollView {
335
+ LazyVStack(spacing: 0) {
336
+ ForEach(model.conversations) { summary in
337
+ ConversationRow(summary: summary, compact: false) { model.open(.thread(summary.id)) }
338
+ DividerLine()
339
+ }
340
+ }
341
+ }
342
+ HStack { Spacer(); PillButton(title: strings.string("list.new"), symbol: "plus", action: { model.openNewConversation() }); Spacer() }
343
+ .padding(.top, 12)
344
+ .padding(.bottom, 16)
345
+ }
346
+ }
347
+ .background(theme.bg.ignoresSafeArea())
348
+ }
349
+ }
350
+
351
+ struct ConsentView: View {
352
+ @Environment(\.chatTheme) private var theme
353
+ @Environment(\.openURL) private var openURL
354
+ @ObservedObject var model: ChatModel
355
+ let onDecline: () -> Void
356
+
357
+ var body: some View {
358
+ let strings = theme.strings
359
+ let text = model.config?.consentText(locale: theme.locale) ?? strings.string("consent.default")
360
+ ZStack(alignment: .bottom) {
361
+ Color.black.opacity(0.32).ignoresSafeArea().onTapGesture { }
362
+ VStack(alignment: .leading, spacing: 14) {
363
+ Capsule().fill(theme.line).frame(width: 38, height: 5).frame(maxWidth: .infinity)
364
+ HStack(spacing: 12) {
365
+ LogoView(logoURL: model.config?.logoUrl, size: 44, radius: 13)
366
+ Text(theme.brandName).chatFont(.screenTitle, size: 19, weight: .bold).foregroundColor(theme.ink)
367
+ .frame(maxWidth: .infinity, alignment: .leading)
368
+ .accessibilityAddTraits(.isHeader)
369
+ }
370
+ Text(text).chatFont(.message).foregroundColor(theme.ink).fixedSize(horizontal: false, vertical: true)
371
+ if let privacy = model.config?.snapshot.behavior.privacyUrl, let url = URL(string: privacy) {
372
+ Button(action: { openURL(url) }) {
373
+ Text(strings.string("consent.privacy")).chatFont(.secondary, size: 14.5, weight: .medium).foregroundColor(theme.link)
374
+ .chatTouchTarget()
375
+ }
376
+ .buttonStyle(PlainButtonStyle())
377
+ }
378
+ Button(action: { Task { await model.acceptConsent() } }) {
379
+ Text(strings.string("consent.accept")).chatFont(.message, weight: .semibold).foregroundColor(theme.onPrimary)
380
+ .frame(maxWidth: .infinity, minHeight: 52)
381
+ .background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(theme.primary))
382
+ }
383
+ .buttonStyle(PlainButtonStyle())
384
+ Button(action: onDecline) {
385
+ Text(strings.string("consent.decline")).chatFont(.message, weight: .medium).foregroundColor(theme.ink)
386
+ .frame(maxWidth: .infinity, minHeight: 48)
387
+ .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(theme.line, lineWidth: 1))
388
+ }
389
+ .buttonStyle(PlainButtonStyle())
390
+ }
391
+ .padding(.horizontal, 20)
392
+ .padding(.top, 12)
393
+ .padding(.bottom, 24)
394
+ .frame(maxWidth: 640)
395
+ .background(
396
+ theme.bg
397
+ .clipShape(RoundedCorners(radius: 24, corners: [.topLeft, .topRight]))
398
+ .ignoresSafeArea(edges: .bottom)
399
+ )
400
+ }
401
+ }
402
+ }
403
+
404
+ /// A shape with chosen rounded corners.
405
+ struct RoundedCorners: Shape {
406
+ var radius: CGFloat
407
+ var corners: UIRectCorner
408
+
409
+ func path(in rect: CGRect) -> Path {
410
+ Path(UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)).cgPath)
411
+ }
412
+ }
413
+ #endif