damus

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

EditMetadataView.swift (12845B)


      1 //
      2 //  EditMetadataView.swift
      3 //  damus
      4 //
      5 //  Created by Thomas Tastet on 23/12/2022.
      6 //
      7 
      8 import SwiftUI
      9 import Combine
     10 
     11 let BANNER_HEIGHT: CGFloat = 150.0;
     12 fileprivate let Scroll_height: CGFloat = 700.0
     13 
     14 struct EditMetadataView: View {
     15     let damus_state: DamusState
     16     @State var display_name: String
     17     @State var about: String
     18     @State var picture: String
     19     @State var banner: String
     20     @State var nip05: String
     21     @State var name: String
     22     @State var ln: String
     23     @State var website: String
     24 
     25     @State var confirm_ln_address: Bool = false
     26     @State var confirm_save_alert: Bool = false
     27     
     28     @StateObject var profileUploadObserver = ImageUploadingObserver()
     29     @StateObject var bannerUploadObserver = ImageUploadingObserver()
     30     
     31     @Environment(\.dismiss) var dismiss
     32     @Environment(\.presentationMode) var presentationMode
     33     
     34     init(damus_state: DamusState) {
     35         self.damus_state = damus_state
     36         let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
     37         let data = profile_txn?.unsafeUnownedValue
     38 
     39         _name = State(initialValue: data?.name ?? "")
     40         _display_name = State(initialValue: data?.display_name ?? "")
     41         _about = State(initialValue: data?.about ?? "")
     42         _website = State(initialValue: data?.website ?? "")
     43         _picture = State(initialValue: data?.picture ?? "")
     44         _banner = State(initialValue: data?.banner ?? "")
     45         _nip05 = State(initialValue: data?.nip05 ?? "")
     46         _ln = State(initialValue: data?.lud16 ?? data?.lud06 ?? "")
     47     }
     48     
     49     func to_profile() -> Profile {
     50         let new_nip05 = nip05.isEmpty ? nil : nip05
     51         let new_picture = picture.isEmpty ? nil : picture
     52         let new_banner = banner.isEmpty ? nil : banner
     53         let new_lud06 = ln.contains("@") ? nil : ln
     54         let new_lud16 = ln.contains("@") ? ln : nil
     55 
     56         let profile = Profile(name: name, display_name: display_name, about: about, picture: new_picture, banner: new_banner, website: website, lud06: new_lud06, lud16: new_lud16, nip05: new_nip05, damus_donation: nil)
     57 
     58         return profile
     59     }
     60     
     61     func save() {
     62         let profile = to_profile()
     63         guard let keypair = damus_state.keypair.to_full(),
     64               let metadata_ev = make_metadata_event(keypair: keypair, metadata: profile)
     65         else {
     66             return
     67         }
     68 
     69         damus_state.nostrNetwork.postbox.send(metadata_ev)
     70     }
     71 
     72     func is_ln_valid(ln: String) -> Bool {
     73         return ln.contains("@") || ln.lowercased().starts(with: "lnurl")
     74     }
     75     
     76     var nip05_parts: NIP05? {
     77         return NIP05.parse(nip05)
     78     }
     79     
     80     func topSection(topLevelGeo: GeometryProxy) -> some View {
     81         ZStack(alignment: .top) {
     82             GeometryReader { geo in
     83                 let offset = geo.frame(in: .global).minY
     84                 EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), safeAreaInsets: topLevelGeo.safeAreaInsets, banner_image: URL(string: banner))
     85                     .aspectRatio(contentMode: .fill)
     86                     .frame(width: geo.size.width, height: offset > 0 ? BANNER_HEIGHT + offset : BANNER_HEIGHT)
     87                     .clipped()
     88                     .offset(y: offset > 0 ? -offset : 0) // Pin the top
     89             }
     90             .frame(height: BANNER_HEIGHT)
     91             VStack(alignment: .leading) {
     92                 let pfp_size: CGFloat = 90.0
     93 
     94                 HStack(alignment: .center) {
     95                     EditProfilePictureView(profile_url: URL(string: picture), pubkey: damus_state.pubkey, damus_state: damus_state, size: pfp_size, uploadObserver: profileUploadObserver, callback: uploadedProfilePicture(image_url:))
     96                         .offset(y: -(pfp_size/2.0)) // Increase if set a frame
     97 
     98                    Spacer()
     99                 }.padding(.bottom,-(pfp_size/2.0))
    100             }
    101             .padding(.horizontal,18)
    102             .padding(.top,BANNER_HEIGHT)
    103         }
    104     }
    105     
    106     func navImage(img: String) -> some View {
    107         Image(img)
    108             .frame(width: 33, height: 33)
    109             .background(Color.black.opacity(0.6))
    110             .clipShape(Circle())
    111     }
    112     
    113     var navBackButton: some View {
    114         HStack {
    115             Button {
    116                 if didChange() {
    117                     confirm_save_alert.toggle()
    118                 } else {
    119                     presentationMode.wrappedValue.dismiss()
    120                 }
    121             } label: {
    122                 navImage(img: "chevron-left")
    123             }
    124             Spacer()
    125         }
    126     }
    127     
    128     var body: some View {
    129         GeometryReader { proxy in
    130             self.content(topLevelGeo: proxy)
    131         }
    132     }
    133     
    134     func content(topLevelGeo: GeometryProxy) -> some View {
    135         VStack(alignment: .leading) {
    136             ScrollView(showsIndicators: false) {
    137                 self.topSection(topLevelGeo: topLevelGeo)
    138                 
    139                 Form {
    140                     Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
    141                         let display_name_placeholder = "Satoshi Nakamoto"
    142                         TextField(display_name_placeholder, text: $display_name)
    143                             .autocorrectionDisabled(true)
    144                             .textInputAutocapitalization(.never)
    145                     }
    146                     
    147                     Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
    148                         let username_placeholder = "satoshi"
    149                         TextField(username_placeholder, text: $name)
    150                             .autocorrectionDisabled(true)
    151                             .textInputAutocapitalization(.never)
    152                         
    153                     }
    154                     
    155                     Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
    156                         TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
    157                             .autocorrectionDisabled(true)
    158                             .textInputAutocapitalization(.never)
    159                     }
    160                     
    161                     Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
    162                         let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
    163                         ZStack(alignment: .topLeading) {
    164                             TextEditor(text: $about)
    165                                 .textInputAutocapitalization(.sentences)
    166                                 .frame(minHeight: 45, alignment: .leading)
    167                                 .multilineTextAlignment(.leading)
    168                             Text(about.isEmpty ? placeholder : about)
    169                                 .padding(4)
    170                                 .opacity(about.isEmpty ? 1 : 0)
    171                                 .foregroundColor(Color(uiColor: .placeholderText))
    172                         }
    173                     }
    174                     
    175                     Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
    176                         TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
    177                             .autocorrectionDisabled(true)
    178                             .textInputAutocapitalization(.never)
    179                             .onReceive(Just(ln)) { newValue in
    180                                 self.ln = newValue.trimmingCharacters(in: .whitespaces)
    181                             }
    182                     }
    183                     
    184                     Section(content: {
    185                         TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
    186                             .autocorrectionDisabled(true)
    187                             .textInputAutocapitalization(.never)
    188                             .onReceive(Just(nip05)) { newValue in
    189                                 self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
    190                             }
    191                     }, header: {
    192                         Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
    193                     }, footer: {
    194                         switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
    195                         case .empty:
    196                             // without this, the keyboard dismisses unnecessarily when the footer changes state
    197                             Text("")
    198                         case .valid:
    199                             Text("")
    200                         case .invalid:
    201                             Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
    202                         }
    203                     })
    204                     
    205                     
    206                 }
    207                 .frame(height: Scroll_height)
    208             }
    209             
    210             Button(action: {
    211                 if !ln.isEmpty && !is_ln_valid(ln: ln) {
    212                     confirm_ln_address = true
    213                 } else {
    214                     save()
    215                     dismiss()
    216                 }
    217             }, label: {
    218                 Text(NSLocalizedString("Save", comment: "Button for saving profile."))
    219                     .frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
    220             })
    221             .buttonStyle(GradientButtonStyle(padding: 15))
    222             .padding(.horizontal, 10)
    223             .padding(.bottom, 10 + tabHeight)
    224             .disabled(!didChange())
    225             .opacity(!didChange() ? 0.5 : 1)
    226             .disabled(profileUploadObserver.isLoading || bannerUploadObserver.isLoading)
    227             .alert(NSLocalizedString("Invalid Tip Address", comment: "Title of alerting as invalid tip address."), isPresented: $confirm_ln_address) {
    228                 Button(NSLocalizedString("Ok", comment: "Button to dismiss the alert.")) {
    229                 }
    230             } message: {
    231                 Text("The address should either begin with LNURL or should look like an email address.", comment: "Giving the description of the alert message.")
    232             }
    233         }
    234         .ignoresSafeArea(edges: .top)
    235         .background(Color(.systemGroupedBackground))
    236         .navigationBarBackButtonHidden()
    237         .toolbar {
    238             ToolbarItem(placement: .topBarLeading) {
    239                 navBackButton
    240             }
    241         }
    242         .alert(NSLocalizedString("Discard changes?", comment: "Alert user that changes have been made."), isPresented: $confirm_save_alert) {
    243             Button(NSLocalizedString("No", comment: "Do not discard changes."), role: .cancel) {
    244             }
    245             Button(NSLocalizedString("Yes", comment: "Agree to discard changes made to profile.")) {
    246                 dismiss()
    247             }
    248         }
    249     }
    250     
    251     func uploadedProfilePicture(image_url: URL?) {
    252         picture = image_url?.absoluteString ?? ""
    253     }
    254     
    255     func uploadedBanner(image_url: URL?) {
    256         banner = image_url?.absoluteString ?? ""
    257     }
    258     
    259     func didChange() -> Bool {
    260         let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
    261         let data = profile_txn?.unsafeUnownedValue
    262         
    263         if data?.name ?? "" != name {
    264             return true
    265         }
    266         
    267         if data?.display_name ?? "" != display_name {
    268             return true
    269         }
    270         
    271         if data?.about ?? "" != about {
    272             return true
    273         }
    274         
    275         if data?.website ?? "" != website {
    276             return true
    277         }
    278         
    279         if data?.picture ?? "" != picture {
    280             return true
    281         }
    282         
    283         if data?.banner ?? "" != banner {
    284             return true
    285         }
    286 
    287         if data?.nip05 ?? "" != nip05 {
    288             return true
    289         }
    290         
    291         if data?.lud16 ?? data?.lud06 ?? "" != ln {
    292             return true
    293         }
    294         
    295         return false
    296     }
    297 }
    298 
    299 struct EditMetadataView_Previews: PreviewProvider {
    300     static var previews: some View {
    301         EditMetadataView(damus_state: test_damus_state)
    302     }
    303 }
    304 
    305 enum NIP05ValidationResult {
    306     case empty
    307     case invalid
    308     case valid
    309 }
    310 
    311 func validate_nostr_address(nip05: NIP05?, nip05_str: String) -> NIP05ValidationResult {
    312     guard nip05 != nil else {
    313         // couldn't parse
    314         if nip05_str.isEmpty {
    315             return .empty
    316         } else {
    317             return .invalid
    318         }
    319     }
    320 
    321     // could parse so we valid.
    322     return .valid
    323 }