damus

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

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