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 }