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 }