PostView.swift (36402B)
1 // 2 // Post.swift 3 // damus 4 // 5 // Created by William Casarin on 2022-04-03. 6 // 7 8 import SwiftUI 9 import AVKit 10 import Kingfisher 11 12 enum NostrPostResult { 13 case post(NostrPost) 14 case cancel 15 } 16 17 let POST_PLACEHOLDER = NSLocalizedString("Type your note here...", comment: "Text box prompt to ask user to type their note.") 18 let GHOST_CARET_VIEW_ID = "GhostCaret" 19 let DEBUG_SHOW_GHOST_CARET_VIEW: Bool = false 20 21 class TagModel: ObservableObject { 22 var diff = 0 23 } 24 25 enum PostTarget { 26 case none 27 case user(Pubkey) 28 } 29 30 enum PostAction { 31 case replying_to(NostrEvent) 32 case quoting(NostrEvent) 33 case posting(PostTarget) 34 case highlighting(HighlightContentDraft) 35 case sharing(ShareContent) 36 37 var ev: NostrEvent? { 38 switch self { 39 case .replying_to(let ev): 40 return ev 41 case .quoting(let ev): 42 return ev 43 case .posting: 44 return nil 45 case .highlighting: 46 return nil 47 case .sharing(_): 48 return nil 49 } 50 } 51 } 52 53 struct PostView: View { 54 55 @State var post: NSMutableAttributedString = NSMutableAttributedString() 56 @State var uploadedMedias: [UploadedMedia] = [] 57 @State var references: [RefId] = [] 58 /// Pubkeys that should be filtered out from the references 59 /// 60 /// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove. 61 @State var filtered_pubkeys: Set<Pubkey> = [] 62 63 @FocusState var focus: Bool 64 @State var attach_media: Bool = false 65 @State var attach_camera: Bool = false 66 @State var error: String? = nil 67 @State var image_upload_confirm: Bool = false 68 @State var imagePastedFromPasteboard: PreUploadedMedia? = nil 69 @State var imageUploadConfirmPasteboard: Bool = false 70 @State var imageUploadConfirmDamusShare: Bool = false 71 @State var focusWordAttributes: (String?, NSRange?) = (nil, nil) 72 @State var newCursorIndex: Int? 73 @State var textHeight: CGFloat? = nil 74 /// Manages the auto-save logic for drafts. 75 /// 76 /// ## Implementation notes 77 /// 78 /// - This intentionally does _not_ use `@ObservedObject` or `@StateObject` because observing changes causes unwanted automatic scrolling to the text cursor on each save state update. 79 var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel 80 81 @State var preUploadedMedia: [PreUploadedMedia] = [] 82 83 @StateObject var image_upload: ImageUploadModel = ImageUploadModel() 84 @StateObject var tagModel: TagModel = TagModel() 85 86 @State private var current_placeholder_index = 0 87 @State private var uploadTasks: [Task<Void, Never>] = [] 88 89 let action: PostAction 90 let damus_state: DamusState 91 let prompt_view: (() -> AnyView)? 92 let placeholder_messages: [String] 93 let initial_text_suffix: String? 94 95 init( 96 action: PostAction, 97 damus_state: DamusState, 98 prompt_view: (() -> AnyView)? = nil, 99 placeholder_messages: [String]? = nil, 100 initial_text_suffix: String? = nil 101 ) { 102 self.action = action 103 self.damus_state = damus_state 104 self.prompt_view = prompt_view 105 self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER] 106 self.initial_text_suffix = initial_text_suffix 107 self.autoSaveModel = AutoSaveIndicatorView.AutoSaveViewModel(save: { damus_state.drafts.save(damus_state: damus_state) }) 108 } 109 110 @Environment(\.dismiss) var dismiss 111 112 func cancel() { 113 notify(.post(.cancel)) 114 cancelUploadTasks() 115 dismiss() 116 } 117 118 func cancelUploadTasks() { 119 uploadTasks.forEach { $0.cancel() } 120 uploadTasks.removeAll() 121 } 122 123 func send_post() { 124 let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys) 125 126 notify(.post(.post(new_post))) 127 128 clear_draft() 129 130 dismiss() 131 132 } 133 134 var is_post_empty: Bool { 135 return post.string.allSatisfy { $0.isWhitespace } && uploadedMedias.isEmpty 136 } 137 138 var uploading_disabled: Bool { 139 return image_upload.progress != nil 140 } 141 142 var posting_disabled: Bool { 143 switch action { 144 case .highlighting(_): 145 return false 146 default: 147 return is_post_empty || uploading_disabled 148 } 149 } 150 151 // Returns a valid height for the text box, even when textHeight is not a number 152 func get_valid_text_height() -> CGFloat { 153 if let textHeight, textHeight.isFinite, textHeight > 0 { 154 return textHeight 155 } 156 else { 157 return 10 158 } 159 } 160 161 var ImageButton: some View { 162 Button(action: { 163 preUploadedMedia.removeAll() 164 attach_media = true 165 }, label: { 166 Image("images") 167 .padding(6) 168 }) 169 } 170 171 var CameraButton: some View { 172 Button(action: { 173 attach_camera = true 174 }, label: { 175 Image("camera") 176 .padding(6) 177 }) 178 } 179 180 var AttachmentBar: some View { 181 HStack(alignment: .center, spacing: 15) { 182 ImageButton 183 CameraButton 184 Spacer() 185 AutoSaveIndicatorView(saveViewModel: self.autoSaveModel) 186 } 187 .disabled(uploading_disabled) 188 } 189 190 var PostButton: some View { 191 Button(NSLocalizedString("Post", comment: "Button to post a note.")) { 192 self.send_post() 193 } 194 .disabled(posting_disabled) 195 .opacity(posting_disabled ? 0.5 : 1.0) 196 .bold() 197 .buttonStyle(GradientButtonStyle(padding: 10)) 198 199 } 200 201 func isEmpty() -> Bool { 202 return self.uploadedMedias.count == 0 && 203 self.post.mutableString.trimmingCharacters(in: .whitespacesAndNewlines) == 204 initialString().mutableString.trimmingCharacters(in: .whitespacesAndNewlines) 205 } 206 207 func initialString() -> NSMutableAttributedString { 208 guard case .posting(let target) = action, 209 case .user(let pubkey) = target, 210 damus_state.pubkey != pubkey else { 211 return .init(string: "") 212 } 213 214 let profile_txn = damus_state.profiles.lookup(id: pubkey) 215 let profile = profile_txn?.unsafeUnownedValue 216 return user_tag_attr_string(profile: profile, pubkey: pubkey) 217 } 218 219 func clear_draft() { 220 switch action { 221 case .replying_to(let replying_to): 222 damus_state.drafts.replies.removeValue(forKey: replying_to.id) 223 case .quoting(let quoting): 224 damus_state.drafts.quotes.removeValue(forKey: quoting.id) 225 case .posting: 226 damus_state.drafts.post = nil 227 case .highlighting(let draft): 228 damus_state.drafts.highlights.removeValue(forKey: draft) 229 case .sharing(_): 230 damus_state.drafts.post = nil 231 } 232 233 damus_state.drafts.save(damus_state: damus_state) 234 } 235 236 func load_draft() -> Bool { 237 guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else { 238 self.post = NSMutableAttributedString("") 239 self.uploadedMedias = [] 240 self.autoSaveModel.markNothingToSave() // We should not save empty drafts. 241 return false 242 } 243 244 self.uploadedMedias = draft.media 245 self.post = draft.content 246 self.autoSaveModel.markSaved() // The draft we just loaded is saved to memory. Mark it as such. 247 return true 248 } 249 250 /// Use this to signal that the post contents have changed. This will do two things: 251 /// 252 /// 1. Save the new contents into our in-memory drafts 253 /// 2. Signal that we need to save drafts persistently, which will happen after a certain wait period 254 func post_changed(post: NSMutableAttributedString, media: [UploadedMedia]) { 255 if let draft = load_draft_for_post(drafts: damus_state.drafts, action: action) { 256 draft.content = post 257 draft.media = uploadedMedias 258 draft.references = references 259 draft.filtered_pubkeys = filtered_pubkeys 260 } else { 261 let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString) 262 set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts) 263 } 264 self.autoSaveModel.needsSaving() 265 } 266 267 var TextEntry: some View { 268 ZStack(alignment: .topLeading) { 269 TextViewWrapper( 270 attributedText: $post, 271 textHeight: $textHeight, 272 initialTextSuffix: initial_text_suffix, 273 imagePastedFromPasteboard: $imagePastedFromPasteboard, 274 imageUploadConfirmPasteboard: $imageUploadConfirmPasteboard, 275 cursorIndex: newCursorIndex, 276 getFocusWordForMention: { word, range in 277 focusWordAttributes = (word, range) 278 self.newCursorIndex = nil 279 }, 280 updateCursorPosition: { newCursorIndex in 281 self.newCursorIndex = newCursorIndex 282 } 283 ) 284 .environmentObject(tagModel) 285 .focused($focus) 286 .textInputAutocapitalization(.sentences) 287 .onChange(of: post) { p in 288 post_changed(post: p, media: uploadedMedias) 289 } 290 // Set a height based on the text content height, if it is available and valid 291 .frame(height: get_valid_text_height()) 292 293 if post.string.isEmpty { 294 Text(self.placeholder_messages[self.current_placeholder_index]) 295 .padding(.top, 8) 296 .padding(.leading, 4) 297 .foregroundColor(Color(uiColor: .placeholderText)) 298 .allowsHitTesting(false) 299 } 300 } 301 .onAppear { 302 // Schedule a timer to switch messages every 3 seconds 303 Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { timer in 304 withAnimation { 305 self.current_placeholder_index = (self.current_placeholder_index + 1) % self.placeholder_messages.count 306 } 307 } 308 } 309 } 310 311 var TopBar: some View { 312 VStack { 313 HStack(spacing: 5.0) { 314 Button(action: { 315 self.cancel() 316 }, label: { 317 Text("Cancel", comment: "Button to cancel out of posting a note.") 318 .padding(10) 319 }) 320 .buttonStyle(NeutralButtonStyle()) 321 .accessibilityIdentifier(AppAccessibilityIdentifiers.post_composer_cancel_button.rawValue) 322 323 if let error { 324 Text(error) 325 .foregroundColor(.red) 326 } 327 328 Spacer() 329 330 PostButton 331 } 332 333 if let progress = image_upload.progress { 334 ProgressView(value: progress, total: 1.0) 335 .progressViewStyle(.linear) 336 } 337 338 Divider() 339 .foregroundColor(DamusColors.neutral3) 340 .padding(.top, 5) 341 } 342 .frame(height: 30) 343 .padding() 344 .padding(.top, 15) 345 } 346 347 @discardableResult 348 func handle_upload(media: MediaUpload) async -> Bool { 349 let uploader = damus_state.settings.default_media_uploader 350 351 let img = getImage(media: media) 352 print("img size w:\(img.size.width) h:\(img.size.height)") 353 354 async let blurhash = calculate_blurhash(img: img) 355 let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair) 356 357 switch res { 358 case .success(let url): 359 guard let url = URL(string: url) else { 360 self.error = "Error uploading image :(" 361 return false 362 } 363 let blurhash = await blurhash 364 let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) } 365 let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, metadata: meta) 366 uploadedMedias.append(uploadedMedia) 367 return true 368 369 case .failed(let error): 370 if let error { 371 self.error = error.localizedDescription 372 } else { 373 self.error = "Error uploading image :(" 374 } 375 return false 376 } 377 } 378 379 var multiply_factor: CGFloat { 380 if case .quoting = action { 381 return 0.4 382 } else if !uploadedMedias.isEmpty { 383 return 0.2 384 } else { 385 return 1.0 386 } 387 } 388 389 func Editor(deviceSize: GeometryProxy) -> some View { 390 HStack(alignment: .top, spacing: 0) { 391 VStack(alignment: .leading, spacing: 0) { 392 HStack(alignment: .top) { 393 ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) 394 395 VStack(alignment: .leading) { 396 if let prompt_view { 397 prompt_view() 398 } 399 TextEntry 400 } 401 } 402 .id("post") 403 404 PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width) 405 .onChange(of: uploadedMedias) { media in 406 post_changed(post: post, media: media) 407 } 408 409 if case .quoting(let ev) = action { 410 BuilderEventView(damus: damus_state, event: ev) 411 } 412 else if case .highlighting(let draft) = action { 413 HighlightDraftContentView(draft: draft) 414 } 415 else if case .sharing(let draft) = action, 416 let url = draft.getLinkURL() { 417 LinkViewRepresentable(meta: .url(url)) 418 .frame(height: 50) 419 } 420 } 421 .padding(.horizontal) 422 } 423 } 424 425 func fill_target_content(target: PostTarget) { 426 self.post = initialString() 427 self.tagModel.diff = post.string.count 428 } 429 430 var pubkeys: [Pubkey] { 431 self.references.reduce(into: [Pubkey]()) { pks, ref in 432 guard case .pubkey(let pk) = ref else { 433 return 434 } 435 436 pks.append(pk) 437 } 438 } 439 440 var body: some View { 441 GeometryReader { (deviceSize: GeometryProxy) in 442 VStack(alignment: .leading, spacing: 0) { 443 let searching = get_searching_string(focusWordAttributes.0) 444 let searchingHashTag = get_searching_hashTag(focusWordAttributes.0) 445 TopBar 446 447 ScrollViewReader { scroller in 448 ScrollView { 449 VStack(alignment: .leading) { 450 if case .replying_to(let replying_to) = self.action { 451 ReplyView(replying_to: replying_to, damus: damus_state, original_pubkeys: pubkeys, filtered_pubkeys: $filtered_pubkeys) 452 } 453 454 Editor(deviceSize: deviceSize) 455 .padding(.top, 5) 456 } 457 } 458 .frame(maxHeight: searching == nil && searchingHashTag == nil ? deviceSize.size.height : 70) 459 .onAppear { 460 scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top) 461 } 462 } 463 464 // This if-block observes @ for tagging 465 if let searching { 466 UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post) 467 .frame(maxHeight: .infinity) 468 .environmentObject(tagModel) 469 // This else observes '#' for hash-tag suggestions and creates SuggestedHashtagsView 470 } else if let searchingHashTag { 471 SuggestedHashtagsView(damus_state: damus_state, 472 events: SearchHomeModel(damus_state: damus_state).events, 473 isFromPostView: true, 474 queryHashTag: searchingHashTag, 475 focusWordAttributes: $focusWordAttributes, 476 newCursorIndex: $newCursorIndex, 477 post: $post) 478 .environmentObject(tagModel) 479 } else { 480 Divider() 481 VStack(alignment: .leading) { 482 AttachmentBar 483 .padding(.vertical, 5) 484 .padding(.horizontal) 485 } 486 } 487 } 488 .background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)) 489 .sheet(isPresented: $attach_media) { 490 MediaPicker(mediaPickerEntry: .postView, onMediaSelected: { image_upload_confirm = true }) { media in 491 self.preUploadedMedia.append(media) 492 } 493 .alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $image_upload_confirm) { 494 Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) { 495 // initiate asynchronous uploading Task for multiple-images 496 let task = Task { 497 for media in preUploadedMedia { 498 if let mediaToUpload = generateMediaUpload(media) { 499 await self.handle_upload(media: mediaToUpload) 500 } 501 } 502 } 503 uploadTasks.append(task) 504 self.attach_media = false 505 } 506 Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) { 507 preUploadedMedia.removeAll() 508 } 509 } 510 } 511 .sheet(isPresented: $attach_camera) { 512 CameraController(uploader: damus_state.settings.default_media_uploader, mode: .save_to_library(when_done: { 513 self.attach_camera = false 514 self.attach_media = true 515 })) 516 } 517 // This alert seeks confirmation about Image-upload when user taps Paste option 518 .alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) { 519 Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) { 520 if let image = imagePastedFromPasteboard, 521 let mediaToUpload = generateMediaUpload(image) { 522 let task = Task { 523 _ = await self.handle_upload(media: mediaToUpload) 524 } 525 uploadTasks.append(task) 526 } 527 } 528 Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {} 529 } 530 // This alert seeks confirmation about media-upload from Damus Share Extension 531 .alert(NSLocalizedString("Are you sure you want to upload the selected media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmDamusShare) { 532 Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) { 533 let task = Task { 534 for media in preUploadedMedia { 535 if let mediaToUpload = generateMediaUpload(media) { 536 await self.handle_upload(media: mediaToUpload) 537 } 538 } 539 } 540 uploadTasks.append(task) 541 } 542 Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {} 543 } 544 .onAppear() { 545 let loaded_draft = load_draft() 546 547 switch action { 548 case .replying_to(let replying_to): 549 references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to) 550 case .quoting(let quoting): 551 references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting) 552 case .posting(let target): 553 guard !loaded_draft else { break } 554 fill_target_content(target: target) 555 case .highlighting(let draft): 556 references = [draft.source.ref()] 557 case .sharing(let content): 558 if let url = content.getLinkURL() { 559 self.post = NSMutableAttributedString(string: "\(content.title)\n\(String(url.absoluteString))") 560 } else { 561 self.preUploadedMedia = content.getMediaArray() 562 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 563 self.imageUploadConfirmDamusShare = true // display Confirm Sheet after 1 sec 564 } 565 } 566 } 567 568 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 569 self.focus = true 570 } 571 } 572 .onDisappear { 573 if isEmpty() { 574 clear_draft() 575 } 576 preUploadedMedia.removeAll() 577 } 578 } 579 } 580 } 581 582 func get_searching_string(_ word: String?) -> String? { 583 guard let word = word else { 584 return nil 585 } 586 587 guard word.count >= 2 else { 588 return nil 589 } 590 591 guard let firstCharacter = word.first, 592 firstCharacter == "@" else { 593 return nil 594 } 595 596 // don't include @npub... strings 597 guard word.count != 64 else { 598 return nil 599 } 600 601 return String(word.dropFirst()) 602 } 603 604 fileprivate func get_searching_hashTag(_ word: String?) -> String? { 605 guard let word, 606 word.count >= 2, 607 let first_char = word.first, 608 first_char == "#" else { 609 return nil 610 } 611 612 return String(word.dropFirst()) 613 } 614 615 struct PostView_Previews: PreviewProvider { 616 static var previews: some View { 617 PostView(action: .posting(.none), damus_state: test_damus_state) 618 } 619 } 620 621 struct PVImageCarouselView: View { 622 @Binding var media: [UploadedMedia] 623 624 let deviceWidth: CGFloat 625 626 var body: some View { 627 ScrollView(.horizontal, showsIndicators: false) { 628 HStack { 629 ForEach(media.indices, id: \.self) { index in 630 ZStack(alignment: .topLeading) { 631 if isSupportedVideo(url: media[index].uploadedURL) { 632 VideoPlayer(player: configurePlayer(with: media[index].localURL)) 633 .frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250) 634 .cornerRadius(10) 635 .padding() 636 .contextMenu { contextMenuContent(for: media[index]) } 637 } else { 638 KFAnimatedImage(media[index].uploadedURL) 639 .imageContext(.note, disable_animation: false) 640 .configure { view in 641 view.framePreloadCount = 3 642 } 643 .frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250) 644 .cornerRadius(10) 645 .padding() 646 .contextMenu { contextMenuContent(for: media[index]) } 647 } 648 649 VStack { // Set spacing to 0 to remove the gap between items 650 Image("close-circle") 651 .foregroundColor(.white) 652 .padding(20) 653 .shadow(radius: 5) 654 .onTapGesture { 655 media.remove(at: index) // Direct removal using index 656 } 657 658 if isSupportedVideo(url: media[index].uploadedURL) { 659 Spacer() 660 Image(systemName: "video") 661 .foregroundColor(.white) 662 .padding(10) 663 .shadow(radius: 5) 664 .opacity(0.6) 665 } 666 } 667 .padding(.bottom, 35) 668 } 669 } 670 } 671 .padding() 672 } 673 } 674 675 // Helper Function for Context Menu 676 @ViewBuilder 677 private func contextMenuContent(for mediaItem: UploadedMedia) -> some View { 678 Button(action: { 679 UIPasteboard.general.string = mediaItem.uploadedURL.absoluteString 680 }) { 681 Label( 682 NSLocalizedString("Copy URL", comment: "Copy URL of the selected uploaded media asset."), 683 systemImage: "doc.on.doc" 684 ) 685 } 686 } 687 688 private func configurePlayer(with url: URL) -> AVPlayer { 689 let player = AVPlayer(url: url) 690 player.allowsExternalPlayback = false 691 player.usesExternalPlaybackWhileExternalScreenIsActive = false 692 return player 693 } 694 } 695 696 fileprivate func getImage(media: MediaUpload) -> UIImage { 697 var uiimage: UIImage = UIImage() 698 if media.is_image { 699 // fetch the image data 700 if let data = try? Data(contentsOf: media.localURL) { 701 uiimage = UIImage(data: data) ?? UIImage() 702 } 703 } else { 704 let asset = AVURLAsset(url: media.localURL) 705 let generator = AVAssetImageGenerator(asset: asset) 706 generator.appliesPreferredTrackTransform = true 707 let time = CMTimeMake(value: 1, timescale: 60) // get the thumbnail image at the 1st second 708 do { 709 let cgImage = try generator.copyCGImage(at: time, actualTime: nil) 710 uiimage = UIImage(cgImage: cgImage) 711 } catch { 712 print("No thumbnail: \(error)") 713 } 714 // create a play icon on the top to differentiate if media upload is image or a video, gif is an image 715 let playIcon = UIImage(systemName: "play.fill")?.withTintColor(.white, renderingMode: .alwaysOriginal) 716 let size = uiimage.size 717 let scale = UIScreen.main.scale 718 UIGraphicsBeginImageContextWithOptions(size, false, scale) 719 uiimage.draw(at: .zero) 720 let playIconSize = CGSize(width: 60, height: 60) 721 let playIconOrigin = CGPoint(x: (size.width - playIconSize.width) / 2, y: (size.height - playIconSize.height) / 2) 722 playIcon?.draw(in: CGRect(origin: playIconOrigin, size: playIconSize)) 723 let newImage = UIGraphicsGetImageFromCurrentImageContext() 724 UIGraphicsEndImageContext() 725 uiimage = newImage ?? UIImage() 726 } 727 return uiimage 728 } 729 730 struct UploadedMedia: Equatable { 731 let localURL: URL 732 let uploadedURL: URL 733 let metadata: ImageMetadata? 734 } 735 736 737 func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) { 738 switch action { 739 case .replying_to(let ev): 740 drafts.replies[ev.id] = artifacts 741 case .quoting(let ev): 742 drafts.quotes[ev.id] = artifacts 743 case .posting: 744 drafts.post = artifacts 745 case .highlighting(let draft): 746 drafts.highlights[draft] = artifacts 747 case .sharing(_): 748 drafts.post = artifacts 749 } 750 } 751 752 func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? { 753 switch action { 754 case .replying_to(let ev): 755 return drafts.replies[ev.id] 756 case .quoting(let ev): 757 return drafts.quotes[ev.id] 758 case .posting: 759 return drafts.post 760 case .highlighting(let highlight): 761 if let exact_match = drafts.highlights[highlight] { 762 return exact_match // Always prefer to return the draft for that exact same highlight 763 } 764 // If there are no exact matches to the highlight, try to load a draft for the same highlight source 765 // We do this to improve UX, because we don't want to leave the post view blank if they only selected a slightly different piece of text from before. 766 var other_matches = drafts.highlights 767 .filter { $0.key.source == highlight.source } 768 // It's not an exact match, so there is no way of telling which one is the preferred draft. So just load the first one we found. 769 return other_matches.first?.value 770 case .sharing(_): 771 return drafts.post 772 } 773 } 774 775 private func isAlphanumeric(_ char: Character) -> Bool { 776 return char.isLetter || char.isNumber 777 } 778 779 func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] { 780 guard let nip10 = replying_to.thread_reply() else { 781 // we're replying to a post that isn't in a thread, 782 // just add a single reply-to-root tag 783 return [["e", replying_to.id.hex(), "", "root"]] 784 } 785 786 // otherwise use the root tag from the parent's nip10 reply and include the note 787 // that we are replying to's note id. 788 let tags = [ 789 ["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"], 790 ["e", replying_to.id.hex(), "", "reply"] 791 ] 792 793 return tags 794 } 795 796 func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> NostrPost { 797 return build_post( 798 state: state, 799 post: draft.content, 800 action: action, 801 uploadedMedias: draft.media, 802 references: draft.references, 803 filtered_pubkeys: draft.filtered_pubkeys 804 ) 805 } 806 807 func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set<Pubkey>) -> NostrPost { 808 // don't add duplicate pubkeys but retain order 809 var pkset = Set<Pubkey>() 810 811 // we only want pubkeys really 812 let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in 813 guard case .pubkey(let pk) = ref else { 814 return 815 } 816 817 if pkset.contains(pk) || filtered_pubkeys.contains(pk) { 818 return 819 } 820 821 pkset.insert(pk) 822 acc.append(pk) 823 } 824 825 return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks) 826 } 827 828 /// This builds a Nostr post from draft data from `PostView` or other draft-related classes 829 /// 830 /// ## Implementation notes 831 /// 832 /// - This function _likely_ causes no side-effects, and _should not_ cause side-effects to any of the inputs. 833 /// 834 /// - Parameters: 835 /// - state: The damus state, needed to fetch more Nostr data to form this event 836 /// - post: The text content from `PostView`. 837 /// - action: The intended action of the post (highlighting? replying?) 838 /// - uploadedMedias: The medias attached to this post 839 /// - pubkeys: The referenced pubkeys 840 /// - Returns: A NostrPost, which can then be signed into an event. 841 func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost { 842 let post = NSMutableAttributedString(attributedString: post) 843 post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in 844 if let link = attributes[.link] as? String { 845 let nextCharIndex = range.upperBound 846 if nextCharIndex < post.length, 847 let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first, 848 isAlphanumeric(nextChar) { 849 post.insert(NSAttributedString(string: " "), at: nextCharIndex) 850 } 851 852 let normalized_link: String 853 if link.hasPrefix("damus:nostr:") { 854 // Replace damus:nostr: URI prefix with nostr: since the former is for internal navigation and not meant to be posted. 855 normalized_link = String(link.dropFirst(6)) 856 } else { 857 normalized_link = link 858 } 859 860 // Add zero-width space in case text preceding the mention is not a whitespace. 861 // In the case where the character preceding the mention is a whitespace, the added zero-width space will be stripped out. 862 post.replaceCharacters(in: range, with: "\(normalized_link)") 863 } 864 } 865 866 867 var content = post.string 868 .trimmingCharacters(in: .whitespacesAndNewlines) 869 870 let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: "\n") 871 872 if !imagesString.isEmpty { 873 content.append("\n\n" + imagesString) 874 } 875 876 var tags: [[String]] = [] 877 878 switch action { 879 case .replying_to(let replying_to): 880 // start off with the reply tags 881 tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair) 882 883 case .quoting(let ev): 884 content.append("\n\nnostr:" + bech32_note_id(ev.id)) 885 886 if let quoted_ev = state.events.lookup(ev.id) { 887 tags.append(["p", quoted_ev.pubkey.hex()]) 888 } 889 case .posting, .highlighting, .sharing: 890 break 891 } 892 893 // append additional tags 894 tags += uploadedMedias.compactMap { $0.metadata?.to_tag() } 895 896 switch action { 897 case .highlighting(let draft): 898 tags.append(contentsOf: draft.source.tags()) 899 if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) { 900 tags.append(["comment", content]) 901 } 902 tags += pubkeys.map { pk in 903 ["p", pk.hex(), "mention"] 904 } 905 return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags) 906 default: 907 tags += pubkeys.map { pk in 908 ["p", pk.hex()] 909 } 910 } 911 912 return NostrPost(content: content.trimmingCharacters(in: .whitespacesAndNewlines), kind: .text, tags: tags) 913 } 914 915 func isSupportedVideo(url: URL?) -> Bool { 916 guard let url = url else { return false } 917 let fileExtension = url.pathExtension.lowercased() 918 let supportedUTIs = AVURLAsset.audiovisualTypes().map { $0.rawValue } 919 return supportedUTIs.contains { utiString in 920 if let utType = UTType(utiString), let fileUTType = UTType(filenameExtension: fileExtension) { 921 return fileUTType.conforms(to: utType) 922 } 923 return false 924 } 925 } 926 927 func isSupportedImage(url: URL) -> Bool { 928 let fileExtension = url.pathExtension.lowercased() 929 // It would be better to pull this programmatically from Apple's APIs, but there seems to be no such call 930 let supportedTypes = ["jpg", "png", "gif"] 931 return supportedTypes.contains(fileExtension) 932 } 933