Add Damus Share Feature

This PR change adds Damus Share feature to the app that allows the users to share Photos and URLs from foreign apps.

Changelog-Added: Add Damus Share Feature

Signed-off-by: Swift Coder  <scoder1747@gmail.com>
This commit is contained in:
Swift
2024-11-15 20:08:44 -05:00
committed by GitHub
parent 50ef6600a8
commit eeb6547d3e
6 changed files with 1597 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -213,3 +213,27 @@ enum HighlightSource: Hashable {
}
}
}
struct ShareContent {
let title: String
let content: ContentType
enum ContentType {
case link(URL)
case media([PreUploadedMedia])
}
func getLinkURL() -> URL? {
if case let .link(url) = content {
return url
}
return nil
}
func getMediaArray() -> [PreUploadedMedia] {
if case let .media(mediaArray) = content {
return mediaArray
}
return []
}
}

View File

@@ -31,6 +31,7 @@ enum PostAction {
case quoting(NostrEvent)
case posting(PostTarget)
case highlighting(HighlightContentDraft)
case sharing(ShareContent)
var ev: NostrEvent? {
switch self {
@@ -42,6 +43,8 @@ enum PostAction {
return nil
case .highlighting:
return nil
case .sharing(_):
return nil
}
}
}
@@ -57,6 +60,7 @@ struct PostView: View {
@State var imagePastedFromPasteboard: UIImage? = nil
@State var imageUploadConfirmPasteboard: Bool = false
@State var references: [RefId] = []
@State var imageUploadConfirmDamusShare: Bool = false
@State var filtered_pubkeys: Set<Pubkey> = []
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
@State var newCursorIndex: Int?
@@ -217,6 +221,8 @@ struct PostView: View {
damus_state.drafts.post = nil
case .highlighting(let draft):
damus_state.drafts.highlights.removeValue(forKey: draft.source)
case .sharing(_):
damus_state.drafts.post = nil
}
}
@@ -391,6 +397,11 @@ struct PostView: View {
else if case .highlighting(let draft) = action {
HighlightDraftContentView(draft: draft)
}
else if case .sharing(let draft) = action,
let url = draft.getLinkURL() {
LinkViewRepresentable(meta: .url(url))
.frame(height: 50)
}
}
.padding(.horizontal)
}
@@ -499,6 +510,19 @@ struct PostView: View {
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
// This alert seeks confirmation about media-upload from Damus Share Extension
.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) {
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
Task {
for media in preUploadedMedia {
if let mediaToUpload = generateMediaUpload(media) {
await self.handle_upload(media: mediaToUpload)
}
}
}
}
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
}
.onAppear() {
let loaded_draft = load_draft()
@@ -512,6 +536,15 @@ struct PostView: View {
fill_target_content(target: target)
case .highlighting(let draft):
references = [draft.source.ref()]
case .sharing(let content):
if let url = content.getLinkURL() {
self.post = NSMutableAttributedString(string: "\(content.title)\n\(String(url.absoluteString))")
} else {
self.preUploadedMedia = content.getMediaArray()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.imageUploadConfirmDamusShare = true // display Confirm Sheet after 1 sec
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@@ -661,6 +694,8 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
drafts.post = artifacts
case .highlighting(let draft):
drafts.highlights[draft.source] = artifacts
case .sharing(_):
drafts.post = artifacts
}
}
@@ -674,6 +709,8 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
return drafts.post
case .highlighting(let draft):
return drafts.highlights[draft.source]
case .sharing(_):
return drafts.post
}
}
@@ -749,6 +786,8 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
break
case .highlighting(let draft):
break
case .sharing(_):
break
}
// append additional tags

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
<key>NSExtensionActivationSupportsImage</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,356 @@
//
// ShareViewController.swift
// share extension
//
// Created by Swift on 11/4/24.
//
import SwiftUI
import Social
import UniformTypeIdentifiers
let this_app: UIApplication = UIApplication()
class ShareViewController: SLComposeServiceViewController {
private var contentView: UIHostingController<ShareExtensionView>?
override func viewDidLoad() {
super.viewDidLoad()
self.view.tintColor = UIColor(DamusColors.purple)
DispatchQueue.main.async {
let contentView = UIHostingController(rootView: ShareExtensionView(extensionContext: self.extensionContext!,
dismissParent: { [weak self] in
self?.dismissSelf()
}
))
self.addChild(contentView)
self.contentView = contentView
self.view.addSubview(contentView.view)
// set up constraints
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
}
}
func dismissSelf() {
super.didSelectCancel()
}
}
struct ShareExtensionView: View {
@State private var share_state: ShareState = .loading
let extensionContext: NSExtensionContext
@State private var state: DamusState? = nil
@State private var preUploadedMedia: [PreUploadedMedia] = []
var dismissParent: (() -> Void)?
@Environment(\.scenePhase) var scenePhase
var body: some View {
VStack(spacing: 15) {
switch self.share_state {
case .loading:
ProgressView()
case .no_content:
Group {
Text("No content availabe to share", comment: "Title indicating that there was no available content to share")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
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.")
.multilineTextAlignment(.center)
.padding(.horizontal)
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the view when no content is available to share")
})
.foregroundStyle(.secondary)
}
case .not_logged_in:
Group {
Text("Not Logged In", comment: "Title indicating that sharing cannot proceed because the user is not logged in.")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
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.")
.multilineTextAlignment(.center)
.padding(.horizontal)
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet due to not being logged in.")
})
.foregroundStyle(.secondary)
}
case .loaded(let content):
PostView(
action: .sharing(content),
damus_state: state! // state will have a value at this point
)
case .cancelled:
Group {
Text("Cancelled", comment: "Title indicating that the user has cancelled.")
.font(.largeTitle)
.padding()
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to share.")
})
.foregroundStyle(.secondary)
}
case .failed(let error):
Group {
Text("Error", comment: "Title indicating that an error has occurred.")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
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")
.multilineTextAlignment(.center)
Text("Error: \(error)")
Button(action: {
done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying share.")
})
.foregroundStyle(.secondary)
}
case .posted(event: let event):
Group {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 60, height: 60)
Text("Shared", comment: "Title indicating that the user has shared content successfully")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding(.bottom)
Link(destination: URL(string: "damus:\(event.id.bech32)")!, label: {
Text("Go to the app", comment: "Button label giving the user the option to go to the app after sharing content")
})
.buttonStyle(GradientButtonStyle())
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they shared content")
})
.foregroundStyle(.secondary)
}
case .posting:
Group {
ProgressView()
.frame(width: 20, height: 20)
Text("Sharing", comment: "Title indicating that the content is being published to the network")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding(.bottom)
Text("Your content is being broadcasted to the network. Please wait.", comment: "Label explaining that their content sharing action is in progress")
.multilineTextAlignment(.center)
.padding()
}
}
}
.onAppear(perform: {
if setDamusState() {
self.loadSharedContent()
}
})
.onDisappear {
Task { @MainActor in
self.state?.ndb.close()
}
}
.onReceive(handle_notify(.post)) { post_notification in
switch post_notification {
case .post(let post):
self.post(post)
case .cancel:
self.share_state = .cancelled
dismissParent?()
}
}
.onChange(of: scenePhase) { (phase: ScenePhase) in
guard let state else { return }
switch phase {
case .background:
print("txn: 📙 SHARE BACKGROUNDED")
Task { @MainActor in
state.ndb.close()
}
break
case .inactive:
print("txn: 📙 SHARE INACTIVE")
break
case .active:
print("txn: 📙 SHARE ACTIVE")
state.pool.ping()
@unknown default:
break
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
guard let state else { return }
print("SHARE ACTIVE NOTIFY")
if state.ndb.reopen() {
print("SHARE NOSTRDB REOPENED")
} else {
print(" SHARE NOSTRDB FAILED TO REOPEN closed: \(state.ndb.is_closed)")
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { obj in
guard let state else { return }
print("txn: 📙 SHARE BACKGROUNDED")
Task { @MainActor in
state.ndb.close()
}
}
}
func post(_ post: NostrPost) {
self.share_state = .posting
guard let state else {
self.share_state = .failed(error: "Damus state not initialized")
return
}
guard let full_keypair = state.keypair.to_full() else {
self.share_state = .not_logged_in
return
}
guard let posted_event = post.to_event(keypair: full_keypair) else {
self.share_state = .failed(error: "Cannot convert post data into a nostr event")
return
}
state.postbox.send(posted_event, on_flush: .once({ flushed_event in
if flushed_event.event.id == posted_event.id {
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { // Offset labor perception bias
self.share_state = .posted(event: flushed_event.event)
})
}
else {
self.share_state = .failed(error: "Flushed event is not the event we just tried to post.")
}
}))
}
@discardableResult
private func setDamusState() -> Bool {
guard let keypair = get_saved_keypair(),
keypair.privkey != nil else {
self.share_state = .not_logged_in
return false
}
state = DamusState(keypair: keypair)
return true
}
func loadSharedContent() {
guard let extensionItem = extensionContext.inputItems.first as? NSExtensionItem else {
share_state = .failed(error: "Unable to get item provider")
return
}
var title = ""
// Check for the attributed text from the extension item
if let attributedContentData = extensionItem.userInfo?[NSExtensionItemAttributedContentTextKey] as? Data {
if let attributedText = try? NSAttributedString(data: attributedContentData, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) {
let plainText = attributedText.string
print("Extracted Text: \(plainText)")
title = plainText
} else {
print("Failed to decode RTF content.")
}
} else {
print("Content is not in RTF format or data is unavailable.")
}
// Iterate through all attachments to handle multiple images
for itemProvider in extensionItem.attachments ?? [] {
if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
itemProvider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (item, error) in
if let url = item as? URL {
self.share_state = .loaded(ShareContent(title: title, content: .link(url)))
} else {
self.share_state = .failed(error: "Failed to load text content")
}
}
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
if let url = item as? URL {
attemptAcquireResourceAndChooseMedia(
url: url,
fallback: processImage,
unprocessedEnum: {.unprocessed_image($0)},
processedEnum: {.processed_image($0)})
} else {
self.share_state = .failed(error: "Failed to load image content")
}
}
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
itemProvider.loadItem(forTypeIdentifier: UTType.movie.identifier) { (item, error) in
if let url = item as? URL {
attemptAcquireResourceAndChooseMedia(
url: url,
fallback: processVideo,
unprocessedEnum: {.unprocessed_video($0)},
processedEnum: {.processed_video($0)}
)
} else {
self.share_state = .failed(error: "Failed to load video content")
}
}
} else {
share_state = .no_content
}
}
func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) {
if url.startAccessingSecurityScopedResource() {
// Have permission from system to use url out of scope
print("Acquired permission to security scoped resource")
chooseMedia(unprocessedEnum(url))
} else {
// Need to copy URL to non-security scoped location
guard let newUrl = fallback(url) else { return }
chooseMedia(processedEnum(newUrl))
}
}
func chooseMedia(_ media: PreUploadedMedia) {
self.preUploadedMedia.append(media)
if extensionItem.attachments?.count == preUploadedMedia.count {
self.share_state = .loaded(ShareContent(title: "", content: .media(preUploadedMedia)))
}
}
}
private func done() {
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
}
private enum ShareState {
case loading
case no_content
case not_logged_in
case loaded(ShareContent)
case failed(error: String)
case cancelled
case posting
case posted(event: NostrEvent)
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damus</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
</array>
</dict>
</plist>