damus

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

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