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:
File diff suppressed because it is too large
Load Diff
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
22
share extension/Info.plist
Normal file
22
share extension/Info.plist
Normal 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>
|
||||
356
share extension/ShareViewController.swift
Normal file
356
share extension/ShareViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
18
share extension/share extension.entitlements
Normal file
18
share extension/share extension.entitlements
Normal 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>
|
||||
Reference in New Issue
Block a user