commit bb0ad18913c9aa28adb4b39b653e355917f5aa74 parent 81830c75400730410ece74989571fe29f32dd57b Author: Daniel D’Aquino <daniel@daquino.me> Date: Mon, 23 Dec 2024 12:56:51 +0900 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> Diffstat:
14 files changed, 1191 insertions(+), 245 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -1485,6 +1485,7 @@ D798D22E2B086E4800234419 /* NostrResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB028049D510006080F /* NostrResponse.swift */; }; D79C4C172AFEB061003A41B4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79C4C162AFEB061003A41B4 /* NotificationService.swift */; }; D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */; }; D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */; }; D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343EF2AD0D77C00CED48B /* SnapshotTesting */; }; D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; }; @@ -2449,6 +2450,7 @@ D79C4C162AFEB061003A41B4 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D79C4C1C2AFEB061003A41B4 /* DamusNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DamusNotificationService.entitlements; sourceTree = "<group>"; }; + D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPictureControlTests.swift; sourceTree = "<group>"; }; D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; }; D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; }; D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; }; @@ -3596,6 +3598,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */, E06336A72B7582D600A88E6B /* Assets */, D72A2D032AD9C165002AFF62 /* Mocking */, 4C9B0DEC2A65A74000CBDA21 /* Util */, @@ -4805,6 +4808,7 @@ D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */, E06336AA2B75832100A88E6B /* ImageMetadataTest.swift in Sources */, 4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */, + D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */, 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */, 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */, 4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */, diff --git a/damus/Models/ImageUploadModel.swift b/damus/Models/ImageUploadModel.swift @@ -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(_): diff --git a/damus/Models/MediaUploader.swift b/damus/Models/MediaUploader.swift @@ -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 diff --git a/damus/Views/AttachMediaUtility.swift b/damus/Views/AttachMediaUtility.swift @@ -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) { + 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) + } + + 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.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) + request.setValue(signature, forHTTPHeaderField: "Authorization") } - case .video(let url): - do { - mediaData = try Data(contentsOf: url) - } catch { - return .failed(error) + + 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") + guard let mediaData else { return .failed(nil) } + + request.httpBody = create_upload_body(mediaData: mediaData, boundary: boundary, mediaUploader: mediaUploader, mediaToUpload: mediaToUpload, mediaType: mediaType) - return .success(url) - - } catch { - return .failed(error) + 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) + } } } diff --git a/damus/Views/BannerImageView.swift b/damus/Views/BannerImageView.swift @@ -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")) diff --git a/damus/Views/Camera/CameraController.swift b/damus/Views/Camera/CameraController.swift @@ -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) { diff --git a/damus/Views/CreateAccountView.swift b/damus/Views/CreateAccountView.swift @@ -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) } diff --git a/damus/Views/MediaPicker.swift b/damus/Views/MediaPicker.swift @@ -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() } diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -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) { diff --git a/damus/Views/Profile/EditPictureControl.swift b/damus/Views/Profile/EditPictureControl.swift @@ -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 - let callback: (URL?) -> Void - @StateObject var image_upload: ImageUploadModel = ImageUploadModel() + // MARK: Type aliases - @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 + 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 @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) + } + } + + /// 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()) - .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 + .background { + Circle() + .fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2) } - .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) + .shadow(radius: 3) + } + } + } + + /// 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 } - .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)) + .scaledToFill() + .frame(width: (style.size ?? 25) + 30, height: (style.size ?? 25) + 30) + .kfClickable() + .foregroundColor(DamusColors.white) .clipShape(Circle()) - .background { - Circle() - .fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2) - } + .overlay(Circle().stroke(.white, lineWidth: 4)) + } + else { + self.first_time_setup_no_image_view } - - } - } - .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 + } + + /// 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) } - .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 + .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) } - } - 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) } - )) + 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) + } } - .padding(12) - .background { - RoundedRectangle(cornerRadius: 12) - .stroke(.gray.opacity(0.5), lineWidth: 1) - .background { - RoundedRectangle(cornerRadius: 12) - .foregroundColor(.damusAdaptableWhite) - } + else { + self.cropping_error_screen // Cannot load image } - .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) + case .unprocessed_video(_), .processed_video(_): + self.cropping_error_screen // No support for video profile pictures } } - .onAppear { - image_url_temp = image_url + else { + self.cropping_error_screen // Some form of internal logical inconsistency } - .presentationDetents([.height(300)]) - .presentationDragIndicator(.visible) } } + 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) { - uploadObserver.isLoading = true + 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, keypair: keypair) + 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) - image_url = url + current_image_url = url + self.state = .ready callback(url) case .failed(let error): if let error { - print("Error uploading profile image \(error.localizedDescription)") + Log.info("Error uploading profile image with error: %@", for: .image_uploading, error.localizedDescription) } else { - print("Error uploading image :(") + Log.info("Failed to upload profile image without error", for: .image_uploading) } - callback(nil) + 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")) } - uploadObserver.isLoading = false + 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 // } } diff --git a/damus/Views/Profile/ProfilePictureSelector.swift b/damus/Views/Profile/ProfilePictureSelector.swift @@ -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()) diff --git a/damusTests/EditPictureControlTests.swift b/damusTests/EditPictureControlTests.swift @@ -0,0 +1,368 @@ +// +// EditPictureControlTests.swift +// damus +// +// Created by Daniel D'Aquino on 2024-12-28. +// + +import XCTest +import SnapshotTesting +@testable import damus +import SwiftUI + +final class EditPictureControlTests: XCTestCase { + typealias ViewModel = EditPictureControlViewModel<MockImageUploadModel> + typealias SelectionState = ViewModel.PictureSelectionState + + let mock_uploader = MockMediaUploader() + let mock_url = URL(string: get_test_uploaded_url())! + let test_image = UIImage(named: "bitcoin-p2p")! + let mock_keypair = test_keypair + let mock_pubkey = test_keypair.pubkey + + override func setUp() { + super.setUp() + } + + @MainActor + func testPFPLibrarySelection() async { + let expectation = XCTestExpectation(description: "Received URL") + let view_model = ViewModel( + context: .profile_picture, + pubkey: mock_pubkey, + current_image_url: .constant(mock_url), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + XCTAssertEqual(url, URL(string: get_test_uploaded_url())) + expectation.fulfill() + } + ) + + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + view_model.select_image_from_library() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_library) + + view_model.request_upload_authorization(.uiimage(test_image)) + XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload) + + view_model.confirm_upload_authorization() + XCTAssertEqual(view_model.state.step, SelectionState.Step.cropping) + + view_model.finished_cropping(croppedImage: test_image.resized(to: CGSize(width: 10, height: 10))) + XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading) + + // Wait to receive URL + await fulfillment(of: [expectation], timeout: 5) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + } + + @MainActor + func testBannerLibrarySelection() async { + let expectation = XCTestExpectation(description: "Received URL") + let view_model = ViewModel( + context: .normal, + pubkey: mock_pubkey, + current_image_url: .constant(mock_url), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + XCTAssertEqual(url, URL(string: get_test_uploaded_url())) + expectation.fulfill() + } + ) + + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + view_model.select_image_from_library() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_library) + + let test_image = UIImage(named: "bitcoin-p2p")! + view_model.request_upload_authorization(.uiimage(test_image)) + XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload) + + view_model.confirm_upload_authorization() + XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading) + + // Wait to receive URL + await fulfillment(of: [expectation], timeout: 5) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + } + + @MainActor + func testPFPCameraSelection() async { + let expectation = XCTestExpectation(description: "Received URL") + let view_model = ViewModel( + context: .profile_picture, + pubkey: mock_pubkey, + current_image_url: .constant(mock_url), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + XCTAssertEqual(url, URL(string: get_test_uploaded_url())) + expectation.fulfill() + } + ) + + // Ready + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + // Take picture + view_model.select_image_from_camera() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_camera) + XCTAssertEqual(view_model.state.show_camera, true) + + // Confirm upload + view_model.request_upload_authorization(.uiimage(test_image)) + XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload) + XCTAssertEqual(view_model.state.is_confirming_upload, true) + XCTAssertEqual(view_model.state.show_camera, false) + + // Confirm and crop + view_model.confirm_upload_authorization() + XCTAssertEqual(view_model.state.step, SelectionState.Step.cropping) + XCTAssertEqual(view_model.state.show_image_cropper, true) + XCTAssertEqual(view_model.state.is_confirming_upload, false) + + // Finish cropping and upload + view_model.finished_cropping(croppedImage: test_image.resized(to: CGSize(width: 10, height: 10))) + XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading) + XCTAssertEqual(view_model.state.show_image_cropper, false) + + // Wait to receive URL + await fulfillment(of: [expectation], timeout: 5) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + } + + @MainActor + func testBannerCameraSelection() async { + let expectation = XCTestExpectation(description: "Received URL") + let view_model = ViewModel( + context: .normal, + pubkey: mock_pubkey, + current_image_url: .constant(mock_url), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + XCTAssertEqual(url, URL(string: get_test_uploaded_url())) + expectation.fulfill() + } + ) + + // Ready + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + // Take picture + view_model.select_image_from_camera() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_camera) + XCTAssertEqual(view_model.state.show_camera, true) + + // Confirm upload + view_model.request_upload_authorization(.uiimage(test_image)) + XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload) + XCTAssertEqual(view_model.state.is_confirming_upload, true) + XCTAssertEqual(view_model.state.show_camera, false) + + // Confirm and upload + view_model.confirm_upload_authorization() + XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading) + XCTAssertEqual(view_model.state.show_image_cropper, false) + XCTAssertEqual(view_model.state.is_confirming_upload, false) + + // Wait to receive URL + await fulfillment(of: [expectation], timeout: 5) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + } + + @MainActor + func testPFPUrlSelection() async { + let expectation = XCTestExpectation(description: "Received URL") + let view_model = ViewModel( + context: .profile_picture, + pubkey: mock_pubkey, + current_image_url: .constant(mock_url), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + if url == self.mock_url { + expectation.fulfill() + } + } + ) + + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + view_model.select_image_from_url() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_url) + + view_model.choose_url(mock_url) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + let current_image_url = view_model.current_image_url + XCTAssertEqual(current_image_url, mock_url) + + // Wait to receive URL + await fulfillment(of: [expectation], timeout: 5) + } + + @MainActor + func testPFPSelectionWithCancellation() async { + let expectation = XCTestExpectation(description: "Received URL") + let view_model = ViewModel( + context: .profile_picture, + pubkey: mock_pubkey, + current_image_url: .constant(mock_url), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + XCTAssertEqual(url, URL(string: get_test_uploaded_url())) + expectation.fulfill() + } + ) + + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + // Open camera + view_model.select_image_from_camera() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_camera) + XCTAssertTrue(view_model.show_camera.wrappedValue) + + // Dismiss camera + view_model.show_camera.wrappedValue = false + XCTAssertFalse(view_model.show_camera.wrappedValue) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + // Open library + view_model.select_image_from_library() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_library) + XCTAssertTrue(view_model.show_library.wrappedValue) + + // Dismiss library + view_model.show_library.wrappedValue = false + XCTAssertFalse(view_model.show_library.wrappedValue) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + // Select from URL + view_model.select_image_from_url() + XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_url) + XCTAssertTrue(view_model.show_url_sheet.wrappedValue) + + // Dismiss URL sheet + view_model.show_url_sheet.wrappedValue = false + XCTAssertFalse(view_model.show_url_sheet.wrappedValue) + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + + // Select from library and start cropping + view_model.select_image_from_library() + view_model.request_upload_authorization(.uiimage(test_image)) + view_model.confirm_upload_authorization() + XCTAssertEqual(view_model.state.step, SelectionState.Step.cropping) + XCTAssertTrue(view_model.show_image_cropper.wrappedValue) + + // Cancel during cropping + view_model.show_image_cropper.wrappedValue = false + XCTAssertEqual(view_model.state.step, SelectionState.Step.ready) + } + + @MainActor + func testEditPictureControlFirstTimeSetup() async { + var current_image_url: URL? = nil + + let view_model = EditPictureControl.Model( + context: .profile_picture, + pubkey: mock_pubkey, + current_image_url: Binding(get: { return current_image_url }, set: { current_image_url = $0 }), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + return + } + ) + + // Setup the test view + let test_view = EditPictureControl( + model: view_model, + style: .init(size: 25, first_time_setup: true), + callback: { url in return } + ) + let hostView = UIHostingController(rootView: test_view) + + sleep(2) // Wait a bit for things to load + assertSnapshot(matching: hostView, as: .image(on: .iPhoneSe(.portrait))) + } + + @MainActor + func testEditPictureControlNotFirstTimeSetup() async { + var current_image_url: URL? = nil + + let view_model = EditPictureControl.Model( + context: .profile_picture, + pubkey: mock_pubkey, + current_image_url: Binding(get: { return current_image_url }, set: { current_image_url = $0 }), + state: .ready, + keypair: mock_keypair, + uploader: mock_uploader, + callback: { url in + return + } + ) + + // Setup the test view + let test_view = EditPictureControl( + model: view_model, + style: .init(size: nil, first_time_setup: false), + callback: { url in return } + ) + let hostView = UIHostingController(rootView: test_view) + + sleep(2) // Wait a bit for things to load + assertSnapshot(matching: hostView, as: .image(on: .iPhoneSe(.portrait))) + } + + // MARK: Mock classes + + class MockMediaUploader: MediaUploaderProtocol { + var nameParam: String { return "name_param" } + var mediaTypeParam: String { return "media_type_param" } + var supportsVideo: Bool { return true } + var requiresNip98: Bool { return true } + var postAPI: String { return "http://localhost:8000" } + + func getMediaURL(from data: Data) -> String? { + return "http://localhost:8000" + } + + func mediaTypeValue(for mediaType: damus.ImageUploadMediaType) -> String? { + return "media_type_value" + } + + var uploadCalled = false + var uploadCompletion: (() -> Void)? + } + + class MockImageUploadModel: ImageUploadModelProtocol { + required init() {} + + func start(media: damus.MediaUpload, uploader: any damus.MediaUploaderProtocol, mediaType: damus.ImageUploadMediaType, keypair: damus.Keypair?) async -> damus.ImageUploadResult { + return damus.ImageUploadResult.success(get_test_uploaded_url()) + } + } +} + +fileprivate func get_test_uploaded_url() -> String { + return "https://example.com/newimage.jpg" +} + +fileprivate extension UIImage { + static func from(url: URL) throws -> UIImage? { + let data = try Data(contentsOf: url) + return UIImage(data: data) + } +} diff --git a/damusTests/__Snapshots__/EditPictureControlTests/testEditPictureControlFirstTimeSetup.1.png b/damusTests/__Snapshots__/EditPictureControlTests/testEditPictureControlFirstTimeSetup.1.png Binary files differ. diff --git a/damusTests/__Snapshots__/EditPictureControlTests/testEditPictureControlNotFirstTimeSetup.1.png b/damusTests/__Snapshots__/EditPictureControlTests/testEditPictureControlNotFirstTimeSetup.1.png Binary files differ.