damus

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

ShareViewController.swift (17699B)


      1 //
      2 //  ShareViewController.swift
      3 //  share extension
      4 //
      5 //  Created by Swift on 11/4/24.
      6 //
      7 
      8 import SwiftUI
      9 import Social
     10 import UniformTypeIdentifiers
     11 
     12 let this_app: UIApplication = UIApplication()
     13 
     14 class ShareViewController: SLComposeServiceViewController {
     15     private var contentView: UIHostingController<ShareExtensionView>?
     16     
     17     override func viewDidLoad() {
     18         super.viewDidLoad()
     19         self.view.tintColor = UIColor(DamusColors.purple)
     20         
     21         DispatchQueue.main.async {
     22             let contentView = UIHostingController(rootView: ShareExtensionView(extensionContext: self.extensionContext!,
     23                                                                                dismissParent: { [weak self] in
     24                 self?.dismissSelf()
     25             }
     26                                                                               ))
     27             self.addChild(contentView)
     28             self.contentView = contentView
     29             self.view.addSubview(contentView.view)
     30             
     31             // set up constraints
     32             contentView.view.translatesAutoresizingMaskIntoConstraints = false
     33             contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
     34             contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
     35             contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
     36             contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
     37         }
     38     }
     39     
     40     func dismissSelf() {
     41         super.didSelectCancel()
     42     }
     43 }
     44 
     45 struct ShareExtensionView: View {
     46     @State private var share_state: ShareState = .loading
     47     let extensionContext: NSExtensionContext
     48     @State private var state: DamusState? = nil
     49     @State private var preUploadedMedia: [PreUploadedMedia] = []
     50     var dismissParent: (() -> Void)?
     51     
     52     @Environment(\.scenePhase) var scenePhase
     53     
     54     var body: some View {
     55         VStack(spacing: 15) {
     56                 switch self.share_state {
     57                 case .loading:
     58                     ProgressView()
     59                 case .no_content:
     60                     Group {
     61                         Text("No content available to share", comment: "Title indicating that there was no available content to share")
     62                             .font(.largeTitle)
     63                             .multilineTextAlignment(.center)
     64                             .padding()
     65                         Text("There is no content available to share at this time. Please close this view and try again.", comment: "Label explaining that no content is available to share and instructing the user to close the view and try again.")
     66                             .multilineTextAlignment(.center)
     67                             .padding(.horizontal)
     68                         
     69                         Button(action: {
     70                             self.done()
     71                         }, label: {
     72                             Text("Close", comment: "Button label giving the user the option to close the view when no content is available to share")
     73                         })
     74                         .foregroundStyle(.secondary)
     75                     }
     76                 case .not_logged_in:
     77                     Group {
     78                         Text("Not Logged In", comment: "Title indicating that sharing cannot proceed because the user is not logged in.")
     79                             .font(.largeTitle)
     80                             .multilineTextAlignment(.center)
     81                             .padding()
     82                         
     83                         Text("You cannot share content because you are not logged in. Please close this view, log in to your account, and try again.", comment: "Label explaining that sharing cannot proceed because the user is not logged in.")
     84                             .multilineTextAlignment(.center)
     85                             .padding(.horizontal)
     86                         
     87                         Button(action: {
     88                             self.done()
     89                         }, label: {
     90                             Text("Close", comment: "Button label giving the user the option to close the sheet due to not being logged in.")
     91                         })
     92                         .foregroundStyle(.secondary)
     93                     }
     94                 case .loaded(let content):
     95                     PostView(
     96                         action: .sharing(content),
     97                         damus_state: state!  // state will have a value at this point
     98                     )
     99                 case .cancelled:
    100                     Group {
    101                         Text("Cancelled", comment: "Title indicating that the user has cancelled.")
    102                             .font(.largeTitle)
    103                             .padding()
    104                         Button(action: {
    105                             self.done()
    106                         }, label: {
    107                             Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to share.")
    108                         })
    109                         .foregroundStyle(.secondary)
    110                     }
    111                 case .failed(let error):
    112                     Group {
    113                         Text("Error", comment: "Title indicating that an error has occurred.")
    114                             .font(.largeTitle)
    115                             .multilineTextAlignment(.center)
    116                             .padding()
    117                         Text("An unexpected error occurred. Please contact Damus support via [Nostr](damus:npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955) or [email](support@damus.io) with the error message below.", comment: "Label explaining there was an error, and suggesting next steps")
    118                             .multilineTextAlignment(.center)
    119                         Text("Error: \(error)")
    120                         Button(action: {
    121                             done()
    122                         }, label: {
    123                             Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying share.")
    124                         })
    125                         .foregroundStyle(.secondary)
    126                     }
    127                 case .posted(event: let event):
    128                     Group {
    129                         Image(systemName: "checkmark.circle.fill")
    130                             .resizable()
    131                             .frame(width: 60, height: 60)
    132                         Text("Shared", comment: "Title indicating that the user has shared content successfully")
    133                             .font(.largeTitle)
    134                             .multilineTextAlignment(.center)
    135                             .padding(.bottom)
    136                         
    137                         Link(destination: URL(string: "damus:\(event.id.bech32)")!, label: {
    138                             Text("Go to the app", comment: "Button label giving the user the option to go to the app after sharing content")
    139                         })
    140                         .buttonStyle(GradientButtonStyle())
    141                         
    142                         Button(action: {
    143                             self.done()
    144                         }, label: {
    145                             Text("Close", comment: "Button label giving the user the option to close the sheet from which they shared content")
    146                         })
    147                         .foregroundStyle(.secondary)
    148                     }
    149                 case .posting:
    150                     Group {
    151                         ProgressView()
    152                             .frame(width: 20, height: 20)
    153                         Text("Sharing", comment: "Title indicating that the content is being published to the network")
    154                             .font(.largeTitle)
    155                             .multilineTextAlignment(.center)
    156                             .padding(.bottom)
    157                         Text("Your content is being broadcasted to the network. Please wait.", comment: "Label explaining that their content sharing action is in progress")
    158                             .multilineTextAlignment(.center)
    159                             .padding()
    160                     }
    161                 }
    162         }
    163         .onAppear(perform: {
    164             if setDamusState() {
    165                 self.loadSharedContent()
    166             }
    167         })
    168         .onDisappear {
    169             Task { @MainActor in
    170                 self.state?.ndb.close()
    171             }
    172         }
    173         .onReceive(handle_notify(.post)) { post_notification in
    174             switch post_notification {
    175             case .post(let post):
    176                 self.post(post)
    177             case .cancel:
    178                 self.share_state = .cancelled
    179                 dismissParent?()
    180             }
    181         }
    182         .onChange(of: scenePhase) { (phase: ScenePhase) in
    183             guard let state else { return }
    184             switch phase {
    185             case .background:
    186                 print("txn: 📙 SHARE BACKGROUNDED")
    187                 Task { @MainActor in
    188                     state.ndb.close()
    189                 }
    190                 break
    191             case .inactive:
    192                 print("txn: 📙 SHARE INACTIVE")
    193                 break
    194             case .active:
    195                 print("txn: 📙 SHARE ACTIVE")
    196                 state.nostrNetwork.pool.ping()
    197             @unknown default:
    198                 break
    199             }
    200         }
    201         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
    202             guard let state else { return }
    203             print("SHARE ACTIVE NOTIFY")
    204             if state.ndb.reopen() {
    205                 print("SHARE NOSTRDB REOPENED")
    206             } else {
    207                 print(" SHARE NOSTRDB FAILED TO REOPEN closed: \(state.ndb.is_closed)")
    208             }
    209         }
    210         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { obj in
    211             guard let state else { return }
    212             print("txn: 📙 SHARE BACKGROUNDED")
    213             Task { @MainActor in
    214                 state.ndb.close()
    215             }
    216         }
    217     }
    218     
    219     func post(_ post: NostrPost) {
    220         self.share_state = .posting
    221         guard let state else {
    222             self.share_state = .failed(error: "Damus state not initialized")
    223             return
    224         }
    225         guard let full_keypair = state.keypair.to_full() else {
    226             self.share_state = .not_logged_in
    227             return
    228         }
    229         guard let posted_event = post.to_event(keypair: full_keypair) else {
    230             self.share_state = .failed(error: "Cannot convert post data into a nostr event")
    231             return
    232         }
    233         state.nostrNetwork.postbox.send(posted_event, on_flush: .once({ flushed_event in
    234             if flushed_event.event.id == posted_event.id {
    235                 DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {  // Offset labor perception bias
    236                     self.share_state = .posted(event: flushed_event.event)
    237                 })
    238             }
    239             else {
    240                 self.share_state = .failed(error: "Flushed event is not the event we just tried to post.")
    241             }
    242         }))
    243     }
    244     
    245     @discardableResult
    246     private func setDamusState() -> Bool {
    247         guard let keypair = get_saved_keypair(),
    248               keypair.privkey != nil else {
    249             self.share_state = .not_logged_in
    250             return false
    251         }
    252         state = DamusState(keypair: keypair)
    253         state?.nostrNetwork.connect()
    254         return true
    255     }
    256     
    257     func loadSharedContent() {
    258         guard let extensionItem = extensionContext.inputItems.first as? NSExtensionItem else {
    259             share_state = .failed(error: "Unable to get item provider")
    260             return
    261         }
    262         
    263         var title = ""
    264         
    265         // Check for the attributed text from the extension item
    266         if let attributedContentData = extensionItem.userInfo?[NSExtensionItemAttributedContentTextKey] as? Data {
    267             if let attributedText = try? NSAttributedString(data: attributedContentData, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) {
    268                 let plainText = attributedText.string
    269                 print("Extracted Text: \(plainText)")
    270                 title = plainText
    271             } else {
    272                 print("Failed to decode RTF content.")
    273             }
    274         } else {
    275             print("Content is not in RTF format or data is unavailable.")
    276         }
    277         
    278         // Iterate through all attachments to handle multiple images
    279         for itemProvider in extensionItem.attachments ?? [] {
    280             if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
    281                 itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
    282                     if let url = item as? URL {
    283                         
    284                         attemptAcquireResourceAndChooseMedia(
    285                             url: url,
    286                             fallback: processImage,
    287                             unprocessedEnum: {.unprocessed_image($0)},
    288                             processedEnum: {.processed_image($0)})
    289                         
    290                     } else if let image = item as? UIImage {
    291                         // process it directly if shared item is uiimage (example: image shared from Facebook, Signal apps)
    292                         chooseMedia(PreUploadedMedia.uiimage(image))
    293                     } else {
    294                         self.share_state = .failed(error: "Failed to load image content")
    295                     }
    296                 }
    297             } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
    298                 itemProvider.loadItem(forTypeIdentifier: UTType.movie.identifier) { (item, error) in
    299                     if let url = item as? URL {
    300                         attemptAcquireResourceAndChooseMedia(
    301                             url: url,
    302                             fallback: processVideo,
    303                             unprocessedEnum: {.unprocessed_video($0)},
    304                             processedEnum: {.processed_video($0)}
    305                         )
    306                         
    307                     } else {
    308                         self.share_state = .failed(error: "Failed to load video content")
    309                     }
    310                 }
    311             } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
    312                 itemProvider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (item, error) in
    313                     // Sharing URLs from iPhone/Safari to Damus also follows this pathway
    314                     // Sharing Photos or Links from macOS/Finder or macOS/Safari to Damus sets item-provider conforming to UTType.url.identifier and therefore takes this pathway
    315                     
    316                     if let url = item as? URL {
    317                         // Sharing Photos from macOS/Finder
    318                         if url.absoluteString.hasPrefix("file:///") {
    319                             attemptAcquireResourceAndChooseMedia(
    320                                 url: url,
    321                                 fallback: processImage,
    322                                 unprocessedEnum: {.unprocessed_image($0)},
    323                                 processedEnum: {.processed_image($0)})
    324                             
    325                         } else {
    326                             // Sharing URLs from iPhone/Safari to Damus
    327                             self.share_state = .loaded(ShareContent(title: title, content: .link(url)))
    328                         }
    329                     } else if let data = item as? Data,
    330                               let string = String(data: data, encoding: .utf8),
    331                               let url = URL(string: string)  {
    332                             // Sharing Links from macOS/Safari, does not provide title
    333                             self.share_state = .loaded(ShareContent(title: "", content: .link(url)))
    334                     } else {
    335                         self.share_state = .failed(error: "Failed to load text content")
    336                     }
    337                 }
    338             } else {
    339                 share_state = .no_content
    340             }
    341         }
    342         
    343         func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) {
    344             if url.startAccessingSecurityScopedResource() {
    345                 // Have permission from system to use url out of scope
    346                 print("Acquired permission to security scoped resource")
    347                 chooseMedia(unprocessedEnum(url))
    348             } else {
    349                 // Need to copy URL to non-security scoped location
    350                 guard let newUrl = fallback(url) else { return }
    351                 chooseMedia(processedEnum(newUrl))
    352             }
    353         }
    354         
    355         func chooseMedia(_ media: PreUploadedMedia) {
    356             self.preUploadedMedia.append(media)
    357             if extensionItem.attachments?.count == preUploadedMedia.count {
    358                 self.share_state = .loaded(ShareContent(title: "", content: .media(preUploadedMedia)))
    359             }
    360         }
    361     }
    362     
    363     private func done() {
    364         extensionContext.completeRequest(returningItems: [], completionHandler: nil)
    365     }
    366     
    367     private enum ShareState {
    368         case loading
    369         case no_content
    370         case not_logged_in
    371         case loaded(ShareContent)
    372         case failed(error: String)
    373         case cancelled
    374         case posting
    375         case posted(event: NostrEvent)
    376     }
    377 }
    378