damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

EditPictureControl.swift (28509B)


      1 //
      2 //  EditPictureControl.swift
      3 //  damus
      4 //
      5 //  Created by Joel Klabo on 3/30/23.
      6 //
      7 
      8 import SwiftUI
      9 import Kingfisher
     10 import SwiftyCrop
     11 
     12 // MARK: - Main view
     13 
     14 /// A view that shows an existing picture, and allows a user to upload a new one.
     15 struct EditPictureControl: View {
     16     
     17     // MARK: Type aliases
     18     
     19     typealias T = ImageUploadModel
     20     typealias Model = EditPictureControlViewModel<T>
     21     
     22     
     23     // MARK: Properties and state
     24     
     25     @StateObject var model: Model
     26     @Binding var current_image_url: URL?
     27     let style: Style
     28     let callback: (URL?) -> Void
     29     
     30     @Environment(\.dismiss) var dismiss
     31     
     32     
     33     // MARK: Initializers
     34     
     35     init(model: Model, style: Style? = nil, callback: @escaping (URL?) -> Void) {
     36         self._model = StateObject.init(wrappedValue: model)
     37         self.style = style ?? Style(size: nil, first_time_setup: false)
     38         self.callback = callback
     39         self._current_image_url = model.$current_image_url
     40     }
     41     
     42     init(
     43         uploader: any MediaUploaderProtocol,
     44         context: Model.Context,
     45         keypair: Keypair?,
     46         pubkey: Pubkey,
     47         style: Style? = nil,
     48         current_image_url: Binding<URL?>,
     49         upload_observer: ImageUploadingObserver? = nil,
     50         callback: @escaping (URL?) -> Void
     51     ) {
     52         let model = EditPictureControlViewModel(
     53             context: context,
     54             pubkey: pubkey,
     55             current_image_url: current_image_url,
     56             keypair: keypair,
     57             uploader: uploader,
     58             callback: callback
     59         )
     60         self.init(model: model, style: style, callback: callback)
     61     }
     62     
     63     
     64     // MARK: View definitions
     65 
     66     var body: some View {
     67         Menu {
     68             self.menu_options
     69         } label: {
     70             if self.style.first_time_setup {
     71                 self.first_time_setup_view
     72             }
     73             else {
     74                 self.default_view
     75             }
     76         }
     77         .accessibilityLabel(self.accessibility_label)
     78         .accessibilityHint(self.accessibility_hint)
     79         .maybeAccessibilityValue(self.accessibility_value)
     80         .sheet(isPresented: self.model.show_camera) {
     81             CameraController(uploader: model.uploader, mode: .handle_image(handler: { image in
     82                 self.model.request_upload_authorization(PreUploadedMedia.uiimage(image))
     83             }))
     84         }
     85         .sheet(isPresented: self.model.show_library) {
     86             MediaPicker(mediaPickerEntry: .editPictureControl) { media in
     87                 self.model.request_upload_authorization(media)
     88             }
     89         }
     90         .alert(
     91             NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."),
     92             isPresented: Binding.constant(self.model.state.is_confirming_upload)
     93         ) {
     94             Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
     95                 self.model.confirm_upload_authorization()
     96             }
     97             Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
     98         }
     99         .fullScreenCover(isPresented: self.model.show_image_cropper) {
    100             self.image_cropper
    101         }
    102         .sheet(isPresented: self.model.show_url_sheet) {
    103             ImageURLSelector(callback: { url in
    104                 self.model.choose_url(url)
    105             }, cancel: { self.model.cancel() })
    106             .presentationDetents([.height(300)])
    107             .presentationDragIndicator(.visible)
    108         }
    109         .sheet(item: self.model.error_message, onDismiss: { self.model.cancel() }, content: { error in
    110             Text(error.rawValue)
    111         })
    112     }
    113     
    114     var progress_view: some View {
    115         ProgressView()
    116             .progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple))
    117             .frame(width: style.size, height: style.size)
    118             .padding(10)
    119             .background(DamusColors.white.opacity(0.7))
    120             .clipShape(Circle())
    121             .shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
    122     }
    123     
    124     var menu_options: some View {
    125         Group {
    126             Button(action: { self.model.select_image_from_url() }) {
    127                 Text("Image URL", comment: "Option to enter a url")
    128             }
    129             .accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_from_url.rawValue)
    130             
    131             Button(action: { self.model.select_image_from_library() }) {
    132                 Text("Choose from Library", comment: "Option to select photo from library")
    133             }
    134             
    135             Button(action: { self.model.select_image_from_camera() }) {
    136                 Text("Take Photo", comment: "Option to take a photo with the camera")
    137             }
    138         }
    139     }
    140     
    141     /// We show this on non-onboarding places such as profile edit page
    142     var default_view: some View {
    143         Group {
    144             switch self.model.state {
    145             case .uploading:
    146                 self.progress_view
    147             default:
    148                 Image("camera")
    149                     .resizable()
    150                     .scaledToFit()
    151                     .frame(width: style.size ?? 25, height: style.size ?? 25)
    152                     .foregroundColor(DamusColors.purple)
    153                     .padding(10)
    154                     .background(DamusColors.white.opacity(0.7))
    155                     .clipShape(Circle())
    156                     .background {
    157                         Circle()
    158                             .fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
    159                     }
    160                     .shadow(radius: 3)
    161             }
    162         }
    163     }
    164     
    165     /// We show this on onboarding
    166     var first_time_setup_view: some View {
    167         Group {
    168             switch self.model.state {
    169             case .uploading:
    170                 self.progress_view
    171             default:
    172                 if let url = current_image_url {
    173                     KFAnimatedImage(url)
    174                         .imageContext(.pfp, disable_animation: false)
    175                         .onFailure(fallbackUrl: URL(string: robohash(model.pubkey)), cacheKey: url.absoluteString)
    176                         .cancelOnDisappear(true)
    177                         .configure { view in
    178                             view.framePreloadCount = 3
    179                         }
    180                         .scaledToFill()
    181                         .frame(width: (style.size ?? 25) + 30, height: (style.size ?? 25) + 30)
    182                         .kfClickable()
    183                         .foregroundColor(DamusColors.white)
    184                         .clipShape(Circle())
    185                         .overlay(Circle().stroke(.white, lineWidth: 4))
    186                 }
    187                 else {
    188                     self.first_time_setup_no_image_view
    189                 }
    190             }
    191         }
    192     }
    193     
    194     /// We show this on onboarding before the user enters any image
    195     var first_time_setup_no_image_view: some View {
    196         Image(systemName: "person.fill")
    197             .resizable()
    198             .scaledToFit()
    199             .frame(width: style.size, height: style.size)
    200             .foregroundColor(DamusColors.white)
    201             .padding(20)
    202             .clipShape(Circle())
    203             .background {
    204                 Circle()
    205                     .fill(PinkGradient, strokeBorder: .white, lineWidth: 4)
    206             }
    207             .overlay(
    208                 Image(systemName: "plus.circle.fill")
    209                     .resizable()
    210                     .frame(
    211                         width: max((style.size ?? 30)/3, 20),
    212                         height: max((style.size ?? 30)/3, 20)
    213                     )
    214                     .background(.damusDeepPurple)
    215                     .clipShape(Circle())
    216                     .padding(.leading, -10)
    217                     .padding(.top, -10)
    218                     .foregroundStyle(.white)
    219                     .shadow(color: .black.opacity(0.2), radius: 4)
    220                 , alignment: .bottomTrailing
    221             )
    222     }
    223     
    224     
    225     var crop_configuration: SwiftyCropConfiguration = SwiftyCropConfiguration(rotateImage: false, zoomSensitivity: 5)
    226     
    227     var image_cropper: some View {
    228         Group {
    229             if case .cropping(let preUploadedMedia) = model.state {
    230                 switch preUploadedMedia {
    231                 case .uiimage(let image):
    232                     SwiftyCropView(
    233                         imageToCrop: image,
    234                         maskShape: .circle
    235                     ) { croppedImage in
    236                         self.model.finished_cropping(croppedImage: croppedImage)
    237                     }
    238                 case .unprocessed_image(let url), .processed_image(let url):
    239                     if let image = try? UIImage.from(url: url) {
    240                         SwiftyCropView(
    241                             imageToCrop: image,
    242                             maskShape: .circle,
    243                             configuration: crop_configuration
    244                         ) { croppedImage in
    245                             self.model.finished_cropping(croppedImage: croppedImage)
    246                         }
    247                     }
    248                     else {
    249                         self.cropping_error_screen  // Cannot load image
    250                     }
    251                 case .unprocessed_video(_), .processed_video(_):
    252                     self.cropping_error_screen  // No support for video profile pictures
    253                 }
    254             }
    255             else {
    256                 self.cropping_error_screen  // Some form of internal logical inconsistency
    257             }
    258         }
    259     }
    260     
    261     var cropping_error_screen: some View {
    262         VStack(spacing: 5) {
    263             Text("Error while cropping image", comment: "Heading on cropping error page")
    264                 .font(.headline)
    265             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")
    266             Button(action: { self.model.cancel() }, label: {
    267                 Text("Dismiss", comment: "Button to dismiss error")
    268             })
    269         }
    270     }
    271     
    272     
    273     // MARK: Accesibility helpers
    274     
    275     var accessibility_label: String {
    276         switch self.model.context {
    277         case .normal:
    278             return NSLocalizedString("Edit Image", comment: "Accessibility label for a button that edits an image")
    279         case .profile_picture:
    280             return NSLocalizedString("Edit profile picture", comment: "Accessibility label for a button that edits a profile picture")
    281         }
    282     }
    283     
    284     var accessibility_hint: String {
    285         return NSLocalizedString("Shows options to edit the image", comment: "Accessibility hint for a button that edits an image")
    286     }
    287     
    288     var accessibility_value: String? {
    289         if style.first_time_setup {
    290             if let current_image_url = model.current_image_url {
    291                 switch self.model.context {
    292                 case .normal:
    293                     return NSLocalizedString("Image is setup", comment: "Accessibility value on image control")
    294                 case .profile_picture:
    295                     return NSLocalizedString("Profile picture is setup", comment: "Accessibility value on profile picture image control")
    296                 }
    297             }
    298             else {
    299                 switch self.model.context {
    300                 case .normal:
    301                     return NSLocalizedString("No image is currently setup", comment: "Accessibility value on image control")
    302                 case .profile_picture:
    303                     return NSLocalizedString("No profile picture is currently setup", comment: "Accessibility value on profile picture image control")
    304                 }
    305             }
    306         }
    307         else {
    308             return nil  // Image is shown outside this control and will have its accessibility defined outside this view.
    309         }
    310     }
    311 }
    312 
    313 
    314 // MARK: - View model
    315 
    316 
    317 /// Tracks the state, and provides the logic needed for the EditPictureControl view
    318 ///
    319 /// ## Implementation notes
    320 ///
    321 /// - This makes it easier to test the logic as well as the view, and makes the view easier to work with by separating concerns.
    322 @MainActor
    323 class EditPictureControlViewModel<T: ImageUploadModelProtocol>: ObservableObject {
    324     // MARK: Properties
    325     // Properties are designed to reduce statefulness and hopefully increase predictability.
    326     
    327     /// The context of the upload. Is it a profile picture? A regular picture?
    328     let context: Context
    329     /// Pubkey of the user
    330     let pubkey: Pubkey
    331     /// The currently loaded image URL
    332     @Binding var current_image_url: URL?
    333     /// The state of the picture selection process
    334     @Published private(set) var state: PictureSelectionState
    335     /// User's keypair
    336     let keypair: Keypair?
    337     /// The uploader service to be used when uploading
    338     let uploader: any MediaUploaderProtocol
    339     /// An image upload observer, that can be set when the parent view wants to keep track of the upload process
    340     let image_upload_observer: ImageUploadingObserver?
    341     /// A callback to receive new image urls once the picture selection and upload is complete.
    342     let callback: (URL?) -> Void
    343     
    344     
    345     // MARK: Constants
    346     
    347     /// The desired profile image size
    348     var profile_image_size: CGSize = CGSize(width: 400, height: 400)
    349     
    350     
    351     // MARK: Initializers
    352     
    353     init(
    354         context: Context,
    355         pubkey: Pubkey,
    356         setup: Bool? = nil,
    357         current_image_url: Binding<URL?>,
    358         state: PictureSelectionState = .ready,
    359         keypair: Keypair?,
    360         uploader: any MediaUploaderProtocol,
    361         image_upload_observer: ImageUploadingObserver? = nil,
    362         callback: @escaping (URL?) -> Void
    363     ) {
    364         self.context = context
    365         self.pubkey = pubkey
    366         self._current_image_url = current_image_url
    367         self.state = state
    368         self.keypair = keypair
    369         self.uploader = uploader
    370         self.image_upload_observer = image_upload_observer
    371         self.callback = callback
    372     }
    373     
    374     
    375     // MARK: Convenience bindings to be used in views
    376     
    377     var show_camera: Binding<Bool> {
    378         Binding(
    379             get: { self.state.show_camera },
    380             set: { newShowCamera in
    381                 switch self.state {
    382                 case .selecting_picture_from_camera:
    383                     self.state = newShowCamera ? .selecting_picture_from_camera : .ready
    384                 default:
    385                     if newShowCamera == true { self.state = .selecting_picture_from_camera }
    386                     else { return } // Leave state as-is
    387                 }
    388             }
    389         )
    390     }
    391     
    392     var show_library: Binding<Bool> {
    393         Binding(
    394             get: { self.state.show_library },
    395             set: { newValue in
    396                 switch self.state {
    397                 case .selecting_picture_from_library:
    398                     self.state = newValue ? .selecting_picture_from_library : .ready
    399                 default:
    400                     if newValue == true { self.state = .selecting_picture_from_library }
    401                     else { return } // Leave state as-is
    402                 }
    403             }
    404         )
    405     }
    406     
    407     var show_url_sheet: Binding<Bool> {
    408         Binding(
    409             get: { self.state.show_url_sheet },
    410             set: { newValue in self.state = newValue ? .selecting_picture_from_url : .ready }
    411         )
    412     }
    413     
    414     var show_image_cropper: Binding<Bool> {
    415         Binding(
    416             get: { self.state.show_image_cropper },
    417             set: { newValue in
    418                 switch self.state {
    419                 case .cropping(let media):
    420                     self.state = newValue ? .cropping(media) : .ready
    421                 default:
    422                     return // Leave state as-is
    423                 }
    424             }
    425         )
    426     }
    427     
    428     fileprivate var error_message: Binding<IdentifiableString?> {
    429         Binding(
    430             get: { IdentifiableString(text: self.state.error_message) },
    431             set: { newValue in
    432                 if let newValue {
    433                     self.state = .failed(message: newValue.rawValue)
    434                 }
    435                 else {
    436                     self.state = .ready
    437                 }
    438             }
    439         )
    440     }
    441     
    442     
    443     // MARK: Control methods
    444     // These are methods to be used by the view or a test program to represent user actions.
    445     
    446     /// Ask user if they are sure they want to upload an image
    447     func request_upload_authorization(_ media: PreUploadedMedia) {
    448         self.state = .confirming_upload(media)
    449     }
    450     
    451     /// Confirm on behalf of the user that we have their permission to upload image
    452     func confirm_upload_authorization() {
    453         guard case .confirming_upload(let preUploadedMedia) = state else {
    454             return
    455         }
    456         switch self.context {
    457         case .normal:
    458             self.upload(media: preUploadedMedia)
    459         case .profile_picture:
    460             self.state = .cropping(preUploadedMedia)
    461         }
    462     }
    463     
    464     /// Indicate the image has finished being cropped. This will resize the image and upload it
    465     func finished_cropping(croppedImage: UIImage?) {
    466         guard let croppedImage else { return }
    467         let resizedCroppedImage = croppedImage.resized(to: profile_image_size)
    468         let newPreUploadedMedia: PreUploadedMedia = .uiimage(resizedCroppedImage)
    469         self.upload(media: newPreUploadedMedia)
    470     }
    471     
    472     /// Upload the media
    473     func upload(media: PreUploadedMedia) {
    474         if let mediaToUpload = generateMediaUpload(media) {
    475             self.handle_upload(media: mediaToUpload)
    476         }
    477         else {
    478             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."))
    479         }
    480     }
    481     
    482     /// Cancel the picture selection process
    483     func cancel() {
    484         self.state = .ready
    485     }
    486     
    487     /// Mark the picture selection process as failed
    488     func failed(message: String) {
    489         self.state = .failed(message: message)
    490     }
    491     
    492     /// Choose an image based on a URL
    493     func choose_url(_ url: URL?) {
    494         self.current_image_url = url
    495         callback(url)
    496         self.state = .ready
    497     }
    498     
    499     /// Select an image from the gallery
    500     func select_image_from_library() {
    501         self.state = .selecting_picture_from_library
    502     }
    503     
    504     /// Select an image by taking a photo
    505     func select_image_from_camera() {
    506         self.state = .selecting_picture_from_camera
    507     }
    508     
    509     /// Select an image by specifying a URL
    510     func select_image_from_url() {
    511         self.state = .selecting_picture_from_url
    512     }
    513     
    514     
    515     // MARK: Internal logic
    516     
    517     /// Handles the upload process
    518     private func handle_upload(media: MediaUpload) {
    519         let image_upload = T()
    520         let upload_observer = ImageUploadingObserver()
    521         self.state = .uploading(media: media, upload: image_upload, uploadObserver: upload_observer)
    522         upload_observer.isLoading = true
    523         Task {
    524             let res = await image_upload.start(media: media, uploader: uploader, mediaType: self.context.mediaType, keypair: keypair)
    525             
    526             switch res {
    527             case .success(let urlString):
    528                 let url = URL(string: urlString)
    529                 current_image_url = url
    530                 self.state = .ready
    531                 callback(url)
    532             case .failed(let error):
    533                 if let error {
    534                     Log.info("Error uploading profile image with error: %@", for: .image_uploading, error.localizedDescription)
    535                 } else {
    536                     Log.info("Failed to upload profile image without error", for: .image_uploading)
    537                 }
    538                 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"))
    539             }
    540             upload_observer.isLoading = false
    541         }
    542     }
    543 }
    544 
    545 
    546 // MARK: - Helper views
    547 
    548 /// A view that can be used for inputting a URL.
    549 struct ImageURLSelector: View {
    550     @State var image_url_temp: String = ""
    551     @State var error: String? = nil
    552     @State var image_url: URL? = nil
    553     let callback: (URL?) -> Void
    554     let cancel: () -> Void
    555     
    556     var body: some View {
    557         ZStack {
    558             DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
    559             VStack {
    560                 Text("Image URL", comment: "Label for image url text field")
    561                     .bold()
    562                 
    563                 Divider()
    564                     .padding(.horizontal)
    565                 
    566                 HStack {
    567                     Image(systemName: "doc.on.clipboard")
    568                         .foregroundColor(.gray)
    569                         .onTapGesture {
    570                             if let pastedURL = UIPasteboard.general.string {
    571                                 image_url_temp = URL(string: pastedURL)?.absoluteString ?? ""
    572                             }
    573                         }
    574                     TextField(image_url_temp, text: $image_url_temp)
    575                 }
    576                 .padding(12)
    577                 .background {
    578                     RoundedRectangle(cornerRadius: 12)
    579                         .stroke(.gray.opacity(0.5), lineWidth: 1)
    580                         .background {
    581                             RoundedRectangle(cornerRadius: 12)
    582                                 .foregroundColor(.damusAdaptableWhite)
    583                         }
    584                 }
    585                 .padding(10)
    586                 
    587                 if let error {
    588                     Text(error)
    589                         .foregroundStyle(.red)
    590                 }
    591                 
    592                 Button(action: {
    593                     self.cancel()
    594                 }, label: {
    595                     Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
    596                         .frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
    597                         .padding(10)
    598                 })
    599                 .buttonStyle(NeutralButtonStyle())
    600                 .padding(10)
    601                 
    602                 Button(action: {
    603                     guard let the_url = URL(string: image_url_temp) else {
    604                         error = NSLocalizedString("Invalid URL", comment: "Error label when user enters an invalid URL")
    605                         return
    606                     }
    607                     image_url = the_url
    608                     callback(the_url)
    609                 }, label: {
    610                     Text("Update", comment: "Update button text for updating image url.")
    611                         .frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
    612                 })
    613                 .buttonStyle(GradientButtonStyle(padding: 10))
    614                 .padding(.horizontal, 10)
    615                 .disabled(image_url_temp == image_url?.absoluteString)
    616                 .opacity(image_url_temp == image_url?.absoluteString ? 0.5 : 1)
    617             }
    618         }
    619         .onAppear {
    620             image_url_temp = image_url?.absoluteString ?? ""
    621         }
    622     }
    623 }
    624 
    625 // MARK: - Helper structures
    626 
    627 extension EditPictureControlViewModel {
    628     /// Tracks the state of the picture selection process in the picture control view and provides convenient computed properties for the view
    629     ///
    630     /// ## Implementation notes
    631     ///
    632     /// 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.
    633     enum PictureSelectionState {
    634         case ready
    635         case selecting_picture_from_library
    636         case selecting_picture_from_url
    637         case selecting_picture_from_camera
    638         case confirming_upload(PreUploadedMedia)
    639         case cropping(PreUploadedMedia)
    640         case uploading(media: MediaUpload, upload: any ImageUploadModelProtocol, uploadObserver: ImageUploadingObserver)
    641         case failed(message: String)
    642         
    643         // MARK: Convenience computed properties
    644         // Translates the information in the state, in a way that does not introduce further statefulness
    645         
    646         var is_confirming_upload: Bool { self.step == .confirming_upload }
    647         var show_image_cropper: Bool { self.step == .cropping }
    648         var show_library: Bool { self.step == .selecting_picture_from_library }
    649         var show_camera: Bool { self.step == .selecting_picture_from_camera }
    650         var show_url_sheet: Bool { self.step == .selecting_picture_from_url }
    651         var is_uploading: Bool { self.step == .uploading }
    652         var error_message: String? { if case .failed(let message) = self { return message } else { return nil } }
    653         var step: Step {
    654             switch self {
    655             case .ready: .ready
    656             case .selecting_picture_from_library: .selecting_picture_from_library
    657             case .selecting_picture_from_url: .selecting_picture_from_url
    658             case .selecting_picture_from_camera: .selecting_picture_from_camera
    659             case .confirming_upload(_): .confirming_upload
    660             case .cropping(_): .cropping
    661             case .uploading(_,_,_): .uploading
    662             case .failed(_): .failed
    663             }
    664         }
    665         
    666         /// Tracks the specific step of the picture selection state, without any associated values, to make easy comparisons on where in the process we are
    667         enum Step: String, RawRepresentable, Equatable {
    668             case ready
    669             case selecting_picture_from_library
    670             case selecting_picture_from_url
    671             case selecting_picture_from_camera
    672             case confirming_upload
    673             case cropping
    674             case uploading
    675             case failed
    676         }
    677     }
    678 }
    679 
    680 extension EditPictureControlViewModel {
    681     /// Defines the context of this picture. Is it a profile picture? A normal picture?
    682     enum Context {
    683         case normal
    684         case profile_picture
    685         
    686         var mediaType: ImageUploadMediaType {
    687             switch self {
    688                 case .normal: .normal
    689                 case .profile_picture: .profile_picture
    690             }
    691         }
    692     }
    693 }
    694 
    695 /// An object that can be used for tracking the status of an upload across the view hierarchy.
    696 /// For example, a parent view can instantiate this object and pass it to a child view that handles uploads,
    697 /// and that parent view can change its own style accordingly
    698 ///
    699 /// ## Implementation note:
    700 ///
    701 /// 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.
    702 //@MainActor
    703 class ImageUploadingObserver: ObservableObject {
    704     @MainActor @Published var isLoading: Bool = false
    705 }
    706 
    707 fileprivate struct IdentifiableString: Identifiable, RawRepresentable {
    708     var id: String { return rawValue }
    709     typealias RawValue = String
    710     var rawValue: String
    711     
    712     init?(rawValue: String) {
    713         self.rawValue = rawValue
    714     }
    715     
    716     init?(text: String?) {
    717         guard let text else { return nil }
    718         self.rawValue = text
    719     }
    720 }
    721 
    722 extension EditPictureControl {
    723     struct Style {
    724         let size: CGFloat?
    725         let first_time_setup: Bool
    726     }
    727 }
    728 
    729 // MARK: - Convenience extensions
    730 
    731 fileprivate extension UIImage {
    732     /// Convenience function to easily get an UIImage from a URL
    733     static func from(url: URL) throws -> UIImage? {
    734         let data = try Data(contentsOf: url)
    735         return UIImage(data: data)
    736     }
    737 }
    738 
    739 fileprivate extension View {
    740     func maybeAccessibilityValue(_ value: String?) -> some View {
    741         Group {
    742             if let value { self.accessibilityValue(value) } else { self }
    743         }
    744     }
    745 }
    746 
    747 // MARK: - Previews
    748 
    749 struct EditPictureControl_Previews: PreviewProvider {
    750     static var previews: some View {
    751         let url = Binding<URL?>.constant(URL(string: "https://damus.io")!)
    752         ZStack {
    753             Color.gray
    754             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
    755                 //
    756             }
    757         }
    758     }
    759 }