@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,185 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ extension Color {
6
+ init(_ hex: HexColor) {
7
+ self.init(.sRGB, red: Double(hex.r) / 255, green: Double(hex.g) / 255, blue: Double(hex.b) / 255, opacity: 1)
8
+ }
9
+ }
10
+
11
+ extension UIColor {
12
+ convenience init(hex: HexColor, alpha: CGFloat = 1) {
13
+ self.init(red: CGFloat(hex.r) / 255, green: CGFloat(hex.g) / 255, blue: CGFloat(hex.b) / 255, alpha: alpha)
14
+ }
15
+
16
+ /// #RRGGBB in sRGB. Nil only when the color cannot be read as RGB.
17
+ var hexColor: HexColor? {
18
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
19
+ guard getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
20
+ return HexColor(r: Int((r * 255).rounded()), g: Int((g * 255).rounded()), b: Int((b * 255).rounded()))
21
+ }
22
+ }
23
+
24
+ /// Everything a view needs to draw: palette, language, fonts, corner and density choices.
25
+ struct ChatTheme {
26
+ var palette: ChatPalette
27
+ var locale: ChatLocale
28
+ var fontName: String?
29
+ var corners: Corners
30
+ var density: Density
31
+ var headerStyle: HeaderStyle
32
+ var showAgentAvatar: Bool
33
+ var showAttribution: Bool
34
+ var brandName: String
35
+
36
+ var strings: ChatStrings { ChatStrings(locale: locale) }
37
+ var isRTL: Bool { locale.isRTL }
38
+ var layoutDirection: LayoutDirection { locale.isRTL ? .rightToLeft : .leftToRight }
39
+
40
+ var bg: Color { Color(palette.bg) }
41
+ var bgAlt: Color { Color(palette.bgAlt) }
42
+ var ink: Color { Color(palette.ink) }
43
+ var ink2: Color { Color(palette.ink2) }
44
+ var line: Color { Color(palette.line) }
45
+ var primary: Color { Color(palette.primary) }
46
+ var onPrimary: Color { Color(palette.onPrimary) }
47
+ var customer: Color { Color(palette.customer) }
48
+ var onCustomer: Color { Color(palette.onCustomer) }
49
+ var agentBubble: Color { Color(palette.agentBubble) }
50
+ var ok: Color { Color(palette.ok) }
51
+ var bad: Color { Color(palette.bad) }
52
+ var link: Color { Color(palette.link) }
53
+ var avatarTone: Color { Color(palette.avatarTone) }
54
+ var avatarInk: Color { Color(palette.avatarInk) }
55
+ var disabledSend: Color { Color(palette.disabledSend) }
56
+ var cardShadow: Color { palette.isDark ? Color.black.opacity(0.4) : Color(HexColor(r: 17, g: 20, b: 38)).opacity(0.10) }
57
+ /// Cards on the dark theme use the alt background, like the canvas.
58
+ var card: Color { palette.isDark ? bgAlt : bg }
59
+
60
+ static func make(config: ChatConfig?, locale: ChatLocale, systemDark: Bool, overrides: ZellioChatTheme) -> ChatTheme {
61
+ var appearance = config?.snapshot.appearance ?? Appearance()
62
+ if let primary = overrides.primaryColor?.hexColor {
63
+ appearance.primaryColor = primary.hex
64
+ if overrides.customerMessageColor == nil { appearance.customerMessageColor = primary.hex }
65
+ }
66
+ if let customer = overrides.customerMessageColor?.hexColor { appearance.customerMessageColor = customer.hex }
67
+ if let surface = overrides.surfaceColor?.hexColor { appearance.surfaceColor = surface.hex }
68
+ let palette = ChatPalette.resolve(appearance: appearance, systemDark: systemDark, override: overrides.coreMode)
69
+ return ChatTheme(palette: palette, locale: locale, fontName: overrides.fontName, corners: appearance.corners, density: appearance.density,
70
+ headerStyle: appearance.headerStyle, showAgentAvatar: appearance.showAgentAvatar, showAttribution: appearance.showAttribution,
71
+ brandName: appearance.brandName)
72
+ }
73
+
74
+ static let fallback = ChatTheme.make(config: nil, locale: .en, systemDark: false, overrides: ZellioChatTheme())
75
+
76
+ // UIKit helpers for the thread list.
77
+
78
+ func uiColor(_ hex: HexColor) -> UIColor { UIColor(hex: hex) }
79
+
80
+ func uiFont(_ role: TypeRole, size: CGFloat? = nil, weight: UIFont.Weight? = nil) -> UIFont {
81
+ let points = size ?? CGFloat(role.size)
82
+ let weightValue = weight ?? ChatTheme.uiWeight(role.weight)
83
+ let base: UIFont
84
+ if let name = fontName, let custom = UIFont(name: name, size: points) {
85
+ base = custom
86
+ } else {
87
+ base = UIFont.systemFont(ofSize: points, weight: weightValue)
88
+ }
89
+ return UIFontMetrics(forTextStyle: ChatTheme.uiTextStyle(role)).scaledFont(for: base)
90
+ }
91
+
92
+ static func uiWeight(_ css: Int) -> UIFont.Weight {
93
+ switch css {
94
+ case 700: return .bold
95
+ case 600: return .semibold
96
+ case 500: return .medium
97
+ default: return .regular
98
+ }
99
+ }
100
+
101
+ static func uiTextStyle(_ role: TypeRole) -> UIFont.TextStyle {
102
+ switch role {
103
+ case .homeHeading: return .largeTitle
104
+ case .screenTitle: return .headline
105
+ case .message: return .body
106
+ case .sectionTitle: return .subheadline
107
+ case .secondary: return .footnote
108
+ case .caption: return .caption1
109
+ }
110
+ }
111
+
112
+ static func swiftUIStyle(_ role: TypeRole) -> Font.TextStyle {
113
+ switch role {
114
+ case .homeHeading: return .largeTitle
115
+ case .screenTitle: return .headline
116
+ case .message: return .body
117
+ case .sectionTitle: return .subheadline
118
+ case .secondary: return .footnote
119
+ case .caption: return .caption
120
+ }
121
+ }
122
+
123
+ static func swiftUIWeight(_ css: Int) -> Font.Weight {
124
+ switch css {
125
+ case 700: return .bold
126
+ case 600: return .semibold
127
+ case 500: return .medium
128
+ default: return .regular
129
+ }
130
+ }
131
+ }
132
+
133
+ private struct ChatThemeKey: EnvironmentKey {
134
+ static let defaultValue = ChatTheme.fallback
135
+ }
136
+
137
+ extension EnvironmentValues {
138
+ var chatTheme: ChatTheme {
139
+ get { self[ChatThemeKey.self] }
140
+ set { self[ChatThemeKey.self] = newValue }
141
+ }
142
+ }
143
+
144
+ /// Text that follows Dynamic Type with no cap, uses the brand font when set, and has the spec line height.
145
+ struct ChatFontModifier: ViewModifier {
146
+ @Environment(\.chatTheme) private var theme
147
+ @ScaledMetric private var size: CGFloat
148
+ private let weight: Font.Weight
149
+ private let role: TypeRole
150
+
151
+ init(role: TypeRole, size: CGFloat?, weight: Font.Weight?) {
152
+ self.role = role
153
+ let points = size ?? CGFloat(role.size)
154
+ _size = ScaledMetric(wrappedValue: points, relativeTo: ChatTheme.swiftUIStyle(role))
155
+ self.weight = weight ?? ChatTheme.swiftUIWeight(role.weight)
156
+ }
157
+
158
+ func body(content: Content) -> some View {
159
+ content
160
+ .font(font)
161
+ .lineSpacing(max(0, size * (CGFloat(Metrics.lineHeightMultiple(theme.locale)) - 1.2)))
162
+ }
163
+
164
+ private var font: Font {
165
+ if let name = theme.fontName { return Font.custom(name, fixedSize: size) }
166
+ return Font.system(size: size, weight: weight)
167
+ }
168
+ }
169
+
170
+ extension View {
171
+ func chatFont(_ role: TypeRole, size: CGFloat? = nil, weight: Font.Weight? = nil) -> some View {
172
+ modifier(ChatFontModifier(role: role, size: size, weight: weight))
173
+ }
174
+
175
+ /// Keeps a touch target at 44 pt or more.
176
+ func chatTouchTarget() -> some View {
177
+ frame(minWidth: CGFloat(Metrics.minTouch), minHeight: CGFloat(Metrics.minTouch))
178
+ }
179
+ }
180
+
181
+ /// Reduced motion: fade only, no slide.
182
+ struct MotionPolicy {
183
+ static var reduced: Bool { UIAccessibility.isReduceMotionEnabled }
184
+ }
185
+ #endif
@@ -0,0 +1,354 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import AVFoundation
3
+ import ImageIO
4
+ import PhotosUI
5
+ import UIKit
6
+ import UniformTypeIdentifiers
7
+
8
+ struct PickedFile {
9
+ var data: Data
10
+ var fileName: String
11
+ var mimeType: String
12
+ var thumbnail: UIImage?
13
+ }
14
+
15
+ enum ImagePrep {
16
+ static let maxBytes = 10 * 1024 * 1024
17
+ /// A photo this large can still be scaled down to fit `maxBytes`. Anything else is stopped at `maxBytes`.
18
+ static let maxShrinkableBytes = 40 * 1024 * 1024
19
+
20
+ /// Decodes at most `maxPixelEdge` on the long edge, upright, without decoding the full image first.
21
+ static func decode(_ data: Data, maxPixelEdge: Int) -> UIImage? {
22
+ guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary) else { return nil }
23
+ return decode(source, maxPixelEdge: maxPixelEdge)
24
+ }
25
+
26
+ static func decode(_ source: CGImageSource, maxPixelEdge: Int) -> UIImage? {
27
+ let options = [
28
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
29
+ kCGImageSourceCreateThumbnailWithTransform: true,
30
+ kCGImageSourceShouldCacheImmediately: true,
31
+ kCGImageSourceThumbnailMaxPixelSize: maxPixelEdge,
32
+ ] as CFDictionary
33
+ guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { return nil }
34
+ return UIImage(cgImage: image)
35
+ }
36
+
37
+ /// A very large photo file: scaled down and saved as JPEG. Returns nil if it cannot be made small enough.
38
+ static func shrinkFile(_ url: URL) -> (data: Data, image: UIImage)? {
39
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary),
40
+ let image = decode(source, maxPixelEdge: 2048),
41
+ let data = jpeg(image) else { return nil }
42
+ return (data, image)
43
+ }
44
+
45
+ /// Scales to at most 2048 points on the long edge, then JPEG. Returns nil if it cannot be made small enough.
46
+ static func jpeg(_ image: UIImage) -> Data? {
47
+ var source = image
48
+ let longEdge = Swift.max(image.size.width, image.size.height)
49
+ if longEdge > 2048 {
50
+ let scale = 2048 / longEdge
51
+ let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
52
+ let format = UIGraphicsImageRendererFormat.default()
53
+ format.scale = 1
54
+ source = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
55
+ }
56
+ for quality in [0.85, 0.7, 0.5] as [CGFloat] {
57
+ if let data = source.jpegData(compressionQuality: quality), data.count <= maxBytes { return data }
58
+ }
59
+ return nil
60
+ }
61
+
62
+ static func thumbnail(_ image: UIImage) -> UIImage {
63
+ let side: CGFloat = 112
64
+ let format = UIGraphicsImageRendererFormat.default()
65
+ format.scale = 1
66
+ let scale = Swift.max(side / Swift.max(image.size.width, 1), side / Swift.max(image.size.height, 1))
67
+ let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
68
+ return UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format).image { _ in
69
+ image.draw(in: CGRect(x: (side - size.width) / 2, y: (side - size.height) / 2, width: size.width, height: size.height))
70
+ }
71
+ }
72
+
73
+ static func mimeType(forExtension ext: String) -> String? {
74
+ switch ext.lowercased() {
75
+ case "jpg", "jpeg": return "image/jpeg"
76
+ case "png": return "image/png"
77
+ case "webp": return "image/webp"
78
+ case "heic", "heif": return "image/heic"
79
+ case "pdf": return "application/pdf"
80
+ default: return nil
81
+ }
82
+ }
83
+ }
84
+
85
+ /// Camera, photo library and files. The photo library uses the system picker, so no photo permission is needed.
86
+ /// The camera asks for permission only when the customer taps it.
87
+ final class AttachmentPicker: NSObject, PHPickerViewControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIDocumentPickerDelegate {
88
+ weak var presenter: UIViewController?
89
+ var theme: ChatTheme = .fallback
90
+ var onPicked: ((PickedFile) -> Void)?
91
+ var onProblem: ((ChatError) -> Void)?
92
+
93
+ func showSheet(sourceView: UIView) {
94
+ guard let presenter = presenter else { return }
95
+ let strings = theme.strings
96
+ let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
97
+ if UIImagePickerController.isSourceTypeAvailable(.camera) {
98
+ sheet.addAction(UIAlertAction(title: strings.string("composer.camera"), style: .default) { [weak self] _ in self?.openCamera() })
99
+ }
100
+ sheet.addAction(UIAlertAction(title: strings.string("composer.photos"), style: .default) { [weak self] _ in self?.openPhotos() })
101
+ sheet.addAction(UIAlertAction(title: strings.string("composer.files"), style: .default) { [weak self] _ in self?.openFiles() })
102
+ sheet.addAction(UIAlertAction(title: strings.string("attachment.close"), style: .cancel))
103
+ sheet.overrideUserInterfaceStyle = theme.palette.isDark ? .dark : .light
104
+ if let popover = sheet.popoverPresentationController {
105
+ popover.sourceView = sourceView
106
+ popover.sourceRect = sourceView.bounds
107
+ }
108
+ presenter.present(sheet, animated: true)
109
+ }
110
+
111
+ // MARK: Camera
112
+
113
+ private func openCamera() {
114
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
115
+ case .authorized:
116
+ presentCamera()
117
+ case .notDetermined:
118
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
119
+ DispatchQueue.main.async {
120
+ if granted { self?.presentCamera() } else { self?.showCameraDenied() }
121
+ }
122
+ }
123
+ default:
124
+ showCameraDenied()
125
+ }
126
+ }
127
+
128
+ private func presentCamera() {
129
+ guard let presenter = presenter else { return }
130
+ let picker = UIImagePickerController()
131
+ picker.sourceType = .camera
132
+ picker.delegate = self
133
+ presenter.present(picker, animated: true)
134
+ }
135
+
136
+ private func showCameraDenied() {
137
+ guard let presenter = presenter else { return }
138
+ let strings = theme.strings
139
+ let alert = UIAlertController(title: nil, message: strings.string("composer.cameraDenied"), preferredStyle: .alert)
140
+ alert.addAction(UIAlertAction(title: strings.string("composer.openSettings"), style: .default) { _ in
141
+ if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
142
+ })
143
+ alert.addAction(UIAlertAction(title: strings.string("attachment.close"), style: .cancel))
144
+ presenter.present(alert, animated: true)
145
+ }
146
+
147
+ func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
148
+ picker.dismiss(animated: true)
149
+ guard let image = info[.originalImage] as? UIImage else { return }
150
+ deliver(image: image, name: "photo-\(Int(Date().timeIntervalSince1970)).jpg")
151
+ }
152
+
153
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) }
154
+
155
+ // MARK: Photos
156
+
157
+ private func openPhotos() {
158
+ guard let presenter = presenter else { return }
159
+ var configuration = PHPickerConfiguration()
160
+ configuration.filter = .images
161
+ configuration.selectionLimit = 1
162
+ let picker = PHPickerViewController(configuration: configuration)
163
+ picker.delegate = self
164
+ presenter.present(picker, animated: true)
165
+ }
166
+
167
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
168
+ picker.dismiss(animated: true)
169
+ guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
170
+ provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
171
+ let image = object as? UIImage
172
+ DispatchQueue.main.async {
173
+ guard let image = image else { self?.onProblem?(.network); return }
174
+ self?.deliver(image: image, name: "photo-\(Int(Date().timeIntervalSince1970)).jpg")
175
+ }
176
+ }
177
+ }
178
+
179
+ // MARK: Files
180
+
181
+ private func openFiles() {
182
+ guard let presenter = presenter else { return }
183
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.pdf, UTType.image], asCopy: true)
184
+ picker.delegate = self
185
+ picker.allowsMultipleSelection = false
186
+ presenter.present(picker, animated: true)
187
+ }
188
+
189
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
190
+ guard let url = urls.first else { return }
191
+ let ext = url.pathExtension
192
+ guard let mime = ImagePrep.mimeType(forExtension: ext) else {
193
+ onProblem?(.fileTypeDenied)
194
+ return
195
+ }
196
+ // Read off the main thread, and never read a file into memory before its size is known.
197
+ // 10 MB is the most the server takes.
198
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
199
+ let result = Self.readPicked(url: url, mime: mime)
200
+ DispatchQueue.main.async {
201
+ guard let self = self else { return }
202
+ switch result {
203
+ case .success(let file): self.onPicked?(file)
204
+ case .failure(let problem): self.onProblem?(problem)
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ private static func readPicked(url: URL, mime: String) -> Result<PickedFile, ChatError> {
211
+ let name = url.lastPathComponent
212
+ let isImage = mime.hasPrefix("image/")
213
+ guard let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize else { return .failure(.network) }
214
+ if size > (isImage ? ImagePrep.maxShrinkableBytes : ImagePrep.maxBytes) { return .failure(.fileTooLarge) }
215
+ if size > ImagePrep.maxBytes {
216
+ guard let shrunk = ImagePrep.shrinkFile(url) else { return .failure(.fileTooLarge) }
217
+ let jpegName = (name as NSString).deletingPathExtension + ".jpg"
218
+ return .success(PickedFile(data: shrunk.data, fileName: jpegName, mimeType: "image/jpeg", thumbnail: ImagePrep.thumbnail(shrunk.image)))
219
+ }
220
+ guard let data = try? Data(contentsOf: url), data.count <= ImagePrep.maxBytes else { return .failure(.network) }
221
+ let thumb = isImage ? ImagePrep.decode(data, maxPixelEdge: 240).map { ImagePrep.thumbnail($0) } : nil
222
+ return .success(PickedFile(data: data, fileName: name, mimeType: mime, thumbnail: thumb))
223
+ }
224
+
225
+ private func deliver(image: UIImage, name: String) {
226
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
227
+ let data = ImagePrep.jpeg(image)
228
+ let thumb = ImagePrep.thumbnail(image)
229
+ DispatchQueue.main.async {
230
+ guard let self = self else { return }
231
+ guard let data = data else { self.onProblem?(.fileTooLarge); return }
232
+ self.onPicked?(PickedFile(data: data, fileName: name, mimeType: "image/jpeg", thumbnail: thumb))
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ /// Full screen image with pinch zoom. Swipe down to close.
239
+ final class ImageViewerController: UIViewController, UIScrollViewDelegate {
240
+ private let image: UIImage
241
+ private let closeLabel: String
242
+ private let scroll = UIScrollView()
243
+ private let imageView = UIImageView()
244
+ private let closeButton = UIButton(type: .system)
245
+ private var panStart: CGPoint = .zero
246
+
247
+ init(image: UIImage, closeLabel: String) {
248
+ self.image = image
249
+ self.closeLabel = closeLabel
250
+ super.init(nibName: nil, bundle: nil)
251
+ modalPresentationStyle = .overFullScreen
252
+ modalTransitionStyle = .crossDissolve
253
+ modalPresentationCapturesStatusBarAppearance = true
254
+ }
255
+
256
+ required init?(coder: NSCoder) { nil }
257
+
258
+ override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
259
+
260
+ override func viewDidLoad() {
261
+ super.viewDidLoad()
262
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.94)
263
+ scroll.frame = view.bounds
264
+ scroll.autoresizingMask = [.flexibleWidth, .flexibleHeight]
265
+ scroll.delegate = self
266
+ scroll.minimumZoomScale = 1
267
+ scroll.maximumZoomScale = 4
268
+ scroll.showsVerticalScrollIndicator = false
269
+ scroll.showsHorizontalScrollIndicator = false
270
+ scroll.contentInsetAdjustmentBehavior = .never
271
+ view.addSubview(scroll)
272
+ imageView.image = image
273
+ imageView.contentMode = .scaleAspectFit
274
+ imageView.isAccessibilityElement = true
275
+ imageView.accessibilityLabel = closeLabel
276
+ scroll.addSubview(imageView)
277
+ let double = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:)))
278
+ double.numberOfTapsRequired = 2
279
+ scroll.addGestureRecognizer(double)
280
+ let pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
281
+ pan.delegate = self
282
+ view.addGestureRecognizer(pan)
283
+
284
+ closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
285
+ closeButton.tintColor = .white
286
+ closeButton.backgroundColor = UIColor.white.withAlphaComponent(0.16)
287
+ closeButton.layer.cornerRadius = 22
288
+ closeButton.accessibilityLabel = closeLabel
289
+ closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
290
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
291
+ view.addSubview(closeButton)
292
+ NSLayoutConstraint.activate([
293
+ closeButton.widthAnchor.constraint(equalToConstant: 44), closeButton.heightAnchor.constraint(equalToConstant: 44),
294
+ closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
295
+ closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
296
+ ])
297
+ }
298
+
299
+ override func viewDidLayoutSubviews() {
300
+ super.viewDidLayoutSubviews()
301
+ if scroll.zoomScale == 1 {
302
+ imageView.frame = scroll.bounds
303
+ scroll.contentSize = scroll.bounds.size
304
+ }
305
+ }
306
+
307
+ func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView }
308
+
309
+ @objc private func closeTapped() { dismiss(animated: true) }
310
+
311
+ @objc private func doubleTapped(_ gesture: UITapGestureRecognizer) {
312
+ if scroll.zoomScale > 1 {
313
+ scroll.setZoomScale(1, animated: true)
314
+ } else {
315
+ let point = gesture.location(in: imageView)
316
+ let size = CGSize(width: scroll.bounds.width / 2.5, height: scroll.bounds.height / 2.5)
317
+ scroll.zoom(to: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height), animated: true)
318
+ }
319
+ }
320
+
321
+ @objc private func panned(_ gesture: UIPanGestureRecognizer) {
322
+ guard scroll.zoomScale <= 1.01 else { return }
323
+ let translation = gesture.translation(in: view)
324
+ switch gesture.state {
325
+ case .changed:
326
+ let progress = Swift.min(1, Swift.max(0, translation.y / 300))
327
+ scroll.transform = CGAffineTransform(translationX: 0, y: Swift.max(0, translation.y))
328
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.94 * (1 - progress))
329
+ case .ended, .cancelled:
330
+ let velocity = gesture.velocity(in: view).y
331
+ if translation.y > 120 || velocity > 900 {
332
+ dismiss(animated: true)
333
+ } else {
334
+ UIView.animate(withDuration: 0.2) {
335
+ self.scroll.transform = .identity
336
+ self.view.backgroundColor = UIColor.black.withAlphaComponent(0.94)
337
+ }
338
+ }
339
+ default:
340
+ break
341
+ }
342
+ }
343
+ }
344
+
345
+ extension ImageViewerController: UIGestureRecognizerDelegate {
346
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
347
+
348
+ func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
349
+ guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
350
+ let velocity = pan.velocity(in: view)
351
+ return velocity.y > 0 && abs(velocity.y) > abs(velocity.x)
352
+ }
353
+ }
354
+ #endif