damus

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

EditMetadataView.swift (12167B)


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