Implement profile image cropping and optimization

This commit implements profile image cropping and optimization, as well
as a major refactor on EditPictureControl.

It now employs the following techniques:
- Users can now crop their profile pictures to fit a square aspect
  ratio nicely and avoid issues with automatic resizing/cropping
- Profile images are resized to a 400px by 400px image before sending it
  over the wire for better bandwidth usage
- Profile pictures are now tagged as such to the media uploaders, to
  enable media optimization or special care on their end.

Integrating the cropping step was very difficult with the previous
structures, so `EditPictureControl` was heavily refactored to have
improved state handling and better testability:

1. Enums with associated values are being used to capture all of the
   state in the picture selection process, as that helps ensure the
   needed info in each step is there and more clearly delianeate
   different steps — all at compile-time
2. The view was split into a view-model architecture, with almost all of
   the view logic ported to the new view-model class, making the view
   and the logic more clear to read as concerns are separated. This also
   enables better testabilty

Several automated tests were added to cover EditPictureControl logic and
looks.

Closes: https://github.com/damus-io/damus/issues/2643
Changelog-Added: Profile image cropping tools
Changelog-Changed: Improved profile image bandwidth optimization
Changelog-Changed: Improved reliability of picture selector
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2024-12-23 12:56:51 +09:00
parent 81830c7540
commit bb0ad18913
14 changed files with 1222 additions and 276 deletions

View File

@@ -77,11 +77,19 @@ enum MediaUpload {
}
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
protocol ImageUploadModelProtocol {
init()
func start(media: MediaUpload, uploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, keypair: Keypair?) async -> ImageUploadResult
}
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject, ImageUploadModelProtocol {
@Published var progress: Double? = nil
func start(media: MediaUpload, uploader: MediaUploader, keypair: Keypair? = nil) async -> ImageUploadResult {
let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self, keypair: keypair)
override required init() { }
func start(media: MediaUpload, uploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, keypair: Keypair? = nil) async -> ImageUploadResult {
let res = await AttachMediaUtility.create_upload_request(mediaToUpload: media, mediaUploader: uploader, mediaType: mediaType, progress: self, keypair: keypair)
switch res {
case .success(_):

View File

@@ -7,7 +7,18 @@
import Foundation
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
protocol MediaUploaderProtocol: Identifiable {
var nameParam: String { get }
var mediaTypeParam: String { get }
var supportsVideo: Bool { get }
var requiresNip98: Bool { get }
var postAPI: String { get }
func getMediaURL(from data: Data) -> String?
func mediaTypeValue(for mediaType: ImageUploadMediaType) -> String?
}
enum MediaUploader: String, CaseIterable, MediaUploaderProtocol, StringCodable {
var id: String { self.rawValue }
case nostrBuild
case nostrcheck
@@ -33,6 +44,19 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
}
}
var mediaTypeParam: String {
return "media_type"
}
func mediaTypeValue(for mediaType: ImageUploadMediaType) -> String? {
switch mediaType {
case .normal:
return nil
case .profile_picture:
return "avatar"
}
}
var supportsVideo: Bool {
switch self {
case .nostrBuild:
@@ -42,6 +66,15 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
}
}
var requiresNip98: Bool {
switch self {
case .nostrBuild:
return true
case .nostrcheck:
return true
}
}
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var index: Int

View File

@@ -15,12 +15,30 @@ enum ImageUploadResult {
case failed(Error?)
}
fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUploader: MediaUploader, mediaToUpload: MediaUpload) -> Data {
enum ImageUploadMediaType {
case normal
case profile_picture
}
protocol AttachMediaUtilityProtocol {
static func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, progress: URLSessionTaskDelegate, keypair: Keypair?) async -> ImageUploadResult
}
class AttachMediaUtility {
fileprivate static func create_upload_body(mediaData: Data, boundary: String, mediaUploader: any MediaUploaderProtocol, mediaToUpload: MediaUpload, mediaType: ImageUploadMediaType) -> Data {
let mediaTypeFieldValue = mediaUploader.mediaTypeValue(for: mediaType)
let mediaTypeFieldEntry: String?
if let mediaTypeFieldValue {
mediaTypeFieldEntry = "; \(mediaUploader.mediaTypeParam)=\(mediaTypeFieldValue)"
}
else {
mediaTypeFieldEntry = nil
}
let body = NSMutableData();
let contentType = mediaToUpload.mime_type
body.appendString(string: "Content-Type: multipart/form-data; boundary=\(boundary)\r\n\r\n")
body.appendString(string: "--\(boundary)\r\n")
body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\r\n")
body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\(mediaTypeFieldEntry ?? "")\r\n")
body.appendString(string: "Content-Type: \(contentType)\r\n\r\n")
body.append(mediaData as Data)
body.appendString(string: "\r\n")
@@ -28,59 +46,60 @@ fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUplo
return body as Data
}
func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploader, progress: URLSessionTaskDelegate, keypair: Keypair? = nil) async -> ImageUploadResult {
var mediaData: Data?
guard let url = URL(string: mediaUploader.postAPI) else {
return .failed(nil)
}
var request = URLRequest(url: url)
request.httpMethod = "POST";
let boundary = "Boundary-\(UUID().description)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
// If uploading to a media host that support NIP-98 authorization, add the header
if mediaUploader == .nostrBuild || mediaUploader == .nostrcheck,
let keypair,
let method = request.httpMethod,
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
request.setValue(signature, forHTTPHeaderField: "Authorization")
}
switch mediaToUpload {
case .image(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
case .video(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
}
guard let mediaData else {
return .failed(nil)
}
request.httpBody = create_upload_body(mediaData: mediaData, boundary: boundary, mediaUploader: mediaUploader, mediaToUpload: mediaToUpload)
do {
let (data, _) = try await URLSession.shared.data(for: request, delegate: progress)
guard let url = mediaUploader.getMediaURL(from: data) else {
print("Upload failed getting media url")
static func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: any MediaUploaderProtocol, mediaType: ImageUploadMediaType, progress: URLSessionTaskDelegate, keypair: Keypair? = nil) async -> ImageUploadResult {
var mediaData: Data?
guard let url = URL(string: mediaUploader.postAPI) else {
return .failed(nil)
}
return .success(url)
var request = URLRequest(url: url)
request.httpMethod = "POST";
let boundary = "Boundary-\(UUID().description)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
} catch {
return .failed(error)
// If uploading to a media host that support NIP-98 authorization, add the header
if mediaUploader.requiresNip98,
let keypair,
let method = request.httpMethod,
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
request.setValue(signature, forHTTPHeaderField: "Authorization")
}
switch mediaToUpload {
case .image(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
case .video(let url):
do {
mediaData = try Data(contentsOf: url)
} catch {
return .failed(error)
}
}
guard let mediaData else {
return .failed(nil)
}
request.httpBody = create_upload_body(mediaData: mediaData, boundary: boundary, mediaUploader: mediaUploader, mediaToUpload: mediaToUpload, mediaType: mediaType)
do {
let (data, _) = try await URLSession.shared.data(for: request, delegate: progress)
guard let url = mediaUploader.getMediaURL(from: data) else {
print("Upload failed getting media url")
return .failed(nil)
}
return .success(url)
} catch {
return .failed(error)
}
}
}

View File

@@ -32,7 +32,15 @@ struct EditBannerImageView: View {
.onFailureImage(defaultImage)
.kfClickable()
EditPictureControl(uploader: damus_state.settings.default_media_uploader, keypair: damus_state.keypair, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
EditPictureControl(
uploader: damus_state.settings.default_media_uploader,
context: .normal,
keypair: damus_state.keypair,
pubkey: damus_state.pubkey,
current_image_url: $banner_image,
upload_observer: viewModel,
callback: callback
)
.padding(10)
.backwardsCompatibleSafeAreaPadding(self.safeAreaInsets)
.accessibilityLabel(NSLocalizedString("Edit banner image", comment: "Accessibility label for edit banner image button"))

View File

@@ -13,9 +13,14 @@ struct CameraController: UIViewControllerRepresentable {
@Environment(\.presentationMode)
@Binding private var presentationMode
let uploader: MediaUploader
let done: () -> Void
let uploader: any MediaUploaderProtocol
var imagesOnly: Bool = false
var mode: Mode
enum Mode {
case save_to_library(when_done: () -> Void)
case handle_image(handler: (UIImage) -> Void)
}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: CameraController
@@ -25,18 +30,29 @@ struct CameraController: UIViewControllerRepresentable {
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
if !parent.imagesOnly, let videoURL = info[UIImagePickerController.InfoKey.mediaURL] as? URL {
// Handle the selected video
UISaveVideoAtPathToSavedPhotosAlbum(videoURL.relativePath, nil, nil, nil)
} else if let cameraImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
let orientedImage = cameraImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
} else if let editedImage = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
let orientedImage = editedImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
switch parent.mode {
case .save_to_library(when_done: let done):
if !parent.imagesOnly, let videoURL = info[UIImagePickerController.InfoKey.mediaURL] as? URL {
// Handle the selected video
UISaveVideoAtPathToSavedPhotosAlbum(videoURL.relativePath, nil, nil, nil)
} else if let cameraImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
let orientedImage = cameraImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
} else if let editedImage = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
let orientedImage = editedImage.fixOrientation()
UIImageWriteToSavedPhotosAlbum(orientedImage, nil, nil, nil)
}
done()
case .handle_image(handler: let handler):
if let cameraImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage {
let orientedImage = cameraImage.fixOrientation()
handler(orientedImage)
} else if let editedImage = info[UIImagePickerController.InfoKey.editedImage] as? UIImage {
let orientedImage = editedImage.fixOrientation()
handler(orientedImage)
}
}
parent.done()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {

View File

@@ -32,8 +32,21 @@ struct CreateAccountView: View, KeyboardReadable {
VStack(alignment: .center) {
let screenHeight = UIScreen.main.bounds.height
let style = EditPictureControl.Style(
size: keyboardVisible && screenHeight < maxViewportHeightForAdaptiveContentSize ? 25 : 75,
first_time_setup: true
)
EditPictureControl(uploader: .nostrBuild, keypair: account.keypair, pubkey: account.pubkey, size: keyboardVisible && screenHeight < maxViewportHeightForAdaptiveContentSize ? 25 : 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
EditPictureControl(
uploader: MediaUploader.nostrBuild,
context: .profile_picture,
keypair: account.keypair,
pubkey: account.pubkey,
style: style,
current_image_url: $account.profile_image,
upload_observer: profileUploadObserver,
callback: uploadedProfilePicture
)
.shadow(radius: 2)
}

View File

@@ -20,8 +20,14 @@ struct MediaPicker: UIViewControllerRepresentable {
@Binding private var presentationMode
let mediaPickerEntry: MediaPickerEntry
@Binding var image_upload_confirm: Bool
let onMediaSelected: (() -> Void)?
let onMediaPicked: (PreUploadedMedia) -> Void
init(mediaPickerEntry: MediaPickerEntry, onMediaSelected: (() -> Void)? = nil, onMediaPicked: @escaping (PreUploadedMedia) -> Void) {
self.mediaPickerEntry = mediaPickerEntry
self.onMediaSelected = onMediaSelected
self.onMediaPicked = onMediaPicked
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
var parent: MediaPicker
@@ -121,7 +127,7 @@ struct MediaPicker: UIViewControllerRepresentable {
private func chooseMedia(_ media: PreUploadedMedia, orderId: String) {
self.parent.image_upload_confirm = true
self.parent.onMediaSelected?()
self.orderMap[orderId] = media
self.dispatchGroup.leave()
}

View File

@@ -339,7 +339,7 @@ struct PostView: View {
print("img size w:\(img.size.width) h:\(img.size.height)")
async let blurhash = calculate_blurhash(img: img)
let res = await image_upload.start(media: media, uploader: uploader, keypair: damus_state.keypair)
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
switch res {
case .success(let url):
@@ -474,7 +474,7 @@ struct PostView: View {
}
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
.sheet(isPresented: $attach_media) {
MediaPicker(mediaPickerEntry: .postView, image_upload_confirm: $image_upload_confirm){ media in
MediaPicker(mediaPickerEntry: .postView, onMediaSelected: { image_upload_confirm = true }) { media in
self.preUploadedMedia.append(media)
}
.alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $image_upload_confirm) {
@@ -495,10 +495,10 @@ struct PostView: View {
}
}
.sheet(isPresented: $attach_camera) {
CameraController(uploader: damus_state.settings.default_media_uploader) {
CameraController(uploader: damus_state.settings.default_media_uploader, mode: .save_to_library(when_done: {
self.attach_camera = false
self.attach_media = true
}
}))
}
// This alert seeks confirmation about Image-upload when user taps Paste option
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) {

View File

@@ -7,237 +7,700 @@
import SwiftUI
import Kingfisher
import SwiftyCrop
class ImageUploadingObserver: ObservableObject {
@Published var isLoading: Bool = false
}
// MARK: - Main view
/// A view that shows an existing picture, and allows a user to upload a new one.
struct EditPictureControl: View {
let uploader: MediaUploader
let keypair: Keypair?
let pubkey: Pubkey
var size: CGFloat? = 25
var setup: Bool? = false
@Binding var image_url: URL?
@State var image_url_temp: URL?
@ObservedObject var uploadObserver: ImageUploadingObserver
// MARK: Type aliases
typealias T = ImageUploadModel
typealias Model = EditPictureControlViewModel<T>
// MARK: Properties and state
@StateObject var model: Model
@Binding var current_image_url: URL?
let style: Style
let callback: (URL?) -> Void
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
@State private var show_camera = false
@State private var show_library = false
@State private var show_url_sheet = false
@State var image_upload_confirm: Bool = false
@State var preUploadedMedia: PreUploadedMedia? = nil
@Environment(\.dismiss) var dismiss
// MARK: Initializers
init(model: Model, style: Style? = nil, callback: @escaping (URL?) -> Void) {
self._model = StateObject.init(wrappedValue: model)
self.style = style ?? Style(size: nil, first_time_setup: false)
self.callback = callback
self._current_image_url = model.$current_image_url
}
init(
uploader: any MediaUploaderProtocol,
context: Model.Context,
keypair: Keypair?,
pubkey: Pubkey,
style: Style? = nil,
current_image_url: Binding<URL?>,
upload_observer: ImageUploadingObserver? = nil,
callback: @escaping (URL?) -> Void
) {
let model = EditPictureControlViewModel(
context: context,
pubkey: pubkey,
current_image_url: current_image_url,
keypair: keypair,
uploader: uploader,
callback: callback
)
self.init(model: model, style: style, callback: callback)
}
// MARK: View definitions
var body: some View {
Menu {
Button(action: {
self.show_url_sheet = true
}) {
self.menu_options
} label: {
if self.style.first_time_setup {
self.first_time_setup_view
}
else {
self.default_view
}
}
.sheet(isPresented: self.model.show_camera) {
CameraController(uploader: model.uploader, mode: .handle_image(handler: { image in
self.model.request_upload_authorization(PreUploadedMedia.uiimage(image))
}))
}
.sheet(isPresented: self.model.show_library) {
MediaPicker(mediaPickerEntry: .editPictureControl) { media in
self.model.request_upload_authorization(media)
}
}
.alert(
NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."),
isPresented: Binding.constant(self.model.state.is_confirming_upload)
) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
self.model.confirm_upload_authorization()
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
.fullScreenCover(isPresented: self.model.show_image_cropper) {
self.image_cropper
}
.sheet(isPresented: self.model.show_url_sheet) {
ImageURLSelector(callback: { url in
self.model.choose_url(url)
}, cancel: { self.model.cancel() })
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
.sheet(item: self.model.error_message, onDismiss: { self.model.cancel() }, content: { error in
Text(error.rawValue)
})
}
var progress_view: some View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple))
.frame(width: style.size, height: style.size)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
}
var menu_options: some View {
Group {
Button(action: { self.model.select_image_from_url() }) {
Text("Image URL", comment: "Option to enter a url")
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_from_url.rawValue)
Button(action: {
self.show_library = true
}) {
Button(action: { self.model.select_image_from_library() }) {
Text("Choose from Library", comment: "Option to select photo from library")
}
Button(action: {
self.show_camera = true
}) {
Button(action: { self.model.select_image_from_camera() }) {
Text("Take Photo", comment: "Option to take a photo with the camera")
}
} label: {
if uploadObserver.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple))
.frame(width: size, height: size)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
} else if let url = image_url, setup ?? false {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: false)
.onFailure(fallbackUrl: URL(string: robohash(pubkey)), cacheKey: url.absoluteString)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.scaledToFill()
.frame(width: (size ?? 25) + 30, height: (size ?? 25) + 30)
.kfClickable()
.foregroundColor(DamusColors.white)
.clipShape(Circle())
.overlay(Circle().stroke(.white, lineWidth: 4))
} else {
if setup ?? false {
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(DamusColors.white)
.padding(20)
.clipShape(Circle())
.background {
Circle()
.fill(PinkGradient, strokeBorder: .white, lineWidth: 4)
}
.overlay(
Image(systemName: "plus.circle.fill")
.resizable()
.frame(
width: max((size ?? 30)/3, 20),
height: max((size ?? 30)/3, 20)
)
.background(.damusDeepPurple)
.clipShape(Circle())
.padding(.leading, -10)
.padding(.top, -10)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.2), radius: 4)
, alignment: .bottomTrailing)
} else {
Image("camera")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(DamusColors.purple)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.background {
Circle()
.fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
}
}
}
}
.sheet(isPresented: $show_camera) {
CameraController(uploader: uploader) {
self.show_camera = false
self.show_library = true
}
}
.sheet(isPresented: $show_library) {
MediaPicker(mediaPickerEntry: .editPictureControl, image_upload_confirm: $image_upload_confirm) { media in
self.preUploadedMedia = media
}
.alert(NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."), isPresented: $image_upload_confirm) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
if let mediaToUpload = generateMediaUpload(preUploadedMedia) {
self.handle_upload(media: mediaToUpload)
self.show_library = false
}
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
}
.sheet(isPresented: $show_url_sheet) {
ZStack {
DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
VStack {
Text("Image URL")
.bold()
Divider()
.padding(.horizontal)
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedURL = UIPasteboard.general.string {
image_url_temp = URL(string: pastedURL)
}
}
TextField(image_url_temp?.absoluteString ?? "", text: Binding(
get: { image_url_temp?.absoluteString ?? "" },
set: { image_url_temp = URL(string: $0) }
))
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.gray.opacity(0.5), lineWidth: 1)
.background {
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.damusAdaptableWhite)
}
}
.padding(10)
Button(action: {
show_url_sheet.toggle()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
.padding(10)
Button(action: {
image_url = image_url_temp
callback(image_url)
show_url_sheet.toggle()
}, label: {
Text("Update", comment: "Update button text for updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
})
.buttonStyle(GradientButtonStyle(padding: 10))
.padding(.horizontal, 10)
.disabled(image_url_temp == image_url)
.opacity(image_url_temp == image_url ? 0.5 : 1)
}
}
.onAppear {
image_url_temp = image_url
}
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
private func handle_upload(media: MediaUpload) {
uploadObserver.isLoading = true
Task {
let res = await image_upload.start(media: media, uploader: uploader, keypair: keypair)
switch res {
case .success(let urlString):
let url = URL(string: urlString)
image_url = url
callback(url)
case .failed(let error):
if let error {
print("Error uploading profile image \(error.localizedDescription)")
} else {
print("Error uploading image :(")
}
callback(nil)
/// We show this on non-onboarding places such as profile edit page
var default_view: some View {
Group {
switch self.model.state {
case .uploading:
self.progress_view
default:
Image("camera")
.resizable()
.scaledToFit()
.frame(width: style.size ?? 25, height: style.size ?? 25)
.foregroundColor(DamusColors.purple)
.padding(10)
.background(DamusColors.white.opacity(0.7))
.clipShape(Circle())
.background {
Circle()
.fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
}
.shadow(radius: 3)
}
uploadObserver.isLoading = false
}
}
/// We show this on onboarding
var first_time_setup_view: some View {
Group {
switch self.model.state {
case .uploading:
self.progress_view
default:
if let url = current_image_url {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: false)
.onFailure(fallbackUrl: URL(string: robohash(model.pubkey)), cacheKey: url.absoluteString)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.scaledToFill()
.frame(width: (style.size ?? 25) + 30, height: (style.size ?? 25) + 30)
.kfClickable()
.foregroundColor(DamusColors.white)
.clipShape(Circle())
.overlay(Circle().stroke(.white, lineWidth: 4))
}
else {
self.first_time_setup_no_image_view
}
}
}
}
/// We show this on onboarding before the user enters any image
var first_time_setup_no_image_view: some View {
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.frame(width: style.size, height: style.size)
.foregroundColor(DamusColors.white)
.padding(20)
.clipShape(Circle())
.background {
Circle()
.fill(PinkGradient, strokeBorder: .white, lineWidth: 4)
}
.overlay(
Image(systemName: "plus.circle.fill")
.resizable()
.frame(
width: max((style.size ?? 30)/3, 20),
height: max((style.size ?? 30)/3, 20)
)
.background(.damusDeepPurple)
.clipShape(Circle())
.padding(.leading, -10)
.padding(.top, -10)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.2), radius: 4)
, alignment: .bottomTrailing
)
}
var crop_configuration: SwiftyCropConfiguration = SwiftyCropConfiguration(rotateImage: false, zoomSensitivity: 5)
var image_cropper: some View {
Group {
if case .cropping(let preUploadedMedia) = model.state {
switch preUploadedMedia {
case .uiimage(let image):
SwiftyCropView(
imageToCrop: image,
maskShape: .circle
) { croppedImage in
self.model.finished_cropping(croppedImage: croppedImage)
}
case .unprocessed_image(let url), .processed_image(let url):
if let image = try? UIImage.from(url: url) {
SwiftyCropView(
imageToCrop: image,
maskShape: .circle,
configuration: crop_configuration
) { croppedImage in
self.model.finished_cropping(croppedImage: croppedImage)
}
}
else {
self.cropping_error_screen // Cannot load image
}
case .unprocessed_video(_), .processed_video(_):
self.cropping_error_screen // No support for video profile pictures
}
}
else {
self.cropping_error_screen // Some form of internal logical inconsistency
}
}
}
var cropping_error_screen: some View {
VStack(spacing: 5) {
Text("Error while cropping image", comment: "Heading on cropping error page")
.font(.headline)
Text("Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)", comment: "Cropping error message")
Button(action: { self.model.cancel() }, label: {
Text("Dismiss", comment: "Button to dismiss error")
})
}
}
}
// MARK: - View model
/// Tracks the state, and provides the logic needed for the EditPictureControl view
///
/// ## Implementation notes
///
/// - This makes it easier to test the logic as well as the view, and makes the view easier to work with by separating concerns.
@MainActor
class EditPictureControlViewModel<T: ImageUploadModelProtocol>: ObservableObject {
// MARK: Properties
// Properties are designed to reduce statefulness and hopefully increase predictability.
/// The context of the upload. Is it a profile picture? A regular picture?
let context: Context
/// Pubkey of the user
let pubkey: Pubkey
/// The currently loaded image URL
@Binding var current_image_url: URL?
/// The state of the picture selection process
@Published private(set) var state: PictureSelectionState
/// User's keypair
let keypair: Keypair?
/// The uploader service to be used when uploading
let uploader: any MediaUploaderProtocol
/// An image upload observer, that can be set when the parent view wants to keep track of the upload process
let image_upload_observer: ImageUploadingObserver?
/// A callback to receive new image urls once the picture selection and upload is complete.
let callback: (URL?) -> Void
// MARK: Constants
/// The desired profile image size
var profile_image_size: CGSize = CGSize(width: 400, height: 400)
// MARK: Initializers
init(
context: Context,
pubkey: Pubkey,
setup: Bool? = nil,
current_image_url: Binding<URL?>,
state: PictureSelectionState = .ready,
keypair: Keypair?,
uploader: any MediaUploaderProtocol,
image_upload_observer: ImageUploadingObserver? = nil,
callback: @escaping (URL?) -> Void
) {
self.context = context
self.pubkey = pubkey
self._current_image_url = current_image_url
self.state = state
self.keypair = keypair
self.uploader = uploader
self.image_upload_observer = image_upload_observer
self.callback = callback
}
// MARK: Convenience bindings to be used in views
var show_camera: Binding<Bool> {
Binding(
get: { self.state.show_camera },
set: { newShowCamera in
switch self.state {
case .selecting_picture_from_camera:
self.state = newShowCamera ? .selecting_picture_from_camera : .ready
default:
if newShowCamera == true { self.state = .selecting_picture_from_camera }
else { return } // Leave state as-is
}
}
)
}
var show_library: Binding<Bool> {
Binding(
get: { self.state.show_library },
set: { newValue in
switch self.state {
case .selecting_picture_from_library:
self.state = newValue ? .selecting_picture_from_library : .ready
default:
if newValue == true { self.state = .selecting_picture_from_library }
else { return } // Leave state as-is
}
}
)
}
var show_url_sheet: Binding<Bool> {
Binding(
get: { self.state.show_url_sheet },
set: { newValue in self.state = newValue ? .selecting_picture_from_url : .ready }
)
}
var show_image_cropper: Binding<Bool> {
Binding(
get: { self.state.show_image_cropper },
set: { newValue in
switch self.state {
case .cropping(let media):
self.state = newValue ? .cropping(media) : .ready
default:
return // Leave state as-is
}
}
)
}
fileprivate var error_message: Binding<IdentifiableString?> {
Binding(
get: { IdentifiableString(text: self.state.error_message) },
set: { newValue in
if let newValue {
self.state = .failed(message: newValue.rawValue)
}
else {
self.state = .ready
}
}
)
}
// MARK: Control methods
// These are methods to be used by the view or a test program to represent user actions.
/// Ask user if they are sure they want to upload an image
func request_upload_authorization(_ media: PreUploadedMedia) {
self.state = .confirming_upload(media)
}
/// Confirm on behalf of the user that we have their permission to upload image
func confirm_upload_authorization() {
guard case .confirming_upload(let preUploadedMedia) = state else {
return
}
switch self.context {
case .normal:
self.upload(media: preUploadedMedia)
case .profile_picture:
self.state = .cropping(preUploadedMedia)
}
}
/// Indicate the image has finished being cropped. This will resize the image and upload it
func finished_cropping(croppedImage: UIImage?) {
guard let croppedImage else { return }
let resizedCroppedImage = croppedImage.resized(to: profile_image_size)
let newPreUploadedMedia: PreUploadedMedia = .uiimage(resizedCroppedImage)
self.upload(media: newPreUploadedMedia)
}
/// Upload the media
func upload(media: PreUploadedMedia) {
if let mediaToUpload = generateMediaUpload(media) {
self.handle_upload(media: mediaToUpload)
}
else {
self.state = .failed(message: NSLocalizedString("Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io", comment: "Error label forming media for upload after user crops the image."))
}
}
/// Cancel the picture selection process
func cancel() {
self.state = .ready
}
/// Mark the picture selection process as failed
func failed(message: String) {
self.state = .failed(message: message)
}
/// Choose an image based on a URL
func choose_url(_ url: URL?) {
self.current_image_url = url
callback(url)
self.state = .ready
}
/// Select an image from the gallery
func select_image_from_library() {
self.state = .selecting_picture_from_library
}
/// Select an image by taking a photo
func select_image_from_camera() {
self.state = .selecting_picture_from_camera
}
/// Select an image by specifying a URL
func select_image_from_url() {
self.state = .selecting_picture_from_url
}
// MARK: Internal logic
/// Handles the upload process
private func handle_upload(media: MediaUpload) {
let image_upload = T()
let upload_observer = ImageUploadingObserver()
self.state = .uploading(media: media, upload: image_upload, uploadObserver: upload_observer)
upload_observer.isLoading = true
Task {
let res = await image_upload.start(media: media, uploader: uploader, mediaType: self.context.mediaType, keypair: keypair)
switch res {
case .success(let urlString):
let url = URL(string: urlString)
current_image_url = url
self.state = .ready
callback(url)
case .failed(let error):
if let error {
Log.info("Error uploading profile image with error: %@", for: .image_uploading, error.localizedDescription)
} else {
Log.info("Failed to upload profile image without error", for: .image_uploading)
}
self.state = .failed(message: NSLocalizedString("Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).", comment: "Error label when uploading profile image"))
}
upload_observer.isLoading = false
}
}
}
// MARK: - Helper views
/// A view that can be used for inputting a URL.
struct ImageURLSelector: View {
@State var image_url_temp: String = ""
@State var error: String? = nil
@State var image_url: URL? = nil
let callback: (URL?) -> Void
let cancel: () -> Void
var body: some View {
ZStack {
DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
VStack {
Text("Image URL", comment: "Label for image url text field")
.bold()
Divider()
.padding(.horizontal)
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedURL = UIPasteboard.general.string {
image_url_temp = URL(string: pastedURL)?.absoluteString ?? ""
}
}
TextField(image_url_temp, text: $image_url_temp)
}
.padding(12)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.gray.opacity(0.5), lineWidth: 1)
.background {
RoundedRectangle(cornerRadius: 12)
.foregroundColor(.damusAdaptableWhite)
}
}
.padding(10)
if let error {
Text(error)
.foregroundStyle(.red)
}
Button(action: {
self.cancel()
}, label: {
Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
.padding(10)
Button(action: {
guard let the_url = URL(string: image_url_temp) else {
error = NSLocalizedString("Invalid URL", comment: "Error label when user enters an invalid URL")
return
}
image_url = the_url
callback(the_url)
}, label: {
Text("Update", comment: "Update button text for updating image url.")
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
})
.buttonStyle(GradientButtonStyle(padding: 10))
.padding(.horizontal, 10)
.disabled(image_url_temp == image_url?.absoluteString)
.opacity(image_url_temp == image_url?.absoluteString ? 0.5 : 1)
}
}
.onAppear {
image_url_temp = image_url?.absoluteString ?? ""
}
}
}
// MARK: - Helper structures
extension EditPictureControlViewModel {
/// Tracks the state of the picture selection process in the picture control view and provides convenient computed properties for the view
///
/// ## Implementation notes
///
/// Made as an enum with associated values to reduce the amount of independent variables in the view model, and enforce the presence of certain values in certain steps of the process.
enum PictureSelectionState {
case ready
case selecting_picture_from_library
case selecting_picture_from_url
case selecting_picture_from_camera
case confirming_upload(PreUploadedMedia)
case cropping(PreUploadedMedia)
case uploading(media: MediaUpload, upload: any ImageUploadModelProtocol, uploadObserver: ImageUploadingObserver)
case failed(message: String)
// MARK: Convenience computed properties
// Translates the information in the state, in a way that does not introduce further statefulness
var is_confirming_upload: Bool { self.step == .confirming_upload }
var show_image_cropper: Bool { self.step == .cropping }
var show_library: Bool { self.step == .selecting_picture_from_library }
var show_camera: Bool { self.step == .selecting_picture_from_camera }
var show_url_sheet: Bool { self.step == .selecting_picture_from_url }
var is_uploading: Bool { self.step == .uploading }
var error_message: String? { if case .failed(let message) = self { return message } else { return nil } }
var step: Step {
switch self {
case .ready: .ready
case .selecting_picture_from_library: .selecting_picture_from_library
case .selecting_picture_from_url: .selecting_picture_from_url
case .selecting_picture_from_camera: .selecting_picture_from_camera
case .confirming_upload(_): .confirming_upload
case .cropping(_): .cropping
case .uploading(_,_,_): .uploading
case .failed(_): .failed
}
}
/// Tracks the specific step of the picture selection state, without any associated values, to make easy comparisons on where in the process we are
enum Step: String, RawRepresentable, Equatable {
case ready
case selecting_picture_from_library
case selecting_picture_from_url
case selecting_picture_from_camera
case confirming_upload
case cropping
case uploading
case failed
}
}
}
extension EditPictureControlViewModel {
/// Defines the context of this picture. Is it a profile picture? A normal picture?
enum Context {
case normal
case profile_picture
var mediaType: ImageUploadMediaType {
switch self {
case .normal: .normal
case .profile_picture: .profile_picture
}
}
}
}
/// An object that can be used for tracking the status of an upload across the view hierarchy.
/// For example, a parent view can instantiate this object and pass it to a child view that handles uploads,
/// and that parent view can change its own style accordingly
///
/// ## Implementation note:
///
/// It would be correct to put this entire class in the MainActor, but for some reason adding `@MainActor` crashes the Swift compiler with no helpful messages (on Xcode 16.2 (16C5032a)), so individual members of this class need to be manually put into the main actor.
//@MainActor
class ImageUploadingObserver: ObservableObject {
@MainActor @Published var isLoading: Bool = false
}
fileprivate struct IdentifiableString: Identifiable, RawRepresentable {
var id: String { return rawValue }
typealias RawValue = String
var rawValue: String
init?(rawValue: String) {
self.rawValue = rawValue
}
init?(text: String?) {
guard let text else { return nil }
self.rawValue = text
}
}
extension EditPictureControl {
struct Style {
let size: CGFloat?
let first_time_setup: Bool
}
}
// MARK: - Convenience extensions
fileprivate extension UIImage {
/// Convenience function to easily get an UIImage from a URL
static func from(url: URL) throws -> UIImage? {
let data = try Data(contentsOf: url)
return UIImage(data: data)
}
}
// MARK: - Previews
struct EditPictureControl_Previews: PreviewProvider {
static var previews: some View {
let url = Binding<URL?>.constant(URL(string: "https://damus.io")!)
let observer = ImageUploadingObserver()
ZStack {
Color.gray
EditPictureControl(uploader: .nostrBuild, keypair: test_keypair, pubkey: test_pubkey, size: 100, setup: false, image_url: url, uploadObserver: observer) { _ in
EditPictureControl(uploader: MediaUploader.nostrBuild, context: .profile_picture, keypair: test_keypair, pubkey: test_pubkey, style: .init(size: 100, first_time_setup: false), current_image_url: url) { _ in
//
}
}

View File

@@ -33,7 +33,15 @@ struct EditProfilePictureView: View {
.scaledToFill()
.kfClickable()
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, keypair: damus_state?.keypair, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
EditPictureControl(
uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild,
context: .profile_picture,
keypair: damus_state?.keypair,
pubkey: pubkey,
current_image_url: $profile_url,
upload_observer: uploadObserver,
callback: callback
)
}
.frame(width: size, height: size)
.clipShape(Circle())