PostView.swift (23036B)
1 // 2 // Post.swift 3 // damus 4 // 5 // Created by William Casarin on 2022-04-03. 6 // 7 8 import SwiftUI 9 import AVFoundation 10 11 enum NostrPostResult { 12 case post(NostrPost) 13 case cancel 14 } 15 16 let POST_PLACEHOLDER = NSLocalizedString("Type your note here...", comment: "Text box prompt to ask user to type their note.") 17 let GHOST_CARET_VIEW_ID = "GhostCaret" 18 let DEBUG_SHOW_GHOST_CARET_VIEW: Bool = false 19 20 class TagModel: ObservableObject { 21 var diff = 0 22 } 23 24 enum PostTarget { 25 case none 26 case user(Pubkey) 27 } 28 29 enum PostAction { 30 case replying_to(NostrEvent) 31 case quoting(NostrEvent) 32 case posting(PostTarget) 33 34 var ev: NostrEvent? { 35 switch self { 36 case .replying_to(let ev): 37 return ev 38 case .quoting(let ev): 39 return ev 40 case .posting: 41 return nil 42 } 43 } 44 } 45 46 struct PostView: View { 47 @State var post: NSMutableAttributedString = NSMutableAttributedString() 48 @FocusState var focus: Bool 49 @State var attach_media: Bool = false 50 @State var attach_camera: Bool = false 51 @State var error: String? = nil 52 @State var uploadedMedias: [UploadedMedia] = [] 53 @State var image_upload_confirm: Bool = false 54 @State var references: [RefId] = [] 55 @State var filtered_pubkeys: Set<Pubkey> = [] 56 @State var focusWordAttributes: (String?, NSRange?) = (nil, nil) 57 @State var newCursorIndex: Int? 58 @State var textHeight: CGFloat? = nil 59 60 @State var preUploadedMedia: PreUploadedMedia? = nil 61 62 @StateObject var image_upload: ImageUploadModel = ImageUploadModel() 63 @StateObject var tagModel: TagModel = TagModel() 64 65 @State private var current_placeholder_index = 0 66 67 let action: PostAction 68 let damus_state: DamusState 69 let prompt_view: (() -> AnyView)? 70 let placeholder_messages: [String] 71 let initial_text_suffix: String? 72 73 init( 74 action: PostAction, 75 damus_state: DamusState, 76 prompt_view: (() -> AnyView)? = nil, 77 placeholder_messages: [String]? = nil, 78 initial_text_suffix: String? = nil 79 ) { 80 self.action = action 81 self.damus_state = damus_state 82 self.prompt_view = prompt_view 83 self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER] 84 self.initial_text_suffix = initial_text_suffix 85 } 86 87 @Environment(\.dismiss) var dismiss 88 89 func cancel() { 90 notify(.post(.cancel)) 91 dismiss() 92 } 93 94 func send_post() { 95 let refs = references.filter { ref in 96 if case .pubkey(let pk) = ref, filtered_pubkeys.contains(pk) { 97 return false 98 } 99 return true 100 } 101 let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: refs) 102 103 notify(.post(.post(new_post))) 104 105 clear_draft() 106 107 dismiss() 108 109 } 110 111 var is_post_empty: Bool { 112 return post.string.allSatisfy { $0.isWhitespace } && uploadedMedias.isEmpty 113 } 114 115 var uploading_disabled: Bool { 116 return image_upload.progress != nil 117 } 118 119 var posting_disabled: Bool { 120 return is_post_empty || uploading_disabled 121 } 122 123 // Returns a valid height for the text box, even when textHeight is not a number 124 func get_valid_text_height() -> CGFloat { 125 if let textHeight, textHeight.isFinite, textHeight > 0 { 126 return textHeight 127 } 128 else { 129 return 10 130 } 131 } 132 133 var ImageButton: some View { 134 Button(action: { 135 attach_media = true 136 }, label: { 137 Image("images") 138 .padding(6) 139 }) 140 } 141 142 var CameraButton: some View { 143 Button(action: { 144 attach_camera = true 145 }, label: { 146 Image("camera") 147 .padding(6) 148 }) 149 } 150 151 var AttachmentBar: some View { 152 HStack(alignment: .center, spacing: 15) { 153 ImageButton 154 CameraButton 155 } 156 .disabled(uploading_disabled) 157 } 158 159 var PostButton: some View { 160 Button(NSLocalizedString("Post", comment: "Button to post a note.")) { 161 self.send_post() 162 } 163 .disabled(posting_disabled) 164 .opacity(posting_disabled ? 0.5 : 1.0) 165 .bold() 166 .buttonStyle(GradientButtonStyle(padding: 10)) 167 168 } 169 170 func isEmpty() -> Bool { 171 return self.uploadedMedias.count == 0 && 172 self.post.mutableString.trimmingCharacters(in: .whitespacesAndNewlines) == 173 initialString().mutableString.trimmingCharacters(in: .whitespacesAndNewlines) 174 } 175 176 func initialString() -> NSMutableAttributedString { 177 guard case .posting(let target) = action, 178 case .user(let pubkey) = target, 179 damus_state.pubkey != pubkey else { 180 return .init(string: "") 181 } 182 183 let profile_txn = damus_state.profiles.lookup(id: pubkey) 184 let profile = profile_txn?.unsafeUnownedValue 185 return user_tag_attr_string(profile: profile, pubkey: pubkey) 186 } 187 188 func clear_draft() { 189 switch action { 190 case .replying_to(let replying_to): 191 damus_state.drafts.replies.removeValue(forKey: replying_to) 192 case .quoting(let quoting): 193 damus_state.drafts.quotes.removeValue(forKey: quoting) 194 case .posting: 195 damus_state.drafts.post = nil 196 } 197 198 } 199 200 func load_draft() -> Bool { 201 guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else { 202 self.post = NSMutableAttributedString("") 203 self.uploadedMedias = [] 204 205 return false 206 } 207 208 self.uploadedMedias = draft.media 209 self.post = draft.content 210 return true 211 } 212 213 func post_changed(post: NSMutableAttributedString, media: [UploadedMedia]) { 214 if let draft = load_draft_for_post(drafts: damus_state.drafts, action: action) { 215 draft.content = post 216 draft.media = media 217 } else { 218 let artifacts = DraftArtifacts(content: post, media: media) 219 set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts) 220 } 221 } 222 223 var TextEntry: some View { 224 ZStack(alignment: .topLeading) { 225 TextViewWrapper( 226 attributedText: $post, 227 textHeight: $textHeight, 228 initialTextSuffix: initial_text_suffix, 229 cursorIndex: newCursorIndex, 230 getFocusWordForMention: { word, range in 231 focusWordAttributes = (word, range) 232 self.newCursorIndex = nil 233 }, 234 updateCursorPosition: { newCursorIndex in 235 self.newCursorIndex = newCursorIndex 236 } 237 ) 238 .environmentObject(tagModel) 239 .focused($focus) 240 .textInputAutocapitalization(.sentences) 241 .onChange(of: post) { p in 242 post_changed(post: p, media: uploadedMedias) 243 } 244 // Set a height based on the text content height, if it is available and valid 245 .frame(height: get_valid_text_height()) 246 247 if post.string.isEmpty { 248 Text(self.placeholder_messages[self.current_placeholder_index]) 249 .padding(.top, 8) 250 .padding(.leading, 4) 251 .foregroundColor(Color(uiColor: .placeholderText)) 252 .allowsHitTesting(false) 253 } 254 } 255 .onAppear { 256 // Schedule a timer to switch messages every 3 seconds 257 Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in 258 withAnimation { 259 self.current_placeholder_index = (self.current_placeholder_index + 1) % self.placeholder_messages.count 260 } 261 } 262 } 263 } 264 265 var TopBar: some View { 266 VStack { 267 HStack(spacing: 5.0) { 268 Button(action: { 269 self.cancel() 270 }, label: { 271 Text(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note.")) 272 .padding(10) 273 }) 274 .buttonStyle(NeutralButtonStyle()) 275 276 if let error { 277 Text(error) 278 .foregroundColor(.red) 279 } 280 281 Spacer() 282 283 PostButton 284 } 285 286 if let progress = image_upload.progress { 287 ProgressView(value: progress, total: 1.0) 288 .progressViewStyle(.linear) 289 } 290 291 Divider() 292 .foregroundColor(DamusColors.neutral3) 293 .padding(.top, 5) 294 } 295 .frame(height: 30) 296 .padding() 297 .padding(.top, 15) 298 } 299 300 func handle_upload(media: MediaUpload) { 301 let uploader = damus_state.settings.default_media_uploader 302 Task { 303 let img = getImage(media: media) 304 print("img size w:\(img.size.width) h:\(img.size.height)") 305 async let blurhash = calculate_blurhash(img: img) 306 let res = await image_upload.start(media: media, uploader: uploader, keypair: damus_state.keypair) 307 308 switch res { 309 case .success(let url): 310 guard let url = URL(string: url) else { 311 self.error = "Error uploading image :(" 312 return 313 } 314 let blurhash = await blurhash 315 let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) } 316 let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta) 317 uploadedMedias.append(uploadedMedia) 318 319 case .failed(let error): 320 if let error { 321 self.error = error.localizedDescription 322 } else { 323 self.error = "Error uploading image :(" 324 } 325 } 326 327 } 328 } 329 330 var multiply_factor: CGFloat { 331 if case .quoting = action { 332 return 0.4 333 } else if !uploadedMedias.isEmpty { 334 return 0.2 335 } else { 336 return 1.0 337 } 338 } 339 340 func Editor(deviceSize: GeometryProxy) -> some View { 341 HStack(alignment: .top, spacing: 0) { 342 VStack(alignment: .leading, spacing: 0) { 343 HStack(alignment: .top) { 344 ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) 345 346 VStack(alignment: .leading) { 347 if let prompt_view { 348 prompt_view() 349 } 350 TextEntry 351 } 352 } 353 .id("post") 354 355 PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width) 356 .onChange(of: uploadedMedias) { media in 357 post_changed(post: post, media: media) 358 } 359 360 if case .quoting(let ev) = action { 361 BuilderEventView(damus: damus_state, event: ev) 362 } 363 } 364 .padding(.horizontal) 365 } 366 } 367 368 func fill_target_content(target: PostTarget) { 369 self.post = initialString() 370 self.tagModel.diff = post.string.count 371 } 372 373 var pubkeys: [Pubkey] { 374 self.references.reduce(into: [Pubkey]()) { pks, ref in 375 guard case .pubkey(let pk) = ref else { 376 return 377 } 378 379 pks.append(pk) 380 } 381 } 382 383 var body: some View { 384 GeometryReader { (deviceSize: GeometryProxy) in 385 VStack(alignment: .leading, spacing: 0) { 386 let searching = get_searching_string(focusWordAttributes.0) 387 388 TopBar 389 390 ScrollViewReader { scroller in 391 ScrollView { 392 VStack(alignment: .leading) { 393 if case .replying_to(let replying_to) = self.action { 394 ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys) 395 } 396 397 Editor(deviceSize: deviceSize) 398 .padding(.top, 5) 399 } 400 } 401 .frame(maxHeight: searching == nil ? deviceSize.size.height : 70) 402 .onAppear { 403 scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top) 404 } 405 } 406 407 // This if-block observes @ for tagging 408 if let searching { 409 UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post) 410 .frame(maxHeight: .infinity) 411 .environmentObject(tagModel) 412 } else { 413 Divider() 414 VStack(alignment: .leading) { 415 AttachmentBar 416 .padding(.vertical, 5) 417 .padding(.horizontal) 418 } 419 } 420 } 421 .background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)) 422 .sheet(isPresented: $attach_media) { 423 MediaPicker(image_upload_confirm: $image_upload_confirm){ media in 424 self.preUploadedMedia = media 425 } 426 .alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $image_upload_confirm) { 427 Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) { 428 if let mediaToUpload = generateMediaUpload(preUploadedMedia) { 429 self.handle_upload(media: mediaToUpload) 430 self.attach_media = false 431 } 432 } 433 Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {} 434 } 435 } 436 .sheet(isPresented: $attach_camera) { 437 CameraController(uploader: damus_state.settings.default_media_uploader) { 438 self.attach_camera = false 439 self.attach_media = true 440 } 441 } 442 .onAppear() { 443 let loaded_draft = load_draft() 444 445 switch action { 446 case .replying_to(let replying_to): 447 references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to) 448 case .quoting(let quoting): 449 references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting) 450 case .posting(let target): 451 guard !loaded_draft else { break } 452 453 fill_target_content(target: target) 454 } 455 456 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 457 self.focus = true 458 } 459 } 460 .onDisappear { 461 if isEmpty() { 462 clear_draft() 463 } 464 } 465 } 466 } 467 } 468 469 func get_searching_string(_ word: String?) -> String? { 470 guard let word = word else { 471 return nil 472 } 473 474 guard word.count >= 2 else { 475 return nil 476 } 477 478 guard let firstCharacter = word.first, 479 firstCharacter == "@" else { 480 return nil 481 } 482 483 // don't include @npub... strings 484 guard word.count != 64 else { 485 return nil 486 } 487 488 return String(word.dropFirst()) 489 } 490 491 struct PostView_Previews: PreviewProvider { 492 static var previews: some View { 493 PostView(action: .posting(.none), damus_state: test_damus_state) 494 } 495 } 496 497 struct PVImageCarouselView: View { 498 @Binding var media: [UploadedMedia] 499 500 let deviceWidth: CGFloat 501 502 var body: some View { 503 ScrollView(.horizontal, showsIndicators: false) { 504 HStack { 505 ForEach(media.map({$0.representingImage}), id: \.self) { image in 506 ZStack(alignment: .topTrailing) { 507 Image(uiImage: image) 508 .resizable() 509 .aspectRatio(contentMode: .fill) 510 .frame(width: media.count == 1 ? deviceWidth*0.8 : 250, height: media.count == 1 ? 400 : 250) 511 .cornerRadius(10) 512 .padding() 513 .contextMenu { 514 if let uploadedURL = media.first(where: { $0.representingImage == image })?.uploadedURL { 515 Button(action: { 516 UIPasteboard.general.string = uploadedURL.absoluteString 517 }) { 518 Label(NSLocalizedString("Copy URL", comment: "Label for button in context menu to copy URL of the selected uploaded media asset."), image: "copy") 519 } 520 } 521 } 522 Image("close-circle") 523 .foregroundColor(.white) 524 .padding(20) 525 .shadow(radius: 5) 526 .onTapGesture { 527 if let index = media.map({$0.representingImage}).firstIndex(of: image) { 528 media.remove(at: index) 529 } 530 } 531 } 532 } 533 } 534 .padding() 535 } 536 } 537 } 538 539 fileprivate func getImage(media: MediaUpload) -> UIImage { 540 var uiimage: UIImage = UIImage() 541 if media.is_image { 542 // fetch the image data 543 if let data = try? Data(contentsOf: media.localURL) { 544 uiimage = UIImage(data: data) ?? UIImage() 545 } 546 } else { 547 let asset = AVURLAsset(url: media.localURL) 548 let generator = AVAssetImageGenerator(asset: asset) 549 generator.appliesPreferredTrackTransform = true 550 let time = CMTimeMake(value: 1, timescale: 60) // get the thumbnail image at the 1st second 551 do { 552 let cgImage = try generator.copyCGImage(at: time, actualTime: nil) 553 uiimage = UIImage(cgImage: cgImage) 554 } catch { 555 print("No thumbnail: \(error)") 556 } 557 // create a play icon on the top to differentiate if media upload is image or a video, gif is an image 558 let playIcon = UIImage(systemName: "play.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal) 559 let size = uiimage.size 560 let scale = UIScreen.main.scale 561 UIGraphicsBeginImageContextWithOptions(size, false, scale) 562 uiimage.draw(at: .zero) 563 let playIconSize = CGSize(width: 60, height: 60) 564 let playIconOrigin = CGPoint(x: (size.width - playIconSize.width) / 2, y: (size.height - playIconSize.height) / 2) 565 playIcon?.draw(in: CGRect(origin: playIconOrigin, size: playIconSize)) 566 let newImage = UIGraphicsGetImageFromCurrentImageContext() 567 UIGraphicsEndImageContext() 568 uiimage = newImage ?? UIImage() 569 } 570 return uiimage 571 } 572 573 struct UploadedMedia: Equatable { 574 let localURL: URL 575 let uploadedURL: URL 576 let representingImage: UIImage 577 let metadata: ImageMetadata? 578 } 579 580 581 func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) { 582 switch action { 583 case .replying_to(let ev): 584 drafts.replies[ev] = artifacts 585 case .quoting(let ev): 586 drafts.quotes[ev] = artifacts 587 case .posting: 588 drafts.post = artifacts 589 } 590 } 591 592 func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? { 593 switch action { 594 case .replying_to(let ev): 595 return drafts.replies[ev] 596 case .quoting(let ev): 597 return drafts.quotes[ev] 598 case .posting: 599 return drafts.post 600 } 601 } 602 603 private func isAlphanumeric(_ char: Character) -> Bool { 604 return char.isLetter || char.isNumber 605 } 606 607 func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost { 608 post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in 609 if let link = attributes[.link] as? String { 610 let nextCharIndex = range.upperBound 611 if nextCharIndex < post.length, 612 let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first, 613 isAlphanumeric(nextChar) { 614 post.insert(NSAttributedString(string: " "), at: nextCharIndex) 615 } 616 617 let normalized_link: String 618 if link.hasPrefix("damus:nostr:") { 619 // Replace damus:nostr: URI prefix with nostr: since the former is for internal navigation and not meant to be posted. 620 normalized_link = String(link.dropFirst(6)) 621 } else { 622 normalized_link = link 623 } 624 625 // Add zero-width space in case text preceding the mention is not a whitespace. 626 // In the case where the character preceding the mention is a whitespace, the added zero-width space will be stripped out. 627 post.replaceCharacters(in: range, with: "\(normalized_link)") 628 } 629 } 630 631 632 var content = post.string 633 .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) 634 635 let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ") 636 637 var tags = uploadedMedias.compactMap { $0.metadata?.to_tag() } 638 639 if !imagesString.isEmpty { 640 content.append(" " + imagesString + " ") 641 } 642 643 if case .quoting(let ev) = action { 644 content.append(" nostr:" + bech32_note_id(ev.id)) 645 646 if let quoted_ev = state.events.lookup(ev.id) { 647 tags.append(["p", quoted_ev.pubkey.hex()]) 648 } 649 } 650 651 return NostrPost(content: content, references: references, kind: .text, tags: tags) 652 } 653