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